diff --git a/src/sentry/dashboards/endpoints/organization_dashboards.py b/src/sentry/dashboards/endpoints/organization_dashboards.py index 677a6143fe06a1..3190dd94dfa923 100644 --- a/src/sentry/dashboards/endpoints/organization_dashboards.py +++ b/src/sentry/dashboards/endpoints/organization_dashboards.py @@ -1,7 +1,7 @@ from __future__ import annotations from enum import IntEnum -from typing import Any, TypedDict +from typing import Any, Required, TypedDict import sentry_sdk from django.db import IntegrityError, router, transaction @@ -92,9 +92,10 @@ class PrebuiltDashboardId(IntEnum): BACKEND_CACHES = 28 -class PrebuiltDashboard(TypedDict): - prebuilt_id: PrebuiltDashboardId - title: str +class PrebuiltDashboard(TypedDict, total=False): + prebuilt_id: Required[PrebuiltDashboardId] + title: Required[str] + pre_favorited: bool # Prebuilt dashboards store minimal fields in the database. The actual dashboard and widget settings are @@ -130,6 +131,7 @@ class PrebuiltDashboard(TypedDict): { "prebuilt_id": PrebuiltDashboardId.WEB_VITALS, "title": "Web Vitals", + "pre_favorited": True, }, { "prebuilt_id": PrebuiltDashboardId.WEB_VITALS_SUMMARY, @@ -138,6 +140,7 @@ class PrebuiltDashboard(TypedDict): { "prebuilt_id": PrebuiltDashboardId.MOBILE_VITALS, "title": "Mobile Vitals", + "pre_favorited": True, }, { "prebuilt_id": PrebuiltDashboardId.MOBILE_VITALS_APP_STARTS, @@ -154,6 +157,7 @@ class PrebuiltDashboard(TypedDict): { "prebuilt_id": PrebuiltDashboardId.BACKEND_OVERVIEW, "title": "Backend Overview", + "pre_favorited": True, }, { "prebuilt_id": PrebuiltDashboardId.MOBILE_SESSION_HEALTH, @@ -190,10 +194,12 @@ class PrebuiltDashboard(TypedDict): { "prebuilt_id": PrebuiltDashboardId.AI_AGENTS_OVERVIEW, "title": "AI Agents Overview", + "pre_favorited": True, }, { "prebuilt_id": PrebuiltDashboardId.MCP_OVERVIEW, "title": "MCP Overview", + "pre_favorited": True, }, { "prebuilt_id": PrebuiltDashboardId.LARAVEL_OVERVIEW, @@ -222,6 +228,28 @@ class PrebuiltDashboard(TypedDict): ] +def get_enabled_prebuilt_dashboards( + organization: Organization, +) -> list[PrebuiltDashboard]: + """ + Returns the list of prebuilt dashboards that are enabled for the given organization, + based on the prebuilt-dashboard-ids option and the sync-all feature flag. + """ + enabled_prebuilt_dashboard_ids = options.get("dashboards.prebuilt-dashboard-ids") + should_sync_all_registered_prebuilt_dashboards = features.has( + "organizations:dashboards-sync-all-registered-prebuilt-dashboards", + organization, + ) + all_prebuilt_dashboards = [dashboard for dashboard in PREBUILT_DASHBOARDS] + if should_sync_all_registered_prebuilt_dashboards: + return all_prebuilt_dashboards + return [ + dashboard + for dashboard in all_prebuilt_dashboards + if dashboard["prebuilt_id"] in enabled_prebuilt_dashboard_ids + ] + + def sync_prebuilt_dashboards(organization: Organization) -> None: """ Queries the database to check if prebuilt dashboards have a Dashboard record and @@ -230,16 +258,7 @@ def sync_prebuilt_dashboards(organization: Organization) -> None: """ with transaction.atomic(router.db_for_write(Dashboard)): - enabled_prebuilt_dashboard_ids = options.get("dashboards.prebuilt-dashboard-ids") - enabled_prebuilt_dashboards = [ - dashboard - for dashboard in PREBUILT_DASHBOARDS - if dashboard["prebuilt_id"] in enabled_prebuilt_dashboard_ids - or features.has( - "organizations:dashboards-sync-all-registered-prebuilt-dashboards", - organization, - ) - ] + enabled_prebuilt_dashboards = get_enabled_prebuilt_dashboards(organization) saved_prebuilt_dashboards = Dashboard.objects.filter( organization=organization, @@ -277,6 +296,41 @@ def sync_prebuilt_dashboards(organization: Organization) -> None: ).exclude(prebuilt_id__in=prebuilt_ids).delete() +def sync_prebuilt_dashboards_favorited(organization: Organization, user_id: int) -> None: + """ + Checks if pre-favorited prebuilt dashboards have a DashboardFavoriteUser record for the + user, and creates them if they don't. This ensures that certain prebuilt dashboards are + favorited by default for all users. + """ + enabled_prebuilt_dashboards = get_enabled_prebuilt_dashboards(organization) + pre_favorited_ids = [ + d["prebuilt_id"] for d in enabled_prebuilt_dashboards if d.get("pre_favorited") + ] + if not pre_favorited_ids: + return + + with transaction.atomic(router.db_for_write(DashboardFavoriteUser)): + prebuilt_dashboards_without_favorite = ( + Dashboard.objects.filter( + organization=organization, + prebuilt_id__in=pre_favorited_ids, + ) + .exclude( + id__in=DashboardFavoriteUser.objects.filter( + organization=organization, + user_id=user_id, + ).values_list("dashboard_id", flat=True) + ) + .order_by("prebuilt_id") + ) + for dashboard in prebuilt_dashboards_without_favorite: + DashboardFavoriteUser.objects.insert_favorite_dashboard( + organization=organization, + user_id=user_id, + dashboard=dashboard, + ) + + class OrganizationDashboardsPermission(OrganizationPermission): scope_map = { "GET": ["org:read", "org:write", "org:admin"], @@ -360,15 +414,31 @@ def get(self, request: Request, organization: Organization) -> Response: 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) + # Favorite pre-favorited prebuilt dashboards for the user + # TODO - remove this flag check once we confirm the sync is proper, this should be done for all users + if features.has( + "organizations:dashboards-sync-all-registered-prebuilt-dashboards", + organization, + ): + try: + favorite_lock = locks.get( + f"dashboards:sync_prebuilt_dashboards_favorited:{organization.id}:{request.user.id}", + duration=10, + name="sync_prebuilt_dashboards_favorited", + ) + with favorite_lock.acquire(): + sync_prebuilt_dashboards_favorited(organization, request.user.id) + except UnableToAcquireLock: + pass + except Exception as err: + sentry_sdk.capture_exception(err) + filters = request.query_params.getlist("filter") dashboards = Dashboard.objects.filter(organization_id=organization.id) diff --git a/tests/sentry/dashboards/endpoints/test_organization_dashboards.py b/tests/sentry/dashboards/endpoints/test_organization_dashboards.py index 644220367890c1..810b7f05a0131f 100644 --- a/tests/sentry/dashboards/endpoints/test_organization_dashboards.py +++ b/tests/sentry/dashboards/endpoints/test_organization_dashboards.py @@ -2269,6 +2269,37 @@ def test_get_with_exclude_prebuilt(self) -> None: assert response.status_code == 200 assert len(response.data) == total_count - prebuilt_dashboards_count + def test_endpoint_creates_favorites_for_pre_favorited_prebuilt_dashboards(self) -> None: + assert ( + DashboardFavoriteUser.objects.filter( + organization=self.organization, user_id=self.user.id + ).count() + == 0 + ) + + pre_favorited_prebuilt_ids = { + d["prebuilt_id"] for d in PREBUILT_DASHBOARDS if d.get("pre_favorited") + } + + with self.feature( + [ + "organizations:dashboards-prebuilt-insights-dashboards", + "organizations:dashboards-sync-all-registered-prebuilt-dashboards", + ] + ): + response = self.do_request("get", self.url) + assert response.status_code == 200 + + favorites = DashboardFavoriteUser.objects.filter( + organization=self.organization, user_id=self.user.id + ) + favorited_prebuilt_ids = set( + Dashboard.objects.filter( + id__in=favorites.values_list("dashboard_id", flat=True) + ).values_list("prebuilt_id", flat=True) + ) + assert favorited_prebuilt_ids == pre_favorited_prebuilt_ids + def test_post_with_text_widget(self) -> None: with self.feature("organizations:dashboards-text-widgets"): data = {