You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by mi...@apache.org on 2026/10/20 18:35:41 UTC

(superset) branch master updated: fix(soft-delete): show Recently Archived in the menu to everyone who can open it (#45523)

This is an automated email from the ASF dual-hosted git repository.

aminghadersohi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 488a3c53b45 fix(soft-delete): show Recently Archived in the menu to everyone who can open it (#45523)
488a3c53b45 is described below

commit 488a3c53b45fba895e16e952b9e42ed5f9cac325
Author: Mike Bridge <mi...@preset.io>
AuthorDate: Tue Oct 20 12:35:34 2026 -0600

    fix(soft-delete): show Recently Archived in the menu to everyone who can open it (#45523)
---
 docs/docs/using-superset/recently-archived.mdx     |  4 +
 .../src/features/home/RightMenu.test.tsx           | 76 +++++++++++++++++++
 superset-frontend/src/features/home/RightMenu.tsx  | 16 ++++
 superset-frontend/src/types/bootstrapTypes.ts      |  1 +
 superset/views/archived_assets.py                  | 18 ++++-
 superset/views/base.py                             |  4 +
 tests/unit_tests/views/test_archived_assets.py     | 86 ++++++++++++++++++++++
 7 files changed, 201 insertions(+), 4 deletions(-)

diff --git a/docs/docs/using-superset/recently-archived.mdx b/docs/docs/using-superset/recently-archived.mdx
index 49a9864387f..b2f9f968363 100644
--- a/docs/docs/using-superset/recently-archived.mdx
+++ b/docs/docs/using-superset/recently-archived.mdx
@@ -32,6 +32,10 @@ page and its menu entry are hidden, and deletes are permanent as before.
 
 ## Finding archived objects
 
+Find **Recently Archived** under **Settings → Manage**. If that entry is not
+available in Manage and you can access the page, it appears under
+**Settings → User** instead.
+
 Open **Recently Archived** and pick a type — **Chart**, **Dashboard**, or
 **Dataset** (shown as **Datasource** when semantic layers are enabled) — from
 the Type selector. The view shows one type at a time; each
diff --git a/superset-frontend/src/features/home/RightMenu.test.tsx b/superset-frontend/src/features/home/RightMenu.test.tsx
index d76db6fab91..d74b0605376 100644
--- a/superset-frontend/src/features/home/RightMenu.test.tsx
+++ b/superset-frontend/src/features/home/RightMenu.test.tsx
@@ -620,3 +620,79 @@ describe('mobile consumption mode', () => {
     expect(await screen.findByRole('dialog')).toBeInTheDocument();
   });
 });
+
+test.each([
+  ['no Manage', [], true, 1],
+  ['no admission', [], false, 0],
+  ['missing bootstrap flag', [], undefined, 0],
+  [
+    'Manage without archive',
+    [{ name: 'Manage', label: 'Manage', childs: [] }],
+    true,
+    1,
+  ],
+  [
+    'existing translated archive',
+    [
+      {
+        name: 'Manage',
+        label: 'Gestion',
+        childs: [
+          {
+            name: 'Recently Archived',
+            label: 'Archives récentes',
+            url: '/archived/',
+          },
+        ],
+      },
+    ],
+    true,
+    1,
+  ],
+])('archive fallback: %s', async (_name, settings, admitted, count) => {
+  resetUseSelectorMock();
+  const props = createProps();
+  props.settings = settings;
+  props.navbarRight = {
+    ...props.navbarRight,
+    can_access_recently_archived: admitted,
+  };
+  render(<RightMenu {...props} />, {
+    useRedux: true,
+    useQueryParams: true,
+    useRouter: true,
+    useTheme: true,
+  });
+  await userEvent.hover(await screen.findByText('Settings'));
+  await screen.findByText('Info');
+  const archiveLinks = screen
+    .queryAllByRole('link')
+    .filter(link => link.getAttribute('href') === '/archived/');
+  expect(archiveLinks).toHaveLength(count);
+});
+
+test('archive fallback respects the application root', async () => {
+  const rootSpy = jest
+    .spyOn(getBootstrapData, 'applicationRoot')
+    .mockReturnValue('/analytics');
+  try {
+    resetUseSelectorMock();
+    const props = createProps();
+    props.navbarRight = {
+      ...props.navbarRight,
+      can_access_recently_archived: true,
+    };
+    render(<RightMenu {...props} />, {
+      useRedux: true,
+      useQueryParams: true,
+      useRouter: true,
+      useTheme: true,
+    });
+    await userEvent.hover(await screen.findByText('Settings'));
+    expect(
+      await screen.findByRole('link', { name: 'Recently Archived' }),
+    ).toHaveAttribute('href', '/analytics/archived/');
+  } finally {
+    rootSpy.mockRestore();
+  }
+});
diff --git a/superset-frontend/src/features/home/RightMenu.tsx b/superset-frontend/src/features/home/RightMenu.tsx
index 6e95c03971e..7820a4630f7 100644
--- a/superset-frontend/src/features/home/RightMenu.tsx
+++ b/superset-frontend/src/features/home/RightMenu.tsx
@@ -525,6 +525,22 @@ const RightMenu = ({
         items.push({ type: 'divider', key: 'user-divider' });
 
         const userItems: MenuItem[] = [];
+        const hasArchiveEntry = settings?.some(section =>
+          section.childs?.some(
+            child =>
+              typeof child !== 'string' && child.name === 'Recently Archived',
+          ),
+        );
+        if (navbarRight.can_access_recently_archived && !hasArchiveEntry) {
+          userItems.push({
+            key: 'recently-archived',
+            label: (
+              <Typography.Link href={ensureAppRoot('/archived/')}>
+                {t('Recently Archived')}
+              </Typography.Link>
+            ),
+          });
+        }
         if (navbarRight.user_info_url) {
           userItems.push({
             key: 'info',
diff --git a/superset-frontend/src/types/bootstrapTypes.ts b/superset-frontend/src/types/bootstrapTypes.ts
index cdd563264d9..e036648d5de 100644
--- a/superset-frontend/src/types/bootstrapTypes.ts
+++ b/superset-frontend/src/types/bootstrapTypes.ts
@@ -104,6 +104,7 @@ export interface BrandProps {
 }
 
 export interface NavBarProps {
+  can_access_recently_archived?: boolean;
   show_watermark: boolean;
   bug_report_url?: string;
   bug_report_text?: string;
diff --git a/superset/views/archived_assets.py b/superset/views/archived_assets.py
index 8487ac6658e..51d50f1f847 100644
--- a/superset/views/archived_assets.py
+++ b/superset/views/archived_assets.py
@@ -33,6 +33,19 @@ logger = logging.getLogger(__name__)
 _ADMITTING_RESOURCES = ("Chart", "Dashboard", "Dataset")
 
 
+def can_access_recently_archived() -> bool:
+    """Whether the current viewer can open the archived-assets page."""
+    return bool(
+        is_feature_enabled("SOFT_DELETE")
+        and getattr(g, "user", None)
+        and not g.user.is_anonymous
+        and any(
+            security_manager.can_access("can_read", resource)
+            for resource in _ADMITTING_RESOURCES
+        )
+    )
+
+
 class ArchivedAssetsView(BaseSupersetView):
     """Serves the SPA shell for the Archive (Recently-Archived) view.
 
@@ -58,9 +71,6 @@ class ArchivedAssetsView(BaseSupersetView):
             abort(404)
         if not g.user or g.user.is_anonymous:
             return redirect_to_login()
-        if not any(
-            security_manager.can_access("can_read", resource)
-            for resource in _ADMITTING_RESOURCES
-        ):
+        if not can_access_recently_archived():
             abort(403)
         return super().render_app_template()
diff --git a/superset/views/base.py b/superset/views/base.py
index 71ca927fa31..1de41c6c553 100644
--- a/superset/views/base.py
+++ b/superset/views/base.py
@@ -294,6 +294,9 @@ def get_environment_tag() -> dict[str, Any]:
 
 
 def menu_data(user: User) -> dict[str, Any]:
+    # The archived view inherits BaseSupersetView from this module.
+    from superset.views.archived_assets import can_access_recently_archived
+
     languages = {
         lang: {**appbuilder.languages[lang], "url": appbuilder.get_url_for_locale(lang)}
         for lang in appbuilder.languages
@@ -325,6 +328,7 @@ def menu_data(user: User) -> dict[str, Any]:
         },
         "environment_tag": get_environment_tag(),
         "navbar_right": {
+            "can_access_recently_archived": can_access_recently_archived(),
             # show the watermark if the default app icon has been overridden
             "show_watermark": ("superset-logo-horiz" not in appbuilder.app_icon),
             "bug_report_url": app.config["BUG_REPORT_URL"],
diff --git a/tests/unit_tests/views/test_archived_assets.py b/tests/unit_tests/views/test_archived_assets.py
new file mode 100644
index 00000000000..2f87309b6f7
--- /dev/null
+++ b/tests/unit_tests/views/test_archived_assets.py
@@ -0,0 +1,86 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Archive page and menu fallback must share the same admission rule."""
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+from flask import g, Response
+from werkzeug.exceptions import HTTPException
+
+from superset.app import SupersetApp
+from superset.superset_typing import FlaskResponse
+
+
+@pytest.mark.parametrize("resource", ["Chart", "Dashboard", "Dataset", None])
+@pytest.mark.parametrize("enabled", [True, False])
+@pytest.mark.parametrize("anonymous", [True, False])
+def test_archive_bootstrap_matches_page(
+    app: SupersetApp, resource: str | None, enabled: bool, anonymous: bool
+) -> None:
+    """Readers see a fallback hint only when the page would admit them."""
+    from superset.views import archived_assets, base
+
+    user: MagicMock = MagicMock(is_anonymous=anonymous)
+    expected: bool = enabled and not anonymous and resource is not None
+    manager: MagicMock
+    builder: MagicMock
+    base_manager: MagicMock
+    status: int
+    response: FlaskResponse
+
+    def can_read(permission: str, name: str) -> bool:
+        """Model a reader of exactly one archived type without menu grants."""
+        return permission == "can_read" and name == resource
+
+    with (
+        app.test_request_context("/archived/"),
+        patch.object(archived_assets, "is_feature_enabled", return_value=enabled),
+        patch.object(
+            archived_assets, "security_manager", new_callable=MagicMock
+        ) as manager,
+        patch.object(base, "appbuilder") as builder,
+        patch.object(base, "security_manager", new_callable=MagicMock) as base_manager,
+        patch.object(base, "get_environment_tag", return_value={}),
+        patch.object(
+            archived_assets.BaseSupersetView,
+            "render_app_template",
+            return_value="shell",
+        ),
+        patch.object(
+            archived_assets, "redirect_to_login", return_value=Response(status=302)
+        ),
+    ):
+        g.user = user
+        manager.can_access.side_effect = can_read
+        base_manager.is_admin.return_value = False
+        builder.languages = {}
+        builder.menu.get_data.return_value = []
+        builder.app_icon = "superset-logo-horiz"
+        assert archived_assets.can_access_recently_archived() is expected
+        try:
+            response = archived_assets.ArchivedAssetsView().list()
+            status = response.status_code if isinstance(response, Response) else 200
+        except HTTPException as error:
+            status = error.code or 500
+        assert status == (
+            404 if not enabled else 302 if anonymous else 200 if expected else 403
+        )
+        assert (
+            base.menu_data(user)["navbar_right"]["can_access_recently_archived"]
+            is expected
+        )