From 97c8f5436caf67af1f1e0fe789cf749f3f16df1f Mon Sep 17 00:00:00 2001
From: Mike Bridge
Date: Tue, 20 Oct 2026 16:22:32 -0600
Subject: [PATCH 01/12] fix(semantic): preserve archived dependencies and
clarify delete dialogs
---
.../docs/using-superset/recently-archived.mdx | 2 +
docs/static/resources/openapi.json | 18 ++-
.../DatabaseList.semanticLayerDelete.test.tsx | 12 +-
.../src/pages/DatabaseList/index.tsx | 11 +-
.../DatasetList/DatasetList.behavior.test.tsx | 6 +-
.../DatasetList.integration.test.tsx | 2 +-
.../src/pages/DatasetList/index.tsx | 26 ++--
superset/commands/semantic_layer/delete.py | 78 ++++++----
.../commands/semantic_layer/exceptions.py | 35 +++--
superset/semantic_layers/api.py | 30 +++-
superset/translations/messages.pot | 74 +++++++--
.../commands/semantic_layer/delete_test.py | 141 ++++++++++++++----
tests/unit_tests/semantic_layers/api_test.py | 10 +-
13 files changed, 329 insertions(+), 116 deletions(-)
diff --git a/docs/docs/using-superset/recently-archived.mdx b/docs/docs/using-superset/recently-archived.mdx
index b2f9f9683635..c217d2febe66 100644
--- a/docs/docs/using-superset/recently-archived.mdx
+++ b/docs/docs/using-superset/recently-archived.mdx
@@ -392,3 +392,5 @@ exit status 0 alone as proof of erasure.
The purge removes the entity together with its
[version history](./version-history.mdx). The retention window for version history
itself is configured separately and is described on that page.
+
+Archived charts and dashboards still block deletion of semantic views and layers they depend on. The refusal includes the number of archived dependents. Use **Recently Archived** to restore and repoint them, or permanently delete them, before deleting the semantic source.
diff --git a/docs/static/resources/openapi.json b/docs/static/resources/openapi.json
index f1bedd1da143..1be46e8d1146 100644
--- a/docs/static/resources/openapi.json
+++ b/docs/static/resources/openapi.json
@@ -34263,6 +34263,9 @@
"application/json": {
"schema": {
"properties": {
+ "archived_count": {
+ "type": "integer"
+ },
"dependents": {
"items": {
"properties": {
@@ -34306,7 +34309,8 @@
"message",
"total",
"dependents",
- "inaccessible_count"
+ "inaccessible_count",
+ "archived_count"
],
"type": "object"
}
@@ -34648,6 +34652,9 @@
"application/json": {
"schema": {
"properties": {
+ "archived_count": {
+ "type": "integer"
+ },
"dependents": {
"items": {
"properties": {
@@ -34691,7 +34698,8 @@
"message",
"total",
"dependents",
- "inaccessible_count"
+ "inaccessible_count",
+ "archived_count"
],
"type": "object"
}
@@ -34810,6 +34818,9 @@
"application/json": {
"schema": {
"properties": {
+ "archived_count": {
+ "type": "integer"
+ },
"dependents": {
"items": {
"properties": {
@@ -34853,7 +34864,8 @@
"message",
"total",
"dependents",
- "inaccessible_count"
+ "inaccessible_count",
+ "archived_count"
],
"type": "object"
}
diff --git a/superset-frontend/src/pages/DatabaseList/DatabaseList.semanticLayerDelete.test.tsx b/superset-frontend/src/pages/DatabaseList/DatabaseList.semanticLayerDelete.test.tsx
index cf4afdffa157..57d61b402ff6 100644
--- a/superset-frontend/src/pages/DatabaseList/DatabaseList.semanticLayerDelete.test.tsx
+++ b/superset-frontend/src/pages/DatabaseList/DatabaseList.semanticLayerDelete.test.tsx
@@ -205,7 +205,7 @@ test('delete confirmation warns about cascade-deleting dependent views by count
expect(
within(dialog).getByText(
- 'This will also permanently delete its 3 semantic views. Charts built on those views will stop working.',
+ 'This will also permanently delete its 3 semantic views. This cannot be undone.',
),
).toBeInTheDocument();
expect(
@@ -232,7 +232,7 @@ test('a single dependent view is announced in the singular', async () => {
expect(
within(dialog).getByText(
- 'This will also permanently delete its 1 semantic view. Charts built on that view will stop working.',
+ 'This will also permanently delete its 1 semantic view. This cannot be undone.',
),
).toBeInTheDocument();
});
@@ -275,7 +275,7 @@ test('a counted response with an empty name page keeps the count but omits the l
expect(
within(dialog).getByText(
- 'This will also permanently delete its 3 semantic views. Charts built on those views will stop working.',
+ 'This will also permanently delete its 3 semantic views. This cannot be undone.',
),
).toBeInTheDocument();
expect(
@@ -291,7 +291,7 @@ test('a failed dependent lookup still opens the modal with an uncounted warning'
expect(
within(dialog).getByText(
- 'Deleting this semantic layer also permanently deletes any semantic views it contains, and charts built on those views will stop working. The affected views could not be listed.',
+ 'Deleting this semantic layer also permanently deletes any semantic views it contains. This cannot be undone. The affected views could not be listed.',
),
).toBeInTheDocument();
});
@@ -316,7 +316,7 @@ test('the overflow footer reports dependent views beyond the listed page', async
expect(
within(dialog).getByText(
- 'This will also permanently delete its 12 semantic views. Charts built on those views will stop working.',
+ 'This will also permanently delete its 12 semantic views. This cannot be undone.',
),
).toBeInTheDocument();
expect(within(dialog).getByText('view_10')).toBeInTheDocument();
@@ -443,7 +443,7 @@ test("a stale lookup resolving late cannot replace a newer row's modal", async (
test('layer delete displays the counted dependency conflict', async () => {
const reason =
- 'Semantic source is used by 25 dependent assets and cannot be deleted.';
+ 'Semantic source is used by 25 dependent assets and cannot be deleted. 3 dependent assets are archived. Go to Recently Archived to restore and repoint archived assets, or delete them permanently.';
setupMocks({ dependents: [], deleteError: reason });
const addDangerToast = jest.fn();
renderDatabaseList(mockUser, addDangerToast);
diff --git a/superset-frontend/src/pages/DatabaseList/index.tsx b/superset-frontend/src/pages/DatabaseList/index.tsx
index c0fdb53dcdd7..591cdb3537c8 100644
--- a/superset-frontend/src/pages/DatabaseList/index.tsx
+++ b/superset-frontend/src/pages/DatabaseList/index.tsx
@@ -137,7 +137,7 @@ function SemanticLayerCascadeWarning({
return (
{t(
- 'Deleting this semantic layer also permanently deletes any semantic views it contains, and charts built on those views will stop working. The affected views could not be listed.',
+ 'Deleting this semantic layer also permanently deletes any semantic views it contains. This cannot be undone. The affected views could not be listed.',
)}
);
@@ -158,8 +158,8 @@ function SemanticLayerCascadeWarning({
<>
{tn(
- 'This will also permanently delete its %s semantic view. Charts built on that view will stop working.',
- 'This will also permanently delete its %s semantic views. Charts built on those views will stop working.',
+ 'This will also permanently delete its %s semantic view. This cannot be undone.',
+ 'This will also permanently delete its %s semantic views. This cannot be undone.',
preview.dependentViewCount,
preview.dependentViewCount,
)}
@@ -1181,6 +1181,11 @@ function DatabaseList({
{slDeletePreview.item.database_name}?
+
+ {t(
+ 'Deletion is blocked while any assets depend on these views, including archived assets. Remove or repoint them first. Use Recently Archived to restore and repoint archived assets, or delete them permanently.',
+ )}
+
>
}
onConfirm={() => {
diff --git a/superset-frontend/src/pages/DatasetList/DatasetList.behavior.test.tsx b/superset-frontend/src/pages/DatasetList/DatasetList.behavior.test.tsx
index 097e8c02102b..cf38acb1b6b2 100644
--- a/superset-frontend/src/pages/DatasetList/DatasetList.behavior.test.tsx
+++ b/superset-frontend/src/pages/DatasetList/DatasetList.behavior.test.tsx
@@ -81,7 +81,7 @@ test('single semantic view delete displays the counted dependency conflict', asy
kind: 'semantic_view',
};
const reason =
- 'Semantic source is used by 25 dependent assets and cannot be deleted.';
+ 'Semantic source is used by 25 dependent assets and cannot be deleted. 3 dependent assets are archived. Go to Recently Archived to restore and repoint archived assets, or delete them permanently.';
const addDangerToast = jest.fn();
mockDatasetListEndpoints({ result: [view], count: 1 });
fetchMock.delete('glob:*/api/v1/semantic_view/99', {
@@ -95,6 +95,8 @@ test('single semantic view delete displays the counted dependency conflict', asy
await screen.findByText(view.table_name);
await userEvent.click(screen.getByTestId('dataset-row-delete'));
const modal = await screen.findByRole('dialog');
+ expect(modal).toHaveTextContent('This cannot be undone.');
+ expect(modal).toHaveTextContent('including archived assets');
await userEvent.type(
within(modal).getByTestId('delete-modal-input'),
'DELETE',
@@ -117,7 +119,7 @@ test('mixed bulk delete names refused semantic views and their conflict reason',
};
const addDangerToast = jest.fn();
const reason =
- 'Semantic source is used by 25 dependent assets and cannot be deleted.';
+ 'Semantic source is used by 25 dependent assets and cannot be deleted. 3 dependent assets are archived. Go to Recently Archived to restore and repoint archived assets, or delete them permanently.';
mockDatasetListEndpoints({ result: [dataset, semanticView], count: 2 });
fetchMock.get(API_ENDPOINTS.DATASET_BULK_RELATED_OBJECTS, {
charts: { count: 0, result: [] },
diff --git a/superset-frontend/src/pages/DatasetList/DatasetList.integration.test.tsx b/superset-frontend/src/pages/DatasetList/DatasetList.integration.test.tsx
index fa620ccabd5c..0c73dade7b75 100644
--- a/superset-frontend/src/pages/DatasetList/DatasetList.integration.test.tsx
+++ b/superset-frontend/src/pages/DatasetList/DatasetList.integration.test.tsx
@@ -394,7 +394,7 @@ test('bulk delete confirm never claims a semantic view has no dependents', async
expect(
await within(modal).findByText(
- /charts built on the selected semantic view/i,
+ /deletion is blocked while any assets depend on the selected semantic views/i,
),
).toBeInTheDocument();
expect(modal).not.toHaveTextContent(/no charts or dashboards depend on/i);
diff --git a/superset-frontend/src/pages/DatasetList/index.tsx b/superset-frontend/src/pages/DatasetList/index.tsx
index 43b82ec3fca6..398ed8678602 100644
--- a/superset-frontend/src/pages/DatasetList/index.tsx
+++ b/superset-frontend/src/pages/DatasetList/index.tsx
@@ -1592,10 +1592,21 @@ const DatasetList: FunctionComponent = ({
)}
{svCurrentlyDeleting && (
+
+ {t(
+ 'Are you sure you want to delete %s? This cannot be undone.',
+ svCurrentlyDeleting.table_name,
+ )}
+
+
+ {t(
+ 'Deletion is blocked while any assets depend on this view, including archived assets. Remove or repoint them first. Use Recently Archived to restore and repoint archived assets, or delete them permanently.',
+ )}
+
+ >
+ }
onConfirm={handleSemanticViewDeleteConfirm}
onHide={() => setSvCurrentlyDeleting(null)}
open
@@ -1718,11 +1729,8 @@ const DatasetList: FunctionComponent = ({
))}
{pendingBulkSemanticCount > 0 && (
- {tn(
- 'Charts built on the selected semantic view are not checked and will break if it is deleted.',
- 'Charts built on the %s selected semantic views are not checked and will break if they are deleted.',
- pendingBulkSemanticCount,
- pendingBulkSemanticCount,
+ {t(
+ 'Deletion is blocked while any assets depend on the selected semantic views, including archived assets. Remove or repoint them first. Use Recently Archived to restore and repoint archived assets, or delete them permanently.',
)}
)}
diff --git a/superset/commands/semantic_layer/delete.py b/superset/commands/semantic_layer/delete.py
index 1416819c6727..2f4ea894cc65 100644
--- a/superset/commands/semantic_layer/delete.py
+++ b/superset/commands/semantic_layer/delete.py
@@ -97,13 +97,14 @@ def _dashboard_targets_view(
def _dashboard_target_ids(view_ids: sa.Select) -> set[int]:
- """Find live dashboards with semantic targets after a coarse SQL prefilter."""
+ """Find dashboards with semantic targets after a coarse SQL prefilter."""
selected_ids: set[int] = set(db.session.execute(view_ids).scalars())
if not selected_ids:
return set()
- candidates: sa.Select = sa.select(Dashboard.id, Dashboard.json_metadata).where(
- Dashboard.deleted_at.is_(None),
- Dashboard.json_metadata.like("%semantic_view%"),
+ candidates: sa.Select = sa.select(
+ Dashboard.__table__.c.id, Dashboard.__table__.c.json_metadata
+ ).where(
+ Dashboard.__table__.c.json_metadata.like("%semantic_view%"),
)
matched_ids: set[int] = set()
for dashboard_id, metadata in db.session.execute(
@@ -116,47 +117,52 @@ def _dashboard_target_ids(view_ids: sa.Select) -> set[int]:
def _dependent_assets(
view_ids: sa.Select,
-) -> tuple[int, list[dict[str, str | int]], int]:
+) -> tuple[int, list[dict[str, str | int]], int, int]:
"""Count dependents, naming only assets visible through their list APIs."""
- chart_ids: sa.Select = sa.select(Slice.id).where(
- Slice.datasource_type == "semantic_view",
- Slice.datasource_id.in_(view_ids),
- Slice.deleted_at.is_(None),
+ chart_ids: sa.Select = sa.select(Slice.__table__.c.id).where(
+ Slice.__table__.c.datasource_type == "semantic_view",
+ Slice.__table__.c.datasource_id.in_(view_ids),
)
chart_dashboard_ids: sa.Select = (
sa.select(dashboard_slices.c.dashboard_id)
- .join(Slice, dashboard_slices.c.slice_id == Slice.id)
- .join(Dashboard, dashboard_slices.c.dashboard_id == Dashboard.id)
+ .join(Slice.__table__, dashboard_slices.c.slice_id == Slice.__table__.c.id)
+ .join(
+ Dashboard.__table__,
+ dashboard_slices.c.dashboard_id == Dashboard.__table__.c.id,
+ )
.where(
- Slice.id.in_(chart_ids),
- Dashboard.deleted_at.is_(None),
+ Slice.__table__.c.id.in_(chart_ids),
)
.distinct()
)
dashboard_target_ids: set[int] = _dashboard_target_ids(view_ids)
- dashboard_ids: sa.Select = sa.select(Dashboard.id).where(
- Dashboard.deleted_at.is_(None),
+ dashboard_ids: sa.Select = sa.select(Dashboard.__table__.c.id).where(
sa.or_(
- Dashboard.id.in_(chart_dashboard_ids),
- Dashboard.id.in_(dashboard_target_ids),
+ Dashboard.__table__.c.id.in_(chart_dashboard_ids),
+ Dashboard.__table__.c.id.in_(dashboard_target_ids),
),
)
dependents: sa.Subquery = sa.union_all(
sa.select(
sa.literal("chart").label("type"),
- Slice.id.label("id"),
- sa.func.coalesce(Slice.slice_name, str(_("Untitled"))).label("name"),
- ).where(Slice.id.in_(chart_ids)),
- sa.select(
- sa.literal("dashboard").label("type"),
- Dashboard.id.label("id"),
- sa.func.coalesce(Dashboard.dashboard_title, str(_("Untitled"))).label(
+ Slice.__table__.c.id.label("id"),
+ Slice.__table__.c.deleted_at.is_not(None).label("archived"),
+ sa.func.coalesce(Slice.__table__.c.slice_name, str(_("Untitled"))).label(
"name"
),
- ).where(Dashboard.id.in_(dashboard_ids)),
+ ).where(Slice.__table__.c.id.in_(chart_ids)),
+ sa.select(
+ sa.literal("dashboard").label("type"),
+ Dashboard.__table__.c.id.label("id"),
+ Dashboard.__table__.c.deleted_at.is_not(None).label("archived"),
+ sa.func.coalesce(
+ Dashboard.__table__.c.dashboard_title, str(_("Untitled"))
+ ).label("name"),
+ ).where(Dashboard.__table__.c.id.in_(dashboard_ids)),
sa.select(
sa.func.lower(ReportSchedule.type).label("type"),
ReportSchedule.id.label("id"),
+ sa.literal(False).label("archived"),
ReportSchedule.name.label("name"),
).where(
ReportSchedule.active.is_(True),
@@ -170,7 +176,7 @@ def _dependent_assets(
cast(
sa.Select,
ChartFilter("id", SQLAInterface(Slice, db.session))
- .apply(db.session.query(Slice.id), None)
+ .apply(db.session.query(Slice.id).filter(Slice.deleted_at.is_(None)), None)
.statement,
)
if security_manager.can_access("can_read", "Chart")
@@ -180,7 +186,10 @@ def _dependent_assets(
cast(
sa.Select,
DashboardAccessFilter("id", SQLAInterface(Dashboard, db.session))
- .apply(db.session.query(Dashboard.id), None)
+ .apply(
+ db.session.query(Dashboard.id).filter(Dashboard.deleted_at.is_(None)),
+ None,
+ )
.statement,
)
if security_manager.can_access("can_read", "Dashboard")
@@ -211,18 +220,19 @@ def _dependent_assets(
),
)
# Both counts come from one statement so READ COMMITTED cannot mix snapshots.
- counts: tuple[int, int | None] = cast(
- tuple[int, int | None],
+ counts: tuple[int, int | None, int | None] = cast(
+ tuple[int, int | None, int | None],
db.session.execute(
sa.select(
sa.func.count(),
sa.func.sum(sa.case((visibility, 1), else_=0)),
+ sa.func.sum(sa.case((dependents.c.archived, 1), else_=0)),
).select_from(dependents)
).one(),
)
total: int = int(counts[0])
if not total:
- return 0, [], 0
+ return 0, [], 0, 0
visible_total: int = int(counts[1] or 0)
visible: sa.Subquery = sa.select(dependents).where(visibility).subquery()
rows: list[tuple[str, int, str]] = cast(
@@ -248,6 +258,7 @@ def _dependent_assets(
for asset_type, asset_id, name in rows
],
total - visible_total,
+ int(counts[2] or 0),
)
@@ -256,9 +267,12 @@ def _raise_for_dependents(view_ids: sa.Select) -> None:
total: int
dependents: list[dict[str, str | int]]
inaccessible_count: int
- total, dependents, inaccessible_count = _dependent_assets(view_ids)
+ archived_count: int
+ total, dependents, inaccessible_count, archived_count = _dependent_assets(view_ids)
if total:
- raise SemanticDeleteDependentsError(total, dependents, inaccessible_count)
+ raise SemanticDeleteDependentsError(
+ total, dependents, inaccessible_count, archived_count
+ )
class DeleteSemanticLayerCommand(BaseCommand):
diff --git a/superset/commands/semantic_layer/exceptions.py b/superset/commands/semantic_layer/exceptions.py
index 95f329ddc02a..40d0bd3939f5 100644
--- a/superset/commands/semantic_layer/exceptions.py
+++ b/superset/commands/semantic_layer/exceptions.py
@@ -77,7 +77,7 @@ class SemanticViewDeleteFailedError(DeleteFailedError):
class SemanticDeleteDependentsError(CommandException):
- """A semantic view or layer still has live dependent assets."""
+ """A semantic view or layer still has live or archived dependent assets."""
status: int = 409
@@ -86,17 +86,32 @@ def __init__(
total: int,
dependents: list[dict[str, str | int]],
inaccessible_count: int = 0,
+ archived_count: int = 0,
) -> None:
+ self.archived_count: int = archived_count
self.total: int = total
self.dependents: list[dict[str, str | int]] = dependents
self.inaccessible_count: int = inaccessible_count
- super().__init__(
- ngettext(
- "Semantic source is used by %(count)s dependent asset "
- "and cannot be deleted.",
- "Semantic source is used by %(count)s dependent assets "
- "and cannot be deleted.",
- total,
- count=total,
- )
+ message: str = ngettext(
+ "Semantic source is used by %(count)s dependent asset "
+ "and cannot be deleted.",
+ "Semantic source is used by %(count)s dependent assets "
+ "and cannot be deleted.",
+ total,
+ count=total,
+ )
+ message += " " + _(
+ "Remove or repoint these assets before deleting the semantic source."
)
+ if archived_count:
+ message += " " + ngettext(
+ "%(count)s dependent asset is archived.",
+ "%(count)s dependent assets are archived.",
+ archived_count,
+ count=archived_count,
+ )
+ message += " " + _(
+ "Go to Recently Archived to restore and repoint archived assets, "
+ "or delete them permanently."
+ )
+ super().__init__(message)
diff --git a/superset/semantic_layers/api.py b/superset/semantic_layers/api.py
index 791cf3289636..1559fd9e86ba 100644
--- a/superset/semantic_layers/api.py
+++ b/superset/semantic_layers/api.py
@@ -519,7 +519,12 @@ def delete(self, pk: int) -> Response:
application/json:
schema:
type: object
- required: [message, total, dependents, inaccessible_count]
+ required:
+ - message
+ - total
+ - dependents
+ - inaccessible_count
+ - archived_count
properties:
message:
type: string
@@ -541,6 +546,8 @@ def delete(self, pk: int) -> Response:
type: string
inaccessible_count:
type: integer
+ archived_count:
+ type: integer
422:
$ref: '#/components/responses/422'
"""
@@ -561,6 +568,7 @@ def delete(self, pk: int) -> Response:
total=ex.total,
dependents=ex.dependents,
inaccessible_count=ex.inaccessible_count,
+ archived_count=ex.archived_count,
)
except SemanticViewDeleteFailedError as ex:
logger.error(
@@ -612,7 +620,12 @@ def bulk_delete(self, **kwargs: Any) -> Response:
application/json:
schema:
type: object
- required: [message, total, dependents, inaccessible_count]
+ required:
+ - message
+ - total
+ - dependents
+ - inaccessible_count
+ - archived_count
properties:
message:
type: string
@@ -634,6 +647,8 @@ def bulk_delete(self, **kwargs: Any) -> Response:
type: string
inaccessible_count:
type: integer
+ archived_count:
+ type: integer
422:
$ref: '#/components/responses/422'
"""
@@ -662,6 +677,7 @@ def bulk_delete(self, **kwargs: Any) -> Response:
total=ex.total,
dependents=ex.dependents,
inaccessible_count=ex.inaccessible_count,
+ archived_count=ex.archived_count,
)
except SemanticViewDeleteFailedError as ex:
logger.error(
@@ -1135,7 +1151,12 @@ def delete(self, uuid: str) -> FlaskResponse:
application/json:
schema:
type: object
- required: [message, total, dependents, inaccessible_count]
+ required:
+ - message
+ - total
+ - dependents
+ - inaccessible_count
+ - archived_count
properties:
message:
type: string
@@ -1157,6 +1178,8 @@ def delete(self, uuid: str) -> FlaskResponse:
type: string
inaccessible_count:
type: integer
+ archived_count:
+ type: integer
422:
$ref: '#/components/responses/422'
"""
@@ -1174,6 +1197,7 @@ def delete(self, uuid: str) -> FlaskResponse:
total=ex.total,
dependents=ex.dependents,
inaccessible_count=ex.inaccessible_count,
+ archived_count=ex.archived_count,
)
except SemanticLayerDeleteFailedError as ex:
logger.error(
diff --git a/superset/translations/messages.pot b/superset/translations/messages.pot
index ffd822fc4a17..a897b4b5afda 100644
--- a/superset/translations/messages.pot
+++ b/superset/translations/messages.pot
@@ -3247,12 +3247,6 @@ msgstr ""
msgid "Charts"
msgstr ""
-#, python-format
-msgid "Charts built on the selected semantic view are not checked and will break if it is deleted."
-msgid_plural "Charts built on the %s selected semantic views are not checked and will break if they are deleted."
-msgstr[0] ""
-msgstr[1] ""
-
msgid "Charts could not be deleted."
msgstr ""
@@ -5262,9 +5256,6 @@ msgstr ""
msgid "Deleting this dataset is permanent. The affected charts and dashboards will remain, but they may no longer work."
msgstr ""
-msgid "Deleting this semantic layer also permanently deletes any semantic views it contains, and charts built on those views will stop working. The affected views could not be listed."
-msgstr ""
-
msgid "Delimited long & lat single column"
msgstr ""
@@ -15206,12 +15197,6 @@ msgstr[1] ""
msgid "This will abort (stop) the task for all %s subscriber(s)."
msgstr ""
-#, python-format
-msgid "This will also permanently delete its %s semantic view. Charts built on that view will stop working."
-msgid_plural "This will also permanently delete its %s semantic views. Charts built on those views will stop working."
-msgstr[0] ""
-msgstr[1] ""
-
msgid "This will be applied to the whole table. Arrows (↑ and ↓) will be added to main columns for increase and decrease. Basic conditional formatting can be overwritten by conditional formatting below."
msgstr ""
@@ -18261,3 +18246,62 @@ msgstr ""
msgid "The view configuration must be a JSON object."
msgstr ""
+
+#, python-format
+msgid "%(count)s dependent asset is archived."
+msgid_plural "%(count)s dependent assets are archived."
+msgstr[0] ""
+msgstr[1] ""
+
+
+#, python-format
+msgid "Are you sure you want to delete %s? This cannot be undone."
+msgstr ""
+
+
+msgid ""
+"Deleting this semantic layer also permanently deletes any semantic views it "
+"contains. This cannot be undone. The affected views could not be listed."
+msgstr ""
+
+
+msgid ""
+"Deletion is blocked while any assets depend on the selected semantic views, "
+"including archived assets. Remove or repoint them first. Use Recently "
+"Archived to restore and repoint archived assets, or delete them permanently."
+msgstr ""
+
+
+msgid ""
+"Deletion is blocked while any assets depend on these views, including "
+"archived assets. Remove or repoint them first. Use Recently Archived to "
+"restore and repoint archived assets, or delete them permanently."
+msgstr ""
+
+
+msgid ""
+"Deletion is blocked while any assets depend on this view, including archived"
+" assets. Remove or repoint them first. Use Recently Archived to restore and "
+"repoint archived assets, or delete them permanently."
+msgstr ""
+
+
+msgid ""
+"Go to Recently Archived to restore and repoint archived assets, or delete "
+"them permanently."
+msgstr ""
+
+
+msgid "Remove or repoint these assets before deleting the semantic source."
+msgstr ""
+
+
+#, python-format
+msgid ""
+"This will also permanently delete its %s semantic view. This cannot be "
+"undone."
+msgid_plural ""
+"This will also permanently delete its %s semantic views. This cannot be "
+"undone."
+msgstr[0] ""
+msgstr[1] ""
diff --git a/tests/unit_tests/commands/semantic_layer/delete_test.py b/tests/unit_tests/commands/semantic_layer/delete_test.py
index cbae58162162..fa8f7048c9eb 100644
--- a/tests/unit_tests/commands/semantic_layer/delete_test.py
+++ b/tests/unit_tests/commands/semantic_layer/delete_test.py
@@ -41,7 +41,7 @@ def test_delete_semantic_layer_success(mocker: MockerFixture) -> None:
mock_model.uuid = "00000000-0000-0000-0000-000000000001"
mocker.patch(
"superset.commands.semantic_layer.delete._dependent_assets",
- return_value=(0, [], 0),
+ return_value=(0, [], 0, 0),
)
dao = mocker.patch(
@@ -74,7 +74,7 @@ def test_delete_semantic_layer_refuses_dependents(mocker: MockerFixture) -> None
)
mocker.patch(
"superset.commands.semantic_layer.delete._dependent_assets",
- return_value=(1, [{"type": "chart", "id": 7, "name": "Revenue"}], 0),
+ return_value=(1, [{"type": "chart", "id": 7, "name": "Revenue"}], 0, 0),
)
exc_info: pytest.ExceptionInfo[SemanticDeleteDependentsError]
@@ -138,7 +138,7 @@ def test_delete_semantic_layer_creator_allowed(mocker: MockerFixture) -> None:
mock_model.uuid = "00000000-0000-0000-0000-000000000001"
mocker.patch(
"superset.commands.semantic_layer.delete._dependent_assets",
- return_value=(0, [], 0),
+ return_value=(0, [], 0, 0),
)
dao = mocker.patch(
@@ -187,7 +187,7 @@ def test_delete_semantic_view_success(mocker: MockerFixture) -> None:
mock_model.id = 42
mocker.patch(
"superset.commands.semantic_layer.delete._dependent_assets",
- return_value=(0, [], 0),
+ return_value=(0, [], 0, 0),
)
dao = mocker.patch(
@@ -225,7 +225,7 @@ def test_delete_semantic_view_refuses_dependents(mocker: MockerFixture) -> None:
)
mocker.patch(
"superset.commands.semantic_layer.delete._dependent_assets",
- return_value=(1, [{"type": "chart", "id": 7, "name": "Revenue"}], 0),
+ return_value=(1, [{"type": "chart", "id": 7, "name": "Revenue"}], 0, 0),
)
exc_info: pytest.ExceptionInfo[SemanticDeleteDependentsError]
@@ -254,7 +254,7 @@ def test_bulk_delete_semantic_views_refuses_all_on_one_dependency(
)
mocker.patch(
"superset.commands.semantic_layer.delete._dependent_assets",
- return_value=(1, [{"type": "chart", "id": 7, "name": "Revenue"}], 0),
+ return_value=(1, [{"type": "chart", "id": 7, "name": "Revenue"}], 0, 0),
)
with pytest.raises(SemanticDeleteDependentsError):
@@ -264,7 +264,7 @@ def test_bulk_delete_semantic_views_refuses_all_on_one_dependency(
@pytest.mark.parametrize("orm_execution", [False, True])
-def test_semantic_delete_lists_live_dependents_only(
+def test_semantic_delete_counts_archived_dependents_without_listing_them(
session: Session, mocker: MockerFixture, orm_execution: bool
) -> None:
"""Charts, dashboard membership and active schedules form the dependency set."""
@@ -356,10 +356,14 @@ def test_semantic_delete_lists_live_dependents_only(
total: int
dependents: list[dict[str, str | int]]
inaccessible_count: int
- total, dependents, inaccessible_count = _dependent_assets(sa.select(sa.literal(42)))
+ archived_count: int
+ total, dependents, inaccessible_count, archived_count = _dependent_assets(
+ sa.select(sa.literal(42))
+ )
- assert total == 5
- assert inaccessible_count == 0
+ assert total == 6
+ assert archived_count == 1
+ assert inaccessible_count == 1
assert {item["name"] for item in dependents} == {
"Live chart",
"Live dashboard",
@@ -377,13 +381,13 @@ def test_semantic_delete_lists_live_dependents_only(
capped_total: int
capped_dependents: list[dict[str, str | int]]
capped_inaccessible_count: int
- capped_total, capped_dependents, capped_inaccessible_count = _dependent_assets(
- sa.select(sa.literal(42))
+ capped_total, capped_dependents, capped_inaccessible_count, archived_count = (
+ _dependent_assets(sa.select(sa.literal(42)))
)
- assert capped_total == 5
+ assert capped_total == 6
assert len(capped_dependents) == 2
assert [item["type"] for item in capped_dependents] == ["chart", "chart"]
- assert capped_inaccessible_count == 0
+ assert capped_inaccessible_count == 1
mocker.patch(
"superset.commands.semantic_layer.delete.ChartFilter.apply",
return_value=session.query(Slice.id).filter(sa.false()),
@@ -391,11 +395,11 @@ def test_semantic_delete_lists_live_dependents_only(
hidden_total: int
hidden_dependents: list[dict[str, str | int]]
hidden_count: int
- hidden_total, hidden_dependents, hidden_count = _dependent_assets(
+ hidden_total, hidden_dependents, hidden_count, archived_count = _dependent_assets(
sa.select(sa.literal(42))
)
- assert hidden_total == 5
- assert hidden_count == 2
+ assert hidden_total == 6
+ assert hidden_count == 3
assert all(dependent["type"] != "chart" for dependent in hidden_dependents)
if not orm_execution:
connection.close()
@@ -404,7 +408,7 @@ def test_semantic_delete_lists_live_dependents_only(
def test_semantic_delete_finds_native_filter_dashboards(
session: Session, mocker: MockerFixture
) -> None:
- """Only live, typed semantic targets block deletion; membership is deduped."""
+ """Live and archived typed targets block deletion; membership is deduped."""
import uuid
from datetime import datetime
@@ -537,11 +541,13 @@ def metadata(source_type: str | None, dataset_id: int | str) -> str:
total: int
dependents: list[dict[str, str | int]]
inaccessible_count: int
- total, dependents, inaccessible_count = _dependent_assets(
+ archived_count: int
+ total, dependents, inaccessible_count, archived_count = _dependent_assets(
sa.select(SemanticView.id).where(SemanticView.id == 42)
)
- assert total == 5
- assert inaccessible_count == 0
+ assert total == 6
+ assert archived_count == 1
+ assert inaccessible_count == 1
assert {item["name"] for item in dependents} == {
"Typed integer",
"Typed string",
@@ -568,11 +574,11 @@ def metadata(source_type: str | None, dataset_id: int | str) -> str:
hidden_total: int
hidden_dependents: list[dict[str, str | int]]
hidden_count: int
- hidden_total, hidden_dependents, hidden_count = _dependent_assets(
- sa.select(SemanticView.id).where(SemanticView.id == 42)
+ hidden_total, hidden_dependents, hidden_count, archived_count = (
+ _dependent_assets(sa.select(SemanticView.id).where(SemanticView.id == 42))
)
- assert hidden_total == 5
- assert hidden_count == 3
+ assert hidden_total == 6
+ assert hidden_count == 4
assert {item["name"] for item in hidden_dependents} == {
"Member chart",
"Native report",
@@ -767,14 +773,17 @@ def test_semantic_delete_counts_use_one_snapshot(
"superset.db.session.scalar", side_effect=[1, 2]
)
rows: MagicMock = mocker.patch("superset.db.session.execute")
- rows.return_value.one.return_value = (1, 0)
+ rows.return_value.one.return_value = (1, 0, 0)
rows.return_value.all.return_value = []
mocker.patch("superset.security_manager.can_access", return_value=False)
total: int
dependents: list[dict[str, str | int]]
inaccessible_count: int
- total, dependents, inaccessible_count = _dependent_assets(sa.select(sa.literal(42)))
+ archived_count: int
+ total, dependents, inaccessible_count, archived_count = _dependent_assets(
+ sa.select(sa.literal(42))
+ )
assert (total, dependents, inaccessible_count) == (1, [], 1)
stale_scalar.assert_not_called()
@@ -810,7 +819,7 @@ def test_delete_semantic_view_creator_allowed(mocker: MockerFixture) -> None:
mock_model.id = 42
mocker.patch(
"superset.commands.semantic_layer.delete._dependent_assets",
- return_value=(0, [], 0),
+ return_value=(0, [], 0, 0),
)
dao = mocker.patch(
@@ -877,7 +886,7 @@ def test_bulk_delete_semantic_view_success(mocker: MockerFixture) -> None:
mock_models = [MagicMock(), MagicMock()]
mocker.patch(
"superset.commands.semantic_layer.delete._dependent_assets",
- return_value=(0, [], 0),
+ return_value=(0, [], 0, 0),
)
dao = mocker.patch(
@@ -925,7 +934,7 @@ def test_bulk_delete_semantic_view_creator_allowed(mocker: MockerFixture) -> Non
mock_models = [MagicMock(), MagicMock()]
mocker.patch(
"superset.commands.semantic_layer.delete._dependent_assets",
- return_value=(0, [], 0),
+ return_value=(0, [], 0, 0),
)
dao = mocker.patch(
@@ -987,3 +996,75 @@ def test_bulk_delete_semantic_view_not_found(mocker: MockerFixture) -> None:
with pytest.raises(SemanticViewNotFoundError):
BulkDeleteSemanticViewCommand([1, 2]).run()
+
+
+@pytest.mark.parametrize("kind", ["layer", "view", "bulk"])
+def test_archived_chart_blocks_semantic_delete(
+ session: Session, mocker: MockerFixture, kind: str
+) -> None:
+ """A recoverable chart must retain its semantic source on every delete path."""
+ import uuid
+ from datetime import datetime
+
+ from superset.commands.semantic_layer.delete import (
+ BulkDeleteSemanticViewCommand,
+ DeleteSemanticViewCommand,
+ )
+ from superset.models.slice import Slice
+ from superset.semantic_layers.models import SemanticLayer, SemanticView
+
+ Slice.metadata.create_all(session.get_bind())
+ layer_uuid: uuid.UUID = uuid.uuid4()
+ session.execute(
+ SemanticLayer.__table__.insert().values(
+ uuid=layer_uuid, name="source", type="test"
+ )
+ )
+ session.execute(
+ SemanticView.__table__.insert().values(
+ id=42, name="view", semantic_layer_uuid=layer_uuid
+ )
+ )
+ session.execute(
+ Slice.__table__.insert().values(
+ id=901,
+ slice_name="archived",
+ datasource_type="semantic_view",
+ datasource_id=42,
+ deleted_at=datetime(2026, 1, 1),
+ )
+ )
+ mocker.patch("superset.security_manager.can_access", return_value=False)
+ mocker.patch(
+ "superset.commands.semantic_layer.delete.current_user_can_modify_object",
+ return_value=True,
+ )
+ layer_dao: MagicMock = mocker.patch(
+ "superset.commands.semantic_layer.delete.SemanticLayerDAO"
+ )
+ view_dao: MagicMock = mocker.patch(
+ "superset.commands.semantic_layer.delete.SemanticViewDAO"
+ )
+ layer_dao.find_by_uuid.return_value = MagicMock(uuid=layer_uuid)
+ view_dao.find_by_id.return_value = MagicMock(id=42)
+ view_dao.find_by_ids.return_value = [MagicMock(id=42)]
+ command: (
+ DeleteSemanticLayerCommand
+ | DeleteSemanticViewCommand
+ | BulkDeleteSemanticViewCommand
+ ) = (
+ DeleteSemanticLayerCommand(str(layer_uuid))
+ if kind == "layer"
+ else DeleteSemanticViewCommand(42)
+ if kind == "view"
+ else BulkDeleteSemanticViewCommand([42])
+ )
+ exc_info: pytest.ExceptionInfo[SemanticDeleteDependentsError]
+ with pytest.raises(SemanticDeleteDependentsError) as exc_info:
+ command.run()
+ assert exc_info.value.total == 1
+ assert exc_info.value.archived_count == 1
+ assert exc_info.value.dependents == []
+ assert "Recently Archived" in str(exc_info.value)
+ layer_dao.delete.assert_not_called()
+ view_dao.delete.assert_not_called()
diff --git a/tests/unit_tests/semantic_layers/api_test.py b/tests/unit_tests/semantic_layers/api_test.py
index f86058071d76..cb6bd20bcead 100644
--- a/tests/unit_tests/semantic_layers/api_test.py
+++ b/tests/unit_tests/semantic_layers/api_test.py
@@ -1036,7 +1036,7 @@ def test_semantic_delete_reports_dependents_as_conflict(
}
mock_command: MagicMock = mocker.patch(f"superset.semantic_layers.api.{command}")
mock_command.return_value.run.side_effect = SemanticDeleteDependentsError(
- 25, [dependent], 24
+ 25, [dependent], 24, 3
)
request_path: str = (
@@ -1047,11 +1047,16 @@ def test_semantic_delete_reports_dependents_as_conflict(
response: TestResponse = client.delete(request_path)
assert response.status_code == 409
+ assert response.json["archived_count"] == 3
assert response.json["total"] == 25
assert response.json["dependents"] == [dependent]
assert response.json["inaccessible_count"] == 24
assert response.json["message"] == (
- "Semantic source is used by 25 dependent assets and cannot be deleted."
+ "Semantic source is used by 25 dependent assets and cannot be deleted. "
+ "Remove or repoint these assets before deleting the semantic source. "
+ "3 dependent assets are archived. "
+ "Go to Recently Archived to restore and repoint archived assets, "
+ "or delete them permanently."
)
@@ -1083,6 +1088,7 @@ def test_semantic_delete_openapi_conflict_schema(
"total",
"dependents",
"inaccessible_count",
+ "archived_count",
}
assert schema["properties"]["dependents"]["maxItems"] == 20
assert schema["properties"]["dependents"]["items"]["properties"]["type"][
From 119a57d32755a8a31d9c464c426db121833f58b7 Mon Sep 17 00:00:00 2001
From: Mike Bridge
Date: Tue, 20 Oct 2026 16:37:50 -0600
Subject: [PATCH 02/12] fix(semantic): clarify deletion guidance and guard
archived dashboards
---
.../docs/using-superset/recently-archived.mdx | 8 ++-
docs/static/resources/openapi.json | 3 +
.../DatabaseList.semanticLayerDelete.test.tsx | 6 +-
.../src/pages/DatabaseList/index.tsx | 21 +++++--
.../DatasetList/DatasetList.behavior.test.tsx | 4 +-
.../src/pages/DatasetList/index.tsx | 4 +-
superset/commands/semantic_layer/delete.py | 4 ++
.../commands/semantic_layer/exceptions.py | 2 +-
superset/semantic_layers/api.py | 9 +++
superset/translations/messages.pot | 46 +++++++-------
.../commands/semantic_layer/delete_test.py | 61 +++++++++++++++++++
tests/unit_tests/semantic_layers/api_test.py | 2 +-
12 files changed, 131 insertions(+), 39 deletions(-)
diff --git a/docs/docs/using-superset/recently-archived.mdx b/docs/docs/using-superset/recently-archived.mdx
index c217d2febe66..5b08cd893d5b 100644
--- a/docs/docs/using-superset/recently-archived.mdx
+++ b/docs/docs/using-superset/recently-archived.mdx
@@ -85,6 +85,12 @@ filter cleanup; the response warns that the dashboard's filters need review.
## Deleting an object permanently
+Archived charts and dashboards still block deletion of semantic views and layers
+they depend on. The refusal includes the number of archived dependents. Use
+**Recently Archived** to restore and repoint them, or permanently delete them,
+before deleting the semantic source. If you cannot access those assets, ask an
+admin for help.
+
Use the **Delete permanently** action on a row to remove an object for good.
You will be asked to confirm.
@@ -392,5 +398,3 @@ exit status 0 alone as proof of erasure.
The purge removes the entity together with its
[version history](./version-history.mdx). The retention window for version history
itself is configured separately and is described on that page.
-
-Archived charts and dashboards still block deletion of semantic views and layers they depend on. The refusal includes the number of archived dependents. Use **Recently Archived** to restore and repoint them, or permanently delete them, before deleting the semantic source.
diff --git a/docs/static/resources/openapi.json b/docs/static/resources/openapi.json
index 1be46e8d1146..e4e1d91dd171 100644
--- a/docs/static/resources/openapi.json
+++ b/docs/static/resources/openapi.json
@@ -34264,6 +34264,7 @@
"schema": {
"properties": {
"archived_count": {
+ "description": "Archived dependents are included in inaccessible_count; these counts overlap and must not be added together.",
"type": "integer"
},
"dependents": {
@@ -34653,6 +34654,7 @@
"schema": {
"properties": {
"archived_count": {
+ "description": "Archived dependents are included in inaccessible_count; these counts overlap and must not be added together.",
"type": "integer"
},
"dependents": {
@@ -34819,6 +34821,7 @@
"schema": {
"properties": {
"archived_count": {
+ "description": "Archived dependents are included in inaccessible_count; these counts overlap and must not be added together.",
"type": "integer"
},
"dependents": {
diff --git a/superset-frontend/src/pages/DatabaseList/DatabaseList.semanticLayerDelete.test.tsx b/superset-frontend/src/pages/DatabaseList/DatabaseList.semanticLayerDelete.test.tsx
index 57d61b402ff6..d7793fec08dc 100644
--- a/superset-frontend/src/pages/DatabaseList/DatabaseList.semanticLayerDelete.test.tsx
+++ b/superset-frontend/src/pages/DatabaseList/DatabaseList.semanticLayerDelete.test.tsx
@@ -245,10 +245,12 @@ test('a genuinely empty layer says so instead of warning about nonexistent views
renderDatabaseList();
const dialog = await openDeleteModal();
+ expect(dialog).toHaveTextContent('This cannot be undone.');
+ expect(dialog).not.toHaveTextContent('Deletion is blocked');
expect(
within(dialog).getByText(
- 'This semantic layer has no dependent semantic views.',
+ 'This semantic layer has no dependent semantic views. This cannot be undone.',
),
).toBeInTheDocument();
expect(
@@ -443,7 +445,7 @@ test("a stale lookup resolving late cannot replace a newer row's modal", async (
test('layer delete displays the counted dependency conflict', async () => {
const reason =
- 'Semantic source is used by 25 dependent assets and cannot be deleted. 3 dependent assets are archived. Go to Recently Archived to restore and repoint archived assets, or delete them permanently.';
+ 'Semantic source is used by 25 dependent assets and cannot be deleted. 3 dependent assets are archived. Go to Recently Archived to restore and repoint archived assets, or delete them permanently, or ask an admin for help.';
setupMocks({ dependents: [], deleteError: reason });
const addDangerToast = jest.fn();
renderDatabaseList(mockUser, addDangerToast);
diff --git a/superset-frontend/src/pages/DatabaseList/index.tsx b/superset-frontend/src/pages/DatabaseList/index.tsx
index 591cdb3537c8..0dcc3bad7243 100644
--- a/superset-frontend/src/pages/DatabaseList/index.tsx
+++ b/superset-frontend/src/pages/DatabaseList/index.tsx
@@ -148,7 +148,13 @@ function SemanticLayerCascadeWarning({
// never access-filtered. An honest empty message keeps the destructive
// warning credible for the layers where it matters.
if (preview.dependentViewCount === 0) {
- return {t('This semantic layer has no dependent semantic views.')}
;
+ return (
+
+ {t(
+ 'This semantic layer has no dependent semantic views. This cannot be undone.',
+ )}
+
+ );
}
const listedViewCount = preview.dependentViewNames.length;
@@ -1181,11 +1187,14 @@ function DatabaseList({
{slDeletePreview.item.database_name}?
-
- {t(
- 'Deletion is blocked while any assets depend on these views, including archived assets. Remove or repoint them first. Use Recently Archived to restore and repoint archived assets, or delete them permanently.',
- )}
-
+ {(slDeletePreview.status === 'failed' ||
+ slDeletePreview.dependentViewCount > 0) && (
+
+ {t(
+ 'Deletion is blocked while any assets depend on these views, including archived assets. Remove or repoint them first. Use Recently Archived to restore and repoint archived assets, or delete them permanently, or ask an admin for help.',
+ )}
+
+ )}
>
}
onConfirm={() => {
diff --git a/superset-frontend/src/pages/DatasetList/DatasetList.behavior.test.tsx b/superset-frontend/src/pages/DatasetList/DatasetList.behavior.test.tsx
index cf38acb1b6b2..de1db7bd4dc8 100644
--- a/superset-frontend/src/pages/DatasetList/DatasetList.behavior.test.tsx
+++ b/superset-frontend/src/pages/DatasetList/DatasetList.behavior.test.tsx
@@ -81,7 +81,7 @@ test('single semantic view delete displays the counted dependency conflict', asy
kind: 'semantic_view',
};
const reason =
- 'Semantic source is used by 25 dependent assets and cannot be deleted. 3 dependent assets are archived. Go to Recently Archived to restore and repoint archived assets, or delete them permanently.';
+ 'Semantic source is used by 25 dependent assets and cannot be deleted. 3 dependent assets are archived. Go to Recently Archived to restore and repoint archived assets, or delete them permanently, or ask an admin for help.';
const addDangerToast = jest.fn();
mockDatasetListEndpoints({ result: [view], count: 1 });
fetchMock.delete('glob:*/api/v1/semantic_view/99', {
@@ -119,7 +119,7 @@ test('mixed bulk delete names refused semantic views and their conflict reason',
};
const addDangerToast = jest.fn();
const reason =
- 'Semantic source is used by 25 dependent assets and cannot be deleted. 3 dependent assets are archived. Go to Recently Archived to restore and repoint archived assets, or delete them permanently.';
+ 'Semantic source is used by 25 dependent assets and cannot be deleted. 3 dependent assets are archived. Go to Recently Archived to restore and repoint archived assets, or delete them permanently, or ask an admin for help.';
mockDatasetListEndpoints({ result: [dataset, semanticView], count: 2 });
fetchMock.get(API_ENDPOINTS.DATASET_BULK_RELATED_OBJECTS, {
charts: { count: 0, result: [] },
diff --git a/superset-frontend/src/pages/DatasetList/index.tsx b/superset-frontend/src/pages/DatasetList/index.tsx
index 398ed8678602..8b0aeacb3f25 100644
--- a/superset-frontend/src/pages/DatasetList/index.tsx
+++ b/superset-frontend/src/pages/DatasetList/index.tsx
@@ -1602,7 +1602,7 @@ const DatasetList: FunctionComponent = ({
{t(
- 'Deletion is blocked while any assets depend on this view, including archived assets. Remove or repoint them first. Use Recently Archived to restore and repoint archived assets, or delete them permanently.',
+ 'Deletion is blocked while any assets depend on this view, including archived assets. Remove or repoint them first. Use Recently Archived to restore and repoint archived assets, or delete them permanently, or ask an admin for help.',
)}
>
@@ -1730,7 +1730,7 @@ const DatasetList: FunctionComponent = ({
{pendingBulkSemanticCount > 0 && (
{t(
- 'Deletion is blocked while any assets depend on the selected semantic views, including archived assets. Remove or repoint them first. Use Recently Archived to restore and repoint archived assets, or delete them permanently.',
+ 'Deletion is blocked while any assets depend on the selected semantic views, including archived assets. Remove or repoint them first. Use Recently Archived to restore and repoint archived assets, or delete them permanently, or ask an admin for help.',
)}
)}
diff --git a/superset/commands/semantic_layer/delete.py b/superset/commands/semantic_layer/delete.py
index 2f4ea894cc65..17f150d52365 100644
--- a/superset/commands/semantic_layer/delete.py
+++ b/superset/commands/semantic_layer/delete.py
@@ -101,6 +101,8 @@ def _dashboard_target_ids(view_ids: sa.Select) -> set[int]:
selected_ids: set[int] = set(db.session.execute(view_ids).scalars())
if not selected_ids:
return set()
+ # Core columns bypass the ORM do_orm_execute soft-delete filter so archived
+ # dashboards remain deletion blockers, including metadata-only targets.
candidates: sa.Select = sa.select(
Dashboard.__table__.c.id, Dashboard.__table__.c.json_metadata
).where(
@@ -119,6 +121,8 @@ def _dependent_assets(
view_ids: sa.Select,
) -> tuple[int, list[dict[str, str | int]], int, int]:
"""Count dependents, naming only assets visible through their list APIs."""
+ # Core table selects bypass the ORM do_orm_execute soft-delete filter:
+ # archived charts and dashboards must still block semantic-source deletion.
chart_ids: sa.Select = sa.select(Slice.__table__.c.id).where(
Slice.__table__.c.datasource_type == "semantic_view",
Slice.__table__.c.datasource_id.in_(view_ids),
diff --git a/superset/commands/semantic_layer/exceptions.py b/superset/commands/semantic_layer/exceptions.py
index 40d0bd3939f5..3aa2690d87b0 100644
--- a/superset/commands/semantic_layer/exceptions.py
+++ b/superset/commands/semantic_layer/exceptions.py
@@ -112,6 +112,6 @@ def __init__(
)
message += " " + _(
"Go to Recently Archived to restore and repoint archived assets, "
- "or delete them permanently."
+ "or delete them permanently, or ask an admin for help."
)
super().__init__(message)
diff --git a/superset/semantic_layers/api.py b/superset/semantic_layers/api.py
index 1559fd9e86ba..8346631d8675 100644
--- a/superset/semantic_layers/api.py
+++ b/superset/semantic_layers/api.py
@@ -548,6 +548,9 @@ def delete(self, pk: int) -> Response:
type: integer
archived_count:
type: integer
+ description: >-
+ Archived dependents are included in inaccessible_count;
+ these counts overlap and must not be added together.
422:
$ref: '#/components/responses/422'
"""
@@ -649,6 +652,9 @@ def bulk_delete(self, **kwargs: Any) -> Response:
type: integer
archived_count:
type: integer
+ description: >-
+ Archived dependents are included in inaccessible_count;
+ these counts overlap and must not be added together.
422:
$ref: '#/components/responses/422'
"""
@@ -1180,6 +1186,9 @@ def delete(self, uuid: str) -> FlaskResponse:
type: integer
archived_count:
type: integer
+ description: >-
+ Archived dependents are included in inaccessible_count;
+ these counts overlap and must not be added together.
422:
$ref: '#/components/responses/422'
"""
diff --git a/superset/translations/messages.pot b/superset/translations/messages.pot
index a897b4b5afda..67c57ac440df 100644
--- a/superset/translations/messages.pot
+++ b/superset/translations/messages.pot
@@ -15128,9 +15128,6 @@ msgstr ""
msgid "This section contains validation errors"
msgstr ""
-msgid "This semantic layer has no dependent semantic views."
-msgstr ""
-
msgid "This semantic provider cannot apply the requested offset. Turn off pagination or use a supported limit and offset."
msgstr ""
@@ -18265,43 +18262,46 @@ msgid ""
msgstr ""
+msgid "Remove or repoint these assets before deleting the semantic source."
+msgstr ""
+
+
+#, python-format
+msgid ""
+"This will also permanently delete its %s semantic view. This cannot be "
+"undone."
+msgid_plural ""
+"This will also permanently delete its %s semantic views. This cannot be "
+"undone."
+msgstr[0] ""
+msgstr[1] ""
+
msgid ""
"Deletion is blocked while any assets depend on the selected semantic views, "
"including archived assets. Remove or repoint them first. Use Recently "
-"Archived to restore and repoint archived assets, or delete them permanently."
+"Archived to restore and repoint archived assets, or delete them permanently,"
+" or ask an admin for help."
msgstr ""
-
msgid ""
"Deletion is blocked while any assets depend on these views, including "
"archived assets. Remove or repoint them first. Use Recently Archived to "
-"restore and repoint archived assets, or delete them permanently."
+"restore and repoint archived assets, or delete them permanently, or ask an "
+"admin for help."
msgstr ""
-
msgid ""
"Deletion is blocked while any assets depend on this view, including archived"
" assets. Remove or repoint them first. Use Recently Archived to restore and "
-"repoint archived assets, or delete them permanently."
+"repoint archived assets, or delete them permanently, or ask an admin for "
+"help."
msgstr ""
-
msgid ""
"Go to Recently Archived to restore and repoint archived assets, or delete "
-"them permanently."
-msgstr ""
-
-
-msgid "Remove or repoint these assets before deleting the semantic source."
+"them permanently, or ask an admin for help."
msgstr ""
-
-#, python-format
msgid ""
-"This will also permanently delete its %s semantic view. This cannot be "
-"undone."
-msgid_plural ""
-"This will also permanently delete its %s semantic views. This cannot be "
-"undone."
-msgstr[0] ""
-msgstr[1] ""
+"This semantic layer has no dependent semantic views. This cannot be undone."
+msgstr ""
diff --git a/tests/unit_tests/commands/semantic_layer/delete_test.py b/tests/unit_tests/commands/semantic_layer/delete_test.py
index fa8f7048c9eb..8c094a62f98d 100644
--- a/tests/unit_tests/commands/semantic_layer/delete_test.py
+++ b/tests/unit_tests/commands/semantic_layer/delete_test.py
@@ -1068,3 +1068,64 @@ def test_archived_chart_blocks_semantic_delete(
assert "Recently Archived" in str(exc_info.value)
layer_dao.delete.assert_not_called()
view_dao.delete.assert_not_called()
+
+
+@pytest.mark.parametrize(
+ "path", ["membership", "native_filter_configuration", "chart_customization_config"]
+)
+def test_archived_dashboard_blocks_delete_with_orm_listener(
+ session: Session, mocker: MockerFixture, path: str
+) -> None:
+ """Core dependency queries must retain dashboards hidden by the ORM listener."""
+ from datetime import datetime
+
+ from superset.commands.semantic_layer.delete import _raise_for_dependents
+ from superset.models.dashboard import Dashboard
+ from superset.models.slice import Slice
+ from superset.utils import json
+
+ Dashboard.metadata.create_all(session.get_bind())
+ session.execute(
+ Dashboard.__table__.insert().values(
+ id=950,
+ dashboard_title="Archived dashboard",
+ deleted_at=datetime(2026, 1, 1),
+ json_metadata=json.dumps(
+ {
+ path: [
+ {
+ "targets": [
+ {"datasourceType": "semantic_view", "datasetId": 42}
+ ]
+ }
+ ]
+ }
+ )
+ if path != "membership"
+ else "{}",
+ )
+ )
+ if path == "membership":
+ session.execute(
+ Slice.__table__.insert().values(
+ id=951,
+ slice_name="Member",
+ datasource_type="semantic_view",
+ datasource_id=42,
+ )
+ )
+ session.execute(
+ sa.text(
+ "INSERT INTO dashboard_slices (dashboard_id, slice_id) "
+ "VALUES (950, 951)"
+ )
+ )
+ # Prove the session listener is active; do not substitute Connection.execute.
+ assert session.scalar(sa.select(Dashboard.id).where(Dashboard.id == 950)) is None
+ mocker.patch("superset.security_manager.can_access", return_value=False)
+ exc_info: pytest.ExceptionInfo[SemanticDeleteDependentsError]
+ with pytest.raises(SemanticDeleteDependentsError) as exc_info:
+ _raise_for_dependents(sa.select(sa.literal(42)))
+ assert exc_info.value.total == (2 if path == "membership" else 1)
+ assert exc_info.value.archived_count == 1
+ assert exc_info.value.dependents == []
diff --git a/tests/unit_tests/semantic_layers/api_test.py b/tests/unit_tests/semantic_layers/api_test.py
index cb6bd20bcead..6115f2a607c8 100644
--- a/tests/unit_tests/semantic_layers/api_test.py
+++ b/tests/unit_tests/semantic_layers/api_test.py
@@ -1056,7 +1056,7 @@ def test_semantic_delete_reports_dependents_as_conflict(
"Remove or repoint these assets before deleting the semantic source. "
"3 dependent assets are archived. "
"Go to Recently Archived to restore and repoint archived assets, "
- "or delete them permanently."
+ "or delete them permanently, or ask an admin for help."
)
From da0f8246475774750dc97c39eea3c79367bf9e4e Mon Sep 17 00:00:00 2001
From: Mike Bridge
Date: Wed, 21 Oct 2026 17:00:15 -0600
Subject: [PATCH 03/12] fix(semantic): keep deletion conflicts in confirmation
dialogs
---
.../docs/using-superset/recently-archived.mdx | 6 ++
.../DeleteModal/DeleteModal.test.tsx | 26 ++++++
.../src/components/DeleteModal/index.tsx | 7 +-
.../src/components/DeleteModal/types.ts | 2 +
.../DatabaseList.semanticLayerDelete.test.tsx | 29 +++++--
.../src/pages/DatabaseList/index.tsx | 87 ++++++++++++++-----
.../DatasetList/DatasetList.behavior.test.tsx | 26 ++++--
.../src/pages/DatasetList/index.tsx | 80 ++++++++++++-----
superset/translations/messages.pot | 20 ++---
9 files changed, 213 insertions(+), 70 deletions(-)
diff --git a/docs/docs/using-superset/recently-archived.mdx b/docs/docs/using-superset/recently-archived.mdx
index 5b08cd893d5b..da5c0b1935ac 100644
--- a/docs/docs/using-superset/recently-archived.mdx
+++ b/docs/docs/using-superset/recently-archived.mdx
@@ -91,6 +91,12 @@ they depend on. The refusal includes the number of archived dependents. Use
before deleting the semantic source. If you cannot access those assets, ask an
admin for help.
+When deleting a semantic layer or view, a dependency refusal stays in the
+confirmation dialog with the total and archived counts and a link to Recently
+Archived. The typed confirmation is preserved so you can retry after removing
+or repointing the dependents. Dependencies are checked when you confirm deletion,
+not before you type the confirmation.
+
Use the **Delete permanently** action on a row to remove an object for good.
You will be asked to confirm.
diff --git a/superset-frontend/packages/superset-ui-core/src/components/DeleteModal/DeleteModal.test.tsx b/superset-frontend/packages/superset-ui-core/src/components/DeleteModal/DeleteModal.test.tsx
index 07e329d22ecd..30f28b7ca3c1 100644
--- a/superset-frontend/packages/superset-ui-core/src/components/DeleteModal/DeleteModal.test.tsx
+++ b/superset-frontend/packages/superset-ui-core/src/components/DeleteModal/DeleteModal.test.tsx
@@ -299,3 +299,29 @@ test('confirmation reset key clears and re-arms type-to-confirm', async () => {
expect(screen.getByTestId('delete-modal-input')).toHaveValue('');
expect(screen.getByRole('button', { name: 'Delete' })).toBeDisabled();
});
+
+test.each([false, true])(
+ 'confirmation preservation is opt-in: %s',
+ async preserveConfirmationOnConfirm => {
+ const onConfirm = jest.fn();
+ render(
+ ,
+ );
+ const input = screen.getByTestId('delete-modal-input');
+ await userEvent.type(input, 'DELETE');
+ await userEvent.click(screen.getByTestId('modal-confirm-button'));
+ expect(onConfirm).toHaveBeenCalledTimes(1);
+ expect(input).toHaveValue(preserveConfirmationOnConfirm ? 'DELETE' : '');
+ await userEvent.click(screen.getByTestId('close-modal-btn'));
+ expect(input).toHaveValue('');
+ },
+);
diff --git a/superset-frontend/packages/superset-ui-core/src/components/DeleteModal/index.tsx b/superset-frontend/packages/superset-ui-core/src/components/DeleteModal/index.tsx
index 6acb1104a8ec..f2350ee8d2d1 100644
--- a/superset-frontend/packages/superset-ui-core/src/components/DeleteModal/index.tsx
+++ b/superset-frontend/packages/superset-ui-core/src/components/DeleteModal/index.tsx
@@ -46,6 +46,7 @@ export function DeleteModal({
disableConfirmationInput = false,
loading = false,
confirmationResetKey,
+ preserveConfirmationOnConfirm = false,
}: DeleteModalProps) {
// Recoverable (archive) deletes drop the "type DELETE to confirm" step;
// a permanent delete keeps it.
@@ -77,8 +78,10 @@ export function DeleteModal({
};
const confirm = () => {
- setConfirmation('');
- setDisableChange(true);
+ if (!preserveConfirmationOnConfirm) {
+ setConfirmation('');
+ setDisableChange(true);
+ }
onConfirm();
};
diff --git a/superset-frontend/packages/superset-ui-core/src/components/DeleteModal/types.ts b/superset-frontend/packages/superset-ui-core/src/components/DeleteModal/types.ts
index a62917e66547..d3c337d502da 100644
--- a/superset-frontend/packages/superset-ui-core/src/components/DeleteModal/types.ts
+++ b/superset-frontend/packages/superset-ui-core/src/components/DeleteModal/types.ts
@@ -55,4 +55,6 @@ export interface DeleteModalProps {
loading?: boolean;
/** Clear and re-arm the typed-text gate when the reviewed data changes. */
confirmationResetKey?: string | number;
+ /** Keep typed confirmation until dismissal or an explicit reset. */
+ preserveConfirmationOnConfirm?: boolean;
}
diff --git a/superset-frontend/src/pages/DatabaseList/DatabaseList.semanticLayerDelete.test.tsx b/superset-frontend/src/pages/DatabaseList/DatabaseList.semanticLayerDelete.test.tsx
index d7793fec08dc..540f9c7b3706 100644
--- a/superset-frontend/src/pages/DatabaseList/DatabaseList.semanticLayerDelete.test.tsx
+++ b/superset-frontend/src/pages/DatabaseList/DatabaseList.semanticLayerDelete.test.tsx
@@ -137,7 +137,12 @@ const setupMocks = ({
}
fetchMock.delete(
DELETE_ROUTE,
- deleteError ? { status: 409, body: { message: deleteError } } : {},
+ deleteError
+ ? {
+ status: 409,
+ body: { message: deleteError, total: 25, archived_count: 3 },
+ }
+ : {},
);
};
@@ -202,6 +207,7 @@ test('delete confirmation warns about cascade-deleting dependent views by count
renderDatabaseList();
const dialog = await openDeleteModal();
+ expect(dialog).not.toHaveTextContent('Deletion is blocked');
expect(
within(dialog).getByText(
@@ -455,11 +461,24 @@ test('layer delete displays the counted dependency conflict', async () => {
'DELETE',
);
await userEvent.click(within(dialog).getByRole('button', { name: 'Delete' }));
- await waitFor(() =>
- expect(addDangerToast).toHaveBeenCalledWith(
- `There was an issue deleting Demo Semantic Layer: ${reason}`,
- ),
+ const alert = await within(dialog).findByRole('alert');
+ expect(alert).toHaveTextContent('25 dependent assets');
+ expect(alert).toHaveTextContent('3 archived');
+ expect(
+ within(alert).getByRole('link', { name: 'Recently Archived' }),
+ ).toHaveAttribute('href', '/archived/');
+ expect(within(dialog).getByTestId('delete-modal-input')).toHaveValue(
+ 'DELETE',
);
+ expect(addDangerToast).not.toHaveBeenCalled();
+ await userEvent.click(within(dialog).getByRole('button', { name: 'Cancel' }));
+ await openDeleteModal();
+ expect(
+ within(screen.getByRole('dialog')).queryByRole('alert'),
+ ).not.toBeInTheDocument();
+ expect(
+ within(screen.getByRole('dialog')).getByTestId('delete-modal-input'),
+ ).toHaveValue('');
});
test('confirming the modal deletes the semantic layer', async () => {
diff --git a/superset-frontend/src/pages/DatabaseList/index.tsx b/superset-frontend/src/pages/DatabaseList/index.tsx
index 0dcc3bad7243..27a9c2715376 100644
--- a/superset-frontend/src/pages/DatabaseList/index.tsx
+++ b/superset-frontend/src/pages/DatabaseList/index.tsx
@@ -16,10 +16,12 @@
* specific language governing permissions and limitations
* under the License.
*/
+import { Alert } from '@apache-superset/core/components';
import { t, tn } from '@apache-superset/core/translation';
import {
getExtensionsRegistry,
SupersetClient,
+ getClientErrorObject,
isFeatureEnabled,
FeatureFlag,
} from '@superset-ui/core';
@@ -422,6 +424,13 @@ function DatabaseList({
const [slCurrentlyEditing, setSlCurrentlyEditing] = useState(
null,
);
+ const [slDeleteError, setSLDeleteError] = useState<{
+ error: string;
+ total?: number;
+ archived_count?: number;
+ } | null>(null);
+ const [slDeleting, setSLDeleting] = useState(false);
+ const slDeleteRequest = useRef(0);
const [slDeletePreview, setSlDeletePreview] =
useState(null);
@@ -481,6 +490,9 @@ function DatabaseList({
const slDeleteLookupRef = useRef(0);
const openSemanticLayerDeleteModal = useCallback((item: ConnectionItem) => {
slDeleteLookupRef.current += 1;
+ slDeleteRequest.current += 1;
+ setSLDeleteError(null);
+ setSLDeleting(false);
const lookupId = slDeleteLookupRef.current;
setSlDeletePreview({ status: 'loading', item });
return SupersetClient.get({
@@ -786,21 +798,34 @@ function DatabaseList({
const initialSort = [{ id: 'changed_on_delta_humanized', desc: true }];
- function handleSemanticLayerDelete(item: ConnectionItem) {
- SupersetClient.delete({
- endpoint: `/api/v1/semantic_layer/${item.uuid}`,
- }).then(
- () => {
- refreshData();
- addSuccessToast(t('Deleted: %s', item.database_name));
- setSlDeletePreview(null);
- },
- createErrorHandler(errMsg =>
+ async function handleSemanticLayerDelete(item: ConnectionItem) {
+ slDeleteRequest.current += 1;
+ const request = slDeleteRequest.current;
+ setSLDeleting(true);
+ try {
+ await SupersetClient.delete({
+ endpoint: `/api/v1/semantic_layer/${item.uuid}`,
+ });
+ refreshData();
+ addSuccessToast(t('Deleted: %s', item.database_name));
+ if (request === slDeleteRequest.current) setSlDeletePreview(null);
+ } catch (error) {
+ const parsed = await getClientErrorObject(error);
+ if (request !== slDeleteRequest.current) return;
+ if (parsed.status === 409) {
+ setSLDeleteError(parsed);
+ } else {
addDangerToast(
- t('There was an issue deleting %s: %s', item.database_name, errMsg),
- ),
- ),
- );
+ t(
+ 'There was an issue deleting %s: %s',
+ item.database_name,
+ parsed.error,
+ ),
+ );
+ }
+ } finally {
+ if (request === slDeleteRequest.current) setSLDeleting(false);
+ }
}
const columns = useMemo(
@@ -1180,6 +1205,8 @@ function DatabaseList({
/>
{slDeletePreview && slDeletePreview.status !== 'loading' && (
@@ -1187,20 +1214,36 @@ function DatabaseList({
{slDeletePreview.item.database_name}?
- {(slDeletePreview.status === 'failed' ||
- slDeletePreview.dependentViewCount > 0) && (
-
- {t(
- 'Deletion is blocked while any assets depend on these views, including archived assets. Remove or repoint them first. Use Recently Archived to restore and repoint archived assets, or delete them permanently, or ask an admin for help.',
- )}
-
+ {slDeleteError && (
+
+ {typeof slDeleteError.total === 'number' &&
+ typeof slDeleteError.archived_count === 'number'
+ ? t(
+ 'Deletion is blocked by %s dependent assets (%s archived). Remove or repoint them before deleting.',
+ slDeleteError.total,
+ slDeleteError.archived_count,
+ )
+ : slDeleteError.error}{' '}
+
+ {t('Recently Archived')}
+
+ >
+ }
+ />
)}
>
}
onConfirm={() => {
handleSemanticLayerDelete(slDeletePreview.item);
}}
- onHide={() => setSlDeletePreview(null)}
+ onHide={() => {
+ slDeleteRequest.current += 1;
+ setSlDeletePreview(null);
+ }}
open
title={
- expect(addDangerToast).toHaveBeenCalledWith(
- `There was an issue deleting orders_semantic: ${reason}`,
- ),
- );
+ const alert = await within(modal).findByRole('alert');
+ expect(alert).toHaveTextContent('25 dependent assets');
+ expect(alert).toHaveTextContent('3 archived');
+ expect(
+ within(alert).getByRole('link', { name: 'Recently Archived' }),
+ ).toHaveAttribute('href', '/archived/');
+ expect(within(modal).getByTestId('delete-modal-input')).toHaveValue('DELETE');
+ expect(addDangerToast).not.toHaveBeenCalled();
+ await userEvent.click(within(modal).getByRole('button', { name: 'Cancel' }));
+ await userEvent.click(screen.getByTestId('dataset-row-delete'));
+ await screen.findByRole('dialog');
+ expect(
+ within(screen.getByRole('dialog')).queryByRole('alert'),
+ ).not.toBeInTheDocument();
+ expect(
+ within(screen.getByRole('dialog')).getByTestId('delete-modal-input'),
+ ).toHaveValue('');
});
test('mixed bulk delete names refused semantic views and their conflict reason', async () => {
diff --git a/superset-frontend/src/pages/DatasetList/index.tsx b/superset-frontend/src/pages/DatasetList/index.tsx
index 8b0aeacb3f25..e206e46bdac5 100644
--- a/superset-frontend/src/pages/DatasetList/index.tsx
+++ b/superset-frontend/src/pages/DatasetList/index.tsx
@@ -16,10 +16,12 @@
* specific language governing permissions and limitations
* under the License.
*/
+import { Alert } from '@apache-superset/core/components';
import { t, tn } from '@apache-superset/core/translation';
import {
getExtensionsRegistry,
SupersetClient,
+ getClientErrorObject,
isFeatureEnabled,
FeatureFlag,
} from '@superset-ui/core';
@@ -628,6 +630,13 @@ const DatasetList: FunctionComponent = ({
null,
);
+ const [svDeleteError, setSVDeleteError] = useState<{
+ error: string;
+ total?: number;
+ archived_count?: number;
+ } | null>(null);
+ const [svDeleting, setSVDeleting] = useState(false);
+ const svDeleteRequest = useRef(0);
const [svCurrentlyDeleting, setSvCurrentlyDeleting] =
useState(null);
@@ -817,26 +826,36 @@ const DatasetList: FunctionComponent = ({
);
const handleSemanticViewDelete = (sv: Dataset) => {
+ svDeleteRequest.current += 1;
+ setSVDeleteError(null);
+ setSVDeleting(false);
setSvCurrentlyDeleting(sv);
};
- const handleSemanticViewDeleteConfirm = () => {
+ const handleSemanticViewDeleteConfirm = async () => {
if (!svCurrentlyDeleting) return;
const { id, table_name: tableName } = svCurrentlyDeleting;
- SupersetClient.delete({
- endpoint: `/api/v1/semantic_view/${id}`,
- }).then(
- () => {
- setSvCurrentlyDeleting(null);
- refreshData();
- addSuccessToast(t('Deleted: %s', tableName));
- },
- createErrorHandler(errMsg =>
+ svDeleteRequest.current += 1;
+ const request = svDeleteRequest.current;
+ setSVDeleting(true);
+ try {
+ await SupersetClient.delete({ endpoint: `/api/v1/semantic_view/${id}` });
+ refreshData();
+ addSuccessToast(t('Deleted: %s', tableName));
+ if (request === svDeleteRequest.current) setSvCurrentlyDeleting(null);
+ } catch (error) {
+ const parsed = await getClientErrorObject(error);
+ if (request !== svDeleteRequest.current) return;
+ if (parsed.status === 409) {
+ setSVDeleteError(parsed);
+ } else {
addDangerToast(
- t('There was an issue deleting %s: %s', tableName, errMsg),
- ),
- ),
- );
+ t('There was an issue deleting %s: %s', tableName, parsed.error),
+ );
+ }
+ } finally {
+ if (request === svDeleteRequest.current) setSVDeleting(false);
+ }
};
const columns = useMemo(
@@ -1592,6 +1611,8 @@ const DatasetList: FunctionComponent = ({
)}
{svCurrentlyDeleting && (
@@ -1600,15 +1621,34 @@ const DatasetList: FunctionComponent = ({
svCurrentlyDeleting.table_name,
)}
-
- {t(
- 'Deletion is blocked while any assets depend on this view, including archived assets. Remove or repoint them first. Use Recently Archived to restore and repoint archived assets, or delete them permanently, or ask an admin for help.',
- )}
-
+ {svDeleteError && (
+
+ {typeof svDeleteError.total === 'number' &&
+ typeof svDeleteError.archived_count === 'number'
+ ? t(
+ 'Deletion is blocked by %s dependent assets (%s archived). Remove or repoint them before deleting.',
+ svDeleteError.total,
+ svDeleteError.archived_count,
+ )
+ : svDeleteError.error}{' '}
+
+ {t('Recently Archived')}
+
+ >
+ }
+ />
+ )}
>
}
onConfirm={handleSemanticViewDeleteConfirm}
- onHide={() => setSvCurrentlyDeleting(null)}
+ onHide={() => {
+ svDeleteRequest.current += 1;
+ setSvCurrentlyDeleting(null);
+ }}
open
title={t('Delete Semantic View?')}
/>
diff --git a/superset/translations/messages.pot b/superset/translations/messages.pot
index 67c57ac440df..4cdd2b3f1d23 100644
--- a/superset/translations/messages.pot
+++ b/superset/translations/messages.pot
@@ -18283,20 +18283,6 @@ msgid ""
" or ask an admin for help."
msgstr ""
-msgid ""
-"Deletion is blocked while any assets depend on these views, including "
-"archived assets. Remove or repoint them first. Use Recently Archived to "
-"restore and repoint archived assets, or delete them permanently, or ask an "
-"admin for help."
-msgstr ""
-
-msgid ""
-"Deletion is blocked while any assets depend on this view, including archived"
-" assets. Remove or repoint them first. Use Recently Archived to restore and "
-"repoint archived assets, or delete them permanently, or ask an admin for "
-"help."
-msgstr ""
-
msgid ""
"Go to Recently Archived to restore and repoint archived assets, or delete "
"them permanently, or ask an admin for help."
@@ -18305,3 +18291,9 @@ msgstr ""
msgid ""
"This semantic layer has no dependent semantic views. This cannot be undone."
msgstr ""
+
+#, python-format
+msgid ""
+"Deletion is blocked by %s dependent assets (%s archived). Remove or repoint "
+"them before deleting."
+msgstr ""
From 2746774f67217b97f8ad3e56ac8cb2dbe3b3d44f Mon Sep 17 00:00:00 2001
From: Mike Bridge
Date: Thu, 22 Oct 2026 12:02:15 -0600
Subject: [PATCH 04/12] fix(semantic): name the assets that block a semantic
layer or view delete
The deletion refusal already returns up to 20 visible dependents, but the
dialogs showed counts only, and archived dependents were never named, even
to admins, because visibility was computed over live rows only.
Name archived charts and dashboards to the audience Recently Archived
shows them to (admins and the asset's editors), flag each dependent as
archived, and list the dependents in the layer and view delete dialogs
with links for live assets, an "and N more" overflow, and an "ask an
admin" hint when some dependents are hidden. Also replace "Semantic
source" in the refusal, bold the view name and add the trash icon to the
view dialog, and align "Recently Archived" capitalisation.
Co-Authored-By: Claude Opus 5.5
---
.../docs/using-superset/recently-archived.mdx | 11 +-
.../semanticLayers/SemanticDeleteBlockers.tsx | 115 ++++++++++++++++++
.../DatabaseList.semanticLayerDelete.test.tsx | 2 +-
.../src/pages/DatabaseList/index.tsx | 37 ++----
.../DatasetList/DatasetList.behavior.test.tsx | 47 +++++++
.../src/pages/DatasetList/index.tsx | 46 +++----
superset/commands/semantic_layer/delete.py | 53 +++++---
.../commands/semantic_layer/exceptions.py | 8 +-
superset/semantic_layers/api.py | 30 +++--
superset/translations/messages.pot | 33 +++--
.../commands/semantic_layer/delete_test.py | 89 +++++++++++++-
tests/unit_tests/semantic_layers/api_test.py | 4 +-
12 files changed, 365 insertions(+), 110 deletions(-)
create mode 100644 superset-frontend/src/features/semanticLayers/SemanticDeleteBlockers.tsx
diff --git a/docs/docs/using-superset/recently-archived.mdx b/docs/docs/using-superset/recently-archived.mdx
index da5c0b1935ac..755ec34c34d2 100644
--- a/docs/docs/using-superset/recently-archived.mdx
+++ b/docs/docs/using-superset/recently-archived.mdx
@@ -88,12 +88,15 @@ filter cleanup; the response warns that the dashboard's filters need review.
Archived charts and dashboards still block deletion of semantic views and layers
they depend on. The refusal includes the number of archived dependents. Use
**Recently Archived** to restore and repoint them, or permanently delete them,
-before deleting the semantic source. If you cannot access those assets, ask an
-admin for help.
+before deleting the semantic layer or view. If you cannot access those assets,
+ask an admin for help.
When deleting a semantic layer or view, a dependency refusal stays in the
-confirmation dialog with the total and archived counts and a link to Recently
-Archived. The typed confirmation is preserved so you can retry after removing
+confirmation dialog with the total and archived counts, the names of up to 20
+blocking assets you can see, and a link to Recently Archived. Archived blockers
+are named only to users who could restore them from Recently Archived (admins
+and the asset's editors); the dialog counts the others and suggests asking an
+admin. The typed confirmation is preserved so you can retry after removing
or repointing the dependents. Dependencies are checked when you confirm deletion,
not before you type the confirmation.
diff --git a/superset-frontend/src/features/semanticLayers/SemanticDeleteBlockers.tsx b/superset-frontend/src/features/semanticLayers/SemanticDeleteBlockers.tsx
new file mode 100644
index 000000000000..ccea4b03ef50
--- /dev/null
+++ b/superset-frontend/src/features/semanticLayers/SemanticDeleteBlockers.tsx
@@ -0,0 +1,115 @@
+/**
+ * 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.
+ */
+import { t } from '@apache-superset/core/translation';
+import { Alert } from '@apache-superset/core/components';
+import { ensureAppRoot } from 'src/utils/navigationUtils';
+
+interface Dependent {
+ type: string;
+ id: number;
+ name: string;
+ archived?: boolean;
+}
+
+export interface SemanticDeleteError {
+ error: string;
+ total?: number;
+ archived_count?: number;
+ inaccessible_count?: number;
+ dependents?: Dependent[];
+}
+
+const typeLabel = (type: string): string => {
+ const labels: Record = {
+ chart: t('chart'),
+ dashboard: t('dashboard'),
+ alert: t('alert'),
+ report: t('report'),
+ };
+ return labels[type] ?? type;
+};
+
+const LIVE_LINKS: Record string> = {
+ chart: id => `/explore/?slice_id=${id}`,
+ dashboard: id => `/superset/dashboard/${id}/`,
+};
+
+/** The refusal shown inside a semantic layer or view delete dialog. */
+export default function SemanticDeleteBlockers({
+ error,
+}: {
+ error: SemanticDeleteError;
+}) {
+ const dependents = error.dependents ?? [];
+ const hidden = error.inaccessible_count ?? 0;
+ const unlisted = (error.total ?? 0) - hidden - dependents.length;
+ const counted =
+ typeof error.total === 'number' && typeof error.archived_count === 'number';
+ const summary = counted
+ ? t(
+ 'Deletion is blocked by %s dependent assets (%s archived). Remove or repoint them before deleting.',
+ error.total,
+ error.archived_count,
+ )
+ : error.error;
+ // Archived assets have no page to open; Recently Archived lists them.
+ const href = (item: Dependent): string | undefined =>
+ item.archived ? undefined : LIVE_LINKS[item.type]?.(item.id);
+ return (
+
+ {dependents.length > 0 && (
+
+ {dependents.map(item => (
+ -
+ {href(item) ? (
+
+ {item.name}
+
+ ) : (
+ item.name
+ )}{' '}
+ (
+ {item.archived
+ ? t('archived %s', typeLabel(item.type))
+ : typeLabel(item.type)}
+ )
+
+ ))}
+
+ )}
+ {unlisted > 0 && {t('and %s more', unlisted)}
}
+ {hidden > 0 && (
+
+ {t(
+ "%s dependent asset(s) you can't see. Ask an admin for help.",
+ hidden,
+ )}
+
+ )}
+ {t('Recently Archived')}
+ >
+ }
+ />
+ );
+}
diff --git a/superset-frontend/src/pages/DatabaseList/DatabaseList.semanticLayerDelete.test.tsx b/superset-frontend/src/pages/DatabaseList/DatabaseList.semanticLayerDelete.test.tsx
index 540f9c7b3706..5ab00a8e131c 100644
--- a/superset-frontend/src/pages/DatabaseList/DatabaseList.semanticLayerDelete.test.tsx
+++ b/superset-frontend/src/pages/DatabaseList/DatabaseList.semanticLayerDelete.test.tsx
@@ -256,7 +256,7 @@ test('a genuinely empty layer says so instead of warning about nonexistent views
expect(
within(dialog).getByText(
- 'This semantic layer has no dependent semantic views. This cannot be undone.',
+ 'This semantic layer has no semantic views. This cannot be undone.',
),
).toBeInTheDocument();
expect(
diff --git a/superset-frontend/src/pages/DatabaseList/index.tsx b/superset-frontend/src/pages/DatabaseList/index.tsx
index 27a9c2715376..30208978babe 100644
--- a/superset-frontend/src/pages/DatabaseList/index.tsx
+++ b/superset-frontend/src/pages/DatabaseList/index.tsx
@@ -16,7 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Alert } from '@apache-superset/core/components';
import { t, tn } from '@apache-superset/core/translation';
import {
getExtensionsRegistry,
@@ -74,6 +73,9 @@ import { DatabaseObject } from 'src/features/databases/types';
import { QueryObjectColumns } from 'src/views/CRUD/types';
import { WIDER_DROPDOWN_WIDTH } from 'src/components/ListView/utils';
import { ModalTitleWithIcon } from 'src/components/ModalTitleWithIcon';
+import SemanticDeleteBlockers, {
+ SemanticDeleteError,
+} from 'src/features/semanticLayers/SemanticDeleteBlockers';
import type User from 'src/types/User';
import {
databaseLabel,
@@ -152,9 +154,7 @@ function SemanticLayerCascadeWarning({
if (preview.dependentViewCount === 0) {
return (
- {t(
- 'This semantic layer has no dependent semantic views. This cannot be undone.',
- )}
+ {t('This semantic layer has no semantic views. This cannot be undone.')}
);
}
@@ -424,11 +424,8 @@ function DatabaseList({
const [slCurrentlyEditing, setSlCurrentlyEditing] = useState(
null,
);
- const [slDeleteError, setSLDeleteError] = useState<{
- error: string;
- total?: number;
- archived_count?: number;
- } | null>(null);
+ const [slDeleteError, setSLDeleteError] =
+ useState(null);
const [slDeleting, setSLDeleting] = useState(false);
const slDeleteRequest = useRef(0);
const [slDeletePreview, setSlDeletePreview] =
@@ -1215,25 +1212,7 @@ function DatabaseList({
{slDeleteError && (
-
- {typeof slDeleteError.total === 'number' &&
- typeof slDeleteError.archived_count === 'number'
- ? t(
- 'Deletion is blocked by %s dependent assets (%s archived). Remove or repoint them before deleting.',
- slDeleteError.total,
- slDeleteError.archived_count,
- )
- : slDeleteError.error}{' '}
-
- {t('Recently Archived')}
-
- >
- }
- />
+
)}
>
}
@@ -1273,7 +1252,7 @@ function DatabaseList({
databaseCurrentlyDeleting.datasets.count,
)}{' '}
{t(
- 'Delete or move active datasets. To remove archived datasets, open Recently archived and select Delete permanently.',
+ 'Delete or move active datasets. To remove archived datasets, open Recently Archived and select Delete permanently.',
)}
) : (
diff --git a/superset-frontend/src/pages/DatasetList/DatasetList.behavior.test.tsx b/superset-frontend/src/pages/DatasetList/DatasetList.behavior.test.tsx
index 9634be0b6e76..33d8f33e4532 100644
--- a/superset-frontend/src/pages/DatasetList/DatasetList.behavior.test.tsx
+++ b/superset-frontend/src/pages/DatasetList/DatasetList.behavior.test.tsx
@@ -121,6 +121,53 @@ test('single semantic view delete displays the counted dependency conflict', asy
).toHaveValue('');
});
+test('single semantic view delete names its blockers, including archived ones', async () => {
+ const view = {
+ ...mockDatasets[0],
+ id: 99,
+ table_name: 'orders_semantic',
+ kind: 'semantic_view',
+ };
+ mockDatasetListEndpoints({ result: [view], count: 1 });
+ fetchMock.delete('glob:*/api/v1/semantic_view/99', {
+ status: 409,
+ body: {
+ message: 'blocked',
+ total: 5,
+ archived_count: 1,
+ inaccessible_count: 2,
+ dependents: [
+ { type: 'chart', id: 7, name: 'Revenue chart', archived: false },
+ { type: 'dashboard', id: 8, name: 'Old board', archived: true },
+ ],
+ },
+ });
+ renderDatasetList(mockAdminUser, {
+ addDangerToast: jest.fn(),
+ addSuccessToast: jest.fn(),
+ });
+ await screen.findByText(view.table_name);
+ await userEvent.click(screen.getByTestId('dataset-row-delete'));
+ const modal = await screen.findByRole('dialog');
+ await userEvent.type(
+ within(modal).getByTestId('delete-modal-input'),
+ 'DELETE',
+ );
+ await userEvent.click(within(modal).getByRole('button', { name: 'Delete' }));
+ const alert = await within(modal).findByRole('alert');
+ expect(
+ within(alert).getByRole('link', { name: 'Revenue chart' }),
+ ).toHaveAttribute('href', '/explore/?slice_id=7');
+ expect(alert).toHaveTextContent('Old board (archived dashboard)');
+ expect(
+ within(alert).queryByRole('link', { name: 'Old board' }),
+ ).not.toBeInTheDocument();
+ expect(alert).toHaveTextContent('and 1 more');
+ expect(alert).toHaveTextContent(
+ "2 dependent asset(s) you can't see. Ask an admin for help.",
+ );
+});
+
test('mixed bulk delete names refused semantic views and their conflict reason', async () => {
const dataset = mockDatasets[0];
const semanticView = {
diff --git a/superset-frontend/src/pages/DatasetList/index.tsx b/superset-frontend/src/pages/DatasetList/index.tsx
index e206e46bdac5..565368090946 100644
--- a/superset-frontend/src/pages/DatasetList/index.tsx
+++ b/superset-frontend/src/pages/DatasetList/index.tsx
@@ -16,7 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Alert } from '@apache-superset/core/components';
import { t, tn } from '@apache-superset/core/translation';
import {
getExtensionsRegistry,
@@ -110,6 +109,10 @@ import {
import { useSelector } from 'react-redux';
import { QueryObjectColumns } from 'src/views/CRUD/types';
import { WIDER_DROPDOWN_WIDTH } from 'src/components/ListView/utils';
+import { ModalTitleWithIcon } from 'src/components/ModalTitleWithIcon';
+import SemanticDeleteBlockers, {
+ SemanticDeleteError,
+} from 'src/features/semanticLayers/SemanticDeleteBlockers';
import type {
BootstrapData,
UserWithPermissionsAndRoles,
@@ -630,11 +633,8 @@ const DatasetList: FunctionComponent = ({
null,
);
- const [svDeleteError, setSVDeleteError] = useState<{
- error: string;
- total?: number;
- archived_count?: number;
- } | null>(null);
+ const [svDeleteError, setSVDeleteError] =
+ useState(null);
const [svDeleting, setSVDeleting] = useState(false);
const svDeleteRequest = useRef(0);
const [svCurrentlyDeleting, setSvCurrentlyDeleting] =
@@ -1616,31 +1616,12 @@ const DatasetList: FunctionComponent = ({
description={
<>
- {t(
- 'Are you sure you want to delete %s? This cannot be undone.',
- svCurrentlyDeleting.table_name,
- )}
+ {t('Are you sure you want to delete')}{' '}
+ {svCurrentlyDeleting.table_name}?{' '}
+ {t('This cannot be undone.')}
{svDeleteError && (
-
- {typeof svDeleteError.total === 'number' &&
- typeof svDeleteError.archived_count === 'number'
- ? t(
- 'Deletion is blocked by %s dependent assets (%s archived). Remove or repoint them before deleting.',
- svDeleteError.total,
- svDeleteError.archived_count,
- )
- : svDeleteError.error}{' '}
-
- {t('Recently Archived')}
-
- >
- }
- />
+
)}
>
}
@@ -1650,7 +1631,12 @@ const DatasetList: FunctionComponent = ({
setSvCurrentlyDeleting(null);
}}
open
- title={t('Delete Semantic View?')}
+ title={
+ }
+ title={t('Delete Semantic View?')}
+ />
+ }
/>
)}
{datasetCurrentlyEditing && (
diff --git a/superset/commands/semantic_layer/delete.py b/superset/commands/semantic_layer/delete.py
index 17f150d52365..0e34c30711ee 100644
--- a/superset/commands/semantic_layer/delete.py
+++ b/superset/commands/semantic_layer/delete.py
@@ -26,7 +26,7 @@
from sqlalchemy.exc import SQLAlchemyError
from superset import db, security_manager
-from superset.charts.filters import ChartFilter
+from superset.charts.filters import ChartDeletedStateFilter, ChartFilter
from superset.commands.base import BaseCommand
from superset.commands.semantic_layer.exceptions import (
SemanticDeleteDependentsError,
@@ -38,8 +38,12 @@
SemanticViewNotFoundError,
)
from superset.commands.utils import current_user_can_modify_object
+from superset.constants import SKIP_VISIBILITY_FILTER_CLASSES
from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO
-from superset.dashboards.filters import DashboardAccessFilter
+from superset.dashboards.filters import (
+ DashboardAccessFilter,
+ DashboardDeletedStateFilter,
+)
from superset.exceptions import SupersetSecurityException
from superset.models.dashboard import Dashboard, dashboard_slices
from superset.models.slice import Slice
@@ -51,6 +55,11 @@
logger = logging.getLogger(__name__)
_DEPENDENT_LIMIT: int = 20
+# Visibility subqueries see archived charts and dashboards; the restore-audience
+# filter then decides which of them may be named.
+_INCLUDE_ARCHIVED: dict[str, set[type]] = {
+ SKIP_VISIBILITY_FILTER_CLASSES: {Slice, Dashboard}
+}
def _semantic_target_matches(target: object, view_ids: set[int]) -> bool:
@@ -179,8 +188,16 @@ def _dependent_assets(
visible_chart_ids: sa.Select = (
cast(
sa.Select,
- ChartFilter("id", SQLAInterface(Slice, db.session))
- .apply(db.session.query(Slice.id).filter(Slice.deleted_at.is_(None)), None)
+ # Archived rows are named only to their Recently Archived audience.
+ ChartDeletedStateFilter( # pylint: disable=protected-access
+ "id", SQLAInterface(Slice, db.session)
+ )
+ ._scope_to_restore_audience(
+ ChartFilter("id", SQLAInterface(Slice, db.session)).apply(
+ db.session.query(Slice.id), None
+ ),
+ "include",
+ )
.statement,
)
if security_manager.can_access("can_read", "Chart")
@@ -189,10 +206,14 @@ def _dependent_assets(
visible_dashboard_ids: sa.Select = (
cast(
sa.Select,
- DashboardAccessFilter("id", SQLAInterface(Dashboard, db.session))
- .apply(
- db.session.query(Dashboard.id).filter(Dashboard.deleted_at.is_(None)),
- None,
+ DashboardDeletedStateFilter( # pylint: disable=protected-access
+ "id", SQLAInterface(Dashboard, db.session)
+ )
+ ._scope_to_restore_audience(
+ DashboardAccessFilter("id", SQLAInterface(Dashboard, db.session)).apply(
+ db.session.query(Dashboard.id), None
+ ),
+ "include",
)
.statement,
)
@@ -231,7 +252,8 @@ def _dependent_assets(
sa.func.count(),
sa.func.sum(sa.case((visibility, 1), else_=0)),
sa.func.sum(sa.case((dependents.c.archived, 1), else_=0)),
- ).select_from(dependents)
+ ).select_from(dependents),
+ execution_options=_INCLUDE_ARCHIVED,
).one(),
)
total: int = int(counts[0])
@@ -239,10 +261,10 @@ def _dependent_assets(
return 0, [], 0, 0
visible_total: int = int(counts[1] or 0)
visible: sa.Subquery = sa.select(dependents).where(visibility).subquery()
- rows: list[tuple[str, int, str]] = cast(
- list[tuple[str, int, str]],
+ rows: list[tuple[str, int, str, bool]] = cast(
+ list[tuple[str, int, str, bool]],
db.session.execute(
- sa.select(visible.c.type, visible.c.id, visible.c.name)
+ sa.select(visible.c.type, visible.c.id, visible.c.name, visible.c.archived)
.order_by(
sa.case(
(visible.c.type == "chart", 0),
@@ -252,14 +274,15 @@ def _dependent_assets(
visible.c.type,
visible.c.id,
)
- .limit(_DEPENDENT_LIMIT)
+ .limit(_DEPENDENT_LIMIT),
+ execution_options=_INCLUDE_ARCHIVED,
).all(),
)
return (
total,
[
- {"type": asset_type, "id": asset_id, "name": name}
- for asset_type, asset_id, name in rows
+ {"type": asset_type, "id": asset_id, "name": name, "archived": archived}
+ for asset_type, asset_id, name, archived in rows
],
total - visible_total,
int(counts[2] or 0),
diff --git a/superset/commands/semantic_layer/exceptions.py b/superset/commands/semantic_layer/exceptions.py
index 3aa2690d87b0..f54e121cd182 100644
--- a/superset/commands/semantic_layer/exceptions.py
+++ b/superset/commands/semantic_layer/exceptions.py
@@ -93,16 +93,14 @@ def __init__(
self.dependents: list[dict[str, str | int]] = dependents
self.inaccessible_count: int = inaccessible_count
message: str = ngettext(
- "Semantic source is used by %(count)s dependent asset "
+ "This semantic layer or view is used by %(count)s dependent asset "
"and cannot be deleted.",
- "Semantic source is used by %(count)s dependent assets "
+ "This semantic layer or view is used by %(count)s dependent assets "
"and cannot be deleted.",
total,
count=total,
)
- message += " " + _(
- "Remove or repoint these assets before deleting the semantic source."
- )
+ message += " " + _("Remove or repoint these assets before deleting it.")
if archived_count:
message += " " + ngettext(
"%(count)s dependent asset is archived.",
diff --git a/superset/semantic_layers/api.py b/superset/semantic_layers/api.py
index 8346631d8675..0b6e7d07de9a 100644
--- a/superset/semantic_layers/api.py
+++ b/superset/semantic_layers/api.py
@@ -535,7 +535,7 @@ def delete(self, pk: int) -> Response:
maxItems: 20
items:
type: object
- required: [type, id, name]
+ required: [type, id, name, archived]
properties:
type:
type: string
@@ -544,13 +544,17 @@ def delete(self, pk: int) -> Response:
type: integer
name:
type: string
+ archived:
+ type: boolean
inaccessible_count:
type: integer
archived_count:
type: integer
description: >-
- Archived dependents are included in inaccessible_count;
- these counts overlap and must not be added together.
+ Archived dependents are named only to users who could
+ restore them from Recently Archived; the others are
+ included in inaccessible_count. The counts overlap and
+ must not be added together.
422:
$ref: '#/components/responses/422'
"""
@@ -639,7 +643,7 @@ def bulk_delete(self, **kwargs: Any) -> Response:
maxItems: 20
items:
type: object
- required: [type, id, name]
+ required: [type, id, name, archived]
properties:
type:
type: string
@@ -648,13 +652,17 @@ def bulk_delete(self, **kwargs: Any) -> Response:
type: integer
name:
type: string
+ archived:
+ type: boolean
inaccessible_count:
type: integer
archived_count:
type: integer
description: >-
- Archived dependents are included in inaccessible_count;
- these counts overlap and must not be added together.
+ Archived dependents are named only to users who could
+ restore them from Recently Archived; the others are
+ included in inaccessible_count. The counts overlap and
+ must not be added together.
422:
$ref: '#/components/responses/422'
"""
@@ -1173,7 +1181,7 @@ def delete(self, uuid: str) -> FlaskResponse:
maxItems: 20
items:
type: object
- required: [type, id, name]
+ required: [type, id, name, archived]
properties:
type:
type: string
@@ -1182,13 +1190,17 @@ def delete(self, uuid: str) -> FlaskResponse:
type: integer
name:
type: string
+ archived:
+ type: boolean
inaccessible_count:
type: integer
archived_count:
type: integer
description: >-
- Archived dependents are included in inaccessible_count;
- these counts overlap and must not be added together.
+ Archived dependents are named only to users who could
+ restore them from Recently Archived; the others are
+ included in inaccessible_count. The counts overlap and
+ must not be added together.
422:
$ref: '#/components/responses/422'
"""
diff --git a/superset/translations/messages.pot b/superset/translations/messages.pot
index 4cdd2b3f1d23..d086d7150af2 100644
--- a/superset/translations/messages.pot
+++ b/superset/translations/messages.pot
@@ -5107,7 +5107,7 @@ msgstr ""
msgid "Delete item"
msgstr ""
-msgid "Delete or move active datasets. To remove archived datasets, open Recently archived and select Delete permanently."
+msgid "Delete or move active datasets. To remove archived datasets, open Recently Archived and select Delete permanently."
msgstr ""
msgid "Delete permanently"
@@ -12393,8 +12393,8 @@ msgid "Semantic layer updated"
msgstr ""
#, python-format
-msgid "Semantic source is used by %(count)s dependent asset and cannot be deleted."
-msgid_plural "Semantic source is used by %(count)s dependent assets and cannot be deleted."
+msgid "This semantic layer or view is used by %(count)s dependent asset and cannot be deleted."
+msgid_plural "This semantic layer or view is used by %(count)s dependent assets and cannot be deleted."
msgstr[0] ""
msgstr[1] ""
@@ -18251,10 +18251,6 @@ msgstr[0] ""
msgstr[1] ""
-#, python-format
-msgid "Are you sure you want to delete %s? This cannot be undone."
-msgstr ""
-
msgid ""
"Deleting this semantic layer also permanently deletes any semantic views it "
@@ -18262,7 +18258,7 @@ msgid ""
msgstr ""
-msgid "Remove or repoint these assets before deleting the semantic source."
+msgid "Remove or repoint these assets before deleting it."
msgstr ""
@@ -18288,8 +18284,7 @@ msgid ""
"them permanently, or ask an admin for help."
msgstr ""
-msgid ""
-"This semantic layer has no dependent semantic views. This cannot be undone."
+msgid "This semantic layer has no semantic views. This cannot be undone."
msgstr ""
#, python-format
@@ -18297,3 +18292,21 @@ msgid ""
"Deletion is blocked by %s dependent assets (%s archived). Remove or repoint "
"them before deleting."
msgstr ""
+
+msgid "This cannot be undone."
+msgstr ""
+
+msgid "alert"
+msgstr ""
+
+#, python-format
+msgid "archived %s"
+msgstr ""
+
+#, python-format
+msgid "and %s more"
+msgstr ""
+
+#, python-format
+msgid "%s dependent asset(s) you can't see. Ask an admin for help."
+msgstr ""
diff --git a/tests/unit_tests/commands/semantic_layer/delete_test.py b/tests/unit_tests/commands/semantic_layer/delete_test.py
index 8c094a62f98d..3469133553bd 100644
--- a/tests/unit_tests/commands/semantic_layer/delete_test.py
+++ b/tests/unit_tests/commands/semantic_layer/delete_test.py
@@ -264,7 +264,7 @@ def test_bulk_delete_semantic_views_refuses_all_on_one_dependency(
@pytest.mark.parametrize("orm_execution", [False, True])
-def test_semantic_delete_counts_archived_dependents_without_listing_them(
+def test_semantic_delete_counts_and_names_archived_dependents(
session: Session, mocker: MockerFixture, orm_execution: bool
) -> None:
"""Charts, dashboard membership and active schedules form the dependency set."""
@@ -363,9 +363,13 @@ def test_semantic_delete_counts_archived_dependents_without_listing_them(
assert total == 6
assert archived_count == 1
- assert inaccessible_count == 1
+ assert inaccessible_count == 0
+ assert {item["name"] for item in dependents if item["archived"]} == {
+ "Deleted chart"
+ }
assert {item["name"] for item in dependents} == {
"Live chart",
+ "Deleted chart",
"Live dashboard",
"Live alert",
"Live report",
@@ -387,7 +391,7 @@ def test_semantic_delete_counts_archived_dependents_without_listing_them(
assert capped_total == 6
assert len(capped_dependents) == 2
assert [item["type"] for item in capped_dependents] == ["chart", "chart"]
- assert capped_inaccessible_count == 1
+ assert capped_inaccessible_count == 0
mocker.patch(
"superset.commands.semantic_layer.delete.ChartFilter.apply",
return_value=session.query(Slice.id).filter(sa.false()),
@@ -547,13 +551,14 @@ def metadata(source_type: str | None, dataset_id: int | str) -> str:
)
assert total == 6
assert archived_count == 1
- assert inaccessible_count == 1
+ assert inaccessible_count == 0
assert {item["name"] for item in dependents} == {
"Typed integer",
"Typed string",
"Filter and chart",
"Member chart",
"Native report",
+ "Deleted",
}
dao: MagicMock = mocker.patch(
"superset.commands.semantic_layer.delete.SemanticViewDAO"
@@ -654,7 +659,7 @@ def test_semantic_layer_delete_finds_dashboard_target(
DeleteSemanticLayerCommand(str(layer_uuid)).run()
assert exc_info.value.total == 1
assert exc_info.value.dependents == [
- {"type": "dashboard", "id": 741, "name": "Filter-only"}
+ {"type": "dashboard", "id": 741, "name": "Filter-only", "archived": False}
]
dao.delete.assert_not_called()
finally:
@@ -1129,3 +1134,77 @@ def test_archived_dashboard_blocks_delete_with_orm_listener(
assert exc_info.value.total == (2 if path == "membership" else 1)
assert exc_info.value.archived_count == 1
assert exc_info.value.dependents == []
+
+
+@pytest.mark.parametrize(
+ ("viewer", "named"),
+ [
+ ("admin", {"Live chart": False, "Archived chart": True}),
+ ("editor", {"Live chart": False, "Archived chart": True}),
+ ("reader", {"Live chart": False}),
+ ],
+)
+def test_semantic_delete_names_archived_dependents_to_restore_audience(
+ session: Session, mocker: MockerFixture, viewer: str, named: dict[str, bool]
+) -> None:
+ """Archived blockers are named to whoever Recently Archived would show them."""
+ from datetime import datetime
+
+ from superset.commands.semantic_layer.delete import _dependent_assets
+ from superset.models.slice import Slice
+ from superset.subjects.models import chart_editors, Subject
+
+ Slice.metadata.create_all(session.get_bind())
+ session.execute(Subject.__table__.insert().values(id=1, label="editor", type=1))
+ session.execute(
+ Slice.__table__.insert(),
+ [
+ {
+ "id": 961,
+ "slice_name": "Live chart",
+ "datasource_type": "semantic_view",
+ "datasource_id": 42,
+ "deleted_at": None,
+ },
+ {
+ "id": 962,
+ "slice_name": "Archived chart",
+ "datasource_type": "semantic_view",
+ "datasource_id": 42,
+ "deleted_at": datetime(2026, 1, 1),
+ },
+ ],
+ )
+ session.execute(chart_editors.insert().values(chart_id=962, subject_id=1))
+ # Prove the session listener is active; do not substitute Connection.execute.
+ assert session.scalar(sa.select(Slice.id).where(Slice.id == 962)) is None
+ mocker.patch("superset.security_manager.can_access", return_value=True)
+ for access_filter in (
+ "ChartFilter",
+ "DashboardAccessFilter",
+ "ReportScheduleFilter",
+ ):
+ mocker.patch(
+ f"superset.commands.semantic_layer.delete.{access_filter}.apply",
+ side_effect=lambda query, value: query,
+ )
+ mocker.patch("superset.security_manager.is_admin", return_value=viewer == "admin")
+ mocker.patch("superset.views.filters.get_user_id", return_value=7)
+ mocker.patch(
+ "superset.subjects.utils.get_user_subject_ids_subquery",
+ return_value=sa.select(sa.literal(1 if viewer == "editor" else 2)),
+ )
+
+ total: int
+ dependents: list[dict[str, str | int | bool]]
+ inaccessible_count: int
+ archived_count: int
+ total, dependents, inaccessible_count, archived_count = _dependent_assets(
+ sa.select(sa.literal(42))
+ )
+
+ assert (total, archived_count) == (2, 1)
+ assert {str(item["name"]): item["archived"] for item in dependents} == named
+ assert inaccessible_count == 2 - len(named)
+ # The bypass is per statement: later queries still hide archived charts.
+ assert session.scalar(sa.select(Slice.id).where(Slice.id == 962)) is None
diff --git a/tests/unit_tests/semantic_layers/api_test.py b/tests/unit_tests/semantic_layers/api_test.py
index 6115f2a607c8..ebe0987a8bf0 100644
--- a/tests/unit_tests/semantic_layers/api_test.py
+++ b/tests/unit_tests/semantic_layers/api_test.py
@@ -1052,8 +1052,8 @@ def test_semantic_delete_reports_dependents_as_conflict(
assert response.json["dependents"] == [dependent]
assert response.json["inaccessible_count"] == 24
assert response.json["message"] == (
- "Semantic source is used by 25 dependent assets and cannot be deleted. "
- "Remove or repoint these assets before deleting the semantic source. "
+ "This semantic layer or view is used by 25 dependent assets and cannot "
+ "be deleted. Remove or repoint these assets before deleting it. "
"3 dependent assets are archived. "
"Go to Recently Archived to restore and repoint archived assets, "
"or delete them permanently, or ask an admin for help."
From 546d456b6d0e1ae66fdf1ecda1dffd64cb61328a Mon Sep 17 00:00:00 2001
From: Mike Bridge
Date: Thu, 22 Oct 2026 12:02:42 -0600
Subject: [PATCH 05/12] docs(semantic): regenerate OpenAPI for named archived
dependents
Co-Authored-By: Claude Opus 5.5
---
docs/static/resources/openapi.json | 24 ++++++++++++++++++------
1 file changed, 18 insertions(+), 6 deletions(-)
diff --git a/docs/static/resources/openapi.json b/docs/static/resources/openapi.json
index e4e1d91dd171..4c96f13c0bec 100644
--- a/docs/static/resources/openapi.json
+++ b/docs/static/resources/openapi.json
@@ -34264,12 +34264,15 @@
"schema": {
"properties": {
"archived_count": {
- "description": "Archived dependents are included in inaccessible_count; these counts overlap and must not be added together.",
+ "description": "Archived dependents are named only to users who could restore them from Recently Archived; the others are included in inaccessible_count. The counts overlap and must not be added together.",
"type": "integer"
},
"dependents": {
"items": {
"properties": {
+ "archived": {
+ "type": "boolean"
+ },
"id": {
"type": "integer"
},
@@ -34289,7 +34292,8 @@
"required": [
"type",
"id",
- "name"
+ "name",
+ "archived"
],
"type": "object"
},
@@ -34654,12 +34658,15 @@
"schema": {
"properties": {
"archived_count": {
- "description": "Archived dependents are included in inaccessible_count; these counts overlap and must not be added together.",
+ "description": "Archived dependents are named only to users who could restore them from Recently Archived; the others are included in inaccessible_count. The counts overlap and must not be added together.",
"type": "integer"
},
"dependents": {
"items": {
"properties": {
+ "archived": {
+ "type": "boolean"
+ },
"id": {
"type": "integer"
},
@@ -34679,7 +34686,8 @@
"required": [
"type",
"id",
- "name"
+ "name",
+ "archived"
],
"type": "object"
},
@@ -34821,12 +34829,15 @@
"schema": {
"properties": {
"archived_count": {
- "description": "Archived dependents are included in inaccessible_count; these counts overlap and must not be added together.",
+ "description": "Archived dependents are named only to users who could restore them from Recently Archived; the others are included in inaccessible_count. The counts overlap and must not be added together.",
"type": "integer"
},
"dependents": {
"items": {
"properties": {
+ "archived": {
+ "type": "boolean"
+ },
"id": {
"type": "integer"
},
@@ -34846,7 +34857,8 @@
"required": [
"type",
"id",
- "name"
+ "name",
+ "archived"
],
"type": "object"
},
From 5bb9d45f7e24fd419b1c92d01faad6d29219b423 Mon Sep 17 00:00:00 2001
From: Mike Bridge
Date: Thu, 22 Oct 2026 13:36:25 -0600
Subject: [PATCH 06/12] fix(semantic): name archived blockers without widening
live visibility
Bypassing the soft-delete listener for charts and dashboards across the
whole dependents statement also changed the live access filters: the
dashboard filter's chart join started counting archived charts. Keep the
live filters as they were and add archived rows through a separate
audience read from the editor tables, which is the Recently Archived
audience (admins see every archived row, others the rows they edit).
Co-Authored-By: Claude Opus 5.5
---
superset/commands/semantic_layer/delete.py | 82 +++++++++++--------
.../commands/semantic_layer/delete_test.py | 20 ++++-
2 files changed, 63 insertions(+), 39 deletions(-)
diff --git a/superset/commands/semantic_layer/delete.py b/superset/commands/semantic_layer/delete.py
index 0e34c30711ee..c17508bda3c5 100644
--- a/superset/commands/semantic_layer/delete.py
+++ b/superset/commands/semantic_layer/delete.py
@@ -26,7 +26,7 @@
from sqlalchemy.exc import SQLAlchemyError
from superset import db, security_manager
-from superset.charts.filters import ChartDeletedStateFilter, ChartFilter
+from superset.charts.filters import ChartFilter
from superset.commands.base import BaseCommand
from superset.commands.semantic_layer.exceptions import (
SemanticDeleteDependentsError,
@@ -38,28 +38,22 @@
SemanticViewNotFoundError,
)
from superset.commands.utils import current_user_can_modify_object
-from superset.constants import SKIP_VISIBILITY_FILTER_CLASSES
from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO
-from superset.dashboards.filters import (
- DashboardAccessFilter,
- DashboardDeletedStateFilter,
-)
+from superset.dashboards.filters import DashboardAccessFilter
from superset.exceptions import SupersetSecurityException
from superset.models.dashboard import Dashboard, dashboard_slices
from superset.models.slice import Slice
from superset.reports.filters import ReportScheduleFilter
from superset.reports.models import ReportSchedule
from superset.semantic_layers.models import SemanticLayer, SemanticView
+from superset.subjects.models import chart_editors, dashboard_editors
+from superset.subjects.utils import get_user_subject_ids_subquery
from superset.utils import json
+from superset.utils.core import get_user_id
from superset.utils.decorators import on_error, transaction
logger = logging.getLogger(__name__)
_DEPENDENT_LIMIT: int = 20
-# Visibility subqueries see archived charts and dashboards; the restore-audience
-# filter then decides which of them may be named.
-_INCLUDE_ARCHIVED: dict[str, set[type]] = {
- SKIP_VISIBILITY_FILTER_CLASSES: {Slice, Dashboard}
-}
def _semantic_target_matches(target: object, view_ids: set[int]) -> bool:
@@ -126,6 +120,22 @@ def _dashboard_target_ids(view_ids: sa.Select) -> set[int]:
return matched_ids
+def _archived_audience_ids(table: sa.Table, editors: sa.Table, fk: str) -> sa.Select:
+ """Archived ids the user could restore from Recently Archived.
+
+ Mirrors ``BaseDeletedStateFilter``: admins see every archived row, others
+ only rows they edit. Core tables keep the soft-delete listener out of it.
+ """
+ if security_manager.is_admin():
+ return sa.select(table.c.id)
+ user_id: int | None = get_user_id()
+ if not user_id:
+ return sa.select(table.c.id).where(sa.false())
+ return sa.select(editors.c[fk]).where(
+ editors.c.subject_id.in_(get_user_subject_ids_subquery(user_id))
+ )
+
+
def _dependent_assets(
view_ids: sa.Select,
) -> tuple[int, list[dict[str, str | int]], int, int]:
@@ -188,16 +198,8 @@ def _dependent_assets(
visible_chart_ids: sa.Select = (
cast(
sa.Select,
- # Archived rows are named only to their Recently Archived audience.
- ChartDeletedStateFilter( # pylint: disable=protected-access
- "id", SQLAInterface(Slice, db.session)
- )
- ._scope_to_restore_audience(
- ChartFilter("id", SQLAInterface(Slice, db.session)).apply(
- db.session.query(Slice.id), None
- ),
- "include",
- )
+ ChartFilter("id", SQLAInterface(Slice, db.session))
+ .apply(db.session.query(Slice.id).filter(Slice.deleted_at.is_(None)), None)
.statement,
)
if security_manager.can_access("can_read", "Chart")
@@ -206,14 +208,10 @@ def _dependent_assets(
visible_dashboard_ids: sa.Select = (
cast(
sa.Select,
- DashboardDeletedStateFilter( # pylint: disable=protected-access
- "id", SQLAInterface(Dashboard, db.session)
- )
- ._scope_to_restore_audience(
- DashboardAccessFilter("id", SQLAInterface(Dashboard, db.session)).apply(
- db.session.query(Dashboard.id), None
- ),
- "include",
+ DashboardAccessFilter("id", SQLAInterface(Dashboard, db.session))
+ .apply(
+ db.session.query(Dashboard.id).filter(Dashboard.deleted_at.is_(None)),
+ None,
)
.statement,
)
@@ -230,14 +228,30 @@ def _dependent_assets(
if security_manager.can_access("can_read", "ReportSchedule")
else sa.select(ReportSchedule.id).where(sa.false())
)
+ archived_chart_ids: sa.Select = (
+ _archived_audience_ids(Slice.__table__, chart_editors, "chart_id")
+ if security_manager.can_access("can_read", "Chart")
+ else sa.select(Slice.__table__.c.id).where(sa.false())
+ )
+ archived_dashboard_ids: sa.Select = (
+ _archived_audience_ids(Dashboard.__table__, dashboard_editors, "dashboard_id")
+ if security_manager.can_access("can_read", "Dashboard")
+ else sa.select(Dashboard.__table__.c.id).where(sa.false())
+ )
visibility: sa.ColumnElement[bool] = sa.or_(
sa.and_(
dependents.c.type == "chart",
- dependents.c.id.in_(visible_chart_ids),
+ sa.or_(
+ dependents.c.id.in_(visible_chart_ids),
+ dependents.c.archived & dependents.c.id.in_(archived_chart_ids),
+ ),
),
sa.and_(
dependents.c.type == "dashboard",
- dependents.c.id.in_(visible_dashboard_ids),
+ sa.or_(
+ dependents.c.id.in_(visible_dashboard_ids),
+ dependents.c.archived & dependents.c.id.in_(archived_dashboard_ids),
+ ),
),
sa.and_(
dependents.c.type.notin_(("chart", "dashboard")),
@@ -252,8 +266,7 @@ def _dependent_assets(
sa.func.count(),
sa.func.sum(sa.case((visibility, 1), else_=0)),
sa.func.sum(sa.case((dependents.c.archived, 1), else_=0)),
- ).select_from(dependents),
- execution_options=_INCLUDE_ARCHIVED,
+ ).select_from(dependents)
).one(),
)
total: int = int(counts[0])
@@ -274,8 +287,7 @@ def _dependent_assets(
visible.c.type,
visible.c.id,
)
- .limit(_DEPENDENT_LIMIT),
- execution_options=_INCLUDE_ARCHIVED,
+ .limit(_DEPENDENT_LIMIT)
).all(),
)
return (
diff --git a/tests/unit_tests/commands/semantic_layer/delete_test.py b/tests/unit_tests/commands/semantic_layer/delete_test.py
index 3469133553bd..31d463ba77c3 100644
--- a/tests/unit_tests/commands/semantic_layer/delete_test.py
+++ b/tests/unit_tests/commands/semantic_layer/delete_test.py
@@ -396,6 +396,16 @@ def test_semantic_delete_counts_and_names_archived_dependents(
"superset.commands.semantic_layer.delete.ChartFilter.apply",
return_value=session.query(Slice.id).filter(sa.false()),
)
+ # A user who cannot see the charts is not an admin or their editor either.
+ mocker.patch("superset.security_manager.is_admin", return_value=False)
+ for access_filter in ("DashboardAccessFilter", "ReportScheduleFilter"):
+ mocker.patch(
+ f"superset.commands.semantic_layer.delete.{access_filter}.apply",
+ side_effect=lambda query, value: query,
+ )
+ mocker.patch(
+ "superset.commands.semantic_layer.delete.get_user_id", return_value=None
+ )
hidden_total: int
hidden_dependents: list[dict[str, str | int]]
hidden_count: int
@@ -576,6 +586,10 @@ def metadata(source_type: str | None, dataset_id: int | str) -> str:
"superset.commands.semantic_layer.delete.DashboardAccessFilter.apply",
return_value=session.query(Dashboard.id).filter(sa.false()),
)
+ mocker.patch("superset.security_manager.is_admin", return_value=False)
+ mocker.patch(
+ "superset.commands.semantic_layer.delete.get_user_id", return_value=None
+ )
hidden_total: int
hidden_dependents: list[dict[str, str | int]]
hidden_count: int
@@ -1189,9 +1203,9 @@ def test_semantic_delete_names_archived_dependents_to_restore_audience(
side_effect=lambda query, value: query,
)
mocker.patch("superset.security_manager.is_admin", return_value=viewer == "admin")
- mocker.patch("superset.views.filters.get_user_id", return_value=7)
+ mocker.patch("superset.commands.semantic_layer.delete.get_user_id", return_value=7)
mocker.patch(
- "superset.subjects.utils.get_user_subject_ids_subquery",
+ "superset.commands.semantic_layer.delete.get_user_subject_ids_subquery",
return_value=sa.select(sa.literal(1 if viewer == "editor" else 2)),
)
@@ -1206,5 +1220,3 @@ def test_semantic_delete_names_archived_dependents_to_restore_audience(
assert (total, archived_count) == (2, 1)
assert {str(item["name"]): item["archived"] for item in dependents} == named
assert inaccessible_count == 2 - len(named)
- # The bypass is per statement: later queries still hide archived charts.
- assert session.scalar(sa.select(Slice.id).where(Slice.id == 962)) is None
From 2e689b1afc45d4e3199ecebbc12284c189b38923 Mon Sep 17 00:00:00 2001
From: Mike Bridge
Date: Thu, 22 Oct 2026 13:37:49 -0600
Subject: [PATCH 07/12] fix(semantic): pluralise the hidden-blocker hint
Co-Authored-By: Claude Opus 5.5
---
.../features/semanticLayers/SemanticDeleteBlockers.tsx | 8 +++++---
.../src/pages/DatasetList/DatasetList.behavior.test.tsx | 2 +-
superset/translations/messages.pot | 6 ++++--
3 files changed, 10 insertions(+), 6 deletions(-)
diff --git a/superset-frontend/src/features/semanticLayers/SemanticDeleteBlockers.tsx b/superset-frontend/src/features/semanticLayers/SemanticDeleteBlockers.tsx
index ccea4b03ef50..60b25b82adcc 100644
--- a/superset-frontend/src/features/semanticLayers/SemanticDeleteBlockers.tsx
+++ b/superset-frontend/src/features/semanticLayers/SemanticDeleteBlockers.tsx
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { t } from '@apache-superset/core/translation';
+import { t, tn } from '@apache-superset/core/translation';
import { Alert } from '@apache-superset/core/components';
import { ensureAppRoot } from 'src/utils/navigationUtils';
@@ -101,8 +101,10 @@ export default function SemanticDeleteBlockers({
{unlisted > 0 && {t('and %s more', unlisted)}
}
{hidden > 0 && (
- {t(
- "%s dependent asset(s) you can't see. Ask an admin for help.",
+ {tn(
+ "%s dependent asset is one you can't see. Ask an admin for help.",
+ "%s dependent assets are ones you can't see. Ask an admin for help.",
+ hidden,
hidden,
)}
diff --git a/superset-frontend/src/pages/DatasetList/DatasetList.behavior.test.tsx b/superset-frontend/src/pages/DatasetList/DatasetList.behavior.test.tsx
index 33d8f33e4532..51d7ed4f4953 100644
--- a/superset-frontend/src/pages/DatasetList/DatasetList.behavior.test.tsx
+++ b/superset-frontend/src/pages/DatasetList/DatasetList.behavior.test.tsx
@@ -164,7 +164,7 @@ test('single semantic view delete names its blockers, including archived ones',
).not.toBeInTheDocument();
expect(alert).toHaveTextContent('and 1 more');
expect(alert).toHaveTextContent(
- "2 dependent asset(s) you can't see. Ask an admin for help.",
+ "2 dependent assets are ones you can't see. Ask an admin for help.",
);
});
diff --git a/superset/translations/messages.pot b/superset/translations/messages.pot
index d086d7150af2..c65f35c658fa 100644
--- a/superset/translations/messages.pot
+++ b/superset/translations/messages.pot
@@ -18308,5 +18308,7 @@ msgid "and %s more"
msgstr ""
#, python-format
-msgid "%s dependent asset(s) you can't see. Ask an admin for help."
-msgstr ""
+msgid "%s dependent asset is one you can't see. Ask an admin for help."
+msgid_plural "%s dependent assets are ones you can't see. Ask an admin for help."
+msgstr[0] ""
+msgstr[1] ""
From d5cdb99175590e2e3de3763c71515c69ae3ba157 Mon Sep 17 00:00:00 2001
From: Mike Bridge
Date: Thu, 22 Oct 2026 14:02:50 -0600
Subject: [PATCH 08/12] fix(semantic): never name archived blockers to guest
tokens
An Admin-role guest token skipped straight to the admin branch of the
archived audience, naming more than Recently Archived would show it.
Fail closed for guests first, as the restore gate does, and pin that
editorship names archived rows only.
Co-Authored-By: Claude Opus 5.5
---
superset/commands/semantic_layer/delete.py | 3 +++
.../commands/semantic_layer/delete_test.py | 22 +++++++++++++++++--
2 files changed, 23 insertions(+), 2 deletions(-)
diff --git a/superset/commands/semantic_layer/delete.py b/superset/commands/semantic_layer/delete.py
index c17508bda3c5..21211d354b84 100644
--- a/superset/commands/semantic_layer/delete.py
+++ b/superset/commands/semantic_layer/delete.py
@@ -126,6 +126,9 @@ def _archived_audience_ids(table: sa.Table, editors: sa.Table, fk: str) -> sa.Se
Mirrors ``BaseDeletedStateFilter``: admins see every archived row, others
only rows they edit. Core tables keep the soft-delete listener out of it.
"""
+ # A guest token never restores archived assets, whatever its role.
+ if security_manager.is_guest_user():
+ return sa.select(table.c.id).where(sa.false())
if security_manager.is_admin():
return sa.select(table.c.id)
user_id: int | None = get_user_id()
diff --git a/tests/unit_tests/commands/semantic_layer/delete_test.py b/tests/unit_tests/commands/semantic_layer/delete_test.py
index 31d463ba77c3..95c1e73a4aa6 100644
--- a/tests/unit_tests/commands/semantic_layer/delete_test.py
+++ b/tests/unit_tests/commands/semantic_layer/delete_test.py
@@ -1156,6 +1156,8 @@ def test_archived_dashboard_blocks_delete_with_orm_listener(
("admin", {"Live chart": False, "Archived chart": True}),
("editor", {"Live chart": False, "Archived chart": True}),
("reader", {"Live chart": False}),
+ ("guest", {"Live chart": False}),
+ ("hidden-editor", {"Archived chart": True}),
],
)
def test_semantic_delete_names_archived_dependents_to_restore_audience(
@@ -1202,11 +1204,27 @@ def test_semantic_delete_names_archived_dependents_to_restore_audience(
f"superset.commands.semantic_layer.delete.{access_filter}.apply",
side_effect=lambda query, value: query,
)
- mocker.patch("superset.security_manager.is_admin", return_value=viewer == "admin")
+ if viewer == "hidden-editor":
+ # Editorship names archived rows only: a live row the base filter hides
+ # stays hidden even from its editor.
+ session.execute(chart_editors.insert().values(chart_id=961, subject_id=1))
+ mocker.patch(
+ "superset.commands.semantic_layer.delete.ChartFilter.apply",
+ side_effect=lambda query, value: query.filter(sa.false()),
+ )
+ mocker.patch(
+ "superset.security_manager.is_admin",
+ return_value=viewer in ("admin", "guest"),
+ )
+ mocker.patch(
+ "superset.security_manager.is_guest_user", return_value=viewer == "guest"
+ )
mocker.patch("superset.commands.semantic_layer.delete.get_user_id", return_value=7)
mocker.patch(
"superset.commands.semantic_layer.delete.get_user_subject_ids_subquery",
- return_value=sa.select(sa.literal(1 if viewer == "editor" else 2)),
+ return_value=sa.select(
+ sa.literal(1 if viewer in ("editor", "hidden-editor") else 2)
+ ),
)
total: int
From 8a97757a4956d739b6f781e53bcb5fd343801c1f Mon Sep 17 00:00:00 2001
From: Mike Bridge
Date: Thu, 22 Oct 2026 15:04:12 -0600
Subject: [PATCH 09/12] fix(semantic): link blocking dashboards through the
app-root route
Use /dashboard// rather than a hard-coded /superset/ path, as the
navigation invariant requires; ensureAppRoot applies the deployment root.
Co-Authored-By: Claude Opus 5.5
---
.../src/features/semanticLayers/SemanticDeleteBlockers.tsx | 2 +-
.../src/pages/DatasetList/DatasetList.behavior.test.tsx | 6 +++++-
2 files changed, 6 insertions(+), 2 deletions(-)
diff --git a/superset-frontend/src/features/semanticLayers/SemanticDeleteBlockers.tsx b/superset-frontend/src/features/semanticLayers/SemanticDeleteBlockers.tsx
index 60b25b82adcc..13fbab920678 100644
--- a/superset-frontend/src/features/semanticLayers/SemanticDeleteBlockers.tsx
+++ b/superset-frontend/src/features/semanticLayers/SemanticDeleteBlockers.tsx
@@ -47,7 +47,7 @@ const typeLabel = (type: string): string => {
const LIVE_LINKS: Record string> = {
chart: id => `/explore/?slice_id=${id}`,
- dashboard: id => `/superset/dashboard/${id}/`,
+ dashboard: id => `/dashboard/${id}/`,
};
/** The refusal shown inside a semantic layer or view delete dialog. */
diff --git a/superset-frontend/src/pages/DatasetList/DatasetList.behavior.test.tsx b/superset-frontend/src/pages/DatasetList/DatasetList.behavior.test.tsx
index 51d7ed4f4953..dbce49bee833 100644
--- a/superset-frontend/src/pages/DatasetList/DatasetList.behavior.test.tsx
+++ b/superset-frontend/src/pages/DatasetList/DatasetList.behavior.test.tsx
@@ -133,12 +133,13 @@ test('single semantic view delete names its blockers, including archived ones',
status: 409,
body: {
message: 'blocked',
- total: 5,
+ total: 6,
archived_count: 1,
inaccessible_count: 2,
dependents: [
{ type: 'chart', id: 7, name: 'Revenue chart', archived: false },
{ type: 'dashboard', id: 8, name: 'Old board', archived: true },
+ { type: 'dashboard', id: 9, name: 'Live board', archived: false },
],
},
});
@@ -159,6 +160,9 @@ test('single semantic view delete names its blockers, including archived ones',
within(alert).getByRole('link', { name: 'Revenue chart' }),
).toHaveAttribute('href', '/explore/?slice_id=7');
expect(alert).toHaveTextContent('Old board (archived dashboard)');
+ expect(
+ within(alert).getByRole('link', { name: 'Live board' }),
+ ).toHaveAttribute('href', '/dashboard/9/');
expect(
within(alert).queryByRole('link', { name: 'Old board' }),
).not.toBeInTheDocument();
From a3d85f6906bfb19b56560fc742fa3bff1ace70dd Mon Sep 17 00:00:00 2001
From: Mike Bridge
Date: Thu, 22 Oct 2026 16:24:20 -0600
Subject: [PATCH 10/12] fix(semantic-layer): link delete blockers only by
numeric id
The blocker ids come from the error response, so a non-numeric id
could steer the link off the app. Build links only from whole-number
ids and keep names as plain text.
Co-Authored-By: Claude Opus 5.5
---
.../SemanticDeleteBlockers.test.tsx | 51 +++++++++++++++++++
.../semanticLayers/SemanticDeleteBlockers.tsx | 38 +++++++-------
2 files changed, 71 insertions(+), 18 deletions(-)
create mode 100644 superset-frontend/src/features/semanticLayers/SemanticDeleteBlockers.test.tsx
diff --git a/superset-frontend/src/features/semanticLayers/SemanticDeleteBlockers.test.tsx b/superset-frontend/src/features/semanticLayers/SemanticDeleteBlockers.test.tsx
new file mode 100644
index 000000000000..c16b4e8d89b1
--- /dev/null
+++ b/superset-frontend/src/features/semanticLayers/SemanticDeleteBlockers.test.tsx
@@ -0,0 +1,51 @@
+/**
+ * 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.
+ */
+import { render, screen } from 'spec/helpers/testing-library';
+import SemanticDeleteBlockers, {
+ SemanticDeleteError,
+} from './SemanticDeleteBlockers';
+
+test('markup in the refusal is shown as text, not rendered as HTML', () => {
+ const error = {
+ error: '
Blocked',
+ dependents: [
+ { type: 'chart', id: 7, name: 'Revenue', archived: false },
+ {
+ type: 'dashboard',
+ id: '/evil.example' as unknown as number,
+ name: 'Odd board',
+ archived: false,
+ },
+ ],
+ } satisfies SemanticDeleteError;
+ const { container } = render();
+ expect(
+ screen.getByText('
Blocked'),
+ ).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: 'Revenue' })).toHaveAttribute(
+ 'href',
+ '/explore/?slice_id=7',
+ );
+ expect(container.querySelector('img, b')).toBeNull();
+ // An id that is not a whole number never becomes a link.
+ expect(screen.getByText(/Odd board/)).toBeInTheDocument();
+ expect(
+ screen.queryByRole('link', { name: 'Odd board' }),
+ ).not.toBeInTheDocument();
+});
diff --git a/superset-frontend/src/features/semanticLayers/SemanticDeleteBlockers.tsx b/superset-frontend/src/features/semanticLayers/SemanticDeleteBlockers.tsx
index 13fbab920678..d51960e6b5ea 100644
--- a/superset-frontend/src/features/semanticLayers/SemanticDeleteBlockers.tsx
+++ b/superset-frontend/src/features/semanticLayers/SemanticDeleteBlockers.tsx
@@ -69,8 +69,13 @@ export default function SemanticDeleteBlockers({
)
: error.error;
// Archived assets have no page to open; Recently Archived lists them.
- const href = (item: Dependent): string | undefined =>
- item.archived ? undefined : LIVE_LINKS[item.type]?.(item.id);
+ // The id comes from the error body, so only a whole number becomes a link.
+ const href = (item: Dependent): string | undefined => {
+ const id = Number(item.id);
+ return item.archived || !Number.isSafeInteger(id)
+ ? undefined
+ : LIVE_LINKS[item.type]?.(id);
+ };
return (
{dependents.length > 0 && (
- {dependents.map(item => (
- -
- {href(item) ? (
-
- {item.name}
-
- ) : (
- item.name
- )}{' '}
- (
- {item.archived
- ? t('archived %s', typeLabel(item.type))
- : typeLabel(item.type)}
- )
-
- ))}
+ {dependents.map(item => {
+ const link = href(item);
+ const name = String(item.name);
+ return (
+ -
+ {link ? {name} : name} (
+ {item.archived
+ ? t('archived %s', typeLabel(item.type))
+ : typeLabel(item.type)}
+ )
+
+ );
+ })}
)}
{unlisted > 0 && {t('and %s more', unlisted)}
}
From bbaa8464830d4f8eb98f8206f379ef8a8b0b79c1 Mon Sep 17 00:00:00 2001
From: Mike Bridge
Date: Thu, 22 Oct 2026 23:13:47 -0600
Subject: [PATCH 11/12] chore(i18n): align semantic deletion template after
stack merge
---
superset/translations/messages.pot | 10 ----------
1 file changed, 10 deletions(-)
diff --git a/superset/translations/messages.pot b/superset/translations/messages.pot
index 3649793f16e8..36da400218bf 100644
--- a/superset/translations/messages.pot
+++ b/superset/translations/messages.pot
@@ -2266,10 +2266,6 @@ msgstr ""
msgid "Are you sure you want to delete %s?"
msgstr ""
-#, python-format
-msgid "Are you sure you want to delete %s? This cannot be undone."
-msgstr ""
-
#, python-format
msgid "Are you sure you want to delete the selected %s?"
msgstr ""
@@ -11017,9 +11013,6 @@ msgstr ""
msgid "Remove or repoint these assets before deleting it."
msgstr ""
-msgid "Remove or repoint these assets before deleting the semantic source."
-msgstr ""
-
msgid "Remove query from log"
msgstr ""
@@ -15173,9 +15166,6 @@ msgstr ""
msgid "This section contains validation errors"
msgstr ""
-msgid "This semantic layer has no dependent semantic views. This cannot be undone."
-msgstr ""
-
msgid "This semantic layer has no semantic views. This cannot be undone."
msgstr ""
From 64f1e3e9f546041a550044a7159063d5edb7f7cd Mon Sep 17 00:00:00 2001
From: Mike Bridge
Date: Mon, 26 Oct 2026 16:40:35 -0600
Subject: [PATCH 12/12] fix(semantic-layer): require restore capability for
archived blocker names
---
docs/docs/using-superset/recently-archived.mdx | 2 +-
superset/commands/semantic_layer/delete.py | 4 ++--
.../commands/semantic_layer/delete_test.py | 13 ++++++++++---
3 files changed, 13 insertions(+), 6 deletions(-)
diff --git a/docs/docs/using-superset/recently-archived.mdx b/docs/docs/using-superset/recently-archived.mdx
index 755ec34c34d2..f2be235f9bab 100644
--- a/docs/docs/using-superset/recently-archived.mdx
+++ b/docs/docs/using-superset/recently-archived.mdx
@@ -95,7 +95,7 @@ When deleting a semantic layer or view, a dependency refusal stays in the
confirmation dialog with the total and archived counts, the names of up to 20
blocking assets you can see, and a link to Recently Archived. Archived blockers
are named only to users who could restore them from Recently Archived (admins
-and the asset's editors); the dialog counts the others and suggests asking an
+and the asset's editors with write permission on its resource type); the dialog counts the others and suggests asking an
admin. The typed confirmation is preserved so you can retry after removing
or repointing the dependents. Dependencies are checked when you confirm deletion,
not before you type the confirmation.
diff --git a/superset/commands/semantic_layer/delete.py b/superset/commands/semantic_layer/delete.py
index 21211d354b84..74a866ff3bf3 100644
--- a/superset/commands/semantic_layer/delete.py
+++ b/superset/commands/semantic_layer/delete.py
@@ -233,12 +233,12 @@ def _dependent_assets(
)
archived_chart_ids: sa.Select = (
_archived_audience_ids(Slice.__table__, chart_editors, "chart_id")
- if security_manager.can_access("can_read", "Chart")
+ if security_manager.can_access("can_write", "Chart")
else sa.select(Slice.__table__.c.id).where(sa.false())
)
archived_dashboard_ids: sa.Select = (
_archived_audience_ids(Dashboard.__table__, dashboard_editors, "dashboard_id")
- if security_manager.can_access("can_read", "Dashboard")
+ if security_manager.can_access("can_write", "Dashboard")
else sa.select(Dashboard.__table__.c.id).where(sa.false())
)
visibility: sa.ColumnElement[bool] = sa.or_(
diff --git a/tests/unit_tests/commands/semantic_layer/delete_test.py b/tests/unit_tests/commands/semantic_layer/delete_test.py
index 95c1e73a4aa6..f9071ca602e4 100644
--- a/tests/unit_tests/commands/semantic_layer/delete_test.py
+++ b/tests/unit_tests/commands/semantic_layer/delete_test.py
@@ -1156,6 +1156,7 @@ def test_archived_dashboard_blocks_delete_with_orm_listener(
("admin", {"Live chart": False, "Archived chart": True}),
("editor", {"Live chart": False, "Archived chart": True}),
("reader", {"Live chart": False}),
+ ("read-only-editor", {"Live chart": False}),
("guest", {"Live chart": False}),
("hidden-editor", {"Archived chart": True}),
],
@@ -1163,7 +1164,7 @@ def test_archived_dashboard_blocks_delete_with_orm_listener(
def test_semantic_delete_names_archived_dependents_to_restore_audience(
session: Session, mocker: MockerFixture, viewer: str, named: dict[str, bool]
) -> None:
- """Archived blockers are named to whoever Recently Archived would show them."""
+ """Archived blockers are named only to callers with restore capability."""
from datetime import datetime
from superset.commands.semantic_layer.delete import _dependent_assets
@@ -1194,7 +1195,11 @@ def test_semantic_delete_names_archived_dependents_to_restore_audience(
session.execute(chart_editors.insert().values(chart_id=962, subject_id=1))
# Prove the session listener is active; do not substitute Connection.execute.
assert session.scalar(sa.select(Slice.id).where(Slice.id == 962)) is None
- mocker.patch("superset.security_manager.can_access", return_value=True)
+ mocker.patch(
+ "superset.security_manager.can_access",
+ side_effect=lambda permission, resource: viewer != "read-only-editor"
+ or permission != "can_write",
+ )
for access_filter in (
"ChartFilter",
"DashboardAccessFilter",
@@ -1223,7 +1228,9 @@ def test_semantic_delete_names_archived_dependents_to_restore_audience(
mocker.patch(
"superset.commands.semantic_layer.delete.get_user_subject_ids_subquery",
return_value=sa.select(
- sa.literal(1 if viewer in ("editor", "hidden-editor") else 2)
+ sa.literal(
+ 1 if viewer in ("editor", "hidden-editor", "read-only-editor") else 2
+ )
),
)