Skip to content

Commit 313f8ea

Browse files
committed
force html_body, text_body and raw_blob to be updated all at once via the API
1 parent f37a71f commit 313f8ea

File tree

2 files changed

+222
-54
lines changed

2 files changed

+222
-54
lines changed

src/backend/core/api/serializers.py

Lines changed: 66 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# pylint: disable=too-many-lines
22
"""Client serializers for the messages core app."""
33

4+
from django.db import transaction
45
from django.db.models import Count, Exists, OuterRef, Q
56

67
from drf_spectacular.utils import extend_schema_field
@@ -960,7 +961,7 @@ class MessageTemplateSerializer(AbilitiesModelSerializer):
960961
is_forced = serializers.BooleanField(
961962
required=False, default=False, help_text="Set as forced template"
962963
)
963-
raw_blob = serializers.CharField(required=True, help_text="Raw blob content")
964+
raw_blob = serializers.CharField(required=False, help_text="Raw blob content")
964965

965966
def to_representation(self, instance):
966967
"""Convert model instance to serialized data."""
@@ -1002,45 +1003,64 @@ def validate(self, attrs):
10021003
"Either mailbox_id or maildomain_id is required."
10031004
)
10041005

1005-
# For creation or when both html_body and text_body are being updated
1006-
if "html_body" in attrs or "text_body" in attrs:
1006+
# For creation or when updating content fields
1007+
if "html_body" in attrs or "text_body" in attrs or "raw_blob" in attrs:
10071008
html_body = attrs.get("html_body", "")
10081009
text_body = attrs.get("text_body", "")
1010+
raw_blob = attrs.get("raw_blob", "")
10091011

1010-
# If we're updating both fields and both are empty, that's invalid
1011-
if not html_body and not text_body:
1012-
raise serializers.ValidationError(
1013-
"At least one of html_body or text_body must be provided."
1014-
)
1012+
# If any content field is provided, all three must be provided together
1013+
# This ensures atomic updates and prevents inconsistent states
1014+
if html_body or text_body or raw_blob:
1015+
if not html_body or not text_body or not raw_blob:
1016+
raise serializers.ValidationError(
1017+
"When providing content fields, all three fields (html_body, text_body, raw_blob) "
1018+
"must be provided together to ensure consistency."
1019+
)
10151020

10161021
return attrs
10171022

10181023
def create(self, validated_data):
1019-
"""Create template with relationships."""
1024+
"""Create template with relationships and ensure atomic content creation."""
1025+
10201026
mailbox_id = validated_data.pop("mailbox_id", None)
10211027
maildomain_id = validated_data.pop("maildomain_id", None)
10221028
raw_blob_content = validated_data.pop("raw_blob", None)
10231029

10241030
# Set mailbox or maildomain directly
1025-
if mailbox_id:
1031+
# Update mailbox or maildomain if provided
1032+
if mailbox_id is not None and maildomain_id is not None:
1033+
# check if domain is the same as the mailbox domain
1034+
mailbox = models.Mailbox.objects.get(id=mailbox_id)
1035+
if mailbox.domain.id != maildomain_id:
1036+
raise serializers.ValidationError(
1037+
"Mailbox domain does not match maildomain."
1038+
)
1039+
validated_data["mailbox"] = mailbox
1040+
validated_data["maildomain"] = models.MailDomain.objects.get(
1041+
id=maildomain_id
1042+
)
1043+
elif mailbox_id is not None:
10261044
validated_data["mailbox"] = models.Mailbox.objects.get(id=mailbox_id)
1027-
elif maildomain_id:
1045+
elif maildomain_id is not None:
10281046
validated_data["maildomain"] = models.MailDomain.objects.get(
10291047
id=maildomain_id
10301048
)
10311049

1032-
# Create raw_blob relationship if content provided
1033-
if raw_blob_content:
1034-
blob = models.Blob.objects.create_blob(
1035-
content=raw_blob_content.encode("utf-8"),
1036-
content_type="application/json",
1037-
mailbox_id=mailbox_id,
1038-
maildomain_id=maildomain_id,
1039-
)
1040-
validated_data["raw_blob"] = blob
1050+
# Use atomic transaction to ensure all content fields are created together
1051+
with transaction.atomic():
1052+
# Create raw_blob relationship if content provided
1053+
if raw_blob_content:
1054+
blob = models.Blob.objects.create_blob(
1055+
content=raw_blob_content.encode("utf-8"),
1056+
content_type="application/json",
1057+
mailbox_id=mailbox_id,
1058+
maildomain_id=maildomain_id,
1059+
)
1060+
validated_data["raw_blob"] = blob
10411061

1042-
template = super().create(validated_data)
1043-
return template
1062+
template = super().create(validated_data)
1063+
return template
10441064

10451065
def update(self, instance, validated_data):
10461066
"""Update template with relationships."""
@@ -1069,24 +1089,27 @@ def update(self, instance, validated_data):
10691089
"Mailbox domain does not match maildomain."
10701090
)
10711091

1072-
# Handle raw_blob update
1073-
if raw_blob_content is not None:
1074-
try:
1075-
if instance.raw_blob:
1076-
instance.raw_blob.delete()
1077-
except models.Blob.DoesNotExist:
1078-
pass
1079-
1080-
if raw_blob_content: # Only create blob if content is not empty
1081-
blob = models.Blob.objects.create_blob(
1082-
content=raw_blob_content.encode("utf-8"),
1083-
content_type="application/json",
1084-
mailbox_id=mailbox_id,
1085-
maildomain_id=maildomain_id,
1086-
)
1087-
validated_data["raw_blob"] = blob
1088-
else:
1089-
validated_data["raw_blob"] = None
1090-
1091-
template = super().update(instance, validated_data)
1092-
return template
1092+
# Use atomic transaction to ensure all content fields are updated together
1093+
with transaction.atomic():
1094+
# Handle raw_blob update first
1095+
if raw_blob_content is not None:
1096+
try:
1097+
if instance.raw_blob:
1098+
instance.raw_blob.delete()
1099+
except models.Blob.DoesNotExist:
1100+
pass
1101+
1102+
if raw_blob_content: # Only create blob if content is not empty
1103+
blob = models.Blob.objects.create_blob(
1104+
content=raw_blob_content.encode("utf-8"),
1105+
content_type="application/json",
1106+
mailbox_id=mailbox_id,
1107+
maildomain_id=maildomain_id,
1108+
)
1109+
validated_data["raw_blob"] = blob
1110+
else:
1111+
validated_data["raw_blob"] = None
1112+
1113+
# Update all fields atomically
1114+
template = super().update(instance, validated_data)
1115+
return template

src/backend/core/tests/api/test_message_template.py

Lines changed: 156 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1165,28 +1165,82 @@ def test_create_template_with_raw_blob(self, user, mailbox):
11651165
template = models.MessageTemplate.objects.get()
11661166
assert template.raw_blob.get_content().decode("utf-8") == "test blob content"
11671167

1168-
def test_update_template_raw_blob(self, user, mailbox):
1169-
"""Test updating a template with raw_blob."""
1168+
def test_update_content_fields_atomic_validation(self, user, mailbox):
1169+
"""Test that content fields must be updated together atomically."""
11701170
factories.MailboxAccessFactory(
11711171
mailbox=mailbox,
11721172
user=user,
11731173
role=models.MailboxRoleChoices.ADMIN,
11741174
)
11751175

1176-
# Create template with blob
1176+
# Create a template
11771177
template = factories.MessageTemplateFactory(
1178-
name="Blob Template",
1179-
html_body="<p>Content</p>",
1180-
text_body="Content",
1178+
name="Test Template",
1179+
html_body="<p>Original content</p>",
1180+
text_body="Original content",
11811181
type=models.MessageTemplateTypeChoices.SIGNATURE,
11821182
mailbox=mailbox,
11831183
)
11841184

11851185
client = APIClient()
11861186
client.force_authenticate(user=user)
11871187

1188+
# Try to update only html_body - should fail
11881189
data = {
1189-
"raw_blob": "new blob content",
1190+
"html_body": "<p>Updated content</p>",
1191+
"mailbox_id": str(mailbox.id),
1192+
}
1193+
1194+
response = client.patch(
1195+
reverse("message-templates-detail", kwargs={"pk": template.id}),
1196+
data,
1197+
format="json",
1198+
)
1199+
assert response.status_code == status.HTTP_400_BAD_REQUEST
1200+
assert (
1201+
"all three fields (html_body, text_body, raw_blob) must be provided together"
1202+
in str(response.data)
1203+
)
1204+
1205+
# Try to update only text_body - should fail
1206+
data = {
1207+
"text_body": "Updated content",
1208+
"mailbox_id": str(mailbox.id),
1209+
}
1210+
1211+
response = client.patch(
1212+
reverse("message-templates-detail", kwargs={"pk": template.id}),
1213+
data,
1214+
format="json",
1215+
)
1216+
assert response.status_code == status.HTTP_400_BAD_REQUEST
1217+
assert (
1218+
"all three fields (html_body, text_body, raw_blob) must be provided together"
1219+
in str(response.data)
1220+
)
1221+
1222+
# Try to update only raw_blob - should fail
1223+
data = {
1224+
"raw_blob": "updated raw content",
1225+
"mailbox_id": str(mailbox.id),
1226+
}
1227+
1228+
response = client.patch(
1229+
reverse("message-templates-detail", kwargs={"pk": template.id}),
1230+
data,
1231+
format="json",
1232+
)
1233+
assert response.status_code == status.HTTP_400_BAD_REQUEST
1234+
assert (
1235+
"all three fields (html_body, text_body, raw_blob) must be provided together"
1236+
in str(response.data)
1237+
)
1238+
1239+
# Update all three fields together - should succeed
1240+
data = {
1241+
"html_body": "<p>Updated content</p>",
1242+
"text_body": "Updated content",
1243+
"raw_blob": "updated raw content",
11901244
"mailbox_id": str(mailbox.id),
11911245
}
11921246

@@ -1196,9 +1250,100 @@ def test_update_template_raw_blob(self, user, mailbox):
11961250
format="json",
11971251
)
11981252
assert response.status_code == status.HTTP_200_OK
1199-
# raw_blob should contain the new content
1200-
assert response.data["raw_blob"] == "new blob content"
12011253

1202-
# check that blob was created with new content
1254+
# Verify all fields were updated
12031255
template.refresh_from_db()
1204-
assert template.raw_blob.get_content().decode("utf-8") == "new blob content"
1256+
assert template.html_body == "<p>Updated content</p>"
1257+
assert template.text_body == "Updated content"
1258+
assert template.raw_blob.get_content().decode("utf-8") == "updated raw content"
1259+
1260+
def test_create_content_fields_atomic_validation(self, user, mailbox):
1261+
"""Test that content fields must be created together atomically."""
1262+
factories.MailboxAccessFactory(
1263+
mailbox=mailbox,
1264+
user=user,
1265+
role=models.MailboxRoleChoices.ADMIN,
1266+
)
1267+
1268+
client = APIClient()
1269+
client.force_authenticate(user=user)
1270+
1271+
# Try to create with only html_body - should fail
1272+
data = {
1273+
"name": "Test Template",
1274+
"html_body": "<p>Content</p>",
1275+
"type": "signature",
1276+
"mailbox_id": str(mailbox.id),
1277+
}
1278+
1279+
response = client.post(
1280+
reverse("message-templates-list"),
1281+
data,
1282+
format="json",
1283+
)
1284+
assert response.status_code == status.HTTP_400_BAD_REQUEST
1285+
assert (
1286+
"all three fields (html_body, text_body, raw_blob) must be provided together"
1287+
in str(response.data)
1288+
)
1289+
1290+
# Try to create with only text_body - should fail
1291+
data = {
1292+
"name": "Test Template",
1293+
"text_body": "Content",
1294+
"type": "signature",
1295+
"mailbox_id": str(mailbox.id),
1296+
}
1297+
1298+
response = client.post(
1299+
reverse("message-templates-list"),
1300+
data,
1301+
format="json",
1302+
)
1303+
assert response.status_code == status.HTTP_400_BAD_REQUEST
1304+
assert (
1305+
"all three fields (html_body, text_body, raw_blob) must be provided together"
1306+
in str(response.data)
1307+
)
1308+
1309+
# Try to create with only raw_blob - should fail
1310+
data = {
1311+
"name": "Test Template",
1312+
"raw_blob": "raw content",
1313+
"type": "signature",
1314+
"mailbox_id": str(mailbox.id),
1315+
}
1316+
1317+
response = client.post(
1318+
reverse("message-templates-list"),
1319+
data,
1320+
format="json",
1321+
)
1322+
assert response.status_code == status.HTTP_400_BAD_REQUEST
1323+
assert (
1324+
"all three fields (html_body, text_body, raw_blob) must be provided together"
1325+
in str(response.data)
1326+
)
1327+
1328+
# Create with all three fields together - should succeed
1329+
data = {
1330+
"name": "Test Template",
1331+
"html_body": "<p>Content</p>",
1332+
"text_body": "Content",
1333+
"raw_blob": "raw content",
1334+
"type": "signature",
1335+
"mailbox_id": str(mailbox.id),
1336+
}
1337+
1338+
response = client.post(
1339+
reverse("message-templates-list"),
1340+
data,
1341+
format="json",
1342+
)
1343+
assert response.status_code == status.HTTP_201_CREATED
1344+
1345+
# Verify all fields were created
1346+
template = models.MessageTemplate.objects.get(name="Test Template")
1347+
assert template.html_body == "<p>Content</p>"
1348+
assert template.text_body == "Content"
1349+
assert template.raw_blob.get_content().decode("utf-8") == "raw content"

0 commit comments

Comments
 (0)