This is an automated email from the ASF dual-hosted git repository.
rebenitez1802 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 99b472bef74 fix(dashboard): saving a dashboard that contains an
archived chart no longer fails (#45543)
99b472bef74 is described below
commit 99b472bef749767692c547e496d342ee2157242c
Author: Mike Bridge <[email protected]>
AuthorDate: Fri Oct 23 18:09:16 2026 -0600
fix(dashboard): saving a dashboard that contains an archived chart no
longer fails (#45543)
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
superset/daos/dashboard.py | 64 +++++++++++---
tests/integration_tests/dashboards/api_tests.py | 100 +++++++++++++++++++++-
tests/unit_tests/dao/dashboard_test.py | 108 ++++++++++++++++++++++--
3 files changed, 254 insertions(+), 18 deletions(-)
diff --git a/superset/daos/dashboard.py b/superset/daos/dashboard.py
index a46a7157237..a4cbc4e6af7 100644
--- a/superset/daos/dashboard.py
+++ b/superset/daos/dashboard.py
@@ -25,9 +25,11 @@ from typing import Any, Callable, Dict, List, TYPE_CHECKING
from flask import g
from flask_appbuilder.models.sqla.interface import SQLAInterface
from marshmallow import ValidationError
-from sqlalchemy import or_, select
+from sqlalchemy import inspect as sa_inspect, or_, select
from sqlalchemy.engine import Row
from sqlalchemy.orm import lazyload, Query, selectinload
+from sqlalchemy.orm.attributes import set_committed_value
+from sqlalchemy.orm.state import InstanceState
from superset import security_manager
from superset.commands.dashboard.exceptions import (
@@ -328,6 +330,36 @@ def reconcile_position_json(positions: object,
dashboard_id: int | None = None)
)
+def _install_membership_baseline(dashboard: Dashboard) -> None:
+ """Install *dashboard*'s stored chart membership, archived charts
+ included, as the committed state of ``dashboard.slices``.
+
+ Call inside ``skip_visibility_filter``. Only a persistent dashboard has
+ stored membership. Pending changes to the collection must be flushed
+ first: the baseline query autoflushes them, and a caller running under
+ ``no_autoflush`` flushes explicitly (as ``UpdateDashboardCommand`` does).
+ A change still pending is kept, by leaving the collection alone, because
+ installing the baseline would drop it.
+ """
+ state: InstanceState[Dashboard] = sa_inspect(dashboard)
+ if not state.persistent:
+ return
+ baseline: list[Slice] = (
+ db.session.query(Slice)
+ .join(dashboard_slices, dashboard_slices.c.slice_id == Slice.id)
+ .filter(dashboard_slices.c.dashboard_id == dashboard.id)
+ .all()
+ )
+ if state.attrs.slices.history.has_changes():
+ logger.warning(
+ "dashboard id=%s has unflushed chart-list changes; saving it with "
+ "an archived chart may fail",
+ dashboard.id,
+ )
+ return
+ set_committed_value(dashboard, "slices", baseline)
+
+
class DashboardDAO(BaseDAO[Dashboard]):
base_filter = DashboardAccessFilter
# Column used by MCP tools for title-based identifier fallback, so a
@@ -665,21 +697,31 @@ class DashboardDAO(BaseDAO[Dashboard]):
# 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
+ # The bypass must also cover the ASSIGNMENT: 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.
+ # collection against the existing one. A baseline missing the
+ # trashed member makes the diff 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. Lazy-loading the baseline inside the bypass is not
+ # enough: a dashboard loaded through a query carries the
+ # visibility filter into its own lazy loads. Read the baseline
+ # from the association table instead and install it as the
+ # committed state.
with skip_visibility_filter(db.session, Slice):
current_slices = (
db.session.query(Slice).filter(Slice.id.in_(slice_ids)).all()
)
- dashboard.slices = current_slices
+ _install_membership_baseline(dashboard)
+ # Preserve archived membership even when no layout slot
represents it.
+ layout_ids: set[int] = {chart.id for chart in current_slices}
+ kept_archived: list[Slice] = [
+ chart
+ for chart in dashboard.slices
+ if chart.deleted_at is not None and chart.id not in
layout_ids
+ ]
+ dashboard.slices = current_slices + kept_archived
# add UUID to positions
uuid_map = {slice.id: str(slice.uuid) for slice in current_slices}
diff --git a/tests/integration_tests/dashboards/api_tests.py
b/tests/integration_tests/dashboards/api_tests.py
index c14b49c018f..c396af1d663 100644
--- a/tests/integration_tests/dashboards/api_tests.py
+++ b/tests/integration_tests/dashboards/api_tests.py
@@ -60,7 +60,7 @@ from superset.exceptions import (
ReleaseDistributedLockFailedException,
)
from superset.security.guest_token import GuestTokenResourceType, GuestUser
-from superset.models.dashboard import Dashboard
+from superset.models.dashboard import Dashboard, dashboard_slices
from superset.models.core import FavStar, FavStarClassName
from superset.reports.models import ReportSchedule, ReportScheduleType
from superset.models.slice import Slice
@@ -2991,6 +2991,104 @@ class TestDashboardApi(ApiEditorsTestCaseMixin,
InsertChartMixin, SupersetTestCa
db.session.delete(model)
db.session.commit()
+ @staticmethod
+ def _expunge_dashboards_and_charts() -> None:
+ """Drop dashboards and charts from the session so the next request
+ loads them through a query, as a fresh request would."""
+ for obj in list(db.session):
+ if isinstance(obj, (Dashboard, Slice)):
+ db.session.expunge(obj)
+
+ def test_update_dashboard_keeps_archived_chart_slot(self) -> None:
+ """PUT: saving back the layout of a dashboard that holds an archived
+ chart succeeds, and the chart keeps its slot and its membership, so
+ recovering it puts it back in place."""
+ from superset.connectors.sqla.models import SqlaTable
+ from superset.models.helpers import skip_visibility_filter
+
+ admin: User = self.get_user("admin")
+ dataset_id: int = db.session.query(SqlaTable.id).first()[0]
+ live: Slice = self.insert_chart("archived-slot-live", [admin.id],
dataset_id)
+ archived: Slice = self.insert_chart(
+ "archived-slot-archived", [admin.id], dataset_id
+ )
+ live_id: int = live.id
+ archived_id: int = archived.id
+ archived_uuid: str = str(archived.uuid)
+ positions: dict[str, Any] = {
+ "DASHBOARD_VERSION_KEY": "v2",
+ "ROOT_ID": {"id": "ROOT_ID", "type": "ROOT", "children":
["GRID_ID"]},
+ "GRID_ID": {
+ "id": "GRID_ID",
+ "type": "GRID",
+ "children": ["CHART-live", "CHART-archived"],
+ "parents": ["ROOT_ID"],
+ },
+ }
+ for slot, chart_id in (
+ ("CHART-live", live_id),
+ ("CHART-archived", archived_id),
+ ):
+ positions[slot] = {
+ "id": slot,
+ "type": "CHART",
+ "children": [],
+ "parents": ["ROOT_ID", "GRID_ID"],
+ "meta": {"chartId": chart_id, "width": 4, "height": 50},
+ }
+ dashboard: Dashboard = self.insert_dashboard(
+ "archived-slot",
+ None,
+ [admin.id],
+ slices=[live, archived],
+ position_json=json.dumps(positions),
+ )
+ dashboard_id: int = dashboard.id
+ self.login(ADMIN_USERNAME)
+ rv: TestResponse = self.client.delete(f"api/v1/chart/{archived_id}")
+ assert rv.status_code == 200, rv.data
+ try:
+ # Load the dashboard and its chart list fresh, as a real request
+ # does: through a query that hides archived charts, not from the
+ # identity map this test populated.
+ self._expunge_dashboards_and_charts()
+ rv = self.client.get(f"api/v1/dashboard/{dashboard_id}")
+ assert rv.status_code == 200, rv.data
+ self._expunge_dashboards_and_charts()
+ rv = self.client.put(
+ f"api/v1/dashboard/{dashboard_id}",
+ json={"json_metadata": json.dumps({"positions": positions})},
+ )
+ assert rv.status_code == 200, rv.data
+
+ self._expunge_dashboards_and_charts()
+ model: Dashboard = db.session.get(Dashboard, dashboard_id)
+ stored: dict[str, Any] = json.loads(model.position_json)
+ assert stored["CHART-archived"]["type"] == "CHART"
+ assert stored["CHART-archived"]["meta"]["chartId"] == archived_id
+ member_ids: set[int] = {
+ row[0]
+ for row in
db.session.query(dashboard_slices.c.slice_id).filter(
+ dashboard_slices.c.dashboard_id == dashboard_id
+ )
+ }
+ assert member_ids == {live_id, archived_id}
+
+ rv = self.client.post(f"api/v1/chart/{archived_uuid}/restore")
+ assert rv.status_code == 200, rv.data
+ self._expunge_dashboards_and_charts()
+ model = db.session.get(Dashboard, dashboard_id)
+ assert {s.id for s in model.slices} == {live_id, archived_id}
+ finally:
+ db.session.rollback()
+ self.client.post(f"api/v1/chart/{archived_uuid}/restore")
+ self._expunge_dashboards_and_charts()
+ with skip_visibility_filter(db.session, Slice):
+ db.session.delete(db.session.get(Dashboard, dashboard_id))
+ for chart_id in (live_id, archived_id):
+ db.session.delete(db.session.get(Slice, chart_id))
+ db.session.commit()
+
def test_update_dashboard_reconciles_dangling_position_json(self) -> None:
"""PUT: a raw ``position_json`` layout node referencing a chart absent
from every Slice row is persisted as a markdown placeholder
(sc-115325).
diff --git a/tests/unit_tests/dao/dashboard_test.py
b/tests/unit_tests/dao/dashboard_test.py
index f3291b633aa..2f9d415d7b0 100644
--- a/tests/unit_tests/dao/dashboard_test.py
+++ b/tests/unit_tests/dao/dashboard_test.py
@@ -19,7 +19,7 @@ from typing import Any
from unittest.mock import patch
import pytest
-from sqlalchemy import event, inspect
+from sqlalchemy import event, inspect, select
from sqlalchemy.orm.session import Session
from superset import db, security_manager
@@ -27,12 +27,13 @@ from superset.commands.dashboard.exceptions import
DashboardInvalidError
from superset.commands.dashboard.update import UpdateDashboardCommand
from superset.connectors.sqla.models import Database, SqlaTable
from superset.daos.dashboard import (
+ _install_membership_baseline,
_layout_chart_id,
DashboardDAO,
reconcile_position_json,
)
from superset.dashboards.filter_scope import derive_metadata_scopes
-from superset.models.dashboard import Dashboard
+from superset.models.dashboard import Dashboard, dashboard_slices
from superset.models.helpers import skip_visibility_filter
from superset.models.slice import Slice
from superset.subjects.models import Subject
@@ -97,10 +98,13 @@ def test_set_dash_metadata_preserves_soft_deleted_members(
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"])
+ # Production shape: the PUT flow loads the Dashboard through a query,
+ # which carries the visibility filter into the lazy load of its
+ # ``slices`` baseline. Expiring the collection alone is not enough: an
+ # object built in Python carries no filter into its lazy loads.
+ dashboard_id: int = dashboard.id
+ db.session.expunge(dashboard)
+ dashboard = db.session.query(Dashboard).filter_by(id=dashboard_id).one()
positions: dict[str, dict[str, Any]] = {
"CHART-live": {
@@ -129,10 +133,102 @@ def
test_set_dash_metadata_preserves_soft_deleted_members(
"set_dash_metadata must bypass the visibility filter when "
"resolving incoming chart ids"
)
+ # An archived chart that has a layout slot is listed once, not twice.
+ assert [chart.id for chart in dashboard.slices] == [
+ live_chart.id,
+ trashed_chart.id,
+ ]
# And the position slot kept its UUID rather than being nulled.
assert positions["CHART-trashed"]["meta"]["uuid"] ==
str(trashed_chart.uuid)
[email protected]("previously_in_layout", [False, True])
+@with_feature_flags(SOFT_DELETE=True)
+def test_set_dash_metadata_preserves_archived_member_missing_from_layout(
+ session: Session, previously_in_layout: bool
+) -> None:
+ """A layout save keeps omitted archived members but removes omitted live
ones."""
+ Dashboard.metadata.create_all(session.get_bind())
+ live: Slice = Slice(slice_name="layout_member", datasource_type="table")
+ archived: Slice = Slice(
+ slice_name="archived_member",
+ datasource_type="table",
+ deleted_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
+ )
+ removed: Slice = Slice(slice_name="removed_member",
datasource_type="table")
+ positions: dict[str, dict[str, Any]] = {
+ "CHART-live": {"type": "CHART", "meta": {"chartId": None}}
+ }
+ dashboard: Dashboard = Dashboard(
+ dashboard_title="archived_outside_layout", slices=[live, archived,
removed]
+ )
+ db.session.add(dashboard)
+ db.session.flush()
+ positions["CHART-live"]["meta"]["chartId"] = live.id
+ stored_positions: dict[str, dict[str, Any]] = dict(positions)
+ if previously_in_layout:
+ stored_positions["CHART-archived"] = {
+ "type": "CHART",
+ "meta": {"chartId": archived.id},
+ }
+ dashboard.position_json = json.dumps(stored_positions)
+ # The layout save runs in a separate transaction from the initial
membership.
+ db.session.commit()
+ dashboard_id: int = dashboard.id
+ expected_ids: set[int] = {live.id, archived.id}
+ db.session.expunge(dashboard)
+ dashboard = db.session.query(Dashboard).filter_by(id=dashboard_id).one()
+
+ DashboardDAO.set_dash_metadata(dashboard, {"positions": positions})
+ db.session.flush()
+
+ member_ids: set[int] = set(
+ db.session.scalars(
+ select(dashboard_slices.c.slice_id).where(
+ dashboard_slices.c.dashboard_id == dashboard_id
+ )
+ )
+ )
+ assert member_ids == expected_ids
+ assert json.loads(dashboard.position_json) == positions
+
+
+def test_install_membership_baseline_skips_transient_dashboard(
+ session: Session,
+) -> None:
+ """A dashboard with no stored rows keeps its in-memory chart list."""
+ Dashboard.metadata.create_all(session.get_bind())
+ chart: Slice = Slice(slice_name="transient_chart", datasource_type="table")
+ db.session.add(chart)
+ db.session.flush()
+ dashboard: Dashboard = Dashboard(dashboard_title="transient_dash",
slices=[chart])
+
+ _install_membership_baseline(dashboard)
+
+ assert dashboard.slices == [chart]
+ assert inspect(dashboard).attrs.slices.history.added == [chart]
+
+
+def test_install_membership_baseline_keeps_unflushed_change(
+ session: Session,
+) -> None:
+ """A pending change to the chart list is kept, not replaced by the
+ stored baseline, when autoflush cannot flush it first."""
+ Dashboard.metadata.create_all(session.get_bind())
+ kept: Slice = Slice(slice_name="kept_chart", datasource_type="table")
+ added: Slice = Slice(slice_name="added_chart", datasource_type="table")
+ dashboard: Dashboard = Dashboard(dashboard_title="pending_dash",
slices=[kept])
+ db.session.add_all([kept, added, dashboard])
+ db.session.flush()
+
+ with db.session.no_autoflush:
+ dashboard.slices.append(added)
+ _install_membership_baseline(dashboard)
+
+ assert dashboard.slices == [kept, added]
+ assert inspect(dashboard).attrs.slices.history.added == [added]
+
+
def test_set_dash_metadata_preserves_refresh_frequency(session: Session) ->
None:
"""set_dash_metadata must not reset refresh_frequency when absent from
data.