Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions products/messaging/backend/facade/contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from uuid import UUID

from posthog.dataclasses import frozen
from posthog.enums import LabeledStrEnum


class UnlayerError(Exception):
Expand Down Expand Up @@ -89,3 +90,8 @@ class QueuedEmail:
message_id: str
# True when an earlier request with the same idempotency key already queued this email.
replayed: bool = False


class TemplateManagedBy(LabeledStrEnum):
GUI = "gui", "GUI"
CODE = "code", "Code"
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import django.contrib.postgres.fields
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("messaging", "0008_messagetemplate_sensitive"),
]

operations = [
migrations.AddField(
model_name="messagetemplate",
name="content_hash",
field=models.CharField(
blank=True,
help_text="Hash of the published subject, preheader, HTML and text, as `v1:` and 64 hex characters.",
max_length=80,
null=True,
),
),
migrations.AddField(
model_name="messagetemplate",
name="key",
field=models.CharField(
blank=True,
help_text="Client-chosen identifier, unique within this environment among templates that are not deleted. Letters, numbers, hyphens (-) and underscores (_) only.",
max_length=400,
null=True,
),
),
migrations.AddField(
model_name="messagetemplate",
name="managed_by",
field=models.CharField(
blank=True,
choices=[("gui", "GUI"), ("code", "Code")],
help_text="Who owns the template content: `gui` for the PostHog app, `code` for uploads from a repository. Empty means `gui`.",
max_length=400,
null=True,
),
),
migrations.AddField(
model_name="messagetemplate",
name="source_path",
field=models.CharField(
blank=True,
help_text="Path of the template file in the source repository.",
max_length=400,
null=True,
),
),
migrations.AddField(
model_name="messagetemplate",
name="source_ref",
field=models.CharField(
blank=True,
help_text="Commit or branch of the last upload.",
max_length=400,
null=True,
),
),
migrations.AddField(
model_name="messagetemplate",
name="source_repository",
field=models.CharField(
blank=True,
help_text="Repository the template was last uploaded from, such as `github.com/example/app`.",
max_length=400,
null=True,
),
),
migrations.AddField(
model_name="messagetemplate",
name="superseded_content_hashes",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(max_length=80),
blank=True,
null=True,
size=None,
),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from django.db import migrations, models

from posthog.migration_helpers import CreateIndexConcurrently


class Migration(migrations.Migration):
atomic = False

dependencies = [
("messaging", "0009_messagetemplate_code_managed_fields"),
]

operations = [
migrations.SeparateDatabaseAndState(
state_operations=[
migrations.AddConstraint(
model_name="messagetemplate",
constraint=models.UniqueConstraint(
fields=("team", "key"),
condition=models.Q(("deleted", False), ("key__isnull", False)),
name="unique_message_template_key_per_team",
),
),
],
database_operations=[
CreateIndexConcurrently(
index_name="unique_message_template_key_per_team",
table_name="posthog_messagetemplate",
columns='("team_id", "key")',
unique=True,
where='WHERE (NOT "deleted" AND "key" IS NOT NULL)',
),
],
),
]
2 changes: 1 addition & 1 deletion products/messaging/backend/migrations/max_migration.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0008_messagetemplate_sensitive
0010_messagetemplate_unique_key_per_team
46 changes: 46 additions & 0 deletions products/messaging/backend/models/message_template.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
from django.contrib.postgres.fields import ArrayField
from django.db import models

from posthog.models.tagged_items_relation import Taggable
from posthog.models.utils import UUIDTModel

from products.messaging.backend.facade.contracts import TemplateManagedBy

KEY_CONSTRAINT = "unique_message_template_key_per_team"


# nosemgrep: no-new-uuidt-models -- an existing model, and its stored ids cannot change.
class MessageTemplate(Taggable, UUIDTModel):
Expand All @@ -28,9 +33,50 @@ class MessageTemplate(Taggable, UUIDTModel):
# For emails carrying codes or reset links: sends keep no rendered body or subject, and clicks keep only
# the link's origin.
sensitive = models.BooleanField(default=False, db_default=False)
key = models.CharField(
max_length=400,
null=True,
blank=True,
help_text="Client-chosen identifier, unique within this environment among templates that are not deleted. "
"Letters, numbers, hyphens (-) and underscores (_) only.",
)
managed_by = models.CharField(
max_length=400,
choices=TemplateManagedBy.choices,
null=True,
blank=True,
help_text="Who owns the template content: `gui` for the PostHog app, `code` for uploads from a repository. "
"Empty means `gui`.",
)
source_repository = models.CharField(
max_length=400,
null=True,
blank=True,
help_text="Repository the template was last uploaded from, such as `github.com/example/app`.",
)
source_path = models.CharField(
max_length=400, null=True, blank=True, help_text="Path of the template file in the source repository."
)
source_ref = models.CharField(
max_length=400, null=True, blank=True, help_text="Commit or branch of the last upload."
)
content_hash = models.CharField(
max_length=80,
null=True,
blank=True,
help_text="Hash of the published subject, preheader, HTML and text, as `v1:` and 64 hex characters.",
)
superseded_content_hashes = ArrayField(models.CharField(max_length=80), null=True, blank=True)

class Meta:
db_table = "posthog_messagetemplate"
constraints = [
models.UniqueConstraint(
fields=["team", "key"],
condition=models.Q(key__isnull=False, deleted=False),
name=KEY_CONSTRAINT,
),
]

def __str__(self):
return self.name
34 changes: 34 additions & 0 deletions products/messaging/backend/tests/test_message_template_key.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from posthog.test.base import BaseTest

from django.db import IntegrityError, transaction

from parameterized import parameterized

from posthog.models import Team

from products.messaging.backend.models.message_template import MessageTemplate


class TestMessageTemplateKey(BaseTest):
def _create_template(self, team: Team, key: str, deleted: bool = False) -> MessageTemplate:
return MessageTemplate.objects.create(team=team, name=key, key=key, deleted=deleted)

def test_a_live_template_holds_its_key_within_its_team(self) -> None:
self._create_template(self.team, "welcome")

with self.assertRaises(IntegrityError), transaction.atomic():
self._create_template(self.team, "welcome")

@parameterized.expand(
[
("in_another_team", False, True),
("after_the_holder_is_deleted", True, False),
]
)
def test_the_key_is_free(self, _name: str, first_is_deleted: bool, second_in_other_team: bool) -> None:
other_team = Team.objects.create(organization=self.organization, name="Other team")
self._create_template(self.team, "welcome", deleted=first_is_deleted)

second = self._create_template(other_team if second_in_other_team else self.team, "welcome")

self.assertEqual(MessageTemplate.objects.get(pk=second.pk).key, "welcome")
Loading