diff --git a/bedrock/anonym/blocks.py b/bedrock/anonym/blocks.py index d89f5f616d5..ba71adde5ad 100644 --- a/bedrock/anonym/blocks.py +++ b/bedrock/anonym/blocks.py @@ -12,6 +12,8 @@ from wagtail_link_block.blocks import LinkBlock from wagtail_thumbnail_choice_block import ThumbnailChoiceBlock +from bedrock.cms.blocks import UUIDBlock + BASIC_TEXT_FEATURES = [ "bold", "italic", @@ -401,7 +403,23 @@ class Meta: icon = "doc-full" +class AnalyticsSettings(blocks.StructBlock): + analytics_id = UUIDBlock( + label="Analytics ID", + help_text="Unique identifier for analytics tracking. Leave blank to auto-generate.", + required=False, + ) + + class Meta: + icon = "cog" + collapsed = True + label = "Settings" + label_format = "Analytics ID: {analytics_id}" + form_classname = "compact-form struct-block" + + class LinkWithTextBlock(blocks.StructBlock): + settings = AnalyticsSettings() label = blocks.CharBlock(label="Link Text") link = LinkBlock() @@ -456,6 +474,19 @@ class Meta: icon = "doc-full" +class CaseStudyItemWithAnalyticsBlock(blocks.StructBlock): + page = PageChooserBlock("anonym.AnonymCaseStudyItemPage") + analytics_id = UUIDBlock( + label="Analytics ID", + help_text="Unique identifier for analytics tracking. Leave blank to auto-generate.", + required=False, + ) + + class Meta: + label = "Case Study" + label_format = "Case Study - {page}" + + class CaseStudyListBlock(blocks.StructBlock): """ Display a list of case study items with their logos, client names, and descriptions. @@ -465,7 +496,7 @@ class CaseStudyListBlock(blocks.StructBlock): """ case_study_items = blocks.ListBlock( - PageChooserBlock("anonym.AnonymCaseStudyItemPage"), + CaseStudyItemWithAnalyticsBlock(), min_num=1, max_num=3, default=[], diff --git a/bedrock/anonym/management/commands/populate_case_study_analytics_ids.py b/bedrock/anonym/management/commands/populate_case_study_analytics_ids.py new file mode 100644 index 00000000000..e88602a8231 --- /dev/null +++ b/bedrock/anonym/management/commands/populate_case_study_analytics_ids.py @@ -0,0 +1,45 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import copy +from uuid import uuid4 + +from django.core.management.base import BaseCommand + +from bedrock.anonym.models import AnonymContentSubPage, AnonymIndexPage + + +class Command(BaseCommand): + help = "Restructures CaseStudyListBlock items from int PKs to {page, analytics_id} structs (idempotent)" + + def handle(self, *args, **options): + page_models = [AnonymIndexPage, AnonymContentSubPage] + total = 0 + for Model in page_models: + for page in Model.objects.all(): + if not page.content: + continue + content = copy.deepcopy(list(page.content.raw_data)) + changed = False + for block in content: + if block.get("type") != "section": + continue + for inner_block in block.get("value", {}).get("section_content", []): + if inner_block.get("type") != "case_study_item_list_block": + continue + for item in inner_block.get("value", {}).get("case_study_items", []): + item_val = item.get("value") + if isinstance(item_val, int): + # If the item is a bare PK, set a new object as the value + item["value"] = {"page": item_val, "analytics_id": str(uuid4())} + changed = True + elif isinstance(item_val, dict) and not item_val.get("analytics_id"): + # If the item has an empty analytics_id, fill it in + item_val["analytics_id"] = str(uuid4()) + changed = True + if changed: + page.content = content + page.save(update_fields=["content"]) + total += 1 + self.stdout.write(self.style.SUCCESS(f"Updated {total} page(s).")) diff --git a/bedrock/anonym/management/commands/populate_link_block_analytics_ids.py b/bedrock/anonym/management/commands/populate_link_block_analytics_ids.py new file mode 100644 index 00000000000..e83467ad27a --- /dev/null +++ b/bedrock/anonym/management/commands/populate_link_block_analytics_ids.py @@ -0,0 +1,56 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import copy +from uuid import uuid4 + +from django.core.management.base import BaseCommand + +from bedrock.anonym.models import ( + AnonymCaseStudyItemPage, + AnonymContentSubPage, + AnonymIndexPage, + AnonymNewsItemPage, +) + + +class Command(BaseCommand): + help = "Injects settings.analytics_id into LinkWithTextBlock instances that are missing it (idempotent)" + + def handle(self, *args, **options): + page_models = [ + AnonymIndexPage, + AnonymContentSubPage, + AnonymNewsItemPage, + AnonymCaseStudyItemPage, + ] + total = 0 + for Model in page_models: + for page in Model.objects.all(): + if not page.content: + continue + content = copy.deepcopy(list(page.content.raw_data)) + changed = False + for block in content: + block_type = block.get("type") + value = block.get("value", {}) + if block_type == "section": + for link_item in value.get("action", []): + link_val = link_item.get("value", link_item) + settings = link_val.setdefault("settings", {}) + if not settings.get("analytics_id"): + settings["analytics_id"] = str(uuid4()) + changed = True + elif block_type == "call_to_action": + for link_item in value.get("button", []): + link_val = link_item.get("value", link_item) + settings = link_val.setdefault("settings", {}) + if not settings.get("analytics_id"): + settings["analytics_id"] = str(uuid4()) + changed = True + if changed: + page.content = content + page.save(update_fields=["content"]) + total += 1 + self.stdout.write(self.style.SUCCESS(f"Updated {total} page(s).")) diff --git a/bedrock/anonym/management/commands/populate_nav_button_analytics_ids.py b/bedrock/anonym/management/commands/populate_nav_button_analytics_ids.py new file mode 100644 index 00000000000..dd09f878586 --- /dev/null +++ b/bedrock/anonym/management/commands/populate_nav_button_analytics_ids.py @@ -0,0 +1,32 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import copy +from uuid import uuid4 + +from django.core.management.base import BaseCommand + +from bedrock.anonym.models import AnonymIndexPage + + +class Command(BaseCommand): + help = "Injects analytics_id into NavigationLinkBlock items with has_button_appearance=True that are missing it (idempotent)" + + def handle(self, *args, **options): + total = 0 + for page in AnonymIndexPage.objects.all(): + if not page.navigation: + continue + navigation = copy.deepcopy(list(page.navigation.raw_data)) + changed = False + for item in navigation: + value = item.get("value", {}) + if value.get("has_button_appearance") and not value.get("analytics_id"): + value["analytics_id"] = str(uuid4()) + changed = True + if changed: + page.navigation = navigation + page.save(update_fields=["navigation"]) + total += 1 + self.stdout.write(self.style.SUCCESS(f"Updated {total} page(s).")) diff --git a/bedrock/anonym/migrations/0013_alter_anonymcasestudyitempage_content_and_more.py b/bedrock/anonym/migrations/0013_alter_anonymcasestudyitempage_content_and_more.py new file mode 100644 index 00000000000..d92d550aef7 --- /dev/null +++ b/bedrock/anonym/migrations/0013_alter_anonymcasestudyitempage_content_and_more.py @@ -0,0 +1,1664 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. + +# Generated by Django 5.2.12 on 2026-04-16 19:30 + +from django.db import migrations + +import wagtail.admin.forms.choosers +import wagtail.fields + + +class Migration(migrations.Migration): + dependencies = [ + ("anonym", "0012_alter_anonymcasestudyitempage_content_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="anonymcasestudyitempage", + name="content", + field=wagtail.fields.StreamField( + [("intro_text", 1), ("rich_text", 3), ("blockquote", 5), ("figure", 10), ("call_to_action", 28)], + blank=True, + block_lookup={ + 0: ("wagtail.blocks.RichTextBlock", (), {"features": ["bold", "italic", "link", "superscript", "subscript", "strikethrough"]}), + 1: ("wagtail.blocks.StructBlock", [[("text", 0)]], {}), + 2: ( + "wagtail.blocks.RichTextBlock", + (), + { + "features": [ + "h2", + "h3", + "h4", + "h5", + "bold", + "italic", + "underline", + "ol", + "ul", + "hr", + "link", + "image", + "embed", + "superscript", + "subscript", + "strikethrough", + "blockquote", + ] + }, + ), + 3: ("wagtail.blocks.StructBlock", [[("text", 2)]], {}), + 4: ("wagtail.blocks.CharBlock", (), {"label": "Author", "required": False}), + 5: ("wagtail.blocks.StructBlock", [[("text", 0), ("author", 4)]], {}), + 6: ( + "wagtail.blocks.BooleanBlock", + (), + { + "default": False, + "help_text": "The Default width is constrained to the layout grid with a max-width, centered on the page.", + "inline_form": True, + "label": "Make Full Width", + "required": False, + }, + ), + 7: ("wagtail.blocks.StructBlock", [[("make_full_width", 6)]], {}), + 8: ("wagtail.images.blocks.ImageChooserBlock", (), {}), + 9: ( + "wagtail.blocks.RichTextBlock", + (), + {"features": ["bold", "italic", "link", "superscript", "subscript", "strikethrough"], "label": "Caption", "required": False}, + ), + 10: ("wagtail.blocks.StructBlock", [[("settings", 7), ("image", 8), ("caption", 9)]], {}), + 11: ( + "wagtail.blocks.CharBlock", + (), + {"help_text": "Optional: Add an ID to make this section linkable from navigation", "max_length": 100, "required": False}, + ), + 12: ("wagtail.blocks.StructBlock", [[("anchor_id", 11)]], {}), + 13: ( + "wagtail.blocks.RichTextBlock", + (), + { + "features": ["bold", "italic", "link", "superscript", "subscript", "strikethrough"], + "help_text": "Use bold to make parts of this text black.", + }, + ), + 14: ( + "bedrock.cms.blocks.UUIDBlock", + (), + { + "help_text": "Unique identifier for analytics tracking. Leave blank to auto-generate.", + "label": "Analytics ID", + "required": False, + }, + ), + 15: ("wagtail.blocks.StructBlock", [[("analytics_id", 14)]], {}), + 16: ("wagtail.blocks.CharBlock", (), {"label": "Link Text"}), + 17: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": [ + ("page", "Page"), + ("file", "File"), + ("custom_url", "Custom URL"), + ("email", "Email"), + ("anchor", "Anchor"), + ("phone", "Phone"), + ], + "classname": "link_choice_type_selector", + "label": "Link to", + "required": False, + }, + ), + 18: ("wagtail.blocks.PageChooserBlock", (), {"form_classname": "page_link", "label": "Page", "required": False}), + 19: ("wagtail.documents.blocks.DocumentChooserBlock", (), {"form_classname": "file_link", "label": "File", "required": False}), + 20: ( + "wagtail.blocks.CharBlock", + (), + { + "form_classname": "custom_url_link url_field", + "label": "Custom URL", + "max_length": 300, + "required": False, + "validators": [wagtail.admin.forms.choosers.URLOrAbsolutePathValidator()], + }, + ), + 21: ("wagtail.blocks.CharBlock", (), {"form_classname": "anchor_link", "label": "#", "max_length": 300, "required": False}), + 22: ("wagtail.blocks.EmailBlock", (), {"required": False}), + 23: ("wagtail.blocks.CharBlock", (), {"form_classname": "phone_link", "label": "Phone", "max_length": 30, "required": False}), + 24: ( + "wagtail.blocks.BooleanBlock", + (), + {"form_classname": "new_window_toggle", "label": "Open in new window", "required": False}, + ), + 25: ( + "wagtail.blocks.StructBlock", + [ + [ + ("link_to", 17), + ("page", 18), + ("file", 19), + ("custom_url", 20), + ("anchor", 21), + ("email", 22), + ("phone", 23), + ("new_window", 24), + ] + ], + {}, + ), + 26: ("wagtail.blocks.StructBlock", [[("settings", 15), ("label", 16), ("link", 25)]], {}), + 27: ("wagtail.blocks.ListBlock", (26,), {"default": [], "max_num": 1, "min_num": 0}), + 28: ("wagtail.blocks.StructBlock", [[("settings", 12), ("heading", 13), ("button", 27)]], {}), + }, + null=True, + ), + ), + migrations.AlterField( + model_name="anonymcontentsubpage", + name="content", + field=wagtail.fields.StreamField( + [("section", 55), ("competitor_table", 62), ("toggle_items", 71), ("call_to_action", 73)], + blank=True, + block_lookup={ + 0: ( + "wagtail.blocks.CharBlock", + (), + { + "help_text": "Optional: Add an ID to make this section linkable from navigation (e.g., 'overview', 'features')", + "max_length": 100, + "required": False, + }, + ), + 1: ( + "wagtail.blocks.ChoiceBlock", + [], + {"choices": [("", "---"), ("index", "Index"), ("top_glow", "Top Glow")], "inline_form": True, "required": False}, + ), + 2: ("wagtail.blocks.StructBlock", [[("anchor_id", 0), ("theme", 1)]], {}), + 3: ( + "wagtail.blocks.RichTextBlock", + (), + {"features": ["bold", "italic", "link", "superscript", "subscript", "strikethrough"], "required": False}, + ), + 4: ( + "wagtail.blocks.RichTextBlock", + (), + { + "features": ["bold", "italic", "link", "superscript", "subscript", "strikethrough"], + "help_text": "Use Bold to make parts of this text black.", + }, + ), + 5: ( + "wagtail.blocks.BooleanBlock", + (), + { + "default": False, + "help_text": "The Default width is constrained to the layout grid with a max-width, centered on the page.", + "inline_form": True, + "label": "Make Full Width", + "required": False, + }, + ), + 6: ("wagtail.blocks.StructBlock", [[("make_full_width", 5)]], {}), + 7: ("wagtail.images.blocks.ImageChooserBlock", (), {}), + 8: ("wagtail.blocks.StructBlock", [[("settings", 6), ("image", 7)]], {}), + 9: ("wagtail.blocks.BooleanBlock", (), {"default": False, "help_text": "The default behavior is stacked", "required": False}), + 10: ( + "wagtail.blocks.BooleanBlock", + (), + {"default": False, "help_text": "Add divider lines between cards on desktop", "required": False}, + ), + 11: ("wagtail.blocks.StructBlock", [[("scrollable_on_mobile", 9), ("dividers_between_cards_on_desktop", 10)]], {}), + 12: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": [ + ("", "---"), + ("IRL", "IRL"), + ("accessibility", "Accessibility"), + ("accounts", "Accounts"), + ("add-search-engine", "Add Search Engine"), + ("ai", "AI"), + ("alert", "Alert"), + ("arrow-down", "Arrow Down"), + ("arrow-left-white", "Arrow Left White"), + ("arrow-left", "Arrow Left"), + ("arrow-right-white", "Arrow Right White"), + ("arrow-right", "Arrow Right"), + ("arrow-up", "Arrow Up"), + ("audio-card", "Audio Card"), + ("audio-mute", "Audio Mute"), + ("audio", "Audio"), + ("auto-play-block", "Auto Play Block"), + ("back", "Back"), + ("bell", "Bell"), + ("beta", "Beta"), + ("blog", "Blog"), + ("bookmark-menu", "Bookmark Menu"), + ("bookmark-narrow", "Bookmark Narrow"), + ("bookmark-remove", "Bookmark Remove"), + ("bookmark", "Bookmark"), + ("brightness", "Brightness"), + ("browser", "Browser"), + ("calendar", "Calendar"), + ("careers", "Careers"), + ("caret-down-white", "Caret Down White"), + ("caret-down", "Caret Down"), + ("caret-up", "Caret Up"), + ("chat", "Chat"), + ("check", "Check"), + ("close-white", "Close White"), + ("close", "Close"), + ("cloud", "Cloud"), + ("command-console", "Command Console"), + ("command-noautohide", "Command Noautohide"), + ("common-voice", "Common Voice"), + ("copy", "Copy"), + ("current-view", "Current View"), + ("customize", "Customize"), + ("cut", "Cut"), + ("dashboard", "Dashboard"), + ("data-collection", "Data Collection"), + ("data-insights", "Data Insights"), + ("data-pie", "Data Pie"), + ("default-browser", "Default Browser"), + ("delete", "Delete"), + ("desktop", "Desktop"), + ("dev-edition", "Dev Edition"), + ("developer-innovations", "Developer Innovations"), + ("developer", "Developer"), + ("dictionaries", "Dictionaries"), + ("dock-bottom", "Dock Bottom"), + ("dock-left", "Dock Left"), + ("dock-right", "Dock Right"), + ("dock-undock", "Dock Undock"), + ("download-white", "Download White"), + ("download", "Download"), + ("earth", "Earth"), + ("edit-write", "Edit Write"), + ("email", "Email"), + ("enterprise", "Enterprise"), + ("event", "Event"), + ("expand-white", "Expand White"), + ("expand", "Expand"), + ("experiments", "Experiments"), + ("extension-available-update", "Extension Available Update"), + ("extension-recent-updates", "Extension Recent Updates"), + ("extensions-legacy", "Extensions Legacy"), + ("extensions", "Extensions"), + ("external-link-white", "External Link White"), + ("external-link", "External Link"), + ("eye-closed", "Eye Closed"), + ("eye-open", "Eye Open"), + ("facebook-container", "Facebook Container"), + ("features", "Features"), + ("feeback", "Feeback"), + ("file-code", "File Code"), + ("file-image", "File Image"), + ("file-lock", "File Lock"), + ("file-music", "File Music"), + ("file-text", "File Text"), + ("file", "File"), + ("fire-tv", "Fire TV"), + ("firefox-reality", "Firefox Reality"), + ("folder-open", "Folder Open"), + ("folder-plus", "Folder Plus"), + ("folder-save", "Folder Save"), + ("folder", "Folder"), + ("font", "Font"), + ("forget", "Forget"), + ("forward", "Forward"), + ("full-screen-disabled", "Full Screen Disabled"), + ("full-screen-exit", "Full Screen Exit"), + ("full-screen", "Full Screen"), + ("gear", "Gear"), + ("get-involved", "Get Involved"), + ("globe-white", "Globe White"), + ("globe", "Globe"), + ("hashtag-narrow", "Hashtag Narrow"), + ("hashtag", "Hashtag"), + ("headphone", "Headphone"), + ("heart", "Heart"), + ("heart-rate", "Heart Rate"), + ("heart-white", "Heart White"), + ("help", "Help"), + ("highlight", "Highlight"), + ("history", "History"), + ("home", "Home"), + ("hubs", "Hubs"), + ("identity-notification", "Identity Notification"), + ("identity", "Identity"), + ("image", "Image"), + ("import", "Import"), + ("inbox", "Inbox"), + ("info", "Info"), + ("labs", "Labs"), + ("language", "Language"), + ("library", "Library"), + ("layer", "Layer"), + ("link", "Link"), + ("listen", "Listen"), + ("lite", "Lite"), + ("location-disabled", "Location Disabled"), + ("location-macos-disabled", "Location Macos Disabled"), + ("location-macos", "Location Macos"), + ("location-pin", "Location Pin"), + ("location-windows-disabled", "Location Windows Disabled"), + ("location-windows", "Location Windows"), + ("location", "Location"), + ("lock", "Lock"), + ("lockbox", "Lockbox"), + ("login", "Login"), + ("mail", "Mail"), + ("maximize", "Maximize"), + ("megaphone", "Megaphone"), + ("menu-white", "Menu White"), + ("menu", "Menu"), + ("microphone-disabled", "Microphone Disabled"), + ("microphone", "Microphone"), + ("midi", "Midi"), + ("minimize", "Minimize"), + ("minus", "Minus"), + ("mobile-narrow", "Mobile Narrow"), + ("mobile", "Mobile"), + ("monitor", "Monitor"), + ("more-horizontal", "More Horizontal"), + ("more-vertical", "More Vertical"), + ("mountain", "Mountain"), + ("mouse-pointer-disabled", "Mouse Pointer Disabled"), + ("mouse-pointer", "Mouse Pointer"), + ("mozilla", "Mozilla"), + ("new", "New"), + ("nightly", "Nightly"), + ("notes", "Notes"), + ("notifications-disabled", "Notifications Disabled"), + ("notifications", "Notifications"), + ("open-in-new", "Open In New"), + ("open", "Open"), + ("opensource", "Opensource"), + ("overflow", "Overflow"), + ("paperclip-narrow", "Paperclip Narrow"), + ("paperclip", "Paperclip"), + ("paste", "Paste"), + ("pause-white", "Pause White"), + ("pause", "Pause"), + ("performance", "Performance"), + ("photon", "Photon"), + ("pin-remove", "Pin Remove"), + ("pin", "Pin"), + ("play-white", "Play White"), + ("play", "Play"), + ("plugin-disabled", "Plugin Disabled"), + ("plugin", "Plugin"), + ("plus", "Plus"), + ("pocket-list", "Pocket List"), + ("pocket-remove", "Pocket Remove"), + ("pocket", "Pocket"), + ("popular", "Popular"), + ("popup-block", "Popup Block"), + ("preferences", "Preferences"), + ("pricetag", "Pricetag"), + ("pricetag-white", "Pricetag White"), + ("printer", "Printer"), + ("privacy", "Privacy"), + ("private-browsing", "Private Browsing"), + ("protocol", "Protocol"), + ("proton", "Proton"), + ("query", "Query"), + ("quit", "Quit"), + ("quote", "Quote"), + ("read", "Read"), + ("reader-mode", "Reader Mode"), + ("redo", "Redo"), + ("refresh", "Refresh"), + ("release-notes", "Release Notes"), + ("reminders", "Reminders"), + ("report-narrow", "Report Narrow"), + ("report", "Report"), + ("resources", "Resources"), + ("restore-session", "Restore Session"), + ("rhombus-layers", "Rhombus Layers"), + ("rhombus-layers-white", "Rhombus Layers White"), + ("screen-share-disabled", "Screen Share Disabled"), + ("screen-share", "Screen Share"), + ("screenshot", "Screenshot"), + ("search-white", "Search White"), + ("search", "Search"), + ("secure-broken", "Secure Broken"), + ("secure-mixed", "Secure Mixed"), + ("secure", "Secure"), + ("security", "Security"), + ("send-to-device", "Send To Device"), + ("send", "Send"), + ("settings", "Settings"), + ("share-windows", "Share Windows"), + ("share", "Share"), + ("shield", "Shield"), + ("sidebar", "Sidebar"), + ("sign-in", "Sign In"), + ("sign-up", "Sign Up"), + ("sound-off", "Sound Off"), + ("sound-on", "Sound On"), + ("sparkles", "Sparkles"), + ("star", "Star"), + ("stop", "Stop"), + ("store-data-disabled", "Store Data Disabled"), + ("store-data", "Store Data"), + ("sub-item", "Sub Item"), + ("subscribe", "Subscribe"), + ("sync", "Sync"), + ("tab-mobile", "Tab Mobile"), + ("tab-new", "Tab New"), + ("tab", "Tab"), + ("tablet", "Tablet"), + ("thumbs-up-narrow", "Thumbs Up Narrow"), + ("thumbs-up", "Thumbs Up"), + ("toggle-off", "Toggle Off"), + ("toggle-on", "Toggle On"), + ("toolbar", "Toolbar"), + ("top-sites", "Top Sites"), + ("tracing-protection-disabled", "Tracing Protection Disabled"), + ("tracking-protection", "Tracking Protection"), + ("trash-narrow", "Trash Narrow"), + ("trash", "Trash"), + ("turbo-mode", "Turbo Mode"), + ("undo", "Undo"), + ("update", "Update"), + ("user", "User"), + ("users", "Users"), + ("video-card", "Video Card"), + ("video-recoder-disabled", "Video Recoder Disabled"), + ("video-recorder", "Video Recorder"), + ("warning", "Warning"), + ("watch", "Watch"), + ("web-of-things", "Web Of Things"), + ("web-vr", "Web Vr"), + ("window-new", "Window New"), + ("window", "Window"), + ("zoom-in", "Zoom In"), + ("zoom-out", "Zoom Out"), + ], + "inline_form": True, + "required": False, + }, + ), + 13: ("wagtail.blocks.CharBlock", (), {"label": "Heading", "required": False}), + 14: ("wagtail.blocks.RichTextBlock", (), {"features": ["bold", "italic", "link", "superscript", "subscript", "strikethrough"]}), + 15: ("wagtail.blocks.StructBlock", [[("icon", 12), ("heading", 13), ("text", 14)]], {}), + 16: ("wagtail.blocks.CharBlock", (), {"label": "Heading"}), + 17: ( + "bedrock.cms.blocks.UUIDBlock", + (), + { + "help_text": "Unique identifier for analytics tracking. Leave blank to auto-generate.", + "label": "Analytics ID", + "required": False, + }, + ), + 18: ("wagtail.blocks.StructBlock", [[("analytics_id", 17)]], {}), + 19: ("wagtail.blocks.CharBlock", (), {"label": "Link Text"}), + 20: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": [ + ("page", "Page"), + ("file", "File"), + ("custom_url", "Custom URL"), + ("email", "Email"), + ("anchor", "Anchor"), + ("phone", "Phone"), + ], + "classname": "link_choice_type_selector", + "label": "Link to", + "required": False, + }, + ), + 21: ("wagtail.blocks.PageChooserBlock", (), {"form_classname": "page_link", "label": "Page", "required": False}), + 22: ("wagtail.documents.blocks.DocumentChooserBlock", (), {"form_classname": "file_link", "label": "File", "required": False}), + 23: ( + "wagtail.blocks.CharBlock", + (), + { + "form_classname": "custom_url_link url_field", + "label": "Custom URL", + "max_length": 300, + "required": False, + "validators": [wagtail.admin.forms.choosers.URLOrAbsolutePathValidator()], + }, + ), + 24: ("wagtail.blocks.CharBlock", (), {"form_classname": "anchor_link", "label": "#", "max_length": 300, "required": False}), + 25: ("wagtail.blocks.EmailBlock", (), {"required": False}), + 26: ("wagtail.blocks.CharBlock", (), {"form_classname": "phone_link", "label": "Phone", "max_length": 30, "required": False}), + 27: ( + "wagtail.blocks.BooleanBlock", + (), + {"form_classname": "new_window_toggle", "label": "Open in new window", "required": False}, + ), + 28: ( + "wagtail.blocks.StructBlock", + [ + [ + ("link_to", 20), + ("page", 21), + ("file", 22), + ("custom_url", 23), + ("anchor", 24), + ("email", 25), + ("phone", 26), + ("new_window", 27), + ] + ], + {}, + ), + 29: ("wagtail.blocks.StructBlock", [[("settings", 18), ("label", 19), ("link", 28)]], {}), + 30: ("wagtail.blocks.ListBlock", (29,), {"default": [], "max_num": 1, "min_num": 0}), + 31: ("wagtail.blocks.StructBlock", [[("logo", 7), ("heading", 16), ("text", 14), ("button", 30)]], {}), + 32: ("wagtail.snippets.blocks.SnippetChooserBlock", ("anonym.Person",), {}), + 33: ("wagtail.blocks.StructBlock", [[("person", 32), ("link", 30)]], {}), + 34: ("wagtail.blocks.StreamBlock", [[("icon_card", 15), ("logo_card", 31), ("person_card", 33)]], {"max_num": 4, "min_num": 1}), + 35: ("wagtail.blocks.StructBlock", [[("settings", 11), ("cards", 34)]], {}), + 36: ("wagtail.blocks.PageChooserBlock", (), {"page_type": ["anonym.AnonymNewsItemPage", "anonym.AnonymCaseStudyItemPage"]}), + 37: ( + "wagtail.blocks.ListBlock", + (36,), + {"default": [], "help_text": "Select news items or case studies to display.", "max_num": 6, "min_num": 1}, + ), + 38: ("wagtail.blocks.StructBlock", [[("pages", 37)]], {}), + 39: ("wagtail.blocks.PageChooserBlock", ("anonym.AnonymCaseStudyItemPage",), {}), + 40: ("wagtail.blocks.StructBlock", [[("page", 39), ("analytics_id", 17)]], {}), + 41: ( + "wagtail.blocks.ListBlock", + (40,), + { + "default": [], + "help_text": "Select case study pages to display. Each will show as a card with logo, client name, and description.", + "max_num": 3, + "min_num": 1, + }, + ), + 42: ("wagtail.blocks.StructBlock", [[("case_study_items", 41)]], {}), + 43: ("wagtail.blocks.CharBlock", (), {"help_text": "Section label, e.g. 'Publishers' or 'Advertisers'", "max_length": 50}), + 44: ("wagtail.blocks.StreamBlock", [[("logo", 7), ("label", 43)]], {"max_num": 24, "min_num": 1}), + 45: ("wagtail.blocks.StructBlock", [[("items", 44)]], {}), + 46: ("wagtail.blocks.ListBlock", (32,), {"default": [], "max_num": 8, "min_num": 1}), + 47: ("wagtail.blocks.StructBlock", [[("people", 46)]], {}), + 48: ("wagtail.blocks.StructBlock", [[("heading_text", 16), ("supporting_text", 14)]], {}), + 49: ("wagtail.blocks.ListBlock", (48,), {"min_num": 0}), + 50: ("wagtail.blocks.StructBlock", [[("heading_text", 16), ("subheading_text", 3), ("second_column", 49)]], {}), + 51: ( + "wagtail.blocks.RichTextBlock", + (), + { + "features": [ + "h2", + "h3", + "h4", + "h5", + "bold", + "italic", + "underline", + "ol", + "ul", + "hr", + "link", + "image", + "embed", + "superscript", + "subscript", + "strikethrough", + "blockquote", + ] + }, + ), + 52: ("wagtail.blocks.StructBlock", [[("text", 51)]], {}), + 53: ( + "wagtail.blocks.RichTextBlock", + (), + { + "features": [ + "h2", + "h3", + "h4", + "h5", + "bold", + "italic", + "underline", + "ol", + "ul", + "hr", + "link", + "image", + "embed", + "superscript", + "subscript", + "strikethrough", + "blockquote", + ], + "template": "anonym/blocks/rich-text.html", + }, + ), + 54: ( + "wagtail.blocks.StreamBlock", + [ + [ + ("figure_block", 8), + ("cards_list", 35), + ("stat_card_list_block", 38), + ("case_study_item_list_block", 42), + ("logo_list_block", 45), + ("people_list", 47), + ("two_column", 50), + ("legal_rich_text", 52), + ("rich_text", 53), + ] + ], + {"required": False}, + ), + 55: ( + "wagtail.blocks.StructBlock", + [ + [ + ("settings", 2), + ("superheading_text", 3), + ("heading_text", 4), + ("subheading_text", 3), + ("section_content", 54), + ("action", 30), + ] + ], + {}, + ), + 56: ("wagtail.blocks.CharBlock", (), {"label": "Row text"}), + 57: ( + "wagtail.blocks.BooleanBlock", + (), + {"default": False, "help_text": "Traditional Tracking & Measurement Technology", "required": False}, + ), + 58: ("wagtail.blocks.BooleanBlock", (), {"default": False, "help_text": "Data Clean Rooms", "required": False}), + 59: ("wagtail.blocks.BooleanBlock", (), {"default": False, "help_text": "Anonym", "required": False}), + 60: ("wagtail.blocks.StructBlock", [[("text", 56), ("tradition_tracking", 57), ("clean_rooms", 58), ("anonym", 59)]], {}), + 61: ("wagtail.blocks.ListBlock", (60,), {"min_num": 1}), + 62: ("wagtail.blocks.StructBlock", [[("heading_text", 16), ("subheading_text", 14), ("rows", 61)]], {}), + 63: ( + "wagtail.blocks.CharBlock", + (), + {"help_text": "Optional: Add an ID to make this section linkable from navigation", "max_length": 100, "required": False}, + ), + 64: ("wagtail.blocks.StructBlock", [[("anchor_id", 63)]], {}), + 65: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": [ + ("IRL", "IRL"), + ("accessibility", "Accessibility"), + ("accounts", "Accounts"), + ("add-search-engine", "Add Search Engine"), + ("ai", "AI"), + ("alert", "Alert"), + ("arrow-down", "Arrow Down"), + ("arrow-left-white", "Arrow Left White"), + ("arrow-left", "Arrow Left"), + ("arrow-right-white", "Arrow Right White"), + ("arrow-right", "Arrow Right"), + ("arrow-up", "Arrow Up"), + ("audio-card", "Audio Card"), + ("audio-mute", "Audio Mute"), + ("audio", "Audio"), + ("auto-play-block", "Auto Play Block"), + ("back", "Back"), + ("bell", "Bell"), + ("beta", "Beta"), + ("blog", "Blog"), + ("bookmark-menu", "Bookmark Menu"), + ("bookmark-narrow", "Bookmark Narrow"), + ("bookmark-remove", "Bookmark Remove"), + ("bookmark", "Bookmark"), + ("brightness", "Brightness"), + ("browser", "Browser"), + ("calendar", "Calendar"), + ("careers", "Careers"), + ("caret-down-white", "Caret Down White"), + ("caret-down", "Caret Down"), + ("caret-up", "Caret Up"), + ("chat", "Chat"), + ("check", "Check"), + ("close-white", "Close White"), + ("close", "Close"), + ("cloud", "Cloud"), + ("command-console", "Command Console"), + ("command-noautohide", "Command Noautohide"), + ("common-voice", "Common Voice"), + ("copy", "Copy"), + ("current-view", "Current View"), + ("customize", "Customize"), + ("cut", "Cut"), + ("dashboard", "Dashboard"), + ("data-collection", "Data Collection"), + ("data-insights", "Data Insights"), + ("data-pie", "Data Pie"), + ("default-browser", "Default Browser"), + ("delete", "Delete"), + ("desktop", "Desktop"), + ("dev-edition", "Dev Edition"), + ("developer-innovations", "Developer Innovations"), + ("developer", "Developer"), + ("dictionaries", "Dictionaries"), + ("dock-bottom", "Dock Bottom"), + ("dock-left", "Dock Left"), + ("dock-right", "Dock Right"), + ("dock-undock", "Dock Undock"), + ("download-white", "Download White"), + ("download", "Download"), + ("earth", "Earth"), + ("edit-write", "Edit Write"), + ("email", "Email"), + ("enterprise", "Enterprise"), + ("event", "Event"), + ("expand-white", "Expand White"), + ("expand", "Expand"), + ("experiments", "Experiments"), + ("extension-available-update", "Extension Available Update"), + ("extension-recent-updates", "Extension Recent Updates"), + ("extensions-legacy", "Extensions Legacy"), + ("extensions", "Extensions"), + ("external-link-white", "External Link White"), + ("external-link", "External Link"), + ("eye-closed", "Eye Closed"), + ("eye-open", "Eye Open"), + ("facebook-container", "Facebook Container"), + ("features", "Features"), + ("feeback", "Feeback"), + ("file-code", "File Code"), + ("file-image", "File Image"), + ("file-lock", "File Lock"), + ("file-music", "File Music"), + ("file-text", "File Text"), + ("file", "File"), + ("fire-tv", "Fire TV"), + ("firefox-reality", "Firefox Reality"), + ("folder-open", "Folder Open"), + ("folder-plus", "Folder Plus"), + ("folder-save", "Folder Save"), + ("folder", "Folder"), + ("font", "Font"), + ("forget", "Forget"), + ("forward", "Forward"), + ("full-screen-disabled", "Full Screen Disabled"), + ("full-screen-exit", "Full Screen Exit"), + ("full-screen", "Full Screen"), + ("gear", "Gear"), + ("get-involved", "Get Involved"), + ("globe-white", "Globe White"), + ("globe", "Globe"), + ("hashtag-narrow", "Hashtag Narrow"), + ("hashtag", "Hashtag"), + ("headphone", "Headphone"), + ("heart", "Heart"), + ("heart-rate", "Heart Rate"), + ("heart-white", "Heart White"), + ("help", "Help"), + ("highlight", "Highlight"), + ("history", "History"), + ("home", "Home"), + ("hubs", "Hubs"), + ("identity-notification", "Identity Notification"), + ("identity", "Identity"), + ("image", "Image"), + ("import", "Import"), + ("inbox", "Inbox"), + ("info", "Info"), + ("labs", "Labs"), + ("language", "Language"), + ("library", "Library"), + ("layer", "Layer"), + ("link", "Link"), + ("listen", "Listen"), + ("lite", "Lite"), + ("location-disabled", "Location Disabled"), + ("location-macos-disabled", "Location Macos Disabled"), + ("location-macos", "Location Macos"), + ("location-pin", "Location Pin"), + ("location-windows-disabled", "Location Windows Disabled"), + ("location-windows", "Location Windows"), + ("location", "Location"), + ("lock", "Lock"), + ("lockbox", "Lockbox"), + ("login", "Login"), + ("mail", "Mail"), + ("maximize", "Maximize"), + ("megaphone", "Megaphone"), + ("menu-white", "Menu White"), + ("menu", "Menu"), + ("microphone-disabled", "Microphone Disabled"), + ("microphone", "Microphone"), + ("midi", "Midi"), + ("minimize", "Minimize"), + ("minus", "Minus"), + ("mobile-narrow", "Mobile Narrow"), + ("mobile", "Mobile"), + ("monitor", "Monitor"), + ("more-horizontal", "More Horizontal"), + ("more-vertical", "More Vertical"), + ("mountain", "Mountain"), + ("mouse-pointer-disabled", "Mouse Pointer Disabled"), + ("mouse-pointer", "Mouse Pointer"), + ("mozilla", "Mozilla"), + ("new", "New"), + ("nightly", "Nightly"), + ("notes", "Notes"), + ("notifications-disabled", "Notifications Disabled"), + ("notifications", "Notifications"), + ("open-in-new", "Open In New"), + ("open", "Open"), + ("opensource", "Opensource"), + ("overflow", "Overflow"), + ("paperclip-narrow", "Paperclip Narrow"), + ("paperclip", "Paperclip"), + ("paste", "Paste"), + ("pause-white", "Pause White"), + ("pause", "Pause"), + ("performance", "Performance"), + ("photon", "Photon"), + ("pin-remove", "Pin Remove"), + ("pin", "Pin"), + ("play-white", "Play White"), + ("play", "Play"), + ("plugin-disabled", "Plugin Disabled"), + ("plugin", "Plugin"), + ("plus", "Plus"), + ("pocket-list", "Pocket List"), + ("pocket-remove", "Pocket Remove"), + ("pocket", "Pocket"), + ("popular", "Popular"), + ("popup-block", "Popup Block"), + ("preferences", "Preferences"), + ("pricetag", "Pricetag"), + ("pricetag-white", "Pricetag White"), + ("printer", "Printer"), + ("privacy", "Privacy"), + ("private-browsing", "Private Browsing"), + ("protocol", "Protocol"), + ("proton", "Proton"), + ("query", "Query"), + ("quit", "Quit"), + ("quote", "Quote"), + ("read", "Read"), + ("reader-mode", "Reader Mode"), + ("redo", "Redo"), + ("refresh", "Refresh"), + ("release-notes", "Release Notes"), + ("reminders", "Reminders"), + ("report-narrow", "Report Narrow"), + ("report", "Report"), + ("resources", "Resources"), + ("restore-session", "Restore Session"), + ("rhombus-layers", "Rhombus Layers"), + ("rhombus-layers-white", "Rhombus Layers White"), + ("screen-share-disabled", "Screen Share Disabled"), + ("screen-share", "Screen Share"), + ("screenshot", "Screenshot"), + ("search-white", "Search White"), + ("search", "Search"), + ("secure-broken", "Secure Broken"), + ("secure-mixed", "Secure Mixed"), + ("secure", "Secure"), + ("security", "Security"), + ("send-to-device", "Send To Device"), + ("send", "Send"), + ("settings", "Settings"), + ("share-windows", "Share Windows"), + ("share", "Share"), + ("shield", "Shield"), + ("sidebar", "Sidebar"), + ("sign-in", "Sign In"), + ("sign-up", "Sign Up"), + ("sound-off", "Sound Off"), + ("sound-on", "Sound On"), + ("sparkles", "Sparkles"), + ("star", "Star"), + ("stop", "Stop"), + ("store-data-disabled", "Store Data Disabled"), + ("store-data", "Store Data"), + ("sub-item", "Sub Item"), + ("subscribe", "Subscribe"), + ("sync", "Sync"), + ("tab-mobile", "Tab Mobile"), + ("tab-new", "Tab New"), + ("tab", "Tab"), + ("tablet", "Tablet"), + ("thumbs-up-narrow", "Thumbs Up Narrow"), + ("thumbs-up", "Thumbs Up"), + ("toggle-off", "Toggle Off"), + ("toggle-on", "Toggle On"), + ("toolbar", "Toolbar"), + ("top-sites", "Top Sites"), + ("tracing-protection-disabled", "Tracing Protection Disabled"), + ("tracking-protection", "Tracking Protection"), + ("trash-narrow", "Trash Narrow"), + ("trash", "Trash"), + ("turbo-mode", "Turbo Mode"), + ("undo", "Undo"), + ("update", "Update"), + ("user", "User"), + ("users", "Users"), + ("video-card", "Video Card"), + ("video-recoder-disabled", "Video Recoder Disabled"), + ("video-recorder", "Video Recorder"), + ("warning", "Warning"), + ("watch", "Watch"), + ("web-of-things", "Web Of Things"), + ("web-vr", "Web Vr"), + ("window-new", "Window New"), + ("window", "Window"), + ("zoom-in", "Zoom In"), + ("zoom-out", "Zoom Out"), + ], + "inline_form": True, + }, + ), + 66: ("wagtail.blocks.CharBlock", (), {}), + 67: ("wagtail.blocks.StructBlock", [[("first_section", 55), ("second_section", 55)]], {}), + 68: ("wagtail.blocks.StreamBlock", [[("two_column_block", 67)]], {"required": True}), + 69: ("wagtail.blocks.StructBlock", [[("icon", 65), ("toggle_text", 66), ("toggle_content", 68)]], {}), + 70: ("wagtail.blocks.StreamBlock", [[("toggle_items", 69)]], {"required": True}), + 71: ("wagtail.blocks.StructBlock", [[("settings", 64), ("toggle_items", 70)]], {}), + 72: ( + "wagtail.blocks.RichTextBlock", + (), + { + "features": ["bold", "italic", "link", "superscript", "subscript", "strikethrough"], + "help_text": "Use bold to make parts of this text black.", + }, + ), + 73: ("wagtail.blocks.StructBlock", [[("settings", 64), ("heading", 72), ("button", 30)]], {}), + }, + null=True, + ), + ), + migrations.AlterField( + model_name="anonymindexpage", + name="content", + field=wagtail.fields.StreamField( + [("section", 55), ("call_to_action", 59)], + blank=True, + block_lookup={ + 0: ( + "wagtail.blocks.CharBlock", + (), + { + "help_text": "Optional: Add an ID to make this section linkable from navigation (e.g., 'overview', 'features')", + "max_length": 100, + "required": False, + }, + ), + 1: ( + "wagtail.blocks.ChoiceBlock", + [], + {"choices": [("", "---"), ("index", "Index"), ("top_glow", "Top Glow")], "inline_form": True, "required": False}, + ), + 2: ("wagtail.blocks.StructBlock", [[("anchor_id", 0), ("theme", 1)]], {}), + 3: ( + "wagtail.blocks.RichTextBlock", + (), + {"features": ["bold", "italic", "link", "superscript", "subscript", "strikethrough"], "required": False}, + ), + 4: ( + "wagtail.blocks.RichTextBlock", + (), + { + "features": ["bold", "italic", "link", "superscript", "subscript", "strikethrough"], + "help_text": "Use Bold to make parts of this text black.", + }, + ), + 5: ( + "wagtail.blocks.BooleanBlock", + (), + { + "default": False, + "help_text": "The Default width is constrained to the layout grid with a max-width, centered on the page.", + "inline_form": True, + "label": "Make Full Width", + "required": False, + }, + ), + 6: ("wagtail.blocks.StructBlock", [[("make_full_width", 5)]], {}), + 7: ("wagtail.images.blocks.ImageChooserBlock", (), {}), + 8: ("wagtail.blocks.StructBlock", [[("settings", 6), ("image", 7)]], {}), + 9: ("wagtail.blocks.BooleanBlock", (), {"default": False, "help_text": "The default behavior is stacked", "required": False}), + 10: ( + "wagtail.blocks.BooleanBlock", + (), + {"default": False, "help_text": "Add divider lines between cards on desktop", "required": False}, + ), + 11: ("wagtail.blocks.StructBlock", [[("scrollable_on_mobile", 9), ("dividers_between_cards_on_desktop", 10)]], {}), + 12: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": [ + ("", "---"), + ("IRL", "IRL"), + ("accessibility", "Accessibility"), + ("accounts", "Accounts"), + ("add-search-engine", "Add Search Engine"), + ("ai", "AI"), + ("alert", "Alert"), + ("arrow-down", "Arrow Down"), + ("arrow-left-white", "Arrow Left White"), + ("arrow-left", "Arrow Left"), + ("arrow-right-white", "Arrow Right White"), + ("arrow-right", "Arrow Right"), + ("arrow-up", "Arrow Up"), + ("audio-card", "Audio Card"), + ("audio-mute", "Audio Mute"), + ("audio", "Audio"), + ("auto-play-block", "Auto Play Block"), + ("back", "Back"), + ("bell", "Bell"), + ("beta", "Beta"), + ("blog", "Blog"), + ("bookmark-menu", "Bookmark Menu"), + ("bookmark-narrow", "Bookmark Narrow"), + ("bookmark-remove", "Bookmark Remove"), + ("bookmark", "Bookmark"), + ("brightness", "Brightness"), + ("browser", "Browser"), + ("calendar", "Calendar"), + ("careers", "Careers"), + ("caret-down-white", "Caret Down White"), + ("caret-down", "Caret Down"), + ("caret-up", "Caret Up"), + ("chat", "Chat"), + ("check", "Check"), + ("close-white", "Close White"), + ("close", "Close"), + ("cloud", "Cloud"), + ("command-console", "Command Console"), + ("command-noautohide", "Command Noautohide"), + ("common-voice", "Common Voice"), + ("copy", "Copy"), + ("current-view", "Current View"), + ("customize", "Customize"), + ("cut", "Cut"), + ("dashboard", "Dashboard"), + ("data-collection", "Data Collection"), + ("data-insights", "Data Insights"), + ("data-pie", "Data Pie"), + ("default-browser", "Default Browser"), + ("delete", "Delete"), + ("desktop", "Desktop"), + ("dev-edition", "Dev Edition"), + ("developer-innovations", "Developer Innovations"), + ("developer", "Developer"), + ("dictionaries", "Dictionaries"), + ("dock-bottom", "Dock Bottom"), + ("dock-left", "Dock Left"), + ("dock-right", "Dock Right"), + ("dock-undock", "Dock Undock"), + ("download-white", "Download White"), + ("download", "Download"), + ("earth", "Earth"), + ("edit-write", "Edit Write"), + ("email", "Email"), + ("enterprise", "Enterprise"), + ("event", "Event"), + ("expand-white", "Expand White"), + ("expand", "Expand"), + ("experiments", "Experiments"), + ("extension-available-update", "Extension Available Update"), + ("extension-recent-updates", "Extension Recent Updates"), + ("extensions-legacy", "Extensions Legacy"), + ("extensions", "Extensions"), + ("external-link-white", "External Link White"), + ("external-link", "External Link"), + ("eye-closed", "Eye Closed"), + ("eye-open", "Eye Open"), + ("facebook-container", "Facebook Container"), + ("features", "Features"), + ("feeback", "Feeback"), + ("file-code", "File Code"), + ("file-image", "File Image"), + ("file-lock", "File Lock"), + ("file-music", "File Music"), + ("file-text", "File Text"), + ("file", "File"), + ("fire-tv", "Fire TV"), + ("firefox-reality", "Firefox Reality"), + ("folder-open", "Folder Open"), + ("folder-plus", "Folder Plus"), + ("folder-save", "Folder Save"), + ("folder", "Folder"), + ("font", "Font"), + ("forget", "Forget"), + ("forward", "Forward"), + ("full-screen-disabled", "Full Screen Disabled"), + ("full-screen-exit", "Full Screen Exit"), + ("full-screen", "Full Screen"), + ("gear", "Gear"), + ("get-involved", "Get Involved"), + ("globe-white", "Globe White"), + ("globe", "Globe"), + ("hashtag-narrow", "Hashtag Narrow"), + ("hashtag", "Hashtag"), + ("headphone", "Headphone"), + ("heart", "Heart"), + ("heart-rate", "Heart Rate"), + ("heart-white", "Heart White"), + ("help", "Help"), + ("highlight", "Highlight"), + ("history", "History"), + ("home", "Home"), + ("hubs", "Hubs"), + ("identity-notification", "Identity Notification"), + ("identity", "Identity"), + ("image", "Image"), + ("import", "Import"), + ("inbox", "Inbox"), + ("info", "Info"), + ("labs", "Labs"), + ("language", "Language"), + ("library", "Library"), + ("layer", "Layer"), + ("link", "Link"), + ("listen", "Listen"), + ("lite", "Lite"), + ("location-disabled", "Location Disabled"), + ("location-macos-disabled", "Location Macos Disabled"), + ("location-macos", "Location Macos"), + ("location-pin", "Location Pin"), + ("location-windows-disabled", "Location Windows Disabled"), + ("location-windows", "Location Windows"), + ("location", "Location"), + ("lock", "Lock"), + ("lockbox", "Lockbox"), + ("login", "Login"), + ("mail", "Mail"), + ("maximize", "Maximize"), + ("megaphone", "Megaphone"), + ("menu-white", "Menu White"), + ("menu", "Menu"), + ("microphone-disabled", "Microphone Disabled"), + ("microphone", "Microphone"), + ("midi", "Midi"), + ("minimize", "Minimize"), + ("minus", "Minus"), + ("mobile-narrow", "Mobile Narrow"), + ("mobile", "Mobile"), + ("monitor", "Monitor"), + ("more-horizontal", "More Horizontal"), + ("more-vertical", "More Vertical"), + ("mountain", "Mountain"), + ("mouse-pointer-disabled", "Mouse Pointer Disabled"), + ("mouse-pointer", "Mouse Pointer"), + ("mozilla", "Mozilla"), + ("new", "New"), + ("nightly", "Nightly"), + ("notes", "Notes"), + ("notifications-disabled", "Notifications Disabled"), + ("notifications", "Notifications"), + ("open-in-new", "Open In New"), + ("open", "Open"), + ("opensource", "Opensource"), + ("overflow", "Overflow"), + ("paperclip-narrow", "Paperclip Narrow"), + ("paperclip", "Paperclip"), + ("paste", "Paste"), + ("pause-white", "Pause White"), + ("pause", "Pause"), + ("performance", "Performance"), + ("photon", "Photon"), + ("pin-remove", "Pin Remove"), + ("pin", "Pin"), + ("play-white", "Play White"), + ("play", "Play"), + ("plugin-disabled", "Plugin Disabled"), + ("plugin", "Plugin"), + ("plus", "Plus"), + ("pocket-list", "Pocket List"), + ("pocket-remove", "Pocket Remove"), + ("pocket", "Pocket"), + ("popular", "Popular"), + ("popup-block", "Popup Block"), + ("preferences", "Preferences"), + ("pricetag", "Pricetag"), + ("pricetag-white", "Pricetag White"), + ("printer", "Printer"), + ("privacy", "Privacy"), + ("private-browsing", "Private Browsing"), + ("protocol", "Protocol"), + ("proton", "Proton"), + ("query", "Query"), + ("quit", "Quit"), + ("quote", "Quote"), + ("read", "Read"), + ("reader-mode", "Reader Mode"), + ("redo", "Redo"), + ("refresh", "Refresh"), + ("release-notes", "Release Notes"), + ("reminders", "Reminders"), + ("report-narrow", "Report Narrow"), + ("report", "Report"), + ("resources", "Resources"), + ("restore-session", "Restore Session"), + ("rhombus-layers", "Rhombus Layers"), + ("rhombus-layers-white", "Rhombus Layers White"), + ("screen-share-disabled", "Screen Share Disabled"), + ("screen-share", "Screen Share"), + ("screenshot", "Screenshot"), + ("search-white", "Search White"), + ("search", "Search"), + ("secure-broken", "Secure Broken"), + ("secure-mixed", "Secure Mixed"), + ("secure", "Secure"), + ("security", "Security"), + ("send-to-device", "Send To Device"), + ("send", "Send"), + ("settings", "Settings"), + ("share-windows", "Share Windows"), + ("share", "Share"), + ("shield", "Shield"), + ("sidebar", "Sidebar"), + ("sign-in", "Sign In"), + ("sign-up", "Sign Up"), + ("sound-off", "Sound Off"), + ("sound-on", "Sound On"), + ("sparkles", "Sparkles"), + ("star", "Star"), + ("stop", "Stop"), + ("store-data-disabled", "Store Data Disabled"), + ("store-data", "Store Data"), + ("sub-item", "Sub Item"), + ("subscribe", "Subscribe"), + ("sync", "Sync"), + ("tab-mobile", "Tab Mobile"), + ("tab-new", "Tab New"), + ("tab", "Tab"), + ("tablet", "Tablet"), + ("thumbs-up-narrow", "Thumbs Up Narrow"), + ("thumbs-up", "Thumbs Up"), + ("toggle-off", "Toggle Off"), + ("toggle-on", "Toggle On"), + ("toolbar", "Toolbar"), + ("top-sites", "Top Sites"), + ("tracing-protection-disabled", "Tracing Protection Disabled"), + ("tracking-protection", "Tracking Protection"), + ("trash-narrow", "Trash Narrow"), + ("trash", "Trash"), + ("turbo-mode", "Turbo Mode"), + ("undo", "Undo"), + ("update", "Update"), + ("user", "User"), + ("users", "Users"), + ("video-card", "Video Card"), + ("video-recoder-disabled", "Video Recoder Disabled"), + ("video-recorder", "Video Recorder"), + ("warning", "Warning"), + ("watch", "Watch"), + ("web-of-things", "Web Of Things"), + ("web-vr", "Web Vr"), + ("window-new", "Window New"), + ("window", "Window"), + ("zoom-in", "Zoom In"), + ("zoom-out", "Zoom Out"), + ], + "inline_form": True, + "required": False, + }, + ), + 13: ("wagtail.blocks.CharBlock", (), {"label": "Heading", "required": False}), + 14: ("wagtail.blocks.RichTextBlock", (), {"features": ["bold", "italic", "link", "superscript", "subscript", "strikethrough"]}), + 15: ("wagtail.blocks.StructBlock", [[("icon", 12), ("heading", 13), ("text", 14)]], {}), + 16: ("wagtail.blocks.CharBlock", (), {"label": "Heading"}), + 17: ( + "bedrock.cms.blocks.UUIDBlock", + (), + { + "help_text": "Unique identifier for analytics tracking. Leave blank to auto-generate.", + "label": "Analytics ID", + "required": False, + }, + ), + 18: ("wagtail.blocks.StructBlock", [[("analytics_id", 17)]], {}), + 19: ("wagtail.blocks.CharBlock", (), {"label": "Link Text"}), + 20: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": [ + ("page", "Page"), + ("file", "File"), + ("custom_url", "Custom URL"), + ("email", "Email"), + ("anchor", "Anchor"), + ("phone", "Phone"), + ], + "classname": "link_choice_type_selector", + "label": "Link to", + "required": False, + }, + ), + 21: ("wagtail.blocks.PageChooserBlock", (), {"form_classname": "page_link", "label": "Page", "required": False}), + 22: ("wagtail.documents.blocks.DocumentChooserBlock", (), {"form_classname": "file_link", "label": "File", "required": False}), + 23: ( + "wagtail.blocks.CharBlock", + (), + { + "form_classname": "custom_url_link url_field", + "label": "Custom URL", + "max_length": 300, + "required": False, + "validators": [wagtail.admin.forms.choosers.URLOrAbsolutePathValidator()], + }, + ), + 24: ("wagtail.blocks.CharBlock", (), {"form_classname": "anchor_link", "label": "#", "max_length": 300, "required": False}), + 25: ("wagtail.blocks.EmailBlock", (), {"required": False}), + 26: ("wagtail.blocks.CharBlock", (), {"form_classname": "phone_link", "label": "Phone", "max_length": 30, "required": False}), + 27: ( + "wagtail.blocks.BooleanBlock", + (), + {"form_classname": "new_window_toggle", "label": "Open in new window", "required": False}, + ), + 28: ( + "wagtail.blocks.StructBlock", + [ + [ + ("link_to", 20), + ("page", 21), + ("file", 22), + ("custom_url", 23), + ("anchor", 24), + ("email", 25), + ("phone", 26), + ("new_window", 27), + ] + ], + {}, + ), + 29: ("wagtail.blocks.StructBlock", [[("settings", 18), ("label", 19), ("link", 28)]], {}), + 30: ("wagtail.blocks.ListBlock", (29,), {"default": [], "max_num": 1, "min_num": 0}), + 31: ("wagtail.blocks.StructBlock", [[("logo", 7), ("heading", 16), ("text", 14), ("button", 30)]], {}), + 32: ("wagtail.snippets.blocks.SnippetChooserBlock", ("anonym.Person",), {}), + 33: ("wagtail.blocks.StructBlock", [[("person", 32), ("link", 30)]], {}), + 34: ("wagtail.blocks.StreamBlock", [[("icon_card", 15), ("logo_card", 31), ("person_card", 33)]], {"max_num": 4, "min_num": 1}), + 35: ("wagtail.blocks.StructBlock", [[("settings", 11), ("cards", 34)]], {}), + 36: ("wagtail.blocks.PageChooserBlock", (), {"page_type": ["anonym.AnonymNewsItemPage", "anonym.AnonymCaseStudyItemPage"]}), + 37: ( + "wagtail.blocks.ListBlock", + (36,), + {"default": [], "help_text": "Select news items or case studies to display.", "max_num": 6, "min_num": 1}, + ), + 38: ("wagtail.blocks.StructBlock", [[("pages", 37)]], {}), + 39: ("wagtail.blocks.PageChooserBlock", ("anonym.AnonymCaseStudyItemPage",), {}), + 40: ("wagtail.blocks.StructBlock", [[("page", 39), ("analytics_id", 17)]], {}), + 41: ( + "wagtail.blocks.ListBlock", + (40,), + { + "default": [], + "help_text": "Select case study pages to display. Each will show as a card with logo, client name, and description.", + "max_num": 3, + "min_num": 1, + }, + ), + 42: ("wagtail.blocks.StructBlock", [[("case_study_items", 41)]], {}), + 43: ("wagtail.blocks.CharBlock", (), {"help_text": "Section label, e.g. 'Publishers' or 'Advertisers'", "max_length": 50}), + 44: ("wagtail.blocks.StreamBlock", [[("logo", 7), ("label", 43)]], {"max_num": 24, "min_num": 1}), + 45: ("wagtail.blocks.StructBlock", [[("items", 44)]], {}), + 46: ("wagtail.blocks.ListBlock", (32,), {"default": [], "max_num": 8, "min_num": 1}), + 47: ("wagtail.blocks.StructBlock", [[("people", 46)]], {}), + 48: ("wagtail.blocks.StructBlock", [[("heading_text", 16), ("supporting_text", 14)]], {}), + 49: ("wagtail.blocks.ListBlock", (48,), {"min_num": 0}), + 50: ("wagtail.blocks.StructBlock", [[("heading_text", 16), ("subheading_text", 3), ("second_column", 49)]], {}), + 51: ( + "wagtail.blocks.RichTextBlock", + (), + { + "features": [ + "h2", + "h3", + "h4", + "h5", + "bold", + "italic", + "underline", + "ol", + "ul", + "hr", + "link", + "image", + "embed", + "superscript", + "subscript", + "strikethrough", + "blockquote", + ] + }, + ), + 52: ("wagtail.blocks.StructBlock", [[("text", 51)]], {}), + 53: ( + "wagtail.blocks.RichTextBlock", + (), + { + "features": [ + "h2", + "h3", + "h4", + "h5", + "bold", + "italic", + "underline", + "ol", + "ul", + "hr", + "link", + "image", + "embed", + "superscript", + "subscript", + "strikethrough", + "blockquote", + ], + "template": "anonym/blocks/rich-text.html", + }, + ), + 54: ( + "wagtail.blocks.StreamBlock", + [ + [ + ("figure_block", 8), + ("cards_list", 35), + ("stat_card_list_block", 38), + ("case_study_item_list_block", 42), + ("logo_list_block", 45), + ("people_list", 47), + ("two_column", 50), + ("legal_rich_text", 52), + ("rich_text", 53), + ] + ], + {"required": False}, + ), + 55: ( + "wagtail.blocks.StructBlock", + [ + [ + ("settings", 2), + ("superheading_text", 3), + ("heading_text", 4), + ("subheading_text", 3), + ("section_content", 54), + ("action", 30), + ] + ], + {}, + ), + 56: ( + "wagtail.blocks.CharBlock", + (), + {"help_text": "Optional: Add an ID to make this section linkable from navigation", "max_length": 100, "required": False}, + ), + 57: ("wagtail.blocks.StructBlock", [[("anchor_id", 56)]], {}), + 58: ( + "wagtail.blocks.RichTextBlock", + (), + { + "features": ["bold", "italic", "link", "superscript", "subscript", "strikethrough"], + "help_text": "Use bold to make parts of this text black.", + }, + ), + 59: ("wagtail.blocks.StructBlock", [[("settings", 57), ("heading", 58), ("button", 30)]], {}), + }, + null=True, + ), + ), + migrations.AlterField( + model_name="anonymnewsitempage", + name="content", + field=wagtail.fields.StreamField( + [("intro_text", 1), ("rich_text", 3), ("blockquote", 5), ("figure", 10), ("call_to_action", 28)], + blank=True, + block_lookup={ + 0: ("wagtail.blocks.RichTextBlock", (), {"features": ["bold", "italic", "link", "superscript", "subscript", "strikethrough"]}), + 1: ("wagtail.blocks.StructBlock", [[("text", 0)]], {}), + 2: ( + "wagtail.blocks.RichTextBlock", + (), + { + "features": [ + "h2", + "h3", + "h4", + "h5", + "bold", + "italic", + "underline", + "ol", + "ul", + "hr", + "link", + "image", + "embed", + "superscript", + "subscript", + "strikethrough", + "blockquote", + ] + }, + ), + 3: ("wagtail.blocks.StructBlock", [[("text", 2)]], {}), + 4: ("wagtail.blocks.CharBlock", (), {"label": "Author", "required": False}), + 5: ("wagtail.blocks.StructBlock", [[("text", 0), ("author", 4)]], {}), + 6: ( + "wagtail.blocks.BooleanBlock", + (), + { + "default": False, + "help_text": "The Default width is constrained to the layout grid with a max-width, centered on the page.", + "inline_form": True, + "label": "Make Full Width", + "required": False, + }, + ), + 7: ("wagtail.blocks.StructBlock", [[("make_full_width", 6)]], {}), + 8: ("wagtail.images.blocks.ImageChooserBlock", (), {}), + 9: ( + "wagtail.blocks.RichTextBlock", + (), + {"features": ["bold", "italic", "link", "superscript", "subscript", "strikethrough"], "label": "Caption", "required": False}, + ), + 10: ("wagtail.blocks.StructBlock", [[("settings", 7), ("image", 8), ("caption", 9)]], {}), + 11: ( + "wagtail.blocks.CharBlock", + (), + {"help_text": "Optional: Add an ID to make this section linkable from navigation", "max_length": 100, "required": False}, + ), + 12: ("wagtail.blocks.StructBlock", [[("anchor_id", 11)]], {}), + 13: ( + "wagtail.blocks.RichTextBlock", + (), + { + "features": ["bold", "italic", "link", "superscript", "subscript", "strikethrough"], + "help_text": "Use bold to make parts of this text black.", + }, + ), + 14: ( + "bedrock.cms.blocks.UUIDBlock", + (), + { + "help_text": "Unique identifier for analytics tracking. Leave blank to auto-generate.", + "label": "Analytics ID", + "required": False, + }, + ), + 15: ("wagtail.blocks.StructBlock", [[("analytics_id", 14)]], {}), + 16: ("wagtail.blocks.CharBlock", (), {"label": "Link Text"}), + 17: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": [ + ("page", "Page"), + ("file", "File"), + ("custom_url", "Custom URL"), + ("email", "Email"), + ("anchor", "Anchor"), + ("phone", "Phone"), + ], + "classname": "link_choice_type_selector", + "label": "Link to", + "required": False, + }, + ), + 18: ("wagtail.blocks.PageChooserBlock", (), {"form_classname": "page_link", "label": "Page", "required": False}), + 19: ("wagtail.documents.blocks.DocumentChooserBlock", (), {"form_classname": "file_link", "label": "File", "required": False}), + 20: ( + "wagtail.blocks.CharBlock", + (), + { + "form_classname": "custom_url_link url_field", + "label": "Custom URL", + "max_length": 300, + "required": False, + "validators": [wagtail.admin.forms.choosers.URLOrAbsolutePathValidator()], + }, + ), + 21: ("wagtail.blocks.CharBlock", (), {"form_classname": "anchor_link", "label": "#", "max_length": 300, "required": False}), + 22: ("wagtail.blocks.EmailBlock", (), {"required": False}), + 23: ("wagtail.blocks.CharBlock", (), {"form_classname": "phone_link", "label": "Phone", "max_length": 30, "required": False}), + 24: ( + "wagtail.blocks.BooleanBlock", + (), + {"form_classname": "new_window_toggle", "label": "Open in new window", "required": False}, + ), + 25: ( + "wagtail.blocks.StructBlock", + [ + [ + ("link_to", 17), + ("page", 18), + ("file", 19), + ("custom_url", 20), + ("anchor", 21), + ("email", 22), + ("phone", 23), + ("new_window", 24), + ] + ], + {}, + ), + 26: ("wagtail.blocks.StructBlock", [[("settings", 15), ("label", 16), ("link", 25)]], {}), + 27: ("wagtail.blocks.ListBlock", (26,), {"default": [], "max_num": 1, "min_num": 0}), + 28: ("wagtail.blocks.StructBlock", [[("settings", 12), ("heading", 13), ("button", 27)]], {}), + }, + null=True, + ), + ), + ] diff --git a/bedrock/anonym/migrations/0014_alter_anonymindexpage_navigation.py b/bedrock/anonym/migrations/0014_alter_anonymindexpage_navigation.py new file mode 100644 index 00000000000..1ac37246728 --- /dev/null +++ b/bedrock/anonym/migrations/0014_alter_anonymindexpage_navigation.py @@ -0,0 +1,86 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. + +# Generated by Django 5.2.12 on 2026-04-16 19:34 + +from django.db import migrations + +import wagtail.admin.forms.choosers +import wagtail.fields + + +class Migration(migrations.Migration): + dependencies = [ + ("anonym", "0013_alter_anonymcasestudyitempage_content_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="anonymindexpage", + name="navigation", + field=wagtail.fields.StreamField( + [("link", 12)], + blank=True, + block_lookup={ + 0: ("wagtail.blocks.CharBlock", (), {"help_text": "Text to display for this navigation link", "max_length": 50}), + 1: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": [ + ("page", "Page"), + ("file", "File"), + ("custom_url", "Custom URL"), + ("email", "Email"), + ("anchor", "Anchor"), + ("phone", "Phone"), + ], + "classname": "link_choice_type_selector", + "label": "Link to", + "required": False, + }, + ), + 2: ("wagtail.blocks.PageChooserBlock", (), {"form_classname": "page_link", "label": "Page", "required": False}), + 3: ("wagtail.documents.blocks.DocumentChooserBlock", (), {"form_classname": "file_link", "label": "File", "required": False}), + 4: ( + "wagtail.blocks.CharBlock", + (), + { + "form_classname": "custom_url_link url_field", + "label": "Custom URL", + "max_length": 300, + "required": False, + "validators": [wagtail.admin.forms.choosers.URLOrAbsolutePathValidator()], + }, + ), + 5: ("wagtail.blocks.CharBlock", (), {"form_classname": "anchor_link", "label": "#", "max_length": 300, "required": False}), + 6: ("wagtail.blocks.EmailBlock", (), {"required": False}), + 7: ("wagtail.blocks.CharBlock", (), {"form_classname": "phone_link", "label": "Phone", "max_length": 30, "required": False}), + 8: ("wagtail.blocks.BooleanBlock", (), {"form_classname": "new_window_toggle", "label": "Open in new window", "required": False}), + 9: ( + "wagtail.blocks.StructBlock", + [[("link_to", 1), ("page", 2), ("file", 3), ("custom_url", 4), ("anchor", 5), ("email", 6), ("phone", 7), ("new_window", 8)]], + {}, + ), + 10: ( + "wagtail.blocks.BooleanBlock", + (), + {"default": False, "help_text": "This link should look like a button", "required": False}, + ), + 11: ( + "wagtail.blocks.CharBlock", + (), + { + "help_text": "Unique identifier for analytics tracking. Leave blank for standard nav links.", + "label": "Analytics ID", + "required": False, + }, + ), + 12: ("wagtail.blocks.StructBlock", [[("link_text", 0), ("link", 9), ("has_button_appearance", 10), ("analytics_id", 11)]], {}), + }, + help_text="Configure the navigation menu items.", + null=True, + ), + ), + ] diff --git a/bedrock/anonym/migrations/0015_anonymnewsitempage_analytics_id.py b/bedrock/anonym/migrations/0015_anonymnewsitempage_analytics_id.py new file mode 100644 index 00000000000..6c8b611cc7f --- /dev/null +++ b/bedrock/anonym/migrations/0015_anonymnewsitempage_analytics_id.py @@ -0,0 +1,21 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. + +# Generated by Django 5.2.12 on 2026-04-16 19:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("anonym", "0014_alter_anonymindexpage_navigation"), + ] + + operations = [ + migrations.AddField( + model_name="anonymnewsitempage", + name="analytics_id", + field=models.CharField(blank=True, help_text="Unique identifier for analytics tracking. Leave blank to auto-generate.", max_length=36), + ), + ] diff --git a/bedrock/anonym/migrations/0016_populate_analytics_ids.py b/bedrock/anonym/migrations/0016_populate_analytics_ids.py new file mode 100644 index 00000000000..50bd69d54a7 --- /dev/null +++ b/bedrock/anonym/migrations/0016_populate_analytics_ids.py @@ -0,0 +1,57 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import sys +from uuid import uuid4 + +from django.core.management import call_command +from django.db import migrations + +from bedrock.base.config_manager import config + + +def _should_skip(): + return "pytest" in sys.modules or config("SQLITE_EXPORT_MODE", parser=bool, default="false") + + +def populate_news_item_analytics_ids(apps, schema_editor): + if _should_skip(): + return + AnonymNewsItemPage = apps.get_model("anonym", "AnonymNewsItemPage") + for page in AnonymNewsItemPage.objects.filter(analytics_id=""): + page.analytics_id = str(uuid4()) + page.save(update_fields=["analytics_id"]) + + +def inject_analytics_ids_into_link_blocks(apps, schema_editor): + if _should_skip(): + return + call_command("populate_link_block_analytics_ids") + + +def restructure_case_study_list_blocks(apps, schema_editor): + if _should_skip(): + return + call_command("populate_case_study_analytics_ids") + + +def populate_nav_button_analytics_ids(apps, schema_editor): + if _should_skip(): + return + call_command("populate_nav_button_analytics_ids") + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ("anonym", "0015_anonymnewsitempage_analytics_id"), + ] + + operations = [ + migrations.RunPython(populate_news_item_analytics_ids, migrations.RunPython.noop), + migrations.RunPython(inject_analytics_ids_into_link_blocks, migrations.RunPython.noop), + migrations.RunPython(restructure_case_study_list_blocks, migrations.RunPython.noop), + migrations.RunPython(populate_nav_button_analytics_ids, migrations.RunPython.noop), + ] diff --git a/bedrock/anonym/models.py b/bedrock/anonym/models.py index a2f26ab615a..25710a5ffb7 100644 --- a/bedrock/anonym/models.py +++ b/bedrock/anonym/models.py @@ -2,6 +2,8 @@ # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at https://mozilla.org/MPL/2.0/. +from uuid import uuid4 + from django.conf import settings from django.core.mail import EmailMessage from django.db import models @@ -141,6 +143,12 @@ class AnonymNewsItemPage(AbstractStatCardPage): use_json_field=True, ) + analytics_id = models.CharField( + max_length=36, + blank=True, + help_text="Unique identifier for analytics tracking. Leave blank to auto-generate.", + ) + content_panels = ( AbstractBedrockCMSPage.content_panels + [ @@ -156,10 +164,19 @@ class AnonymNewsItemPage(AbstractStatCardPage): ] ) + promote_panels = AbstractBedrockCMSPage.promote_panels + [ + FieldPanel("analytics_id"), + ] + # Since the concept of a AnonymNewsItemPage is similar to an # AnonymCaseStudyItemPage, use the AnonymCaseStudyItemPage template. template = "anonym/anonym_case_study_item_page.html" + def save(self, *args, **kwargs): + if not self.analytics_id: + self.analytics_id = str(uuid4()) + super().save(*args, **kwargs) + @property def category_label(self): return "Press" diff --git a/bedrock/anonym/templates/anonym/anonym_news.html b/bedrock/anonym/templates/anonym/anonym_news.html index 5e8fa01be70..a3d76869b54 100644 --- a/bedrock/anonym/templates/anonym/anonym_news.html +++ b/bedrock/anonym/templates/anonym/anonym_news.html @@ -41,7 +41,10 @@

News

{% endif %} - + {% if featured_item.link %} @@ -85,7 +88,10 @@

{% endif %} - + {% if item.link %} diff --git a/bedrock/anonym/templates/anonym/blocks/call-to-action.html b/bedrock/anonym/templates/anonym/blocks/call-to-action.html index 305d26c1cc9..5b1b5aff19b 100644 --- a/bedrock/anonym/templates/anonym/blocks/call-to-action.html +++ b/bedrock/anonym/templates/anonym/blocks/call-to-action.html @@ -10,6 +10,8 @@

{{ value.heading|richtext|remove_p_tag }}

{{ button.label }} {% endfor %} diff --git a/bedrock/anonym/templates/anonym/blocks/case-study-item-list.html b/bedrock/anonym/templates/anonym/blocks/case-study-item-list.html index 0b7c0ce45fe..c2cb20eb7a0 100644 --- a/bedrock/anonym/templates/anonym/blocks/case-study-item-list.html +++ b/bedrock/anonym/templates/anonym/blocks/case-study-item-list.html @@ -6,8 +6,9 @@
diff --git a/bedrock/anonym/templates/anonym/blocks/section.html b/bedrock/anonym/templates/anonym/blocks/section.html index 4963d6dcd1f..b2e49fdccd4 100644 --- a/bedrock/anonym/templates/anonym/blocks/section.html +++ b/bedrock/anonym/templates/anonym/blocks/section.html @@ -22,6 +22,8 @@ {{ cta.label }} {% endfor %} {% endif %} diff --git a/bedrock/anonym/templates/anonym/includes/case_study_list_item.html b/bedrock/anonym/templates/anonym/includes/case_study_list_item.html index 5c4ffbb0939..c0fcead1fc0 100644 --- a/bedrock/anonym/templates/anonym/includes/case_study_list_item.html +++ b/bedrock/anonym/templates/anonym/includes/case_study_list_item.html @@ -5,22 +5,20 @@ #}
  • + {% set name = case_study.company_name or case_study.title %} {% if case_study.logo %}
    {% endif %} -

    - {% if case_study.company_name %} - {{ case_study.company_name }} - {% else %} - {{ case_study.title }} - {% endif %} -

    +

    {{ name }}

    {% if case_study.description %}

    {{ case_study.description }}

    {% endif %} {% set item_url = case_study.link if case_study.link else case_study.url %} - Case Study + Case Study
  • diff --git a/bedrock/anonym/templates/anonym/includes/navigation.html b/bedrock/anonym/templates/anonym/includes/navigation.html index d0cd13fb1e8..cb782821672 100644 --- a/bedrock/anonym/templates/anonym/includes/navigation.html +++ b/bedrock/anonym/templates/anonym/includes/navigation.html @@ -40,6 +40,10 @@ data-link-text="{{ link_block.link_text }}" {% if link_block.has_button_appearance %}class="mzan-c-contact-button"{% endif %} {% if link_block.link.new_window %}target="_blank" rel="noopener"{% endif %} + {% if link_block.analytics_id %} + data-cta-text="{{ link_block.link_text }}" + data-cta-uid="{{ link_block.analytics_id }}" + {% endif %} > {{ link_block.link_text }} diff --git a/bedrock/anonym/tests/test_management_commands.py b/bedrock/anonym/tests/test_management_commands.py new file mode 100644 index 00000000000..ad95941dfe8 --- /dev/null +++ b/bedrock/anonym/tests/test_management_commands.py @@ -0,0 +1,361 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. + +from django.core.management import call_command + +import pytest +from wagtail.models import Site + +from bedrock.anonym.fixtures.base_fixtures import get_test_anonym_index_page +from bedrock.anonym.models import ( + AnonymCaseStudyItemPage, + AnonymCaseStudyPage, + AnonymContentSubPage, + AnonymIndexPage, + AnonymNewsItemPage, + AnonymNewsPage, +) +from bedrock.cms.tests.conftest import minimal_site # noqa: F401 + +pytestmark = [pytest.mark.django_db] + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _cta_block(anchor_id="cta", label="Contact"): + """call_to_action block whose button settings.analytics_id is empty.""" + return { + "type": "call_to_action", + "value": { + "settings": {"anchor_id": anchor_id}, + "heading": f"

    {label}

    ", + "button": [ + { + "type": "item", + "value": { + "label": label, + "link": { + "link_to": "custom_url", + "custom_url": "https://example.com", + "new_window": False, + }, + "settings": {"analytics_id": ""}, + }, + "id": f"btn-{anchor_id}", + } + ], + }, + "id": f"cta-{anchor_id}", + } + + +def _section_block(anchor_id="s", label="Learn More"): + """section block whose action link settings.analytics_id is empty.""" + return { + "type": "section", + "value": { + "settings": {"anchor_id": anchor_id, "theme": ""}, + "superheading_text": "", + "heading_text": "

    Test

    ", + "subheading_text": "", + "section_content": [], + "action": [ + { + "type": "item", + "value": { + "label": label, + "link": { + "link_to": "custom_url", + "custom_url": "https://example.com", + "new_window": False, + }, + "settings": {"analytics_id": ""}, + }, + "id": f"action-{anchor_id}", + } + ], + }, + "id": f"section-{anchor_id}", + } + + +def _cs_section_block(case_study_pk, item_id="cs-item-uuid"): + """section block with a case_study_item_list_block whose analytics_id is empty.""" + return { + "type": "section", + "value": { + "settings": {"anchor_id": "cs", "theme": ""}, + "superheading_text": "", + "heading_text": "

    Case Studies

    ", + "subheading_text": "", + "section_content": [ + { + "type": "case_study_item_list_block", + "value": { + "case_study_items": [ + { + "type": "item", + "value": {"page": case_study_pk, "analytics_id": ""}, + "id": item_id, + } + ] + }, + "id": "cs-list", + } + ], + "action": [], + }, + "id": "section-cs", + } + + +def _nav_button_link(slug_suffix, has_button=True, analytics_id=None): + """navigation link block, optionally with has_button_appearance and/or analytics_id.""" + value = { + "link_text": "Contact", + "link": { + "link_to": "custom_url", + "custom_url": "https://example.com/contact", + "new_window": False, + }, + "has_button_appearance": has_button, + } + if analytics_id is not None: + value["analytics_id"] = analytics_id + return {"type": "link", "value": value, "id": f"nav-{slug_suffix}"} + + +# --------------------------------------------------------------------------- +# populate_link_block_analytics_ids +# --------------------------------------------------------------------------- + + +def test_populate_link_block_analytics_ids_section_action(minimal_site: Site) -> None: # noqa: F811 + """Injects settings.analytics_id into section.action links that are missing it.""" + root_page = minimal_site.root_page + page = AnonymIndexPage( + title="LBAI Section Test", + slug="lbai-section-test", + content=[_section_block("lbai-s")], + ) + root_page.add_child(instance=page) + page.save_revision().publish() + # Confirm pre-condition: settings exists but analytics_id is not yet populated + assert not list(page.content.raw_data)[0]["value"]["action"][0]["value"]["settings"].get("analytics_id") + + call_command("populate_link_block_analytics_ids") + + page.refresh_from_db() + action_link = list(page.content.raw_data)[0]["value"]["action"][0]["value"] + assert "settings" in action_link + assert action_link["settings"].get("analytics_id") + + +def test_populate_link_block_analytics_ids_cta_button(minimal_site: Site) -> None: # noqa: F811 + """Injects settings.analytics_id into call_to_action.button links that are missing it.""" + root_page = minimal_site.root_page + page = AnonymIndexPage( + title="LBAI CTA Test", + slug="lbai-cta-test", + content=[_cta_block("lbai-c")], + ) + root_page.add_child(instance=page) + page.save_revision().publish() + # Confirm pre-condition: settings exists but analytics_id is not yet populated + assert not list(page.content.raw_data)[0]["value"]["button"][0]["value"]["settings"].get("analytics_id") + + call_command("populate_link_block_analytics_ids") + + page.refresh_from_db() + button = list(page.content.raw_data)[0]["value"]["button"][0]["value"] + assert "settings" in button + assert button["settings"].get("analytics_id") + + +def test_populate_link_block_analytics_ids_idempotent(minimal_site: Site) -> None: # noqa: F811 + """Does not overwrite an existing analytics_id when the command is run again.""" + existing_uid = "fixed-uid-lbai-1234" + root_page = minimal_site.root_page + section = _section_block("lbai-idem") + section["value"]["action"][0]["value"]["settings"] = {"analytics_id": existing_uid} + page = AnonymIndexPage( + title="LBAI Idempotent Test", + slug="lbai-idem-test", + content=[section], + ) + root_page.add_child(instance=page) + page.save_revision().publish() + + call_command("populate_link_block_analytics_ids") + + page.refresh_from_db() + action_link = list(page.content.raw_data)[0]["value"]["action"][0]["value"] + assert action_link["settings"]["analytics_id"] == existing_uid + + +def test_populate_link_block_analytics_ids_all_page_types(minimal_site: Site) -> None: # noqa: F811 + """Processes AnonymIndexPage, AnonymContentSubPage, AnonymNewsItemPage, and AnonymCaseStudyItemPage.""" + index_page = get_test_anonym_index_page() + cta = _cta_block("lbai-all") + + sub_page = AnonymContentSubPage(title="LBAI Sub", slug="lbai-all-sub", content=[cta]) + index_page.add_child(instance=sub_page) + sub_page.save_revision().publish() + + news_page = AnonymNewsPage(title="LBAI News", slug="lbai-all-news") + index_page.add_child(instance=news_page) + news_page.save_revision().publish() + news_item = AnonymNewsItemPage(title="LBAI News Item", slug="lbai-all-news-item", content=[cta]) + news_page.add_child(instance=news_item) + news_item.save_revision().publish() + + cs_page = AnonymCaseStudyPage(title="LBAI CS", slug="lbai-all-cs") + index_page.add_child(instance=cs_page) + cs_page.save_revision().publish() + cs_item = AnonymCaseStudyItemPage(title="LBAI CS Item", slug="lbai-all-cs-item", content=[cta]) + cs_page.add_child(instance=cs_item) + cs_item.save_revision().publish() + # Confirm pre-condition: settings exists but analytics_id is not yet populated on any page + for page_obj in [sub_page, news_item, cs_item]: + assert not list(page_obj.content.raw_data)[0]["value"]["button"][0]["value"]["settings"].get("analytics_id") + + call_command("populate_link_block_analytics_ids") + + for page_obj in [sub_page, news_item, cs_item]: + page_obj.refresh_from_db() + button = list(page_obj.content.raw_data)[0]["value"]["button"][0]["value"] + assert "settings" in button + assert button["settings"].get("analytics_id") + + +# --------------------------------------------------------------------------- +# populate_case_study_analytics_ids +# --------------------------------------------------------------------------- + + +def test_populate_case_study_analytics_ids_populates_empty_analytics_id(minimal_site: Site) -> None: # noqa: F811 + """Fills in analytics_id for case_study_item_list_block items where it is empty.""" + index_page = get_test_anonym_index_page() + + cs_page = AnonymCaseStudyPage(title="PCSA CS", slug="pcsa-cs-page") + index_page.add_child(instance=cs_page) + cs_page.save_revision().publish() + cs_item = AnonymCaseStudyItemPage(title="PCSA CS Item", slug="pcsa-cs-item") + cs_page.add_child(instance=cs_item) + cs_item.save_revision().publish() + + page = AnonymIndexPage( + title="PCSA Index", + slug="pcsa-index", + content=[_cs_section_block(cs_item.pk)], + ) + minimal_site.root_page.add_child(instance=page) + page.save_revision().publish() + # Confirm pre-condition: analytics_id exists but is not yet populated + item_before = list(page.content.raw_data)[0]["value"]["section_content"][0]["value"]["case_study_items"][0] + assert not item_before["value"].get("analytics_id") + + call_command("populate_case_study_analytics_ids") + + page.refresh_from_db() + item_value = list(page.content.raw_data)[0]["value"]["section_content"][0]["value"]["case_study_items"][0]["value"] + assert item_value["page"] == cs_item.pk + assert item_value.get("analytics_id") + + +def test_populate_case_study_analytics_ids_idempotent(minimal_site: Site) -> None: # noqa: F811 + """Does not overwrite an existing analytics_id when the item is already restructured.""" + existing_uid = "fixed-uid-pcsa-5678" + index_page = get_test_anonym_index_page() + + cs_page = AnonymCaseStudyPage(title="PCSA CS Idem", slug="pcsa-idem-cs") + index_page.add_child(instance=cs_page) + cs_page.save_revision().publish() + cs_item = AnonymCaseStudyItemPage(title="PCSA CS Item Idem", slug="pcsa-idem-cs-item") + cs_page.add_child(instance=cs_item) + cs_item.save_revision().publish() + + section = _cs_section_block(cs_item.pk, item_id="cs-idem-uuid") + # Pre-set the post-migration struct form with an existing analytics_id + section["value"]["section_content"][0]["value"]["case_study_items"][0]["value"] = { + "page": cs_item.pk, + "analytics_id": existing_uid, + } + page = AnonymIndexPage( + title="PCSA Index Idem", + slug="pcsa-idem-index", + content=[section], + ) + minimal_site.root_page.add_child(instance=page) + page.save_revision().publish() + + call_command("populate_case_study_analytics_ids") + + page.refresh_from_db() + item_value = list(page.content.raw_data)[0]["value"]["section_content"][0]["value"]["case_study_items"][0]["value"] + assert item_value["analytics_id"] == existing_uid + + +# --------------------------------------------------------------------------- +# populate_nav_button_analytics_ids +# --------------------------------------------------------------------------- + + +def test_populate_nav_button_analytics_ids_injects_uuid(minimal_site: Site) -> None: # noqa: F811 + """Injects analytics_id into nav links with has_button_appearance=True.""" + page = AnonymIndexPage( + title="PNBA Inject Test", + slug="pnba-inject", + navigation=[_nav_button_link("inject")], + ) + minimal_site.root_page.add_child(instance=page) + page.save_revision().publish() + # Confirm pre-condition: analytics_id exists but is not yet populated + assert not list(page.navigation.raw_data)[0]["value"].get("analytics_id") + + call_command("populate_nav_button_analytics_ids") + + page.refresh_from_db() + nav_value = list(page.navigation.raw_data)[0]["value"] + assert nav_value.get("analytics_id") + + +def test_populate_nav_button_analytics_ids_skips_non_button_links(minimal_site: Site) -> None: # noqa: F811 + """Does not inject analytics_id into nav links without has_button_appearance.""" + page = AnonymIndexPage( + title="PNBA Skip Test", + slug="pnba-skip", + navigation=[_nav_button_link("skip", has_button=False)], + ) + minimal_site.root_page.add_child(instance=page) + page.save_revision().publish() + # Confirm pre-condition: analytics_id is absent on non-button links + assert not list(page.navigation.raw_data)[0]["value"].get("analytics_id") + + call_command("populate_nav_button_analytics_ids") + + page.refresh_from_db() + nav_value = list(page.navigation.raw_data)[0]["value"] + assert not nav_value.get("analytics_id") + + +def test_populate_nav_button_analytics_ids_idempotent(minimal_site: Site) -> None: # noqa: F811 + """Does not overwrite an existing analytics_id when the command is run again.""" + existing_uid = "fixed-uid-pnba-9012" + page = AnonymIndexPage( + title="PNBA Idem Test", + slug="pnba-idem", + navigation=[_nav_button_link("idem", has_button=True, analytics_id=existing_uid)], + ) + minimal_site.root_page.add_child(instance=page) + page.save_revision().publish() + + call_command("populate_nav_button_analytics_ids") + + page.refresh_from_db() + nav_value = list(page.navigation.raw_data)[0]["value"] + assert nav_value["analytics_id"] == existing_uid diff --git a/bedrock/anonym/tests/test_models.py b/bedrock/anonym/tests/test_models.py index 38271eb2c9a..f067615e486 100644 --- a/bedrock/anonym/tests/test_models.py +++ b/bedrock/anonym/tests/test_models.py @@ -3,6 +3,7 @@ # file, You can obtain one at https://mozilla.org/MPL/2.0/. from unittest.mock import patch +from uuid import uuid4 from django.test import RequestFactory @@ -922,3 +923,35 @@ def test_legal_rich_text_block_renders_formatted(minimal_site: Site, rf: Request assert resp.status_code == 200 assert "mzan-legal-rich-text" in page_content assert "applicable law" in page_content + + +def test_anonym_news_item_page_analytics_id_auto_generated(minimal_site: Site) -> None: # noqa: F811 + """analytics_id is populated automatically on first save.""" + index_page = get_test_anonym_index_page() + news_page = AnonymNewsPage(title="News Auto UID", slug="news-auto-uid") + index_page.add_child(instance=news_page) + news_page.save_revision().publish() + + page = AnonymNewsItemPage(slug="test-auto-uid", title="Auto UID Test") + news_page.add_child(instance=page) + page.save() + + assert page.analytics_id != "" + assert len(page.analytics_id) == 36 # UUID v4 string length + + +def test_anonym_news_item_page_analytics_id_not_overwritten(minimal_site: Site) -> None: # noqa: F811 + """An existing analytics_id is not replaced on subsequent saves.""" + index_page = get_test_anonym_index_page() + news_page = AnonymNewsPage(title="News Preserve UID", slug="news-preserve-uid") + index_page.add_child(instance=news_page) + news_page.save_revision().publish() + + original_uuid = str(uuid4()) + page = AnonymNewsItemPage(slug="test-preserve-uid", title="Preserve UID Test", analytics_id=original_uuid) + news_page.add_child(instance=page) + page.save() + page.title = "Updated Title" + page.save() + + assert page.analytics_id == original_uuid diff --git a/bedrock/base/templatetags/helpers.py b/bedrock/base/templatetags/helpers.py index 648e13c13fa..55f49af3e1f 100644 --- a/bedrock/base/templatetags/helpers.py +++ b/bedrock/base/templatetags/helpers.py @@ -11,7 +11,7 @@ from django.contrib.staticfiles.storage import staticfiles_storage from django.urls import NoReverseMatch from django.utils.encoding import smart_str -from django.utils.safestring import mark_safe +from django.utils.safestring import SafeData, mark_safe from django.utils.text import slugify import jinja2 @@ -210,6 +210,33 @@ def add_bedrock_attributes(html): return str(soup) +@library.filter +def add_cta_analytics(html, analytics_id): + """ + Inject data-cta-text and data-cta-uid attributes onto every tag in an + HTML string, for analytics tracking. + + Intended to be chained after the |richtext filter on a NotificationSnippet's + notification_text field, where links in freeform rich text cannot receive + analytics attributes at render time through the normal template path: + + {{ value.notification_text|richtext|add_cta_analytics(value.analytics_id) }} + + data-cta-text is taken from the link's visible text (nested markup is stripped). + data-cta-uid is the analytics_id passed as argument. + """ + if not analytics_id: + return html + soup = BeautifulSoup(str(html), "html.parser") + for link in soup.find_all("a", href=True): + link["data-cta-text"] = link.get_text() + link["data-cta-uid"] = analytics_id + result = str(soup) + if isinstance(html, (SafeData, Markup)): + return mark_safe(result) + return result + + @library.filter def remove_p_tag(value: str) -> str: rich_text = RichText(value) diff --git a/bedrock/base/tests/test_helpers.py b/bedrock/base/tests/test_helpers.py index 7b1eefc8775..7a004869b35 100644 --- a/bedrock/base/tests/test_helpers.py +++ b/bedrock/base/tests/test_helpers.py @@ -4,9 +4,11 @@ from unittest.mock import patch from django.test import TestCase, override_settings +from django.utils.safestring import SafeData, mark_safe import pytest from django_jinja.backend import Jinja2 +from markupsafe import Markup from bedrock.base.templatetags import helpers from lib.l10n_utils import get_translations_native_names @@ -86,6 +88,93 @@ def test_add_bedrock_attributes(self): assert processed_html == expected_html +class TestAddCtaAnalytics: + ANALYTICS_ID = "test-uuid-1234" + + def test_adds_attributes_to_links(self): + html = '' + result = helpers.add_cta_analytics(html, self.ANALYTICS_ID) + assert 'data-cta-text="here"' in result + assert f'data-cta-uid="{self.ANALYTICS_ID}"' in result + + def test_multiple_links_all_get_attributes(self): + link1 = 'First' + link2 = 'Second' + html = f"

    {link1} and {link2}

    " + + link1_expected_result = link1.replace("{link1_expected_result} and {link2_expected_result}

    " + + result = helpers.add_cta_analytics(html, self.ANALYTICS_ID) + + assert result.count(f'data-cta-uid="{self.ANALYTICS_ID}"') == 2 + assert expected_result == result + + def test_nested_markup_in_link_text_is_stripped(self): + html = '

    bold link

    ' + + expected_result = html.replace("" not in result + assert "" not in result + assert result.startswith("" not in result + assert "" not in result + assert result.startswith("" not in result + assert "" not in result + assert result.startswith("link

    ') + result = helpers.add_cta_analytics(html, self.ANALYTICS_ID) + assert isinstance(result, SafeData) + + def test_markup_input_returns_safe_output(self): + html = Markup('

    link

    ') + result = helpers.add_cta_analytics(html, self.ANALYTICS_ID) + assert isinstance(result, SafeData) + + def test_plain_string_input_returns_plain_string(self): + html = '

    link

    ' + result = helpers.add_cta_analytics(html, self.ANALYTICS_ID) + assert not isinstance(result, (SafeData, Markup)) + + def test_empty_analytics_id_returns_html_unchanged(self): + html = '

    link

    ' + result = helpers.add_cta_analytics(html, "") + assert "data-cta" not in result + assert result == html + + def test_none_analytics_id_returns_html_unchanged(self): + html = '

    link

    ' + result = helpers.add_cta_analytics(html, None) + assert "data-cta" not in result + assert result == html + + @override_settings(LANG_GROUPS={"en": ["en-US", "en-GB"]}) def test_switch(): with patch.object(helpers, "waffle") as waffle: diff --git a/bedrock/cms/blocks.py b/bedrock/cms/blocks.py new file mode 100644 index 00000000000..ea2d6989bcc --- /dev/null +++ b/bedrock/cms/blocks.py @@ -0,0 +1,25 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. + +from uuid import uuid4 + +from wagtail import blocks + + +class UUIDBlock(blocks.CharBlock): + """ + CharBlock that auto-generates a UUID when left blank. + + Used for analytics tracking IDs. Excluded from translation. + """ + + def clean(self, value): + return super().clean(value) or str(uuid4()) + + def get_translatable_segments(self, value): + # UUIDs are analytics IDs, not user-facing content — exclude from translation + return [] + + def restore_translated_segments(self, value, segments): + return value diff --git a/bedrock/cms/tests/test_blocks.py b/bedrock/cms/tests/test_blocks.py new file mode 100644 index 00000000000..cc15035410a --- /dev/null +++ b/bedrock/cms/tests/test_blocks.py @@ -0,0 +1,33 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. + +from bedrock.cms.blocks import UUIDBlock + + +def test_uuid_block_auto_generates_when_blank(): + """clean() returns a UUID when the value is blank (requires required=False).""" + block = UUIDBlock(required=False) + result = block.clean("") + assert result != "" + assert len(result) == 36 # UUID v4 string length + + +def test_uuid_block_preserves_existing_value(): + """clean() does not replace a value that is already set.""" + block = UUIDBlock(required=False) + existing = "existing-uuid-value" + assert block.clean(existing) == existing + + +def test_uuid_block_excluded_from_translation(): + """get_translatable_segments() returns empty list so UUIDs are never sent for translation.""" + block = UUIDBlock() + assert block.get_translatable_segments("any-uuid") == [] + + +def test_uuid_block_restore_translated_segments_is_noop(): + """restore_translated_segments() returns the original value unchanged.""" + block = UUIDBlock() + value = "some-uuid" + assert block.restore_translated_segments(value, []) == value diff --git a/bedrock/mozorg/blocks/navigation.py b/bedrock/mozorg/blocks/navigation.py index 62d27fc0e51..26efde4e9a2 100644 --- a/bedrock/mozorg/blocks/navigation.py +++ b/bedrock/mozorg/blocks/navigation.py @@ -38,6 +38,12 @@ class NavigationLinkBlock(blocks.StructBlock): help_text="This link should look like a button", ) + analytics_id = blocks.CharBlock( + label="Analytics ID", + required=False, + help_text="Unique identifier for analytics tracking. Leave blank for standard nav links.", + ) + def clean(self, value): errors = {} link = value.get("link", {}) diff --git a/bedrock/mozorg/migrations/0029_alter_advertisingindexpage_sub_navigation.py b/bedrock/mozorg/migrations/0029_alter_advertisingindexpage_sub_navigation.py new file mode 100644 index 00000000000..27015ce83e4 --- /dev/null +++ b/bedrock/mozorg/migrations/0029_alter_advertisingindexpage_sub_navigation.py @@ -0,0 +1,86 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. + +# Generated by Django 5.2.12 on 2026-04-16 19:34 + +from django.db import migrations + +import wagtail.admin.forms.choosers +import wagtail.fields + + +class Migration(migrations.Migration): + dependencies = [ + ("mozorg", "0028_alter_aboutuspage_content_alter_homepage_content"), + ] + + operations = [ + migrations.AlterField( + model_name="advertisingindexpage", + name="sub_navigation", + field=wagtail.fields.StreamField( + [("link", 12)], + blank=True, + block_lookup={ + 0: ("wagtail.blocks.CharBlock", (), {"help_text": "Text to display for this navigation link", "max_length": 50}), + 1: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": [ + ("page", "Page"), + ("file", "File"), + ("custom_url", "Custom URL"), + ("email", "Email"), + ("anchor", "Anchor"), + ("phone", "Phone"), + ], + "classname": "link_choice_type_selector", + "label": "Link to", + "required": False, + }, + ), + 2: ("wagtail.blocks.PageChooserBlock", (), {"form_classname": "page_link", "label": "Page", "required": False}), + 3: ("wagtail.documents.blocks.DocumentChooserBlock", (), {"form_classname": "file_link", "label": "File", "required": False}), + 4: ( + "wagtail.blocks.CharBlock", + (), + { + "form_classname": "custom_url_link url_field", + "label": "Custom URL", + "max_length": 300, + "required": False, + "validators": [wagtail.admin.forms.choosers.URLOrAbsolutePathValidator()], + }, + ), + 5: ("wagtail.blocks.CharBlock", (), {"form_classname": "anchor_link", "label": "#", "max_length": 300, "required": False}), + 6: ("wagtail.blocks.EmailBlock", (), {"required": False}), + 7: ("wagtail.blocks.CharBlock", (), {"form_classname": "phone_link", "label": "Phone", "max_length": 30, "required": False}), + 8: ("wagtail.blocks.BooleanBlock", (), {"form_classname": "new_window_toggle", "label": "Open in new window", "required": False}), + 9: ( + "wagtail.blocks.StructBlock", + [[("link_to", 1), ("page", 2), ("file", 3), ("custom_url", 4), ("anchor", 5), ("email", 6), ("phone", 7), ("new_window", 8)]], + {}, + ), + 10: ( + "wagtail.blocks.BooleanBlock", + (), + {"default": False, "help_text": "This link should look like a button", "required": False}, + ), + 11: ( + "wagtail.blocks.CharBlock", + (), + { + "help_text": "Unique identifier for analytics tracking. Leave blank for standard nav links.", + "label": "Analytics ID", + "required": False, + }, + ), + 12: ("wagtail.blocks.StructBlock", [[("link_text", 0), ("link", 9), ("has_button_appearance", 10), ("analytics_id", 11)]], {}), + }, + help_text="Configure the sub-navigation menu items. Leave empty to use the default navigation.", + null=True, + ), + ), + ] diff --git a/bedrock/mozorg/migrations/0030_notificationsnippet_analytics_id.py b/bedrock/mozorg/migrations/0030_notificationsnippet_analytics_id.py new file mode 100644 index 00000000000..3958aab8850 --- /dev/null +++ b/bedrock/mozorg/migrations/0030_notificationsnippet_analytics_id.py @@ -0,0 +1,23 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. + +# Generated by Django 5.2.12 on 2026-04-16 19:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("mozorg", "0029_alter_advertisingindexpage_sub_navigation"), + ] + + operations = [ + migrations.AddField( + model_name="notificationsnippet", + name="analytics_id", + field=models.CharField( + blank=True, help_text="Unique identifier for analytics tracking of social links in this notification.", max_length=36 + ), + ), + ] diff --git a/bedrock/mozorg/migrations/0031_populate_notification_snippet_analytics_ids.py b/bedrock/mozorg/migrations/0031_populate_notification_snippet_analytics_ids.py new file mode 100644 index 00000000000..63469c5c3c6 --- /dev/null +++ b/bedrock/mozorg/migrations/0031_populate_notification_snippet_analytics_ids.py @@ -0,0 +1,35 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import sys +from uuid import uuid4 + +from django.db import migrations + +from bedrock.base.config_manager import config + + +def _should_skip(): + return "pytest" in sys.modules or config("SQLITE_EXPORT_MODE", parser=bool, default="false") + + +def populate_notification_snippet_analytics_ids(apps, schema_editor): + if _should_skip(): + return + NotificationSnippet = apps.get_model("mozorg", "NotificationSnippet") + for snippet in NotificationSnippet.objects.filter(analytics_id=""): + snippet.analytics_id = str(uuid4()) + snippet.save(update_fields=["analytics_id"]) + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ("mozorg", "0030_notificationsnippet_analytics_id"), + ] + + operations = [ + migrations.RunPython(populate_notification_snippet_analytics_ids, migrations.RunPython.noop), + ] diff --git a/bedrock/mozorg/models.py b/bedrock/mozorg/models.py index 33ce5eef72e..af5d05941d4 100644 --- a/bedrock/mozorg/models.py +++ b/bedrock/mozorg/models.py @@ -2,6 +2,8 @@ # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at https://mozilla.org/MPL/2.0/. +from uuid import uuid4 + from django.conf import settings from django.core.exceptions import ValidationError from django.db import models, transaction @@ -220,6 +222,11 @@ class NotificationSnippet(models.Model): max_length=255, blank=True, ) + analytics_id = models.CharField( + max_length=36, + blank=True, + help_text="Unique identifier for analytics tracking of social links in this notification.", + ) panels = [ MultiFieldPanel( @@ -228,6 +235,7 @@ class NotificationSnippet(models.Model): "notification_text", heading="Notification Text", ), + FieldPanel("analytics_id", heading="Analytics ID"), FieldPanel("linkedin_link", heading="Linkedin Link"), FieldPanel("tiktok_link", heading="Tiktok Link"), FieldPanel("spotify_link", heading="Spotify Link"), @@ -243,6 +251,11 @@ def __str__(self): return f"{strip_tags(self.notification_text)} - Notification Snippet" + def save(self, *args, **kwargs): + if not self.analytics_id: + self.analytics_id = str(uuid4()) + super().save(*args, **kwargs) + @property def has_social_links(self): return any( diff --git a/bedrock/mozorg/templates/mozorg/cms/snippets/notification_snippet.html b/bedrock/mozorg/templates/mozorg/cms/snippets/notification_snippet.html index 93b62441965..1129c3055c0 100644 --- a/bedrock/mozorg/templates/mozorg/cms/snippets/notification_snippet.html +++ b/bedrock/mozorg/templates/mozorg/cms/snippets/notification_snippet.html @@ -6,38 +6,50 @@