Browse Source

feat(dashboards): Add equations to Big Number Widget (Backend API change) (#78022)

https://github.com/getsentry/sentry/issues/68167

Adding the API changes with respect to the frontend changes to add
equations to Big Number: https://github.com/getsentry/sentry/pull/77790

---------

Co-authored-by: harshithadurai <harshi.durai@esentry.io>
Harshitha Durai 5 months ago
parent
commit
9ab0573e22

+ 1 - 1
migrations_lockfile.txt

@@ -10,7 +10,7 @@ hybridcloud: 0016_add_control_cacheversion
 nodestore: 0002_nodestore_no_dictfield
 remote_subscriptions: 0003_drop_remote_subscription
 replays: 0004_index_together
-sentry: 0766_fix_substatus_for_pending_merge
+sentry: 0767_add_selected_aggregate_to_dashboards_widget_query
 social_auth: 0002_default_auto_field
 uptime: 0013_uptime_subscription_new_unique
 workflow_engine: 0005_data_source_detector

+ 2 - 0
src/sentry/api/serializers/models/dashboard.py

@@ -41,6 +41,7 @@ class DashboardWidgetQueryResponse(TypedDict):
     widgetId: str
     onDemand: list[OnDemandResponse]
     isHidden: bool
+    selectedAggregate: int | None
 
 
 class ThresholdType(TypedDict):
@@ -164,6 +165,7 @@ class DashboardWidgetQuerySerializer(Serializer):
             "widgetId": str(obj.widget_id),
             "onDemand": attrs["onDemand"],
             "isHidden": obj.is_hidden,
+            "selectedAggregate": obj.selected_aggregate,
         }
 
 

+ 5 - 0
src/sentry/api/serializers/rest_framework/dashboard.py

@@ -160,6 +160,8 @@ class DashboardWidgetQuerySerializer(CamelSnakeSerializer[Dashboard]):
     on_demand_extraction = DashboardWidgetQueryOnDemandSerializer(many=False, required=False)
     on_demand_extraction_disabled = serializers.BooleanField(required=False)
 
+    selected_aggregate = serializers.IntegerField(required=False, allow_null=True)
+
     required_for_create = {"fields", "conditions"}
 
     validate_id = validate_id
@@ -646,6 +648,7 @@ class DashboardDetailsSerializer(CamelSnakeSerializer[Dashboard]):
                     orderby=query.get("orderby", ""),
                     order=i,
                     is_hidden=query.get("is_hidden", False),
+                    selected_aggregate=query.get("selected_aggregate"),
                 )
             )
 
@@ -724,6 +727,7 @@ class DashboardDetailsSerializer(CamelSnakeSerializer[Dashboard]):
                         is_hidden=query_data.get("is_hidden", False),
                         orderby=query_data.get("orderby", ""),
                         order=next_order + i,
+                        selected_aggregate=query_data.get("selected_aggregate"),
                     )
                 )
             else:
@@ -742,6 +746,7 @@ class DashboardDetailsSerializer(CamelSnakeSerializer[Dashboard]):
         query.columns = data.get("columns", query.columns)
         query.field_aliases = data.get("field_aliases", query.field_aliases)
         query.is_hidden = data.get("is_hidden", query.is_hidden)
+        query.selected_aggregate = data.get("selected_aggregate", query.selected_aggregate)
 
         query.order = order
         query.save()

+ 1 - 0
src/sentry/apidocs/examples/dashboard_examples.py

@@ -56,6 +56,7 @@ DASHBOARD_OBJECT = {
                         }
                     ],
                     "isHidden": False,
+                    "selectedAggregate": None,
                 }
             ],
             "limit": None,

+ 33 - 0
src/sentry/migrations/0767_add_selected_aggregate_to_dashboards_widget_query.py

@@ -0,0 +1,33 @@
+# Generated by Django 5.1.1 on 2024-09-24 19:39
+
+from django.db import migrations, models
+
+from sentry.new_migrations.migrations import CheckedMigration
+
+
+class Migration(CheckedMigration):
+    # This flag is used to mark that a migration shouldn't be automatically run in production.
+    # This should only be used for operations where it's safe to run the migration after your
+    # code has deployed. So this should not be used for most operations that alter the schema
+    # of a table.
+    # Here are some things that make sense to mark as post deployment:
+    # - Large data migrations. Typically we want these to be run manually so that they can be
+    #   monitored and not block the deploy for a long period of time while they run.
+    # - Adding indexes to large tables. Since this can take a long time, we'd generally prefer to
+    #   run this outside deployments so that we don't block them. Note that while adding an index
+    #   is a schema change, it's completely safe to run the operation after the code has deployed.
+    # Once deployed, run these manually via: https://develop.sentry.dev/database-migrations/#migration-deployment
+
+    is_post_deployment = False
+
+    dependencies = [
+        ("sentry", "0766_fix_substatus_for_pending_merge"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="dashboardwidgetquery",
+            name="selected_aggregate",
+            field=models.IntegerField(null=True),
+        ),
+    ]

+ 2 - 0
src/sentry/models/dashboard_widget.py

@@ -161,6 +161,8 @@ class DashboardWidgetQuery(Model):
     date_modified = models.DateTimeField(default=timezone.now)
     # Whether this query is hidden from the UI, used by metric widgets
     is_hidden = models.BooleanField(default=False)
+    # Used by Big Number to select aggregate displayed
+    selected_aggregate = models.IntegerField(null=True)
 
     class Meta:
         app_label = "sentry"

+ 2 - 0
src/sentry/testutils/cases.py

@@ -2725,6 +2725,8 @@ class OrganizationDashboardWidgetTestCase(APITestCase):
             assert data["columns"] == widget_data_source.columns
         if "fieldAliases" in data:
             assert data["fieldAliases"] == widget_data_source.field_aliases
+        if "selectedAggregate" in data:
+            assert data["selectedAggregate"] == widget_data_source.selected_aggregate
 
     def get_widgets(self, dashboard_id):
         return DashboardWidget.objects.filter(dashboard_id=dashboard_id).order_by("order")

+ 94 - 0
tests/sentry/api/endpoints/test_organization_dashboard_details.py

@@ -320,6 +320,33 @@ class OrganizationDashboardDetailsGetTest(OrganizationDashboardDetailsTestCase):
         assert response.status_code == 200, response.content
         assert response.data["widgets"][0]["datasetSource"] == "unknown"
 
+    def test_dashboard_widget_query_returns_selected_aggregate(self):
+        widget = DashboardWidget.objects.create(
+            dashboard=self.dashboard,
+            order=2,
+            title="Big Number Widget",
+            display_type=DashboardWidgetDisplayTypes.BIG_NUMBER,
+            widget_type=DashboardWidgetTypes.DISCOVER,
+            interval="1d",
+        )
+        DashboardWidgetQuery.objects.create(
+            widget=widget,
+            fields=["count_unique(issue)", "count()"],
+            columns=[],
+            aggregates=["count_unique(issue)", "count()"],
+            selected_aggregate=1,
+            order=0,
+        )
+        with self.feature({"organizations:dashboards-bignumber-equations": True}):
+            response = self.do_request(
+                "get",
+                self.url(self.dashboard.id),
+            )
+        assert response.status_code == 200, response.content
+
+        assert response.data["widgets"][0]["queries"][0]["selectedAggregate"] is None
+        assert response.data["widgets"][2]["queries"][0]["selectedAggregate"] == 1
+
 
 class OrganizationDashboardDetailsDeleteTest(OrganizationDashboardDetailsTestCase):
     def test_delete(self):
@@ -553,6 +580,73 @@ class OrganizationDashboardDetailsPutTest(OrganizationDashboardDetailsTestCase):
             for expected_query, actual_query in zip(expected_widget["queries"], queries):
                 self.assert_serialized_widget_query(expected_query, actual_query)
 
+    def test_add_widget_with_selected_aggregate(self):
+        data: dict[str, Any] = {
+            "title": "First dashboard",
+            "widgets": [
+                {
+                    "title": "EPM Big Number",
+                    "displayType": "big_number",
+                    "queries": [
+                        {
+                            "name": "",
+                            "fields": ["epm()"],
+                            "columns": [],
+                            "aggregates": ["epm()", "count()"],
+                            "conditions": "",
+                            "orderby": "",
+                            "selectedAggregate": 1,
+                        }
+                    ],
+                },
+            ],
+        }
+        with self.feature({"organizations:dashboards-bignumber-equations": True}):
+            response = self.do_request("put", self.url(self.dashboard.id), data=data)
+        assert response.status_code == 200, response.data
+
+        widgets = self.get_widgets(self.dashboard.id)
+        assert len(widgets) == 1
+
+        self.assert_serialized_widget(data["widgets"][0], widgets[0])
+
+        queries = widgets[0].dashboardwidgetquery_set.all()
+        assert len(queries) == 1
+        self.assert_serialized_widget_query(data["widgets"][0]["queries"][0], queries[0])
+
+    def test_add_big_number_widget_with_equation(self):
+        data: dict[str, Any] = {
+            "title": "First dashboard",
+            "widgets": [
+                {
+                    "title": "EPM Big Number",
+                    "displayType": "big_number",
+                    "queries": [
+                        {
+                            "name": "",
+                            "fields": ["equation|count()"],
+                            "columns": [],
+                            "aggregates": ["count()", "equation|count()*2"],
+                            "conditions": "",
+                            "orderby": "",
+                            "selectedAggregate": 1,
+                        }
+                    ],
+                },
+            ],
+        }
+        response = self.do_request("put", self.url(self.dashboard.id), data=data)
+        assert response.status_code == 200, response.data
+
+        widgets = self.get_widgets(self.dashboard.id)
+        assert len(widgets) == 1
+
+        self.assert_serialized_widget(data["widgets"][0], widgets[0])
+
+        queries = widgets[0].dashboardwidgetquery_set.all()
+        assert len(queries) == 1
+        self.assert_serialized_widget_query(data["widgets"][0]["queries"][0], queries[0])
+
     def test_add_widget_with_aggregates_and_columns(self):
         data: dict[str, Any] = {
             "title": "First dashboard",

+ 24 - 0
tests/sentry/api/endpoints/test_organization_dashboard_widget_details.py

@@ -331,6 +331,30 @@ class OrganizationDashboardWidgetDetailsTestCase(OrganizationDashboardWidgetTest
         )
         assert response.status_code == 200, response.data
 
+    def test_big_number_widget_with_selected_equation(self):
+        data = {
+            "title": "EPM Big Number",
+            "displayType": "big_number",
+            "queries": [
+                {
+                    "name": "",
+                    "fields": ["epm()"],
+                    "columns": [],
+                    "aggregates": ["epm()", "count()", "equation|epm()*count()"],
+                    "conditions": "",
+                    "orderby": "",
+                    "selectedAggregate": "1",
+                }
+            ],
+        }
+        with self.feature({"organizations:dashboards-bignumber-equations": True}):
+            response = self.do_request(
+                "post",
+                self.url(),
+                data=data,
+            )
+        assert response.status_code == 200, response.data
+
     def test_project_search_condition(self):
         self.user = self.create_user(is_superuser=False)
         self.project = self.create_project(

+ 36 - 0
tests/sentry/api/endpoints/test_organization_dashboards.py

@@ -936,3 +936,39 @@ class OrganizationDashboardsTest(OrganizationDashboardWidgetTestCase):
 
         assert widgets[2].widget_type == DashboardWidgetTypes.get_id_for_type_name("issue")
         assert widgets[2].discover_widget_split is None
+
+    def test_add_widget_with_selected_aggregate(self):
+        data: dict[str, Any] = {
+            "title": "First dashboard",
+            "widgets": [
+                {
+                    "title": "EPM Big Number",
+                    "displayType": "big_number",
+                    "queries": [
+                        {
+                            "name": "",
+                            "fields": ["epm()"],
+                            "columns": [],
+                            "aggregates": ["epm()", "count()"],
+                            "conditions": "",
+                            "orderby": "",
+                            "selectedAggregate": 1,
+                        }
+                    ],
+                },
+            ],
+        }
+        with self.feature({"organizations:dashboards-bignumber-equations": True}):
+            response = self.do_request("post", self.url, data=data)
+        assert response.status_code == 201, response.data
+
+        dashboard = Dashboard.objects.get(organization=self.organization, title="First dashboard")
+
+        widgets = self.get_widgets(dashboard.id)
+        assert len(widgets) == 1
+
+        self.assert_serialized_widget(data["widgets"][0], widgets[0])
+
+        queries = widgets[0].dashboardwidgetquery_set.all()
+        assert len(queries) == 1
+        self.assert_serialized_widget_query(data["widgets"][0]["queries"][0], queries[0])

Some files were not shown because too many files changed in this diff