Skip to content

Commit efa5b45

Browse files
fix: Handle missing chart references in dashboard import
1 parent 0294c30 commit efa5b45

File tree

3 files changed

+267
-2
lines changed

3 files changed

+267
-2
lines changed

superset/commands/chart/delete.py

Lines changed: 135 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020

2121
from flask_babel import lazy_gettext as _
2222

23-
from superset import security_manager
23+
from superset import db, security_manager
2424
from superset.commands.base import BaseCommand
2525
from superset.commands.chart.exceptions import (
2626
ChartDeleteFailedError,
@@ -31,7 +31,9 @@
3131
from superset.daos.chart import ChartDAO
3232
from superset.daos.report import ReportScheduleDAO
3333
from superset.exceptions import SupersetSecurityException
34+
from superset.models.dashboard import Dashboard
3435
from superset.models.slice import Slice
36+
from superset.utils import json
3537
from superset.utils.decorators import on_error, transaction
3638

3739
logger = logging.getLogger(__name__)
@@ -46,6 +48,9 @@ def __init__(self, model_ids: list[int]):
4648
def run(self) -> None:
4749
self.validate()
4850
assert self._models
51+
# Clean up dashboard metadata before deleting charts
52+
for chart in self._models:
53+
self._cleanup_dashboard_metadata(chart.id)
4954
ChartDAO.delete(self._models)
5055

5156
def validate(self) -> None:
@@ -68,3 +73,132 @@ def validate(self) -> None:
6873
security_manager.raise_for_ownership(model)
6974
except SupersetSecurityException as ex:
7075
raise ChartForbiddenError() from ex
76+
77+
def _cleanup_dashboard_metadata( # noqa: C901
78+
self, chart_id: int
79+
) -> None:
80+
"""
81+
Remove references to this chart from all dashboard metadata.
82+
83+
When a chart is deleted, dashboards may still contain references to the
84+
chart ID in various metadata fields (expanded_slices, filter_scopes, etc.).
85+
This method cleans up those references to prevent issues during dashboard
86+
export/import.
87+
"""
88+
# Find all dashboards that contain this chart
89+
dashboards = (
90+
db.session.query(Dashboard)
91+
.filter(Dashboard.slices.any(id=chart_id)) # type: ignore[attr-defined]
92+
.all()
93+
)
94+
95+
for dashboard in dashboards:
96+
metadata = dashboard.params_dict
97+
modified = False
98+
99+
# Clean up expanded_slices
100+
if "expanded_slices" in metadata:
101+
chart_id_str = str(chart_id)
102+
if chart_id_str in metadata["expanded_slices"]:
103+
del metadata["expanded_slices"][chart_id_str]
104+
modified = True
105+
logger.info(
106+
"Removed chart %s from expanded_slices in dashboard %s",
107+
chart_id,
108+
dashboard.id,
109+
)
110+
111+
# Clean up timed_refresh_immune_slices
112+
if "timed_refresh_immune_slices" in metadata:
113+
if chart_id in metadata["timed_refresh_immune_slices"]:
114+
metadata["timed_refresh_immune_slices"].remove(chart_id)
115+
modified = True
116+
logger.info(
117+
"Removed chart %s from timed_refresh_immune_slices "
118+
"in dashboard %s",
119+
chart_id,
120+
dashboard.id,
121+
)
122+
123+
# Clean up filter_scopes
124+
if "filter_scopes" in metadata:
125+
chart_id_str = str(chart_id)
126+
if chart_id_str in metadata["filter_scopes"]:
127+
del metadata["filter_scopes"][chart_id_str]
128+
modified = True
129+
logger.info(
130+
"Removed chart %s from filter_scopes in dashboard %s",
131+
chart_id,
132+
dashboard.id,
133+
)
134+
# Also clean from immune lists
135+
for filter_scope in metadata["filter_scopes"].values():
136+
for attributes in filter_scope.values():
137+
if chart_id in attributes.get("immune", []):
138+
attributes["immune"].remove(chart_id)
139+
modified = True
140+
141+
# Clean up default_filters
142+
if "default_filters" in metadata:
143+
default_filters = json.loads(metadata["default_filters"])
144+
chart_id_str = str(chart_id)
145+
if chart_id_str in default_filters:
146+
del default_filters[chart_id_str]
147+
metadata["default_filters"] = json.dumps(default_filters)
148+
modified = True
149+
logger.info(
150+
"Removed chart %s from default_filters in dashboard %s",
151+
chart_id,
152+
dashboard.id,
153+
)
154+
155+
# Clean up native_filter_configuration scope exclusions
156+
if "native_filter_configuration" in metadata:
157+
for native_filter in metadata["native_filter_configuration"]:
158+
scope_excluded = native_filter.get("scope", {}).get("excluded", [])
159+
if chart_id in scope_excluded:
160+
scope_excluded.remove(chart_id)
161+
modified = True
162+
logger.info(
163+
"Removed chart %s from native_filter_configuration "
164+
"in dashboard %s",
165+
chart_id,
166+
dashboard.id,
167+
)
168+
169+
# Clean up chart_configuration
170+
if "chart_configuration" in metadata:
171+
chart_id_str = str(chart_id)
172+
if chart_id_str in metadata["chart_configuration"]:
173+
del metadata["chart_configuration"][chart_id_str]
174+
modified = True
175+
logger.info(
176+
"Removed chart %s from chart_configuration in dashboard %s",
177+
chart_id,
178+
dashboard.id,
179+
)
180+
181+
# Clean up global_chart_configuration scope exclusions
182+
if "global_chart_configuration" in metadata:
183+
scope_excluded = (
184+
metadata["global_chart_configuration"]
185+
.get("scope", {})
186+
.get("excluded", [])
187+
)
188+
if chart_id in scope_excluded:
189+
scope_excluded.remove(chart_id)
190+
modified = True
191+
logger.info(
192+
"Removed chart %s from global_chart_configuration "
193+
"in dashboard %s",
194+
chart_id,
195+
dashboard.id,
196+
)
197+
198+
if modified:
199+
dashboard.json_metadata = json.dumps(metadata)
200+
logger.info(
201+
"Cleaned up metadata for dashboard %s after deleting chart %s",
202+
dashboard.id,
203+
chart_id,
204+
)

superset/commands/dashboard/importers/v1/utils.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,9 @@ def update_id_refs( # pylint: disable=too-many-locals # noqa: C901
7575
metadata = fixed.get("metadata", {})
7676
if "timed_refresh_immune_slices" in metadata:
7777
metadata["timed_refresh_immune_slices"] = [
78-
id_map[old_id] for old_id in metadata["timed_refresh_immune_slices"]
78+
id_map[old_id]
79+
for old_id in metadata["timed_refresh_immune_slices"]
80+
if old_id in id_map
7981
]
8082

8183
if "filter_scopes" in metadata:
@@ -100,6 +102,7 @@ def update_id_refs( # pylint: disable=too-many-locals # noqa: C901
100102
metadata["expanded_slices"] = {
101103
str(id_map[int(old_id)]): value
102104
for old_id, value in metadata["expanded_slices"].items()
105+
if int(old_id) in id_map
103106
}
104107

105108
if "default_filters" in metadata:

tests/unit_tests/dashboards/commands/importers/v1/utils_test.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,3 +199,131 @@ def test_update_id_refs_cross_filter_handles_string_excluded():
199199
fixed = update_id_refs(config, chart_ids, dataset_info)
200200
# Should not raise and should remap key
201201
assert "1" in fixed["metadata"]["chart_configuration"]
202+
203+
204+
def test_update_id_refs_expanded_slices_with_missing_chart():
205+
"""
206+
Test that missing charts in expanded_slices are gracefully skipped.
207+
208+
When a chart is deleted from a workspace, dashboards may still contain
209+
references to the deleted chart ID in expanded_slices metadata. This test
210+
verifies that the import process skips missing chart references instead of
211+
raising a KeyError.
212+
"""
213+
from superset.commands.dashboard.importers.v1.utils import update_id_refs
214+
215+
config = {
216+
"position": {
217+
"CHART1": {
218+
"id": "CHART1",
219+
"meta": {"chartId": 101, "uuid": "uuid1"},
220+
"type": "CHART",
221+
},
222+
},
223+
"metadata": {
224+
"expanded_slices": {
225+
"101": True, # This chart exists in the import
226+
"102": False, # This chart was deleted and doesn't exist
227+
"103": True, # Another deleted chart
228+
},
229+
},
230+
}
231+
chart_ids = {"uuid1": 1} # Only uuid1 exists in the import
232+
dataset_info: dict[str, dict[str, Any]] = {}
233+
234+
fixed = update_id_refs(config, chart_ids, dataset_info)
235+
236+
# Should only include the existing chart, missing charts are skipped
237+
assert fixed["metadata"]["expanded_slices"] == {"1": True}
238+
# Should not raise KeyError for missing charts 102 and 103
239+
240+
241+
def test_update_id_refs_timed_refresh_immune_slices_with_missing_chart():
242+
"""
243+
Test that missing charts in timed_refresh_immune_slices are gracefully skipped.
244+
245+
When a chart is deleted from a workspace, dashboards may still contain
246+
references to the deleted chart ID in timed_refresh_immune_slices metadata.
247+
This test verifies that the import process skips missing chart references
248+
instead of raising a KeyError.
249+
"""
250+
from superset.commands.dashboard.importers.v1.utils import update_id_refs
251+
252+
config = {
253+
"position": {
254+
"CHART1": {
255+
"id": "CHART1",
256+
"meta": {"chartId": 101, "uuid": "uuid1"},
257+
"type": "CHART",
258+
},
259+
"CHART2": {
260+
"id": "CHART2",
261+
"meta": {"chartId": 102, "uuid": "uuid2"},
262+
"type": "CHART",
263+
},
264+
},
265+
"metadata": {
266+
"timed_refresh_immune_slices": [
267+
101, # This chart exists
268+
102, # This chart exists
269+
103, # This chart was deleted and doesn't exist
270+
104, # Another deleted chart
271+
],
272+
},
273+
}
274+
chart_ids = {"uuid1": 1, "uuid2": 2} # Only uuid1 and uuid2 exist
275+
dataset_info: dict[str, dict[str, Any]] = {}
276+
277+
fixed = update_id_refs(config, chart_ids, dataset_info)
278+
279+
# Should only include existing charts, missing charts are skipped
280+
assert fixed["metadata"]["timed_refresh_immune_slices"] == [1, 2]
281+
# Should not raise KeyError for missing charts 103 and 104
282+
283+
284+
def test_update_id_refs_multiple_missing_chart_references():
285+
"""
286+
Test that multiple metadata fields with missing charts are all handled gracefully.
287+
288+
This comprehensive test verifies that all metadata fields properly skip
289+
missing chart references during import.
290+
"""
291+
from superset.commands.dashboard.importers.v1.utils import update_id_refs
292+
293+
config = {
294+
"position": {
295+
"CHART1": {
296+
"id": "CHART1",
297+
"meta": {"chartId": 101, "uuid": "uuid1"},
298+
"type": "CHART",
299+
},
300+
},
301+
"metadata": {
302+
"expanded_slices": {
303+
"101": True,
304+
"999": False, # Missing chart
305+
},
306+
"timed_refresh_immune_slices": [101, 999], # 999 is missing
307+
"filter_scopes": {
308+
"101": {"region": {"immune": [999]}}, # 999 is missing
309+
"999": {"region": {"immune": [101]}}, # Key 999 is missing
310+
},
311+
"default_filters": '{"101": {"col": "value"}, "999": {"col": "other"}}',
312+
"native_filter_configuration": [
313+
{"scope": {"excluded": [101, 999]}} # 999 is missing
314+
],
315+
},
316+
}
317+
chart_ids = {"uuid1": 1} # Only uuid1 exists
318+
dataset_info: dict[str, dict[str, Any]] = {}
319+
320+
fixed = update_id_refs(config, chart_ids, dataset_info)
321+
322+
# All missing chart references should be gracefully skipped
323+
assert fixed["metadata"]["expanded_slices"] == {"1": True}
324+
assert fixed["metadata"]["timed_refresh_immune_slices"] == [1]
325+
assert fixed["metadata"]["filter_scopes"] == {"1": {"region": {"immune": []}}}
326+
assert fixed["metadata"]["default_filters"] == '{"1": {"col": "value"}}'
327+
assert fixed["metadata"]["native_filter_configuration"] == [
328+
{"scope": {"excluded": [1]}}
329+
]

0 commit comments

Comments
 (0)