From 557510f3765f423658ad89512e6cbcc127ca2ace Mon Sep 17 00:00:00 2001 From: Mariana Bedran Lesche Date: Thu, 9 Jul 2026 12:32:38 -0300 Subject: [PATCH] [WT-1407] Regenerate analytics ids on page copy When pages are copied in the admin, regenerate all analytics ID values for the new page so duplicated pages don't share tracking UUIDs with their source. - Add regenerate_analytics_ids() to cms/blocks.py, walking StreamField values to replace every UUIDBlock with a fresh UUID - Add the after_copy_page hook regenerate_analytics_ids_on_copy, applied to the copied page and its copied descendants, skipped for aliases and when the editor opts out - Override Wagtail's page copy form (BedrockCopyForm) and copy.html template with a "Keep analytics IDs" opt-out checkbox - Wire copy_form_class onto AbstractBedrockCMSPage - Add tests covering regeneration, the opt-out checkbox, recursive copies, aliases and translations - Document Wagtail-upgrade override checks in copilot-instructions Ported from mozmeao/springfield#1570. --- .github/copilot-instructions.md | 12 ++ .../templates/wagtailadmin/pages/copy.html | 50 +++++ bedrock/anonym/tests/conftest.py | 26 +++ .../anonym/tests/test_analytics_id_copy.py | 197 ++++++++++++++++++ bedrock/cms/blocks.py | 43 ++++ bedrock/cms/forms.py | 28 +++ bedrock/cms/models/base.py | 4 + bedrock/cms/wagtail_hooks.py | 46 ++++ 8 files changed, 406 insertions(+) create mode 100644 bedrock/admin/templates/wagtailadmin/pages/copy.html create mode 100644 bedrock/anonym/tests/test_analytics_id_copy.py create mode 100644 bedrock/cms/forms.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index bf6415fc4e8..dca9a222fd4 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -94,6 +94,18 @@ Be particularly aware of CMS-backed content that is not richtext. Ensure it's es * Layouts use logical properties (start/end) or the BIDI mixin. * Javascript promise rejections are handled. +## 12. Wagtail version upgrades (When Relevant) + +If Wagtail or a Wagtail-related library is upgraded, verify if overrides and customizations across the codebase would be broken by the new versions. Scan the codebase for implementations that modify Wagtail behavior. In particular, scan these files: + +* `bedrock/cms/models/base.py` - the custom Page model could have attributes or methods that break Wagtail's Page implementation +* `bedrock/cms/models/images.py` - the custom Image model could have attributes or methods that break Wagtail's Image implementation +* `bedrock/cms/models/locale.py` - the custom Locale model could have attributes or methods that break Wagtail's Locale implementation +* `bedrock/cms/wagtail_hooks.py` - verify that the hooks honor the contracts expected by Wagtail +* `bedrock/admin/templates/wagtailadmin/` and `bedrock/cms/templates/wagtailcore/` - verify that the Wagtail admin and core template overrides are still compatible with the originals +* `bedrock/cms/models/pages.py`, `bedrock/anonym/models.py`, `bedrock/mozorg/models.py` and `bedrock/products/models.py` - verify that the page models, snippets, mixins and subclasses respect Wagtail's classes' implementation +* `bedrock/cms/fields.py` - verify that custom fields respect the contracts from the classes they inherit from + # What Not to Do * Do not restate what the code already clearly shows diff --git a/bedrock/admin/templates/wagtailadmin/pages/copy.html b/bedrock/admin/templates/wagtailadmin/pages/copy.html new file mode 100644 index 00000000000..40a2a4f6f37 --- /dev/null +++ b/bedrock/admin/templates/wagtailadmin/pages/copy.html @@ -0,0 +1,50 @@ +{% extends "wagtailadmin/pages/copy.html" %} + +{% comment %} SKIP LICENSE INSERTION {% endcomment %} +{% comment %} + 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/. +{% endcomment %} + +{% comment %} + Modify the original copy form to include our keep_analytics_ids field. + See BedrockCopyForm in bedrock/cms/forms.py. +{% endcomment %} + +{% load i18n wagtailadmin_tags %} + +{% block content %} + {% include "wagtailadmin/shared/header.html" with title=_("Copy") subtitle=page.specific_deferred.get_admin_display_title icon="doc-empty-inverse" %} + +
+
+ {% csrf_token %} + + +
    +
  • {% formattedfield form.new_title %}
  • +
  • {% formattedfield form.new_slug %}
  • +
  • {% formattedfield form.new_parent_page %}
  • + + {% if form.copy_subpages %} +
  • {% formattedfield form.copy_subpages %}
  • + {% endif %} + + {% if form.publish_copies %} +
  • {% formattedfield form.publish_copies %}
  • + {% endif %} + + {% if form.alias %} +
  • {% formattedfield form.alias %}
  • + {% endif %} + + {% if form.keep_analytics_ids %} +
  • {% formattedfield form.keep_analytics_ids %}
  • + {% endif %} +
+ + +
+
+{% endblock %} diff --git a/bedrock/anonym/tests/conftest.py b/bedrock/anonym/tests/conftest.py index bcd784261c3..08dabc7c2f8 100644 --- a/bedrock/anonym/tests/conftest.py +++ b/bedrock/anonym/tests/conftest.py @@ -2,6 +2,9 @@ # 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.contrib.auth import get_user_model +from django.test import override_settings + import pytest from bedrock.anonym.fixtures.base_fixtures import ( @@ -19,6 +22,29 @@ get_anonym_news_test_page, ) +User = get_user_model() + + +@pytest.fixture +def admin_client(client, db): + """Force-login a superuser using the ModelBackend rather than the project's + default SSO backend. Without the override, mozilla_django_oidc's + SessionRefresh middleware sees an OIDC-authenticated user with no OIDC + token in the session and redirects every admin GET to the auth0 login URL + (302) — which would break the 302/200 assertions below. + """ + with override_settings( + AUTHENTICATION_BACKENDS=("django.contrib.auth.backends.ModelBackend",), + USE_SSO_AUTH=False, + ): + admin = User.objects.create_superuser( + username="admin", + email="admin@example.com", + password="adminpass", + ) + client.force_login(admin, backend="django.contrib.auth.backends.ModelBackend") + yield client + @pytest.fixture def placeholder_image(): diff --git a/bedrock/anonym/tests/test_analytics_id_copy.py b/bedrock/anonym/tests/test_analytics_id_copy.py new file mode 100644 index 00000000000..30183b2a935 --- /dev/null +++ b/bedrock/anonym/tests/test_analytics_id_copy.py @@ -0,0 +1,197 @@ +# 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.urls import reverse + +import pytest + +from bedrock.anonym.fixtures.base_fixtures import get_test_anonym_index_page +from bedrock.anonym.models import AnonymContentSubPage +from bedrock.cms.blocks import regenerate_analytics_ids +from bedrock.cms.tests.factories import LocaleFactory + +pytestmark = [ + pytest.mark.django_db, +] + +# A duplicated analytics ID in the source content, so we can assert that +# regeneration still produces unique IDs even where the source repeated one. +_DUPLICATED_ID = "11111111-1111-1111-1111-111111111111" + +_LINK = {"link_to": "custom_url", "custom_url": "https://example.com", "new_window": False} + + +def _cta_block(heading, analytics_id): + """A call_to_action StreamField block whose button carries an analytics ID.""" + return { + "type": "call_to_action", + "value": { + "settings": {"anchor_id": ""}, + "heading": f"

{heading}

", + "button": [ + { + "settings": {"analytics_id": analytics_id}, + "label": heading, + "link": _LINK, + } + ], + }, + } + + +def get_analytics_test_page(slug="analytics-copy-src", title="Analytics Copy Source"): + """Create (or update) an AnonymContentSubPage whose content holds several + analytics IDs, including a duplicated one.""" + index_page = get_test_anonym_index_page() + + page = AnonymContentSubPage.objects.filter(slug=slug).first() + if not page: + page = AnonymContentSubPage(slug=slug, title=title) + index_page.add_child(instance=page) + + page.content = [ + _cta_block("First", _DUPLICATED_ID), + _cta_block("Second", "22222222-2222-2222-2222-222222222222"), + _cta_block("Third", _DUPLICATED_ID), + ] + page.save_revision().publish() + page.refresh_from_db() + return page + + +def collect_analytics_ids(data): + """Recursively collect every value stored under an analytics-id key in a + StreamField's prepared (JSON-serialisable) representation.""" + found = [] + if isinstance(data, dict): + for key, value in data.items(): + if key == "analytics_id" or key.endswith("_analytics_id"): + found.append(value) + else: + found.extend(collect_analytics_ids(value)) + elif isinstance(data, list): + for item in data: + found.extend(collect_analytics_ids(item)) + return found + + +def copy_via_admin(admin_client, page, **extra): + """POST the Wagtail copy form for ``page`` and return the response.""" + data = { + "new_title": "Content Copy", + "new_slug": "content-copy", + "new_parent_page": page.get_parent().id, + "publish_copies": "on", + **extra, + } + return admin_client.post(reverse("wagtailadmin_pages:copy", args=[page.id]), data) + + +def test_regenerate_analytics_ids_replaces_every_id_and_preserves_structure(): + page = get_analytics_test_page() + original_ids = collect_analytics_ids(page.content.get_prep_value()) + # Sanity check: the fixture actually contains analytics IDs to regenerate. + assert len(original_ids) > 1 + + regenerated_value = regenerate_analytics_ids(page.content) + new_ids = collect_analytics_ids(regenerated_value.get_prep_value()) + + # Same number of analytics-id fields — structure is preserved, not dropped. + assert len(new_ids) == len(original_ids) + # None of the original IDs survive — every one was regenerated. + assert set(new_ids).isdisjoint(original_ids) + # Every regenerated ID is unique, even where the source repeated an ID. + assert len(set(new_ids)) == len(new_ids) + # The original value is left untouched (regeneration returns a new value). + assert collect_analytics_ids(page.content.get_prep_value()) == original_ids + + +def test_admin_copy_regenerates_analytics_ids_by_default(admin_client): + page = get_analytics_test_page() + original_ids = collect_analytics_ids(page.content.get_prep_value()) + assert len(original_ids) > 1 + + response = copy_via_admin(admin_client, page) + assert response.status_code == 302 + + copied = AnonymContentSubPage.objects.get(slug="content-copy") + copied_ids = collect_analytics_ids(copied.content.get_prep_value()) + + assert len(copied_ids) == len(original_ids) + assert set(copied_ids).isdisjoint(original_ids) + assert len(set(copied_ids)) == len(copied_ids) + + +def test_admin_copy_keeps_analytics_ids_when_checkbox_checked(admin_client): + page = get_analytics_test_page() + original_ids = collect_analytics_ids(page.content.get_prep_value()) + + response = copy_via_admin(admin_client, page, keep_analytics_ids="on") + assert response.status_code == 302 + + copied = AnonymContentSubPage.objects.get(slug="content-copy") + copied_ids = collect_analytics_ids(copied.content.get_prep_value()) + + # Opt-out: the copy keeps the source page's analytics IDs exactly. + assert copied_ids == original_ids + + +def test_admin_copy_regenerates_analytics_ids_across_subpages(admin_client): + parent_page = get_analytics_test_page() + child_page = AnonymContentSubPage(slug="child-content", title="Child Content") + parent_page.add_child(instance=child_page) + child_page.content = [ + _cta_block("Child First", "33333333-3333-3333-3333-333333333333"), + _cta_block("Child Second", "44444444-4444-4444-4444-444444444444"), + ] + child_page.save_revision().publish() + child_page.refresh_from_db() + original_child_ids = collect_analytics_ids(child_page.content.get_prep_value()) + assert len(original_child_ids) > 1 + + response = copy_via_admin(admin_client, parent_page, copy_subpages="on") + assert response.status_code == 302 + + copied_parent = AnonymContentSubPage.objects.get(slug="content-copy") + copied_child = AnonymContentSubPage.objects.get(slug="child-content", path__startswith=copied_parent.path) + copied_child_ids = collect_analytics_ids(copied_child.content.get_prep_value()) + + # Subpages copied recursively also get fresh analytics IDs. + assert len(copied_child_ids) == len(original_child_ids) + assert set(copied_child_ids).isdisjoint(original_child_ids) + + +def test_admin_copy_as_alias_preserves_analytics_ids(admin_client): + page = get_analytics_test_page() + original_ids = collect_analytics_ids(page.content.get_prep_value()) + + response = copy_via_admin(admin_client, page, alias="on") + assert response.status_code == 302 + + alias = AnonymContentSubPage.objects.get(slug="content-copy") + assert alias.alias_of_id == page.id + # Aliases must mirror their original exactly, including analytics IDs. + assert collect_analytics_ids(alias.content.get_prep_value()) == original_ids + + +def test_copy_for_translation_preserves_analytics_ids(): + fr_locale = LocaleFactory(language_code="fr") + page = get_analytics_test_page() + original_ids = collect_analytics_ids(page.content.get_prep_value()) + assert len(original_ids) > 1 + + translated = page.copy_for_translation(fr_locale, copy_parents=True) + translated_ids = collect_analytics_ids(translated.content.get_prep_value()) + + # A translation keeps the source page's analytics IDs, in the same order. + assert translated_ids == original_ids + + +def test_copy_form_renders_keep_analytics_ids_checkbox(admin_client): + page = get_analytics_test_page() + + response = admin_client.get(reverse("wagtailadmin_pages:copy", args=[page.id])) + + assert response.status_code == 200 + assert b"keep_analytics_ids" in response.content diff --git a/bedrock/cms/blocks.py b/bedrock/cms/blocks.py index ea2d6989bcc..b0cb83645c3 100644 --- a/bedrock/cms/blocks.py +++ b/bedrock/cms/blocks.py @@ -2,6 +2,7 @@ # 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 copy import deepcopy from uuid import uuid4 from wagtail import blocks @@ -12,6 +13,7 @@ class UUIDBlock(blocks.CharBlock): CharBlock that auto-generates a UUID when left blank. Used for analytics tracking IDs. Excluded from translation. + When copying a page, regenerate_analytics_ids() is called to replace all UUIDs with new ones. """ def clean(self, value): @@ -23,3 +25,44 @@ def get_translatable_segments(self, value): def restore_translated_segments(self, value, segments): return value + + +def _regenerate_uuid_blocks(block, value): + """Walk a block's prepared value and replace every UUIDBlock (analytics ID) + with a freshly generated UUID, recursing into structs, streams and lists. + + Operates on the JSON-serialisable form returned by ``get_prep_value`` and + mutates it in place, returning the (possibly replaced) value.""" + if isinstance(block, UUIDBlock): + return str(uuid4()) + if isinstance(block, blocks.StructBlock): + for name, child_block in block.child_blocks.items(): + if name in value: + value[name] = _regenerate_uuid_blocks(child_block, value[name]) + elif isinstance(block, blocks.StreamBlock): + for member in value: + child_block = block.child_blocks.get(member["type"]) + if child_block is not None: + member["value"] = _regenerate_uuid_blocks(child_block, member["value"]) + elif isinstance(block, blocks.ListBlock): + for index, member in enumerate(value): + if isinstance(member, dict) and "value" in member and "type" in member: + member["value"] = _regenerate_uuid_blocks(block.child_block, member["value"]) + else: + value[index] = _regenerate_uuid_blocks(block.child_block, member) + return value + + +def regenerate_analytics_ids(stream_value): + """Return a new StreamField value with every analytics-ID UUIDBlock replaced + by a freshly generated UUID. + + Used when duplicating a page so the copy gets its own unique analytics IDs. + Translations must NOT call this — a translated page keeps the source page's + analytics IDs so tracking stays consistent across locales.""" + stream_block = stream_value.stream_block + # deepcopy so mutating the prepared data never touches the source value — + # get_prep_value() can share nested references with the live StreamValue. + prepared = deepcopy(stream_value.get_prep_value()) + _regenerate_uuid_blocks(stream_block, prepared) + return stream_block.to_python(prepared) diff --git a/bedrock/cms/forms.py b/bedrock/cms/forms.py new file mode 100644 index 00000000000..cb7f6e0ca08 --- /dev/null +++ b/bedrock/cms/forms.py @@ -0,0 +1,28 @@ +# 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 import forms +from django.utils.translation import gettext_lazy as _ + +from wagtail.admin.forms.pages import CopyForm + + +class BedrockCopyForm(CopyForm): + """Wagtail's page copy form plus a "Keep analytics IDs" opt-out. + + By default a copied page gets freshly generated analytics IDs (see the + ``after_copy_page`` hook). Ticking this box preserves the source page's IDs + instead. The checkbox is rendered by the overridden + ``wagtailadmin/pages/copy.html`` template and read from ``request.POST`` by + the hook. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["keep_analytics_ids"] = forms.BooleanField( + required=False, + initial=False, + label=_("Keep analytics IDs"), + help_text=_("Preserve the original page's analytics tracking IDs instead of generating new ones for the copy."), + ) diff --git a/bedrock/cms/models/base.py b/bedrock/cms/models/base.py index 3130f68a0f0..e801cab66d3 100644 --- a/bedrock/cms/models/base.py +++ b/bedrock/cms/models/base.py @@ -12,6 +12,7 @@ from wagtail_localize.fields import SynchronizedField from bedrock.base.i18n import normalize_language +from bedrock.cms.forms import BedrockCopyForm from bedrock.cms.utils import compute_cms_page_locales from lib import l10n_utils @@ -47,6 +48,9 @@ class AbstractBedrockCMSPage(WagtailBasePage): SynchronizedField("slug"), ] + # Add the "Keep analytics IDs" opt-out checkbox to the admin copy form. + copy_form_class = BedrockCopyForm + preview_sizes = [ {"name": "mobile", "icon": "mobile-alt", "device_width": 320, "label": "Mobile"}, {"name": "tablet", "icon": "tablet-alt", "device_width": 768, "label": "Tablet"}, diff --git a/bedrock/cms/wagtail_hooks.py b/bedrock/cms/wagtail_hooks.py index 93e34c68888..fd7b1344c53 100644 --- a/bedrock/cms/wagtail_hooks.py +++ b/bedrock/cms/wagtail_hooks.py @@ -14,8 +14,12 @@ from wagtail import hooks from wagtail.admin.menu import MenuItem from wagtail.admin.rich_text.converters.html_to_contentstate import InlineStyleElementHandler +from wagtail.fields import StreamField from wagtail.models import Locale as WagtailLocale +from bedrock.cms.blocks import regenerate_analytics_ids +from bedrock.cms.models.base import AbstractBedrockCMSPage + @hooks.register("register_admin_menu_item") def register_task_queue_link(): @@ -93,3 +97,45 @@ def register_underline_feature(features): # 6. (optional) Add the feature to the default features list to make it available # on rich text fields that do not specify an explicit 'features' list features.default_features.append("underline") + + +@hooks.register("after_copy_page") +def regenerate_analytics_ids_on_copy(request, page, new_page): + """Give freshly copied pages their own analytics IDs so duplicated pages don't + share tracking UUIDs with their source. + + Runs for every admin "Copy page" action (the admin view uses ``CopyPageAction`` + directly, so overriding ``Page.copy()`` would not catch it). Skipped when: + + * the editor ticked "Keep analytics IDs" on the copy form (opt-out), or + * the copy is an alias, which must mirror its original exactly. + + Applies to the copied page and every copied descendant, since recursive copies + build subpages without calling ``Page.copy()`` either. + """ + post = getattr(request, "POST", {}) + user = getattr(request, "user", None) + + if post.get("keep_analytics_ids"): + return + if new_page.alias_of_id: + return + + for copied_page in new_page.get_descendants(inclusive=True).specific(): + if not isinstance(copied_page, AbstractBedrockCMSPage): + continue + + stream_fields = [field for field in copied_page._meta.get_fields() if isinstance(field, StreamField)] + if not stream_fields: + continue + + for field in stream_fields: + setattr(copied_page, field.name, regenerate_analytics_ids(getattr(copied_page, field.name))) + + copied_page.save() + revision = copied_page.save_revision(user=user) if user else copied_page.save_revision() + if copied_page.live: + if user: + revision.publish(user=user) + else: + revision.publish()