From ea7c305d111d0f9f05f3df1d13c4301f195b0b59 Mon Sep 17 00:00:00 2001 From: Shaun Kaasten <900809+skaasten@users.noreply.github.com> Date: Tue, 14 Apr 2026 11:48:21 -0400 Subject: [PATCH 1/6] feat(dashboards): Add DashboardRevision model and migration Add DashboardRevision model to store snapshots of dashboard state at the time of each edit. Each revision records the dashboard title, source action, a JSON snapshot of the full dashboard state, schema version, and the user who made the change. Includes a composite index on (dashboard_id, date_added DESC) to support efficient per-dashboard history lookups. Refs DAIN-1515 Co-Authored-By: Claude Sonnet 4.6 --- migrations_lockfile.txt | 2 +- .../1067_add_dashboard_revision_model.py | 76 +++++++++++++++++++ src/sentry/models/dashboard.py | 26 +++++++ 3 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 src/sentry/migrations/1067_add_dashboard_revision_model.py diff --git a/migrations_lockfile.txt b/migrations_lockfile.txt index 1da7cc95e74f79..90847f0558c67b 100644 --- a/migrations_lockfile.txt +++ b/migrations_lockfile.txt @@ -31,7 +31,7 @@ replays: 0007_organizationmember_replay_access seer: 0006_add_night_shift_models -sentry: 1066_add_export_format_in_data_export_obj +sentry: 1067_add_dashboard_revision_model social_auth: 0003_social_auth_json_field diff --git a/src/sentry/migrations/1067_add_dashboard_revision_model.py b/src/sentry/migrations/1067_add_dashboard_revision_model.py new file mode 100644 index 00000000000000..1aba202270aa96 --- /dev/null +++ b/src/sentry/migrations/1067_add_dashboard_revision_model.py @@ -0,0 +1,76 @@ +# Generated by Django 5.2.12 on 2026-04-14 15:38 + +import django.db.models.deletion +import django.utils.timezone +import sentry.db.models.fields.bounded +import sentry.db.models.fields.foreignkey +import sentry.db.models.fields.hybrid_cloud_foreign_key +import sentry.db.models.fields.jsonfield +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", "1066_add_export_format_in_data_export_obj"), + ] + + operations = [ + migrations.CreateModel( + name="DashboardRevision", + fields=[ + ( + "id", + sentry.db.models.fields.bounded.BoundedBigAutoField( + primary_key=True, serialize=False + ), + ), + ( + "created_by_id", + sentry.db.models.fields.hybrid_cloud_foreign_key.HybridCloudForeignKey( + "sentry.User", db_index=True, null=True, on_delete="SET_NULL" + ), + ), + ("date_added", models.DateTimeField(default=django.utils.timezone.now)), + ("title", models.CharField(max_length=255)), + ("source", models.CharField(default="edit", max_length=32)), + ("snapshot", sentry.db.models.fields.jsonfield.JSONField(default=dict)), + ("snapshot_schema_version", models.IntegerField(db_default=1)), + ( + "dashboard", + sentry.db.models.fields.foreignkey.FlexibleForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="sentry.dashboard" + ), + ), + ( + "organization", + sentry.db.models.fields.foreignkey.FlexibleForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="sentry.organization" + ), + ), + ], + options={ + "db_table": "sentry_dashboardrevision", + "indexes": [ + models.Index( + fields=["dashboard", "-date_added"], name="sentry_dashrev_dash_date_idx" + ) + ], + }, + ), + ] diff --git a/src/sentry/models/dashboard.py b/src/sentry/models/dashboard.py index 691de3696b5c72..3bba858d20247f 100644 --- a/src/sentry/models/dashboard.py +++ b/src/sentry/models/dashboard.py @@ -427,6 +427,32 @@ class Meta: __repr__ = sane_repr("organization", "slug") +@cell_silo_model +class DashboardRevision(Model): + __relocation_scope__ = RelocationScope.Organization + + created_by_id = HybridCloudForeignKey( + "sentry.User", db_index=True, null=True, on_delete="SET_NULL" + ) + date_added = models.DateTimeField(default=timezone.now) + title = models.CharField(max_length=255) + source = models.CharField(max_length=32, default="edit") + snapshot: models.Field[dict[str, Any], dict[str, Any]] = JSONField(default=dict) + snapshot_schema_version = models.IntegerField(db_default=1) + dashboard = FlexibleForeignKey("sentry.Dashboard", on_delete=models.CASCADE) + organization = FlexibleForeignKey("sentry.Organization", on_delete=models.CASCADE) + + class Meta: + app_label = "sentry" + db_table = "sentry_dashboardrevision" + indexes = [ + models.Index( + fields=["dashboard", "-date_added"], + name="sentry_dashrev_dash_date_idx", + ) + ] + + @cell_silo_model class DashboardLastVisited(DefaultFieldsModel): __relocation_scope__ = RelocationScope.Organization From ee99925b70e70181b99fefa7b497350858d35253 Mon Sep 17 00:00:00 2001 From: Shaun Kaasten <900809+skaasten@users.noreply.github.com> Date: Tue, 14 Apr 2026 11:53:09 -0400 Subject: [PATCH 2/6] ref(dashboards): Drop redundant organization FK from DashboardRevision Organization is already reachable via dashboard.organization_id. The direct FK was unnecessary denormalization with no query benefit. Co-Authored-By: Claude Sonnet 4.6 --- src/sentry/migrations/1067_add_dashboard_revision_model.py | 6 ------ src/sentry/models/dashboard.py | 1 - 2 files changed, 7 deletions(-) diff --git a/src/sentry/migrations/1067_add_dashboard_revision_model.py b/src/sentry/migrations/1067_add_dashboard_revision_model.py index 1aba202270aa96..029e1009f2e4d8 100644 --- a/src/sentry/migrations/1067_add_dashboard_revision_model.py +++ b/src/sentry/migrations/1067_add_dashboard_revision_model.py @@ -57,12 +57,6 @@ class Migration(CheckedMigration): on_delete=django.db.models.deletion.CASCADE, to="sentry.dashboard" ), ), - ( - "organization", - sentry.db.models.fields.foreignkey.FlexibleForeignKey( - on_delete=django.db.models.deletion.CASCADE, to="sentry.organization" - ), - ), ], options={ "db_table": "sentry_dashboardrevision", diff --git a/src/sentry/models/dashboard.py b/src/sentry/models/dashboard.py index 3bba858d20247f..3f04ec13616ddc 100644 --- a/src/sentry/models/dashboard.py +++ b/src/sentry/models/dashboard.py @@ -440,7 +440,6 @@ class DashboardRevision(Model): snapshot: models.Field[dict[str, Any], dict[str, Any]] = JSONField(default=dict) snapshot_schema_version = models.IntegerField(db_default=1) dashboard = FlexibleForeignKey("sentry.Dashboard", on_delete=models.CASCADE) - organization = FlexibleForeignKey("sentry.Organization", on_delete=models.CASCADE) class Meta: app_label = "sentry" From b8d0223fe7759999ba7ac8a298ab5cb215fad420 Mon Sep 17 00:00:00 2001 From: Shaun Kaasten <900809+skaasten@users.noreply.github.com> Date: Tue, 14 Apr 2026 12:56:44 -0400 Subject: [PATCH 3/6] fix(dashboards): Wire DashboardRevision into backup and user merge infrastructure New models with RelocationScope.Organization that reference a user require three additions: fixture creation in create_exhaustive_organization, inclusion in the user merge model_list, and coverage in the expect_models decorators. Co-Authored-By: Claude Sonnet 4.6 --- src/sentry/organizations/services/organization/impl.py | 3 ++- src/sentry/testutils/helpers/backups.py | 6 ++++++ tests/sentry/users/models/test_user.py | 4 +++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/sentry/organizations/services/organization/impl.py b/src/sentry/organizations/services/organization/impl.py index 5b7c8db37cf3fd..fc89dbce5c338e 100644 --- a/src/sentry/organizations/services/organization/impl.py +++ b/src/sentry/organizations/services/organization/impl.py @@ -19,7 +19,7 @@ from sentry.incidents.models.alert_rule import AlertRule, AlertRuleActivity from sentry.incidents.models.incident import IncidentActivity from sentry.models.activity import Activity -from sentry.models.dashboard import Dashboard, DashboardFavoriteUser +from sentry.models.dashboard import Dashboard, DashboardFavoriteUser, DashboardRevision from sentry.models.groupassignee import GroupAssignee from sentry.models.groupbookmark import GroupBookmark from sentry.models.groupsearchview import GroupSearchView @@ -582,6 +582,7 @@ def merge_users(self, *, organization_id: int, from_user_id: int, to_user_id: in AlertRuleActivity, Dashboard, DashboardFavoriteUser, + DashboardRevision, GroupAssignee, GroupBookmark, GroupSeen, diff --git a/src/sentry/testutils/helpers/backups.py b/src/sentry/testutils/helpers/backups.py index f000b211a16f6b..ece74186545b8d 100644 --- a/src/sentry/testutils/helpers/backups.py +++ b/src/sentry/testutils/helpers/backups.py @@ -67,6 +67,7 @@ Dashboard, DashboardFavoriteUser, DashboardLastVisited, + DashboardRevision, DashboardTombstone, ) from sentry.models.dashboard_permissions import DashboardPermissions @@ -600,6 +601,11 @@ def create_exhaustive_organization( field="count()", dashboard=linked_dashboard, ) + DashboardRevision.objects.create( + dashboard=dashboard, + created_by_id=owner_id, + title=dashboard.title, + ) DashboardTombstone.objects.create(organization=org, slug=f"test-tombstone-in-{slug}") # *Search diff --git a/tests/sentry/users/models/test_user.py b/tests/sentry/users/models/test_user.py index 945c8a7048e781..8c7459a1213e8e 100644 --- a/tests/sentry/users/models/test_user.py +++ b/tests/sentry/users/models/test_user.py @@ -16,7 +16,7 @@ from sentry.incidents.models.incident import IncidentActivity from sentry.models.activity import Activity from sentry.models.authidentity import AuthIdentity -from sentry.models.dashboard import Dashboard, DashboardFavoriteUser +from sentry.models.dashboard import Dashboard, DashboardFavoriteUser, DashboardRevision from sentry.models.groupassignee import GroupAssignee from sentry.models.groupbookmark import GroupBookmark from sentry.models.groupsearchview import GroupSearchView @@ -498,6 +498,7 @@ def test_duplicate_memberships(self, expected_models: list[type[Model]]) -> None AlertRuleActivity, Dashboard, DashboardFavoriteUser, + DashboardRevision, GroupAssignee, GroupBookmark, GroupSeen, @@ -541,6 +542,7 @@ def test_only_source_user_is_member_of_organization( AlertRuleActivity, Dashboard, DashboardFavoriteUser, + DashboardRevision, GroupAssignee, GroupBookmark, GroupSeen, From fdb23eaf12616ef5552c78c13a121ab9b705b298 Mon Sep 17 00:00:00 2001 From: Shaun Kaasten <900809+skaasten@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:14:54 -0400 Subject: [PATCH 4/6] ref(dashboards): Switch DashboardRevision to DefaultFieldsModel Gets date_added (auto_now_add) and date_updated (auto_now) for free, removes the manually declared date_added field. Co-Authored-By: Claude Sonnet 4.6 --- src/sentry/migrations/1067_add_dashboard_revision_model.py | 4 ++-- src/sentry/models/dashboard.py | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/sentry/migrations/1067_add_dashboard_revision_model.py b/src/sentry/migrations/1067_add_dashboard_revision_model.py index 029e1009f2e4d8..2fa86277480241 100644 --- a/src/sentry/migrations/1067_add_dashboard_revision_model.py +++ b/src/sentry/migrations/1067_add_dashboard_revision_model.py @@ -1,7 +1,6 @@ # Generated by Django 5.2.12 on 2026-04-14 15:38 import django.db.models.deletion -import django.utils.timezone import sentry.db.models.fields.bounded import sentry.db.models.fields.foreignkey import sentry.db.models.fields.hybrid_cloud_foreign_key @@ -46,7 +45,8 @@ class Migration(CheckedMigration): "sentry.User", db_index=True, null=True, on_delete="SET_NULL" ), ), - ("date_added", models.DateTimeField(default=django.utils.timezone.now)), + ("date_updated", models.DateTimeField(auto_now=True)), + ("date_added", models.DateTimeField(auto_now_add=True)), ("title", models.CharField(max_length=255)), ("source", models.CharField(default="edit", max_length=32)), ("snapshot", sentry.db.models.fields.jsonfield.JSONField(default=dict)), diff --git a/src/sentry/models/dashboard.py b/src/sentry/models/dashboard.py index 3f04ec13616ddc..0dc8b8457a9b11 100644 --- a/src/sentry/models/dashboard.py +++ b/src/sentry/models/dashboard.py @@ -428,13 +428,12 @@ class Meta: @cell_silo_model -class DashboardRevision(Model): +class DashboardRevision(DefaultFieldsModel): __relocation_scope__ = RelocationScope.Organization created_by_id = HybridCloudForeignKey( "sentry.User", db_index=True, null=True, on_delete="SET_NULL" ) - date_added = models.DateTimeField(default=timezone.now) title = models.CharField(max_length=255) source = models.CharField(max_length=32, default="edit") snapshot: models.Field[dict[str, Any], dict[str, Any]] = JSONField(default=dict) From cf0413314f740c9380d06b96a3094520f44c42f5 Mon Sep 17 00:00:00 2001 From: Shaun Kaasten <900809+skaasten@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:16:58 -0400 Subject: [PATCH 5/6] ref(dashboards): Remove default from snapshot_schema_version Force callers to be explicit about which schema version they are saving rather than silently defaulting to 1. Co-Authored-By: Claude Sonnet 4.6 --- src/sentry/migrations/1067_add_dashboard_revision_model.py | 2 +- src/sentry/models/dashboard.py | 2 +- src/sentry/testutils/helpers/backups.py | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/sentry/migrations/1067_add_dashboard_revision_model.py b/src/sentry/migrations/1067_add_dashboard_revision_model.py index 2fa86277480241..8b023c264447b1 100644 --- a/src/sentry/migrations/1067_add_dashboard_revision_model.py +++ b/src/sentry/migrations/1067_add_dashboard_revision_model.py @@ -50,7 +50,7 @@ class Migration(CheckedMigration): ("title", models.CharField(max_length=255)), ("source", models.CharField(default="edit", max_length=32)), ("snapshot", sentry.db.models.fields.jsonfield.JSONField(default=dict)), - ("snapshot_schema_version", models.IntegerField(db_default=1)), + ("snapshot_schema_version", models.IntegerField()), ( "dashboard", sentry.db.models.fields.foreignkey.FlexibleForeignKey( diff --git a/src/sentry/models/dashboard.py b/src/sentry/models/dashboard.py index 0dc8b8457a9b11..f917c81f3af347 100644 --- a/src/sentry/models/dashboard.py +++ b/src/sentry/models/dashboard.py @@ -437,7 +437,7 @@ class DashboardRevision(DefaultFieldsModel): title = models.CharField(max_length=255) source = models.CharField(max_length=32, default="edit") snapshot: models.Field[dict[str, Any], dict[str, Any]] = JSONField(default=dict) - snapshot_schema_version = models.IntegerField(db_default=1) + snapshot_schema_version = models.IntegerField() dashboard = FlexibleForeignKey("sentry.Dashboard", on_delete=models.CASCADE) class Meta: diff --git a/src/sentry/testutils/helpers/backups.py b/src/sentry/testutils/helpers/backups.py index ece74186545b8d..741a2e8adb630f 100644 --- a/src/sentry/testutils/helpers/backups.py +++ b/src/sentry/testutils/helpers/backups.py @@ -605,6 +605,7 @@ def create_exhaustive_organization( dashboard=dashboard, created_by_id=owner_id, title=dashboard.title, + snapshot_schema_version=1, ) DashboardTombstone.objects.create(organization=org, slug=f"test-tombstone-in-{slug}") From f5f2088c87c3e6f647db00339dcfa48082f6f4fd Mon Sep 17 00:00:00 2001 From: Shaun Kaasten <900809+skaasten@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:33:07 -0400 Subject: [PATCH 6/6] fix(dashboards): Register DateUpdatedComparator for DashboardRevision The exhaustive backup test exports and re-imports data, then compares JSON field-by-field. The auto_now/auto_now_add fields date_added and date_updated are re-stamped at import time, causing a spurious UnequalJSON ComparatorFinding. Register a DateUpdatedComparator so the backup infrastructure treats these fields as "may be later" rather than requiring exact equality. Co-Authored-By: Claude Sonnet 4 --- src/sentry/backup/comparators.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/sentry/backup/comparators.py b/src/sentry/backup/comparators.py index 3807557bea2bc5..6bd8362e7c926c 100644 --- a/src/sentry/backup/comparators.py +++ b/src/sentry/backup/comparators.py @@ -859,6 +859,9 @@ def get_default_comparators() -> dict[str, list[JSONScrubbingComparator]]: "sentry.dashboardlastvisited": [ DateUpdatedComparator("last_visited", "date_added", "date_updated"), ], + "sentry.dashboardrevision": [ + DateUpdatedComparator("date_added", "date_updated"), + ], "sentry.dataforwarder": [DateUpdatedComparator("date_updated", "date_added")], "sentry.dataforwarderproject": [DateUpdatedComparator("date_updated", "date_added")], "sentry.groupsearchview": [DateUpdatedComparator("date_updated")],