From 07fe888cc9c8207d905806c9a82c038643e32ff4 Mon Sep 17 00:00:00 2001 From: Edward Gou Date: Wed, 22 Oct 2025 16:33:02 -0400 Subject: [PATCH 1/6] syncs prebuilt dashboards to backend --- migrations_lockfile.txt | 2 +- .../api/serializers/models/dashboard.py | 10 +- .../endpoints/organization_dashboards.py | 98 ++++++++++++++++++- src/sentry/features/temporary.py | 2 + .../0997_add_prebuilt_id_to_dashboards.py | 52 ++++++++++ src/sentry/models/dashboard.py | 14 ++- .../endpoints/test_organization_dashboards.py | 85 ++++++++++++++++ 7 files changed, 256 insertions(+), 7 deletions(-) create mode 100644 src/sentry/migrations/0997_add_prebuilt_id_to_dashboards.py diff --git a/migrations_lockfile.txt b/migrations_lockfile.txt index 3eb97ec892c8aa..ef3bc4eb7181ce 100644 --- a/migrations_lockfile.txt +++ b/migrations_lockfile.txt @@ -29,7 +29,7 @@ releases: 0001_release_models replays: 0006_add_bulk_delete_job -sentry: 0996_add_dashboard_field_link_model +sentry: 0997_add_prebuilt_id_to_dashboards social_auth: 0003_social_auth_json_field diff --git a/src/sentry/api/serializers/models/dashboard.py b/src/sentry/api/serializers/models/dashboard.py index 886dfb804fc9d6..3202c9f00463d7 100644 --- a/src/sentry/api/serializers/models/dashboard.py +++ b/src/sentry/api/serializers/models/dashboard.py @@ -394,6 +394,7 @@ class DashboardListResponse(TypedDict): permissions: DashboardPermissionsResponse | None isFavorited: bool projects: list[int] + prebuiltId: int | None class _WidgetPreview(TypedDict): @@ -565,6 +566,7 @@ def serialize(self, obj, attrs, user, **kwargs) -> DashboardListResponse: "environment": attrs.get("environment", []), "filters": attrs.get("filters", {}), "lastVisited": attrs.get("last_visited", None), + "prebuiltId": obj.prebuilt_id, } @@ -593,6 +595,7 @@ class DashboardDetailsResponse(DashboardDetailsResponseOptional): filters: DashboardFilters permissions: DashboardPermissionsResponse | None isFavorited: bool + prebuiltId: int | None @register(Dashboard) @@ -629,13 +632,18 @@ def serialize(self, obj, attrs, user, **kwargs) -> DashboardDetailsResponse: "id": str(obj.id), "title": obj.title, "dateCreated": obj.date_added, - "createdBy": user_service.serialize_many(filter={"user_ids": [obj.created_by_id]})[0], + "createdBy": ( + user_service.serialize_many(filter={"user_ids": [obj.created_by_id]})[0] + if obj.created_by_id + else None + ), "widgets": attrs["widgets"], "filters": tag_filters, "permissions": serialize(obj.permissions) if hasattr(obj, "permissions") else None, "isFavorited": user.id in obj.favorited_by, "projects": page_filters.get("projects", []), "environment": page_filters.get("environment", []), + "prebuiltId": obj.prebuilt_id, **page_filters, } diff --git a/src/sentry/dashboards/endpoints/organization_dashboards.py b/src/sentry/dashboards/endpoints/organization_dashboards.py index 3bf3fa7781f4c0..3752ad1754f2e2 100644 --- a/src/sentry/dashboards/endpoints/organization_dashboards.py +++ b/src/sentry/dashboards/endpoints/organization_dashboards.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import Any +from enum import IntEnum +from typing import Any, TypedDict import sentry_sdk from django.db import IntegrityError, router, transaction @@ -46,6 +47,7 @@ from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.auth.superuser import is_active_superuser from sentry.db.models.fields.text import CharField +from sentry.locks import locks from sentry.models.dashboard import Dashboard, DashboardFavoriteUser, DashboardLastVisited from sentry.models.organization import Organization from sentry.organizations.services.organization.model import ( @@ -53,10 +55,74 @@ RpcUserOrganizationContext, ) from sentry.users.services.user.service import user_service +from sentry.utils.locking import UnableToAcquireLock MAX_RETRIES = 2 +# Do not delete or modify existing entries. These enums are required to match ids in the frontend. +class PrebuiltDashboardId(IntEnum): + FRONTEND_SESSION_HEALTH = 1 + + +class PrebuiltDashboard(TypedDict): + prebuilt_id: PrebuiltDashboardId + title: str + + +# Prebuilt dashboards store minimal fields in the database. The actual dashboard and widget settings are +# coded in the frontend and we rely on matching prebuilt_id to populate the dashboard and widget display. +# Prebuilt dashboard database records are purely for tracking things like starred status, last viewed, etc. +# +# Note A: This is stored differently from the `default-overview` prebuilt dashboard, which we should +# deprecate once this feature is released. +# Note B: Consider storing all dashboard and widget data in the database instead of relying on matching +# prebuilt_id on the frontend, if there are issues. +PREBUILT_DASHBOARDS: list[PrebuiltDashboard] = [ + { + "prebuilt_id": PrebuiltDashboardId.FRONTEND_SESSION_HEALTH, + "title": "Frontend Session Health", + }, +] + + +def sync_prebuilt_dashboards(organization: Organization) -> None: + """ + Queries the database to check if prebuilt dashboards have a Dashboard record and + creates them if they don't, or deletes them if they should no longer exist. + """ + + with transaction.atomic(router.db_for_write(Dashboard)): + saved_prebuilt_dashboards = Dashboard.objects.filter( + organization=organization, + prebuilt_id__isnull=False, + ) + + saved_prebuilt_dashboard_ids = set( + saved_prebuilt_dashboards.values_list("prebuilt_id", flat=True) + ) + + # Create prebuilt dashboards if they don't exist + for prebuilt_dashboard in PREBUILT_DASHBOARDS: + prebuilt_id: PrebuiltDashboardId = prebuilt_dashboard["prebuilt_id"] + + if prebuilt_id not in saved_prebuilt_dashboard_ids: + # Create new dashboard + Dashboard.objects.create( + organization=organization, + title=prebuilt_dashboard["title"], + created_by_id=None, + prebuilt_id=prebuilt_id, + ) + + # Delete old prebuilt dashboards if they should no longer exist + prebuilt_ids = [d["prebuilt_id"] for d in PREBUILT_DASHBOARDS] + Dashboard.objects.filter( + organization=organization, + prebuilt_id__isnull=False, + ).exclude(prebuilt_id__in=prebuilt_ids).delete() + + class OrganizationDashboardsPermission(OrganizationPermission): scope_map = { "GET": ["org:read", "org:write", "org:admin"], @@ -127,6 +193,28 @@ def get(self, request: Request, organization: Organization) -> Response: if not features.has("organizations:dashboards-basic", organization, actor=request.user): return Response(status=404) + if features.has( + "organizations:dashboards-prebuilt-insights-dashboards", + organization, + actor=request.user, + ): + # Sync prebuilt dashboards to the database + try: + lock = locks.get( + f"dashboards:sync_prebuilt_dashboards:{organization.id}:{request.user.id}", + duration=10, + name="sync_prebuilt_dashboards", + ) + with lock.acquire(): + # Adds prebuilt dashboards to the database if they don't exist. + # Deletes old prebuilt dashboards from the database if they should no longer exist. + sync_prebuilt_dashboards(organization) + except UnableToAcquireLock: + # Another process is already syncing the prebuilt dashboards. We can skip syncing this time. + pass + except Exception as err: + sentry_sdk.capture_exception(err) + filter_by = request.query_params.get("filter") if filter_by == "onlyFavorites": dashboards = Dashboard.objects.filter( @@ -202,7 +290,13 @@ def get(self, request: Request, organization: Organization) -> Response: user_name_dict = { user.id: user.name for user in user_service.get_many_by_id( - ids=list(dashboards.values_list("created_by_id", flat=True)) + ids=list( + id + for id in dashboards.values_list("created_by_id", flat=True).filter( + created_by_id__isnull=False + ) + if id is not None + ) ) } dashboards = dashboards.annotate( diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 9f0a8b2633d9e3..069e61f6b4ecb6 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -106,6 +106,8 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:dashboards-widget-builder-redesign", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable drilldown flow for dashboards manager.add("organizations:dashboards-drilldown-flow", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) + # Enable prebuilt dashboards for insights modules + manager.add("organizations:dashboards-prebuilt-insights-dashboards", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Data Secrecy manager.add("organizations:data-secrecy", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Data Secrecy v2 (with Break the Glass feature) diff --git a/src/sentry/migrations/0997_add_prebuilt_id_to_dashboards.py b/src/sentry/migrations/0997_add_prebuilt_id_to_dashboards.py new file mode 100644 index 00000000000000..e9ad9b32cf4f12 --- /dev/null +++ b/src/sentry/migrations/0997_add_prebuilt_id_to_dashboards.py @@ -0,0 +1,52 @@ +# Generated by Django 5.2.1 on 2025-10-22 17:21 + +from django.db import migrations, models + +import sentry.db.models.fields.bounded +import sentry.db.models.fields.hybrid_cloud_foreign_key +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", "0996_add_dashboard_field_link_model"), + ] + + operations = [ + migrations.AddField( + model_name="dashboard", + name="prebuilt_id", + field=sentry.db.models.fields.bounded.BoundedPositiveIntegerField( + db_default=None, null=True + ), + ), + migrations.AlterField( + model_name="dashboard", + name="created_by_id", + field=sentry.db.models.fields.hybrid_cloud_foreign_key.HybridCloudForeignKey( + "sentry.User", db_index=True, null=True, on_delete="CASCADE" + ), + ), + migrations.AddConstraint( + model_name="dashboard", + constraint=models.UniqueConstraint( + condition=models.Q(("prebuilt_id__isnull", False)), + fields=("organization", "prebuilt_id"), + name="sentry_dashboard_organization_prebuilt_id_uniq", + ), + ), + ] diff --git a/src/sentry/models/dashboard.py b/src/sentry/models/dashboard.py index 743c89bd940bfa..1adb3156a2178c 100644 --- a/src/sentry/models/dashboard.py +++ b/src/sentry/models/dashboard.py @@ -5,7 +5,7 @@ import sentry_sdk from django.db import models, router, transaction -from django.db.models import UniqueConstraint +from django.db.models import Q, UniqueConstraint from django.db.models.query import QuerySet from django.utils import timezone @@ -13,7 +13,7 @@ from sentry.constants import ALL_ACCESS_PROJECT_ID from sentry.db.models import FlexibleForeignKey, Model, region_silo_model, sane_repr from sentry.db.models.base import DefaultFieldsModel -from sentry.db.models.fields.bounded import BoundedBigIntegerField +from sentry.db.models.fields.bounded import BoundedBigIntegerField, BoundedPositiveIntegerField from sentry.db.models.fields.hybrid_cloud_foreign_key import HybridCloudForeignKey from sentry.db.models.fields.jsonfield import JSONField from sentry.db.models.fields.slug import SentrySlugField @@ -223,13 +223,14 @@ class Dashboard(Model): __relocation_scope__ = RelocationScope.Organization title = models.CharField(max_length=255) - created_by_id = HybridCloudForeignKey("sentry.User", on_delete="CASCADE") + created_by_id = HybridCloudForeignKey("sentry.User", null=True, on_delete="CASCADE") organization = FlexibleForeignKey("sentry.Organization") date_added = models.DateTimeField(default=timezone.now) visits = BoundedBigIntegerField(null=True, default=1) last_visited = models.DateTimeField(null=True, default=timezone.now) projects = models.ManyToManyField("sentry.Project", through=DashboardProject) filters: models.Field[dict[str, Any] | None, dict[str, Any] | None] = JSONField(null=True) + prebuilt_id = BoundedPositiveIntegerField(null=True, db_default=None) MAX_WIDGETS = 30 @@ -237,6 +238,13 @@ class Meta: app_label = "sentry" db_table = "sentry_dashboard" unique_together = (("organization", "title"),) + constraints = [ + UniqueConstraint( + fields=["organization", "prebuilt_id"], + condition=Q(prebuilt_id__isnull=False), + name="sentry_dashboard_organization_prebuilt_id_uniq", + ) + ] __repr__ = sane_repr("organization", "title") diff --git a/tests/sentry/dashboards/endpoints/test_organization_dashboards.py b/tests/sentry/dashboards/endpoints/test_organization_dashboards.py index 7be61e0cca6bfa..967b761487fae1 100644 --- a/tests/sentry/dashboards/endpoints/test_organization_dashboards.py +++ b/tests/sentry/dashboards/endpoints/test_organization_dashboards.py @@ -6,6 +6,7 @@ from django.urls import reverse +from sentry.dashboards.endpoints.organization_dashboards import PREBUILT_DASHBOARDS from sentry.models.dashboard import ( Dashboard, DashboardFavoriteUser, @@ -1906,3 +1907,87 @@ def test_prebuilt_dashboard_is_shown_when_favorites_pinned_and_no_dashboards(sel assert response.status_code == 200, response.content assert len(response.data) == 1 assert response.data[0]["title"] == "General" + + def test_endpoint_creates_prebuilt_dashboards_when_none_exist(self) -> None: + prebuilt_count = Dashboard.objects.filter( + organization=self.organization, prebuilt_id__isnull=False + ).count() + assert prebuilt_count == 0 + + with self.feature("organizations:dashboards-prebuilt-insights-dashboards"): + response = self.do_request("get", self.url) + assert response.status_code == 200 + + prebuilt_dashboards = Dashboard.objects.filter( + organization=self.organization, prebuilt_id__isnull=False + ) + assert prebuilt_dashboards.count() == len(PREBUILT_DASHBOARDS) + + for prebuilt_dashboard in PREBUILT_DASHBOARDS: + dashboard = prebuilt_dashboards.get(prebuilt_id=prebuilt_dashboard["prebuilt_id"]) + assert dashboard.title == prebuilt_dashboard["title"] + assert dashboard.organization == self.organization + assert dashboard.created_by_id is None + assert dashboard.prebuilt_id == prebuilt_dashboard["prebuilt_id"] + + matching_response_data = [ + d + for d in response.data + if "prebuiltId" in d and d["prebuiltId"] == prebuilt_dashboard["prebuilt_id"] + ] + assert len(matching_response_data) == 1 + + def test_endpoint_does_not_create_duplicate_prebuilt_dashboards_when_exist(self) -> None: + with self.feature("organizations:dashboards-prebuilt-insights-dashboards"): + response = self.do_request("get", self.url) + assert response.status_code == 200 + + initial_count = Dashboard.objects.filter( + organization=self.organization, prebuilt_id__isnull=False + ).count() + assert initial_count == len(PREBUILT_DASHBOARDS) + + with self.feature("organizations:dashboards-prebuilt-insights-dashboards"): + response = self.do_request("get", self.url) + assert response.status_code == 200 + + final_count = Dashboard.objects.filter( + organization=self.organization, prebuilt_id__isnull=False + ).count() + assert final_count == initial_count + assert final_count == len(PREBUILT_DASHBOARDS) + + def test_endpoint_deletes_old_prebuilt_dashboards_not_in_list(self) -> None: + old_prebuilt_id = 9999 # 9999 is not a valid prebuilt dashboard id + old_dashboard = Dashboard.objects.create( + organization=self.organization, + title="Old Prebuilt Dashboard", + created_by_id=None, + prebuilt_id=old_prebuilt_id, + ) + assert Dashboard.objects.filter(id=old_dashboard.id).exists() + + with self.feature("organizations:dashboards-prebuilt-insights-dashboards"): + response = self.do_request("get", self.url) + assert response.status_code == 200 + + assert not Dashboard.objects.filter(id=old_dashboard.id).exists() + + prebuilt_dashboards = Dashboard.objects.filter( + organization=self.organization, prebuilt_id__isnull=False + ) + assert prebuilt_dashboards.count() == len(PREBUILT_DASHBOARDS) + + def test_endpoint_does_not_sync_without_feature_flag(self) -> None: + prebuilt_count = Dashboard.objects.filter( + organization=self.organization, prebuilt_id__isnull=False + ).count() + assert prebuilt_count == 0 + + response = self.do_request("get", self.url) + assert response.status_code == 200 + + prebuilt_count = Dashboard.objects.filter( + organization=self.organization, prebuilt_id__isnull=False + ).count() + assert prebuilt_count == 0 From c899a8e18c29fa272caab94b29a36e515d09ab82 Mon Sep 17 00:00:00 2001 From: Edward Gou Date: Wed, 22 Oct 2025 17:36:35 -0400 Subject: [PATCH 2/6] fix --- src/sentry/api/serializers/models/dashboard.py | 2 +- .../test_organization_dashboard_details.py | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/sentry/api/serializers/models/dashboard.py b/src/sentry/api/serializers/models/dashboard.py index 6a1ea2492d61ca..2c496fd67b71f2 100644 --- a/src/sentry/api/serializers/models/dashboard.py +++ b/src/sentry/api/serializers/models/dashboard.py @@ -613,7 +613,7 @@ class DashboardDetailsResponse(DashboardDetailsResponseOptional): id: str title: str dateCreated: str - createdBy: UserSerializerResponse + createdBy: UserSerializerResponse | None widgets: list[DashboardWidgetResponse] projects: list[int] filters: DashboardFilters diff --git a/tests/sentry/dashboards/endpoints/test_organization_dashboard_details.py b/tests/sentry/dashboards/endpoints/test_organization_dashboard_details.py index 854945f410a67f..f8d6393efa56ee 100644 --- a/tests/sentry/dashboards/endpoints/test_organization_dashboard_details.py +++ b/tests/sentry/dashboards/endpoints/test_organization_dashboard_details.py @@ -2349,7 +2349,7 @@ def test_update_dashboard_permissions_with_put(self) -> None: "permissions": {"isEditableByEveryone": "False"}, } - user = User(id=self.dashboard.created_by_id) + user = User(id=self.dashboard.created_by_id) # type: ignore[arg-type] self.login_as(user=user) response = self.do_request( "put", f"{self.url(self.dashboard.id)}?environment=mock_env", data=data @@ -2366,7 +2366,7 @@ def test_update_dashboard_permissions_to_false(self) -> None: "permissions": {"isEditableByEveryone": "false"}, } - user = User(id=self.dashboard.created_by_id) + user = User(id=self.dashboard.created_by_id) # type: ignore[arg-type] self.login_as(user=user) response = self.do_request( "put", f"{self.url(self.dashboard.id)}?environment=mock_env", data=data @@ -2386,7 +2386,7 @@ def test_update_dashboard_permissions_when_already_created(self) -> None: } assert permission.is_editable_by_everyone is True - user = User(id=self.dashboard.created_by_id) + user = User(id=self.dashboard.created_by_id) # type: ignore[arg-type] # type: ignore[arg-type] self.login_as(user=user) response = self.do_request( "put", f"{self.url(self.dashboard.id)}?environment=mock_env", data=data @@ -2566,7 +2566,7 @@ def test_update_dashboard_permissions_with_new_teams(self) -> None: }, } - user = User(id=self.dashboard.created_by_id) + user = User(id=self.dashboard.created_by_id) # type: ignore[arg-type] self.login_as(user=user) response = self.do_request( "put", f"{self.url(self.dashboard.id)}?environment=mock_env", data=data @@ -2602,7 +2602,7 @@ def test_update_teams_in_dashboard_permissions(self) -> None: }, } - user = User(id=self.dashboard.created_by_id) + user = User(id=self.dashboard.created_by_id) # type: ignore[arg-type] self.login_as(user=user) response = self.do_request( "put", f"{self.url(self.dashboard.id)}?environment=mock_env", data=data @@ -2633,7 +2633,7 @@ def test_update_dashboard_permissions_with_invalid_teams(self) -> None: }, } - user = User(id=self.dashboard.created_by_id) + user = User(id=self.dashboard.created_by_id) # type: ignore[arg-type] self.login_as(user=user) response = self.do_request( "put", f"{self.url(self.dashboard.id)}?environment=mock_env", data=data @@ -2660,7 +2660,7 @@ def test_update_dashboard_permissions_with_teams_from_different_org(self) -> Non self.create_environment(project=mock_project, name="mock_env") - user = User(id=self.dashboard.created_by_id) + user = User(id=self.dashboard.created_by_id) # type: ignore[arg-type] self.login_as(user=user) response = self.do_request( "put", f"{self.url(self.dashboard.id)}?environment=mock_env", data=data @@ -2706,7 +2706,7 @@ def test_select_everyone_in_dashboard_permissions_clears_all_teams(self) -> None }, } - user = User(id=self.dashboard.created_by_id) + user = User(id=self.dashboard.created_by_id) # type: ignore[arg-type] self.login_as(user=user) response = self.do_request( "put", f"{self.url(self.dashboard.id)}?environment=mock_env", data=data From d86d2482d98b00641cdc2887ca0778ab20a60553 Mon Sep 17 00:00:00 2001 From: Edward Gou Date: Wed, 22 Oct 2025 17:43:31 -0400 Subject: [PATCH 3/6] fix --- .../test_organization_dashboard_details.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/sentry/dashboards/endpoints/test_organization_dashboard_details.py b/tests/sentry/dashboards/endpoints/test_organization_dashboard_details.py index f8d6393efa56ee..5b29966aebbd94 100644 --- a/tests/sentry/dashboards/endpoints/test_organization_dashboard_details.py +++ b/tests/sentry/dashboards/endpoints/test_organization_dashboard_details.py @@ -2349,7 +2349,7 @@ def test_update_dashboard_permissions_with_put(self) -> None: "permissions": {"isEditableByEveryone": "False"}, } - user = User(id=self.dashboard.created_by_id) # type: ignore[arg-type] + user = User(id=self.dashboard.created_by_id) # type: ignore[misc] self.login_as(user=user) response = self.do_request( "put", f"{self.url(self.dashboard.id)}?environment=mock_env", data=data @@ -2366,7 +2366,7 @@ def test_update_dashboard_permissions_to_false(self) -> None: "permissions": {"isEditableByEveryone": "false"}, } - user = User(id=self.dashboard.created_by_id) # type: ignore[arg-type] + user = User(id=self.dashboard.created_by_id) # type: ignore[misc] self.login_as(user=user) response = self.do_request( "put", f"{self.url(self.dashboard.id)}?environment=mock_env", data=data @@ -2386,7 +2386,7 @@ def test_update_dashboard_permissions_when_already_created(self) -> None: } assert permission.is_editable_by_everyone is True - user = User(id=self.dashboard.created_by_id) # type: ignore[arg-type] # type: ignore[arg-type] + user = User(id=self.dashboard.created_by_id) # type: ignore[misc] self.login_as(user=user) response = self.do_request( "put", f"{self.url(self.dashboard.id)}?environment=mock_env", data=data @@ -2566,7 +2566,7 @@ def test_update_dashboard_permissions_with_new_teams(self) -> None: }, } - user = User(id=self.dashboard.created_by_id) # type: ignore[arg-type] + user = User(id=self.dashboard.created_by_id) # type: ignore[misc] self.login_as(user=user) response = self.do_request( "put", f"{self.url(self.dashboard.id)}?environment=mock_env", data=data @@ -2602,7 +2602,7 @@ def test_update_teams_in_dashboard_permissions(self) -> None: }, } - user = User(id=self.dashboard.created_by_id) # type: ignore[arg-type] + user = User(id=self.dashboard.created_by_id) # type: ignore[misc] self.login_as(user=user) response = self.do_request( "put", f"{self.url(self.dashboard.id)}?environment=mock_env", data=data @@ -2633,7 +2633,7 @@ def test_update_dashboard_permissions_with_invalid_teams(self) -> None: }, } - user = User(id=self.dashboard.created_by_id) # type: ignore[arg-type] + user = User(id=self.dashboard.created_by_id) # type: ignore[misc] self.login_as(user=user) response = self.do_request( "put", f"{self.url(self.dashboard.id)}?environment=mock_env", data=data @@ -2660,7 +2660,7 @@ def test_update_dashboard_permissions_with_teams_from_different_org(self) -> Non self.create_environment(project=mock_project, name="mock_env") - user = User(id=self.dashboard.created_by_id) # type: ignore[arg-type] + user = User(id=self.dashboard.created_by_id) # type: ignore[misc] self.login_as(user=user) response = self.do_request( "put", f"{self.url(self.dashboard.id)}?environment=mock_env", data=data @@ -2706,7 +2706,7 @@ def test_select_everyone_in_dashboard_permissions_clears_all_teams(self) -> None }, } - user = User(id=self.dashboard.created_by_id) # type: ignore[arg-type] + user = User(id=self.dashboard.created_by_id) # type: ignore[misc] self.login_as(user=user) response = self.do_request( "put", f"{self.url(self.dashboard.id)}?environment=mock_env", data=data From a690dd9cd114ade076b020ce2067c601a9284cee Mon Sep 17 00:00:00 2001 From: Edward Gou Date: Wed, 22 Oct 2025 17:50:18 -0400 Subject: [PATCH 4/6] fix openapi --- src/sentry/apidocs/examples/dashboard_examples.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/sentry/apidocs/examples/dashboard_examples.py b/src/sentry/apidocs/examples/dashboard_examples.py index 7c038972315dc9..29291aac345fbd 100644 --- a/src/sentry/apidocs/examples/dashboard_examples.py +++ b/src/sentry/apidocs/examples/dashboard_examples.py @@ -77,6 +77,7 @@ "teamsWithEditAccess": [], }, "isFavorited": False, + "prebuiltId": None, } DASHBOARDS_OBJECT = [ @@ -120,6 +121,7 @@ "widgetPreview": [], "permissions": {"isEditableByEveryone": True, "teamsWithEditAccess": []}, "isFavorited": False, + "prebuiltId": None, }, { "id": "2", @@ -161,6 +163,7 @@ "widgetPreview": [], "permissions": None, "isFavorited": False, + "prebuiltId": None, }, ] From 3ee86e0bd65949848e3233113b524772b5750790 Mon Sep 17 00:00:00 2001 From: Edward Gou Date: Thu, 23 Oct 2025 11:08:52 -0400 Subject: [PATCH 5/6] constraint on prebuilt_id and created_by_id and also fix lock key --- .../dashboards/endpoints/organization_dashboards.py | 2 +- src/sentry/models/dashboard.py | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/sentry/dashboards/endpoints/organization_dashboards.py b/src/sentry/dashboards/endpoints/organization_dashboards.py index 3752ad1754f2e2..f6c9ccbd71319f 100644 --- a/src/sentry/dashboards/endpoints/organization_dashboards.py +++ b/src/sentry/dashboards/endpoints/organization_dashboards.py @@ -201,7 +201,7 @@ def get(self, request: Request, organization: Organization) -> Response: # Sync prebuilt dashboards to the database try: lock = locks.get( - f"dashboards:sync_prebuilt_dashboards:{organization.id}:{request.user.id}", + f"dashboards:sync_prebuilt_dashboards:{organization.id}", duration=10, name="sync_prebuilt_dashboards", ) diff --git a/src/sentry/models/dashboard.py b/src/sentry/models/dashboard.py index 1adb3156a2178c..30536cc7558cdb 100644 --- a/src/sentry/models/dashboard.py +++ b/src/sentry/models/dashboard.py @@ -5,7 +5,7 @@ import sentry_sdk from django.db import models, router, transaction -from django.db.models import Q, UniqueConstraint +from django.db.models import CheckConstraint, Q, UniqueConstraint from django.db.models.query import QuerySet from django.utils import timezone @@ -243,7 +243,12 @@ class Meta: fields=["organization", "prebuilt_id"], condition=Q(prebuilt_id__isnull=False), name="sentry_dashboard_organization_prebuilt_id_uniq", - ) + ), + # prebuilt dashboards cannot have a created_by_id + CheckConstraint( + condition=Q(prebuilt_id__isnull=True) | Q(created_by_id__isnull=True), + name="sentry_dashboard_prebuilt_null_created_by", + ), ] __repr__ = sane_repr("organization", "title") From b10680a455073a9349e3c4a6d75d72518cc3d812 Mon Sep 17 00:00:00 2001 From: Edward Gou Date: Thu, 23 Oct 2025 11:15:13 -0400 Subject: [PATCH 6/6] fix migration file --- .../migrations/0998_add_prebuilt_id_to_dashboards.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/sentry/migrations/0998_add_prebuilt_id_to_dashboards.py b/src/sentry/migrations/0998_add_prebuilt_id_to_dashboards.py index e222ce09082198..ff442f544b7fe8 100644 --- a/src/sentry/migrations/0998_add_prebuilt_id_to_dashboards.py +++ b/src/sentry/migrations/0998_add_prebuilt_id_to_dashboards.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.1 on 2025-10-22 17:21 +# Generated by Django 5.2.1 on 2025-10-23 15:14 from django.db import migrations, models @@ -49,4 +49,13 @@ class Migration(CheckedMigration): name="sentry_dashboard_organization_prebuilt_id_uniq", ), ), + migrations.AddConstraint( + model_name="dashboard", + constraint=models.CheckConstraint( + condition=models.Q( + ("prebuilt_id__isnull", True), ("created_by_id__isnull", True), _connector="OR" + ), + name="sentry_dashboard_prebuilt_null_created_by", + ), + ), ]