Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions bedrock/admin/templates/wagtailadmin/pages/copy.html
Original file line number Diff line number Diff line change
@@ -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" %}

<div class="nice-padding">
<form action="{% url 'wagtailadmin_pages:copy' page.id %}" method="POST" novalidate>
{% csrf_token %}
<input type="hidden" name="next" value="{{ next }}" />

<ul class="fields">
<li>{% formattedfield form.new_title %}</li>
<li>{% formattedfield form.new_slug %}</li>
<li>{% formattedfield form.new_parent_page %}</li>

{% if form.copy_subpages %}
<li>{% formattedfield form.copy_subpages %}</li>
{% endif %}

{% if form.publish_copies %}
<li>{% formattedfield form.publish_copies %}</li>
{% endif %}

{% if form.alias %}
<li>{% formattedfield form.alias %}</li>
{% endif %}

{% if form.keep_analytics_ids %}
<li>{% formattedfield form.keep_analytics_ids %}</li>
{% endif %}
</ul>

<input type="submit" value="{% trans 'Copy this page' %}" class="button">
</form>
</div>
{% endblock %}
26 changes: 26 additions & 0 deletions bedrock/anonym/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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():
Expand Down
197 changes: 197 additions & 0 deletions bedrock/anonym/tests/test_analytics_id_copy.py
Original file line number Diff line number Diff line change
@@ -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"<p>{heading}</p>",
"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
43 changes: 43 additions & 0 deletions bedrock/cms/blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand All @@ -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)
28 changes: 28 additions & 0 deletions bedrock/cms/forms.py
Original file line number Diff line number Diff line change
@@ -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."),
)
Loading
Loading