Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
7675ea8
feat(soft-delete): add SoftDeleteMixin, DAO methods, and read-path fi…
Apr 10, 2026
f6581ce
feat(soft-delete): add restore commands and API endpoints
Apr 10, 2026
fe3fac0
test(soft-delete): add integration tests for soft delete and restore
Apr 10, 2026
cca5485
feat(soft-delete): preserve dashboard_slices on chart soft-delete (Mi…
Apr 10, 2026
4bc9127
feat(soft-delete): support include_deleted list endpoints
Apr 15, 2026
1ebf893
refactor(soft-delete): remove TODO comment and clean up mixin/test code
Apr 17, 2026
420a9d3
fix(soft-delete): add skip_visibility_filter to DatabaseDAO.find_by_i…
Apr 20, 2026
35d7eb6
feat(soft-delete): add aggregated GET /api/v1/deleted/ endpoint
Apr 23, 2026
4b47528
db(migrations): merge soft-delete and semantic-layers heads
mikebridge-gfm May 5, 2026
12c9c5d
feat(soft-delete): replace aggregated /deleted endpoint with per-enti…
mikebridge-gfm May 5, 2026
f6109f7
refactor(soft-delete): use shared.utils migration helpers in cb39f18a…
mikebridge-gfm May 7, 2026
80f8ac3
refactor(soft-delete): extend BaseDAO.find_by_ids with skip_visibilit…
mikebridge-gfm May 7, 2026
e8156de
fix(reports): null-guard report execution against missing target
mikebridge-gfm May 7, 2026
d10fe27
style(soft-delete): fix mypy types and ruff-format on test files
mikebridge-gfm May 7, 2026
21cbd91
test(soft-delete): regression tests for #8 + #9 (API blocks, embedded…
mikebridge-gfm May 7, 2026
b41d7f8
refactor(soft-delete): extract three near-duplicates flagged by clean…
mikebridge-gfm May 8, 2026
905029b
style(soft-delete): fix CI pre-commit failures
mikebridge-gfm May 8, 2026
223f038
refactor(soft-delete): apply remaining tidyings from Kent-style review
mikebridge-gfm May 8, 2026
474e0eb
refactor(soft-delete): move BaseRestoreCommand to commands/restore.py
mikebridge-gfm May 8, 2026
5984c0c
style(soft-delete): auto-walrus rewrites in v1 importer utils
mikebridge-gfm May 8, 2026
d3afcf9
docs(soft-delete): document opt-out paths and naive-datetime choice
mikebridge-gfm May 8, 2026
7d54c12
fix(soft-delete): reorder embedded-dashboard regression test assertions
mikebridge-gfm May 8, 2026
5ad4bc8
style(reports): drop redundant f-prefix on non-interpolated fragment
mikebridge-gfm May 8, 2026
d2f2955
style(soft-delete): drop f-prefix on non-interpolated implicit-concat…
mikebridge-gfm May 8, 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
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,3 +267,7 @@ pre-commit run eslint # Frontend linting
---

**LLM Note**: This codebase is actively modernizing toward full TypeScript and type safety. Always run `pre-commit run` to validate changes. Follow the ongoing refactors section to avoid deprecated patterns.

# Agent Rules <!-- tessl-managed -->

@.tessl/RULES.md follow the [instructions](.tessl/RULES.md)
35 changes: 35 additions & 0 deletions UPDATING.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,41 @@ When the feature flag is enabled, these permissions are enforced on both the fro

**Migration behavior:** All three new permissions are granted to every role that currently has `can_csv`, preserving existing access. Admins can then selectively revoke individual export permissions from specific roles as needed.

### Soft delete for Charts, Dashboards, and Datasets

DELETE API endpoints for charts, dashboards, and datasets now perform **soft delete** — setting a `deleted_at` timestamp instead of removing the row from the database. Soft-deleted objects are automatically excluded from all standard API responses and UI views.

**What changed:**
- `DELETE /api/v1/chart/<pk>`, `DELETE /api/v1/dashboard/<pk>`, `DELETE /api/v1/dataset/<pk>` now set `deleted_at` instead of removing the row
- Bulk delete endpoints follow the same behaviour
- New `POST /api/v1/{chart,dashboard,dataset}/<pk>/restore` endpoints allow restoring soft-deleted objects
- No cascade: soft-deleting a dashboard or dataset does NOT cascade to dependent charts

**Impact on external integrations:**
- API callers see no change in request/response format — same endpoints, same status codes
- External tooling that queries the database directly (bypassing the API) may see "deleted" rows that are still present with `deleted_at IS NOT NULL`
- The import/export pipeline uses `skip_visibility_filter` to handle soft-deleted rows during re-import

### Listing soft-deleted items per entity

The chart, dashboard, and dataset list endpoints accept a rison filter that surfaces soft-deleted rows. Use it as part of the standard `q=(filters:!((...)))` envelope.

| Entity | Filter `opr` |
|---|---|
| `/api/v1/chart/` | `chart_deleted_state` |
| `/api/v1/dashboard/` | `dashboard_deleted_state` |
| `/api/v1/dataset/` | `dataset_deleted_state` |

Values:

- `include` — return live rows plus soft-deleted rows
- `only` — return only soft-deleted rows
- absent / any other value — default behaviour (live rows only)

Example: `GET /api/v1/chart/?q=(filters:!((col:id,opr:chart_deleted_state,value:only)),order_column:changed_on,order_direction:desc,page:0,page_size:25)`

When the filter is applied, list responses augment each row with a `deleted_at` ISO-8601 timestamp (null for live rows). Row-level access mirrors the standard list endpoints — only the soft-delete visibility filter is bypassed.

### Deck.gl MapBox viewport and opacity controls are functional

The Deck.gl MapBox chart's **Opacity**, **Default longitude**, **Default latitude**, and **Zoom** controls were previously non-functional — changing them had no effect on the rendered map. These controls are now wired up correctly.
Expand Down
61 changes: 61 additions & 0 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 @@ -122,6 +125,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 +224,7 @@ def ensure_thumbnails_enabled(self) -> Optional[Response]:
"id": [
ChartFavoriteFilter,
ChartCertifiedFilter,
ChartDeletedStateFilter,
ChartOwnedCreatedFavoredByMeFilter,
],
"slice_name": [ChartAllTextFilter],
Expand Down Expand Up @@ -568,6 +573,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
6 changes: 4 additions & 2 deletions superset/commands/chart/importers/v1/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,11 @@ 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 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 Down
44 changes: 44 additions & 0 deletions superset/commands/chart/restore.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# 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 functools import partial

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
from superset.utils.decorators import on_error, transaction

logger = logging.getLogger(__name__)


class RestoreChartCommand(BaseRestoreCommand[Slice]):
"""Restore a soft-deleted chart by clearing its ``deleted_at`` field."""

dao = ChartDAO
not_found_exc = ChartNotFoundError
forbidden_exc = ChartForbiddenError

@transaction(on_error=partial(on_error, reraise=ChartRestoreFailedError))
def run(self) -> None:
super().run()
5 changes: 5 additions & 0 deletions superset/commands/dashboard/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from marshmallow.validate import ValidationError

from superset.commands.exceptions import (
CommandException,
CommandInvalidError,
CreateFailedError,
DeleteFailedError,
Expand Down Expand Up @@ -70,6 +71,10 @@ class DashboardColorsConfigUpdateFailedError(UpdateFailedError):
message = _("Dashboard color configuration could not be updated.")


class DashboardRestoreFailedError(CommandException):
message = _("Dashboard could not be restored.")


class DashboardDeleteFailedError(DeleteFailedError):
message = _("Dashboard could not be deleted.")

Expand Down
6 changes: 4 additions & 2 deletions superset/commands/dashboard/importers/v1/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,9 +200,11 @@ def import_dashboard( # noqa: C901
"can_write",
"Dashboard",
)
existing = db.session.query(Dashboard).filter_by(uuid=config["uuid"]).first()
from superset.commands.importers.v1.utils import find_existing_for_import

user = get_user()
if existing:

if existing := find_existing_for_import(Dashboard, config["uuid"]):
if overwrite and can_write and user:
if not security_manager.can_access_dashboard(existing) or (
user not in existing.owners and not security_manager.is_admin()
Expand Down
44 changes: 44 additions & 0 deletions superset/commands/dashboard/restore.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# 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 dashboard."""

import logging
from functools import partial

from superset.commands.dashboard.exceptions import (
DashboardForbiddenError,
DashboardNotFoundError,
DashboardRestoreFailedError,
)
from superset.commands.restore import BaseRestoreCommand
from superset.daos.dashboard import DashboardDAO
from superset.models.dashboard import Dashboard
from superset.utils.decorators import on_error, transaction

logger = logging.getLogger(__name__)


class RestoreDashboardCommand(BaseRestoreCommand[Dashboard]):
"""Restore a soft-deleted dashboard by clearing its ``deleted_at`` field."""

dao = DashboardDAO
not_found_exc = DashboardNotFoundError
forbidden_exc = DashboardForbiddenError

@transaction(on_error=partial(on_error, reraise=DashboardRestoreFailedError))
def run(self) -> None:
super().run()
4 changes: 4 additions & 0 deletions superset/commands/dataset/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,10 @@ class DatasetUpdateFailedError(UpdateFailedError):
message = _("Dataset could not be updated.")


class DatasetRestoreFailedError(CommandException):
message = _("Dataset could not be restored.")


class DatasetDeleteFailedError(DeleteFailedError):
message = _("Datasets could not be deleted.")

Expand Down
6 changes: 4 additions & 2 deletions superset/commands/dataset/importers/v1/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,11 @@ def import_dataset( # noqa: C901
"can_write",
"Dataset",
)
existing = db.session.query(SqlaTable).filter_by(uuid=config["uuid"]).first()
from superset.commands.importers.v1.utils import find_existing_for_import

user = get_user()
if existing:

if existing := find_existing_for_import(SqlaTable, config["uuid"]):
if overwrite and can_write and user:
if user not in existing.owners and not security_manager.is_admin():
raise ImportFailedError(
Expand Down
44 changes: 44 additions & 0 deletions superset/commands/dataset/restore.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# 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 dataset."""

import logging
from functools import partial

from superset.commands.dataset.exceptions import (
DatasetForbiddenError,
DatasetNotFoundError,
DatasetRestoreFailedError,
)
from superset.commands.restore import BaseRestoreCommand
from superset.connectors.sqla.models import SqlaTable
from superset.daos.dataset import DatasetDAO
from superset.utils.decorators import on_error, transaction

logger = logging.getLogger(__name__)


class RestoreDatasetCommand(BaseRestoreCommand[SqlaTable]):
"""Restore a soft-deleted dataset by clearing its ``deleted_at`` field."""

dao = DatasetDAO
not_found_exc = DatasetNotFoundError
forbidden_exc = DatasetForbiddenError

@transaction(on_error=partial(on_error, reraise=DatasetRestoreFailedError))
def run(self) -> None:
super().run()
Loading
Loading