Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
753c184
feat(charts): soft-delete and restore for charts
May 13, 2026
2c11606
fix(charts): restore-in-place on soft-deleted overwrite import; raise…
May 25, 2026
9f93e32
fix(charts): preserve config[id] on null-user overwrite (example loader)
May 25, 2026
6bbe68e
fix(charts): treat re-import of soft-deleted UUID as implicit restore…
May 25, 2026
2c14011
fix(charts): raise on restore-via-import without can_write (Case B)
May 26, 2026
fd19b87
docs(charts): UPDATING.md — correct rison-filter values, Option-C imp…
May 28, 2026
8867ed8
fix(charts): explicit flush on soft-delete restore + non-admin owner …
May 28, 2026
d6c91fc
style(charts): migration docstring PEP 257 compliance (constitution v…
Jun 1, 2026
70f41ac
refactor(charts): import SKIP_VISIBILITY_FILTER_CLASSES from canonica…
Jun 1, 2026
9dbd63a
docs(charts): drop spec-directory reference from import_chart docstring
Jun 1, 2026
78da3d1
docs(charts): remove internal Shortcut ticket references
Jun 1, 2026
2ec151b
review(charts): cross-cutting fixes from Amin & Richard's review
Jun 2, 2026
589aa5e
review(charts): add -> None return annotations on integration tests
Jun 2, 2026
2ca6eb9
review(charts): normalize ChartRestoreFailedError inheritance
Jun 2, 2026
023f6a8
fix(charts): map restore method to write permission in ChartRestApi
Jun 2, 2026
aa3b01e
review(charts): move test inline imports + update migration down_revi…
Jun 2, 2026
96ef23e
revert(charts): restore down_revision to 33d7e0e21daa (actual alembic…
Jun 2, 2026
2105d89
i18n(charts): seed empty msgstr for "Chart could not be restored." in…
Jun 4, 2026
c429ec1
test(charts): pin migration upgrade/downgrade behaviour with rows pre…
Jun 4, 2026
8dcad9a
fix(charts): drop SQLAlchemy 2.x-only conn.commit() from migration test
Jun 4, 2026
cb38b22
refactor(charts): drop unused logger from RestoreChartCommand
Jun 5, 2026
18e8123
test(charts): dedupe soft-delete import setup; add restore msgid to .pot
Jun 8, 2026
c6f6aab
fix(charts): key importer Case B on is_soft_deleted, not needs_mutation
Jun 8, 2026
f12b87b
fix(charts): scope deleted-state list to restore audience; re-point m…
Jun 9, 2026
932d963
style(charts): ruff-format importer test to satisfy pre-commit
Jun 9, 2026
a4d44e3
refactor(charts): split fused needs_mutation into restore vs overwrite
Jun 10, 2026
02389aa
test(charts): pin restore failure maps to 422
Jun 10, 2026
b353519
refactor(charts): port cross-entity review-panel findings
Jun 11, 2026
73a01a2
fix(charts): panel findings — preserve archived members on dashboard …
Jun 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions UPDATING.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,20 @@ Both default to empty (no behavior change). They apply to both the `LOCAL_EXTENS

The Dynamic Group By chart customization now orders its display values according to the "Sort display control values" toggle: ascending (A–Z), descending (Z–A), or the dataset's source order when the toggle is unset. Previously the dropdown always sorted alphabetically. Existing dashboards where the toggle was never set will show options in source order instead of A–Z; open the customization and enable the toggle to restore alphabetical ordering.

### Soft delete and restore for charts

`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`).

**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 same audience that can restore them.

**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.

**Importer behavior:** importing a chart YAML whose UUID matches an existing **soft-deleted** chart is treated as an implicit restore-with-update. The owner (or an admin) gets the chart back in place — `deleted_at` is cleared and the contents from the upload are applied — preserving the original PK and all out-of-archive references (`dashboard_slices` junctions, `report.chart_id`, tag rows). Non-owners get `ImportFailedError`. Callers without `can_write` get `ImportFailedError` instead of silently receiving the soft-deleted row.

### Granular Export Controls

A new feature flag `GRANULAR_EXPORT_CONTROLS` introduces three fine-grained permissions that replace the legacy `can_csv` permission:
Expand Down
99 changes: 92 additions & 7 deletions superset/charts/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
ChartAllTextFilter,
ChartCertifiedFilter,
ChartCreatedByMeFilter,
ChartDeletedStateFilter,
ChartFavoriteFilter,
ChartFilter,
ChartHasCreatedByFilter,
Expand Down Expand Up @@ -64,12 +65,14 @@
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
Expand Down Expand Up @@ -102,12 +105,16 @@
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"
Expand All @@ -124,6 +131,7 @@ def ensure_thumbnails_enabled(self) -> Optional[Response]:
RouteMethod.IMPORT,
RouteMethod.RELATED,
"bulk_delete", # not using RouteMethod since locally defined
"restore",
Comment thread
rusackas marked this conversation as resolved.
"viz_types",
"favorite_status",
"add_favorite",
Expand All @@ -134,7 +142,17 @@ def ensure_thumbnails_enabled(self) -> Optional[Response]:
"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",
Expand Down Expand Up @@ -222,6 +240,7 @@ def ensure_thumbnails_enabled(self) -> Optional[Response]:
"id": [
ChartFavoriteFilter,
ChartCertifiedFilter,
ChartDeletedStateFilter,
ChartOwnedCreatedFavoredByMeFilter,
],
"slice_name": [ChartAllTextFilter],
Expand Down Expand Up @@ -468,10 +487,15 @@ def put(self, pk: int) -> Response:
log_to_statsd=False,
)
def delete(self, pk: int) -> Response:
"""Delete a chart.
"""Soft-delete a chart.

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.
---
delete:
summary: Delete a chart
summary: Delete a chart (soft delete; recoverable via restore)
parameters:
- in: path
schema:
Expand Down Expand Up @@ -524,10 +548,15 @@ def delete(self, pk: int) -> Response:
log_to_statsd=False,
)
def bulk_delete(self, **kwargs: Any) -> Response:
"""Bulk delete charts.
"""Bulk soft-delete charts.

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.
---
delete:
summary: Bulk delete charts
summary: Bulk delete charts (soft delete; recoverable via restore)
parameters:
- in: query
name: q
Expand Down Expand Up @@ -572,6 +601,62 @@ def bulk_delete(self, **kwargs: Any) -> Response:
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)
Expand Down
38 changes: 38 additions & 0 deletions superset/charts/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
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
Expand Down Expand Up @@ -180,3 +181,40 @@ def apply(self, query: Query, value: Any) -> Query:
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 (kept consistent with the deleted-state
filters in the dashboard and dataset soft-delete rollouts).
"""

arg_name = "chart_deleted_state"
model = Slice

def apply(self, query: Query, value: Any) -> Query:
query = super().apply(query, value)
normalized = str(value).lower().strip() if value is not None else ""
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))
Comment thread
mikebridge marked this conversation as resolved.
1 change: 1 addition & 0 deletions superset/commands/chart/delete.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ def __init__(self, model_ids: list[int]):
def run(self) -> None:
self.validate()
assert self._models

ChartDAO.delete(self._models)

def validate(self) -> None:
Expand Down
10 changes: 10 additions & 0 deletions superset/commands/chart/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Comment thread
mikebridge marked this conversation as resolved.


class ChartDeleteFailedReportsExistError(ChartDeleteFailedError):
message = _("There are associated alerts or reports")

Expand Down
Loading
Loading