You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/07/06 17:07:15 UTC
(superset) branch master updated: feat(charts): soft-delete and restore (#40129)
This is an automated email from the ASF dual-hosted git repository.
rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new a7a051777c2 feat(charts): soft-delete and restore (#40129)
a7a051777c2 is described below
commit a7a051777c2023470eb729e097380d7d5a1a93b2
Author: Mike Bridge <mi...@bridgecanada.com>
AuthorDate: Mon Jul 6 11:07:04 2026 -0600
feat(charts): soft-delete and restore (#40129)
Co-authored-by: Mike Bridge <mi...@ext.preset.io>
Co-authored-by: Claude Opus 4.7 (1M context) <no...@anthropic.com>
---
UPDATING.md | 20 +
superset/charts/api.py | 110 +++-
superset/charts/filters.py | 38 ++
superset/commands/chart/exceptions.py | 10 +
superset/commands/chart/importers/v1/utils.py | 109 +++-
superset/commands/chart/restore.py | 39 ++
superset/commands/report/exceptions.py | 14 +
superset/commands/report/execute.py | 36 ++
superset/daos/dashboard.py | 30 +-
..._12-00_7c4a8d09ca37_add_deleted_at_to_slices.py | 52 ++
superset/models/slice.py | 8 +-
superset/translations/ar/LC_MESSAGES/messages.po | 13 +
superset/translations/ca/LC_MESSAGES/messages.po | 13 +
superset/translations/cs/LC_MESSAGES/messages.po | 13 +
superset/translations/de/LC_MESSAGES/messages.po | 13 +
superset/translations/en/LC_MESSAGES/messages.po | 13 +
superset/translations/es/LC_MESSAGES/messages.po | 13 +
superset/translations/fa/LC_MESSAGES/messages.po | 13 +
superset/translations/fi/LC_MESSAGES/messages.po | 13 +
superset/translations/fr/LC_MESSAGES/messages.po | 13 +
superset/translations/it/LC_MESSAGES/messages.po | 13 +
superset/translations/ja/LC_MESSAGES/messages.po | 13 +
superset/translations/ko/LC_MESSAGES/messages.po | 13 +
superset/translations/lv/LC_MESSAGES/messages.po | 13 +
superset/translations/messages.pot | 13 +
superset/translations/mi/LC_MESSAGES/messages.po | 13 +
superset/translations/nl/LC_MESSAGES/messages.po | 13 +
superset/translations/pl/LC_MESSAGES/messages.po | 13 +
superset/translations/pt/LC_MESSAGES/messages.po | 13 +
.../translations/pt_BR/LC_MESSAGES/messages.po | 13 +
superset/translations/ro/LC_MESSAGES/messages.po | 10 +
superset/translations/ru/LC_MESSAGES/messages.po | 13 +
superset/translations/sk/LC_MESSAGES/messages.po | 13 +
superset/translations/sl/LC_MESSAGES/messages.po | 13 +
superset/translations/sr/LC_MESSAGES/messages.po | 10 +
.../translations/sr_Latn/LC_MESSAGES/messages.po | 10 +
superset/translations/th/LC_MESSAGES/messages.po | 13 +
superset/translations/tr/LC_MESSAGES/messages.po | 13 +
superset/translations/uk/LC_MESSAGES/messages.po | 13 +
superset/translations/zh/LC_MESSAGES/messages.po | 13 +
.../translations/zh_TW/LC_MESSAGES/messages.po | 13 +
tests/integration_tests/charts/commands_tests.py | 2 +-
.../integration_tests/charts/soft_delete_tests.py | 579 +++++++++++++++++++++
.../charts/commands/importers/v1/import_test.py | 181 +++++++
tests/unit_tests/commands/chart/restore_test.py | 90 ++++
tests/unit_tests/commands/report/execute_test.py | 70 +++
tests/unit_tests/dao/dashboard_test.py | 119 +++++
.../migrations/test_add_deleted_at_to_slices.py | 190 +++++++
48 files changed, 2054 insertions(+), 24 deletions(-)
diff --git a/UPDATING.md b/UPDATING.md
index 0388b3ce36e..ec0a06275cc 100644
--- a/UPDATING.md
+++ b/UPDATING.md
@@ -302,6 +302,26 @@ Schedule the cutover in a quiet window. Runtime reads use only the single config
The migration is transactional (all-or-nothing) and idempotent — it can be safely re-run or resumed. Note that AES-GCM, unlike AES-CBC, does not support querying directly over encrypted columns; audit any code that filters on an encrypted column before switching. See the SIP at `docs/sip/authenticated-encryption-at-rest.md` for details.
+### Soft delete and restore for charts
+
+**Everything in this section applies only when the `SOFT_DELETE` feature flag is enabled. The flag defaults to `False`** (`@lifecycle: development`), so on a default deployment `DELETE /api/v1/chart/<id>` continues to **hard-delete permanently** — nothing is recoverable. Enable `SOFT_DELETE` to get the behavior described below.
+
+**Flag-toggle caveat:** the soft-delete visibility filter is evaluated per query while the flag is on. If charts are soft-deleted during a flag-on window and the flag is later turned **off**, those rows reappear as live charts in all lists, lookups, and relationship loads (including dashboards that contained them). The `POST /<uuid>/restore` endpoint and the `chart_deleted_state` list filter remain functional regardless of the flag, deliberately, so rows soft-deleted during a flag-on win [...]
+
+With the flag enabled: `DELETE /api/v1/chart/<id>` no longer hard-deletes the chart (the bulk-delete endpoint behaves the same way). The row is marked with a `deleted_at` timestamp and hidden from all list, detail, and lookup endpoints. Charts in this state are excluded from default queries and from relationship loads (e.g. `dashboard.slices`).
+
+**Operational notes:** a report schedule whose target chart is soft-deleted now fails its runs with an explicit error ("The chart this report targets was deleted...") until the chart is restored or the report re-pointed — chart deletion is blocked while a report references the chart, but a validate/commit race or a flag toggle can still produce this state. Dashboards **preserve** their membership rows for soft-deleted charts: saving a dashboard does not sever a trashed member, and restor [...]
+
+**New endpoint** — `POST /api/v1/chart/<uuid>/restore` clears `deleted_at` and returns the chart to active state. Requires `can_write on Chart` and ownership of the row (or admin). Soft-deleted charts can also be surfaced in the list endpoint via the new `chart_deleted_state` rison filter: `include` returns both live and soft-deleted rows, `only` returns just the soft-deleted ones. Any other value is ignored. For non-admin users, soft-deleted rows are limited to charts they own — the sam [...]
+
+**Permissions migration:** existing role grants of `can_write on Chart` cover the new restore endpoint automatically; no role migration is required.
+
+**Schema migration:** the migration adds a nullable `deleted_at` column and an index on it (`ix_slices_deleted_at`) to the `slices` table. The column add is instant; the index build runs inline (no `CONCURRENTLY`) and may briefly block writes on the `slices` table (INSERT/UPDATE/DELETE are queued while the index builds; reads are unaffected) on large Postgres deployments. MySQL InnoDB builds the index online (no blocking).
+
+**Rollback note:** if the application code is rolled back after charts have been soft-deleted, the older code path's visibility filter no longer applies and previously hidden rows become visible to the older code. Pair the rollback with a data decision (restore the rows, hard-delete them, or also downgrade the migration) rather than assuming the old hard-delete semantics still hold. **Downgrading the migration destroys the deletion markers**: `downgrade()` drops the `deleted_at` column, [...]
+
+**Importer behavior:** importing a chart YAML whose UUID matches an existing **soft-deleted** chart is treated as an implicit restore-with-update — **and this happens even when `overwrite` is not set**. This is a deliberate asymmetry with active rows: an active chart imported without `overwrite=true` is returned unchanged, but a soft-deleted UUID match is restored *and* has the upload's contents applied regardless of the `overwrite` argument, on the reasoning that re-importing a deleted [...]
+
- [39914](https://github.com/apache/superset/pull/39914) `ALERT_REPORT_SLACK_V2` now defaults to `True` and the legacy Slack v1 integration (`Slack` recipient type, `files.upload` API) is deprecated for removal in the next major. Slack blocked new apps from `files.upload` in May 2024 and fully retired the method for all apps on November 12, 2025; because the v1 path sends files through `files.upload`, v1 file-bearing sends now fail at the API level — only text-only `chat_postMessage` sti [...]
### Soft delete and restore for dashboards
diff --git a/superset/charts/api.py b/superset/charts/api.py
index 0098996a502..5a872622f3f 100644
--- a/superset/charts/api.py
+++ b/superset/charts/api.py
@@ -35,6 +35,7 @@ from superset.charts.filters import (
ChartAllTextFilter,
ChartCertifiedFilter,
ChartCreatedByMeFilter,
+ ChartDeletedStateFilter,
ChartFavoriteFilter,
ChartFilter,
ChartHasCreatedByFilter,
@@ -64,12 +65,14 @@ from superset.commands.chart.exceptions import (
ChartForbiddenError,
ChartInvalidError,
ChartNotFoundError,
+ ChartRestoreFailedError,
ChartUpdateFailedError,
DashboardsForbiddenError,
)
from superset.commands.chart.export import ExportChartsCommand
from superset.commands.chart.fave import AddFavoriteChartCommand
from superset.commands.chart.importers.dispatcher import ImportChartsCommand
+from superset.commands.chart.restore import RestoreChartCommand
from superset.commands.chart.unfave import DelFavoriteChartCommand
from superset.commands.chart.update import UpdateChartCommand
from superset.commands.chart.warm_up_cache import ChartWarmUpCacheCommand
@@ -102,12 +105,16 @@ from superset.views.base_api import (
requires_json,
statsd_metrics,
)
-from superset.views.filters import BaseFilterRelatedUsers, FilterRelatedOwners
+from superset.views.filters import (
+ BaseFilterRelatedUsers,
+ FilterRelatedOwners,
+ SoftDeleteApiMixin,
+)
logger = logging.getLogger(__name__)
-class ChartRestApi(BaseSupersetModelRestApi):
+class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
datamodel = SQLAInterface(Slice)
resource_name = "chart"
@@ -124,6 +131,7 @@ class ChartRestApi(BaseSupersetModelRestApi):
RouteMethod.IMPORT,
RouteMethod.RELATED,
"bulk_delete", # not using RouteMethod since locally defined
+ "restore",
"viz_types",
"favorite_status",
"add_favorite",
@@ -134,7 +142,17 @@ class ChartRestApi(BaseSupersetModelRestApi):
"warm_up_cache",
}
class_permission_name = "Chart"
- method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
+ # Custom methods (``restore``) need an explicit entry; FAB's @protect()
+ # decorator falls back to ``can_<method>_<class>`` (i.e.
+ # ``can_restore_Chart``) when the mapping is missing, which standard
+ # roles don't carry. Mirrors the permission model documented for
+ # ``DELETE`` / ``bulk_delete``: endpoint-level ``can_write`` plus
+ # resource-level ``raise_for_ownership``. See themes/api.py for the
+ # established pattern.
+ method_permission_name = {
+ **MODEL_API_RW_METHOD_PERMISSION_MAP,
+ "restore": "write",
+ }
list_columns = [
"is_managed_externally",
@@ -222,6 +240,7 @@ class ChartRestApi(BaseSupersetModelRestApi):
"id": [
ChartFavoriteFilter,
ChartCertifiedFilter,
+ ChartDeletedStateFilter,
ChartOwnedCreatedFavoredByMeFilter,
],
"slice_name": [ChartAllTextFilter],
@@ -471,9 +490,17 @@ class ChartRestApi(BaseSupersetModelRestApi):
)
def delete(self, pk: int) -> Response:
"""Delete a chart.
+
+ When the ``SOFT_DELETE`` feature flag is enabled, marks the chart as
+ deleted (sets ``deleted_at``) and hides it from list/detail endpoints
+ and relationship loads; the row is preserved and recoverable via
+ ``POST /api/v1/chart/<uuid>/restore`` by an owner or admin. With the
+ flag disabled (the default), the chart is permanently hard-deleted
+ and is not recoverable.
---
delete:
- summary: Delete a chart
+ summary: Delete a chart (soft delete, recoverable via restore, when
+ the SOFT_DELETE feature flag is enabled; permanent otherwise)
parameters:
- in: path
schema:
@@ -527,9 +554,17 @@ class ChartRestApi(BaseSupersetModelRestApi):
)
def bulk_delete(self, **kwargs: Any) -> Response:
"""Bulk delete charts.
+
+ When the ``SOFT_DELETE`` feature flag is enabled, marks each chart as
+ deleted (sets ``deleted_at``) and hides it from list/detail endpoints
+ and relationship loads; rows are preserved and recoverable via
+ ``POST /api/v1/chart/<uuid>/restore`` by an owner or admin. With the
+ flag disabled (the default), the charts are permanently hard-deleted
+ and are not recoverable.
---
delete:
- summary: Bulk delete charts
+ summary: Bulk delete charts (soft delete, recoverable via restore,
+ when the SOFT_DELETE feature flag is enabled; permanent otherwise)
parameters:
- in: query
name: q
@@ -574,6 +609,62 @@ class ChartRestApi(BaseSupersetModelRestApi):
except ChartDeleteFailedError as ex:
return self.response_422(message=str(ex))
+ @expose("/<uuid>/restore", methods=("POST",))
+ @protect()
+ @safe
+ @statsd_metrics
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.restore",
+ log_to_statsd=False,
+ )
+ def restore(self, uuid: str) -> Response:
+ """Restore a soft-deleted chart.
+ ---
+ post:
+ summary: Restore a soft-deleted chart
+ parameters:
+ - in: path
+ schema:
+ type: string
+ format: uuid
+ name: uuid
+ responses:
+ 200:
+ description: Chart restored
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ message:
+ type: string
+ 401:
+ $ref: '#/components/responses/401'
+ 403:
+ $ref: '#/components/responses/403'
+ 404:
+ $ref: '#/components/responses/404'
+ 422:
+ $ref: '#/components/responses/422'
+ 500:
+ $ref: '#/components/responses/500'
+ """
+ try:
+ RestoreChartCommand(uuid).run()
+ return self.response(200, message="OK")
+ except ChartNotFoundError:
+ return self.response_404()
+ except ChartForbiddenError:
+ return self.response_403()
+ except ChartRestoreFailedError as ex:
+ logger.error(
+ "Error restoring model %s: %s",
+ self.__class__.__name__,
+ str(ex),
+ exc_info=True,
+ )
+ return self.response_422(message=str(ex))
+
@expose("/<pk>/cache_screenshot/", methods=("GET",))
@protect()
@parse_rison(screenshot_query_schema)
@@ -1103,6 +1194,15 @@ class ChartRestApi(BaseSupersetModelRestApi):
@requires_form_data
def import_(self) -> Response:
"""Import chart(s) with associated datasets and databases.
+
+ When the ``SOFT_DELETE`` feature flag is enabled and an imported
+ chart's UUID matches an existing **soft-deleted** chart, the import
+ restores that chart and applies the upload's contents — **even when
+ ``overwrite`` is not set**. Active charts keep the usual contract
+ (never mutated without ``overwrite=true``); a soft-deleted UUID match
+ is treated as an explicit request to bring the chart back. Requires
+ ``can_write`` and ownership of the deleted row (or admin). See
+ UPDATING.md for details.
---
post:
summary: Import chart(s) with associated datasets and databases
diff --git a/superset/charts/filters.py b/superset/charts/filters.py
index 6908a959bd7..94efb99fabd 100644
--- a/superset/charts/filters.py
+++ b/superset/charts/filters.py
@@ -32,6 +32,7 @@ from superset.utils.core import get_user_id
from superset.utils.filters import get_dataset_access_filters
from superset.views.base import BaseFilter
from superset.views.base_api import BaseFavoriteFilter
+from superset.views.filters import BaseDeletedStateFilter
class ChartAllTextFilter(BaseFilter): # pylint: disable=too-few-public-methods
@@ -196,3 +197,40 @@ class ChartOwnedCreatedFavoredByMeFilter(BaseFilter): # pylint: disable=too-few
FavStar.user_id == get_user_id(),
)
)
+
+
+class ChartDeletedStateFilter( # pylint: disable=too-few-public-methods
+ BaseDeletedStateFilter
+):
+ """Rison filter for the GET list that exposes soft-deleted charts.
+
+ Soft-deleted rows are additionally scoped to the **restore audience**: only
+ the chart's owners (or admins) may enumerate them. This mirrors
+ ``RestoreChartCommand``'s ``raise_for_ownership`` check, so a read-access
+ non-owner (who can see the chart via datasource access) cannot list
+ soft-deleted charts they could never restore. Live rows are unaffected —
+ they keep their normal ``ChartFilter`` visibility. The ownership scoping is
+ part of the cross-entity deleted-state contract: only the restore audience
+ may enumerate soft-deleted rows — any entity exposing a deleted-state
+ filter is expected to apply the same scoping.
+ """
+
+ arg_name = "chart_deleted_state"
+ model = Slice
+
+ def apply(self, query: Query, value: Any) -> Query:
+ query = super().apply(query, value)
+ normalized = self._normalize(value)
+ if normalized not in {"include", "only"} or security_manager.is_admin():
+ return query
+
+ # Non-admins may only see soft-deleted charts they own. ``any()`` emits
+ # an EXISTS subquery so it composes with the base access filter without
+ # producing duplicate rows from a join.
+ owned = Slice.owners.any(security_manager.user_model.id == get_user_id())
+ if normalized == "only":
+ # ``super().apply`` already restricted to ``deleted_at IS NOT NULL``.
+ return query.filter(owned)
+ # ``include``: keep all live rows (normal access) and add only the
+ # soft-deleted rows this user owns.
+ return query.filter(or_(Slice.deleted_at.is_(None), owned))
diff --git a/superset/commands/chart/exceptions.py b/superset/commands/chart/exceptions.py
index 72ef71f466e..43fef980249 100644
--- a/superset/commands/chart/exceptions.py
+++ b/superset/commands/chart/exceptions.py
@@ -123,6 +123,16 @@ class ChartDeleteFailedError(DeleteFailedError):
message = _("Charts could not be deleted.")
+class ChartRestoreFailedError(UpdateFailedError):
+ # Restore semantically clears ``deleted_at``; it is an UPDATE, not a new
+ # row. ``UpdateFailedError`` is the nearest typed middle-tier base in the
+ # codebase. A dedicated ``RestoreFailedError`` in
+ # ``superset/commands/exceptions.py`` would be more precise across the
+ # entity rollouts but lives in already-merged infrastructure (#39977);
+ # introducing it can be a cross-entity follow-up.
+ message = _("Chart could not be restored.")
+
+
class ChartDeleteFailedReportsExistError(ChartDeleteFailedError):
message = _("There are associated alerts or reports")
diff --git a/superset/commands/chart/importers/v1/utils.py b/superset/commands/chart/importers/v1/utils.py
index 33e95279a22..209caa74558 100644
--- a/superset/commands/chart/importers/v1/utils.py
+++ b/superset/commands/chart/importers/v1/utils.py
@@ -21,6 +21,7 @@ from typing import Any
from superset import db, security_manager
from superset.commands.exceptions import ImportFailedError
+from superset.commands.importers.v1.utils import find_existing_for_import
from superset.migrations.shared.migrate_viz import processors
from superset.migrations.shared.migrate_viz.base import MigrateViz
from superset.models.slice import Slice
@@ -48,21 +49,107 @@ def import_chart(
overwrite: bool = False,
ignore_permissions: bool = False,
) -> Slice:
+ """Import a chart from a config dict, handling existing matches.
+
+ Permission model for an existing UUID match:
+
+ +--------------+---------------+---------------------+-----------------+
+ | Existing row | overwrite arg | Caller has perms? | Outcome |
+ +==============+===============+=====================+=================+
+ | alive | False | (n/a) | return existing |
+ +--------------+---------------+---------------------+-----------------+
+ | alive | True | can_write + owner | UPDATE in place |
+ +--------------+---------------+---------------------+-----------------+
+ | alive | True | can_write, | raise |
+ | | | not owner/admin | |
+ +--------------+---------------+---------------------+-----------------+
+ | soft-deleted | False or True | can_write + owner | restore + UPDATE|
+ +--------------+---------------+---------------------+-----------------+
+ | soft-deleted | False or True | can_write, | raise |
+ | | | not owner/admin | |
+ +--------------+---------------+---------------------+-----------------+
+ | soft-deleted | False or True | not can_write | raise (Case B) |
+ +--------------+---------------+---------------------+-----------------+
+
+ Re-importing a soft-deleted UUID is implicitly a restore-with-update:
+ the user is bringing the chart back by uploading it again. We apply
+ the same ownership check as the explicit overwrite path so non-owners
+ cannot resurrect via re-import, and we raise rather than silently
+ returning a soft-deleted row to callers without write permission
+ (which would let them reattach dashboards to a deleted chart).
+ """
can_write = ignore_permissions or security_manager.can_access("can_write", "Chart")
- existing = db.session.query(Slice).filter_by(uuid=config["uuid"]).first()
+ # `user` is None for background / example-loader paths (no Flask request
+ # user). Combined with ``can_write=True`` (typically from
+ # ``ignore_permissions=True``), the ownership checks in the restore /
+ # overwrite branches below are intentionally skipped because the caller has
+ # already established trust at the command level.
user = get_user()
- if existing:
- if overwrite and can_write and user:
- if not security_manager.can_access_chart(existing) or (
- user not in existing.owners and not security_manager.is_admin()
+
+ if existing := find_existing_for_import(Slice, config["uuid"]):
+ if existing.deleted_at is not None:
+ # RESTORE path — re-importing a soft-deleted UUID is an implicit
+ # restore-with-update, a distinct operation from overwriting an
+ # alive row, so it is handled in its own branch.
+ if not can_write:
+ # Case B: don't silently return a soft-deleted row to a caller
+ # without write permission — that would let the dashboard
+ # importer reattach to a deleted chart and produce a broken
+ # dashboard.
+ # Name the chart: a dashboard bundle imports many charts, and
+ # without the identity the operator can't tell which of N
+ # charts in the bundle hit the soft-deleted match.
+ raise ImportFailedError(
+ f"Chart {existing.slice_name!r} (uuid {config['uuid']}) "
+ f"was deleted and re-import requires can_write "
+ f"permission to restore it"
+ )
+ # ``user`` is None on background / example-loader paths; combined
+ # with ``can_write`` (typically from ``ignore_permissions=True``)
+ # the ownership check is intentionally skipped because the caller
+ # already established trust.
+ if user and (
+ not security_manager.can_access_chart(existing)
+ or (user not in existing.owners and not security_manager.is_admin())
+ ):
+ raise ImportFailedError(
+ f"Chart {existing.slice_name!r} (uuid {config['uuid']}) "
+ f"already exists and user doesn't have permissions to "
+ f"restore it"
+ )
+ # Restore in place (clear ``deleted_at``) rather than
+ # hard-delete-and-replace: a hard delete would cascade to
+ # dashboard_slices and other FK references, breaking the dashboards
+ # that previously embedded this chart.
+ #
+ # How the restore lands as an UPDATE: clearing
+ # ``existing.deleted_at`` marks the in-session row dirty and the
+ # explicit flush emits the ``deleted_at = NULL`` UPDATE before
+ # ``Slice.import_from_dict`` (below) does its own query-by-uuid
+ # lookup. Without the flush we would rely on autoflush ahead of that
+ # internal query — correct under default session config but a hidden
+ # contract; the explicit flush makes it robust. The lookup then
+ # finds the now-live row (the listener filters ``deleted_at IS
+ # NULL``) and ``import_from_dict`` applies the config as field
+ # updates on the existing object, preserving the PK.
+ existing.restore()
+ db.session.flush()
+ config["id"] = existing.id
+ else:
+ # OVERWRITE path — existing alive row. Without ``overwrite`` or
+ # write permission, return it unchanged (the pre-soft-delete
+ # overwrite-without-permission behaviour).
+ if not overwrite or not can_write:
+ return existing
+ if user and (
+ not security_manager.can_access_chart(existing)
+ or (user not in existing.owners and not security_manager.is_admin())
):
raise ImportFailedError(
- "A chart already exists and user doesn't "
- "have permissions to overwrite it"
+ "A chart already exists and user doesn't have "
+ "permissions to overwrite it"
)
- if not overwrite or not can_write:
- return existing
- config["id"] = existing.id
+ config["id"] = existing.id
elif not can_write:
raise ImportFailedError(
"Chart doesn't exist and user doesn't have permission to create charts"
@@ -80,7 +167,7 @@ def import_chart(
if chart.id is None:
db.session.flush()
- if (user := get_user()) and user not in chart.owners:
+ if user and user not in chart.owners:
chart.owners.append(user)
return chart
diff --git a/superset/commands/chart/restore.py b/superset/commands/chart/restore.py
new file mode 100644
index 00000000000..a0979ba2a27
--- /dev/null
+++ b/superset/commands/chart/restore.py
@@ -0,0 +1,39 @@
+# 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.
+"""Command to restore a soft-deleted chart."""
+
+from superset.commands.chart.exceptions import (
+ ChartForbiddenError,
+ ChartNotFoundError,
+ ChartRestoreFailedError,
+)
+from superset.commands.restore import BaseRestoreCommand
+from superset.daos.chart import ChartDAO
+from superset.models.slice import Slice
+
+
+class RestoreChartCommand(BaseRestoreCommand[Slice]):
+ """Restore a soft-deleted chart by clearing its ``deleted_at`` field.
+
+ All transactional wrapping and validation lives in
+ ``BaseRestoreCommand``; this subclass is purely declarative.
+ """
+
+ dao = ChartDAO
+ not_found_exc = ChartNotFoundError
+ forbidden_exc = ChartForbiddenError
+ restore_failed_exc = ChartRestoreFailedError
diff --git a/superset/commands/report/exceptions.py b/superset/commands/report/exceptions.py
index 32147edfeb6..31b259bec55 100644
--- a/superset/commands/report/exceptions.py
+++ b/superset/commands/report/exceptions.py
@@ -225,6 +225,20 @@ class ReportScheduleExecuteUnexpectedError(CommandException):
message = _("Report Schedule execution got an unexpected error.")
+class ReportScheduleTargetChartDeletedError(CommandException):
+ message = _(
+ "The chart this report targets was deleted. Restore the chart, or "
+ "update the report to point at an active chart."
+ )
+
+
+class ReportScheduleTargetDashboardDeletedError(CommandException):
+ message = _(
+ "The dashboard this report targets was deleted. Restore the "
+ "dashboard, or update the report to point at an active dashboard."
+ )
+
+
class ReportSchedulePreviousWorkingError(CommandException):
status = 429
message = _("Report Schedule is still working, refusing to re-compute.")
diff --git a/superset/commands/report/execute.py b/superset/commands/report/execute.py
index f08442122f8..d1686f07254 100644
--- a/superset/commands/report/execute.py
+++ b/superset/commands/report/execute.py
@@ -44,6 +44,8 @@ from superset.commands.report.exceptions import (
ReportScheduleScreenshotTimeout,
ReportScheduleStateNotFoundError,
ReportScheduleSystemErrorsException,
+ ReportScheduleTargetChartDeletedError,
+ ReportScheduleTargetDashboardDeletedError,
ReportScheduleUnexpectedError,
ReportScheduleWorkingTimeoutError,
)
@@ -256,6 +258,32 @@ class BaseReportState:
"""
Get the url for this report schedule: chart or dashboard
"""
+ # Soft delete removed the FK-level guarantee that a report's target
+ # chart exists: ``chart`` is a visibility-filtered relationship, so a
+ # chart soft-deleted after this report was created (or attached via a
+ # validate/commit race with DeleteChartCommand) loads as ``None``
+ # while ``chart_id`` is still set. Without this guard the branch
+ # below silently falls through to the dashboard path and fails
+ # opaquely; raising here surfaces a clear, actionable error inside
+ # the state-machine envelope (ERROR log row + owner notification).
+ # Every content path (_get_screenshots, _get_csv_data,
+ # _get_embedded_data, _get_notification_content) funnels through this
+ # method, so this is the single choke point.
+ if (
+ self._report_schedule.chart_id is not None
+ and self._report_schedule.chart is None
+ ):
+ raise ReportScheduleTargetChartDeletedError()
+ # Symmetric guard for dashboard targets. Dashboard soft delete lands
+ # in the sibling rollout; until then this cannot fire (a dashboard
+ # with dependent reports cannot be deleted), which makes it inert
+ # rather than wrong — and it keeps the report-target error vocabulary
+ # parallel across entities from day one.
+ if (
+ self._report_schedule.dashboard_id is not None
+ and self._report_schedule.dashboard is None
+ ):
+ raise ReportScheduleTargetDashboardDeletedError()
force = "true" if self._report_schedule.force_screenshot else "false"
if self._report_schedule.chart:
if result_format in {
@@ -300,6 +328,14 @@ class BaseReportState:
"""
Retrieve the URL for the dashboard tabs, or return the dashboard URL if no tabs are available.
""" # noqa: E501
+ # Called directly from AsyncExecuteReportScheduleCommand.run (permalink
+ # pre-commit) without passing through _get_url, so it needs the same
+ # deleted-target guard.
+ if (
+ self._report_schedule.dashboard_id is not None
+ and self._report_schedule.dashboard is None
+ ):
+ raise ReportScheduleTargetDashboardDeletedError()
force = "true" if self._report_schedule.force_screenshot else "false"
if (
diff --git a/superset/daos/dashboard.py b/superset/daos/dashboard.py
index 135788929e8..4156eff4206 100644
--- a/superset/daos/dashboard.py
+++ b/superset/daos/dashboard.py
@@ -40,6 +40,7 @@ from superset.extensions import db
from superset.models.core import FavStar, FavStarClassName
from superset.models.dashboard import Dashboard, dashboard_user, id_or_slug_filter
from superset.models.embedded_dashboard import EmbeddedDashboard
+from superset.models.helpers import skip_visibility_filter
from superset.models.slice import Slice
from superset.utils import json
from superset.utils.core import get_user_id
@@ -294,11 +295,30 @@ class DashboardDAO(BaseDAO[Dashboard]):
if isinstance(value, dict)
]
- current_slices = (
- db.session.query(Slice).filter(Slice.id.in_(slice_ids)).all()
- )
-
- dashboard.slices = current_slices
+ # Bypass the soft-delete visibility filter when resolving the
+ # incoming chart ids: a dashboard's ``position_json`` may still
+ # reference a chart that is currently soft-deleted, and this
+ # assignment REBUILDS ``dashboard.slices`` wholesale. With the
+ # filter active, the hidden member would be silently dropped —
+ # deleting its ``dashboard_slices`` junction row (breaking the
+ # documented restore-reattach contract) and writing
+ # ``uuid: None`` into its position slot via ``uuid_map`` below.
+ #
+ # The bypass must be session-scoped and cover the ASSIGNMENT,
+ # not just the resolution query: assigning to
+ # ``dashboard.slices`` makes the unit of work diff the new
+ # collection against the existing one, which it lazy-loads at
+ # that moment. A filtered baseline load would exclude the
+ # trashed member, so the diff would treat it as net-new and
+ # INSERT a ``dashboard_slices`` row that still exists (soft
+ # delete never removes junction rows) — an IntegrityError on
+ # the composite primary key on every save of a dashboard
+ # containing a trashed chart.
+ with skip_visibility_filter(db.session, Slice):
+ current_slices = (
+ db.session.query(Slice).filter(Slice.id.in_(slice_ids)).all()
+ )
+ dashboard.slices = current_slices
# add UUID to positions
uuid_map = {slice.id: str(slice.uuid) for slice in current_slices}
diff --git a/superset/migrations/versions/2026-05-08_12-00_7c4a8d09ca37_add_deleted_at_to_slices.py b/superset/migrations/versions/2026-05-08_12-00_7c4a8d09ca37_add_deleted_at_to_slices.py
new file mode 100644
index 00000000000..60b8e064a08
--- /dev/null
+++ b/superset/migrations/versions/2026-05-08_12-00_7c4a8d09ca37_add_deleted_at_to_slices.py
@@ -0,0 +1,52 @@
+# 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.
+"""Add deleted_at column and index to slices for soft-delete.
+
+Adds a nullable ``deleted_at`` column and an index on it to the
+``slices`` table to support soft deletion of charts. Companion to
+the ``SoftDeleteMixin`` infrastructure shipped in PR #39977.
+
+Revision ID: 7c4a8d09ca37
+Revises: b4a3f2e1d0c9
+Create Date: 2026-05-08 12:00:00.000000
+"""
+
+from sqlalchemy import Column, DateTime
+
+from superset.migrations.shared.utils import (
+ add_columns,
+ create_index,
+ drop_columns,
+ drop_index,
+)
+
+# revision identifiers, used by Alembic.
+revision = "7c4a8d09ca37"
+down_revision = "b4a3f2e1d0c9"
+
+TABLE_NAME = "slices"
+INDEX_NAME = f"ix_{TABLE_NAME}_deleted_at"
+
+
+def upgrade() -> None:
+ add_columns(TABLE_NAME, Column("deleted_at", DateTime(), nullable=True))
+ create_index(TABLE_NAME, INDEX_NAME, ["deleted_at"])
+
+
+def downgrade() -> None:
+ drop_index(TABLE_NAME, INDEX_NAME)
+ drop_columns(TABLE_NAME, "deleted_at")
diff --git a/superset/models/slice.py b/superset/models/slice.py
index e9d977b027b..9c2e5ff7ae7 100644
--- a/superset/models/slice.py
+++ b/superset/models/slice.py
@@ -43,7 +43,11 @@ from superset_core.common.models import Chart as CoreChart
from superset import db, is_feature_enabled, security_manager
from superset.legacy import update_time_range
-from superset.models.helpers import AuditMixinNullable, ImportExportMixin
+from superset.models.helpers import (
+ AuditMixinNullable,
+ ImportExportMixin,
+ SoftDeleteMixin,
+)
from superset.tasks.thumbnails import cache_chart_thumbnail
from superset.tasks.utils import get_current_user
from superset.thumbnails.digest import get_chart_digest
@@ -76,7 +80,7 @@ logger = logging.getLogger(__name__)
class Slice( # pylint: disable=too-many-public-methods
- CoreChart, AuditMixinNullable, ImportExportMixin
+ CoreChart, SoftDeleteMixin, AuditMixinNullable, ImportExportMixin
):
"""A slice is essentially a report or a view on data"""
diff --git a/superset/translations/ar/LC_MESSAGES/messages.po b/superset/translations/ar/LC_MESSAGES/messages.po
index 46899c10f1c..be919c1e838 100644
--- a/superset/translations/ar/LC_MESSAGES/messages.po
+++ b/superset/translations/ar/LC_MESSAGES/messages.po
@@ -3374,6 +3374,9 @@ msgstr "تغييرات المخطط"
msgid "Chart could not be created."
msgstr "تعذر إنشاء مخطط."
+msgid "Chart could not be restored."
+msgstr ""
+
msgid "Chart could not be updated."
msgstr "تعذر تحديث المخطط."
@@ -14825,6 +14828,11 @@ msgstr ""
"فئة العقد المصدر المستخدمة لتعيين الألوان. إذا كانت العقدة مرتبطة بأكثر "
"من فئة واحدة، فسيتم استخدام الأولى فقط."
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
# sr_Latn]
#, fuzzy
@@ -14927,6 +14935,11 @@ msgstr "معيار رمز البلد الذي يجب أن تتوقع Superset ا
msgid "The dashboard has been saved"
msgstr "تم حفظ لوحة التحكم"
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
msgid "The data source seems to have been deleted"
msgstr "يبدو أن مصدر البيانات قد تم حذفه"
diff --git a/superset/translations/ca/LC_MESSAGES/messages.po b/superset/translations/ca/LC_MESSAGES/messages.po
index f3ca38c9060..1f3ce639b43 100644
--- a/superset/translations/ca/LC_MESSAGES/messages.po
+++ b/superset/translations/ca/LC_MESSAGES/messages.po
@@ -3292,6 +3292,9 @@ msgstr "Canvis del gràfic"
msgid "Chart could not be created."
msgstr "El gràfic no s'ha pogut crear."
+msgid "Chart could not be restored."
+msgstr ""
+
msgid "Chart could not be updated."
msgstr "El gràfic no s'ha pogut actualitzar."
@@ -14447,6 +14450,11 @@ msgstr ""
"La categoria de nodes font usada per assignar colors. Si un node està "
"associat amb més d'una categoria, només la primera s'usarà."
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
# sr_Latn]
#, fuzzy
@@ -14541,6 +14549,11 @@ msgstr ""
msgid "The dashboard has been saved"
msgstr "El dashboard s'ha desat"
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
msgid "The data source seems to have been deleted"
msgstr "La font de dades sembla haver estat eliminada"
diff --git a/superset/translations/cs/LC_MESSAGES/messages.po b/superset/translations/cs/LC_MESSAGES/messages.po
index 4aa6bcc3aab..739675d3a09 100644
--- a/superset/translations/cs/LC_MESSAGES/messages.po
+++ b/superset/translations/cs/LC_MESSAGES/messages.po
@@ -3161,6 +3161,9 @@ msgstr "Změny grafu"
msgid "Chart could not be created."
msgstr "Graf se nepodařilo vytvořit."
+msgid "Chart could not be restored."
+msgstr ""
+
msgid "Chart could not be updated."
msgstr "Graf se nepodařilo aktualizovat."
@@ -13865,6 +13868,11 @@ msgstr ""
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
# sr_Latn]
#, fuzzy
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
msgid "The chart is still loading. Please wait a moment and try again."
msgstr "Graf se stále načítá. Chvíli počkejte a zkuste to znovu."
@@ -13951,6 +13959,11 @@ msgstr "Standard kódu země, který by měl Superset očekávat ve sloupci [zem
msgid "The dashboard has been saved"
msgstr "Nástěnka byla uložena"
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
msgid "The data source seems to have been deleted"
msgstr "Zdá se, že zdroj dat byl smazán"
diff --git a/superset/translations/de/LC_MESSAGES/messages.po b/superset/translations/de/LC_MESSAGES/messages.po
index 202f3fee3be..f8b789bd100 100644
--- a/superset/translations/de/LC_MESSAGES/messages.po
+++ b/superset/translations/de/LC_MESSAGES/messages.po
@@ -3105,6 +3105,9 @@ msgstr "Diagrammänderungen"
msgid "Chart could not be created."
msgstr "Diagramm konnte nicht erstellt werden."
+msgid "Chart could not be restored."
+msgstr ""
+
msgid "Chart could not be updated."
msgstr "Diagramm konnte nicht aktualisiert werden."
@@ -13455,6 +13458,11 @@ msgstr ""
"werden. Wenn ein Knoten mehr als einer Kategorie zugeordnet ist, wird nur"
" die erste verwendet."
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
msgid "The chart is still loading. Please wait a moment and try again."
msgstr ""
"Das Diagramm wird noch geladen. Bitte warten Sie einen Moment und "
@@ -13545,6 +13553,11 @@ msgstr "Der Ländercodestandard, den Superset in der Spalte [Land] erwarten soll
msgid "The dashboard has been saved"
msgstr "Dashboard wurde erfolgreich gespeichert"
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
msgid "The data source seems to have been deleted"
msgstr "Die Datenquelle scheint gelöscht worden zu sein"
diff --git a/superset/translations/en/LC_MESSAGES/messages.po b/superset/translations/en/LC_MESSAGES/messages.po
index 75cf6c45fdf..a732cccdcc7 100644
--- a/superset/translations/en/LC_MESSAGES/messages.po
+++ b/superset/translations/en/LC_MESSAGES/messages.po
@@ -2791,6 +2791,9 @@ msgstr ""
msgid "Chart could not be created."
msgstr ""
+msgid "Chart could not be restored."
+msgstr ""
+
msgid "Chart could not be updated."
msgstr ""
@@ -12366,6 +12369,11 @@ msgid ""
"associated with more than one category, only the first will be used."
msgstr ""
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
msgid "The chart is still loading. Please wait a moment and try again."
msgstr ""
@@ -12437,6 +12445,11 @@ msgstr ""
msgid "The dashboard has been saved"
msgstr ""
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
msgid "The data source seems to have been deleted"
msgstr ""
diff --git a/superset/translations/es/LC_MESSAGES/messages.po b/superset/translations/es/LC_MESSAGES/messages.po
index e4aef0f9cea..3dc171c34ab 100644
--- a/superset/translations/es/LC_MESSAGES/messages.po
+++ b/superset/translations/es/LC_MESSAGES/messages.po
@@ -3252,6 +3252,9 @@ msgstr "Cambios en el gráfico"
msgid "Chart could not be created."
msgstr "No se ha podido crear el gráfico."
+msgid "Chart could not be restored."
+msgstr ""
+
msgid "Chart could not be updated."
msgstr "No se ha podido actualizar el gráfico."
@@ -14378,6 +14381,11 @@ msgstr ""
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
# sr_Latn]
#, fuzzy
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
msgid "The chart is still loading. Please wait a moment and try again."
msgstr ""
"El gráfico todavía se está cargando. Por favor, espere un momento e "
@@ -14472,6 +14480,11 @@ msgstr ""
msgid "The dashboard has been saved"
msgstr "El panel de control se ha guardado"
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
msgid "The data source seems to have been deleted"
msgstr "Parece que se ha eliminado la fuente de datos"
diff --git a/superset/translations/fa/LC_MESSAGES/messages.po b/superset/translations/fa/LC_MESSAGES/messages.po
index a704203e419..dcefbf6f033 100644
--- a/superset/translations/fa/LC_MESSAGES/messages.po
+++ b/superset/translations/fa/LC_MESSAGES/messages.po
@@ -3274,6 +3274,9 @@ msgstr "تغییرات نمودار"
msgid "Chart could not be created."
msgstr "نمودار نتوانسته ایجاد شود."
+msgid "Chart could not be restored."
+msgstr ""
+
msgid "Chart could not be updated."
msgstr "نمودار نتوانست بهروزرسانی شود."
@@ -14532,6 +14535,11 @@ msgstr ""
"دستهبندی گرههای منبع که برای اختصاص رنگها استفاده میشود. اگر یک گره "
"با بیش از یک دسته مرتبط باشد، تنها اولین دسته استفاده خواهد شد."
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
# sr_Latn]
#, fuzzy
@@ -14633,6 +14641,11 @@ msgstr "استاندارد کد کشور که سوپرسِت باید در ست
msgid "The dashboard has been saved"
msgstr "داشبورد ذخیره شد"
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
msgid "The data source seems to have been deleted"
msgstr "به نظر میرسد منبع داده حذف شده است."
diff --git a/superset/translations/fi/LC_MESSAGES/messages.po b/superset/translations/fi/LC_MESSAGES/messages.po
index 275dd94e5f6..c9ef1c62571 100644
--- a/superset/translations/fi/LC_MESSAGES/messages.po
+++ b/superset/translations/fi/LC_MESSAGES/messages.po
@@ -5396,6 +5396,9 @@ msgstr "Kaaviota ei voitu luoda."
# de, es, fa, fr, it, ja, ko, lv, mi, nl, pl, pt, pt_BR, ru, sk, sl, tr, uk,
# zh, zh_TW]
#, fuzzy
+msgid "Chart could not be restored."
+msgstr ""
+
msgid "Chart could not be updated."
msgstr "Kaaviota ei voitu päivittää."
@@ -24396,6 +24399,11 @@ msgstr ""
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
# sr_Latn]
#, fuzzy
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
msgid "The chart is still loading. Please wait a moment and try again."
msgstr "Kaavio latautuu vielä. Odota hetki ja yritä uudelleen."
@@ -24535,6 +24543,11 @@ msgstr "Maakodistandardi, jonka Superset odottaa löytävänsä [country]-sarakk
msgid "The dashboard has been saved"
msgstr "Koontinäyttö on tallennettu"
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: ar, ca, cs,
# de, es, fa, fr, ja, lv, mi, nl, pl, pt, pt_BR, ru, sk, sl, uk, zh, zh_TW]
#, fuzzy
diff --git a/superset/translations/fr/LC_MESSAGES/messages.po b/superset/translations/fr/LC_MESSAGES/messages.po
index 2efd8d16573..59e97333980 100644
--- a/superset/translations/fr/LC_MESSAGES/messages.po
+++ b/superset/translations/fr/LC_MESSAGES/messages.po
@@ -3175,6 +3175,9 @@ msgstr "Changements de graphique"
msgid "Chart could not be created."
msgstr "Le graphique n'a pas pu être créé."
+msgid "Chart could not be restored."
+msgstr ""
+
msgid "Chart could not be updated."
msgstr "Le graphique n'a pas pu être mis à jour."
@@ -14327,6 +14330,11 @@ msgstr ""
"La catégorie de nœuds sources utilisée pour attribuer des couleurs. Si un "
"nœud est associé à plus d’une catégorie, seul le premier sera utilisé."
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
# sr_Latn]
#, fuzzy
@@ -14418,6 +14426,11 @@ msgstr ""
msgid "The dashboard has been saved"
msgstr "Ce tableau de bord a été sauvegardé"
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
msgid "The data source seems to have been deleted"
msgstr "La source de données semble avoir été effacée"
diff --git a/superset/translations/it/LC_MESSAGES/messages.po b/superset/translations/it/LC_MESSAGES/messages.po
index 7250db62ed6..b10642cfe23 100644
--- a/superset/translations/it/LC_MESSAGES/messages.po
+++ b/superset/translations/it/LC_MESSAGES/messages.po
@@ -4813,6 +4813,9 @@ msgstr "Ultima Modifica"
msgid "Chart could not be created."
msgstr "La tua query non può essere salvata"
+msgid "Chart could not be restored."
+msgstr ""
+
msgid "Chart could not be updated."
msgstr "La tua query non può essere salvata"
@@ -22012,6 +22015,11 @@ msgstr ""
"La categoria dei nodi sorgente utilizzata per assegnare i colori. Se un "
"nodo è associato a più di una categoria, verrà utilizzata solo la prima."
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
# sr_Latn]
#, fuzzy
@@ -22161,6 +22169,11 @@ msgstr ""
msgid "The dashboard has been saved"
msgstr "Il dashboard è stato salvato"
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: ar, ca, cs,
# de, es, fa, fr, ja, lv, mi, nl, pl, pt, pt_BR, ro, ru, sk, sl, sr, sr_Latn,
# tr, uk, zh, zh_TW]
diff --git a/superset/translations/ja/LC_MESSAGES/messages.po b/superset/translations/ja/LC_MESSAGES/messages.po
index 07ca68392db..849ebfc3c1c 100644
--- a/superset/translations/ja/LC_MESSAGES/messages.po
+++ b/superset/translations/ja/LC_MESSAGES/messages.po
@@ -2886,6 +2886,9 @@ msgstr "チャートの変更"
msgid "Chart could not be created."
msgstr "チャートを作成できませんでした。"
+msgid "Chart could not be restored."
+msgstr ""
+
msgid "Chart could not be updated."
msgstr "チャートを更新できませんでした。"
@@ -12807,6 +12810,11 @@ msgstr "色の割り当てに使用されるソースノードのカテゴリ。
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
# sr_Latn]
#, fuzzy
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
msgid "The chart is still loading. Please wait a moment and try again."
msgstr "チャートはまだ読み込み中です。しばらく待ってから再試行してください。"
@@ -12880,6 +12888,11 @@ msgstr "Supersetが「国」列で想定する国コード規格。"
msgid "The dashboard has been saved"
msgstr "ダッシュボードを保存しました"
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
msgid "The data source seems to have been deleted"
msgstr "データソースが削除されたようです"
diff --git a/superset/translations/ko/LC_MESSAGES/messages.po b/superset/translations/ko/LC_MESSAGES/messages.po
index cab6a09c745..abf4a0836f0 100644
--- a/superset/translations/ko/LC_MESSAGES/messages.po
+++ b/superset/translations/ko/LC_MESSAGES/messages.po
@@ -4737,6 +4737,9 @@ msgstr "차트 변경 사항"
msgid "Chart could not be created."
msgstr "차트를 생성할 수 없습니다."
+msgid "Chart could not be restored."
+msgstr ""
+
msgid "Chart could not be updated."
msgstr "차트를 업데이트할 수 없습니다."
@@ -21700,6 +21703,11 @@ msgid ""
"associated with more than one category, only the first will be used."
msgstr "색상을 지정하는 데 사용되는 소스 노드의 카테고리입니다. 노드가 둘 이상의 카테고리와 연결된 경우 첫 번째 카테고리만 사용됩니다."
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
# sr_Latn]
#, fuzzy
@@ -21842,6 +21850,11 @@ msgstr "Superset이 [country] 열에서 찾을 것으로 예상하는 국가 코
msgid "The dashboard has been saved"
msgstr "대시보드가 저장되었습니다"
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: ar, ca, cs,
# de, es, fa, fr, ja, lv, mi, nl, pl, pt, pt_BR, ro, ru, sk, sl, sr, sr_Latn,
# tr, uk, zh, zh_TW]
diff --git a/superset/translations/lv/LC_MESSAGES/messages.po b/superset/translations/lv/LC_MESSAGES/messages.po
index 8aa385e9ebb..fef70f25dfb 100644
--- a/superset/translations/lv/LC_MESSAGES/messages.po
+++ b/superset/translations/lv/LC_MESSAGES/messages.po
@@ -3125,6 +3125,9 @@ msgstr "Diagrammas izmaiņas"
msgid "Chart could not be created."
msgstr "Diagrammu nevarēja izveidot."
+msgid "Chart could not be restored."
+msgstr ""
+
msgid "Chart could not be updated."
msgstr "Diagrammu nevarēja atjaunināt."
@@ -13548,6 +13551,11 @@ msgstr ""
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
# sr_Latn]
#, fuzzy
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
msgid "The chart is still loading. Please wait a moment and try again."
msgstr "Diagramma vēl tiek ielādēta. Lūdzu, uzgaidiet brīdi un mēģiniet vēlreiz."
@@ -13633,6 +13641,11 @@ msgstr "Valsts koda standarts, ko Superset sagaida [country] kolonnā"
msgid "The dashboard has been saved"
msgstr "Panelis ir saglabāts"
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
msgid "The data source seems to have been deleted"
msgstr "Šķiet, ka datu avots ir dzēsts"
diff --git a/superset/translations/messages.pot b/superset/translations/messages.pot
index f87e7c2d2ae..5b93b3bdefc 100644
--- a/superset/translations/messages.pot
+++ b/superset/translations/messages.pot
@@ -2798,6 +2798,9 @@ msgstr ""
msgid "Chart could not be created."
msgstr ""
+msgid "Chart could not be restored."
+msgstr ""
+
msgid "Chart could not be updated."
msgstr ""
@@ -12351,6 +12354,11 @@ msgid ""
"associated with more than one category, only the first will be used."
msgstr ""
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
msgid "The chart is still loading. Please wait a moment and try again."
msgstr ""
@@ -12422,6 +12430,11 @@ msgstr ""
msgid "The dashboard has been saved"
msgstr ""
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
msgid "The data source seems to have been deleted"
msgstr ""
diff --git a/superset/translations/mi/LC_MESSAGES/messages.po b/superset/translations/mi/LC_MESSAGES/messages.po
index 4eb73e2fd2d..2ef1b597691 100644
--- a/superset/translations/mi/LC_MESSAGES/messages.po
+++ b/superset/translations/mi/LC_MESSAGES/messages.po
@@ -3247,6 +3247,9 @@ msgstr "Huringa kauwhata"
msgid "Chart could not be created."
msgstr "Kāore i taea te hanga i te kauwhata."
+msgid "Chart could not be restored."
+msgstr ""
+
msgid "Chart could not be updated."
msgstr "Kāore i taea te whakahou i te kauwhata."
@@ -14356,6 +14359,11 @@ msgstr ""
"hāngai ana tētahi pona ki te nui atu i te kotahi kāwai, ko te tuatahi "
"anake ka whakamahia."
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
# sr_Latn]
#, fuzzy
@@ -14444,6 +14452,11 @@ msgstr ""
msgid "The dashboard has been saved"
msgstr "Kua tiakina te papatohu"
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
msgid "The data source seems to have been deleted"
msgstr "Kua mukua pea te puna raraunga"
diff --git a/superset/translations/nl/LC_MESSAGES/messages.po b/superset/translations/nl/LC_MESSAGES/messages.po
index 93a7e3e3f15..a5befeee807 100644
--- a/superset/translations/nl/LC_MESSAGES/messages.po
+++ b/superset/translations/nl/LC_MESSAGES/messages.po
@@ -3379,6 +3379,9 @@ msgstr "Veranderingen in de grafiek"
msgid "Chart could not be created."
msgstr "Grafiek kon niet worden aangemaakt."
+msgid "Chart could not be restored."
+msgstr ""
+
msgid "Chart could not be updated."
msgstr "De grafiek kon niet worden bijgewerkt."
@@ -14912,6 +14915,11 @@ msgstr ""
"Als een knooppunt is gekoppeld met meer dan één categorie, zal alleen de "
"eerste worden gebruikt."
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
# sr_Latn]
#, fuzzy
@@ -15017,6 +15025,11 @@ msgstr "De landcode die Superset verwacht te vinden in de [land] kolom"
msgid "The dashboard has been saved"
msgstr "Het dashboard is opgeslagen"
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
msgid "The data source seems to have been deleted"
msgstr "De gegevensbron lijkt te zijn verwijderd"
diff --git a/superset/translations/pl/LC_MESSAGES/messages.po b/superset/translations/pl/LC_MESSAGES/messages.po
index 63fb1ca8a8a..e46fe1c7809 100644
--- a/superset/translations/pl/LC_MESSAGES/messages.po
+++ b/superset/translations/pl/LC_MESSAGES/messages.po
@@ -3384,6 +3384,9 @@ msgstr "Zmiany wykresu"
msgid "Chart could not be created."
msgstr "Nie można utworzyć wykresu."
+msgid "Chart could not be restored."
+msgstr ""
+
msgid "Chart could not be updated."
msgstr "Nie można zaktualizować wykresu."
@@ -15247,6 +15250,11 @@ msgstr ""
"jest związany z więcej niż jedną kategorią, używana będzie tylko "
"pierwsza."
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
# sr_Latn]
#, fuzzy
@@ -15348,6 +15356,11 @@ msgstr ""
msgid "The dashboard has been saved"
msgstr "Pulpit nawigacyjny został zapisany."
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
msgid "The data source seems to have been deleted"
msgstr "Źródło danych wydaje się zostało usunięte."
diff --git a/superset/translations/pt/LC_MESSAGES/messages.po b/superset/translations/pt/LC_MESSAGES/messages.po
index a709de51512..36fe4bd6a4b 100644
--- a/superset/translations/pt/LC_MESSAGES/messages.po
+++ b/superset/translations/pt/LC_MESSAGES/messages.po
@@ -4759,6 +4759,9 @@ msgstr "Modificado pela última vez"
msgid "Chart could not be created."
msgstr "Não foi possível gravar a sua query"
+msgid "Chart could not be restored."
+msgstr ""
+
msgid "Chart could not be updated."
msgstr "Não foi possível gravar a sua query"
@@ -21413,6 +21416,11 @@ msgstr ""
"estiver associado a mais do que uma categoria, apenas a primeira será "
"utilizada."
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
# sr_Latn]
#, fuzzy
@@ -21548,6 +21556,11 @@ msgstr ""
msgid "The dashboard has been saved"
msgstr "Dashboard gravado com sucesso."
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
msgid "The data source seems to have been deleted"
msgstr "Esta origem de dados parece ter sido excluída"
diff --git a/superset/translations/pt_BR/LC_MESSAGES/messages.po b/superset/translations/pt_BR/LC_MESSAGES/messages.po
index 1889d1aec6c..ae3db8d64e0 100644
--- a/superset/translations/pt_BR/LC_MESSAGES/messages.po
+++ b/superset/translations/pt_BR/LC_MESSAGES/messages.po
@@ -3334,6 +3334,9 @@ msgstr "Alterações no gráfico"
msgid "Chart could not be created."
msgstr "Não foi possível criar o gráfico."
+msgid "Chart could not be restored."
+msgstr ""
+
msgid "Chart could not be updated."
msgstr "Não foi possível atualizar o gráfico."
@@ -14976,6 +14979,11 @@ msgstr ""
"estiver associado a mais do que uma categoria, apenas a primeira será "
"utilizada."
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
# sr_Latn]
#, fuzzy
@@ -15080,6 +15088,11 @@ msgstr ""
msgid "The dashboard has been saved"
msgstr "O painel foi salvo"
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
msgid "The data source seems to have been deleted"
msgstr "A Fonte de dados parece ter sido excluída"
diff --git a/superset/translations/ro/LC_MESSAGES/messages.po b/superset/translations/ro/LC_MESSAGES/messages.po
index 8470bc31289..bb6b77b1ff3 100644
--- a/superset/translations/ro/LC_MESSAGES/messages.po
+++ b/superset/translations/ro/LC_MESSAGES/messages.po
@@ -12046,9 +12046,19 @@ msgstr ""
"Standardul codului de țară pe care Superset ar trebui să-l găsească în "
"coloana [country]"
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
msgid "The dashboard has been saved"
msgstr "Panoul de control a fost salvat"
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
msgid "The data source seems to have been deleted"
msgstr "Sursa de date pare să fi fost ștearsă"
diff --git a/superset/translations/ru/LC_MESSAGES/messages.po b/superset/translations/ru/LC_MESSAGES/messages.po
index a32e585d9a1..02372849216 100644
--- a/superset/translations/ru/LC_MESSAGES/messages.po
+++ b/superset/translations/ru/LC_MESSAGES/messages.po
@@ -3110,6 +3110,9 @@ msgstr "Изменения диаграммы"
msgid "Chart could not be created."
msgstr "Не удалось создать диаграмму"
+msgid "Chart could not be restored."
+msgstr ""
+
msgid "Chart could not be updated."
msgstr "Не удалось обновить диаграмму"
@@ -13527,6 +13530,11 @@ msgstr ""
"Категория исходных вершин предназначена для задания цветов. Если вершина "
"связана более, чем с одной категорией, только первая будет использована."
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
# sr_Latn]
#, fuzzy
@@ -13610,6 +13618,11 @@ msgstr "Код страны, который Superset ожидает найти
msgid "The dashboard has been saved"
msgstr "Дашборд сохранен"
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
msgid "The data source seems to have been deleted"
msgstr "Похоже, источник данных был удален"
diff --git a/superset/translations/sk/LC_MESSAGES/messages.po b/superset/translations/sk/LC_MESSAGES/messages.po
index 87bfd1f5ff8..9b4b458f254 100644
--- a/superset/translations/sk/LC_MESSAGES/messages.po
+++ b/superset/translations/sk/LC_MESSAGES/messages.po
@@ -3161,6 +3161,9 @@ msgstr "Zmeny grafu"
msgid "Chart could not be created."
msgstr "Graf sa nepodarilo vytvoriť."
+msgid "Chart could not be restored."
+msgstr ""
+
msgid "Chart could not be updated."
msgstr "Graf sa nepodarilo aktualizovať."
@@ -13863,6 +13866,11 @@ msgstr ""
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
# sr_Latn]
#, fuzzy
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
msgid "The chart is still loading. Please wait a moment and try again."
msgstr "Graf sa ešte načítava. Chvíľu počkajte a skúste to znova."
@@ -13949,6 +13957,11 @@ msgstr "Standard kódu zeme, ktorý by mel Superset očekávat ve stĺpci [zeme]
msgid "The dashboard has been saved"
msgstr "Nástenka bola uložena"
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
msgid "The data source seems to have been deleted"
msgstr "Zdá se, že zdroj dat bol zmazaný"
diff --git a/superset/translations/sl/LC_MESSAGES/messages.po b/superset/translations/sl/LC_MESSAGES/messages.po
index 42105e377c5..322cfd151ad 100644
--- a/superset/translations/sl/LC_MESSAGES/messages.po
+++ b/superset/translations/sl/LC_MESSAGES/messages.po
@@ -3300,6 +3300,9 @@ msgstr "Spremembe grafikona"
msgid "Chart could not be created."
msgstr "Grafikona ni mogoče ustvariti."
+msgid "Chart could not be restored."
+msgstr ""
+
msgid "Chart could not be updated."
msgstr "Grafikona ni mogoče posodobiti."
@@ -14552,6 +14555,11 @@ msgstr ""
"Kategorija izvornih vozlišč, na podlagi katere je določena barva. Če je "
"vozlišče povezano z več kot eno kategorijo, bo uporabljena samo prva."
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
# sr_Latn]
#, fuzzy
@@ -14648,6 +14656,11 @@ msgstr "Standard za oznake držav, ki bodo podane v stolpcu z državami"
msgid "The dashboard has been saved"
msgstr "Nadzorna plošča je bila shranjena"
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
msgid "The data source seems to have been deleted"
msgstr "Zdi se, da je bil podatkovni vir izbrisan"
diff --git a/superset/translations/sr/LC_MESSAGES/messages.po b/superset/translations/sr/LC_MESSAGES/messages.po
index 556ce79f641..0c92bafcbc3 100644
--- a/superset/translations/sr/LC_MESSAGES/messages.po
+++ b/superset/translations/sr/LC_MESSAGES/messages.po
@@ -16840,6 +16840,11 @@ msgstr ""
"Категорија изворних чворова која се користи за додељивање боја. Ако је чвор "
"повезан са више од једне категорије, користиће се само прва."
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
msgid "The chart is still loading. Please wait a moment and try again."
msgstr "Графикон се још учитава. Сачекајте тренутак и покушајте поново."
@@ -16933,6 +16938,11 @@ msgstr "Стандард кода земље који Superset треба да
msgid "The dashboard has been saved"
msgstr "Контролна табла је сачувана"
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
# de, es, fa, fr, ja, lv, mi, nl, pl, pt, pt_BR, ru, sk, sl, uk, zh, zh_TW]
msgid "The data source seems to have been deleted"
msgstr "Изгледа да је извор података обрисан"
diff --git a/superset/translations/sr_Latn/LC_MESSAGES/messages.po b/superset/translations/sr_Latn/LC_MESSAGES/messages.po
index 13612f898b9..c9e17ca290a 100644
--- a/superset/translations/sr_Latn/LC_MESSAGES/messages.po
+++ b/superset/translations/sr_Latn/LC_MESSAGES/messages.po
@@ -16855,6 +16855,11 @@ msgstr ""
"Kategorija izvornih čvorova koja se koristi za dodeljivanje boja. Ako je "
"čvor povezan sa više od jedne kategorije, koristiće se samo prva."
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
msgid "The chart is still loading. Please wait a moment and try again."
msgstr "Grafikon se još učitava. Sačekajte trenutak i pokušajte ponovo."
@@ -16949,6 +16954,11 @@ msgstr ""
msgid "The dashboard has been saved"
msgstr "Kontrolna tabla je sačuvana"
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
# de, es, fa, fr, ja, lv, mi, nl, pl, pt, pt_BR, ru, sk, sl, uk, zh, zh_TW]
msgid "The data source seems to have been deleted"
msgstr "Izgleda da je izvor podataka obrisan"
diff --git a/superset/translations/th/LC_MESSAGES/messages.po b/superset/translations/th/LC_MESSAGES/messages.po
index 19942a66c36..ca33fa4f51f 100644
--- a/superset/translations/th/LC_MESSAGES/messages.po
+++ b/superset/translations/th/LC_MESSAGES/messages.po
@@ -5285,6 +5285,9 @@ msgstr "ไม่สามารถสร้างแผนภูมิได้
# de, es, fa, fr, it, ja, ko, lv, mi, nl, pl, pt, pt_BR, ru, sk, sl, tr, uk,
# zh, zh_TW]
#, fuzzy
+msgid "Chart could not be restored."
+msgstr ""
+
msgid "Chart could not be updated."
msgstr "ไม่สามารถอัปเดตแผนภูมิได้"
@@ -24131,6 +24134,11 @@ msgstr ""
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
# sr_Latn]
#, fuzzy
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
msgid "The chart is still loading. Please wait a moment and try again."
msgstr "แผนภูมิยังคงโหลดอยู่ กรุณารอสักครู่แล้วลองอีกครั้ง"
@@ -24268,6 +24276,11 @@ msgstr "มาตรฐานรหัสประเทศที่ Superset
msgid "The dashboard has been saved"
msgstr "บันทึกแดชบอร์ดแล้ว"
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: ar, ca, cs,
# de, es, fa, fr, ja, lv, mi, nl, pl, pt, pt_BR, ru, sk, sl, uk, zh, zh_TW]
#, fuzzy
diff --git a/superset/translations/tr/LC_MESSAGES/messages.po b/superset/translations/tr/LC_MESSAGES/messages.po
index e2e3d328444..2fc8b0e4262 100644
--- a/superset/translations/tr/LC_MESSAGES/messages.po
+++ b/superset/translations/tr/LC_MESSAGES/messages.po
@@ -3261,6 +3261,9 @@ msgstr "Grafik değişiklikleri"
msgid "Chart could not be created."
msgstr "Grafik oluşturulamadı."
+msgid "Chart could not be restored."
+msgstr ""
+
msgid "Chart could not be updated."
msgstr "Grafik gencellenemedi."
@@ -14314,6 +14317,11 @@ msgstr ""
"Renkler atamak için kullanılan kaynak düğümleri kategorisi. Eğer bir node"
" bir kategoriden daha ilişkiliyse, sadece ilki kullanılacaktır."
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
# sr_Latn]
#, fuzzy
@@ -14403,6 +14411,11 @@ msgstr "Superset'in [ülkede bulmayı beklemesi gereken ülke kodu standardı] s
msgid "The dashboard has been saved"
msgstr "Panel kurtarıldı"
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
msgid "The data source seems to have been deleted"
msgstr "Veri kaynağı silinmiş gibi görünüyor"
diff --git a/superset/translations/uk/LC_MESSAGES/messages.po b/superset/translations/uk/LC_MESSAGES/messages.po
index b33bbeb27ee..26cd8c2bc9c 100644
--- a/superset/translations/uk/LC_MESSAGES/messages.po
+++ b/superset/translations/uk/LC_MESSAGES/messages.po
@@ -3109,6 +3109,9 @@ msgstr "Зміни діаграми"
msgid "Chart could not be created."
msgstr "Не вдалося створити діаграму."
+msgid "Chart could not be restored."
+msgstr ""
+
msgid "Chart could not be updated."
msgstr "Не вдалося оновити діаграму."
@@ -13522,6 +13525,11 @@ msgstr ""
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
# sr_Latn]
#, fuzzy
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
msgid "The chart is still loading. Please wait a moment and try again."
msgstr "Графік ще завантажується. Зачекайте хвилину і спробуйте знову."
@@ -13607,6 +13615,11 @@ msgstr "Стандарт коду країни, який очікується в
msgid "The dashboard has been saved"
msgstr "Звіт збережено"
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
msgid "The data source seems to have been deleted"
msgstr "Схоже, джерела даних вилучено"
diff --git a/superset/translations/zh/LC_MESSAGES/messages.po b/superset/translations/zh/LC_MESSAGES/messages.po
index de391e36a28..50cc9fd5bba 100644
--- a/superset/translations/zh/LC_MESSAGES/messages.po
+++ b/superset/translations/zh/LC_MESSAGES/messages.po
@@ -3370,6 +3370,9 @@ msgstr "图表变化"
msgid "Chart could not be created."
msgstr "您的图表无法创建。"
+msgid "Chart could not be restored."
+msgstr ""
+
msgid "Chart could not be updated."
msgstr "您的图表无法更新。"
@@ -15096,6 +15099,11 @@ msgid ""
"associated with more than one category, only the first will be used."
msgstr "用于分配颜色的源节点类别。如果一个节点与多个类别关联,则只使用第一个类别"
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
# sr_Latn]
#, fuzzy
@@ -15194,6 +15202,11 @@ msgstr "Superset 希望能够在 [国家] 栏中找到的 国家 / 地区 的标
msgid "The dashboard has been saved"
msgstr "该看板已成功保存。"
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
msgid "The data source seems to have been deleted"
msgstr "数据源已经被删除"
diff --git a/superset/translations/zh_TW/LC_MESSAGES/messages.po b/superset/translations/zh_TW/LC_MESSAGES/messages.po
index 2f84c608bd3..bf406403b80 100644
--- a/superset/translations/zh_TW/LC_MESSAGES/messages.po
+++ b/superset/translations/zh_TW/LC_MESSAGES/messages.po
@@ -3349,6 +3349,9 @@ msgstr "圖表變化"
msgid "Chart could not be created."
msgstr "您的圖表無法創建。"
+msgid "Chart could not be restored."
+msgstr ""
+
msgid "Chart could not be updated."
msgstr "您的圖表無法更新。"
@@ -15075,6 +15078,11 @@ msgid ""
"with more than one category, only the first will be used."
msgstr "用於分配颜色的源節點類别。如果一個節點與多個類别關聯,则只使用第一個類别"
+msgid ""
+"The chart this report targets was deleted. Restore the chart, or update "
+"the report to point at an active chart."
+msgstr ""
+
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
# sr_Latn]
#, fuzzy
@@ -15169,6 +15177,11 @@ msgstr "Superset 希望能够在 [國家] 欄中找到的 國家 / 地區 的標
msgid "The dashboard has been saved"
msgstr "該看板已成功保存。"
+msgid ""
+"The dashboard this report targets was deleted. Restore the dashboard, or "
+"update the report to point at an active dashboard."
+msgstr ""
+
msgid "The data source seems to have been deleted"
msgstr "數據源已經被删除"
diff --git a/tests/integration_tests/charts/commands_tests.py b/tests/integration_tests/charts/commands_tests.py
index 75d8f7d2a22..01063278dc4 100644
--- a/tests/integration_tests/charts/commands_tests.py
+++ b/tests/integration_tests/charts/commands_tests.py
@@ -721,7 +721,7 @@ class TestFavoriteChartCommand(SupersetTestCase):
def test_fave_unfave_chart_command_not_found(self):
"""Test that faving / unfaving a non-existing chart raises an exception"""
with self.client.application.test_request_context():
- example_chart_id = 1234
+ example_chart_id = 0
with override_user(security_manager.find_user("admin")):
with self.assertRaises(ChartNotFoundError): # noqa: PT027
diff --git a/tests/integration_tests/charts/soft_delete_tests.py b/tests/integration_tests/charts/soft_delete_tests.py
new file mode 100644
index 00000000000..e424289e0f4
--- /dev/null
+++ b/tests/integration_tests/charts/soft_delete_tests.py
@@ -0,0 +1,579 @@
+# 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.
+"""Integration tests for chart soft-delete and restore."""
+
+from datetime import datetime
+
+from superset import security_manager
+from superset.connectors.sqla.models import SqlaTable
+from superset.constants import SKIP_VISIBILITY_FILTER_CLASSES
+from superset.extensions import db
+from superset.models.dashboard import Dashboard, dashboard_slices
+from superset.models.slice import Slice
+from superset.reports.models import (
+ ReportCreationMethod,
+ ReportSchedule,
+ ReportScheduleType,
+)
+from superset.utils import json
+from tests.integration_tests.base_tests import SupersetTestCase
+from tests.integration_tests.conftest import with_feature_flags
+from tests.integration_tests.constants import (
+ ADMIN_USERNAME,
+ ALPHA_USERNAME,
+ GAMMA_USERNAME,
+)
+from tests.integration_tests.insert_chart_mixin import InsertChartMixin
+
+
+def _hard_delete_chart(chart_id: int) -> None:
+ """Hard-delete a chart row regardless of soft-delete state."""
+ row = (
+ db.session.query(Slice)
+ .execution_options(**{SKIP_VISIBILITY_FILTER_CLASSES: {Slice}})
+ .filter(Slice.id == chart_id)
+ .one_or_none()
+ )
+ if row:
+ db.session.delete(row)
+ db.session.commit()
+
+
+def _hard_delete_dashboard_for_charts_test(dashboard_id: int) -> None:
+ """Hard-delete a dashboard row regardless of soft-delete state."""
+ row = (
+ db.session.query(Dashboard)
+ .execution_options(**{SKIP_VISIBILITY_FILTER_CLASSES: {Dashboard}})
+ .filter(Dashboard.id == dashboard_id)
+ .one_or_none()
+ )
+ if row:
+ db.session.delete(row)
+ db.session.commit()
+
+
+class TestChartSoftDelete(InsertChartMixin, SupersetTestCase):
+ """Tests for chart soft-delete behaviour (T013, T016)."""
+
+ @with_feature_flags(SOFT_DELETE=True)
+ def test_delete_chart_soft_deletes(self) -> None:
+ """DELETE /api/v1/chart/<pk> sets deleted_at instead of removing."""
+ admin_id = self.get_user("admin").id
+ chart = self.insert_chart("soft_delete_test", [admin_id], 1)
+ chart_id = chart.id
+ self.login(ADMIN_USERNAME)
+
+ rv = self.client.delete(f"/api/v1/chart/{chart_id}")
+ assert rv.status_code == 200
+
+ # Row still exists in DB with deleted_at set
+ row = (
+ db.session.query(Slice)
+ .execution_options(**{SKIP_VISIBILITY_FILTER_CLASSES: {Slice}})
+ .filter(Slice.id == chart_id)
+ .one_or_none()
+ )
+ assert row is not None
+ assert row.deleted_at is not None
+
+ # Cleanup
+ _hard_delete_chart(chart_id)
+
+ @with_feature_flags(SOFT_DELETE=True)
+ def test_soft_deleted_chart_excluded_from_get(self) -> None:
+ """GET /api/v1/chart/<pk> returns 404 for a soft-deleted chart."""
+ admin_id = self.get_user("admin").id
+ chart = self.insert_chart("invisible_chart", [admin_id], 1)
+ chart_id = chart.id
+ self.login(ADMIN_USERNAME)
+
+ self.client.delete(f"/api/v1/chart/{chart_id}")
+ rv = self.client.get(f"/api/v1/chart/{chart_id}")
+ assert rv.status_code == 404
+
+ # Cleanup
+ _hard_delete_chart(chart_id)
+
+ @with_feature_flags(SOFT_DELETE=True)
+ def test_soft_deleted_chart_excluded_from_list(self) -> None:
+ """GET /api/v1/chart/ should not include soft-deleted charts."""
+ admin_id = self.get_user("admin").id
+ chart = self.insert_chart("listed_then_deleted", [admin_id], 1)
+ chart_id = chart.id
+ self.login(ADMIN_USERNAME)
+
+ self.client.delete(f"/api/v1/chart/{chart_id}")
+ rv = self.client.get("/api/v1/chart/")
+ data = json.loads(rv.data)
+ chart_ids = [c["id"] for c in data["result"]]
+ assert chart_id not in chart_ids
+
+ # Cleanup
+ _hard_delete_chart(chart_id)
+
+ @with_feature_flags(SOFT_DELETE=True)
+ def test_soft_deleted_chart_included_in_list_when_requested(self) -> None:
+ """GET /api/v1/chart/ with chart_deleted_state=include returns deleted charts.""" # noqa: E501
+ admin_id = self.get_user("admin").id
+ chart = self.insert_chart("listed_with_deleted", [admin_id], 1)
+ chart_id = chart.id
+ self.login(ADMIN_USERNAME)
+
+ self.client.delete(f"/api/v1/chart/{chart_id}")
+
+ rison_query = "(filters:!((col:id,opr:chart_deleted_state,value:include)))"
+ rv = self.client.get(f"/api/v1/chart/?q={rison_query}")
+ assert rv.status_code == 200
+
+ data = json.loads(rv.data)
+ deleted_row = next(
+ (row for row in data["result"] if row["id"] == chart_id),
+ None,
+ )
+ assert deleted_row is not None
+ assert deleted_row["deleted_at"] is not None
+
+ # Cleanup
+ _hard_delete_chart(chart_id)
+
+ @with_feature_flags(SOFT_DELETE=True)
+ def test_only_filter_returns_only_soft_deleted_charts(self) -> None:
+ """chart_deleted_state=only excludes live rows and returns only deleted ones."""
+ admin_id = self.get_user("admin").id
+ live_chart = self.insert_chart("only_live", [admin_id], 1)
+ deleted_chart = self.insert_chart("only_deleted", [admin_id], 1)
+ live_id = live_chart.id
+ deleted_id = deleted_chart.id
+ self.login(ADMIN_USERNAME)
+
+ self.client.delete(f"/api/v1/chart/{deleted_id}")
+
+ rison_query = "(filters:!((col:id,opr:chart_deleted_state,value:only)))"
+ rv = self.client.get(f"/api/v1/chart/?q={rison_query}")
+ assert rv.status_code == 200
+
+ data = json.loads(rv.data)
+ returned_ids = {row["id"] for row in data["result"]}
+ assert deleted_id in returned_ids
+ assert live_id not in returned_ids
+
+ # Cleanup
+ _hard_delete_chart(live_id)
+ _hard_delete_chart(deleted_id)
+
+ @with_feature_flags(SOFT_DELETE=True)
+ def test_deleted_state_list_shows_owner_their_own_deleted(self) -> None:
+ """A non-admin owner can still enumerate their own soft-deleted charts.
+ Deleted-state scoping mirrors the restore audience, so it must not lock
+ owners out of their own trash."""
+ alpha_id = self.get_user(ALPHA_USERNAME).id
+ chart = self.insert_chart("sd_owner_chart", [alpha_id], 1)
+ chart_id = chart.id
+
+ chart.deleted_at = datetime(2026, 1, 1, 12, 0, 0)
+ db.session.commit()
+
+ self.login(ALPHA_USERNAME)
+ rison_query = (
+ "(filters:!((col:id,opr:chart_deleted_state,value:only)),page_size:200)"
+ )
+ rv = self.client.get(f"/api/v1/chart/?q={rison_query}")
+ assert rv.status_code == 200
+ ids = [c["id"] for c in json.loads(rv.data)["result"]]
+ assert chart_id in ids
+
+ # Cleanup
+ _hard_delete_chart(chart_id)
+
+ @with_feature_flags(SOFT_DELETE=True)
+ def test_deleted_state_list_hides_non_owned_from_read_access_user(self) -> None:
+ """A read-access non-owner must not enumerate a chart once it is
+ soft-deleted.
+
+ Gamma is granted ``datasource_access`` to the chart's dataset, so
+ ``ChartFilter`` makes the chart visible to gamma while it is live. After
+ soft-delete, the deleted-state list is scoped to the restore audience
+ (owners/admins), so gamma — who could never restore it — must not see it
+ via ``include`` or ``only``.
+ """
+ admin_id = self.get_user(ADMIN_USERNAME).id
+ chart = self.insert_chart("sd_acl_chart", [admin_id], 1)
+ chart_id = chart.id
+
+ table = db.session.query(SqlaTable).get(1)
+ gamma_role = security_manager.find_role("Gamma")
+ pvm = security_manager.add_permission_view_menu("datasource_access", table.perm)
+ gamma_role.permissions.append(pvm)
+ db.session.commit()
+
+ try:
+ # Precondition: gamma can see the chart while it is live.
+ self.login(GAMMA_USERNAME)
+ rv = self.client.get("/api/v1/chart/?q=(page_size:200)")
+ assert chart_id in [c["id"] for c in json.loads(rv.data)["result"]], (
+ "precondition: gamma should see the live chart via datasource access"
+ )
+
+ # Soft-delete directly (avoids a mid-test re-login to admin).
+ reloaded = (
+ db.session.query(Slice)
+ .execution_options(**{SKIP_VISIBILITY_FILTER_CLASSES: {Slice}})
+ .filter(Slice.id == chart_id)
+ .one()
+ )
+ reloaded.deleted_at = datetime(2026, 1, 1, 12, 0, 0)
+ db.session.commit()
+
+ # Gamma must not see the soft-deleted chart in either mode.
+ for value in ("include", "only"):
+ rison_query = (
+ f"(filters:!((col:id,opr:chart_deleted_state,value:{value})),"
+ "page_size:200)"
+ )
+ rv = self.client.get(f"/api/v1/chart/?q={rison_query}")
+ assert rv.status_code == 200
+ ids = [c["id"] for c in json.loads(rv.data)["result"]]
+ assert chart_id not in ids, (
+ "read-access non-owner must not enumerate a soft-deleted "
+ f"chart via chart_deleted_state={value}"
+ )
+ finally:
+ pvm = security_manager.find_permission_view_menu(
+ "datasource_access", table.perm
+ )
+ if pvm:
+ security_manager.del_permission_role(gamma_role, pvm)
+ db.session.commit()
+ _hard_delete_chart(chart_id)
+
+ @with_feature_flags(SOFT_DELETE=True)
+ def test_delete_already_soft_deleted_chart_returns_404(self) -> None:
+ """DELETE on an already soft-deleted chart returns 404 (FR-008)."""
+ admin_id = self.get_user("admin").id
+ chart = self.insert_chart("double_delete_test", [admin_id], 1)
+ chart_id = chart.id
+ self.login(ADMIN_USERNAME)
+
+ rv = self.client.delete(f"/api/v1/chart/{chart_id}")
+ assert rv.status_code == 200
+ rv = self.client.delete(f"/api/v1/chart/{chart_id}")
+ assert rv.status_code == 404
+
+ # Cleanup
+ _hard_delete_chart(chart_id)
+
+ @with_feature_flags(SOFT_DELETE=True)
+ def test_delete_chart_blocked_when_report_references_it(self) -> None:
+ """DELETE /api/v1/chart/<id> returns 422 when a report references it.
+
+ Pins down the existing API protection in `DeleteChartCommand.validate()`:
+ when *any* `report_schedule` row references the chart — active or
+ paused; `ReportScheduleDAO.find_by_chart_ids` has no active-only
+ predicate — the validation raises `ChartDeleteFailedReportsExistError`
+ *before* `ChartDAO.delete()` is invoked, so no soft-delete routing
+ happens. This is the contract soft-delete inherits from the
+ pre-existing API; the validate/commit race and flag-toggle windows it
+ cannot close are handled by the defensive guard in
+ `commands/report/execute.py:_get_url`.
+ """
+ admin_id = self.get_user("admin").id
+ chart = self.insert_chart("blocked_by_report_test", [admin_id], 1)
+ chart_id = chart.id
+
+ report = ReportSchedule(
+ type=ReportScheduleType.REPORT,
+ name="blocking_report_for_chart_delete",
+ description="Report that should block chart deletion",
+ crontab="0 9 * * *",
+ chart=chart,
+ creation_method=ReportCreationMethod.ALERTS_REPORTS,
+ )
+ db.session.add(report)
+ db.session.commit()
+ report_id = report.id
+
+ self.login(ADMIN_USERNAME)
+
+ rv = self.client.delete(f"/api/v1/chart/{chart_id}")
+ assert rv.status_code == 422
+ body = json.loads(rv.data)
+ assert "associated alerts or reports" in body.get("message", "").lower() or (
+ "associated" in body.get("message", "").lower()
+ and "report" in body.get("message", "").lower()
+ )
+ assert "blocking_report_for_chart_delete" in body.get("message", "")
+
+ # Confirm the chart was NOT soft-deleted (deleted_at remains NULL).
+ row = (
+ db.session.query(Slice)
+ .execution_options(**{SKIP_VISIBILITY_FILTER_CLASSES: {Slice}})
+ .filter(Slice.id == chart_id)
+ .one()
+ )
+ assert row.deleted_at is None
+
+ # Cleanup
+ db.session.delete(
+ db.session.query(ReportSchedule)
+ .filter(ReportSchedule.id == report_id)
+ .one()
+ )
+ db.session.commit()
+ _hard_delete_chart(chart_id)
+
+
+class TestChartRestore(InsertChartMixin, SupersetTestCase):
+ """Tests for chart restore behaviour (T025)."""
+
+ @with_feature_flags(SOFT_DELETE=True)
+ def test_restore_soft_deleted_chart(self) -> None:
+ """POST /api/v1/chart/<uuid>/restore makes the chart visible again."""
+ admin_id = self.get_user("admin").id
+ chart = self.insert_chart("restore_test", [admin_id], 1)
+ chart_id = chart.id
+ chart_uuid = str(chart.uuid)
+ self.login(ADMIN_USERNAME)
+
+ self.client.delete(f"/api/v1/chart/{chart_id}")
+ rv = self.client.post(f"/api/v1/chart/{chart_uuid}/restore")
+ assert rv.status_code == 200
+
+ rv = self.client.get(f"/api/v1/chart/{chart_id}")
+ assert rv.status_code == 200
+
+ # Cleanup
+ _hard_delete_chart(chart_id)
+
+ @with_feature_flags(SOFT_DELETE=True)
+ def test_restore_failure_returns_422(self) -> None:
+ """A failure during restore surfaces as a clean 422 via the
+ ``ChartRestoreFailedError`` handler rather than an unhandled 500.
+
+ ``RestoreChartCommand.run`` wraps the restore in ``@transaction``
+ and rethrows ``ChartRestoreFailedError`` on any underlying
+ SQLAlchemy error; this pins that the endpoint maps it to 422.
+ """
+ from unittest.mock import patch
+
+ from superset.commands.chart.exceptions import (
+ ChartRestoreFailedError,
+ )
+
+ admin_id = self.get_user("admin").id
+ chart = self.insert_chart("restore_fail_test", [admin_id], 1)
+ chart_id = chart.id
+ chart_uuid = str(chart.uuid)
+ self.login(ADMIN_USERNAME)
+ self.client.delete(f"/api/v1/chart/{chart_id}")
+
+ with patch(
+ "superset.commands.chart.restore.RestoreChartCommand.run",
+ side_effect=ChartRestoreFailedError(),
+ ):
+ rv = self.client.post(f"/api/v1/chart/{chart_uuid}/restore")
+ assert rv.status_code == 422
+
+ # Cleanup
+ _hard_delete_chart(chart_id)
+
+ @with_feature_flags(SOFT_DELETE=True)
+ def test_restore_nonexistent_chart_returns_404(self) -> None:
+ """POST /api/v1/chart/<uuid>/restore returns 404 for unknown UUID."""
+ self.login(ADMIN_USERNAME)
+ rv = self.client.post(
+ "/api/v1/chart/00000000-0000-0000-0000-000000000000/restore"
+ )
+ assert rv.status_code == 404
+
+ @with_feature_flags(SOFT_DELETE=True)
+ def test_restore_active_chart_returns_404(self) -> None:
+ """POST /api/v1/chart/<uuid>/restore on active chart returns 404."""
+ admin_id = self.get_user("admin").id
+ chart = self.insert_chart("active_restore_test", [admin_id], 1)
+ chart_id = chart.id
+ chart_uuid = str(chart.uuid)
+ self.login(ADMIN_USERNAME)
+
+ rv = self.client.post(f"/api/v1/chart/{chart_uuid}/restore")
+ assert rv.status_code == 404
+
+ # Cleanup
+ _hard_delete_chart(chart_id)
+
+ @with_feature_flags(SOFT_DELETE=True)
+ def test_restore_uses_can_write_permission(self) -> None:
+ """Non-admin owner with ``can_write_Chart`` can hit the restore
+ endpoint.
+
+ Pins the permission contract: ``method_permission_name`` must map
+ ``restore`` to ``write`` so FAB's ``@protect`` resolves the gate to
+ ``can_write_Chart`` (which Alpha already carries), not the implicit
+ fallback ``can_restore_Chart`` (which no standard role carries).
+
+ Without the mapping FAB defaults to ``can_<method>_<class>`` and
+ every non-admin would get 403 here — admins bypass FAB permission
+ checks entirely, so the admin-authed restore tests above don't
+ exercise the mapping.
+ """
+ alpha = self.get_user(ALPHA_USERNAME)
+ chart = self.insert_chart("restore_perm_test", [alpha.id], 1)
+ chart_id = chart.id
+ chart_uuid = str(chart.uuid)
+
+ self.login(ALPHA_USERNAME)
+ rv = self.client.delete(f"/api/v1/chart/{chart_id}")
+ assert rv.status_code == 200, (
+ f"Alpha owner soft-delete failed: {rv.status_code} {rv.data!r}"
+ )
+
+ rv = self.client.post(f"/api/v1/chart/{chart_uuid}/restore")
+ assert rv.status_code == 200, (
+ f"Expected 200 from Alpha owner restore (can_write_Chart), got "
+ f"{rv.status_code}: {rv.data!r}. If 403, "
+ "method_permission_name is missing 'restore': 'write'."
+ )
+
+ # Cleanup
+ _hard_delete_chart(chart_id)
+
+ @with_feature_flags(SOFT_DELETE=True)
+ def test_restore_chart_reattaches_to_dashboards(self) -> None:
+ """Soft-deleting a chart preserves dashboard_slices junction rows;
+ restore makes the chart reappear in its dashboards automatically.
+
+ This is the positive test that pins down the SIP's "no cascade"
+ contract and the corrected commit ``feat(soft-delete): preserve
+ dashboard_slices on chart soft-delete (MissingChart handles UI)``.
+ Soft-delete leaves the junction intact so:
+
+ - dashboards continue to render the chart slot (frontend uses
+ ``MissingChart`` placeholder while the chart is hidden via the
+ visibility filter)
+ - on restore the chart is automatically a member of every
+ dashboard it was a member of before, with no manual
+ re-attachment step
+ """
+ admin = self.get_user("admin")
+ admin_id = admin.id
+
+ chart = self.insert_chart("reattach_test_chart", [admin_id], 1)
+ chart_id = chart.id
+ chart_uuid = str(chart.uuid)
+
+ dashboard = Dashboard(
+ dashboard_title="reattach_test_dashboard",
+ slug="slug_reattach_test",
+ owners=[admin],
+ published=True,
+ )
+ dashboard.slices = [chart]
+ db.session.add(dashboard)
+ db.session.commit()
+ dashboard_id = dashboard.id
+
+ # Sanity: the junction row exists
+ junction_count = (
+ db.session.query(dashboard_slices)
+ .filter(
+ dashboard_slices.c.dashboard_id == dashboard_id,
+ dashboard_slices.c.slice_id == chart_id,
+ )
+ .count()
+ )
+ assert junction_count == 1, "junction row should exist after dashboard creation"
+
+ self.login(ADMIN_USERNAME)
+
+ # Soft-delete the chart
+ rv = self.client.delete(f"/api/v1/chart/{chart_id}")
+ assert rv.status_code == 200
+
+ # The junction row is preserved (no cascade)
+ junction_count_after_delete = (
+ db.session.query(dashboard_slices)
+ .filter(
+ dashboard_slices.c.dashboard_id == dashboard_id,
+ dashboard_slices.c.slice_id == chart_id,
+ )
+ .count()
+ )
+ assert junction_count_after_delete == 1, (
+ "junction row should remain intact on chart soft-delete; "
+ "MissingChart placeholder handles the UI gap"
+ )
+
+ # The dashboard's loaded `slices` collection no longer includes the
+ # soft-deleted chart (the global visibility filter applies to
+ # relationship loads via `with_loader_criteria(..., include_aliases=True)`).
+ db.session.expire_all()
+ dashboard_after_delete = (
+ db.session.query(Dashboard).filter(Dashboard.id == dashboard_id).one()
+ )
+ assert chart_id not in [s.id for s in dashboard_after_delete.slices], (
+ "soft-deleted chart should be filtered out of dashboard.slices "
+ "by the visibility-filter listener"
+ )
+
+ # Restore the chart
+ rv = self.client.post(f"/api/v1/chart/{chart_uuid}/restore")
+ assert rv.status_code == 200
+
+ # The chart automatically reappears in the dashboard — junction row
+ # was preserved, so no manual reattach was needed.
+ db.session.expire_all()
+ dashboard_after_restore = (
+ db.session.query(Dashboard).filter(Dashboard.id == dashboard_id).one()
+ )
+ assert chart_id in [s.id for s in dashboard_after_restore.slices], (
+ "restored chart should reappear in dashboard.slices automatically; "
+ "the junction row was never removed by soft-delete"
+ )
+
+ # Cleanup
+ _hard_delete_dashboard_for_charts_test(dashboard_id)
+ _hard_delete_chart(chart_id)
+
+ @with_feature_flags(SOFT_DELETE=True)
+ def test_restore_chart_by_non_admin_owner(self) -> None:
+ """Non-admin owners can restore their own soft-deleted charts.
+
+ The unit-level restore command tests mock security; this
+ integration test exercises the FAB security wiring end-to-end
+ so a future change that breaks the owner check on a non-admin
+ path can't slip through.
+ """
+ alpha = self.get_user(ALPHA_USERNAME)
+ alpha_id = alpha.id
+
+ chart = self.insert_chart("alpha_owned_chart", [alpha_id], 1)
+ chart_id = chart.id
+ chart_uuid = str(chart.uuid)
+
+ self.login(ALPHA_USERNAME)
+ rv = self.client.delete(f"/api/v1/chart/{chart_id}")
+ assert rv.status_code == 200
+
+ rv = self.client.post(f"/api/v1/chart/{chart_uuid}/restore")
+ assert rv.status_code == 200, rv.data
+
+ db.session.expire_all()
+ restored = db.session.query(Slice).filter(Slice.id == chart_id).one_or_none()
+ assert restored is not None
+ assert restored.deleted_at is None
+
+ # Cleanup
+ _hard_delete_chart(chart_id)
diff --git a/tests/unit_tests/charts/commands/importers/v1/import_test.py b/tests/unit_tests/charts/commands/importers/v1/import_test.py
index 42a4afb779c..31094115dee 100644
--- a/tests/unit_tests/charts/commands/importers/v1/import_test.py
+++ b/tests/unit_tests/charts/commands/importers/v1/import_test.py
@@ -18,6 +18,7 @@
import copy
from collections.abc import Generator
+from datetime import datetime, timezone
from unittest.mock import patch
import pytest
@@ -287,6 +288,186 @@ def test_import_existing_chart_with_permission(
mock_can_access_chart.assert_called_once_with(slice)
+def _soft_delete_existing_chart(session: Session) -> int:
+ """Soft-delete the seeded chart (by fixture UUID) and return its original id.
+
+ Shared setup for the soft-delete import tests: locate the chart, stamp
+ ``deleted_at``, flush, and return the id so callers can assert the restore
+ happened in place (same id).
+ """
+ existing = (
+ session.query(Slice).filter(Slice.uuid == chart_config["uuid"]).one_or_none()
+ )
+ assert existing is not None
+ existing.deleted_at = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
+ session.flush()
+ return existing.id
+
+
+def test_import_soft_deleted_chart_overwrite_restores_in_place(
+ mocker: MockerFixture,
+ session_with_data: Session,
+) -> None:
+ """
+ Overwrite-importing a soft-deleted chart must restore the row in place,
+ not hard-delete-and-replace. Otherwise out-of-archive references
+ (dashboard_slices junctions, report.chart_id) would cascade away.
+ """
+ mocker.patch.object(security_manager, "can_access", return_value=True)
+ mocker.patch.object(security_manager, "can_access_chart", return_value=True)
+
+ original_id = _soft_delete_existing_chart(session_with_data)
+
+ admin = User(
+ first_name="Alice",
+ last_name="Doe",
+ email="adoe@example.org",
+ username="admin",
+ roles=[Role(name="Admin")],
+ )
+
+ config = copy.deepcopy(chart_config)
+ config["datasource_id"] = 1
+ config["datasource_type"] = "table"
+
+ with override_user(admin):
+ chart = import_chart(config, overwrite=True)
+
+ assert chart.id == original_id
+ assert chart.deleted_at is None
+
+
+def test_import_soft_deleted_chart_ignore_permissions_restores_in_place(
+ mocker: MockerFixture,
+ session_with_data: Session,
+) -> None:
+ """
+ The example loader path: ignore_permissions=True with no logged-in
+ user. The if/elif structure must preserve config["id"] on the
+ fallthrough overwrite path so the example loader can re-import over
+ a soft-deleted match without colliding on the UUID unique index.
+ """
+ original_id = _soft_delete_existing_chart(session_with_data)
+
+ config = copy.deepcopy(chart_config)
+ config["datasource_id"] = 1
+ config["datasource_type"] = "table"
+
+ chart = import_chart(config, overwrite=True, ignore_permissions=True)
+
+ assert chart.id == original_id
+ assert chart.deleted_at is None
+
+
+def test_import_soft_deleted_chart_non_overwrite_restores_for_owner(
+ mocker: MockerFixture,
+ session_with_data: Session,
+) -> None:
+ """
+ Non-overwrite re-import of a soft-deleted UUID is implicitly a
+ restore-and-update: the user is bringing the chart back by uploading
+ it again. The same ownership rule as the overwrite path applies, so
+ an owner (or admin) succeeds without setting overwrite=True.
+ """
+ mocker.patch.object(security_manager, "can_access", return_value=True)
+ mocker.patch.object(security_manager, "can_access_chart", return_value=True)
+
+ original_id = _soft_delete_existing_chart(session_with_data)
+
+ admin = User(
+ first_name="Alice",
+ last_name="Doe",
+ email="adoe@example.org",
+ username="admin",
+ roles=[Role(name="Admin")],
+ )
+
+ config = copy.deepcopy(chart_config)
+ config["datasource_id"] = 1
+ config["datasource_type"] = "table"
+
+ with override_user(admin):
+ chart = import_chart(config, overwrite=False)
+
+ assert chart.id == original_id
+ assert chart.deleted_at is None
+
+
+def test_import_soft_deleted_chart_non_overwrite_raises_for_non_owner(
+ mocker: MockerFixture,
+ session_with_data: Session,
+) -> None:
+ """
+ Non-overwrite re-import that would resurrect a soft-deleted chart
+ must respect ownership: a non-owner without admin role cannot
+ restore-via-import. Mirrors the explicit /restore endpoint's check.
+ """
+ mocker.patch.object(security_manager, "can_access", return_value=True)
+ mocker.patch.object(security_manager, "can_access_chart", return_value=True)
+
+ _soft_delete_existing_chart(session_with_data)
+
+ non_owner = User(
+ first_name="Bob",
+ last_name="Roe",
+ email="bob@example.org",
+ username="bob",
+ roles=[Role(name="Gamma")],
+ )
+
+ with override_user(non_owner):
+ with pytest.raises(ImportFailedError) as excinfo:
+ import_chart(chart_config, overwrite=False)
+ assert "permissions to restore" in str(excinfo.value)
+
+
+def test_import_soft_deleted_chart_raises_when_caller_lacks_can_write(
+ mocker: MockerFixture,
+ session_with_data: Session,
+) -> None:
+ """
+ Case B: re-import of a soft-deleted UUID by a caller without
+ can_write must raise, not silently return the soft-deleted row.
+
+ Real-world scenario: a user has can_write Dashboard but not
+ can_write Chart, and they import a dashboard zip that references a
+ soft-deleted chart. Silently returning the row would let the
+ dashboard importer reattach to it via chart_ids[uuid] = existing.id
+ and produce a dashboard with hidden (broken) charts.
+ """
+ mocker.patch.object(security_manager, "can_access", return_value=False)
+
+ _soft_delete_existing_chart(session_with_data)
+
+ with pytest.raises(ImportFailedError) as excinfo:
+ import_chart(chart_config, overwrite=False)
+ assert "can_write" in str(excinfo.value)
+
+
+def test_import_existing_active_chart_overwrite_without_can_write_returns_existing(
+ mocker: MockerFixture,
+ session_with_data: Session,
+) -> None:
+ """
+ An *active* (not soft-deleted) chart re-imported with overwrite=True by a
+ caller without can_write must fall through to returning the existing row,
+ not raise the restore error. Case B is keyed on ``is_soft_deleted``, so the
+ fused ``needs_mutation`` condition must not pull active rows into the
+ restore-without-permission branch (pre-soft-delete overwrite behaviour).
+ """
+ mocker.patch.object(security_manager, "can_access", return_value=False)
+
+ existing = (
+ session_with_data.query(Slice).filter(Slice.uuid == chart_config["uuid"]).one()
+ )
+ assert existing.deleted_at is None
+
+ result = import_chart(chart_config, overwrite=True)
+
+ assert result.id == existing.id
+ assert result.deleted_at is None
+
+
def test_import_tag_logic_for_charts(session_with_schema: Session):
contents = {
"tags.yaml": yaml.dump(
diff --git a/tests/unit_tests/commands/chart/restore_test.py b/tests/unit_tests/commands/chart/restore_test.py
new file mode 100644
index 00000000000..62309a50366
--- /dev/null
+++ b/tests/unit_tests/commands/chart/restore_test.py
@@ -0,0 +1,90 @@
+# 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.
+"""Unit tests for RestoreChartCommand."""
+
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from superset.commands.chart.exceptions import (
+ ChartForbiddenError,
+ ChartNotFoundError,
+)
+from superset.commands.chart.restore import RestoreChartCommand
+from superset.exceptions import SupersetSecurityException
+
+
+def test_restore_chart_clears_deleted_at(app_context: None) -> None:
+ """RestoreChartCommand.run() restores a soft-deleted chart."""
+ chart = MagicMock()
+ chart.deleted_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
+ chart.id = 1
+
+ with (
+ patch(
+ "superset.daos.chart.ChartDAO.find_by_id", return_value=chart
+ ) as mock_find,
+ patch("superset.commands.restore.security_manager") as mock_sec,
+ ):
+ mock_sec.raise_for_ownership.return_value = None
+
+ cmd = RestoreChartCommand("1")
+ cmd.run()
+
+ mock_find.assert_called_once()
+ chart.restore.assert_called_once()
+
+
+def test_restore_chart_not_found_raises(app_context: None) -> None:
+ """RestoreChartCommand raises ChartNotFoundError for missing chart."""
+ with patch("superset.daos.chart.ChartDAO.find_by_id", return_value=None):
+ cmd = RestoreChartCommand("999")
+ with pytest.raises(ChartNotFoundError):
+ cmd.run()
+
+
+def test_restore_active_chart_raises_not_found(app_context: None) -> None:
+ """RestoreChartCommand raises ChartNotFoundError for non-deleted chart."""
+ chart = MagicMock()
+ chart.deleted_at = None # not soft-deleted
+
+ with patch("superset.daos.chart.ChartDAO.find_by_id", return_value=chart):
+ cmd = RestoreChartCommand("1")
+ with pytest.raises(ChartNotFoundError):
+ cmd.run()
+
+
+def test_restore_chart_forbidden_raises(app_context: None) -> None:
+ """RestoreChartCommand raises ChartForbiddenError on permission check."""
+ chart = MagicMock()
+ chart.deleted_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
+
+ def raise_security(*args: object, **kwargs: object) -> None:
+ raise SupersetSecurityException(MagicMock())
+
+ with (
+ patch("superset.daos.chart.ChartDAO.find_by_id", return_value=chart),
+ patch("superset.commands.restore.security_manager") as mock_sec,
+ ):
+ mock_sec.raise_for_ownership = raise_security
+
+ cmd = RestoreChartCommand("1")
+ with pytest.raises(ChartForbiddenError):
+ cmd.run()
diff --git a/tests/unit_tests/commands/report/execute_test.py b/tests/unit_tests/commands/report/execute_test.py
index 73937d53dd0..99d9356c653 100644
--- a/tests/unit_tests/commands/report/execute_test.py
+++ b/tests/unit_tests/commands/report/execute_test.py
@@ -2012,3 +2012,73 @@ def test_get_url_for_csv_uses_post_processed_type(
f"CSV report URL must use type=post_processed so chart filters "
f"(incl. time filters) are applied; got: {url}; see issue #25538"
)
+
+
+def test_get_url_raises_when_target_chart_soft_deleted(
+ mocker: MockerFixture,
+ app_context: None,
+) -> None:
+ """A dangling chart reference must fail loudly, not fall through.
+
+ Soft delete removed the FK-level guarantee that a report's target chart
+ exists: ``ReportSchedule.chart`` is a visibility-filtered relationship,
+ so a chart soft-deleted after the report was created loads as ``None``
+ while ``chart_id`` is still set. Pre-guard, ``_get_url`` silently fell
+ through to the dashboard branch (``dashboard`` also ``None``) and failed
+ opaquely; it must instead raise the dedicated, actionable error inside
+ the state-machine envelope.
+ """
+ from superset.commands.report.exceptions import (
+ ReportScheduleTargetChartDeletedError,
+ )
+
+ report_schedule = mocker.MagicMock()
+ report_schedule.chart_id = 42
+ report_schedule.chart = None
+
+ state = BaseReportState(report_schedule, datetime.utcnow(), uuid4())
+ with pytest.raises(ReportScheduleTargetChartDeletedError):
+ state._get_url()
+
+
+def test_get_url_raises_when_target_dashboard_soft_deleted(
+ mocker: MockerFixture,
+ app_context: None,
+) -> None:
+ """Symmetric twin of the chart-target guard: a dashboard report whose
+ visibility-filtered ``dashboard`` relationship loads ``None`` while
+ ``dashboard_id`` is set must raise the dedicated error, not fall into
+ ``dashboard.id`` on ``None`` (an opaque ``AttributeError``)."""
+ from superset.commands.report.exceptions import (
+ ReportScheduleTargetDashboardDeletedError,
+ )
+
+ report_schedule = mocker.MagicMock()
+ report_schedule.chart_id = None
+ report_schedule.chart = None
+ report_schedule.dashboard_id = 7
+ report_schedule.dashboard = None
+
+ state = BaseReportState(report_schedule, datetime.utcnow(), uuid4())
+ with pytest.raises(ReportScheduleTargetDashboardDeletedError):
+ state._get_url()
+
+
+def test_get_dashboard_urls_raises_when_target_dashboard_soft_deleted(
+ mocker: MockerFixture,
+ app_context: None,
+) -> None:
+ """``get_dashboard_urls`` is entered directly from the async command's
+ permalink pre-commit (bypassing ``_get_url``), so it needs — and has —
+ the same deleted-target guard."""
+ from superset.commands.report.exceptions import (
+ ReportScheduleTargetDashboardDeletedError,
+ )
+
+ report_schedule = mocker.MagicMock()
+ report_schedule.dashboard_id = 7
+ report_schedule.dashboard = None
+
+ state = BaseReportState(report_schedule, datetime.utcnow(), uuid4())
+ with pytest.raises(ReportScheduleTargetDashboardDeletedError):
+ state.get_dashboard_urls()
diff --git a/tests/unit_tests/dao/dashboard_test.py b/tests/unit_tests/dao/dashboard_test.py
new file mode 100644
index 00000000000..b9694ee0d51
--- /dev/null
+++ b/tests/unit_tests/dao/dashboard_test.py
@@ -0,0 +1,119 @@
+# 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.
+from datetime import datetime, timezone
+from typing import Any
+
+from sqlalchemy.orm.session import Session
+
+from superset import db
+from superset.connectors.sqla.models import Database, SqlaTable
+from superset.daos.dashboard import DashboardDAO
+from superset.models.dashboard import Dashboard
+from superset.models.slice import Slice
+from tests.unit_tests.conftest import with_feature_flags
+
+
+@with_feature_flags(SOFT_DELETE=True)
+def test_set_dash_metadata_preserves_soft_deleted_members(
+ session: Session,
+) -> None:
+ """Saving a dashboard must not sever a soft-deleted member chart.
+
+ ``set_dash_metadata`` rebuilds ``dashboard.slices`` wholesale from the
+ incoming position data. The soft-delete visibility filter must be
+ bypassed for the whole rebuild — the slice-resolution query AND the
+ collection assignment:
+
+ - Filtered resolution would silently drop the trashed member from the
+ new collection — deleting its ``dashboard_slices`` junction row
+ (breaking the restore-reattach contract) and writing ``uuid: None``
+ into its position slot.
+ - A filtered *baseline* load (the unit of work lazy-loads the existing
+ collection when diffing the assignment) would exclude the trashed
+ member from the old collection, so the diff treats it as net-new and
+ INSERTs a duplicate ``dashboard_slices`` row — an IntegrityError on
+ the composite PK on every save of a dashboard containing a trashed
+ chart.
+
+ The test reproduces the production shape: SOFT_DELETE enabled (the
+ listener actually filters), the collection expired (as with the fresh
+ ``find_by_id`` load in the PUT flow), and a flush afterwards so the
+ diff's SQL actually hits the composite-PK junction table. It fails on
+ either a missing resolution bypass or a query-scoped-only bypass.
+ """
+ Dashboard.metadata.create_all(session.get_bind())
+
+ dataset = SqlaTable(
+ table_name="dash_meta_table",
+ database=Database(database_name="dash_meta_db", sqlalchemy_uri="sqlite://"),
+ )
+ db.session.add(dataset)
+ db.session.flush()
+
+ live_chart = Slice(
+ slice_name="live_chart",
+ datasource_id=dataset.id,
+ datasource_type="table",
+ )
+ trashed_chart = Slice(
+ slice_name="trashed_chart",
+ datasource_id=dataset.id,
+ datasource_type="table",
+ deleted_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
+ )
+ dashboard = Dashboard(
+ dashboard_title="meta_test_dash",
+ slices=[live_chart, trashed_chart],
+ published=True,
+ )
+ db.session.add_all([live_chart, trashed_chart, dashboard])
+ db.session.flush()
+
+ # Production shape: the PUT flow loads a fresh Dashboard whose
+ # ``slices`` collection is unloaded; expiring forces the baseline
+ # reload through the visibility listener during the assignment.
+ db.session.expire(dashboard, ["slices"])
+
+ positions: dict[str, dict[str, Any]] = {
+ "CHART-live": {
+ "type": "CHART",
+ "id": "CHART-live",
+ "children": [],
+ "meta": {"chartId": live_chart.id, "width": 4, "height": 50},
+ },
+ "CHART-trashed": {
+ "type": "CHART",
+ "id": "CHART-trashed",
+ "children": [],
+ "meta": {"chartId": trashed_chart.id, "width": 4, "height": 50},
+ },
+ }
+
+ DashboardDAO.set_dash_metadata(dashboard, {"positions": positions})
+ # Flush so the collection diff's SQL reaches the composite-PK junction
+ # table — a duplicate INSERT fails here, not at assignment time.
+ db.session.flush()
+
+ member_ids = {chart.id for chart in dashboard.slices}
+ assert live_chart.id in member_ids
+ assert trashed_chart.id in member_ids, (
+ "soft-deleted member chart was severed from dashboard.slices; "
+ "set_dash_metadata must bypass the visibility filter when "
+ "resolving incoming chart ids"
+ )
+ # And the position slot kept its UUID rather than being nulled.
+ assert positions["CHART-trashed"]["meta"]["uuid"] == str(trashed_chart.uuid)
diff --git a/tests/unit_tests/migrations/test_add_deleted_at_to_slices.py b/tests/unit_tests/migrations/test_add_deleted_at_to_slices.py
new file mode 100644
index 00000000000..7a46c4da4cc
--- /dev/null
+++ b/tests/unit_tests/migrations/test_add_deleted_at_to_slices.py
@@ -0,0 +1,190 @@
+# 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.
+"""Tests for migration ``7c4a8d09ca37_add_deleted_at_to_slices``.
+
+Runs the migration's ``upgrade()`` and ``downgrade()`` against an
+in-memory SQLite engine with a real Alembic ``Operations`` context.
+The behaviour being pinned is the operator-facing contract documented
+in ``UPDATING.md``: ``downgrade()`` reverses the schema but does not
+hard-delete or otherwise mutate rows that were soft-deleted before
+the migration was reversed — those rows survive the downgrade and
+become visible to any code path that no longer applies the
+soft-delete visibility filter.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime
+from importlib import import_module
+
+import pytest
+from alembic.migration import MigrationContext
+from alembic.operations import Operations
+from sqlalchemy import (
+ Column,
+ create_engine,
+ insert,
+ inspect,
+ Integer,
+ MetaData,
+ select,
+ String,
+ Table,
+)
+from sqlalchemy.engine import Engine
+
+migration = import_module(
+ "superset.migrations.versions."
+ "2026-05-08_12-00_7c4a8d09ca37_add_deleted_at_to_slices"
+)
+
+TABLE_NAME: str = migration.TABLE_NAME # "slices"
+INDEX_NAME: str = migration.INDEX_NAME # "ix_slices_deleted_at"
+
+
+@pytest.fixture
+def engine() -> Engine:
+ """In-memory SQLite seeded with a minimal pre-migration ``slices`` table.
+
+ The real ``slices`` table has many columns; the migration only touches
+ ``deleted_at`` and its index, so only the columns that participate in
+ the test are seeded.
+ """
+ engine = create_engine("sqlite:///:memory:")
+ md = MetaData()
+ Table(
+ TABLE_NAME,
+ md,
+ Column("id", Integer, primary_key=True),
+ Column("slice_name", String(250), nullable=False),
+ )
+ md.create_all(engine)
+ return engine
+
+
+def _columns(engine: Engine) -> set[str]:
+ """Current column names on the target ``slices`` table."""
+ return {col["name"] for col in inspect(engine).get_columns(TABLE_NAME)}
+
+
+def _indexes(engine: Engine) -> set[str]:
+ """Current index names on the target ``slices`` table."""
+ return {ix["name"] for ix in inspect(engine).get_indexes(TABLE_NAME)}
+
+
+def test_upgrade_adds_deleted_at_column_and_index(engine: Engine) -> None:
+ """upgrade() adds the nullable ``deleted_at`` column and its index."""
+ with engine.connect() as conn:
+ ctx = MigrationContext.configure(conn)
+ with Operations.context(ctx):
+ migration.upgrade()
+
+ assert "deleted_at" in _columns(engine), "upgrade() must add the deleted_at column"
+ assert INDEX_NAME in _indexes(engine), (
+ "upgrade() must create the supporting index on deleted_at"
+ )
+
+
+def test_downgrade_drops_deleted_at_column_and_index(engine: Engine) -> None:
+ """downgrade() removes both schema artifacts (column and index)."""
+ with engine.connect() as conn:
+ ctx = MigrationContext.configure(conn)
+ with Operations.context(ctx):
+ migration.upgrade()
+ migration.downgrade()
+
+ assert "deleted_at" not in _columns(engine), (
+ "downgrade() must drop the deleted_at column"
+ )
+ assert INDEX_NAME not in _indexes(engine), (
+ "downgrade() must drop the supporting index"
+ )
+
+
+def test_downgrade_preserves_soft_deleted_row_data(engine: Engine) -> None:
+ """Pin the operator-facing contract from ``UPDATING.md``: rows that
+ were soft-deleted before the migration is reversed survive the
+ downgrade. The ``deleted_at`` column is gone, so those rows are
+ indistinguishable from live rows to any code path that no longer
+ applies the visibility filter — operators must decide on
+ hard-delete, restore, or rename BEFORE downgrading. See the
+ "Rollback note" in ``UPDATING.md``.
+ """
+ with engine.connect() as conn:
+ ctx = MigrationContext.configure(conn)
+ with Operations.context(ctx):
+ migration.upgrade()
+
+ slices = Table(TABLE_NAME, MetaData(), autoload_with=conn)
+ conn.execute(
+ insert(slices).values(
+ [
+ {
+ "id": 1,
+ "slice_name": "live_chart",
+ "deleted_at": None,
+ },
+ {
+ "id": 2,
+ "slice_name": "archived_chart",
+ "deleted_at": datetime(2026, 1, 1, 12, 0, 0),
+ },
+ ]
+ )
+ )
+
+ migration.downgrade()
+
+ slices_after = Table(TABLE_NAME, MetaData(), autoload_with=conn)
+ rows = conn.execute(
+ select(slices_after).order_by(slices_after.c.id)
+ ).fetchall()
+
+ assert [(r.id, r.slice_name) for r in rows] == [
+ (1, "live_chart"),
+ (2, "archived_chart"),
+ ], (
+ "downgrade() must not delete or mutate row data — soft-deleted "
+ "rows become indistinguishable from live rows but they remain"
+ )
+ assert "deleted_at" not in {
+ c["name"] for c in inspect(engine).get_columns(TABLE_NAME)
+ }
+
+
+def test_upgrade_is_idempotent(engine: Engine) -> None:
+ """The migration helpers (``add_columns``, ``create_index``) are
+ idempotent skip-if-exists; running ``upgrade()`` twice must not
+ raise.
+ """
+ with engine.connect() as conn:
+ ctx = MigrationContext.configure(conn)
+ with Operations.context(ctx):
+ migration.upgrade()
+ migration.upgrade()
+
+
+def test_downgrade_is_idempotent(engine: Engine) -> None:
+ """``drop_columns`` / ``drop_index`` are skip-if-not-exists; running
+ ``downgrade()`` twice must not raise.
+ """
+ with engine.connect() as conn:
+ ctx = MigrationContext.configure(conn)
+ with Operations.context(ctx):
+ migration.upgrade()
+ migration.downgrade()
+ migration.downgrade()