From ee71646f7fc641832c5a2abc50f4a705312d1a17 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Tue, 5 May 2026 15:30:04 -0400 Subject: [PATCH 1/3] feat(dashboards): Disallow updates to widgets with deprecated display types Reject PUT requests that touch widgets whose saved display_type is in DashboardWidgetDisplayTypes.DEPRECATED_TYPES (stacked_area, top_n). Previously these widgets were creatable-blocked but updatable, which left existing deprecated widgets indefinitely editable. Clients now need to delete and recreate them with a supported display type. Co-Authored-By: Claude --- src/sentry/api/serializers/rest_framework/dashboard.py | 7 +++++++ src/sentry/models/dashboard_widget.py | 4 ++-- .../endpoints/test_organization_dashboard_details.py | 9 ++++----- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/sentry/api/serializers/rest_framework/dashboard.py b/src/sentry/api/serializers/rest_framework/dashboard.py index 06412741bb713d..d2af61c066b835 100644 --- a/src/sentry/api/serializers/rest_framework/dashboard.py +++ b/src/sentry/api/serializers/rest_framework/dashboard.py @@ -1123,6 +1123,13 @@ def _update_or_create_field_links( ) def update_widget(self, widget, data): + if widget.display_type in DashboardWidgetDisplayTypes.DEPRECATED_TYPES: + raise serializers.ValidationError( + { + "display_type": f"{DashboardWidgetDisplayTypes.get_type_name(widget.display_type)} is no longer a supported display type. Please delete and recreate this widget." + } + ) + prev_layout = widget.detail.get("layout") if widget.detail else None prev_axis_range = widget.detail.get("axis_range") if widget.detail else None prev_legend_type = widget.detail.get("legend_type") if widget.detail else None diff --git a/src/sentry/models/dashboard_widget.py b/src/sentry/models/dashboard_widget.py index f65697bd38c574..c85c5734971064 100644 --- a/src/sentry/models/dashboard_widget.py +++ b/src/sentry/models/dashboard_widget.py @@ -210,8 +210,8 @@ class DashboardWidgetDisplayTypes(TypesClass): # Display types that the frontend no longer exposes in either the widget # builder dropdown or the widget library. TOP_N is converted to AREA at # every UI entry point (widget library, builder URL deserialization). New - # widgets should not be created with these, but existing widgets remain - # editable. + # widgets cannot be created with these and existing widgets cannot be + # updated; clients must delete and recreate them with a supported type. DEPRECATED_TYPES: list[int] = [STACKED_AREA_CHART, TOP_N] diff --git a/tests/sentry/dashboards/endpoints/test_organization_dashboard_details.py b/tests/sentry/dashboards/endpoints/test_organization_dashboard_details.py index 6157157c427ca2..4e73bcde7268b0 100644 --- a/tests/sentry/dashboards/endpoints/test_organization_dashboard_details.py +++ b/tests/sentry/dashboards/endpoints/test_organization_dashboard_details.py @@ -1696,9 +1696,7 @@ def test_update_widget_title(self) -> None: widgets = self.get_widgets(self.dashboard.id) self.assert_serialized_widget(data["widgets"][0], widgets[0]) - def test_update_widget_with_deprecated_display_type_is_allowed(self) -> None: - # Existing widgets created via the API with deprecated display types - # should remain editable; the rejection only applies to new widgets. + def test_update_widget_with_deprecated_display_type_is_rejected(self) -> None: deprecated_widget = DashboardWidget.objects.create( dashboard=self.dashboard, order=4, @@ -1729,10 +1727,11 @@ def test_update_widget_with_deprecated_display_type_is_allowed(self) -> None: ], } response = self.do_request("put", self.url(self.dashboard.id), data=data) - assert response.status_code == 200, response.data + assert response.status_code == 400, response.data + assert "stacked_area is no longer a supported display type" in str(response.data) deprecated_widget.refresh_from_db() - assert deprecated_widget.title == "Renamed stacked area" + assert deprecated_widget.title == "Stacked area" assert deprecated_widget.display_type == DashboardWidgetDisplayTypes.STACKED_AREA_CHART def test_update_widget_add_query(self) -> None: From e5e2cb5dee01645de978c47f2dd8f5f463957afe Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Tue, 5 May 2026 15:42:27 -0400 Subject: [PATCH 2/3] feat(dashboards): Update deprecated display type error to suggest area Co-Authored-By: Claude --- src/sentry/api/serializers/rest_framework/dashboard.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry/api/serializers/rest_framework/dashboard.py b/src/sentry/api/serializers/rest_framework/dashboard.py index d2af61c066b835..d7aa0ba95dfd01 100644 --- a/src/sentry/api/serializers/rest_framework/dashboard.py +++ b/src/sentry/api/serializers/rest_framework/dashboard.py @@ -1126,7 +1126,7 @@ def update_widget(self, widget, data): if widget.display_type in DashboardWidgetDisplayTypes.DEPRECATED_TYPES: raise serializers.ValidationError( { - "display_type": f"{DashboardWidgetDisplayTypes.get_type_name(widget.display_type)} is no longer a supported display type. Please delete and recreate this widget." + "display_type": f"{DashboardWidgetDisplayTypes.get_type_name(widget.display_type)} is no longer a supported display type. Please use `area` instead." } ) From 40e1fccf17cf80f0bdcb62784badc33d51a8b573 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Tue, 5 May 2026 16:00:54 -0400 Subject: [PATCH 3/3] ref(dashboards): Remove top_n and stacked_area display type handling The 1080 backfill migration converted all top_n and stacked_area widgets to area, so neither value can appear in the database anymore. Drop the enum entries, the DEPRECATED_TYPES list, the create-time and update-time validator branches, the slack unfurl mappings, and the generate_dashboard_artifact blocklist entry. Also reverts the prior commit's update_widget rejection (no widgets remain to reject) and fixes is_table_display_type which relied on the TYPES list position matching the TABLE id; switched to get_type_name so future enum changes don't silently swap which type is "table". Update the 1080 backfill test to use raw integer ids (2, 7) for the historic display types since the constants no longer exist on the model. Co-Authored-By: Claude --- .../serializers/rest_framework/dashboard.py | 22 +---------- .../models/generate_dashboard_artifact.py | 1 - .../integrations/slack/unfurl/dashboards.py | 2 - src/sentry/models/dashboard_widget.py | 11 ------ .../test_organization_dashboard_details.py | 38 ------------------- .../endpoints/test_organization_dashboards.py | 30 --------------- ...precated_dashboard_widget_display_types.py | 12 ++++-- 7 files changed, 10 insertions(+), 106 deletions(-) diff --git a/src/sentry/api/serializers/rest_framework/dashboard.py b/src/sentry/api/serializers/rest_framework/dashboard.py index d7aa0ba95dfd01..8d6ae13a8113de 100644 --- a/src/sentry/api/serializers/rest_framework/dashboard.py +++ b/src/sentry/api/serializers/rest_framework/dashboard.py @@ -98,9 +98,8 @@ def validate_id(self, value): def is_table_display_type(display_type): - return ( - display_type - == DashboardWidgetDisplayTypes.as_text_choices()[DashboardWidgetDisplayTypes.TABLE][0] + return display_type == DashboardWidgetDisplayTypes.get_type_name( + DashboardWidgetDisplayTypes.TABLE ) @@ -412,16 +411,6 @@ def validate(self, data): if data.get("display_type") == DashboardWidgetDisplayTypes.TEXT: return self._validate_text_widget(data) - if ( - not data.get("id") - and data.get("display_type") in DashboardWidgetDisplayTypes.DEPRECATED_TYPES - ): - raise serializers.ValidationError( - { - "display_type": f"{DashboardWidgetDisplayTypes.get_type_name(data['display_type'])} is no longer a supported display type." - } - ) - query_errors = [] all_columns: set[str] = set() has_columns = False @@ -1123,13 +1112,6 @@ def _update_or_create_field_links( ) def update_widget(self, widget, data): - if widget.display_type in DashboardWidgetDisplayTypes.DEPRECATED_TYPES: - raise serializers.ValidationError( - { - "display_type": f"{DashboardWidgetDisplayTypes.get_type_name(widget.display_type)} is no longer a supported display type. Please use `area` instead." - } - ) - prev_layout = widget.detail.get("layout") if widget.detail else None prev_axis_range = widget.detail.get("axis_range") if widget.detail else None prev_legend_type = widget.detail.get("legend_type") if widget.detail else None diff --git a/src/sentry/dashboards/models/generate_dashboard_artifact.py b/src/sentry/dashboards/models/generate_dashboard_artifact.py index 7eaf0cad14da12..3250658c6ab803 100644 --- a/src/sentry/dashboards/models/generate_dashboard_artifact.py +++ b/src/sentry/dashboards/models/generate_dashboard_artifact.py @@ -15,7 +15,6 @@ "rage_and_dead_clicks", "wheel", "agents_traces_table", - "stacked_area", } # Most of these are deprecated, not selectable in the UI, or don't make sense for generated dashboards. diff --git a/src/sentry/integrations/slack/unfurl/dashboards.py b/src/sentry/integrations/slack/unfurl/dashboards.py index c4d1b24532fdd2..bd68b5f2a6ce34 100644 --- a/src/sentry/integrations/slack/unfurl/dashboards.py +++ b/src/sentry/integrations/slack/unfurl/dashboards.py @@ -63,9 +63,7 @@ class DashboardsUnfurlArgs(TypedDict): _TIMESERIES_DISPLAY_TYPES = { DashboardWidgetDisplayTypes.LINE_CHART: "line", DashboardWidgetDisplayTypes.AREA_CHART: "area", - DashboardWidgetDisplayTypes.STACKED_AREA_CHART: "area", DashboardWidgetDisplayTypes.BAR_CHART: "bar", - DashboardWidgetDisplayTypes.TOP_N: "area", } diff --git a/src/sentry/models/dashboard_widget.py b/src/sentry/models/dashboard_widget.py index c85c5734971064..1439ca9cf6316c 100644 --- a/src/sentry/models/dashboard_widget.py +++ b/src/sentry/models/dashboard_widget.py @@ -177,11 +177,9 @@ def as_text_choices(cls): class DashboardWidgetDisplayTypes(TypesClass): LINE_CHART = 0 AREA_CHART = 1 - STACKED_AREA_CHART = 2 BAR_CHART = 3 TABLE = 4 BIG_NUMBER = 6 - TOP_N = 7 DETAILS = 8 CATEGORICAL_BAR_CHART = 9 WHEEL = 10 @@ -192,11 +190,9 @@ class DashboardWidgetDisplayTypes(TypesClass): TYPES = [ (LINE_CHART, "line"), (AREA_CHART, "area"), - (STACKED_AREA_CHART, "stacked_area"), (BAR_CHART, "bar"), (TABLE, "table"), (BIG_NUMBER, "big_number"), - (TOP_N, "top_n"), (DETAILS, "details"), (CATEGORICAL_BAR_CHART, "categorical_bar"), (WHEEL, "wheel"), @@ -207,13 +203,6 @@ class DashboardWidgetDisplayTypes(TypesClass): ] TYPE_NAMES = [t[1] for t in TYPES] - # Display types that the frontend no longer exposes in either the widget - # builder dropdown or the widget library. TOP_N is converted to AREA at - # every UI entry point (widget library, builder URL deserialization). New - # widgets cannot be created with these and existing widgets cannot be - # updated; clients must delete and recreate them with a supported type. - DEPRECATED_TYPES: list[int] = [STACKED_AREA_CHART, TOP_N] - @cell_silo_model class DashboardWidgetQuery(Model): diff --git a/tests/sentry/dashboards/endpoints/test_organization_dashboard_details.py b/tests/sentry/dashboards/endpoints/test_organization_dashboard_details.py index 4e73bcde7268b0..b73813dda837d1 100644 --- a/tests/sentry/dashboards/endpoints/test_organization_dashboard_details.py +++ b/tests/sentry/dashboards/endpoints/test_organization_dashboard_details.py @@ -1696,44 +1696,6 @@ def test_update_widget_title(self) -> None: widgets = self.get_widgets(self.dashboard.id) self.assert_serialized_widget(data["widgets"][0], widgets[0]) - def test_update_widget_with_deprecated_display_type_is_rejected(self) -> None: - deprecated_widget = DashboardWidget.objects.create( - dashboard=self.dashboard, - order=4, - title="Stacked area", - display_type=DashboardWidgetDisplayTypes.STACKED_AREA_CHART, - widget_type=DashboardWidgetTypes.DISCOVER, - interval="1d", - limit=5, - ) - DashboardWidgetQuery.objects.create( - widget=deprecated_widget, - name="Transactions", - fields=["count()"], - columns=[], - aggregates=["count()"], - conditions="event.type:transaction", - order=0, - ) - - data: dict[str, Any] = { - "title": "First dashboard", - "widgets": [ - {"id": str(self.widget_1.id)}, - {"id": str(self.widget_2.id)}, - {"id": str(self.widget_3.id)}, - {"id": str(self.widget_4.id)}, - {"id": str(deprecated_widget.id), "title": "Renamed stacked area"}, - ], - } - response = self.do_request("put", self.url(self.dashboard.id), data=data) - assert response.status_code == 400, response.data - assert "stacked_area is no longer a supported display type" in str(response.data) - - deprecated_widget.refresh_from_db() - assert deprecated_widget.title == "Stacked area" - assert deprecated_widget.display_type == DashboardWidgetDisplayTypes.STACKED_AREA_CHART - def test_update_widget_add_query(self) -> None: data: dict[str, Any] = { "title": "First dashboard", diff --git a/tests/sentry/dashboards/endpoints/test_organization_dashboards.py b/tests/sentry/dashboards/endpoints/test_organization_dashboards.py index e439636483b30d..aa7fd4f660f601 100644 --- a/tests/sentry/dashboards/endpoints/test_organization_dashboards.py +++ b/tests/sentry/dashboards/endpoints/test_organization_dashboards.py @@ -2469,33 +2469,3 @@ def test_post_validate_only_error_for_invalid_dashboard(self) -> None: assert not Dashboard.objects.filter( organization=self.organization, title="Invalid Dashboard" ).exists() - - def test_post_with_deprecated_display_type_rejected(self) -> None: - data: dict[str, Any] = { - "title": "Dashboard with Deprecated Widget", - "widgets": [ - { - "displayType": "stacked_area", - "interval": "5m", - "title": "Stacked area", - "queries": [ - { - "name": "Transactions", - "fields": ["count()"], - "columns": [], - "aggregates": ["count()"], - "conditions": "event.type:transaction", - } - ], - }, - ], - } - response = self.do_request("post", self.url, data=data) - assert response.status_code == 400, response.data - assert ( - "stacked_area is no longer a supported display type." - in response.data["widgets"][0]["displayType"][0] - ) - assert not Dashboard.objects.filter( - organization=self.organization, title="Dashboard with Deprecated Widget" - ).exists() diff --git a/tests/sentry/migrations/test_1080_backfill_deprecated_dashboard_widget_display_types.py b/tests/sentry/migrations/test_1080_backfill_deprecated_dashboard_widget_display_types.py index 9c5a1ffa2b6b47..c3db5d502a57c9 100644 --- a/tests/sentry/migrations/test_1080_backfill_deprecated_dashboard_widget_display_types.py +++ b/tests/sentry/migrations/test_1080_backfill_deprecated_dashboard_widget_display_types.py @@ -1,6 +1,10 @@ from sentry.models.dashboard_widget import DashboardWidget, DashboardWidgetDisplayTypes from sentry.testutils.cases import TestMigrations +# Display type IDs that existed before the 1080 backfill removed them. +_LEGACY_STACKED_AREA_CHART = 2 +_LEGACY_TOP_N = 7 + class BackfillDeprecatedDashboardWidgetDisplayTypesTest(TestMigrations): migrate_from = "1079_purge_scm_legacy_org_options" @@ -15,28 +19,28 @@ def setup_before_migration(self, apps): self.top_n_null_limit = DashboardWidget.objects.create( dashboard=dashboard, title="top_n null limit", - display_type=DashboardWidgetDisplayTypes.TOP_N, + display_type=_LEGACY_TOP_N, order=0, limit=None, ) self.top_n_explicit_limit = DashboardWidget.objects.create( dashboard=dashboard, title="top_n explicit limit", - display_type=DashboardWidgetDisplayTypes.TOP_N, + display_type=_LEGACY_TOP_N, order=1, limit=3, ) self.stacked_area_null_limit = DashboardWidget.objects.create( dashboard=dashboard, title="stacked_area null limit", - display_type=DashboardWidgetDisplayTypes.STACKED_AREA_CHART, + display_type=_LEGACY_STACKED_AREA_CHART, order=2, limit=None, ) self.stacked_area_explicit_limit = DashboardWidget.objects.create( dashboard=dashboard, title="stacked_area explicit limit", - display_type=DashboardWidgetDisplayTypes.STACKED_AREA_CHART, + display_type=_LEGACY_STACKED_AREA_CHART, order=3, limit=8, )