Skip to content

Commit fb0610f

Browse files
authored
feat(dashboards): Add endpoint for reordering dashboard positions (#93738)
Adds functionality to support reordering dashboard positions. Expose the functionality through a new endpoint at `/dashboards/starred/order/` to mimic issue views and explore queries. Adds tests to cover cases such as basic reordering, error validation on duplicates or missing IDs, as well as allowing reordering even if positions aren't already set.
1 parent 3a3d499 commit fb0610f

File tree

4 files changed

+200
-0
lines changed

4 files changed

+200
-0
lines changed
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
from django.db import IntegrityError, router, transaction
2+
from rest_framework import status
3+
from rest_framework.exceptions import ParseError
4+
from rest_framework.request import Request
5+
from rest_framework.response import Response
6+
7+
from sentry import features
8+
from sentry.api.api_owners import ApiOwner
9+
from sentry.api.api_publish_status import ApiPublishStatus
10+
from sentry.api.base import region_silo_endpoint
11+
from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission
12+
from sentry.api.serializers.rest_framework.dashboard import DashboardStarredOrderSerializer
13+
from sentry.models.dashboard import DashboardFavoriteUser
14+
from sentry.models.organization import Organization
15+
16+
17+
class MemberPermission(OrganizationPermission):
18+
scope_map = {
19+
"PUT": ["member:read", "member:write"],
20+
}
21+
22+
23+
@region_silo_endpoint
24+
class OrganizationDashboardsStarredOrderEndpoint(OrganizationEndpoint):
25+
publish_status = {"PUT": ApiPublishStatus.PRIVATE}
26+
owner = ApiOwner.PERFORMANCE
27+
permission_classes = (MemberPermission,)
28+
29+
def has_feature(self, organization, request):
30+
return features.has(
31+
"organizations:dashboards-starred-reordering", organization, actor=request.user
32+
)
33+
34+
def put(self, request: Request, organization: Organization) -> Response:
35+
if not request.user.is_authenticated:
36+
return Response(status=status.HTTP_400_BAD_REQUEST)
37+
38+
if not self.has_feature(organization, request):
39+
return self.respond(status=status.HTTP_404_NOT_FOUND)
40+
41+
serializer = DashboardStarredOrderSerializer(
42+
data=request.data, context={"organization": organization, "user": request.user}
43+
)
44+
45+
if not serializer.is_valid():
46+
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
47+
48+
dashboard_ids = serializer.validated_data["dashboard_ids"]
49+
50+
try:
51+
with transaction.atomic(using=router.db_for_write(DashboardFavoriteUser)):
52+
DashboardFavoriteUser.objects.reorder_favorite_dashboards(
53+
organization=organization,
54+
user_id=request.user.id,
55+
new_dashboard_positions=dashboard_ids,
56+
)
57+
except (IntegrityError, ValueError):
58+
raise ParseError("Mismatch between existing and provided starred dashboards.")
59+
60+
return Response(status=status.HTTP_204_NO_CONTENT)

src/sentry/api/serializers/rest_framework/dashboard.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -983,6 +983,15 @@ class DashboardSerializer(DashboardDetailsSerializer):
983983
)
984984

985985

986+
class DashboardStarredOrderSerializer(serializers.Serializer):
987+
dashboard_ids = serializers.ListField(child=serializers.IntegerField(), required=True)
988+
989+
def validate_dashboard_ids(self, dashboard_ids):
990+
if len(dashboard_ids) != len(set(dashboard_ids)):
991+
raise serializers.ValidationError("Single dashboard cannot take up multiple positions")
992+
return dashboard_ids
993+
994+
986995
def schedule_update_project_configs(dashboard: Dashboard):
987996
"""
988997
Schedule a task to update project configs for all projects of an organization when a dashboard is updated.

src/sentry/api/urls.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
OrganizationAuthTokenDetailsEndpoint,
1212
)
1313
from sentry.api.endpoints.organization_auth_tokens import OrganizationAuthTokensEndpoint
14+
from sentry.api.endpoints.organization_dashboards_starred import (
15+
OrganizationDashboardsStarredOrderEndpoint,
16+
)
1417
from sentry.api.endpoints.organization_events_anomalies import OrganizationEventsAnomaliesEndpoint
1518
from sentry.api.endpoints.organization_events_root_cause_analysis import (
1619
OrganizationEventsRootCauseAnalysisEndpoint,
@@ -1396,6 +1399,11 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]:
13961399
OrganizationDashboardVisitEndpoint.as_view(),
13971400
name="sentry-api-0-organization-dashboard-visit",
13981401
),
1402+
re_path(
1403+
r"^(?P<organization_id_or_slug>[^\/]+)/dashboards/starred/order/$",
1404+
OrganizationDashboardsStarredOrderEndpoint.as_view(),
1405+
name="sentry-api-0-organization-dashboard-starred-order",
1406+
),
13991407
re_path(
14001408
r"^(?P<organization_id_or_slug>[^\/]+)/dashboards/(?P<dashboard_id>[^\/]+)/favorite/$",
14011409
OrganizationDashboardFavoriteEndpoint.as_view(),
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
from django.urls import reverse
2+
from rest_framework.exceptions import ErrorDetail
3+
4+
from sentry.models.dashboard import DashboardFavoriteUser
5+
from sentry.testutils.cases import OrganizationDashboardWidgetTestCase
6+
7+
8+
class OrganizationDashboardsStarredOrderTest(OrganizationDashboardWidgetTestCase):
9+
def setUp(self):
10+
super().setUp()
11+
self.login_as(self.user)
12+
self.url = reverse(
13+
"sentry-api-0-organization-dashboard-starred-order",
14+
kwargs={"organization_id_or_slug": self.organization.slug},
15+
)
16+
self.dashboard_1 = self.create_dashboard(title="Dashboard 1")
17+
self.dashboard_2 = self.create_dashboard(title="Dashboard 2")
18+
self.dashboard_3 = self.create_dashboard(title="Dashboard 3")
19+
20+
def create_dashboard_favorite(self, dashboard, user, organization, position):
21+
DashboardFavoriteUser.objects.create(
22+
dashboard=dashboard, user_id=user.id, organization=organization, position=position
23+
)
24+
25+
def test_reorder_dashboards(self):
26+
self.create_dashboard_favorite(self.dashboard_1, self.user, self.organization, 0)
27+
self.create_dashboard_favorite(self.dashboard_2, self.user, self.organization, 1)
28+
self.create_dashboard_favorite(self.dashboard_3, self.user, self.organization, 2)
29+
30+
assert list(
31+
DashboardFavoriteUser.objects.filter(
32+
organization=self.organization, user_id=self.user.id
33+
)
34+
.order_by("position")
35+
.values_list("dashboard_id", flat=True)
36+
) == [
37+
self.dashboard_1.id,
38+
self.dashboard_2.id,
39+
self.dashboard_3.id,
40+
]
41+
42+
# Reorder the favorited dashboards
43+
with self.feature("organizations:dashboards-starred-reordering"):
44+
response = self.do_request(
45+
"put",
46+
self.url,
47+
data={
48+
"dashboard_ids": [self.dashboard_3.id, self.dashboard_1.id, self.dashboard_2.id]
49+
},
50+
)
51+
assert response.status_code == 204
52+
53+
assert list(
54+
DashboardFavoriteUser.objects.filter(
55+
organization=self.organization, user_id=self.user.id
56+
)
57+
.order_by("position")
58+
.values_list("dashboard_id", flat=True)
59+
) == [
60+
self.dashboard_3.id,
61+
self.dashboard_1.id,
62+
self.dashboard_2.id,
63+
]
64+
65+
def test_throws_an_error_if_dashboard_ids_are_not_unique(self):
66+
self.create_dashboard_favorite(self.dashboard_1, self.user, self.organization, 0)
67+
self.create_dashboard_favorite(self.dashboard_2, self.user, self.organization, 1)
68+
self.create_dashboard_favorite(self.dashboard_3, self.user, self.organization, 2)
69+
70+
with self.feature("organizations:dashboards-starred-reordering"):
71+
response = self.do_request(
72+
"put",
73+
self.url,
74+
data={
75+
"dashboard_ids": [self.dashboard_1.id, self.dashboard_1.id, self.dashboard_2.id]
76+
},
77+
)
78+
assert response.status_code == 400
79+
assert response.data == {
80+
"dashboard_ids": ["Single dashboard cannot take up multiple positions"]
81+
}
82+
83+
def test_throws_an_error_if_reordered_dashboard_ids_are_not_complete(self):
84+
self.create_dashboard_favorite(self.dashboard_1, self.user, self.organization, 0)
85+
self.create_dashboard_favorite(self.dashboard_2, self.user, self.organization, 1)
86+
self.create_dashboard_favorite(self.dashboard_3, self.user, self.organization, 2)
87+
88+
with self.feature("organizations:dashboards-starred-reordering"):
89+
response = self.do_request(
90+
"put",
91+
self.url,
92+
data={"dashboard_ids": [self.dashboard_1.id, self.dashboard_2.id]},
93+
)
94+
assert response.status_code == 400
95+
assert response.data == {
96+
"detail": ErrorDetail(
97+
string="Mismatch between existing and provided starred dashboards.",
98+
code="parse_error",
99+
)
100+
}
101+
102+
def test_allows_reordering_even_if_no_initial_positions(self):
103+
self.create_dashboard_favorite(self.dashboard_1, self.user, self.organization, 0)
104+
self.create_dashboard_favorite(self.dashboard_2, self.user, self.organization, 1)
105+
self.create_dashboard_favorite(self.dashboard_3, self.user, self.organization, 2)
106+
107+
with self.feature("organizations:dashboards-starred-reordering"):
108+
response = self.do_request(
109+
"put",
110+
self.url,
111+
data={
112+
"dashboard_ids": [self.dashboard_3.id, self.dashboard_1.id, self.dashboard_2.id]
113+
},
114+
)
115+
assert response.status_code == 204
116+
117+
assert list(
118+
DashboardFavoriteUser.objects.filter(
119+
organization=self.organization, user_id=self.user.id
120+
)
121+
.order_by("position")
122+
.values_list("dashboard_id", flat=True)
123+
) == [self.dashboard_3.id, self.dashboard_1.id, self.dashboard_2.id]

0 commit comments

Comments
 (0)