Skip to content

Commit 753a09a

Browse files
authored
Merge pull request #5952 from nyaruka/broadcast_quickreplies_update
Update broadcast views to support quick replies as structs
2 parents 197db00 + d26a3e7 commit 753a09a

File tree

6 files changed

+162
-41
lines changed

6 files changed

+162
-41
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Generated by Django 5.1.4 on 2025-03-21 15:34
2+
3+
from django.db import migrations
4+
5+
6+
def update_quick_replies(apps, schema_editor):
7+
Broadcast = apps.get_model("msgs", "Broadcast")
8+
9+
num_updated = 0
10+
11+
for broadcast in Broadcast.objects.filter(is_active=True).only("translations"):
12+
updated = False
13+
for trans in broadcast.translations.values():
14+
if "quick_replies" in trans:
15+
for i, qr in enumerate(trans["quick_replies"]):
16+
if isinstance(qr, str):
17+
trans["quick_replies"][i] = {"text": qr}
18+
updated = True
19+
if updated:
20+
broadcast.save(update_fields=("translations",))
21+
num_updated += 1
22+
23+
if num_updated % 1000 == 0: # pragma: no cover
24+
print(f"Updated {num_updated} broadcasts with quick replies")
25+
26+
27+
class Migration(migrations.Migration):
28+
29+
dependencies = [
30+
("msgs", "0285_delete_systemlabelcount"),
31+
]
32+
33+
operations = [
34+
migrations.RunPython(update_quick_replies, migrations.RunPython.noop),
35+
]

temba/msgs/models.py

+3
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,9 @@ def bulk_delete(cls, attachments):
412412
def as_json(self) -> dict:
413413
return {"content_type": self.content_type, "url": self.url}
414414

415+
def __str__(self) -> str:
416+
return f"{self.content_type}:{self.url}"
417+
415418

416419
@dataclass
417420
class QuickReply:

temba/msgs/tests/test_migrations.py

+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from temba.msgs.models import Broadcast
2+
from temba.tests import MigrationTest
3+
4+
5+
class UpdateQuickRepliesTest(MigrationTest):
6+
app = "msgs"
7+
migrate_from = "0285_delete_systemlabelcount"
8+
migrate_to = "0286_update_quick_replies"
9+
10+
def setUpBeforeMigration(self, apps):
11+
def create_broadcast(translations, is_active=True):
12+
return Broadcast.objects.create(org=self.org, translations=translations, is_active=is_active)
13+
14+
self.bcast1 = create_broadcast( # active broadcast with quick replies as strings
15+
{
16+
"eng": {"text": "Hi there", "quick_replies": ["yes", "no"]},
17+
"spa": {"text": "Hola", "quick_replies": ["si", "no"]},
18+
}
19+
)
20+
self.bcast2 = create_broadcast( # active broadcast with quick replies already as objects
21+
{"eng": {"text": "Hi there", "quick_replies": [{"text": "yes"}, {"text": "no"}]}}
22+
)
23+
self.bcast3 = create_broadcast({"eng": {"text": "Hi there"}}) # active broadcast with no quick replies
24+
self.bcast4 = create_broadcast( # inactive broadcast with quick replies as strings
25+
{"eng": {"text": "Hi there", "quick_replies": ["yes", "no"]}},
26+
is_active=False,
27+
)
28+
29+
def test_migration(self):
30+
def assert_translations(bcast, expected: dict):
31+
bcast.refresh_from_db()
32+
self.assertEqual(bcast.translations, expected)
33+
34+
assert_translations(
35+
self.bcast1,
36+
{
37+
"eng": {"text": "Hi there", "quick_replies": [{"text": "yes"}, {"text": "no"}]},
38+
"spa": {"text": "Hola", "quick_replies": [{"text": "si"}, {"text": "no"}]},
39+
},
40+
)
41+
assert_translations(
42+
self.bcast2,
43+
{"eng": {"text": "Hi there", "quick_replies": [{"text": "yes"}, {"text": "no"}]}}, # unchanged
44+
)
45+
assert_translations(self.bcast3, {"eng": {"text": "Hi there"}}) # unchanged
46+
assert_translations(self.bcast4, {"eng": {"text": "Hi there", "quick_replies": ["yes", "no"]}}) # unchanged

temba/utils/compose.py

+39-36
Original file line numberDiff line numberDiff line change
@@ -4,59 +4,62 @@
44
from temba.msgs.models import Attachment, Media, Q
55

66

7-
def compose_serialize(translation=None, json_encode=False, *, base_language=None, optin=None):
7+
def compose_serialize(translations, *, json_encode=False, base_language=None, optin=None) -> dict:
88
"""
9-
Serializes attachments from db to compose widget for populating initial widget values
9+
Serializes translations to format for compose widget
1010
"""
1111

12-
if not translation:
12+
if not translations:
1313
return {}
1414

15-
translation = copy.deepcopy(translation)
15+
translations = copy.deepcopy(translations)
1616

1717
if base_language and optin:
18-
translation[base_language]["optin"] = {"uuid": str(optin.uuid), "name": optin.name}
18+
translations[base_language]["optin"] = {"uuid": str(optin.uuid), "name": optin.name}
1919

20-
for details in translation.values():
21-
if "attachments" in details:
22-
details["attachments"] = compose_serialize_attachments(details["attachments"])
20+
for translation in translations.values():
21+
if "attachments" in translation:
22+
translation["attachments"] = compose_serialize_attachments(translation["attachments"])
2323

24-
if json_encode:
25-
return json.dumps(translation)
24+
# for now compose widget only supports simple text quick replies
25+
if "quick_replies" in translation:
26+
translation["quick_replies"] = [qr["text"] for qr in translation["quick_replies"]]
2627

27-
return translation
28+
return json.dumps(translations) if json_encode else translations
2829

2930

30-
def compose_serialize_attachments(attachments):
31-
if not attachments:
32-
return []
33-
parsed_attachments = Attachment.parse_all(attachments)
34-
serialized_attachments = []
35-
for parsed_attachment in parsed_attachments:
36-
media = Media.objects.filter(
37-
Q(content_type=parsed_attachment.content_type) and Q(url=parsed_attachment.url)
38-
).first()
39-
serialized_attachment = {
40-
"uuid": str(media.uuid),
41-
"content_type": media.content_type,
42-
"url": media.url,
43-
"filename": media.filename,
44-
"size": str(media.size),
45-
}
46-
serialized_attachments.append(serialized_attachment)
47-
return serialized_attachments
48-
49-
50-
def compose_deserialize(compose):
31+
def compose_serialize_attachments(attachments: list) -> list:
32+
serialized = []
33+
34+
for parsed in Attachment.parse_all(attachments):
35+
media = Media.objects.filter(Q(content_type=parsed.content_type) and Q(url=parsed.url)).first()
36+
serialized.append(
37+
{
38+
"uuid": str(media.uuid),
39+
"content_type": media.content_type,
40+
"url": media.url,
41+
"filename": media.filename,
42+
"size": str(media.size),
43+
}
44+
)
45+
return serialized
46+
47+
48+
def compose_deserialize(compose: dict) -> dict:
5149
"""
5250
Deserializes attachments from compose widget to db for saving final db values
5351
"""
54-
for details in compose.values():
55-
details["attachments"] = compose_deserialize_attachments(details.get("attachments", []))
52+
for translation in compose.values():
53+
translation["attachments"] = compose_deserialize_attachments(translation.get("attachments", []))
54+
55+
# for now compose widget only supports simple text quick replies
56+
if "quick_replies" in translation:
57+
translation["quick_replies"] = [{"text": qr} for qr in translation["quick_replies"]]
5658
return compose
5759

5860

59-
def compose_deserialize_attachments(attachments):
61+
def compose_deserialize_attachments(attachments: list) -> list:
6062
if not attachments:
6163
return []
62-
return [f"{a['content_type']}:{a['url']}" for a in attachments]
64+
65+
return [str(Attachment(a["content_type"], a["url"])) for a in attachments]

temba/utils/tests/test_compose.py

+37-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,41 @@
11
from temba.tests import TembaTest
2-
from temba.utils.compose import compose_serialize
2+
from temba.utils.compose import compose_deserialize, compose_serialize
33

44

55
class ComposeTest(TembaTest):
6-
def test_empty_compose(self):
7-
self.assertEqual(compose_serialize(), {})
6+
def test_serialize(self):
7+
self.assertEqual(compose_serialize(None), {})
8+
self.assertEqual(compose_serialize({"eng": {"text": "Hello"}}), {"eng": {"text": "Hello"}})
9+
self.assertEqual(
10+
compose_serialize({"eng": {"text": "Hello", "quick_replies": [{"text": "Yes"}, {"text": "No"}]}}),
11+
{"eng": {"text": "Hello", "quick_replies": ["Yes", "No"]}},
12+
)
13+
14+
def test_deserialize(self):
15+
self.assertEqual(compose_deserialize({"eng": {"text": "Hello"}}), {"eng": {"text": "Hello", "attachments": []}})
16+
self.assertEqual(
17+
compose_deserialize(
18+
{
19+
"eng": {
20+
"text": "Hello",
21+
"attachments": [
22+
{
23+
"uuid": "8a798c81-c890-4fe5-b9c7-617c06096b94",
24+
"content_type": "image/jpeg",
25+
"url": "https://example.com/image.jpg",
26+
"filename": "image.jpg",
27+
"size": "12345",
28+
}
29+
],
30+
"quick_replies": ["Yes", "No"],
31+
}
32+
}
33+
),
34+
{
35+
"eng": {
36+
"text": "Hello",
37+
"attachments": ["image/jpeg:https://example.com/image.jpg"],
38+
"quick_replies": [{"text": "Yes"}, {"text": "No"}],
39+
}
40+
},
41+
)

templates/msgs/includes/broadcast.html

+2-2
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@
2424
<div class="text">
2525
{{ translation.text }}
2626
<br />
27-
{% for reply in translation.quick_replies %}
27+
{% for qr in translation.quick_replies %}
2828
<temba-label class="mr-2 mt-2" backgroundcolor="#fafafa">
29-
{{ reply }}
29+
{{ qr.text }}
3030
</temba-label>
3131
{% endfor %}
3232
{% if broadcast.optin or broadcast.template %}

0 commit comments

Comments
 (0)