diff --git a/home/export_content_pages.py b/home/export_content_pages.py index 3c9d552b0..3ada87ec9 100644 --- a/home/export_content_pages.py +++ b/home/export_content_pages.py @@ -54,6 +54,7 @@ class ExportRow: web_body: str = "" whatsapp_title: str = "" whatsapp_body: str = "" + whatsapp_message_title: str = "" whatsapp_template_slug: str = "" variation_title: str = "" variation_body: str = "" @@ -126,6 +127,8 @@ def add_message_fields(self, msg_blocks: MsgBlocks) -> None: self.whatsapp_template_slug = whatsapp.value.slug else: self.whatsapp_body = whatsapp.value["message"].strip() + # Per-message WhatsApp title (not the page-level whatsapp_title) + self.whatsapp_message_title = whatsapp.value.get("title", "") if "image" in whatsapp.value and whatsapp.value["image"] is not None: self.image_link = whatsapp.value["image"].file.url if ( @@ -344,6 +347,7 @@ def _set_xlsx_styles(wb: Workbook, sheet: Worksheet) -> None: "web_body": 370, "whatsapp_title": 118, "whatsapp_body": 370, + "whatsapp_message_title": 118, "whatsapp_template_slug": 118, "variation_title": 118, "variation_body": 370, diff --git a/home/import_content_pages.py b/home/import_content_pages.py index a18a33d0c..ee7830706 100644 --- a/home/import_content_pages.py +++ b/home/import_content_pages.py @@ -459,6 +459,7 @@ def add_message_to_shadow_content_page_from_row( page.whatsapp_body.append( ShadowWhatsappBlock( message=row.whatsapp_body, + title=row.whatsapp_message_title, next_prompt=row.next_prompt, example_values=row.example_values, buttons=buttons, @@ -685,6 +686,7 @@ def formatted_viber_body(self) -> list[StructValue]: @dataclass(slots=True) class ShadowWhatsappBlock: message: str = "" + title: str = "" next_prompt: str = "" buttons: list[dict[str, Any]] = field(default_factory=list) example_values: list[str] = field(default_factory=list) @@ -699,6 +701,7 @@ def wagtail_format( ) -> dict[str, str | list[dict[str, str | list[dict[str, str]]]] | list[str]]: return { "message": self.message, + "title": self.title, "next_prompt": self.next_prompt, "example_values": self.example_values, "buttons": self.buttons, @@ -782,6 +785,7 @@ class ContentRow: web_body: str = "" whatsapp_title: str = "" whatsapp_body: str = "" + whatsapp_message_title: str = "" whatsapp_template_slug: str = "" example_values: list[str] = field(default_factory=list) variation_title: dict[str, str] = field(default_factory=dict) diff --git a/home/migrations/0105_alter_contentpage_whatsapp_body.py b/home/migrations/0105_alter_contentpage_whatsapp_body.py new file mode 100644 index 000000000..6e2f7e946 --- /dev/null +++ b/home/migrations/0105_alter_contentpage_whatsapp_body.py @@ -0,0 +1,319 @@ +# Generated by Django 4.2.17 on 2025-09-01 12:19 + +import django.core.validators +from django.db import migrations +import home.models +import wagtail.blocks +import wagtail.documents.blocks +import wagtail.fields +import wagtail.images.blocks +import wagtail.snippets.blocks + + +class Migration(migrations.Migration): + + dependencies = [ + ("home", "0104_remove_whatsapptemplate_unique_name_locale_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="contentpage", + name="whatsapp_body", + field=wagtail.fields.StreamField( + [ + ( + "Whatsapp_Message", + wagtail.blocks.StructBlock( + [ + ( + "image", + wagtail.images.blocks.ImageChooserBlock( + required=False + ), + ), + ( + "document", + wagtail.documents.blocks.DocumentChooserBlock( + icon="document", required=False + ), + ), + ( + "media", + home.models.MediaBlock( + icon="media", required=False + ), + ), + ( + "title", + wagtail.blocks.CharBlock( + default="", + help_text="Short heading shown above the message body, up to 60 characters.", + max_length=60, + required=False, + ), + ), + ( + "message", + wagtail.blocks.TextBlock( + help_text="each text message cannot exceed 4096 characters, messages with media cannot exceed 1024 characters.", + validators=( + django.core.validators.MaxLengthValidator( + 4096 + ), + ), + ), + ), + ( + "variation_messages", + wagtail.blocks.ListBlock( + wagtail.blocks.StructBlock( + [ + ( + "variation_restrictions", + wagtail.blocks.StreamBlock( + [ + ( + "gender", + wagtail.blocks.ChoiceBlock( + choices=home.models.get_gender_choices + ), + ), + ( + "age", + wagtail.blocks.ChoiceBlock( + choices=home.models.get_age_choices + ), + ), + ( + "relationship", + wagtail.blocks.ChoiceBlock( + choices=home.models.get_relationship_choices + ), + ), + ], + help_text="Restrict this variation to users with this profile value. Valid values must be added to the Site Settings", + max_num=1, + min_num=1, + required=False, + use_json_field=True, + ), + ), + ( + "message", + wagtail.blocks.TextBlock( + help_text="each message cannot exceed 4096 characters.", + validators=( + django.core.validators.MaxLengthValidator( + 4096 + ), + ), + ), + ), + ] + ), + default=[], + ), + ), + ( + "next_prompt", + wagtail.blocks.CharBlock( + help_text="prompt text for next message", + required=False, + validators=( + django.core.validators.MaxLengthValidator( + 20 + ), + ), + ), + ), + ( + "buttons", + wagtail.blocks.StreamBlock( + [ + ( + "next_message", + wagtail.blocks.StructBlock( + [ + ( + "title", + wagtail.blocks.CharBlock( + help_text="Text for the button, up to 20 characters.", + validators=( + django.core.validators.MaxLengthValidator( + 20 + ), + ), + ), + ) + ] + ), + ), + ( + "go_to_page", + wagtail.blocks.StructBlock( + [ + ( + "title", + wagtail.blocks.CharBlock( + help_text="Text for the button, up to 20 characters.", + validators=( + django.core.validators.MaxLengthValidator( + 20 + ), + ), + ), + ), + ( + "page", + wagtail.blocks.PageChooserBlock( + help_text="Page the button should go to" + ), + ), + ] + ), + ), + ( + "go_to_form", + wagtail.blocks.StructBlock( + [ + ( + "title", + wagtail.blocks.CharBlock( + help_text="Text for the button, up to 20 characters.", + validators=( + django.core.validators.MaxLengthValidator( + 20 + ), + ), + ), + ), + ( + "form", + wagtail.snippets.blocks.SnippetChooserBlock( + "home.Assessment", + help_text="Form the button should start", + ), + ), + ] + ), + ), + ], + max_num=3, + required=False, + ), + ), + ( + "list_title", + wagtail.blocks.CharBlock( + help_text="List title, up to 24 characters.", + max_length=24, + required=False, + ), + ), + ( + "list_items", + wagtail.blocks.StreamBlock( + [ + ( + "next_message", + wagtail.blocks.StructBlock( + [ + ( + "title", + wagtail.blocks.CharBlock( + help_text="Text for the list item, up to 24 characters.", + validators=( + django.core.validators.MaxLengthValidator( + 24 + ), + ), + ), + ) + ] + ), + ), + ( + "go_to_page", + wagtail.blocks.StructBlock( + [ + ( + "title", + wagtail.blocks.CharBlock( + help_text="Text for the list item, up to 24 characters.", + validators=( + django.core.validators.MaxLengthValidator( + 24 + ), + ), + ), + ), + ( + "page", + wagtail.blocks.PageChooserBlock( + help_text="Page the list item should go to" + ), + ), + ] + ), + ), + ( + "go_to_form", + wagtail.blocks.StructBlock( + [ + ( + "title", + wagtail.blocks.CharBlock( + help_text="Text for the list item, up to 24 characters.", + validators=( + django.core.validators.MaxLengthValidator( + 24 + ), + ), + ), + ), + ( + "form", + wagtail.snippets.blocks.SnippetChooserBlock( + "home.Assessment", + help_text="Form the list item should start", + ), + ), + ] + ), + ), + ], + help_text="Items to appear in the list message", + max_num=10, + required=False, + ), + ), + ( + "footer", + wagtail.blocks.CharBlock( + help_text="Footer cannot exceed 60 characters.", + required=False, + validators=( + django.core.validators.MaxLengthValidator( + 60 + ), + ), + ), + ), + ], + help_text="Each message will be sent with the text and media", + ), + ), + ( + "Whatsapp_Template", + wagtail.snippets.blocks.SnippetChooserBlock( + "home.WhatsAppTemplate", help_text="WhatsAppTemplate to use" + ), + ), + ], + blank=True, + null=True, + use_json_field=True, + ), + ), + ] diff --git a/home/models.py b/home/models.py index 6f19a9e82..36bab1328 100644 --- a/home/models.py +++ b/home/models.py @@ -290,6 +290,12 @@ class WhatsappBlock(blocks.StructBlock): image = ImageChooserBlock(required=False) document = DocumentChooserBlock(icon="document", required=False) media = MediaBlock(icon="media", required=False) + title = blocks.CharBlock( + required=False, + help_text="Short heading shown above the message body, up to 60 characters.", + max_length=60, + default="", + ) message = blocks.TextBlock( help_text="each text message cannot exceed 4096 characters, messages with " "media cannot exceed 1024 characters.", @@ -1625,6 +1631,12 @@ def __str__(self) -> str: """String repr of this snippet.""" return self.slug + # Provide a minimal snippet_viewset interface for chooser widgets when the + # snippet isn't registered in the admin (feature-flagged). Wagtail's + # SnippetChooserBlock accesses `target_model.snippet_viewset.icon`. + class snippet_viewset: + icon = "order" + def clean(self) -> None: result = super().clean() errors: dict[str, list[ValidationError]] = {} diff --git a/home/serializers.py b/home/serializers.py index dcd2051ad..956eee976 100644 --- a/home/serializers.py +++ b/home/serializers.py @@ -125,6 +125,7 @@ def format_whatsapp_template_message(message_index, content_page) -> dict[str, A # to retain the v2 api behaviour. We also return some fields as null or empty values, for the same reason "type": "Whatsapp_Message", "value": { + "title": "", "image": wa_template.image.id if wa_template.image else "", "media": None, "footer": "", @@ -145,6 +146,8 @@ def format_whatsapp_message(message_index, content_page, platform): text = content_page.whatsapp_body._raw_data[message_index] if str(text["type"]) == "Whatsapp_Message": + # Ensure the new title field always exists in API output + text["value"].setdefault("title", "") # Flattens the variation_messages field in the whatsapp message variation_messages = text["value"].get("variation_messages", []) new_var_messages = [] diff --git a/home/tests/page_builder.py b/home/tests/page_builder.py index 6d4547663..4cbe753dc 100644 --- a/home/tests/page_builder.py +++ b/home/tests/page_builder.py @@ -169,6 +169,7 @@ class WABlk(ContentBlock): BLOCK_TYPE = WhatsappBlock # TODO: More body things. + title: str = "" next_prompt: str | None = None variation_messages: list[VarMsg] = field(default_factory=list) buttons: list[Btn] = field(default_factory=list) diff --git a/home/tests/test_api.py b/home/tests/test_api.py index 92c41fd01..7ea0ccd69 100644 --- a/home/tests/test_api.py +++ b/home/tests/test_api.py @@ -655,6 +655,7 @@ def test_detail_view_whatsapp_message(self, uclient: Any) -> None: assert body["total_messages"] == 1 assert body["text"]["type"] == "Whatsapp_Message" assert body["text"]["value"]["message"] == "*Default whatsapp Content 1* 🏥" + assert body["text"]["value"].get("title", "") == "" @pytest.mark.parametrize("platform", ALL_PLATFORMS) def test_detail_view_platform_enabled_no_message(self, uclient, platform): @@ -1221,6 +1222,7 @@ def test_empty_whatsapp(self, uclient: Any) -> None: whatsapp_value = content["body"]["text"]["value"] assert whatsapp_value == { + "title": "", "image": None, "media": None, "footer": "", diff --git a/home/tests/test_content_import_export.py b/home/tests/test_content_import_export.py index c4ca2e172..a1023abc4 100644 --- a/home/tests/test_content_import_export.py +++ b/home/tests/test_content_import_export.py @@ -85,6 +85,7 @@ def add_new_fields(entry: ExpDict) -> ExpDict: return { "whatsapp_template_slug": "", **entry, + "whatsapp_message_title": entry.get("whatsapp_message_title") or "", "whatsapp_template_category": entry.get("whatsapp_template_category") or "UTILITY", "example_values": entry.get("example_values") or "[]", diff --git a/home/tests/test_page_builder.py b/home/tests/test_page_builder.py index 10b855644..2cb263613 100644 --- a/home/tests/test_page_builder.py +++ b/home/tests/test_page_builder.py @@ -109,6 +109,7 @@ def test_build_simple_pages() -> None: ( "Whatsapp_Message", { + "title": "", "document": None, "image": None, "list_title": "", @@ -289,6 +290,7 @@ def test_build_variations() -> None: ( "Whatsapp_Message", { + "title": "", "document": None, "image": None, "list_title": "", diff --git a/home/tests/test_serializers.py b/home/tests/test_serializers.py index 9d022c354..2c8cf7e51 100644 --- a/home/tests/test_serializers.py +++ b/home/tests/test_serializers.py @@ -468,6 +468,7 @@ def test_happy_case_single_variation_message(self) -> None: expected_result = { "type": "Whatsapp_Message", "value": { + "title": "", "image": None, "document": None, "media": None, @@ -499,6 +500,7 @@ def test_happy_case_no_variation_messages(self) -> None: expected_result = { "type": "Whatsapp_Message", "value": { + "title": "", "image": None, "document": None, "media": None,