You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by mi...@apache.org on 2026/03/20 19:43:31 UTC

(superset) 30/32: fix(reports): validate nativeFilters on report create/update and deactivate on dashboard filter deletion (#38715)

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

michaelsmolina pushed a commit to branch 6.1
in repository https://gitbox.apache.org/repos/asf/superset.git

commit cf493a928a345654476253d7221d505fb70dbfe5
Author: Enzo Martellucci <52...@users.noreply.github.com>
AuthorDate: Fri Mar 20 17:20:02 2026 +0100

    fix(reports): validate nativeFilters on report create/update and deactivate on dashboard filter deletion (#38715)
---
 superset/commands/dashboard/update.py        | 118 ++++++---
 superset/commands/report/base.py             | 146 ++++++++++-
 superset/commands/report/create.py           |  34 ---
 superset/commands/report/update.py           |   1 +
 superset/daos/report.py                      |  13 +
 tests/integration_tests/reports/api_tests.py | 368 +++++++++++++++++++++++++++
 6 files changed, 609 insertions(+), 71 deletions(-)

diff --git a/superset/commands/dashboard/update.py b/superset/commands/dashboard/update.py
index 962bf1a449d..56d15befb3f 100644
--- a/superset/commands/dashboard/update.py
+++ b/superset/commands/dashboard/update.py
@@ -60,6 +60,7 @@ class UpdateDashboardCommand(UpdateMixin, BaseCommand):
         self.validate()
         assert self._model is not None
         self.process_tab_diff()
+        self.process_native_filter_diff()
 
         # Update tags
         if (tags := self._properties.pop("tags", None)) is not None:
@@ -121,7 +122,43 @@ class UpdateDashboardCommand(UpdateMixin, BaseCommand):
         if exceptions:
             raise DashboardInvalidError(exceptions=exceptions)
 
-    def process_tab_diff(self) -> None:  # noqa: C901
+    @staticmethod
+    def _send_deactivated_report_email(
+        report: ReportSchedule, description: str
+    ) -> None:
+        html_content = textwrap.dedent(
+            f"""
+                <html>
+                <head>
+                    <style type="text/css">
+                    table, th, td {{
+                        border-collapse: collapse;
+                        border-color: rgb(200, 212, 227);
+                        color: rgb(42, 63, 95);
+                        padding: 4px 8px;
+                    }}
+                    .image{{
+                        margin-bottom: 18px;
+                    }}
+                    </style>
+                </head>
+                <body>
+                    <div>{description}</div>
+                    <br>
+                </body>
+                </html>
+                """
+        )
+        for report_owner in report.owners:
+            if email := report_owner.email:
+                send_email_smtp(
+                    to=email,
+                    subject=f"[Report: {report.name}] Deactivated",
+                    html_content=html_content,
+                    config=current_app.config,
+                )
+
+    def process_tab_diff(self) -> None:
         def find_deleted_tabs() -> list[str]:
             position_json = self._properties.get("position_json", "")
             current_tabs = self._model.tabs  # type: ignore
@@ -147,50 +184,61 @@ class UpdateDashboardCommand(UpdateMixin, BaseCommand):
                 Please update your report settings to remove or change the tab used.
                 """  # noqa: E501
             )
-
-            html_content = textwrap.dedent(
-                f"""
-                    <html>
-                    <head>
-                        <style type="text/css">
-                        table, th, td {{
-                            border-collapse: collapse;
-                            border-color: rgb(200, 212, 227);
-                            color: rgb(42, 63, 95);
-                            padding: 4px 8px;
-                        }}
-                        .image{{
-                            margin-bottom: 18px;
-                        }}
-                        </style>
-                    </head>
-                    <body>
-                        <div>{description}</div>
-                        <br>
-                    </body>
-                    </html>
-                    """
-            )
-            for report_owner in report.owners:
-                if email := report_owner.email:
-                    send_email_smtp(
-                        to=email,
-                        subject=f"[Report: {report.name}] Deactivated",
-                        html_content=html_content,
-                        config=current_app.config,
-                    )
+            self._send_deactivated_report_email(report, description)
 
         def deactivate_reports(reports_list: list[ReportSchedule]) -> None:
             for report in reports_list:
-                # deactivate
                 ReportScheduleDAO.update(report, {"active": False})
-                # send email to report owner
                 send_deactivated_email_warning(report)
 
         deleted_tabs = find_deleted_tabs()
         reports = find_reports_containing_tabs(deleted_tabs)
         deactivate_reports(reports)
 
+    def process_native_filter_diff(self) -> None:
+        def find_deleted_native_filter_ids() -> list[str]:
+            new_json_metadata = self._properties.get("json_metadata", "")
+            if not new_json_metadata:
+                return []
+            current_metadata = json.loads(self._model.json_metadata or "{}")  # type: ignore
+            new_metadata = json.loads(new_json_metadata)
+            current_filter_ids = {
+                f["id"]
+                for f in (current_metadata.get("native_filter_configuration") or [])
+                if "id" in f
+            }
+            new_filter_ids = {
+                f["id"]
+                for f in (new_metadata.get("native_filter_configuration") or [])
+                if "id" in f
+            }
+            return list(current_filter_ids - new_filter_ids)
+
+        def find_reports_containing_native_filters(
+            filter_ids: list[str],
+        ) -> list[ReportSchedule]:
+            seen: set[int] = set()
+            reports: list[ReportSchedule] = []
+            for filter_id in filter_ids:
+                for report in ReportScheduleDAO.find_by_native_filter_id(filter_id):
+                    if report.dashboard_id != self._model.id:  # type: ignore
+                        continue
+                    if report.id not in seen:
+                        seen.add(report.id)
+                        reports.append(report)
+            return reports
+
+        description = textwrap.dedent(
+            """
+            The dashboard filter used in this report has been deleted and your report has not been sent.
+            Please update your report settings to remove or change the filter used.
+            """  # noqa: E501
+        )
+        deleted_filter_ids = find_deleted_native_filter_ids()
+        for report in find_reports_containing_native_filters(deleted_filter_ids):
+            ReportScheduleDAO.update(report, {"active": False})
+            self._send_deactivated_report_email(report, description)
+
 
 class UpdateDashboardNativeFiltersCommand(UpdateDashboardCommand):
     @transaction(
diff --git a/superset/commands/report/base.py b/superset/commands/report/base.py
index 18ea6ad1fd9..3a7ec66634f 100644
--- a/superset/commands/report/base.py
+++ b/superset/commands/report/base.py
@@ -15,10 +15,11 @@
 # specific language governing permissions and limitations
 # under the License.
 import logging
-from typing import Any
+from typing import Any, Optional
 
 from croniter import croniter
 from flask import current_app as app
+from flask_babel import gettext as _
 from marshmallow import ValidationError
 
 from superset.commands.base import BaseCommand
@@ -34,6 +35,8 @@ from superset.commands.report.exceptions import (
 from superset.daos.chart import ChartDAO
 from superset.daos.dashboard import DashboardDAO
 from superset.reports.models import ReportCreationMethod, ReportScheduleType
+from superset.reports.types import ReportScheduleExtra
+from superset.utils import json
 
 logger = logging.getLogger(__name__)
 
@@ -80,6 +83,145 @@ class BaseReportScheduleCommand(BaseCommand):
         elif not update:
             exceptions.append(ReportScheduleEitherChartOrDashboardError())
 
+    def _validate_report_extra(self, exceptions: list[ValidationError]) -> None:
+        extra: Optional[ReportScheduleExtra] = self._properties.get("extra")
+        dashboard = self._properties.get("dashboard")
+
+        # On PUT requests, dashboard may not be in the payload — fall back to the model
+        if dashboard is None:
+            model = getattr(self, "_model", None)
+            dashboard = getattr(model, "dashboard", None)
+
+        if extra is None or dashboard is None:
+            return
+
+        dashboard_state = extra.get("dashboard")
+        if not dashboard_state:
+            return
+
+        if not isinstance(dashboard_state, dict):
+            exceptions.append(
+                ValidationError(
+                    _("extra.dashboard must be an object"),
+                    "extra",
+                )
+            )
+            return
+
+        position_data = json.loads(dashboard.position_json or "{}")
+        active_tabs = dashboard_state.get("activeTabs") or []
+        invalid_tab_ids = set(active_tabs) - set(position_data.keys())
+
+        if anchor := dashboard_state.get("anchor"):
+            try:
+                anchor_list: list[str] = json.loads(anchor)
+                if _invalid_tab_ids := set(anchor_list) - set(position_data.keys()):
+                    invalid_tab_ids.update(_invalid_tab_ids)
+            except json.JSONDecodeError:
+                if anchor not in position_data:
+                    invalid_tab_ids.add(anchor)
+
+        if invalid_tab_ids:
+            exceptions.append(
+                ValidationError(
+                    _("Invalid tab ids: %(tab_ids)s", tab_ids=str(invalid_tab_ids)),
+                    "extra",
+                )
+            )
+
+        self._validate_native_filters(dashboard, dashboard_state, exceptions)
+
+    def _validate_native_filters(
+        self,
+        dashboard: Any,
+        dashboard_state: Any,
+        exceptions: list[ValidationError],
+    ) -> None:
+        native_filters = dashboard_state.get("nativeFilters")
+        if not native_filters:
+            return
+
+        if not isinstance(native_filters, list):
+            exceptions.append(
+                ValidationError(
+                    _("nativeFilters must be a list"),
+                    "extra",
+                )
+            )
+            return
+
+        required_keys = {"nativeFilterId", "filterType", "columnName", "filterValues"}
+        valid_filter_ids: set[str] | None = None
+
+        for idx, native_filter in enumerate(native_filters):
+            if not isinstance(native_filter, dict):
+                exceptions.append(
+                    ValidationError(
+                        _("nativeFilters[%(idx)s] must be an object", idx=idx),
+                        "extra",
+                    )
+                )
+                continue
+
+            missing_keys = required_keys - set(native_filter.keys())
+            if missing_keys:
+                exceptions.append(
+                    ValidationError(
+                        _(
+                            "nativeFilters[%(idx)s] missing required keys: %(keys)s",
+                            idx=idx,
+                            keys=", ".join(sorted(missing_keys)),
+                        ),
+                        "extra",
+                    )
+                )
+                continue
+
+            if not isinstance(native_filter["filterValues"], list):
+                exceptions.append(
+                    ValidationError(
+                        _(
+                            "nativeFilters[%(idx)s].filterValues must be a list",
+                            idx=idx,
+                        ),
+                        "extra",
+                    )
+                )
+                continue
+
+            filter_id = native_filter["nativeFilterId"]
+            if not isinstance(filter_id, str) or not filter_id:
+                exceptions.append(
+                    ValidationError(
+                        _(
+                            "nativeFilters[%(idx)s].nativeFilterId"
+                            " must be a non-empty string",
+                            idx=idx,
+                        ),
+                        "extra",
+                    )
+                )
+                continue
+            if valid_filter_ids is None:
+                json_metadata = json.loads(dashboard.json_metadata or "{}")
+                valid_filter_ids = {
+                    f["id"]
+                    for f in json_metadata.get("native_filter_configuration", [])
+                    if "id" in f
+                }
+            if filter_id not in valid_filter_ids:
+                exceptions.append(
+                    ValidationError(
+                        _(
+                            "nativeFilters[%(idx)s].nativeFilterId '%(filter_id)s' "
+                            "does not exist on the dashboard",
+                            idx=idx,
+                            filter_id=filter_id,
+                        ),
+                        "extra",
+                    )
+                )
+
     def validate_report_frequency(
         self,
         cron_schedule: str,
@@ -116,7 +258,7 @@ class BaseReportScheduleCommand(BaseCommand):
         schedule = croniter(cron_schedule)
         current_exec = next(schedule)
 
-        for _ in range(iterations):
+        for _i in range(iterations):
             next_exec = next(schedule)
             diff, current_exec = next_exec - current_exec, next_exec
             if int(diff) < minimum_interval:
diff --git a/superset/commands/report/create.py b/superset/commands/report/create.py
index b3cd0dd16f0..f3b9b0fc6e6 100644
--- a/superset/commands/report/create.py
+++ b/superset/commands/report/create.py
@@ -19,7 +19,6 @@ from functools import partial
 from typing import Any, Optional
 
 from flask import g
-from flask_babel import gettext as _
 from marshmallow import ValidationError
 
 from superset.commands.base import CreateMixin
@@ -41,7 +40,6 @@ from superset.reports.models import (
     ReportSchedule,
     ReportScheduleType,
 )
-from superset.reports.types import ReportScheduleExtra
 from superset.utils import json
 from superset.utils.decorators import on_error, transaction
 
@@ -164,35 +162,3 @@ class CreateReportScheduleCommand(CreateMixin, BaseReportScheduleCommand):
             exceptions.append(ex)
         if exceptions:
             raise ReportScheduleInvalidError(exceptions=exceptions)
-
-    def _validate_report_extra(self, exceptions: list[ValidationError]) -> None:
-        extra: Optional[ReportScheduleExtra] = self._properties.get("extra")
-        dashboard = self._properties.get("dashboard")
-
-        if extra is None or dashboard is None:
-            return
-
-        dashboard_state = extra.get("dashboard")
-        if not dashboard_state:
-            return
-
-        position_data = json.loads(dashboard.position_json or "{}")
-        active_tabs = dashboard_state.get("activeTabs") or []
-        invalid_tab_ids = set(active_tabs) - set(position_data.keys())
-
-        if anchor := dashboard_state.get("anchor"):
-            try:
-                anchor_list: list[str] = json.loads(anchor)
-                if _invalid_tab_ids := set(anchor_list) - set(position_data.keys()):
-                    invalid_tab_ids.update(_invalid_tab_ids)
-            except json.JSONDecodeError:
-                if anchor not in position_data:
-                    invalid_tab_ids.add(anchor)
-
-        if invalid_tab_ids:
-            exceptions.append(
-                ValidationError(
-                    _("Invalid tab ids: %s(tab_ids)", tab_ids=str(invalid_tab_ids)),
-                    "extra",
-                )
-            )
diff --git a/superset/commands/report/update.py b/superset/commands/report/update.py
index 1ee09ea7a71..e09f1414f4b 100644
--- a/superset/commands/report/update.py
+++ b/superset/commands/report/update.py
@@ -131,6 +131,7 @@ class UpdateReportScheduleCommand(UpdateMixin, BaseReportScheduleCommand):
 
         # Validate chart or dashboard relations
         self.validate_chart_dashboard(exceptions, update=True)
+        self._validate_report_extra(exceptions)
 
         if "validator_config_json" in self._properties:
             self._properties["validator_config_json"] = json.dumps(
diff --git a/superset/daos/report.py b/superset/daos/report.py
index ab7af8e9763..83e22e3e215 100644
--- a/superset/daos/report.py
+++ b/superset/daos/report.py
@@ -98,6 +98,19 @@ class ReportScheduleDAO(BaseDAO[ReportSchedule]):
             .all()
         )
 
+    @staticmethod
+    def find_by_native_filter_id(native_filter_id: str) -> list[ReportSchedule]:
+        """
+        searches extra_json for a filter ID string
+        """
+        return (
+            db.session.query(ReportSchedule)
+            .filter(
+                ReportSchedule.extra_json.contains(native_filter_id, autoescape=True)
+            )
+            .all()
+        )
+
     @staticmethod
     def validate_unique_creation_method(
         dashboard_id: int | None = None, chart_id: int | None = None
diff --git a/tests/integration_tests/reports/api_tests.py b/tests/integration_tests/reports/api_tests.py
index 87fb8ccf046..6ff6547a58b 100644
--- a/tests/integration_tests/reports/api_tests.py
+++ b/tests/integration_tests/reports/api_tests.py
@@ -2345,3 +2345,371 @@ class TestReportSchedulesApi(SupersetTestCase):
         )
 
         assert json.loads(report_schedule.extra_json) == extra_json
+
+    @pytest.mark.usefixtures("create_report_schedules")
+    def test_create_report_schedule_with_garbage_native_filters(self):
+        """
+        ReportSchedule API: POST with nativeFilters containing garbage data returns 422
+        """
+        dashboard = db.session.query(Dashboard).first()
+        self.login(ADMIN_USERNAME)
+        report_schedule_data = {
+            "type": ReportScheduleType.REPORT,
+            "name": "garbage_native_filters_test",
+            "description": "description",
+            "creation_method": ReportCreationMethod.ALERTS_REPORTS,
+            "crontab": "0 9 * * *",
+            "working_timeout": 3600,
+            "dashboard": dashboard.id,
+            "extra": {"dashboard": {"nativeFilters": [{"garbage": True}]}},
+        }
+        uri = "api/v1/report/"
+        rv = self.post_assert_metric(uri, report_schedule_data, "post")
+        assert rv.status_code == 422
+        data = json.loads(rv.data.decode("utf-8"))
+        assert "message" in data
+        assert "extra" in data["message"]
+
+    @pytest.mark.usefixtures("create_report_schedules")
+    def test_create_report_schedule_with_missing_native_filter_keys(self):
+        """
+        ReportSchedule API: POST with nativeFilters missing required keys returns 422
+        """
+        dashboard = db.session.query(Dashboard).first()
+        self.login(ADMIN_USERNAME)
+        report_schedule_data = {
+            "type": ReportScheduleType.REPORT,
+            "name": "missing_keys_native_filters_test",
+            "description": "description",
+            "creation_method": ReportCreationMethod.ALERTS_REPORTS,
+            "crontab": "0 9 * * *",
+            "working_timeout": 3600,
+            "dashboard": dashboard.id,
+            "extra": {
+                "dashboard": {
+                    "nativeFilters": [{"nativeFilterId": "NATIVE_FILTER-abc"}]
+                }
+            },
+        }
+        uri = "api/v1/report/"
+        rv = self.post_assert_metric(uri, report_schedule_data, "post")
+        assert rv.status_code == 422
+        data = json.loads(rv.data.decode("utf-8"))
+        assert "message" in data
+        assert "extra" in data["message"]
+
+    @pytest.mark.usefixtures("create_report_schedules")
+    def test_create_report_schedule_with_nonexistent_native_filter_id(self):
+        """
+        ReportSchedule API: POST with nativeFilterId not on dashboard returns 422
+        """
+        dashboard = db.session.query(Dashboard).first()
+        self.login(ADMIN_USERNAME)
+        report_schedule_data = {
+            "type": ReportScheduleType.REPORT,
+            "name": "nonexistent_filter_id_test",
+            "description": "description",
+            "creation_method": ReportCreationMethod.ALERTS_REPORTS,
+            "crontab": "0 9 * * *",
+            "working_timeout": 3600,
+            "dashboard": dashboard.id,
+            "extra": {
+                "dashboard": {
+                    "nativeFilters": [
+                        {
+                            "nativeFilterId": "NATIVE_FILTER-does-not-exist",
+                            "filterType": "filter_select",
+                            "columnName": "col",
+                            "filterValues": ["a"],
+                        }
+                    ]
+                }
+            },
+        }
+        uri = "api/v1/report/"
+        rv = self.post_assert_metric(uri, report_schedule_data, "post")
+        assert rv.status_code == 422
+        data = json.loads(rv.data.decode("utf-8"))
+        assert "message" in data
+        assert "extra" in data["message"]
+
+    def test_create_report_schedule_with_valid_native_filter_empty_values(self):
+        """
+        ReportSchedule API: POST with valid nativeFilterId and empty filterValues
+        returns 201
+        """
+        # Create a dashboard with a native filter in json_metadata
+        filter_id = "NATIVE_FILTER-valid123"
+        dashboard = Dashboard()
+        dashboard.dashboard_title = "dash_with_native_filter"
+        dashboard.slug = "dash_with_native_filter"
+        dashboard.json_metadata = json.dumps(
+            {"native_filter_configuration": [{"id": filter_id, "name": "Test Filter"}]}
+        )
+        db.session.add(dashboard)
+        db.session.commit()
+
+        self.login(ADMIN_USERNAME)
+        report_schedule_data = {
+            "type": ReportScheduleType.REPORT,
+            "name": "valid_native_filter_empty_values",
+            "description": "description",
+            "creation_method": ReportCreationMethod.ALERTS_REPORTS,
+            "crontab": "0 9 * * *",
+            "working_timeout": 3600,
+            "dashboard": dashboard.id,
+            "extra": {
+                "dashboard": {
+                    "nativeFilters": [
+                        {
+                            "nativeFilterId": filter_id,
+                            "filterType": "filter_select",
+                            "columnName": "col",
+                            "filterValues": [],
+                        }
+                    ]
+                }
+            },
+        }
+        uri = "api/v1/report/"
+        rv = self.post_assert_metric(uri, report_schedule_data, "post")
+        assert rv.status_code == 201
+
+        created_id = json.loads(rv.data.decode("utf-8")).get("id")
+        created_model = db.session.query(ReportSchedule).get(created_id)
+        db.session.delete(created_model)
+        db.session.delete(dashboard)
+        db.session.commit()
+
+    @pytest.mark.usefixtures("create_report_schedules")
+    def test_update_report_schedule_with_garbage_native_filters(self):
+        """
+        ReportSchedule API: PUT with nativeFilters containing garbage data returns 422
+        """
+        report_schedule = (
+            db.session.query(ReportSchedule)
+            .filter(ReportSchedule.name == "name2")
+            .one_or_none()
+        )
+        dashboard = db.session.query(Dashboard).first()
+        self.login(ADMIN_USERNAME)
+        report_schedule_data = {
+            "type": ReportScheduleType.REPORT,
+            "name": "name2",
+            "crontab": "0 10 * * *",
+            "dashboard": dashboard.id,
+            "extra": {"dashboard": {"nativeFilters": [{"garbage": True}]}},
+        }
+        uri = f"api/v1/report/{report_schedule.id}"
+        rv = self.put_assert_metric(uri, report_schedule_data, "put")
+        assert rv.status_code == 422
+        data = json.loads(rv.data.decode("utf-8"))
+        assert "message" in data
+        assert "extra" in data["message"]
+
+    @pytest.mark.usefixtures("create_report_schedules")
+    def test_update_report_schedule_with_stale_native_filter_id(self):
+        """
+        ReportSchedule API: PUT with nativeFilterId no longer on dashboard returns 422
+        """
+        report_schedule = (
+            db.session.query(ReportSchedule)
+            .filter(ReportSchedule.name == "name2")
+            .one_or_none()
+        )
+        # Dashboard with no native filters configured
+        dashboard = db.session.query(Dashboard).first()
+        self.login(ADMIN_USERNAME)
+        report_schedule_data = {
+            "type": ReportScheduleType.REPORT,
+            "name": "name2",
+            "crontab": "0 10 * * *",
+            "dashboard": dashboard.id,
+            "extra": {
+                "dashboard": {
+                    "nativeFilters": [
+                        {
+                            "nativeFilterId": "NATIVE_FILTER-stale",
+                            "filterType": "filter_select",
+                            "columnName": "col",
+                            "filterValues": ["val"],
+                        }
+                    ]
+                }
+            },
+        }
+        uri = f"api/v1/report/{report_schedule.id}"
+        rv = self.put_assert_metric(uri, report_schedule_data, "put")
+        assert rv.status_code == 422
+        data = json.loads(rv.data.decode("utf-8"))
+        assert "message" in data
+        assert "extra" in data["message"]
+
+    @patch("superset.commands.dashboard.update.send_email_smtp")
+    def test_dashboard_update_deletes_native_filter_deactivates_reports(
+        self, mock_send_email: Any
+    ):
+        """
+        Dashboard API: removing a native filter deactivates referencing reports
+        and emails each owner
+        """
+        filter_id = "NATIVE_FILTER-todelete"
+
+        # Create dashboard with that filter
+        dashboard = Dashboard()
+        dashboard.dashboard_title = "dash_filter_delete"
+        dashboard.slug = "dash_filter_delete"
+        dashboard.json_metadata = json.dumps(
+            {"native_filter_configuration": [{"id": filter_id, "name": "To Delete"}]}
+        )
+        db.session.add(dashboard)
+        db.session.flush()
+
+        admin = self.get_user("admin")
+
+        # Create report referencing that filter
+        report = insert_report_schedule(
+            type=ReportScheduleType.REPORT,
+            name="report_with_filter",
+            crontab="0 9 * * *",
+            owners=[admin],
+            dashboard=dashboard,
+            extra={
+                "dashboard": {
+                    "nativeFilters": [
+                        {
+                            "nativeFilterId": filter_id,
+                            "filterType": "filter_select",
+                            "columnName": "col",
+                            "filterValues": [],
+                        }
+                    ]
+                }
+            },
+        )
+
+        db.session.commit()
+
+        self.login(ADMIN_USERNAME)
+        # Update dashboard removing the native filter
+        uri = f"api/v1/dashboard/{dashboard.id}"
+        rv = self.put_assert_metric(
+            uri,
+            {"json_metadata": json.dumps({"native_filter_configuration": []})},
+            "put",
+        )
+        assert rv.status_code == 200
+
+        db.session.refresh(report)
+        assert report.active is False
+        assert mock_send_email.called
+
+        db.session.delete(report)
+        db.session.delete(dashboard)
+        db.session.commit()
+
+    @patch("superset.commands.dashboard.update.send_email_smtp")
+    def test_dashboard_update_unrelated_filter_removal_no_side_effects(
+        self, mock_send_email: Any
+    ):
+        """
+        Dashboard API: removing a filter not referenced by any report has no
+        side effects
+        """
+        filter_id = "NATIVE_FILTER-unreferenced"
+
+        dashboard = Dashboard()
+        dashboard.dashboard_title = "dash_no_reports"
+        dashboard.slug = "dash_no_reports"
+        dashboard.json_metadata = json.dumps(
+            {"native_filter_configuration": [{"id": filter_id, "name": "Unused"}]}
+        )
+        db.session.add(dashboard)
+        db.session.commit()
+
+        self.login(ADMIN_USERNAME)
+        uri = f"api/v1/dashboard/{dashboard.id}"
+        rv = self.put_assert_metric(
+            uri,
+            {"json_metadata": json.dumps({"native_filter_configuration": []})},
+            "put",
+        )
+        assert rv.status_code == 200
+        assert not mock_send_email.called
+
+        db.session.delete(dashboard)
+        db.session.commit()
+
+    @patch("superset.commands.dashboard.update.send_email_smtp")
+    def test_dashboard_update_deleted_filter_multiple_reports_notifies_all_owners(
+        self, mock_send_email: Any
+    ):
+        """
+        Dashboard API: removing a filter referenced by multiple reports deactivates
+        all of them and emails each owner once per report
+        """
+        filter_id = "NATIVE_FILTER-shared"
+
+        dashboard = Dashboard()
+        dashboard.dashboard_title = "dash_shared_filter"
+        dashboard.slug = "dash_shared_filter"
+        dashboard.json_metadata = json.dumps(
+            {"native_filter_configuration": [{"id": filter_id, "name": "Shared"}]}
+        )
+        db.session.add(dashboard)
+        db.session.flush()
+
+        admin = self.get_user("admin")
+
+        native_filter_extra = {
+            "dashboard": {
+                "nativeFilters": [
+                    {
+                        "nativeFilterId": filter_id,
+                        "filterType": "filter_select",
+                        "columnName": "col",
+                        "filterValues": [],
+                    }
+                ]
+            }
+        }
+
+        report_a = insert_report_schedule(
+            type=ReportScheduleType.REPORT,
+            name="report_shared_filter_a",
+            crontab="0 9 * * *",
+            owners=[admin],
+            dashboard=dashboard,
+            extra=native_filter_extra,
+        )
+        report_b = insert_report_schedule(
+            type=ReportScheduleType.REPORT,
+            name="report_shared_filter_b",
+            crontab="0 10 * * *",
+            owners=[admin],
+            dashboard=dashboard,
+            extra=native_filter_extra,
+        )
+
+        db.session.commit()
+
+        self.login(ADMIN_USERNAME)
+        uri = f"api/v1/dashboard/{dashboard.id}"
+        rv = self.put_assert_metric(
+            uri,
+            {"json_metadata": json.dumps({"native_filter_configuration": []})},
+            "put",
+        )
+        assert rv.status_code == 200
+
+        db.session.refresh(report_a)
+        db.session.refresh(report_b)
+        assert report_a.active is False
+        assert report_b.active is False
+        # One email call per report (admin owns both)
+        assert mock_send_email.call_count == 2
+
+        db.session.delete(report_a)
+        db.session.delete(report_b)
+        db.session.delete(dashboard)
+        db.session.commit()