Skip to content

Commit 648187c

Browse files
skaastenclaude
andcommitted
feat(dashboards): Store dashboard snapshot on PUT when revisions flag is enabled
When the organizations:dashboards-revisions feature flag is enabled, the dashboard PUT endpoint now serializes the current dashboard state (using the existing DashboardDetailsModelSerializer) into a DashboardRevision before writing the new version. The snapshot and the save are wrapped in the same transaction so a failed update rolls back the revision too. Serialization is intentionally done outside the transaction to avoid making hybrid-cloud RPC calls (user_service.serialize_many) inside an atomic block. Revisions beyond the 10 most recent are deleted after each save to bound storage growth. Refs DAIN-1516 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a16fcfa commit 648187c

File tree

2 files changed

+141
-0
lines changed

2 files changed

+141
-0
lines changed

src/sentry/dashboards/endpoints/organization_dashboard_details.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,17 @@
3030
Dashboard,
3131
DashboardFavoriteUser,
3232
DashboardLastVisited,
33+
DashboardRevision,
3334
DashboardTombstone,
3435
)
3536
from sentry.models.organization import Organization
3637
from sentry.models.organizationmember import OrganizationMember
3738

3839
EDIT_FEATURE = "organizations:dashboards-edit"
3940
READ_FEATURE = "organizations:dashboards-basic"
41+
REVISIONS_FEATURE = "organizations:dashboards-revisions"
42+
DASHBOARD_REVISION_SNAPSHOT_SCHEMA_VERSION = 1
43+
DASHBOARD_REVISION_RETENTION_LIMIT = 10
4044

4145

4246
class OrganizationDashboardBase(OrganizationEndpoint):
@@ -208,8 +212,32 @@ def put(
208212
{"detail": "Cannot change the title of prebuilt Dashboards."}, status=409
209213
)
210214

215+
# Serialize outside the transaction to avoid making RPC calls inside a transaction.
216+
# The snapshot captures the dashboard state before the update is applied.
217+
create_revision = features.has(
218+
REVISIONS_FEATURE, organization, actor=request.user
219+
) and isinstance(dashboard, Dashboard)
220+
snapshot = serialize(dashboard, request.user) if create_revision else None
221+
211222
try:
212223
with transaction.atomic(router.db_for_write(DashboardTombstone)):
224+
if create_revision and snapshot is not None:
225+
revision = DashboardRevision.objects.create(
226+
dashboard=dashboard,
227+
created_by_id=(request.user.id if request.user.is_authenticated else None),
228+
title=dashboard.title,
229+
snapshot=snapshot,
230+
snapshot_schema_version=DASHBOARD_REVISION_SNAPSHOT_SCHEMA_VERSION,
231+
)
232+
# Keep only the most recent revisions; delete the rest
233+
old_revision_ids = list(
234+
DashboardRevision.objects.filter(dashboard=dashboard)
235+
.exclude(id=revision.id)
236+
.order_by("-date_added")
237+
.values_list("id", flat=True)[DASHBOARD_REVISION_RETENTION_LIMIT - 1 :]
238+
)
239+
if old_revision_ids:
240+
DashboardRevision.objects.filter(id__in=old_revision_ids).delete()
213241
serializer.save()
214242
if tombstone:
215243
DashboardTombstone.objects.get_or_create(

tests/sentry/dashboards/endpoints/test_organization_dashboard_details.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
Dashboard,
1717
DashboardFavoriteUser,
1818
DashboardLastVisited,
19+
DashboardRevision,
1920
DashboardTombstone,
2021
)
2122
from sentry.models.dashboard_permissions import DashboardPermissions
@@ -4012,6 +4013,118 @@ def test_text_widget_errors_provided_queries(self) -> None:
40124013
assert "queries" in response.data["widgets"][1], response.data
40134014
assert response.data["widgets"][1]["queries"][0] == "Text widgets don't have queries"
40144015

4016+
def test_put_creates_dashboard_revision_when_feature_enabled(self) -> None:
4017+
original_title = self.dashboard.title
4018+
with self.feature("organizations:dashboards-revisions"):
4019+
response = self.do_request(
4020+
"put", self.url(self.dashboard.id), data={"title": "Updated Title"}
4021+
)
4022+
assert response.status_code == 200, response.data
4023+
4024+
revisions = DashboardRevision.objects.filter(dashboard=self.dashboard)
4025+
assert revisions.count() == 1
4026+
revision = revisions.first()
4027+
assert revision is not None
4028+
assert revision.title == original_title
4029+
assert revision.snapshot["title"] == original_title
4030+
assert revision.snapshot_schema_version == 1
4031+
assert revision.created_by_id == self.user.id
4032+
4033+
def test_put_does_not_create_revision_when_feature_disabled(self) -> None:
4034+
response = self.do_request(
4035+
"put", self.url(self.dashboard.id), data={"title": "Updated Title"}
4036+
)
4037+
assert response.status_code == 200, response.data
4038+
assert DashboardRevision.objects.filter(dashboard=self.dashboard).count() == 0
4039+
4040+
def test_put_snapshot_contains_pre_save_state(self) -> None:
4041+
with self.feature("organizations:dashboards-revisions"):
4042+
self.do_request("put", self.url(self.dashboard.id), data={"title": "New Title"})
4043+
4044+
revision = DashboardRevision.objects.get(dashboard=self.dashboard)
4045+
# Snapshot reflects the state before the update
4046+
assert revision.snapshot["title"] == self.dashboard.title
4047+
assert revision.title == self.dashboard.title
4048+
# The dashboard itself was updated
4049+
self.dashboard.refresh_from_db()
4050+
assert self.dashboard.title == "New Title"
4051+
4052+
def test_put_deletes_revisions_beyond_retention_limit(self) -> None:
4053+
# Create 10 existing revisions
4054+
for i in range(10):
4055+
DashboardRevision.objects.create(
4056+
dashboard=self.dashboard,
4057+
created_by_id=self.user.id,
4058+
title=f"Revision {i}",
4059+
snapshot={},
4060+
snapshot_schema_version=1,
4061+
)
4062+
4063+
assert DashboardRevision.objects.filter(dashboard=self.dashboard).count() == 10
4064+
4065+
with self.feature("organizations:dashboards-revisions"):
4066+
response = self.do_request(
4067+
"put", self.url(self.dashboard.id), data={"title": "Updated Title"}
4068+
)
4069+
assert response.status_code == 200, response.data
4070+
4071+
# After creating the 11th revision, the oldest should be deleted to maintain 10
4072+
assert DashboardRevision.objects.filter(dashboard=self.dashboard).count() == 10
4073+
4074+
def test_put_snapshot_includes_widgets_and_queries(self) -> None:
4075+
with self.feature("organizations:dashboards-revisions"):
4076+
self.do_request("put", self.url(self.dashboard.id), data={"title": "New Title"})
4077+
4078+
revision = DashboardRevision.objects.get(dashboard=self.dashboard)
4079+
snapshot_widgets = revision.snapshot["widgets"]
4080+
4081+
assert len(snapshot_widgets) == 4
4082+
assert snapshot_widgets[0]["id"] == str(self.widget_1.id)
4083+
assert snapshot_widgets[0]["title"] == self.widget_1.title
4084+
assert len(snapshot_widgets[0]["queries"]) == 2
4085+
assert snapshot_widgets[1]["id"] == str(self.widget_2.id)
4086+
assert snapshot_widgets[1]["title"] == self.widget_2.title
4087+
assert len(snapshot_widgets[1]["queries"]) == 1
4088+
assert snapshot_widgets[2]["id"] == str(self.widget_3.id)
4089+
assert snapshot_widgets[3]["id"] == str(self.widget_4.id)
4090+
4091+
def test_put_snapshot_captures_widget_state_before_widget_edit(self) -> None:
4092+
original_widget_title = self.widget_1.title
4093+
with self.feature("organizations:dashboards-revisions"):
4094+
self.do_request(
4095+
"put",
4096+
self.url(self.dashboard.id),
4097+
data={
4098+
"title": self.dashboard.title,
4099+
"widgets": [
4100+
{"id": str(self.widget_1.id), "title": "Updated Widget Title"},
4101+
{"id": str(self.widget_2.id)},
4102+
{"id": str(self.widget_3.id)},
4103+
{"id": str(self.widget_4.id)},
4104+
],
4105+
},
4106+
)
4107+
4108+
revision = DashboardRevision.objects.get(dashboard=self.dashboard)
4109+
snapshot_widget = revision.snapshot["widgets"][0]
4110+
# Snapshot reflects the widget state before the edit
4111+
assert snapshot_widget["id"] == str(self.widget_1.id)
4112+
assert snapshot_widget["title"] == original_widget_title
4113+
# The widget itself was updated
4114+
self.widget_1.refresh_from_db()
4115+
assert self.widget_1.title == "Updated Widget Title"
4116+
4117+
def test_put_does_not_create_revision_for_prebuilt_tombstone(self) -> None:
4118+
with self.feature("organizations:dashboards-revisions"):
4119+
response = self.do_request(
4120+
"put",
4121+
self.url("default-overview"),
4122+
data={"title": "default-overview"},
4123+
)
4124+
assert response.status_code == 200, response.data
4125+
# No revision should be created for a pre-built dashboard that hasn't been saved yet
4126+
assert DashboardRevision.objects.count() == 0
4127+
40154128

40164129
class OrganizationDashboardDetailsOnDemandTest(OrganizationDashboardDetailsTestCase):
40174130
widget_type = DashboardWidgetTypes.TRANSACTION_LIKE

0 commit comments

Comments
 (0)