You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/07/16 05:00:52 UTC

(superset) branch master updated: fix(reports): null-guard execution against missing target (#39973)

This is an automated email from the ASF dual-hosted git repository.

rusackas 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 635b18103dc fix(reports): null-guard execution against missing target (#39973)
635b18103dc is described below

commit 635b18103dc312e378aa96a3f029aafdfc1b964e
Author: Mike Bridge <mi...@bridgecanada.com>
AuthorDate: Wed Jul 15 23:00:46 2026 -0600

    fix(reports): null-guard execution against missing target (#39973)
    
    Co-authored-by: Mike Bridge <mi...@ext.preset.io>
    Co-authored-by: Claude Opus 4.7 (1M context) <no...@anthropic.com>
    Co-authored-by: Evan Rusackas <ev...@preset.io>
---
 superset/commands/report/execute.py              | 44 ++++++++++--------
 tests/unit_tests/commands/report/execute_test.py | 58 ++++++++++++++++++++++++
 2 files changed, 84 insertions(+), 18 deletions(-)

diff --git a/superset/commands/report/execute.py b/superset/commands/report/execute.py
index 8c92d427da9..50cfe33af0b 100644
--- a/superset/commands/report/execute.py
+++ b/superset/commands/report/execute.py
@@ -265,6 +265,9 @@ class BaseReportState:
         """
         Get the url for this report schedule: chart or dashboard
         """
+        chart = self._report_schedule.chart
+        dashboard = self._report_schedule.dashboard
+
         # Soft delete removed the FK-level guarantee that a report's target
         # chart exists: ``chart`` is a visibility-filtered relationship, so a
         # chart soft-deleted after this report was created (or attached via a
@@ -276,23 +279,29 @@ class BaseReportState:
         # Every content path (_get_screenshots, _get_csv_data,
         # _get_embedded_data, _get_notification_content) funnels through this
         # method, so this is the single choke point.
-        if (
-            self._report_schedule.chart_id is not None
-            and self._report_schedule.chart is None
-        ):
-            raise ReportScheduleTargetChartDeletedError()
-        # Symmetric guard for dashboard targets. Dashboard soft delete lands
-        # in the sibling rollout; until then this cannot fire (a dashboard
-        # with dependent reports cannot be deleted), which makes it inert
-        # rather than wrong — and it keeps the report-target error vocabulary
-        # parallel across entities from day one.
-        if (
-            self._report_schedule.dashboard_id is not None
-            and self._report_schedule.dashboard is None
-        ):
-            raise ReportScheduleTargetDashboardDeletedError()
+        if chart is None and dashboard is None:
+            if self._report_schedule.chart_id is not None:
+                raise ReportScheduleTargetChartDeletedError()
+            # Symmetric guard for dashboard targets. Dashboard soft delete lands
+            # in the sibling rollout; until then this cannot fire (a dashboard
+            # with dependent reports cannot be deleted), which makes it inert
+            # rather than wrong — and it keeps the report-target error vocabulary
+            # parallel across entities from day one.
+            if self._report_schedule.dashboard_id is not None:
+                raise ReportScheduleTargetDashboardDeletedError()
+            # Defensive fallback for a malformed report with no target IDs.
+            # Missing relationships with a target ID are handled by the
+            # dedicated deleted-target errors above.
+            raise ReportScheduleUnexpectedError(
+                f"Report schedule {self._report_schedule.id} "
+                f"({self._report_schedule.name!r}) has no resolvable target "
+                f"(chart_id={self._report_schedule.chart_id}, "
+                f"dashboard_id={self._report_schedule.dashboard_id}); "
+                "the report has neither a chart nor a dashboard."
+            )
+
         force = "true" if self._report_schedule.force_screenshot else "false"
-        if self._report_schedule.chart:
+        if chart:
             if result_format in {
                 ChartDataResultFormat.CSV,
                 ChartDataResultFormat.XLSX,
@@ -318,9 +327,8 @@ class BaseReportState:
         ) and feature_flag_manager.is_feature_enabled("ALERT_REPORT_TABS"):
             return self._get_tab_url(dashboard_state, user_friendly=user_friendly)
 
-        dashboard = self._report_schedule.dashboard
         dashboard_id_or_slug = (
-            dashboard.uuid if dashboard and dashboard.uuid else dashboard.id
+            dashboard.uuid if dashboard.uuid is not None else dashboard.id
         )
         return get_url_path(
             "Superset.dashboard",
diff --git a/tests/unit_tests/commands/report/execute_test.py b/tests/unit_tests/commands/report/execute_test.py
index ab4e3ae6b33..232d6ab48a0 100644
--- a/tests/unit_tests/commands/report/execute_test.py
+++ b/tests/unit_tests/commands/report/execute_test.py
@@ -2386,6 +2386,8 @@ def test_get_url_raises_when_target_chart_soft_deleted(
     report_schedule = mocker.MagicMock()
     report_schedule.chart_id = 42
     report_schedule.chart = None
+    report_schedule.dashboard_id = None
+    report_schedule.dashboard = None
 
     state = BaseReportState(report_schedule, datetime.utcnow(), uuid4())
     with pytest.raises(ReportScheduleTargetChartDeletedError):
@@ -2415,6 +2417,33 @@ def test_get_url_raises_when_target_dashboard_soft_deleted(
         state._get_url()
 
 
+def test_get_url_uses_valid_chart_with_stale_dashboard_reference(
+    mocker: MockerFixture,
+    app_context: None,
+) -> None:
+    """A stale non-target dashboard must not block a valid chart report."""
+    report_schedule: ReportSchedule = mocker.Mock(spec=ReportSchedule)
+    report_schedule.chart_id = 42
+    report_schedule.chart = mocker.sentinel.chart
+    report_schedule.dashboard_id = 7
+    report_schedule.dashboard = None
+    report_schedule.force_screenshot = False
+    get_url_path = mocker.patch(
+        "superset.commands.report.execute.get_url_path",
+        return_value="/chart",
+    )
+
+    state = BaseReportState(report_schedule, datetime.utcnow(), uuid4())
+
+    assert state._get_url() == "/chart"
+    get_url_path.assert_called_once_with(
+        "ExploreView.root",
+        user_friendly=False,
+        form_data=json.dumps({"slice_id": 42}),
+        force="false",
+    )
+
+
 def test_get_dashboard_urls_raises_when_target_dashboard_soft_deleted(
     mocker: MockerFixture,
     app_context: None,
@@ -2433,3 +2462,32 @@ def test_get_dashboard_urls_raises_when_target_dashboard_soft_deleted(
     state = BaseReportState(report_schedule, datetime.utcnow(), uuid4())
     with pytest.raises(ReportScheduleTargetDashboardDeletedError):
         state.get_dashboard_urls()
+
+
+def test_get_url_raises_unexpected_error_when_target_is_missing(
+    mocker: MockerFixture,
+    app: SupersetApp,
+) -> None:
+    """A malformed schedule without either target raises a useful error."""
+    mock_report_schedule: ReportSchedule = mocker.Mock(spec=ReportSchedule)
+    mock_report_schedule.id = 42
+    mock_report_schedule.name = "orphan_report"
+    mock_report_schedule.chart = None
+    mock_report_schedule.chart_id = None
+    mock_report_schedule.dashboard = None
+    mock_report_schedule.dashboard_id = None
+    mock_report_schedule.force_screenshot = False
+
+    class_instance: BaseReportState = BaseReportState(
+        mock_report_schedule, "January 1, 2021", "execution_id_example"
+    )
+    class_instance._report_schedule = mock_report_schedule
+
+    with pytest.raises(ReportScheduleUnexpectedError) as excinfo:
+        class_instance._get_url()
+
+    message: str = str(excinfo.value)
+    assert "Report schedule 42" in message
+    assert "orphan_report" in message
+    assert "chart_id=None" in message
+    assert "dashboard_id=None" in message