Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
11bd83b
feat(core): SoftDeleteMixin and restore infrastructure
May 13, 2026
2feb3ee
style(soft-delete): type-annotate _add_soft_delete_filter
May 13, 2026
08e4381
fix(soft-delete): two bugs flagged in codeant review
May 13, 2026
092d502
fix(soft-delete): address richardfogaca review feedback
May 13, 2026
a40ced5
fix(soft-delete): address richardfogaca second-round review
May 13, 2026
8db9767
refactor(soft-delete): move per-request bypass into raise_for_ownership
May 13, 2026
431d766
test(soft-delete): empirically verify both bypass paths work with Que…
May 13, 2026
e5b8031
refactor(soft-delete): switch raise_for_ownership bypass from per-req…
May 13, 2026
2f83baf
docs(soft-delete): SQLAlchemy review follow-ups
May 13, 2026
c143074
refactor(soft-delete): clean-code review polish
May 13, 2026
e6d1fd1
test(soft-delete): unit tests for BaseRestoreCommand + small tidyings
May 13, 2026
f0d9860
refactor(soft-delete): per-entity session-scoped bypass
May 14, 2026
2870f15
refactor(soft-delete): cache-stable iteration + hot-path allocation
May 14, 2026
e64b8b5
refactor(soft-delete): address richardfogaca review on PR #39977
May 14, 2026
84528df
refactor(soft-delete): extract-method tidyings from clean-code review
May 14, 2026
03a16d5
fix(soft-delete): narrow subclass return type + relocate type: ignore
May 14, 2026
06a64f0
refactor(soft-delete): address aminghadersohi review on PR #39977
May 20, 2026
8b0495a
fix(soft-delete): release session bypass after list response augmenta…
May 20, 2026
3c4fe8e
fix(soft-delete): mypy fixes for pre-commit (current)
May 20, 2026
a3ea26e
fix(soft-delete): adapt loader criteria to table aliases in joins
May 20, 2026
c5832d0
fix(soft-delete): attach loader criteria on relationship loads too
May 21, 2026
291e648
feat(charts): soft-delete and restore for charts
May 13, 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
69 changes: 67 additions & 2 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 @@ -63,12 +64,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 @@ -100,12 +103,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 @@ -122,6 +129,7 @@ def ensure_thumbnails_enabled(self) -> Optional[Response]:
RouteMethod.IMPORT,
RouteMethod.RELATED,
"bulk_delete", # not using RouteMethod since locally defined
"restore",
"viz_types",
"favorite_status",
"add_favorite",
Expand Down Expand Up @@ -220,6 +228,7 @@ def ensure_thumbnails_enabled(self) -> Optional[Response]:
"id": [
ChartFavoriteFilter,
ChartCertifiedFilter,
ChartDeletedStateFilter,
ChartOwnedCreatedFavoredByMeFilter,
],
"slice_name": [ChartAllTextFilter],
Expand Down Expand Up @@ -568,6 +577,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
8 changes: 8 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,10 @@ def apply(self, query: Query, value: Any) -> Query:
FavStar.user_id == get_user_id(),
)
)


class ChartDeletedStateFilter(BaseDeletedStateFilter): # pylint: disable=too-few-public-methods
"""Rison filter for the GET list that exposes soft-deleted charts."""

arg_name = "chart_deleted_state"
model = Slice
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
4 changes: 4 additions & 0 deletions superset/commands/chart/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,10 @@ class ChartDeleteFailedError(DeleteFailedError):
message = _("Charts could not be deleted.")


class ChartRestoreFailedError(CommandException):
message = _("Chart could not be restored.")


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

Expand Down
19 changes: 15 additions & 4 deletions superset/commands/chart/importers/v1/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,14 @@ def import_chart(
ignore_permissions: bool = False,
) -> Slice:
can_write = ignore_permissions or security_manager.can_access("can_write", "Chart")
existing = db.session.query(Slice).filter_by(uuid=config["uuid"]).first()
from superset.commands.importers.v1.utils import (
clear_soft_deleted_for_import,
find_existing_for_import,
)

user = get_user()
if existing:

if existing := find_existing_for_import(Slice, config["uuid"]):
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()
Expand All @@ -60,9 +65,15 @@ def import_chart(
"A chart already exists and user doesn't "
"have permissions to overwrite it"
)
if not overwrite or not can_write:
# Permission check passed. Hard-delete a soft-deleted match
# now (after the check) so the fresh insert below doesn't
# collide on the UUID unique constraint.
if existing.deleted_at is not None:
clear_soft_deleted_for_import(existing)
else:
config["id"] = existing.id
elif not overwrite or not can_write:
return existing
config["id"] = existing.id
elif not can_write:
raise ImportFailedError(
"Chart doesn't exist and user doesn't have permission to create charts"
Expand Down
43 changes: 43 additions & 0 deletions superset/commands/chart/restore.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# 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."""

import logging

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

logger = logging.getLogger(__name__)


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
49 changes: 49 additions & 0 deletions superset/commands/importers/v1/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from superset.extensions import feature_flag_manager
from superset.models.core import Database
from superset.models.dashboard import dashboard_slices
from superset.models.helpers import SKIP_VISIBILITY_FILTER_CLASSES
from superset.tags.models import Tag, TaggedObject
from superset.utils import json
from superset.utils.core import check_is_safe_zip
Expand Down Expand Up @@ -400,3 +401,51 @@ def get_resource_mappings_batched(
mapping.update({str(x.uuid): value_func(x) for x in batch})
offset += batch_size
return mapping


def find_existing_for_import(model_cls: type[Any], uuid: str) -> Any | None:
"""Look up an existing row by UUID for an import operation,
bypassing the soft-delete visibility filter so soft-deleted matches
are returned too.

Side-effect-free: returns the row as-is whether it's live or
soft-deleted (or ``None`` if no row exists). The caller is
responsible for deciding what to do with a soft-deleted match —
typically calling :func:`clear_soft_deleted_for_import` to remove
it before re-import, but only after the caller has validated
overwrite/permission decisions.

Splitting the lookup from the destructive cleanup keeps the
destructive action explicit at the call site, so a future change
that adds a permission check on the overwrite path doesn't
silently leave a "duck around it via soft-delete" backdoor.
"""
return (
db.session.query(model_cls)
.execution_options(**{SKIP_VISIBILITY_FILTER_CLASSES: {model_cls}})
.filter_by(uuid=uuid)
.first()
)


def clear_soft_deleted_for_import(existing: Any) -> None:
"""Hard-delete a soft-deleted row so a subsequent import of the
same UUID does not collide with the unique constraint.

Uses ``db.session.delete()`` rather than a raw Core ``DELETE`` so
the ORM ``after_delete`` event listeners fire. Cleanup that depends
on those listeners would otherwise be skipped — notably tag rows in
``tagged_object`` (cleaned up by ``ObjectUpdater.after_delete`` in
``superset/tags/core.py``; the table's ``object_id`` is a plain
integer, not a foreign key, so the database cannot cascade them)
and dataset permission-view rows (cleaned up by
``SqlaTable.after_delete`` in ``superset/connectors/sqla/models.py``).

Caller contract: ``existing`` must be a soft-deleted row returned
from :func:`find_existing_for_import`. Callers should run their
overwrite / permission validation *before* invoking this so the
destructive action only happens once the import path is committed
to proceeding.
"""
db.session.delete(existing)
db.session.flush()
98 changes: 98 additions & 0 deletions superset/commands/restore.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# 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.
"""Base class shared by all soft-delete restore commands."""

from functools import partial
from typing import Any, ClassVar, Generic, TypeVar

from superset import security_manager
from superset.commands.base import BaseCommand
from superset.exceptions import SupersetSecurityException
from superset.models.helpers import SoftDeleteMixin
from superset.utils.decorators import on_error, transaction

T = TypeVar("T", bound=SoftDeleteMixin)


class BaseRestoreCommand(BaseCommand, Generic[T]):
"""Base class for soft-delete restore commands.

Subclasses provide the entity-specific bindings as class variables —
no method override required:

- ``dao``: the DAO class (e.g. ``ChartDAO``)
- ``not_found_exc``: raised when the row doesn't exist OR isn't
soft-deleted
- ``forbidden_exc``: raised when the caller doesn't have ownership
- ``restore_failed_exc``: re-raised by the transactional wrapper
when an underlying SQLAlchemy error aborts the commit

The transactional wrapper is applied by this class's ``run()``
using ``restore_failed_exc`` as the rethrow type, so each subclass
just declares the four ClassVars and is done. There is no
subclass-managed decorator contract — earlier iterations of this
PR required subclasses to override ``run()`` purely to add a
``@transaction`` decorator, which was fragile (every new entity
rollout had to remember).

The model returned from ``validate()`` is the soft-deleted row,
type-narrowed via ``Generic[T]``. ``run()`` calls ``model.restore()``
on it (the method comes from ``SoftDeleteMixin``).
"""

dao: ClassVar[Any]
not_found_exc: ClassVar[type[Exception]]
forbidden_exc: ClassVar[type[Exception]]
restore_failed_exc: ClassVar[type[Exception]]

def __init__(self, model_uuid: str) -> None:
self._model_uuid = model_uuid

def run(self) -> None:
# Build the transactional wrapper at call time so ``on_error`` can
# reference ``self.restore_failed_exc`` — a per-subclass ClassVar
# that isn't available when this method is defined on the base.
@transaction(on_error=partial(on_error, reraise=self.restore_failed_exc))
def _perform() -> None:
model = self.validate()
model.restore()

_perform()

def validate(self) -> T: # type: ignore[override]
# ``skip_visibility_filter=True`` is the *only* bypass — the
# entity's RBAC ``base_filter`` stays in effect, matching the
# behavior of ``find_by_ids`` on the existing delete paths.
# Restore should not see rows the user cannot see in the live
# UI; ownership is then verified by ``raise_for_ownership``.
model = self.dao.find_by_id(
self._model_uuid,
id_column="uuid",
skip_visibility_filter=True,
)
if model is None:
raise self.not_found_exc(f"No row with uuid={self._model_uuid!r}")
if model.deleted_at is None:
raise self.not_found_exc(
f"Row with uuid={self._model_uuid!r} is not soft-deleted; "
"nothing to restore"
)
try:
security_manager.raise_for_ownership(model)
except SupersetSecurityException as ex:
raise self.forbidden_exc() from ex
return model
Loading
Loading