Browse Source

Migration for all the model changes for Project Templates (#69816)

## Description
This will create 2 new models, `ProjectTemplate` and
`ProjectTemplateOptions`. These models will be used to store settings
across multiple projects. This PR also creates a 1 to 1 relationship for
`ProjectTemplates` and `Projects`.

**Design Document**:
https://www.notion.so/sentry/Service-Settings-Templates-ca6fdb38b15f4a6db61d319bc342b86c?pvs=4#60623e7fbaba4e0985f8d4581af18e64
Josh Callender 9 months ago
parent
commit
751636b617

+ 52 - 1
fixtures/backup/model_dependencies/detailed.json

@@ -4087,6 +4087,11 @@
         "kind": "FlexibleForeignKey",
         "model": "sentry.organization",
         "nullable": false
+      },
+      "template": {
+        "kind": "FlexibleForeignKey",
+        "model": "sentry.projecttemplate",
+        "nullable": true
       }
     },
     "model": "sentry.project",
@@ -4386,6 +4391,52 @@
       ]
     ]
   },
+  "sentry.projecttemplate": {
+    "dangling": false,
+    "foreign_keys": {
+      "organization": {
+        "kind": "FlexibleForeignKey",
+        "model": "sentry.organization",
+        "nullable": false
+      }
+    },
+    "model": "sentry.projecttemplate",
+    "relocation_dependencies": [],
+    "relocation_scope": "Organization",
+    "silos": [
+      "Region"
+    ],
+    "table_name": "sentry_projecttemplate",
+    "uniques": [
+      [
+        "name",
+        "organization"
+      ]
+    ]
+  },
+  "sentry.projecttemplateoption": {
+    "dangling": false,
+    "foreign_keys": {
+      "project_template": {
+        "kind": "FlexibleForeignKey",
+        "model": "sentry.projecttemplate",
+        "nullable": false
+      }
+    },
+    "model": "sentry.projecttemplateoption",
+    "relocation_dependencies": [],
+    "relocation_scope": "Organization",
+    "silos": [
+      "Region"
+    ],
+    "table_name": "sentry_projecttemplateoption",
+    "uniques": [
+      [
+        "key",
+        "project_template"
+      ]
+    ]
+  },
   "sentry.projecttransactionthreshold": {
     "dangling": false,
     "foreign_keys": {
@@ -6148,4 +6199,4 @@
       ]
     ]
   }
-}
+}

+ 9 - 2
fixtures/backup/model_dependencies/flat.json

@@ -563,7 +563,8 @@
     "sentry.projectdebugfile"
   ],
   "sentry.project": [
-    "sentry.organization"
+    "sentry.organization",
+    "sentry.projecttemplate"
   ],
   "sentry.projectartifactbundle": [
     "sentry.artifactbundle",
@@ -606,6 +607,12 @@
     "sentry.project",
     "sentry.team"
   ],
+  "sentry.projecttemplate": [
+    "sentry.organization"
+  ],
+  "sentry.projecttemplateoption": [
+    "sentry.projecttemplate"
+  ],
   "sentry.projecttransactionthreshold": [
     "sentry.organization",
     "sentry.project",
@@ -845,4 +852,4 @@
   "social_auth.usersocialauth": [
     "sentry.user"
   ]
-}
+}

+ 58 - 56
fixtures/backup/model_dependencies/sorted.json

@@ -27,24 +27,15 @@
   "sentry.organizationmapping",
   "sentry.organizationoption",
   "sentry.perfstringindexer",
-  "sentry.project",
-  "sentry.projectintegration",
-  "sentry.projectkey",
-  "sentry.projectoption",
-  "sentry.projectownership",
-  "sentry.projectplatform",
-  "sentry.projectredirect",
-  "sentry.rawevent",
+  "sentry.projecttemplate",
+  "sentry.projecttemplateoption",
   "sentry.regionimportchunk",
   "sentry.regionoutbox",
   "sentry.regionscheduleddeletion",
   "sentry.regiontombstone",
-  "sentry.regressiongroup",
   "sentry.relay",
   "sentry.relayusage",
   "sentry.repository",
-  "sentry.repositoryprojectpathconfig",
-  "sentry.reprocessingreport",
   "sentry.scheduleddeletion",
   "sentry.sentryshot",
   "sentry.stringindexer",
@@ -53,21 +44,14 @@
   "sentry.user",
   "sentry.useravatar",
   "sentry.userip",
-  "sentry.useroption",
   "sentry.userpermission",
   "sentry.userrole",
   "sentry.userroleuser",
   "social_auth.usersocialauth",
   "sentry.savedsearch",
   "sentry.relocation",
-  "sentry.release",
   "sentry.recentsearch",
-  "sentry.promptsactivity",
-  "sentry.projecttransactionthresholdoverride",
-  "sentry.projecttransactionthreshold",
-  "sentry.projectteam",
-  "sentry.projectcodeowners",
-  "sentry.projectbookmark",
+  "sentry.project",
   "sentry.processingissue",
   "sentry.orgauthtoken",
   "sentry.organizationslugreservation",
@@ -82,20 +66,15 @@
   "sentry.integrationexternalproject",
   "sentry.identity",
   "sentry.grouptombstone",
-  "sentry.group",
   "sentry.fileblobowner",
   "sentry.file",
   "sentry.featureadoption",
   "sentry.externalissue",
   "sentry.externalactor",
   "sentry.exporteddata",
-  "sentry.eventprocessingissue",
-  "sentry.eventattachment",
   "sentry.environment",
   "sentry.email",
-  "sentry.distribution",
   "sentry.discoversavedquery",
-  "sentry.deploy",
   "sentry.deletedteam",
   "sentry.deletedproject",
   "sentry.dashboardtombstone",
@@ -114,36 +93,43 @@
   "sentry.appconnectbuild",
   "sentry.apikey",
   "sentry.apiapplication",
-  "sentry.activity",
   "replays.replayrecordingsegment",
   "hybridcloud.orgauthtokenreplica",
   "hybridcloud.organizationslugreservationreplica",
   "hybridcloud.externalactorreplica",
   "hybridcloud.apikeyreplica",
   "feedback.feedback",
-  "sentry.userreport",
+  "sentry.useroption",
   "sentry.useremail",
-  "sentry.teamkeytransaction",
   "sentry.snubaquery",
   "sentry.sentryapp",
   "sentry.rule",
+  "sentry.reprocessingreport",
+  "sentry.repositoryprojectpathconfig",
   "sentry.relocationvalidation",
   "sentry.relocationfile",
   "sentry.releasethreshold",
-  "sentry.releaseprojectenvironment",
-  "sentry.releaseproject",
-  "sentry.releaseheadcommit",
-  "sentry.releasefile",
-  "sentry.releaseenvironment",
-  "sentry.releasecommit",
   "sentry.releaseartifactbundle",
-  "sentry.releaseactivity",
+  "sentry.release",
+  "sentry.regressiongroup",
+  "sentry.rawevent",
   "sentry.querysubscription",
   "sentry.pullrequest",
+  "sentry.promptsactivity",
+  "sentry.projecttransactionthresholdoverride",
+  "sentry.projecttransactionthreshold",
+  "sentry.projectteam",
+  "sentry.projectredirect",
+  "sentry.projectplatform",
+  "sentry.projectownership",
+  "sentry.projectoption",
+  "sentry.projectkey",
+  "sentry.projectintegration",
   "sentry.projectdebugfile",
+  "sentry.projectcodeowners",
+  "sentry.projectbookmark",
   "sentry.projectartifactbundle",
   "sentry.proguardartifactrelease",
-  "sentry.platformexternalissue",
   "sentry.organizationmemberteam",
   "sentry.organizationmembermapping",
   "sentry.organizationavatar",
@@ -151,30 +137,15 @@
   "sentry.neglectedrule",
   "sentry.monitorenvironment",
   "sentry.monitorcheckin",
-  "sentry.latestreporeleaseenvironment",
-  "sentry.groupsubscription",
-  "sentry.groupsnooze",
-  "sentry.groupshare",
-  "sentry.groupseen",
-  "sentry.grouprulestatus",
-  "sentry.groupresolution",
-  "sentry.grouprelease",
-  "sentry.groupredirect",
-  "sentry.groupowner",
-  "sentry.groupmeta",
-  "sentry.grouplink",
-  "sentry.groupinbox",
-  "sentry.grouphistory",
-  "sentry.grouphash",
-  "sentry.groupenvironment",
-  "sentry.groupemailthread",
-  "sentry.groupcommitresolution",
-  "sentry.groupbookmark",
-  "sentry.groupassignee",
+  "sentry.group",
   "sentry.fileblobindex",
   "sentry.exporteddatablob",
+  "sentry.eventprocessingissue",
+  "sentry.eventattachment",
   "sentry.environmentproject",
+  "sentry.distribution",
   "sentry.discoversavedqueryproject",
+  "sentry.deploy",
   "sentry.debugidartifactbundle",
   "sentry.dashboardwidget",
   "sentry.dashboardproject",
@@ -189,7 +160,10 @@
   "sentry.apigrant",
   "sentry.apiauthorization",
   "sentry.alertrule",
+  "sentry.activity",
   "hybridcloud.apitokenreplica",
+  "sentry.userreport",
+  "sentry.teamkeytransaction",
   "sentry.snubaqueryeventtype",
   "sentry.sentryappinstallation",
   "sentry.sentryappcomponent",
@@ -198,12 +172,40 @@
   "sentry.rulefirehistory",
   "sentry.ruleactivity",
   "sentry.relocationvalidationattempt",
+  "sentry.releaseprojectenvironment",
+  "sentry.releaseproject",
+  "sentry.releaseheadcommit",
+  "sentry.releasefile",
+  "sentry.releaseenvironment",
+  "sentry.releasecommit",
+  "sentry.releaseactivity",
   "sentry.pullrequestcommit",
   "sentry.pullrequestcomment",
+  "sentry.platformexternalissue",
   "sentry.organizationmemberteamreplica",
   "sentry.notificationactionproject",
   "sentry.monitorincident",
   "sentry.monitorenvbrokendetection",
+  "sentry.latestreporeleaseenvironment",
+  "sentry.groupsubscription",
+  "sentry.groupsnooze",
+  "sentry.groupshare",
+  "sentry.groupseen",
+  "sentry.grouprulestatus",
+  "sentry.groupresolution",
+  "sentry.grouprelease",
+  "sentry.groupredirect",
+  "sentry.groupowner",
+  "sentry.groupmeta",
+  "sentry.grouplink",
+  "sentry.groupinbox",
+  "sentry.grouphistory",
+  "sentry.grouphash",
+  "sentry.groupenvironment",
+  "sentry.groupemailthread",
+  "sentry.groupcommitresolution",
+  "sentry.groupbookmark",
+  "sentry.groupassignee",
   "sentry.dashboardwidgetquery",
   "sentry.alertruletrigger",
   "sentry.alertruleprojects",
@@ -228,4 +230,4 @@
   "sentry.incidentseen",
   "sentry.incidentproject",
   "sentry.incidentactivity"
-]
+]

+ 58 - 56
fixtures/backup/model_dependencies/truncate.json

@@ -27,24 +27,15 @@
   "sentry_organizationmapping",
   "sentry_organizationoptions",
   "sentry_perfstringindexer",
-  "sentry_project",
-  "sentry_projectintegration",
-  "sentry_projectkey",
-  "sentry_projectoptions",
-  "sentry_projectownership",
-  "sentry_projectplatform",
-  "sentry_projectredirect",
-  "sentry_rawevent",
+  "sentry_projecttemplate",
+  "sentry_projecttemplateoption",
   "sentry_regionimportchunk",
   "sentry_regionoutbox",
   "sentry_regionscheduleddeletion",
   "sentry_regiontombstone",
-  "sentry_regressiongroup",
   "sentry_relay",
   "sentry_relayusage",
   "sentry_repository",
-  "sentry_repositoryprojectpathconfig",
-  "sentry_reprocessingreport",
   "sentry_scheduleddeletion",
   "sentry_sentryshot",
   "sentry_stringindexer",
@@ -53,21 +44,14 @@
   "auth_user",
   "sentry_useravatar",
   "sentry_userip",
-  "sentry_useroption",
   "sentry_userpermission",
   "sentry_userrole",
   "sentry_userrole_users",
   "social_auth_usersocialauth",
   "sentry_savedsearch",
   "sentry_relocation",
-  "sentry_release",
   "sentry_recentsearch",
-  "sentry_promptsactivity",
-  "sentry_projecttransactionthresholdoverride",
-  "sentry_projecttransactionthreshold",
-  "sentry_projectteam",
-  "sentry_projectcodeowners",
-  "sentry_projectbookmark",
+  "sentry_project",
   "sentry_processingissue",
   "sentry_orgauthtoken",
   "sentry_organizationslugreservation",
@@ -82,20 +66,15 @@
   "sentry_integrationexternalproject",
   "sentry_identity",
   "sentry_grouptombstone",
-  "sentry_groupedmessage",
   "sentry_fileblobowner",
   "sentry_file",
   "sentry_featureadoption",
   "sentry_externalissue",
   "sentry_externalactor",
   "sentry_exporteddata",
-  "sentry_eventprocessingissue",
-  "sentry_eventattachment",
   "sentry_environment",
   "sentry_email",
-  "sentry_distribution",
   "sentry_discoversavedquery",
-  "sentry_deploy",
   "sentry_deletedteam",
   "sentry_deletedproject",
   "sentry_dashboardtombstone",
@@ -114,36 +93,43 @@
   "sentry_appconnectbuild",
   "sentry_apikey",
   "sentry_apiapplication",
-  "sentry_activity",
   "replays_replayrecordingsegment",
   "hybridcloud_orgauthtokenreplica",
   "hybridcloud_organizationslugreservationreplica",
   "hybridcloud_externalactorreplica",
   "hybridcloud_apikeyreplica",
   "feedback_feedback",
-  "sentry_userreport",
+  "sentry_useroption",
   "sentry_useremail",
-  "sentry_performanceteamkeytransaction",
   "sentry_snubaquery",
   "sentry_sentryapp",
   "sentry_rule",
+  "sentry_reprocessingreport",
+  "sentry_repositoryprojectpathconfig",
   "sentry_relocationvalidation",
   "sentry_relocationfile",
   "sentry_releasethreshold",
-  "sentry_releaseprojectenvironment",
-  "sentry_release_project",
-  "sentry_releaseheadcommit",
-  "sentry_releasefile",
-  "sentry_environmentrelease",
-  "sentry_releasecommit",
   "sentry_releaseartifactbundle",
-  "sentry_releaseactivity",
+  "sentry_release",
+  "sentry_regressiongroup",
+  "sentry_rawevent",
   "sentry_querysubscription",
   "sentry_pull_request",
+  "sentry_promptsactivity",
+  "sentry_projecttransactionthresholdoverride",
+  "sentry_projecttransactionthreshold",
+  "sentry_projectteam",
+  "sentry_projectredirect",
+  "sentry_projectplatform",
+  "sentry_projectownership",
+  "sentry_projectoptions",
+  "sentry_projectkey",
+  "sentry_projectintegration",
   "sentry_projectdsymfile",
+  "sentry_projectcodeowners",
+  "sentry_projectbookmark",
   "sentry_projectartifactbundle",
   "sentry_proguardartifactrelease",
-  "sentry_platformexternalissue",
   "sentry_organizationmember_teams",
   "sentry_organizationmembermapping",
   "sentry_organizationavatar",
@@ -151,30 +137,15 @@
   "sentry_neglectedrule",
   "sentry_monitorenvironment",
   "sentry_monitorcheckin",
-  "sentry_latestrelease",
-  "sentry_groupsubscription",
-  "sentry_groupsnooze",
-  "sentry_groupshare",
-  "sentry_groupseen",
-  "sentry_grouprulestatus",
-  "sentry_groupresolution",
-  "sentry_grouprelease",
-  "sentry_groupredirect",
-  "sentry_groupowner",
-  "sentry_groupmeta",
-  "sentry_grouplink",
-  "sentry_groupinbox",
-  "sentry_grouphistory",
-  "sentry_grouphash",
-  "sentry_groupenvironment",
-  "sentry_groupemailthread",
-  "sentry_groupcommitresolution",
-  "sentry_groupbookmark",
-  "sentry_groupasignee",
+  "sentry_groupedmessage",
   "sentry_fileblobindex",
   "sentry_exporteddatablob",
+  "sentry_eventprocessingissue",
+  "sentry_eventattachment",
   "sentry_environmentproject",
+  "sentry_distribution",
   "sentry_discoversavedqueryproject",
+  "sentry_deploy",
   "sentry_debugidartifactbundle",
   "sentry_dashboardwidget",
   "sentry_dashboardproject",
@@ -189,7 +160,10 @@
   "sentry_apigrant",
   "sentry_apiauthorization",
   "sentry_alertrule",
+  "sentry_activity",
   "hybridcloud_apitokenreplica",
+  "sentry_userreport",
+  "sentry_performanceteamkeytransaction",
   "sentry_snubaqueryeventtype",
   "sentry_sentryappinstallation",
   "sentry_sentryappcomponent",
@@ -198,12 +172,40 @@
   "sentry_rulefirehistory",
   "sentry_ruleactivity",
   "sentry_relocationvalidationattempt",
+  "sentry_releaseprojectenvironment",
+  "sentry_release_project",
+  "sentry_releaseheadcommit",
+  "sentry_releasefile",
+  "sentry_environmentrelease",
+  "sentry_releasecommit",
+  "sentry_releaseactivity",
   "sentry_pullrequest_commit",
   "sentry_pullrequest_comment",
+  "sentry_platformexternalissue",
   "sentry_organizationmember_teamsreplica",
   "sentry_notificationactionproject",
   "sentry_monitorincident",
   "sentry_monitorenvbrokendetection",
+  "sentry_latestrelease",
+  "sentry_groupsubscription",
+  "sentry_groupsnooze",
+  "sentry_groupshare",
+  "sentry_groupseen",
+  "sentry_grouprulestatus",
+  "sentry_groupresolution",
+  "sentry_grouprelease",
+  "sentry_groupredirect",
+  "sentry_groupowner",
+  "sentry_groupmeta",
+  "sentry_grouplink",
+  "sentry_groupinbox",
+  "sentry_grouphistory",
+  "sentry_grouphash",
+  "sentry_groupenvironment",
+  "sentry_groupemailthread",
+  "sentry_groupcommitresolution",
+  "sentry_groupbookmark",
+  "sentry_groupasignee",
   "sentry_dashboardwidgetquery",
   "sentry_alertruletrigger",
   "sentry_alertruleprojects",
@@ -228,4 +230,4 @@
   "sentry_incidentseen",
   "sentry_incidentproject",
   "sentry_incidentactivity"
-]
+]

+ 1 - 1
migrations_lockfile.txt

@@ -9,5 +9,5 @@ feedback: 0004_index_together
 hybridcloud: 0016_add_control_cacheversion
 nodestore: 0002_nodestore_no_dictfield
 replays: 0004_index_together
-sentry: 0722_drop_sentryfunctions
+sentry: 0723_project_template_models
 social_auth: 0002_default_auto_field

+ 1 - 0
src/sentry/backup/comparators.py

@@ -823,6 +823,7 @@ def get_default_comparators() -> dict[str, list[JSONScrubbingComparator]]:
                 HashObfuscatingComparator("public_key", "secret_key"),
                 SecretHexComparator(16, "public_key", "secret_key"),
             ],
+            "sentry.projecttemplate": [DateUpdatedComparator("date_updated")],
             "sentry.querysubscription": [
                 DateUpdatedComparator("date_updated"),
                 # We regenerate subscriptions when importing them, so even though all of the

+ 97 - 0
src/sentry/migrations/0723_project_template_models.py

@@ -0,0 +1,97 @@
+# Generated by Django 5.0.4 on 2024-05-15 20:33
+
+import django.db.models.deletion
+import django.utils.timezone
+from django.db import migrations, models
+
+import sentry.db.models.fields.bounded
+import sentry.db.models.fields.foreignkey
+import sentry.db.models.fields.picklefield
+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", "0722_drop_sentryfunctions"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="ProjectTemplate",
+            fields=[
+                (
+                    "id",
+                    sentry.db.models.fields.bounded.BoundedBigAutoField(
+                        primary_key=True, serialize=False
+                    ),
+                ),
+                ("date_updated", models.DateTimeField(default=django.utils.timezone.now)),
+                ("date_added", models.DateTimeField(default=django.utils.timezone.now, null=True)),
+                ("name", models.CharField(max_length=200)),
+                (
+                    "organization",
+                    sentry.db.models.fields.foreignkey.FlexibleForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE, to="sentry.organization"
+                    ),
+                ),
+            ],
+            options={
+                "db_table": "sentry_projecttemplate",
+            },
+        ),
+        migrations.AddField(
+            model_name="project",
+            name="template",
+            field=sentry.db.models.fields.foreignkey.FlexibleForeignKey(
+                null=True, on_delete=django.db.models.deletion.CASCADE, to="sentry.projecttemplate"
+            ),
+        ),
+        migrations.CreateModel(
+            name="ProjectTemplateOption",
+            fields=[
+                (
+                    "id",
+                    sentry.db.models.fields.bounded.BoundedBigAutoField(
+                        primary_key=True, serialize=False
+                    ),
+                ),
+                ("key", models.CharField(max_length=64)),
+                ("value", sentry.db.models.fields.picklefield.PickledObjectField(editable=False)),
+                (
+                    "project_template",
+                    sentry.db.models.fields.foreignkey.FlexibleForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="options",
+                        to="sentry.projecttemplate",
+                    ),
+                ),
+            ],
+            options={
+                "db_table": "sentry_projecttemplateoption",
+            },
+        ),
+        migrations.AddConstraint(
+            model_name="projecttemplate",
+            constraint=models.UniqueConstraint(
+                fields=("name", "organization"), name="unique_projecttemplate_name_per_org"
+            ),
+        ),
+        migrations.AlterUniqueTogether(
+            name="projecttemplateoption",
+            unique_together={("project_template", "key")},
+        ),
+    ]

+ 1 - 0
src/sentry/models/__init__.py

@@ -90,6 +90,7 @@ from .projectownership import ProjectOwnership  # NOQA
 from .projectplatform import *  # NOQA
 from .projectredirect import *  # NOQA
 from .projectteam import ProjectTeam  # noqa
+from .projecttemplate import ProjectTemplate  # noqa
 from .promptsactivity import *  # NOQA
 from .pullrequest import *  # NOQA
 from .rawevent import *  # NOQA

+ 2 - 0
src/sentry/models/options/__init__.py

@@ -1,6 +1,7 @@
 from .option import ControlOption, Option
 from .organization_option import OrganizationOption
 from .project_option import ProjectOption
+from .project_template_option import ProjectTemplateOption
 from .user_option import UserOption
 
 __all__ = (
@@ -8,5 +9,6 @@ __all__ = (
     "ControlOption",
     "OrganizationOption",
     "ProjectOption",
+    "ProjectTemplateOption",
     "UserOption",
 )

+ 28 - 0
src/sentry/models/options/project_template_option.py

@@ -0,0 +1,28 @@
+from django.db import models
+
+from sentry.backup.scopes import RelocationScope
+from sentry.db.models import FlexibleForeignKey, Model, region_silo_model, sane_repr
+from sentry.db.models.fields import PickledObjectField
+
+
+@region_silo_model
+class ProjectTemplateOption(Model):
+    """
+    A ProjectTemplateOption is a templated version of a project
+
+    It is used to store the values that are shared between different
+    projects across the organization.
+    """
+
+    __relocation_scope__ = RelocationScope.Organization
+
+    project_template = FlexibleForeignKey("sentry.ProjectTemplate", related_name="options")
+    key = models.CharField(max_length=64)
+    value = PickledObjectField()
+
+    class Meta:
+        app_label = "sentry"
+        db_table = "sentry_projecttemplateoption"
+        unique_together = (("project_template", "key"),)
+
+    __repr__ = sane_repr("project_template_id", "key", "value")

Some files were not shown because too many files changed in this diff