diff --git a/bedrock/base/context_processors.py b/bedrock/base/context_processors.py index 0221762a436..ca57892ae81 100644 --- a/bedrock/base/context_processors.py +++ b/bedrock/base/context_processors.py @@ -13,9 +13,19 @@ def geo(request): def i18n(request): + url_locale = translation.get_language() + lang = dict(settings.LANGUAGE_URL_MAP).get(url_locale) or url_locale + # Normally, CANONICAL_LANG == LANG, but sometimes, a user requests a page + # that does not exist, but the locale has a fallback locale, so the user is + # served the content from the fallback locale at the requested URL (for + # example, the user requests /pt-PT/somepage, which does not exist, so the + # user gets /pt-BR/somepage content at the /pt-PT/somepage/ URL). In this + # case, pt-PT is the LANG, and pt-BR is the CANONICAL_LANG. + content_locale = getattr(request, "content_locale", None) return { "LANGUAGES": settings.LANGUAGES, - "LANG": (dict(settings.LANGUAGE_URL_MAP).get(translation.get_language()) or translation.get_language()), + "LANG": lang, + "CANONICAL_LANG": content_locale or lang, "DIR": "rtl" if translation.get_language_bidi() else "ltr", } diff --git a/bedrock/base/templates/includes/canonical-url.html b/bedrock/base/templates/includes/canonical-url.html index 44996f1710b..44476e632a9 100644 --- a/bedrock/base/templates/includes/canonical-url.html +++ b/bedrock/base/templates/includes/canonical-url.html @@ -5,11 +5,19 @@ #} {%- set available_languages = get_locale_options(request, translations) -%} +{%- set alias_locales = settings.FALLBACK_LOCALES|default({}) -%} - + + {%- if CANONICAL_LANG != LANG %} + + {%- endif %} {% if is_homepage %}{% endif %} {% if available_languages -%} {%- for code, label in available_languages|dictsort -%} + {#- Skip alias locales that don't have their own content -#} + {%- if code in alias_locales and content_locales is defined and code not in content_locales -%} + {%- continue -%} + {%- endif -%} {%- set alt_url = alternate_url(canonical_path, code) -%} {%- if alt_url -%} {%- set loop_canonical_path = alt_url -%} @@ -38,7 +46,6 @@ {% elif code == 'pt-PT' -%} - {% elif code == 'sv-SE' -%} @@ -46,6 +53,9 @@ {% elif code == 'zh-CN' -%} + {% elif code == 'pt-BR' -%} + + {% elif code|length != 3 -%}{#- Bug 1364470: Drop ISO 639-2 and -3 locales not supported by Google -#} {% endif -%} diff --git a/bedrock/base/templatetags/helpers.py b/bedrock/base/templatetags/helpers.py index 648e13c13fa..7cf7aa37a95 100644 --- a/bedrock/base/templatetags/helpers.py +++ b/bedrock/base/templatetags/helpers.py @@ -183,6 +183,19 @@ def get_locale_options(request, translations): if cms_locale_count > 0 and django_fallback_locale_count > 0: available_locales = get_translations_native_names(sorted(set(request._locales_available_via_cms + request._locales_for_django_fallback_view))) + # For pure Fluent pages, translations only reflects FTL-active locales and does not + # include alias locales. Add alias locales whose fallback canonical locale is already + # present. (CMS pages are already handled upstream by get_locales_for_cms_page().) + alias_additions = get_translations_native_names( + [ + alias_code + for alias_code, fallback_code in getattr(settings, "FALLBACK_LOCALES", {}).items() + if fallback_code in available_locales and alias_code not in available_locales + ] + ) + if alias_additions: + available_locales = {**available_locales, **alias_additions} + return available_locales diff --git a/bedrock/base/tests/test_context_processors.py b/bedrock/base/tests/test_context_processors.py index 9b4bb0db9b2..c5386433186 100644 --- a/bedrock/base/tests/test_context_processors.py +++ b/bedrock/base/tests/test_context_processors.py @@ -8,6 +8,7 @@ import jinja2 +from bedrock.base.context_processors import i18n from lib.l10n_utils import translation @@ -69,3 +70,20 @@ def test_invalid_geo_param(self): req = self.factory.get("/", data={"geo": "france"}) assert self.render("{{ country_code }}", req) == "None" + + def test_canonical_lang_equals_lang_normally(self): + """CANONICAL_LANG should equal LANG when there is no content_locale on the request.""" + translation.activate("fr") + req = self.factory.get("/fr/page/") + ctx = i18n(req) + assert ctx["LANG"] == "fr" + assert ctx["CANONICAL_LANG"] == "fr" + + def test_canonical_lang_uses_content_locale(self): + """CANONICAL_LANG uses request.content_locale when set (alias locale serving).""" + translation.activate("pt-PT") + req = self.factory.get("/pt-PT/page/") + req.content_locale = "pt-BR" + ctx = i18n(req) + assert ctx["LANG"] == "pt-PT" + assert ctx["CANONICAL_LANG"] == "pt-BR" diff --git a/bedrock/base/tests/test_helpers.py b/bedrock/base/tests/test_helpers.py index 7b1eefc8775..a63448638b6 100644 --- a/bedrock/base/tests/test_helpers.py +++ b/bedrock/base/tests/test_helpers.py @@ -112,6 +112,7 @@ def test_switch(): waffle.switch.assert_called_with("dude") +@override_settings(FALLBACK_LOCALES={}) @pytest.mark.parametrize( "translations_locales, cms_locales, django_locales, expected", ( @@ -159,3 +160,49 @@ def test_get_locale_options(rf, translations_locales, cms_locales, django_locale request=request, translations=native_translations, ) + + +@override_settings(FALLBACK_LOCALES={"es-AR": "es-ES", "es-CL": "es-ES"}) +def test_get_locale_options_adds_alias_locales_when_fallback_present(rf): + """Alias locales whose fallback is in translations are added to the language picker. + + For pure Fluent pages, translations only reflects FTL-active locales. When a + fallback locale (e.g. es-ES) is present, its aliases (es-AR, es-CL) must also + appear so the language picker offers them to users browsing those locales. + """ + request = rf.get("/dummy/path/") + translations = get_translations_native_names(["en-US", "es-ES", "fr"]) + + result = helpers.get_locale_options(request=request, translations=translations) + + assert "es-AR" in result + assert "es-CL" in result + assert "en-US" in result + assert "es-ES" in result + assert "fr" in result + + +@override_settings(FALLBACK_LOCALES={"es-AR": "es-ES"}) +def test_get_locale_options_does_not_add_alias_when_fallback_absent(rf): + """Alias locales are not added when their fallback locale is not in translations.""" + request = rf.get("/dummy/path/") + translations = get_translations_native_names(["en-US", "fr"]) # no es-ES + + result = helpers.get_locale_options(request=request, translations=translations) + + assert "es-AR" not in result + + +@override_settings(FALLBACK_LOCALES={"es-AR": "es-ES"}) +def test_get_locale_options_does_not_double_add_alias_already_present(rf): + """Alias locales are not duplicated when already present (e.g. from CMS path).""" + # Simulate CMS page where get_locales_for_cms_page() already added es-AR. + translations_locales = ["en-US", "es-ES", "es-AR"] + native_translations = get_translations_native_names(translations_locales) + request = rf.get("/dummy/path/") + + result = helpers.get_locale_options(request=request, translations=native_translations) + + assert len(result) == len(translations_locales) + for key in translations_locales: + assert list(result).count(key) == 1 # no duplicate diff --git a/bedrock/cms/decorators.py b/bedrock/cms/decorators.py index 88f3e57d9ce..94f28d88b41 100644 --- a/bedrock/cms/decorators.py +++ b/bedrock/cms/decorators.py @@ -6,9 +6,8 @@ from django.http import Http404 -from wagtail.views import serve as wagtail_serve - from bedrock.base.i18n import remove_lang_prefix +from bedrock.cms.views import wagtail_serve_with_locale_fallback from lib.l10n_utils.fluent import get_active_locales from .utils import get_cms_locales_for_path @@ -142,8 +141,10 @@ def wrapped_view(request, *args, **kwargs): ) try: - # Does Wagtail have a route that matches this? If so, show that page - wagtail_response = wagtail_serve(request, path) + # Does Wagtail have a route that matches this? If so, show that page. + # wagtail_serve_with_locale_fallback handles alias-locale + # transparent serving before deferring to Wagtail's own serve. + wagtail_response = wagtail_serve_with_locale_fallback(request, path) if wagtail_response.status_code == HTTP_200_OK: return wagtail_response except Http404: @@ -152,15 +153,15 @@ def wrapped_view(request, *args, **kwargs): # If the page does not exist in Wagtail, call the original view function and... # # 1) Un-mark this request as being for a CMS page (which happened - # via wagtail_serve()) to avoid lib.l10n_utils.render() incorrectly - # looking for available translations based on CMS data, rather than - # Fluent files + # via wagtail_serve_with_locale_fallback()) to avoid lib.l10n_utils.render() + # incorrectly looking for available translations based on CMS data, rather + # than Fluent files request.is_cms_page = False # 2) Make extra sure this request is still annotated with any CMS-backed # locale versions that are available, so that we can populate the # language picker appropriately. (The annotation also happened via - # wagtail_serve() thanks to AbstractBedrockCMSPage._patch_request_for_bedrock + # wagtail_serve_with_locale_fallback() thanks to AbstractBedrockCMSPage._patch_request_for_bedrock request._locales_available_via_cms = getattr( request, "_locales_available_via_cms", diff --git a/bedrock/cms/migrations/0005_create_alias_locale_records.py b/bedrock/cms/migrations/0005_create_alias_locale_records.py new file mode 100644 index 00000000000..256ee2cf532 --- /dev/null +++ b/bedrock/cms/migrations/0005_create_alias_locale_records.py @@ -0,0 +1,106 @@ +# 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 django.db import migrations + +from bedrock.base.config_manager import config + + +def create_alias_locales(apps, schema_editor): + # Skip in test environments — test fixtures create the locale records they need. + if "pytest" in sys.modules or config("SQLITE_EXPORT_MODE", parser=bool, default="false"): + return + + from wagtail.models import Locale, Page, Site + + alias_locales = ["es-AR", "es-CL", "es-MX", "pt-PT", "en-GB", "en-CA"] + + site = Site.objects.filter(is_default_site=True).select_related("root_page").first() + if not site: + return + + # The locale root pages live at depth 2 (children of the Wagtail tree + # root at depth 1). Note: site.root_page is the *homepage* (depth 3), + # not the locale root — using it would place new pages one level too deep. + en_us_locale_root = Page.objects.filter( + depth=2, + locale__language_code="en-US", + ).first() + if not en_us_locale_root: + return + wagtail_root = en_us_locale_root.get_parent() + + for code in alias_locales: + locale, _ = Locale.objects.get_or_create(language_code=code) + + if en_us_locale_root.get_translation_or_none(locale) is not None: + continue + + # Create a non-live locale root page for the alias locale at depth 2, + # alongside the other locale roots (de, fr, es-ES, etc.). + # + # We use copy() rather than copy_for_translation() because the Wagtail + # tree root (depth 1) has no per-locale translations, which causes + # copy_for_translation()'s parent-translation check to fail. + # + # keep_live=False ensures Wagtail won't serve this page at + # // — the fallback machinery + # (wagtail_serve_with_locale_fallback + find_fallback_page_for_locale) + # handles those requests and serves the canonical locale's content. + # The root page is a structural record; the fallback machinery + # does not require it to exist. + en_us_locale_root.copy( + to=wagtail_root, + update_attrs={ + "locale": locale, + "slug": f"home-{code}", + }, + copy_revisions=False, + keep_live=False, + reset_translation_key=False, + log_action=None, + ) + + +def remove_alias_locales(apps, schema_editor): + if "pytest" in sys.modules or config("SQLITE_EXPORT_MODE", parser=bool, default="false"): + return + + from wagtail.models import Locale, Page + + alias_locales = ["es-AR", "es-CL", "es-MX", "pt-PT", "en-GB", "en-CA"] + + en_us_locale_root = Page.objects.filter( + depth=2, + locale__language_code="en-US", + ).first() + if not en_us_locale_root: + return + + for code in alias_locales: + locale = Locale.objects.filter(language_code=code).first() + if not locale: + continue + + # Delete the locale root page only if it has no child pages + # (i.e. the alias locale was never promoted to a full locale with its own content). + alias_root = en_us_locale_root.get_translation_or_none(locale) + if alias_root is not None and not alias_root.get_children().exists(): + alias_root.delete() + + # Delete the Locale record if no pages remain under it. + if not Page.objects.filter(locale=locale).exists(): + locale.delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("cms", "0004_bedrocklocale"), + ] + + operations = [ + migrations.RunPython(create_alias_locales, remove_alias_locales), + ] diff --git a/bedrock/cms/models/base.py b/bedrock/cms/models/base.py index 085f5bc179d..b88d9dd724d 100644 --- a/bedrock/cms/models/base.py +++ b/bedrock/cms/models/base.py @@ -3,14 +3,16 @@ # file, You can obtain one at https://mozilla.org/MPL/2.0/. from django.conf import settings +from django.utils import translation from django.utils.cache import add_never_cache_headers from django.utils.decorators import method_decorator from django.views.decorators.cache import never_cache -from wagtail.models import Page as WagtailBasePage +from wagtail.models import Locale, Page as WagtailBasePage from wagtail_localize.fields import SynchronizedField -from bedrock.cms.utils import get_locales_for_cms_page +from bedrock.base.i18n import normalize_language +from bedrock.cms.utils import compute_cms_page_locales from lib import l10n_utils @@ -48,6 +50,58 @@ class AbstractBedrockCMSPage(WagtailBasePage): class Meta: abstract = True + @property + def localized(self): + """ + Extends Wagtail's localized to handle alias locales in FALLBACK_LOCALES. + + When the active locale is an alias (e.g. pt-PT → pt-BR) and the page has + no translation in that alias locale, returns the fallback locale's translation + instead of the source-locale original. + """ + localized = super().localized + + lang_code = normalize_language(translation.get_language()) + + if localized.locale.language_code == lang_code: + return localized + + fallback_locales = getattr(settings, "FALLBACK_LOCALES", {}) + if lang_code in fallback_locales: + fallback_code = fallback_locales[lang_code] + try: + fallback_locale = Locale.objects.get(language_code=fallback_code) + if localized.locale_id != fallback_locale.id: + fallback_page = self.get_translation_or_none(fallback_locale) + if fallback_page: + return fallback_page + except Locale.DoesNotExist: + pass + + return localized + + def get_active_locale_url(self, request=None): + """ + Returns the page URL with the locale prefix rewritten to the active + alias locale when serving alias locale content. + + If the active locale is an alias (e.g. pt-PT → pt-BR) and this page is + in the fallback locale (e.g. pt-BR), swaps the prefix so the user stays + on their preferred alias URL rather than being sent to the canonical one. + host/pt-BR/page/ → host/pt-PT/page/ + """ + url = super().get_url(request) + + active_language = normalize_language(translation.get_language()) + fallback_locales = getattr(settings, "FALLBACK_LOCALES", {}) + + if active_language in fallback_locales: + fallback_code = fallback_locales[active_language] + if self.locale.language_code == fallback_code: + url = url.replace(f"/{fallback_code}/", f"/{active_language}/", 1) + + return url + @classmethod def can_create_at(cls, parent): """Only allow users to add new child pages that are permitted by configuration.""" @@ -62,8 +116,18 @@ def _patch_request_for_bedrock(self, request): # Quick annotation to help us track the origin of the page request.is_cms_page = True - # Patch in a list of available locales for pages that are translations, not just aliases - request._locales_available_via_cms = get_locales_for_cms_page(self) + # Compute locales in two sets: + # _locales_available_via_cms – all locales, including alias expansion from + # FALLBACK_LOCALES. Used for the language picker + # and redirect decisions. + # _content_locales_via_cms – only locales with real translated content (no + # alias expansion). Used by l10n_utils.render() to + # set context["content_locales"], which the template + # uses for hreflang filtering and noindex. + all_locales, content_locales = compute_cms_page_locales(self) + request._locales_available_via_cms = all_locales + request._content_locales_via_cms = content_locales + return request def _render_with_fluent_string_support(self, request, *args, **kwargs): diff --git a/bedrock/cms/models/locale.py b/bedrock/cms/models/locale.py index 493b5f5e6e9..d54a9e2f538 100644 --- a/bedrock/cms/models/locale.py +++ b/bedrock/cms/models/locale.py @@ -60,11 +60,20 @@ def get_active(cls): logger.warning(f"[BedrockLocale.get_active] Normalization returned None, using original: {language_code}") return cls.objects.get(language_code=language_code) except cls.DoesNotExist: - # Fall back to default locale from django.conf import settings - logger.warning( - f"[BedrockLocale.get_active] Locale not found for '{normalized_code or language_code}', " - f"falling back to default: {settings.LANGUAGE_CODE}" - ) + code = normalized_code or language_code + # Before falling back all the way to the default locale, check if + # this language code has a configured fallback (e.g. pt-PT → pt-BR). + # This ensures that page.localized resolves to the fallback locale's + # pages rather than the en-US originals when the alias locale has no + # Locale DB record. + fallback_locales = getattr(settings, "FALLBACK_LOCALES", {}) + if code in fallback_locales: + try: + return cls.objects.get(language_code=fallback_locales[code]) + except cls.DoesNotExist: + pass + + logger.warning(f"[BedrockLocale.get_active] Locale not found for '{code}', falling back to default: {settings.LANGUAGE_CODE}") return cls.objects.get(language_code=settings.LANGUAGE_CODE) diff --git a/bedrock/cms/tests/decorator_test_views.py b/bedrock/cms/tests/decorator_test_views.py index 2902199953c..c7dce3b67d9 100644 --- a/bedrock/cms/tests/decorator_test_views.py +++ b/bedrock/cms/tests/decorator_test_views.py @@ -19,7 +19,7 @@ def decorated_dummy_view(request): return HttpResponse("This is a dummy response from the decorated view") -@prefer_cms(fallback_lang_codes=["fr-CA", "es-MX", "sco"]) +@prefer_cms(fallback_lang_codes=["fr-CA", "es-ES", "sco"]) def decorated_dummy_view_with_locale_strings(request): return HttpResponse("This is a dummy response from the decorated view with locale strings passed in") diff --git a/bedrock/cms/tests/templates/test-hreflang.html b/bedrock/cms/tests/templates/test-hreflang.html new file mode 100644 index 00000000000..40083d9aac8 --- /dev/null +++ b/bedrock/cms/tests/templates/test-hreflang.html @@ -0,0 +1,13 @@ +{# + 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/. +#} + + + + +{% include 'includes/canonical-url.html' %} + + + diff --git a/bedrock/cms/tests/test_alias_locale_serving.py b/bedrock/cms/tests/test_alias_locale_serving.py new file mode 100644 index 00000000000..36ea5b5d81e --- /dev/null +++ b/bedrock/cms/tests/test_alias_locale_serving.py @@ -0,0 +1,415 @@ +# 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.conf import settings +from django.test.utils import override_settings + +import pytest +from wagtail.models import Locale as WagtailLocale, Page, Site + +from bedrock.cms.tests.factories import LocaleFactory + +pytestmark = [pytest.mark.django_db] + +ALIAS_SETTINGS = { + "FALLBACK_LOCALES": { + "pt-PT": "pt-BR", + "en-GB": "en-US", + } +} + + +@pytest.fixture +def localized_site_with_alias(tiny_localized_site): + """ + Extends tiny_localized_site with a non-live pt-PT root page. + + Uses .copy() rather than .copy_for_translation() since copy_for_translation() + walks up the tree and requires the Wagtail root (depth 1) to already have a + pt-PT translation, which it never does. .copy() places the page directly + without that parent check. + """ + pt_pt_locale = LocaleFactory(language_code="pt-PT") + + en_us_locale_root = Page.objects.filter( + depth=2, + locale__language_code="en-US", + ).first() + wagtail_root = en_us_locale_root.get_parent() + + en_us_locale_root.copy( + to=wagtail_root, + update_attrs={ + "locale": pt_pt_locale, + "slug": "home-pt-PT", + }, + copy_revisions=False, + keep_live=False, + reset_translation_key=False, + log_action=None, + ) + + return tiny_localized_site + + +@pytest.fixture +def site_with_es_es_and_aliases(tiny_localized_site): + """ + Extends tiny_localized_site with: + - es-ES: real translated content (test-page + child-page) + - es-AR, es-CL: alias locales with non-live root pages only (no child-page) + + Uses copy_for_translation(copy_parents=True) for es-ES so the depth-2 site + root is also translated. Uses .copy() for the alias locale roots for the same + reason as the migration: copy_for_translation walks up to depth 1 and fails + unless every ancestor is already translated. + """ + site = Site.objects.get(is_default_site=True) + en_us_root = site.root_page + en_us_test_page = en_us_root.get_children()[0] + en_us_child = Page.objects.get(locale__language_code="en-US", slug="child-page") + wagtail_root = en_us_root.get_parent() + + es_es_locale = LocaleFactory(language_code="es-ES") + es_es_test_page = en_us_test_page.copy_for_translation(es_es_locale, copy_parents=True) + es_es_test_page.save_revision().publish() + es_es_child = en_us_child.copy_for_translation(es_es_locale) + es_es_child.save_revision().publish() + + for lang_code in ("es-AR", "es-CL"): + alias_locale = LocaleFactory(language_code=lang_code) + en_us_root.copy( + to=wagtail_root, + update_attrs={"locale": alias_locale, "slug": f"home-{lang_code}"}, + copy_revisions=False, + keep_live=False, + reset_translation_key=False, + log_action=None, + ) + + return tiny_localized_site + + +@pytest.fixture +def localized_site_with_live_alias_root(tiny_localized_site): + """ + Extends tiny_localized_site with a LIVE pt-PT root page. + The pt-PT locale has a live root but no 'test-page' translation. + This simulates an alias locale being promoted to its own live root + while not yet having all pages translated. + """ + pt_pt_locale = LocaleFactory(language_code="pt-PT") + + en_us_locale_root = Page.objects.filter( + depth=2, + locale__language_code="en-US", + ).first() + wagtail_root = en_us_locale_root.get_parent() + + en_us_locale_root.copy( + to=wagtail_root, + update_attrs={ + "locale": pt_pt_locale, + "slug": "home-pt-PT", + }, + copy_revisions=False, + keep_live=True, + reset_translation_key=False, + log_action=None, + ) + + return tiny_localized_site + + +@pytest.fixture +def localized_site_with_live_alias_and_test_page(localized_site_with_live_alias_root): + """ + Extends localized_site_with_live_alias_root by adding a live pt-PT translation + of 'test-page'. This simulates an alias locale that has been fully promoted and + has its own page — Wagtail should serve it directly. + """ + pt_pt_locale = WagtailLocale.objects.get(language_code="pt-PT") + en_us_test_page = Page.objects.get(locale__language_code="en-US", slug="test-page") + + pt_pt_test_page = en_us_test_page.copy_for_translation(pt_pt_locale) + pt_pt_test_page.title = "Página de Teste PT-PT" + pt_pt_test_page.save_revision().publish() + + return localized_site_with_live_alias_root + + +@override_settings(**ALIAS_SETTINGS) +def test_cms_alias_locale_served_transparently(localized_site_with_alias, client): + """GET /pt-PT/test-page/ returns 200 with pt-BR content, no redirect.""" + pt_br_page = Page.objects.get(locale__language_code="pt-BR", slug="test-page") + # The "test-page" does not exist in the pt-PT locale. + assert Page.objects.filter(locale__language_code="pt-PT", slug="test-page").exists() is False + + response = client.get("/pt-PT/test-page/", follow=False) + + assert response.status_code == 200 + assert pt_br_page.title in response.text + # request.content_locale is set to the fallback locale code when serving an alias + assert response.wsgi_request.content_locale == "pt-BR" + + +@override_settings(**ALIAS_SETTINGS) +def test_cms_alias_sets_locales_on_request(localized_site_with_alias, client): + assert Page.objects.filter(locale__language_code="pt-BR", slug="test-page").exists() is True + # The "test-page" does not exist in the pt-PT locale. + assert Page.objects.filter(locale__language_code="pt-PT", slug="test-page").exists() is False + + response = client.get("/pt-PT/test-page/") + + assert response.status_code == 200 + # Both pt-PT and pt-BR are in _locales_available_via_cms. + assert "pt-PT" in response.wsgi_request._locales_available_via_cms + assert "pt-BR" in response.wsgi_request._locales_available_via_cms + # The alias locale is not a content locale. + assert "pt-PT" not in response.wsgi_request._content_locales_via_cms + # The fallback locale is a content locale (since the page exists). + assert "pt-BR" in response.wsgi_request._content_locales_via_cms + + +@override_settings(**ALIAS_SETTINGS) +def test_cms_alias_no_page_returns_404(localized_site_with_alias, client): + """GET /pt-PT/nonexistent/ returns 404 when there is no matching pt-BR page.""" + assert Page.objects.filter(locale__language_code="pt-PT", slug="nonexistent").exists() is False + assert Page.objects.filter(locale__language_code="pt-BR", slug="nonexistent").exists() is False + + response = client.get("/pt-PT/nonexistent/") + + assert response.status_code == 404 + + +@override_settings(**ALIAS_SETTINGS) +def test_cms_alias_canonical_url_uses_fallback_locale(localized_site_with_alias, client): + """The canonical on an alias-served page points to the fallback locale URL.""" + assert Page.objects.filter(locale__language_code="pt-BR", slug="test-page").exists() is True + # The "test-page" does not exist in the pt-PT locale. + assert Page.objects.filter(locale__language_code="pt-PT", slug="test-page").exists() is False + + response = client.get("/pt-PT/test-page/") + + assert response.status_code == 200 + assert 'rel="canonical"' in response.text + # The pt-PT URL must NOT appear as the canonical href + canonical_line = [line for line in response.text.splitlines() if 'rel="canonical"' in line][0] + assert "/pt-PT/" not in canonical_line + # The pt-BR does appear as the canonical href + assert "/pt-BR/" in canonical_line + + +@override_settings(**ALIAS_SETTINGS) +def test_cms_alias_noindex_present(localized_site_with_alias, client): + """Alias-served pages carry noindex,follow robots meta.""" + assert Page.objects.filter(locale__language_code="pt-BR", slug="test-page").exists() is True + # The "test-page" does not exist in the pt-PT locale. + assert Page.objects.filter(locale__language_code="pt-PT", slug="test-page").exists() is False + + response = client.get("/pt-PT/test-page/") + + assert response.status_code == 200 + assert 'content="noindex,follow"' in response.text + + +@override_settings(**ALIAS_SETTINGS) +def test_cms_non_alias_no_noindex(localized_site_with_alias, client): + """A normal (non-alias) page does NOT carry the noindex meta.""" + assert Page.objects.filter(locale__language_code="pt-BR", slug="test-page").exists() is True + + response = client.get("/pt-BR/test-page/") + + assert response.status_code == 200 + assert 'content="noindex,follow"' not in response.text + + +@override_settings(**ALIAS_SETTINGS) +def test_cms_alias_hreflang_excludes_alias_locale(localized_site_with_alias, client): + """ + Alias locales without their own content are excluded from hreflang output. + pt-PT has no translated content in this fixture, so it should not appear. + """ + assert Page.objects.filter(locale__language_code="pt-BR", slug="test-page").exists() is True + # The "test-page" does not exist in the pt-PT locale. + assert Page.objects.filter(locale__language_code="pt-PT", slug="test-page").exists() is False + + response = client.get("/pt-PT/test-page/") + + assert response.status_code == 200 + assert 'hreflang="pt-PT"' not in response.text + + +@override_settings(**ALIAS_SETTINGS) +def test_cms_hreflang_pt_br_claims_bare_pt(localized_site_with_alias, client): + """ + The pt-BR page claims hreflang='pt' (bare) now that pt-PT is an alias. + Verify from a pt-BR page (the content locale URL). + """ + assert Page.objects.filter(locale__language_code="pt-BR", slug="test-page").exists() is True + + response = client.get("/pt-BR/test-page/") + + assert response.status_code == 200 + assert 'hreflang="pt"' in response.text + bare_pt_line = [line for line in response.text.splitlines() if 'hreflang="pt"' in line][0] + assert "/pt-BR/" in bare_pt_line # bare "pt" points to pt-BR, not pt-PT + + +@override_settings(FALLBACK_LOCALES={"es-AR": "es-ES", "es-CL": "es-ES"}) +def test_cms_alias_locales_excluded_from_hreflang_on_all_pages(site_with_es_es_and_aliases, client): + """ + Alias locales without their own content are excluded from hreflang on every page: + the canonical locale, the fallback target, and the alias page itself. + """ + page_path = "/test-page/child-page/" + en_us_child = Page.objects.get(locale__language_code="en-US", slug="child-page") + es_es_child = Page.objects.get(locale__language_code="es-ES", slug="child-page") + # The "child-page" does not exist in the es-AR or es-CL locales. + assert Page.objects.filter(locale__language_code="es-AR", slug="child-page").exists() is False + assert Page.objects.filter(locale__language_code="es-CL", slug="child-page").exists() is False + + # --- 1. Canonical locale (en-US) --- + response = client.get(en_us_child.url) + assert response.status_code == 200 + html = response.text + assert f'rel="canonical" href="{settings.CANONICAL_URL}/en-US{page_path}"' in html + assert 'content="noindex,follow"' not in html + assert 'hreflang="es-ES"' in html + assert 'hreflang="es-AR"' not in html + assert 'hreflang="es-CL"' not in html + + # --- 2. Fallback target (es-ES, has real content) --- + es_es_child.refresh_from_db() + response = client.get(es_es_child.url) + assert response.status_code == 200 + html = response.text + assert f'rel="canonical" href="{settings.CANONICAL_URL}/es-ES{page_path}"' in html + assert 'content="noindex,follow"' not in html + assert 'hreflang="es-AR"' not in html + assert 'hreflang="es-CL"' not in html + + # --- 3. Alias locale (es-AR) served via fallback --- + es_ar_url = es_es_child.url.replace("es-ES", "es-AR") + response = client.get(es_ar_url) + assert response.status_code == 200 + html = response.text + assert f'rel="canonical" href="{settings.CANONICAL_URL}/es-ES{page_path}"' in html + assert f'rel="canonical" href="{settings.CANONICAL_URL}/es-AR{page_path}"' not in html + assert 'content="noindex,follow"' in html + assert 'hreflang="es-ES"' in html + assert 'hreflang="es-AR"' not in html + assert 'hreflang="es-CL"' not in html + + +@override_settings(**ALIAS_SETTINGS) +def test_alias_locale_with_live_root_and_missing_page_serves_fallback(localized_site_with_live_alias_root, client): + """ + When an alias locale (pt-PT) has a live root but no translation for a specific page, + the fallback locale's (pt-BR) page is served transparently. + """ + assert Page.objects.filter(locale__language_code="pt-PT", slug="test-page").exists() is False + assert Page.objects.filter(locale__language_code="pt-BR", slug="test-page").exists() is True + + response = client.get("/pt-PT/test-page/", follow=False) + + assert response.status_code == 200 + pt_br_page = Page.objects.get(locale__language_code="pt-BR", slug="test-page") + assert pt_br_page.title in response.text + assert response.wsgi_request.content_locale == "pt-BR" + # The page's canonical language is from the fallback locale. + assert 'rel="canonical"' in response.text + canonical_line = [line for line in response.text.splitlines() if 'rel="canonical"' in line][0] + assert "/pt-PT/" not in canonical_line + assert "/pt-BR/" in canonical_line + # The page is not indexable. + assert 'content="noindex,follow"' in response.text + + +@override_settings(**ALIAS_SETTINGS) +def test_alias_locale_with_live_root_and_own_page_serves_own_content(localized_site_with_live_alias_and_test_page, client): + """ + When an alias locale has a live root AND its own translated page, Wagtail + serves the alias locale's own content directly (not the fallback locale's). + """ + # Make sure the test page exists in pt-PT and pt-BR, and the page has + # different titles. + pt_pt_page = Page.objects.get(locale__language_code="pt-PT", slug="test-page") + pt_pt_page.title = "Página de Teste PT-PT" + pt_pt_page.save() + rev = pt_pt_page.specific.save_revision() + pt_pt_page.publish(rev) + pt_br_page = Page.objects.get(locale__language_code="pt-BR", slug="test-page") + pt_br_page.title = "Página de Teste PT-BR" + pt_br_page.save() + rev = pt_br_page.specific.save_revision() + pt_br_page.publish(rev) + + response = client.get("/pt-PT/test-page/", follow=False) + + assert response.status_code == 200 + assert pt_pt_page.title in response.text + assert f"{pt_pt_page.title}" in response.text + assert f"<title>{pt_br_page.title}" not in response.text + # The page's canonical language is from the alias locale. + assert 'rel="canonical"' in response.text + canonical_line = [line for line in response.text.splitlines() if 'rel="canonical"' in line][0] + assert "/pt-PT/" in canonical_line + assert "/pt-BR/" not in canonical_line + + +@override_settings(**ALIAS_SETTINGS) +def test_alias_locale_with_live_root_and_no_fallback_returns_404(localized_site_with_live_alias_root, client): + """ + When an alias locale has a live root, Wagtail 404s on a missing page, and no + fallback page exists, the final response is 404. + """ + assert Page.objects.filter(locale__language_code="pt-PT", slug="nonexistent").exists() is False + assert Page.objects.filter(locale__language_code="pt-BR", slug="nonexistent").exists() is False + + response = client.get("/pt-PT/nonexistent/") + + assert response.status_code == 404 + + +@override_settings(**ALIAS_SETTINGS) +def test_cms_promoted_alias_locale_included_in_hreflang(tiny_localized_site, client): + """ + When an alias locale has its own translated content ('promoted'), it appears + in hreflang alternates like any other real locale. + """ + site = Site.objects.get(is_default_site=True) + en_us_root = site.root_page + en_us_test_page = en_us_root.get_children()[0] + en_us_child = Page.objects.get(locale__language_code="en-US", slug="child-page") + + pt_pt_locale = LocaleFactory(language_code="pt-PT") + pt_pt_root = en_us_root.copy_for_translation(pt_pt_locale) + pt_pt_root.live = True + pt_pt_root.save() + pt_pt_test_page = en_us_test_page.copy_for_translation(pt_pt_locale) + pt_pt_test_page.save_revision().publish() + pt_pt_child = en_us_child.copy_for_translation(pt_pt_locale) + pt_pt_child.save_revision().publish() + + page_path = "/test-page/child-page/" + pt_br_child = Page.objects.get(locale__language_code="pt-BR", slug="child-page") + + response_pt_br = client.get(pt_br_child.url) + + assert response_pt_br.status_code == 200 + html = response_pt_br.text + # Both pt-BR and pt-PT have their own content — both must appear. + assert f'hreflang="pt-BR" href="{settings.CANONICAL_URL}/pt-BR{page_path}"' in html + assert f'hreflang="pt-PT" href="{settings.CANONICAL_URL}/pt-PT{page_path}"' in html + assert 'content="noindex,follow"' not in html + + response_pt_pt = client.get(pt_pt_child.url) + + assert response_pt_pt.status_code == 200 + html = response_pt_pt.text + # Both pt-BR and pt-PT have their own content — both must appear. + assert f'hreflang="pt-BR" href="{settings.CANONICAL_URL}/pt-BR{page_path}"' in html + assert f'hreflang="pt-PT" href="{settings.CANONICAL_URL}/pt-PT{page_path}"' in html + assert 'content="noindex,follow"' not in html diff --git a/bedrock/cms/tests/test_decorators.py b/bedrock/cms/tests/test_decorators.py index c4a5a66387e..e96fe0c2ab1 100644 --- a/bedrock/cms/tests/test_decorators.py +++ b/bedrock/cms/tests/test_decorators.py @@ -3,9 +3,11 @@ # file, You can obtain one at https://mozilla.org/MPL/2.0/. +from django.test import override_settings from django.urls import path import pytest +from wagtail.models import Locale, Site from wagtail.rich_text import RichText from bedrock.base.i18n import bedrock_i18n_patterns @@ -13,7 +15,7 @@ from bedrock.cms.tests import decorator_test_views from bedrock.urls import urlpatterns as mozorg_urlpatterns -from .factories import SimpleRichTextPageFactory +from .factories import LocaleFactory, SimpleRichTextPageFactory urlpatterns = ( bedrock_i18n_patterns( @@ -61,7 +63,7 @@ "wrapped/view/path/with/locale/strings/", prefer_cms( decorator_test_views.wrapped_dummy_view, - fallback_lang_codes=["fr-CA", "es-MX", "sco"], + fallback_lang_codes=["fr-CA", "es-ES", "sco"], ), name="url_wrapped_dummy_view", ), @@ -73,6 +75,14 @@ ), name="url_wrapped_dummy_view", ), + path( + "prefer-cms-alias-test/", + prefer_cms( + decorator_test_views.wrapped_dummy_view, + fallback_lang_codes=["es-AR", "es-ES", "en-US"], + ), + name="prefer_cms_alias_test", + ), ) + mozorg_urlpatterns # we need to extend these so Jinja2 can call url() in the templates ) @@ -137,7 +147,7 @@ def test_decorating_django_view__passing_fallback_lang_codes( assert resp.status_code == 200 assert resp.text == "This is a dummy response from the decorated view with locale strings passed in" # Show that the expected locales are annotated onto the request - assert resp.wsgi_request._locales_for_django_fallback_view == ["fr-CA", "es-MX", "sco"] + assert resp.wsgi_request._locales_for_django_fallback_view == ["fr-CA", "es-ES", "sco"] assert resp.wsgi_request._locales_available_via_cms == [] # No page in CMS yet # Show the decorated view will "prefer" to render the Wagtail page when it exists @@ -149,8 +159,8 @@ def test_decorating_django_view__passing_fallback_lang_codes( resp = client.get(f"{lang_code_prefix}/decorated/view/path/with/locale/strings/", follow=True) assert resp.status_code == 200 # Show that the expected locales are annotated onto the request - assert resp.wsgi_request._locales_for_django_fallback_view == ["fr-CA", "es-MX", "sco"] - assert resp.wsgi_request._locales_available_via_cms == ["en-US"] + assert resp.wsgi_request._locales_for_django_fallback_view == ["fr-CA", "es-ES", "sco"] + assert resp.wsgi_request._locales_available_via_cms == ["en-US", "en-GB", "en-CA"] assert "This is a CMS page now, with the slug of strings" in resp.text @@ -181,7 +191,7 @@ def test_decorating_django_view__passing_callable_for_locales( assert resp.status_code == 200 # Show that the expected locales are annotated onto the request assert resp.wsgi_request._locales_for_django_fallback_view == ["sco", "es-ES"] - assert resp.wsgi_request._locales_available_via_cms == ["en-US"] + assert resp.wsgi_request._locales_available_via_cms == ["en-US", "en-GB", "en-CA"] assert "This is a CMS page now, with the slug of a-slug-here" in resp.text @@ -223,7 +233,7 @@ def test_decorating_django_view__passing_ftl_files(lang_code_prefix, minimal_sit ["test/fluentA", "test/fluentB"], force=True, ) - assert resp.wsgi_request._locales_available_via_cms == ["en-US"] + assert resp.wsgi_request._locales_available_via_cms == ["en-US", "en-GB", "en-CA"] assert "This is a CMS page now, with the slug of files" in resp.text @@ -261,7 +271,7 @@ def test_patching_in_urlconf__standard_django_view__with_locale_list( assert resp.status_code == 200 assert resp.text == "This is a dummy response from the wrapped view" # Show that the expected locales are annotated onto the request - assert resp.wsgi_request._locales_for_django_fallback_view == ["fr-CA", "es-MX", "sco"] + assert resp.wsgi_request._locales_for_django_fallback_view == ["fr-CA", "es-ES", "sco"] assert resp.wsgi_request._locales_available_via_cms == [] # No page in CMS yet # Show the decorated view will "prefer" to render the Wagtail page when it exists @@ -273,8 +283,8 @@ def test_patching_in_urlconf__standard_django_view__with_locale_list( resp = client.get(f"{lang_code_prefix}/wrapped/view/path/with/locale/strings/", follow=True) assert resp.status_code == 200 # Show that the expected locales are annotated onto the request - assert resp.wsgi_request._locales_for_django_fallback_view == ["fr-CA", "es-MX", "sco"] - assert resp.wsgi_request._locales_available_via_cms == ["en-US"] + assert resp.wsgi_request._locales_for_django_fallback_view == ["fr-CA", "es-ES", "sco"] + assert resp.wsgi_request._locales_available_via_cms == ["en-US", "en-GB", "en-CA"] assert "This is a CMS page now, with the slug of strings" in resp.text @@ -305,7 +315,7 @@ def test_patching_in_urlconf__standard_django_view__with_callback_for_locales( assert resp.status_code == 200 # Show that the expected locales are annotated onto the request assert resp.wsgi_request._locales_for_django_fallback_view == ["sco", "es-ES"] - assert resp.wsgi_request._locales_available_via_cms == ["en-US"] + assert resp.wsgi_request._locales_available_via_cms == ["en-US", "en-GB", "en-CA"] assert "This is a CMS page now, with the slug of a-slug-here" in resp.text @@ -352,7 +362,7 @@ def test_patching_in_urlconf__standard_django_view__with_fluent_files( ) assert resp.wsgi_request._locales_for_django_fallback_view == ["sco", "es-ES", "fr-CA"] - assert resp.wsgi_request._locales_available_via_cms == ["en-US"] + assert resp.wsgi_request._locales_available_via_cms == ["en-US", "en-GB", "en-CA"] assert "This is a CMS page now, with the slug of files" in resp.text @@ -446,3 +456,195 @@ def test_prefer_cms_rejects_invalid_setup(mocker, config, expect_exeption): prefer_cms(view_func=fake_view, **config) else: prefer_cms(view_func=fake_view, **config) + + +@pytest.fixture() +def prefer_cms_alias_site(tiny_localized_site): + """Site with a CMS page at /prefer-cms-alias-test/ in en-US, with es-ES locale available. + + Uses tiny_localized_site (not minimal_site) because its root page is at depth 2 with a + proper parent, which is required for copy_for_translation to succeed. + """ + site = Site.objects.get(is_default_site=True) + es_es_locale = LocaleFactory(language_code="es-ES") + # Translate the site root so child pages can be translated without copy_parents=True. + site.root_page.copy_for_translation(es_es_locale) + + en_us_page = SimpleRichTextPageFactory( + slug="prefer-cms-alias-test", + parent=site.root_page, + content=RichText("CMS page content"), + ) + en_us_page.save_revision() + en_us_page.publish(en_us_page.latest_revision) + + return {"site": site, "es_es_locale": es_es_locale, "en_us_page": en_us_page} + + +@pytest.mark.urls(__name__) +@override_settings(FALLBACK_LOCALES={"es-AR": "es-ES"}) +def test_prefer_cms_serves_alias_fallback_cms_page_when_no_alias_locale(prefer_cms_alias_site, client): + """ + prefer_cms serves the CMS's fallback page for an alias locale, rather than the static es-AR page. + + This test verifies the following scenario: + - a static page exists in an alias locale (es-AR) + - an alias locale (es-AR) does not exist + - a CMS page does exist in the fallback locale (es-ES) + In this case, the user should get the CMS's es-ES page at the es-AR URL. + """ + en_us_page = prefer_cms_alias_site["en_us_page"] + es_es_locale = prefer_cms_alias_site["es_es_locale"] + + es_es_page = en_us_page.copy_for_translation(es_es_locale) + es_es_page.content = RichText("ES-ES CMS page content") + es_es_page.save() + es_es_page.save_revision() + es_es_page.publish(es_es_page.latest_revision) + + # The es-AR locale does not exist + assert not Locale.objects.filter(language_code="es-AR").exists() + + response = client.get("/es-AR/prefer-cms-alias-test/") + + assert response.status_code == 200 + # The es-ES CMS page was served + assert "ES-ES CMS page content" in response.text + assert "dummy response from the wrapped view" not in response.text # Django view was NOT reached + assert set(response.wsgi_request._locales_available_via_cms) == {"es-ES", "en-US", "es-AR"} + + +@pytest.mark.urls(__name__) +@override_settings(FALLBACK_LOCALES={"es-AR": "es-ES"}) +def test_prefer_cms_serves_alias_fallback_cms_page_when_alias_locale_has_no_live_root(prefer_cms_alias_site, client): + """ + prefer_cms serves the CMS's fallback page for an alias locale, rather than the static es-AR page. + + This test verifies the following scenario: + - a static page exists in an alias locale (es-AR) + - an alias locale (es-AR) exists, but has no live root page + - a CMS page does exist in the fallback locale (es-ES) + In this case, the user should get the CMS's es-ES page at the es-AR URL. + """ + en_us_page = prefer_cms_alias_site["en_us_page"] + es_es_locale = prefer_cms_alias_site["es_es_locale"] + + es_es_page = en_us_page.copy_for_translation(es_es_locale) + es_es_page.content = RichText("ES-ES CMS page content") + es_es_page.save() + es_es_page.save_revision() + es_es_page.publish(es_es_page.latest_revision) + + # es-AR also exists, but its root page is not live. + es_ar_locale = LocaleFactory(language_code="es-AR") + es_ar_root = prefer_cms_alias_site["site"].root_page.copy_for_translation(es_ar_locale) + es_ar_root.live = False + es_ar_root.save() + + response = client.get("/es-AR/prefer-cms-alias-test/") + + assert response.status_code == 200 + # The es-ES CMS page was served + assert "ES-ES CMS page content" in response.text + assert "dummy response from the wrapped view" not in response.text # Django view was NOT reached + assert set(response.wsgi_request._locales_available_via_cms) == {"es-ES", "en-US", "es-AR"} + + +@pytest.mark.urls(__name__) +@override_settings(FALLBACK_LOCALES={"es-AR": "es-ES"}) +def test_prefer_cms_serves_alias_fallback_cms_page_when_alias_locale_has_live_root(prefer_cms_alias_site, client): + """ + prefer_cms serves the CMS's fallback page for an alias locale, rather than the static es-AR page. + + This test verifies the following scenario: + - a static page exists in an alias locale (es-AR) + - an alias locale (es-AR) exists, and has a live root page + - a CMS page does NOT exist in the alias locale (es-AR) + - a CMS page does exist in the fallback locale (es-ES) + In this case, the user should get the CMS's es-ES page at the es-AR URL. + """ + en_us_page = prefer_cms_alias_site["en_us_page"] + es_es_locale = prefer_cms_alias_site["es_es_locale"] + + es_es_page = en_us_page.copy_for_translation(es_es_locale) + es_es_page.content = RichText("ES-ES CMS page content") + es_es_page.save() + es_es_page.save_revision() + es_es_page.publish(es_es_page.latest_revision) + + # es-AR also exists and has a live root page. + es_ar_locale = LocaleFactory(language_code="es-AR") + es_ar_root = prefer_cms_alias_site["site"].root_page.copy_for_translation(es_ar_locale) + es_ar_root.live = True + es_ar_root.save() + + response = client.get("/es-AR/prefer-cms-alias-test/") + + assert response.status_code == 200 + # The es-ES CMS page was served + assert "ES-ES CMS page content" in response.text + assert "dummy response from the wrapped view" not in response.text # Django view was NOT reached + assert set(response.wsgi_request._locales_available_via_cms) == {"es-ES", "en-US", "es-AR"} + + +@pytest.mark.urls(__name__) +@override_settings(FALLBACK_LOCALES={"es-AR": "es-ES"}) +def test_prefer_cms_serves_direct_cms_page_for_alias_locale(prefer_cms_alias_site, client): + """ + When a CMS page exists in an alias locale, prefer_cms serves it, rather than the static alias locale page. + + This test verifies the following scenario: + - a static page exists in an alias locale (es-AR) + - a CMS page does exist in an alias locale (es-AR) + - a CMS page does exist in a fallback locale (es-ES) + In this case, the user should get the CMS's es-AR page at the es-AR URL. + """ + en_us_page = prefer_cms_alias_site["en_us_page"] + es_es_locale = prefer_cms_alias_site["es_es_locale"] + + # es-ES page must exist for the locale chain to be valid + es_es_page = en_us_page.copy_for_translation(es_es_locale) + es_es_page.content = RichText("ES-ES CMS page content") + es_es_page.save() + es_es_page.save_revision() + es_es_page.publish(es_es_page.latest_revision) + + # es-AR also has its own promoted page; the root must be live so + # _alias_needs_prewagtail_intercept returns False and Wagtail serves es-AR directly. + es_ar_locale = LocaleFactory(language_code="es-AR") + es_ar_root = prefer_cms_alias_site["site"].root_page.copy_for_translation(es_ar_locale) + es_ar_root.live = True + es_ar_root.save() + es_ar_page = en_us_page.copy_for_translation(es_ar_locale) + es_ar_page.content = RichText("ES-AR CMS page content") + es_ar_page.save() + es_ar_page.save_revision() + es_ar_page.publish(es_ar_page.latest_revision) + + response = client.get("/es-AR/prefer-cms-alias-test/") + + assert response.status_code == 200 + # The es-AR CMS page was served + assert "ES-AR CMS page content" in response.text + assert "dummy response from the wrapped view" not in response.text + assert set(response.wsgi_request._locales_available_via_cms) == {"es-ES", "en-US", "es-AR"} + + +@pytest.mark.urls(__name__) +@override_settings(FALLBACK_LOCALES={"es-AR": "es-ES"}) +def test_prefer_cms_falls_through_to_django_when_no_alias_cms_page(prefer_cms_alias_site, client): + """ + prefer_cms falls through to the Django view when CMS has no page for es-AR or es-ES. + + This test verifies the following scenario: + - a static page exists in an alias locale (es-AR) + - a CMS page does NOT exist in an alias locale (es-AR) + - a CMS page does NOT exist in a fallback locale (es-ES) + - a CMS page does exist in the default locale (en-US) + In this case, the user should get the static es-AR page at the es-AR URL. + """ + # prefer_cms_alias_site has an en-US CMS page only — no es-ES or es-AR translations. + response = client.get("/es-AR/prefer-cms-alias-test/") + assert response.status_code == 200 + assert "dummy response from the wrapped view" in response.text # Django view served + assert "CMS page content" not in response.text diff --git a/bedrock/cms/tests/test_locale_fallback_rendering.py b/bedrock/cms/tests/test_locale_fallback_rendering.py new file mode 100644 index 00000000000..8d9a836a020 --- /dev/null +++ b/bedrock/cms/tests/test_locale_fallback_rendering.py @@ -0,0 +1,109 @@ +# 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 os + +from django.conf import settings +from django.template import engines +from django.test import override_settings +from django.urls import path + +import pytest + +from bedrock.base.i18n import bedrock_i18n_patterns +from bedrock.urls import urlpatterns as bedrock_urlpatterns +from lib import l10n_utils + +pytestmark = [pytest.mark.django_db] + +TEST_TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), "templates") + + +def _hreflang_test_view(request): + return l10n_utils.render( + request, + "test-hreflang.html", + {"active_locales": ["en-US", "es-ES", "fr", "de"]}, + ) + + +urlpatterns = ( + bedrock_i18n_patterns( + path("test-hreflang/", _hreflang_test_view, name="test-hreflang"), + ) + + bedrock_urlpatterns +) + + +@pytest.fixture() +def _add_test_templates_dir(): + """Temporarily prepend the test templates directory to the Jinja2 searchpath. + + Modifies the loader's searchpath directly instead of resetting engines._engines, + which would invalidate module-level references to the Jinja2 environment used by + other tests' mock patches. + """ + jinja2_loader = engines["jinja2"].env.loader + jinja2_loader.searchpath.insert(0, TEST_TEMPLATES_DIR) + try: + yield + finally: + jinja2_loader.searchpath.remove(TEST_TEMPLATES_DIR) + + +@pytest.mark.urls(__name__) +@pytest.mark.usefixtures("_add_test_templates_dir") +@override_settings(FALLBACK_LOCALES={"es-AR": "es-ES", "es-CL": "es-ES"}) +def test_non_cms_page_hreflang_alternates(client): + """ + Non-CMS (Django/Fluent) pages render correct hreflang alternates. + + Uses a test-only view with controlled active_locales. Alias locales whose + fallback is in active_locales must not appear in hreflang. Verifies from + three perspectives: canonical locale, fallback target, and alias locale. + """ + page_path = "/test-hreflang/" + + # --- 1. Canonical locale (en-US) --- + response = client.get(f"/en-US{page_path}") + assert response.status_code == 200 + html = response.text + # The page is indexable + assert 'content="noindex,follow"' not in html + # The page is the canonical link. + assert f'rel="canonical" href="{settings.CANONICAL_URL}/en-US{page_path}"' in html + # The supported languages have hreflang entries. + assert f'hreflang="en" href="{settings.CANONICAL_URL}/en-US{page_path}"' in html + assert f'hreflang="es-ES" href="{settings.CANONICAL_URL}/es-ES{page_path}"' in html + # The alias languages do not have a hreflang entries. + assert 'hreflang="es-AR"' not in html + assert 'hreflang="es-CL"' not in html + + # --- 2. Fallback target (es-ES, has content in active_locales) --- + response = client.get(f"/es-ES{page_path}") + assert response.status_code == 200 + html = response.text + # The page is indexable + assert 'content="noindex,follow"' not in html + # The page is the canonical link. + assert f'rel="canonical" href="{settings.CANONICAL_URL}/es-ES{page_path}"' in html + # The supported languages have hreflang entries. + assert f'hreflang="en" href="{settings.CANONICAL_URL}/en-US{page_path}"' in html + assert f'hreflang="es-ES" href="{settings.CANONICAL_URL}/es-ES{page_path}"' in html + # The alias languages do not have a hreflang entries. + assert 'hreflang="es-AR"' not in html + assert 'hreflang="es-CL"' not in html + + # --- 3. Alias locale (es-AR) served via non-CMS fallback --- + response = client.get(f"/es-AR{page_path}") + assert response.status_code == 200 + html = response.text + # The page is not indexable + assert 'content="noindex,follow"' in html + # The page has a canonical link pointing to the es-ES page + assert f'rel="canonical" href="{settings.CANONICAL_URL}/es-ES{page_path}"' in html + assert f'rel="canonical" href="{settings.CANONICAL_URL}/es-AR{page_path}"' not in html + # The alias languages do not have a hreflang entries. + assert 'hreflang="es-AR"' not in html + assert 'hreflang="es-CL"' not in html diff --git a/bedrock/cms/tests/test_models.py b/bedrock/cms/tests/test_models.py index 5ed8f039e85..894dc9000a7 100644 --- a/bedrock/cms/tests/test_models.py +++ b/bedrock/cms/tests/test_models.py @@ -5,6 +5,7 @@ from unittest import mock from django.test import override_settings +from django.utils import translation import pytest from wagtail.models import Locale, Page @@ -100,9 +101,9 @@ def test_CMS_ALLOWED_PAGE_MODELS_controls_Page_can_create_at( assert page_class.can_create_at(home_page) == success_expected -@mock.patch("bedrock.cms.models.base.get_locales_for_cms_page") +@mock.patch("bedrock.cms.models.base.compute_cms_page_locales") def test__patch_request_for_bedrock__locales_available_via_cms( - mock_get_locales_for_cms_page, + mock_compute_cms_page_locales, minimal_site, rf, ): @@ -110,10 +111,101 @@ def test__patch_request_for_bedrock__locales_available_via_cms( page = SimpleRichTextPage.objects.last() # made by the minimal_site fixture - mock_get_locales_for_cms_page.return_value = ["en-US", "fr", "pt-BR"] + mock_compute_cms_page_locales.return_value = (["en-US", "fr", "pt-BR"], ["en-US", "fr"]) patched_request = page.specific._patch_request_for_bedrock(request) assert sorted(patched_request._locales_available_via_cms) == ["en-US", "fr", "pt-BR"] + assert sorted(patched_request._content_locales_via_cms) == ["en-US", "fr"] + + +@override_settings(FALLBACK_LOCALES={"pt-PT": "pt-BR"}) +def test_localized_returns_fallback_translation_for_alias_locale(tiny_localized_site): + """When the active locale is an alias (pt-PT) and the page has no pt-PT + translation, localized returns the pt-BR translation instead.""" + en_us_page = Page.objects.get(locale__language_code="en-US", slug="test-page") + pt_br_page = Page.objects.get(locale__language_code="pt-BR", slug="test-page") + # The page has no pt-PT translation. + assert Page.objects.filter(locale__language_code="pt-PT", slug="test-page").exists() is False + + with translation.override("pt-pt"): # Django uses lowercase internally + result = en_us_page.specific.localized + + assert result.pk == pt_br_page.pk + assert result.locale.language_code == "pt-BR" + + +@override_settings(FALLBACK_LOCALES={"pt-PT": "pt-BR"}) +def test_localized_returns_alias_translation_for_alias_locale_when_alias_translation_exists(tiny_localized_site): + """When the active locale is an alias (pt-PT) and the page has a pt-PT + translation, localized returns the pt-PT translation.""" + en_us_page = Page.objects.get(locale__language_code="en-US", slug="test-page") + # Translate the page into pt-PT: create the locale, copy the root, then the page. + pt_pt_locale = LocaleFactory(language_code="pt-PT") + en_us_page.get_parent().copy_for_translation(pt_pt_locale) + pt_pt_page = en_us_page.copy_for_translation(pt_pt_locale) + pt_pt_page.save_revision().publish() + pt_pt_page.refresh_from_db() + + with translation.override("pt-pt"): # Django uses lowercase internally + result = en_us_page.specific.localized + + assert result.pk == pt_pt_page.pk + assert result.locale.language_code == "pt-PT" + + +@override_settings(FALLBACK_LOCALES={"pt-PT": "pt-BR"}) +def test_localized_returns_original_when_no_fallback_translation(tiny_localized_site): + """When alias locale is active but no fallback translation exists, localized + falls back to Wagtail's default (returns the en-US original).""" + en_us_page = Page.objects.get(locale__language_code="en-US", slug="test-page") + pt_br_page = Page.objects.get(locale__language_code="pt-BR", slug="test-page") + pt_br_page.delete() + # The page has no pt-PT translation. + assert Page.objects.filter(locale__language_code="pt-PT", slug="test-page").exists() is False + + with translation.override("pt-pt"): + result = en_us_page.specific.localized + + # Wagtail default: returns en-US when no translation found + assert result.locale.language_code == "en-US" + + +def test_localized_non_alias_locale_unchanged(tiny_localized_site): + """For a non-alias locale, localized behaves exactly like Wagtail's default.""" + en_us_page = Page.objects.get(locale__language_code="en-US", slug="test-page") + fr_page = Page.objects.get(locale__language_code="fr", slug="test-page") + + with translation.override("fr"): + result = en_us_page.specific.localized + + assert result.pk == fr_page.pk + + +@override_settings(FALLBACK_LOCALES={"pt-PT": "pt-BR"}, WAGTAILFRONTENDCHACHE={}) +def test_get_active_locale_url_rewrites_prefix_for_alias_locale(tiny_localized_site): + """When the active locale is pt-PT (alias for pt-BR), get_active_locale_url + returns the URL with /pt-BR/ replaced by /pt-PT/.""" + pt_br_page = Page.objects.get(locale__language_code="pt-BR", slug="test-page") + # The page has no pt-PT translation. + assert Page.objects.filter(locale__language_code="pt-PT", slug="test-page").exists() is False + + with translation.override("pt-pt"): + url = pt_br_page.specific.get_active_locale_url() + + assert "/pt-PT/" in url + assert "/pt-BR/" not in url + + +def test_get_active_locale_url_unchanged_for_non_alias_locale(tiny_localized_site): + """For a non-alias locale, get_active_locale_url returns the standard URL.""" + fr_page = Page.objects.get(locale__language_code="fr", slug="test-page") + + with translation.override("fr"): + url = fr_page.specific.get_active_locale_url() + + assert "/fr/" in url + assert "/pt-BR/" not in url + assert "/pt-PT/" not in url def test__patch_request_for_bedrock_annotates_is_cms_page(tiny_localized_site, rf): diff --git a/bedrock/cms/tests/test_utils.py b/bedrock/cms/tests/test_utils.py index f925fa3ce2f..8cac3dfde64 100644 --- a/bedrock/cms/tests/test_utils.py +++ b/bedrock/cms/tests/test_utils.py @@ -3,11 +3,13 @@ # file, You can obtain one at https://mozilla.org/MPL/2.0/. +from django.test import override_settings + import pytest from wagtail.coreutils import get_dummy_request from wagtail.models import Locale, Page -from bedrock.cms.utils import get_cms_locales_for_path, get_locales_for_cms_page, get_page_for_request +from bedrock.cms.utils import find_fallback_page_for_locale, get_cms_locales_for_path, get_locales_for_cms_page, get_page_for_request pytestmark = [pytest.mark.django_db] @@ -20,7 +22,7 @@ def test_get_locales_for_cms_page(tiny_localized_site): # match the pages set up in the tiny_localized_site fixture assert Page.objects.filter(alias_of__isnull=False).count() == 0 - assert sorted(get_locales_for_cms_page(en_us_test_page)) == ["en-US", "fr", "pt-BR"] + assert sorted(get_locales_for_cms_page(en_us_test_page)) == ["en-CA", "en-GB", "en-US", "fr", "pt-BR", "pt-PT"] # now make aliases of the test_page into Dutch and Spanish nl_locale = Locale.objects.create(language_code="nl") @@ -34,8 +36,9 @@ def test_get_locales_for_cms_page(tiny_localized_site): assert Page.objects.filter(alias_of__isnull=False).count() == 4 # 2 child + 2 parent pages, which had to be copied too - # Show that the aliases don't appear in the available locales - assert sorted(get_locales_for_cms_page(en_us_test_page)) == ["en-US", "fr", "pt-BR"] + # Show that the Wagtail page aliases don't appear in the available locales + # (FALLBACK_LOCALES alias expansion still applies for en-US and pt-BR) + assert sorted(get_locales_for_cms_page(en_us_test_page)) == ["en-CA", "en-GB", "en-US", "fr", "pt-BR", "pt-PT"] def test_get_locales_for_cms_page__ensure_draft_pages_are_excluded(tiny_localized_site): @@ -46,7 +49,7 @@ def test_get_locales_for_cms_page__ensure_draft_pages_are_excluded(tiny_localize fr_test_page.unpublish() - assert sorted(get_locales_for_cms_page(en_us_test_page)) == ["en-US", "pt-BR"] + assert sorted(get_locales_for_cms_page(en_us_test_page)) == ["en-CA", "en-GB", "en-US", "pt-BR", "pt-PT"] @pytest.mark.parametrize( @@ -142,3 +145,42 @@ def test_get_cms_locales_for_path( if get_page_for_request_should_return_a_page: mock_get_page_for_request.assert_called_once_with(request=request) mock_get_locales_for_cms_page.assert_called_once_with(page=page) + + +@override_settings(FALLBACK_LOCALES={"pt-PT": "pt-BR"}) +def test_find_fallback_page_for_locale_returns_fallback_page(tiny_localized_site): + """Alias locale with a live fallback page returns that page.""" + pt_br_page = Page.objects.get(locale__language_code="pt-BR", slug="test-page") + + fallback_page = find_fallback_page_for_locale("pt-PT", "test-page/") + + assert fallback_page == pt_br_page + + +@override_settings(FALLBACK_LOCALES={"pt-PT": "pt-BR"}) +def test_find_fallback_page_for_locale_returns_none_when_no_fallback_page(tiny_localized_site): + """Alias locale configured but no page at the path in fallback locale → None.""" + assert Page.objects.filter(slug="nonexistent-path").exists() is False + fallback_page = find_fallback_page_for_locale("pt-PT", "nonexistent-path/") + assert fallback_page is None + + +@override_settings(FALLBACK_LOCALES={"pt-PT": "pt-BR"}) +def test_find_fallback_page_for_locale_returns_none_for_non_alias_locale(tiny_localized_site): + """Locale not in FALLBACK_LOCALES → None (no fallback lookup performed).""" + # The fr Page exists in the database. + assert Page.objects.filter(locale__language_code="fr", slug="test-page").exists() is True + + fallback_page = find_fallback_page_for_locale("fr", "test-page/") + + # Since "fr" is not in the FALLBACK_LOCALES, the function returns None. + assert fallback_page is None + + +@override_settings(FALLBACK_LOCALES={"pt-PT": "pt-BR"}) +def test_find_fallback_page_for_locale_returns_none_for_draft_page(tiny_localized_site): + """Fallback page exists but is unpublished → None.""" + pt_br_page = Page.objects.get(locale__language_code="pt-BR", slug="test-page") + pt_br_page.unpublish() + fallback_page = find_fallback_page_for_locale("pt-PT", "test-page/") + assert fallback_page is None diff --git a/bedrock/cms/utils.py b/bedrock/cms/utils.py index eed3b69b408..04f4fe73881 100644 --- a/bedrock/cms/utils.py +++ b/bedrock/cms/utils.py @@ -1,14 +1,19 @@ # 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 logging + +from django.conf import settings from django.core.exceptions import ObjectDoesNotExist from django.db.models import Subquery from django.http import Http404 -from wagtail.models import Locale, Page +from wagtail.models import Locale, Page, Site from bedrock.base.i18n import split_path_and_normalize_language +logger = logging.getLogger(__name__) + def get_page_for_request(*, request): """For the given HTTPRequest (and its path) find the corresponding Wagtail @@ -31,11 +36,47 @@ def get_page_for_request(*, request): return page -def get_locales_for_cms_page(page): - # Patch in a list of CMS-available locales for pages that are - # translations, not just aliases +def find_fallback_page_for_locale(locale_code, url_path): + """ + For an alias locale (e.g. 'pt-PT'), find the corresponding live page + in the fallback locale's page tree (e.g. 'pt-BR'). + url_path is the bare path without locale prefix (normalized internally). + Returns a Page instance or None. + """ + fallback_locale_code = getattr(settings, "FALLBACK_LOCALES", {}).get(locale_code) + if not fallback_locale_code: + return None - locales_available_via_cms = [page.locale.language_code] + try: + fallback_locale = Locale.objects.get(language_code=fallback_locale_code) + except Locale.DoesNotExist: + return None + + site = Site.objects.filter(is_default_site=True).select_related("root_page").first() + if not site: + return None + try: + locale_root = site.root_page.get_translation(fallback_locale) + except Page.DoesNotExist: + logger.exception("No root page translation found for fallback locale %r", fallback_locale_code) + return None + + _url_path = url_path.strip("/") + if not _url_path: + return locale_root if locale_root.live else None + full_url_path = f"{locale_root.url_path}{_url_path}/" + + return Page.objects.live().filter(url_path=full_url_path).first() + + +def compute_cms_page_locales(page): + """ + Return a tuple of locales: (all_locales, content_locales) for a CMS page. + + all_locales: content_locales + alias locales from FALLBACK_LOCALES. + content_locales: locales with real translated content (no alias expansion). + """ + content_locales = [page.locale.language_code] try: _actual_translations = ( page.get_translations() @@ -46,12 +87,26 @@ def get_locales_for_cms_page(page): ) ) ) - locales_available_via_cms += [x.locale.language_code for x in _actual_translations] + content_locales += [x.locale.language_code for x in _actual_translations] except ValueError: # when there's no draft and no potential for aliases, etc, the above lookup will fail pass - return locales_available_via_cms + # Expand with alias locales from FALLBACK_LOCALES reverse map. + # e.g. if pt-BR is in the list, also add pt-PT. + alias_additions = [alias for alias, target in getattr(settings, "FALLBACK_LOCALES", {}).items() if target in content_locales] + + all_locales = list(dict.fromkeys(content_locales + alias_additions)) + deduped_content = list(dict.fromkeys(content_locales)) + + return all_locales, deduped_content + + +def get_locales_for_cms_page(page): + # Patch in a list of CMS-available locales for pages that are + # translations, not just aliases + all_locales, _ = compute_cms_page_locales(page) + return all_locales def get_cms_locales_for_path(request): diff --git a/bedrock/cms/views.py b/bedrock/cms/views.py new file mode 100644 index 00000000000..0a76a951442 --- /dev/null +++ b/bedrock/cms/views.py @@ -0,0 +1,118 @@ +# 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.conf import settings +from django.http import Http404, HttpResponseRedirect + +from wagtail.models import Locale as WagtailLocale, Site +from wagtail.views import serve as wagtail_serve + +from bedrock.cms.utils import find_fallback_page_for_locale + + +def _alias_needs_prewagtail_intercept(lang_prefix): + """ + Return True if the alias locale requires interception before Wagtail handles the request: + + 1. If lang_prefix is not a configured alias locale, we do not intercept. + 2. Otherwise, pre-Wagtail interception is needed when Wagtail cannot correctly + serve pages for this alias locale on its own. Either of these: + a. No Locale DB record exists, OR + b. The Locale record exists but site.root_page has no live translation in this locale. + Wagtail's default behaviour would serve the en-US homepage, which is not + what we want for alias locales. + """ + fallback_locales = getattr(settings, "FALLBACK_LOCALES", {}) + if lang_prefix not in fallback_locales: + return False + + alias_locale = WagtailLocale.objects.filter(language_code=lang_prefix).first() + if not alias_locale: + return True + + site = Site.objects.filter(is_default_site=True).select_related("root_page").first() + if not site: + return False + + alias_root = site.root_page.get_translation_or_none(alias_locale) + return not alias_root or not alias_root.live + + +def _serve_fallback_page(request, lang_prefix, sub_path, fallback_locales): + """ + Find and serve the fallback locale's page for an alias locale URL. + + Returns an HttpResponse if a fallback page is found, or None if no fallback + page exists (allowing the caller to fall through to other checks). + + View-restricted pages are never transparently served; they redirect to the + canonical URL so Wagtail's restriction enforcement fires there. + """ + fallback_page = find_fallback_page_for_locale(lang_prefix, sub_path) + if not fallback_page: + return None + + if fallback_page.get_view_restrictions(): + return HttpResponseRedirect(fallback_page.url) + + specific_page = fallback_page.specific + request.content_locale = fallback_locales[lang_prefix] + # Note: we intentionally do NOT call translation.activate(request.content_locale) + # here, even though it would make BedrockLocale.get_active() resolve to the + # fallback locale's Wagtail Locale object. + # The reason: Django's LocalePrefixPattern.language_prefix reads + # translation.get_language() directly, so activating a different locale + # (e.g. pt-BR while serving /pt-PT/) would cause all url() template calls + # to generate links with the wrong locale prefix. + # Setting request.content_locale carries the fallback locale into the render + # pipeline without disturbing URL generation. + return specific_page.serve(request) + + +def wagtail_serve_with_locale_fallback(request, path=""): + """ + Wagtail serve wrapper that handles alias-locale fallback. + + This can be used both as the Wagtail catch-all URL pattern (replacing + ``wagtail.views.serve``) and by ``prefer_cms`` (replacing its direct + ``wagtail_serve`` call). + + For alias locales that Wagtail cannot serve correctly on its own + (no Locale DB record, or no live root page translation), this view + tries to serve the fallback locale's page before deferring to Wagtail. + + For alias locales with a live root page in Wagtail, but no page at the specific + path (Wagtail would Http404), the fallback locale's page is also tried. + + If no fallback page exists either, raises Http404 so that callers + (prefer_cms, middleware) can apply their own fallback logic. + """ + _path = request.path.lstrip("/") + lang_prefix, _, sub_path = _path.partition("/") + fallback_locales = getattr(settings, "FALLBACK_LOCALES", {}) + + if _alias_needs_prewagtail_intercept(lang_prefix): + response = _serve_fallback_page(request, lang_prefix, sub_path, fallback_locales) + if response is not None: + return response + # No fallback page and Wagtail can't serve this alias locale + # correctly — raise 404 so: + # - prefer_cms can fall through to its Django view + # - CMSLocaleFallbackMiddleware can try the Accept-Language redirect + raise Http404 + + # Try to serve the response with Wagtail, but if Wagtail gives us a 404 for + # an alias locale, then we try to serve the fallback page. + # If we can't serve the fallback page, then keep Wagtail's 404, so: + # - prefer_cms can fall through to its Django view + # - CMSLocaleFallbackMiddleware can try the Accept-Language redirect + try: + wagtail_response = wagtail_serve(request, path) + except Http404: + if lang_prefix in fallback_locales: + response = _serve_fallback_page(request, lang_prefix, sub_path, fallback_locales) + if response is not None: + return response + raise + return wagtail_response diff --git a/bedrock/cms/wagtail_hooks.py b/bedrock/cms/wagtail_hooks.py index 8d419ffd24c..4effdbf016e 100644 --- a/bedrock/cms/wagtail_hooks.py +++ b/bedrock/cms/wagtail_hooks.py @@ -2,14 +2,19 @@ # 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 json + +from django.conf import settings from django.templatetags.static import static from django.urls import reverse from django.utils.html import format_html +from django.utils.safestring import mark_safe import wagtail.admin.rich_text.editors.draftail.features as draftail_features from wagtail import hooks from wagtail.admin.menu import MenuItem from wagtail.admin.rich_text.converters.html_to_contentstate import InlineStyleElementHandler +from wagtail.models import Locale as WagtailLocale @hooks.register("register_admin_menu_item") @@ -37,6 +42,28 @@ def global_admin_css(): return format_html('<link rel="stylesheet" href="{}">', static("css/cms/wagtail_admin.css")) +@hooks.register("insert_global_admin_js") +def mark_locale_roles_in_admin(): + """Adds 'alias → X' badges next to alias locale names on the locales list page. + + Injects the alias map as window.WAGTAIL_LOCALE_ALIAS_MAP, then loads the + static JS file that reads it and applies the badges to the DOM. + """ + fallback_locales = getattr(settings, "FALLBACK_LOCALES", {}) + if not fallback_locales: + return "" + + alias_rows = WagtailLocale.objects.filter(language_code__in=fallback_locales.keys()).values_list("id", "language_code") + alias_id_map = {id: fallback_locales[code] for id, code in alias_rows} + if not alias_id_map: + return "" + + return mark_safe( + f"<script>window.WAGTAIL_LOCALE_ALIAS_MAP = {json.dumps(alias_id_map)};</script>" + f'<script src="{static("js/wagtailadmin-locale-badges.js")}"></script>' + ) + + @hooks.register("register_rich_text_features") def register_underline_feature(features): """ diff --git a/bedrock/cms/wagtail_urls.py b/bedrock/cms/wagtail_urls.py new file mode 100644 index 00000000000..aad812b02af --- /dev/null +++ b/bedrock/cms/wagtail_urls.py @@ -0,0 +1,47 @@ +# 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/. + +""" +Custom Wagtail URL configuration that replaces the catch-all serve view. + +Wagtail's default urls.py registers a catch-all regex that routes all +remaining page-like paths to ``wagtail.views.serve``. This module keeps +Wagtail's utility URL patterns (password-protected page auth, frontend +login) but swaps the catch-all with our own view that handles alias-locale +fallback *before* deferring to Wagtail's serve. + +Because this lives in the URL router, it only runs for paths that no other +Django view (including ``prefer_cms``-decorated views) has claimed. +""" + +import logging + +from django.urls import re_path + +from wagtail.coreutils import WAGTAIL_APPEND_SLASH +from wagtail.urls import urlpatterns as _wagtail_urlpatterns + +from bedrock.cms.views import wagtail_serve_with_locale_fallback + +logger = logging.getLogger(__name__) + +if WAGTAIL_APPEND_SLASH: + _serve_pattern = r"^((?:[\w\-]+/)*)$" +else: + _serve_pattern = r"^([\w\-/]*)$" + +# Keep all Wagtail utility patterns; replace only the catch-all serve view. +urlpatterns = [p for p in _wagtail_urlpatterns if getattr(p, "name", None) != "wagtail_serve"] + +_removed_count = len(_wagtail_urlpatterns) - len(urlpatterns) +if _removed_count != 1: + logger.error( + "Expected to remove exactly 1 pattern ('wagtail_serve') from Wagtail's urlpatterns, but removed %d. Wagtail patterns: %r", + _removed_count, + _wagtail_urlpatterns, + ) + +urlpatterns.append( + re_path(_serve_pattern, wagtail_serve_with_locale_fallback, name="wagtail_serve"), +) diff --git a/bedrock/firefox/templates/firefox/all/base.html b/bedrock/firefox/templates/firefox/all/base.html index a1a257e3b2b..ee0d4471025 100644 --- a/bedrock/firefox/templates/firefox/all/base.html +++ b/bedrock/firefox/templates/firefox/all/base.html @@ -21,6 +21,7 @@ {% if product %} {# do not index or follow child pages, we prefer people visit more user friendly pages from search results bedrock/16104 #} <meta name="robots" content="none"> + <link rel="canonical" href="{{ settings.CANONICAL_URL + '/' + CANONICAL_LANG + canonical_path }}"> {% else %} {{ super() }} {% endif %} diff --git a/bedrock/firefox/templates/firefox/browsers/mobile/get-ios.html b/bedrock/firefox/templates/firefox/browsers/mobile/get-ios.html index f565e9548fd..19e6ebac757 100644 --- a/bedrock/firefox/templates/firefox/browsers/mobile/get-ios.html +++ b/bedrock/firefox/templates/firefox/browsers/mobile/get-ios.html @@ -8,7 +8,10 @@ {% extends "firefox/base/base-protocol.html" %} -{% block canonical_urls %}<meta name="robots" content="noindex,follow">{% endblock %} +{% block canonical_urls %} +<meta name="robots" content="noindex,follow"> +<link rel="canonical" href="{{ settings.CANONICAL_URL + '/' + CANONICAL_LANG + canonical_path }}"> +{% endblock %} {% block page_css %} {{ css_bundle('get-ios-attr') }} diff --git a/bedrock/firefox/templates/firefox/developer/firstrun.html b/bedrock/firefox/templates/firefox/developer/firstrun.html index 6e8a264f2bf..d7dd0011d7e 100644 --- a/bedrock/firefox/templates/firefox/developer/firstrun.html +++ b/bedrock/firefox/templates/firefox/developer/firstrun.html @@ -6,8 +6,10 @@ {% extends "firefox/base/base-protocol.html" %} -{# "noindex" pages should not have the canonical or hreflang tags: bug 1442331 #} -{% block canonical_urls %}<meta name="robots" content="noindex,follow">{% endblock %} +{% block canonical_urls %} +<meta name="robots" content="noindex,follow"> +<link rel="canonical" href="{{ settings.CANONICAL_URL + '/' + CANONICAL_LANG + canonical_path }}"> +{% endblock %} {% block page_title %}{{ ftl('firefox-developer-page-title') }}{% endblock %} {% block page_desc %}{{ ftl('firefox-developer-firefox-developer-edition-desc') }}{% endblock %} diff --git a/bedrock/firefox/templates/firefox/installer-help.html b/bedrock/firefox/templates/firefox/installer-help.html index bf4728c62d6..546d3996034 100644 --- a/bedrock/firefox/templates/firefox/installer-help.html +++ b/bedrock/firefox/templates/firefox/installer-help.html @@ -7,8 +7,10 @@ {% extends "/firefox/base/base-protocol.html" %} {% from "macros-protocol.html" import callout with context %} -{# "noindex" pages should not have the canonical or hreflang tags: bug 1442331 #} -{% block canonical_urls %}<meta name="robots" content="noindex,follow">{% endblock %} +{% block canonical_urls %} +<meta name="robots" content="noindex,follow"> +<link rel="canonical" href="{{ settings.CANONICAL_URL + '/' + CANONICAL_LANG + canonical_path }}"> +{% endblock %} {% block page_title %}{{ ftl('installer-help-page-title') }}{% endblock %} {% block body_id %}installer-help{% endblock %} diff --git a/bedrock/firefox/templates/firefox/landing/education.html b/bedrock/firefox/templates/firefox/landing/education.html index b5fa3e31798..936ec3e696a 100644 --- a/bedrock/firefox/templates/firefox/landing/education.html +++ b/bedrock/firefox/templates/firefox/landing/education.html @@ -8,7 +8,10 @@ {% extends "firefox/new/desktop/base.html" %} -{% block canonical_urls %}<meta name="robots" content="noindex,follow">{% endblock %} +{% block canonical_urls %} +<meta name="robots" content="noindex,follow"> +<link rel="canonical" href="{{ settings.CANONICAL_URL + '/' + CANONICAL_LANG + canonical_path }}"> +{% endblock %} {% block experiments %}{% endblock %} diff --git a/bedrock/firefox/templates/firefox/landing/gaming.html b/bedrock/firefox/templates/firefox/landing/gaming.html index 8c57cb64fc1..a8d976b6b18 100644 --- a/bedrock/firefox/templates/firefox/landing/gaming.html +++ b/bedrock/firefox/templates/firefox/landing/gaming.html @@ -8,7 +8,10 @@ {% extends "firefox/new/desktop/base.html" %} -{% block canonical_urls %}<meta name="robots" content="noindex,follow">{% endblock %} +{% block canonical_urls %} +<meta name="robots" content="noindex,follow"> +<link rel="canonical" href="{{ settings.CANONICAL_URL + '/' + CANONICAL_LANG + canonical_path }}"> +{% endblock %} {% block experiments %}{% endblock %} diff --git a/bedrock/firefox/templates/firefox/landing/get.html b/bedrock/firefox/templates/firefox/landing/get.html index f6b3316701c..dbdcdc1303f 100644 --- a/bedrock/firefox/templates/firefox/landing/get.html +++ b/bedrock/firefox/templates/firefox/landing/get.html @@ -6,7 +6,10 @@ {% extends "firefox/new/desktop/download.html" %} -{% block canonical_urls %}<meta name="robots" content="noindex,follow">{% endblock %} +{% block canonical_urls %} +<meta name="robots" content="noindex,follow"> +<link rel="canonical" href="{{ settings.CANONICAL_URL + '/' + CANONICAL_LANG + canonical_path }}"> +{% endblock %} {% block experiments %}{% endblock %} diff --git a/bedrock/firefox/templates/firefox/landing/tech.html b/bedrock/firefox/templates/firefox/landing/tech.html index 3260c08c528..fc1294f5e79 100644 --- a/bedrock/firefox/templates/firefox/landing/tech.html +++ b/bedrock/firefox/templates/firefox/landing/tech.html @@ -6,7 +6,10 @@ {% extends "firefox/new/desktop/download.html" %} -{% block canonical_urls %}<meta name="robots" content="noindex,follow">{% endblock %} +{% block canonical_urls %} +<meta name="robots" content="noindex,follow"> +<link rel="canonical" href="{{ settings.CANONICAL_URL + '/' + CANONICAL_LANG + canonical_path }}"> +{% endblock %} {% set win_custom_download_id = 'partner-firefox-release-smi-smi-001-stub' %} {% set mac_custom_download_id = 'partner-firefox-release-smi-smi-001-latest' %} diff --git a/bedrock/firefox/templates/firefox/new/basic/thanks.html b/bedrock/firefox/templates/firefox/new/basic/thanks.html index 38de65a8a2e..7567a1a8381 100644 --- a/bedrock/firefox/templates/firefox/new/basic/thanks.html +++ b/bedrock/firefox/templates/firefox/new/basic/thanks.html @@ -10,7 +10,10 @@ {% block html_attrs %}data-test-fxa-template="firefox-download-thanks"{% endblock %} {# "scene2" page should not be indexed to avoid it appearing in search results: issue 7024 #} -{% block canonical_urls %}<meta name="robots" content="noindex,follow">{% endblock %} +{% block canonical_urls %} +<meta name="robots" content="noindex,follow"> +<link rel="canonical" href="{{ settings.CANONICAL_URL + '/' + CANONICAL_LANG + canonical_path }}"> +{% endblock %} {% block extrahead %} {{ super() }} diff --git a/bedrock/firefox/templates/firefox/new/desktop/thanks.html b/bedrock/firefox/templates/firefox/new/desktop/thanks.html index 3c61c3c2073..b6c1b03a911 100644 --- a/bedrock/firefox/templates/firefox/new/desktop/thanks.html +++ b/bedrock/firefox/templates/firefox/new/desktop/thanks.html @@ -10,7 +10,10 @@ {% block html_attrs %}data-test-fxa-template="firefox-download-thanks"{% endblock %} {# "scene2" page should not be indexed to avoid it appearing in search results: issue 7024 #} -{% block canonical_urls %}<meta name="robots" content="noindex,follow">{% endblock %} +{% block canonical_urls %} +<meta name="robots" content="noindex,follow"> +<link rel="canonical" href="{{ settings.CANONICAL_URL + '/' + CANONICAL_LANG + canonical_path }}"> +{% endblock %} {% block extrahead %} {{ super() }} diff --git a/bedrock/firefox/templates/firefox/nightly/firstrun.html b/bedrock/firefox/templates/firefox/nightly/firstrun.html index cf8b7420b6e..19b6990fefc 100644 --- a/bedrock/firefox/templates/firefox/nightly/firstrun.html +++ b/bedrock/firefox/templates/firefox/nightly/firstrun.html @@ -8,8 +8,10 @@ {% extends "/firefox/base/base-protocol.html" %} -{# "noindex" pages should not have the canonical or hreflang tags: bug 1442331 #} -{% block canonical_urls %}<meta name="robots" content="noindex,follow">{% endblock %} +{% block canonical_urls %} +<meta name="robots" content="noindex,follow"> +<link rel="canonical" href="{{ settings.CANONICAL_URL + '/' + CANONICAL_LANG + canonical_path }}"> +{% endblock %} {% block site_css %} {% if ftl_file_is_active('navigation_refresh') and ftl_file_is_active('footer-refresh') %} diff --git a/bedrock/firefox/templates/firefox/nightly/whatsnew.html b/bedrock/firefox/templates/firefox/nightly/whatsnew.html index 9d94c298b4c..948642adb61 100644 --- a/bedrock/firefox/templates/firefox/nightly/whatsnew.html +++ b/bedrock/firefox/templates/firefox/nightly/whatsnew.html @@ -6,8 +6,10 @@ {% extends "firefox/base/base-protocol.html" %} -{# "noindex" pages should not have the canonical or hreflang tags: bug 1442331 #} -{% block canonical_urls %}<meta name="robots" content="noindex,follow">{% endblock %} +{% block canonical_urls %} +<meta name="robots" content="noindex,follow"> +<link rel="canonical" href="{{ settings.CANONICAL_URL + '/' + CANONICAL_LANG + canonical_path }}"> +{% endblock %} {% block site_css %} {% if ftl_file_is_active('navigation_refresh') and ftl_file_is_active('footer-refresh') %} diff --git a/bedrock/firefox/templates/firefox/set-as-default/thanks.html b/bedrock/firefox/templates/firefox/set-as-default/thanks.html index b6e7f1791a0..24055dbdac6 100644 --- a/bedrock/firefox/templates/firefox/set-as-default/thanks.html +++ b/bedrock/firefox/templates/firefox/set-as-default/thanks.html @@ -8,8 +8,10 @@ {% extends "firefox/base/base-protocol.html" %} -{# "noindex" pages should not have the canonical or hreflang tags: bug 1442331 #} -{% block canonical_urls %}<meta name="robots" content="noindex,follow">{% endblock %} +{% block canonical_urls %} +<meta name="robots" content="noindex,follow"> +<link rel="canonical" href="{{ settings.CANONICAL_URL + '/' + CANONICAL_LANG + canonical_path }}"> +{% endblock %} {% block page_title %}{{ ftl('set-as-default-thanks-set-as-default-thanks-for-choosing-firefox') }}{% endblock %} diff --git a/bedrock/firefox/templates/firefox/welcome/base.html b/bedrock/firefox/templates/firefox/welcome/base.html index da85d28c9c8..60ab06d9cd2 100644 --- a/bedrock/firefox/templates/firefox/welcome/base.html +++ b/bedrock/firefox/templates/firefox/welcome/base.html @@ -6,8 +6,10 @@ {% extends "firefox/base/base-protocol.html" %} -{# "noindex" pages should not have the canonical or hreflang tags: bug 1442331 #} -{% block canonical_urls %}<meta name="robots" content="noindex,follow">{% endblock %} +{% block canonical_urls %} +<meta name="robots" content="noindex,follow"> +<link rel="canonical" href="{{ settings.CANONICAL_URL + '/' + CANONICAL_LANG + canonical_path }}"> +{% endblock %} {% block site_css %} {% if ftl_file_is_active('navigation_refresh') and ftl_file_is_active('footer-refresh') %} diff --git a/bedrock/firefox/templates/firefox/whatsnew/base-new-theme.html b/bedrock/firefox/templates/firefox/whatsnew/base-new-theme.html index 2aa19dd43f1..1fd63da884d 100644 --- a/bedrock/firefox/templates/firefox/whatsnew/base-new-theme.html +++ b/bedrock/firefox/templates/firefox/whatsnew/base-new-theme.html @@ -32,6 +32,7 @@ <head> <meta charset="utf-8"> <meta name="robots" content="noindex,follow"> + <link rel="canonical" href="{{ settings.CANONICAL_URL + '/' + CANONICAL_LANG + canonical_path }}"> <meta name="viewport" content="width=device-width, initial-scale=1"> {% block extra_meta %}{% endblock %} diff --git a/bedrock/firefox/templates/firefox/whatsnew/base.html b/bedrock/firefox/templates/firefox/whatsnew/base.html index 600b05d3180..49bb110ef0a 100644 --- a/bedrock/firefox/templates/firefox/whatsnew/base.html +++ b/bedrock/firefox/templates/firefox/whatsnew/base.html @@ -6,8 +6,10 @@ {% extends "firefox/base/base-protocol.html" %} -{# "noindex" pages should not have the canonical or hreflang tags: bug 1442331 #} -{% block canonical_urls %}<meta name="robots" content="noindex,follow">{% endblock %} +{% block canonical_urls %} +<meta name="robots" content="noindex,follow"> +<link rel="canonical" href="{{ settings.CANONICAL_URL + '/' + CANONICAL_LANG + canonical_path }}"> +{% endblock %} {# Exclude stub attribution for in-product pages: issus 9620 #} {% block stub_attribution %}{% endblock %} diff --git a/bedrock/foundation/templates/foundation/openwebfund/thanks.html b/bedrock/foundation/templates/foundation/openwebfund/thanks.html index 6ead2b3becd..acb02ab39ed 100644 --- a/bedrock/foundation/templates/foundation/openwebfund/thanks.html +++ b/bedrock/foundation/templates/foundation/openwebfund/thanks.html @@ -6,8 +6,10 @@ {% extends "foundation/base.html" %} -{# "noindex" pages should not have the canonical or hreflang tags: bug 1442331 #} -{% block canonical_urls %}<meta name="robots" content="noindex,follow">{% endblock %} +{% block canonical_urls %} +<meta name="robots" content="noindex,follow"> +<link rel="canonical" href="{{ settings.CANONICAL_URL + '/' + CANONICAL_LANG + canonical_path }}"> +{% endblock %} {% block page_title %}Thank you{% endblock %} diff --git a/bedrock/mozorg/templates/mozorg/about/this-site.html b/bedrock/mozorg/templates/mozorg/about/this-site.html index c592f86906c..3dfa93be9e6 100644 --- a/bedrock/mozorg/templates/mozorg/about/this-site.html +++ b/bedrock/mozorg/templates/mozorg/about/this-site.html @@ -9,7 +9,10 @@ {% block page_title %}{{ ftl('about-this-site-title') }}{% endblock %} {% block page_desc %}{{ ftl('about-this-site-desc') }}{% endblock %} -{% block canonical_urls %}<meta name="robots" content="noindex,follow">{% endblock %} +{% block canonical_urls %} +<meta name="robots" content="noindex,follow"> +<link rel="canonical" href="{{ settings.CANONICAL_URL + '/' + CANONICAL_LANG + canonical_path }}"> +{% endblock %} {% block page_favicon %}{{ static('img/favicons/mozilla/dino/favicon.ico') }}{% endblock %} {% block page_favicon_large %}{{ static('img/favicons/mozilla/dino/favicon-196x196.png') }}{% endblock %} diff --git a/bedrock/mozorg/templates/mozorg/analytics-tests/ga-index.html b/bedrock/mozorg/templates/mozorg/analytics-tests/ga-index.html index d24e33ffa51..29afe6fbf6f 100644 --- a/bedrock/mozorg/templates/mozorg/analytics-tests/ga-index.html +++ b/bedrock/mozorg/templates/mozorg/analytics-tests/ga-index.html @@ -6,8 +6,10 @@ {% extends "firefox/base/base-protocol.html" %} -{# "noindex" pages should not have the canonical or hreflang tags: bug 1442331 #} -{% block canonical_urls %}<meta name="robots" content="noindex,follow">{% endblock %} +{% block canonical_urls %} +<meta name="robots" content="noindex,follow"> +<link rel="canonical" href="{{ settings.CANONICAL_URL + '/' + CANONICAL_LANG + canonical_path }}"> +{% endblock %} {% block content %} <main class="mzp-l-content"> diff --git a/bedrock/newsletter/templates/newsletter/confirm.html b/bedrock/newsletter/templates/newsletter/confirm.html index 7b10dea5cb3..c02025c5d60 100644 --- a/bedrock/newsletter/templates/newsletter/confirm.html +++ b/bedrock/newsletter/templates/newsletter/confirm.html @@ -6,8 +6,10 @@ {% extends 'newsletter/base.html' %} -{# "noindex" pages should not have the canonical or hreflang tags: bug 1442331 #} -{% block canonical_urls %}<meta name="robots" content="noindex,follow">{% endblock %} +{% block canonical_urls %} +<meta name="robots" content="noindex,follow"> +<link rel="canonical" href="{{ settings.CANONICAL_URL + '/' + CANONICAL_LANG + canonical_path }}"> +{% endblock %} {% block page_title %}{{ ftl('newsletters-newsletter-confirm') }}{% endblock page_title %} diff --git a/bedrock/newsletter/templates/newsletter/country.html b/bedrock/newsletter/templates/newsletter/country.html index 2a5aa8158d4..c9c88138e92 100644 --- a/bedrock/newsletter/templates/newsletter/country.html +++ b/bedrock/newsletter/templates/newsletter/country.html @@ -6,8 +6,10 @@ {% extends 'newsletter/base.html' %} -{# "noindex" pages should not have the canonical or hreflang tags: bug 1442331 #} -{% block canonical_urls %}<meta name="robots" content="noindex,follow">{% endblock %} +{% block canonical_urls %} +<meta name="robots" content="noindex,follow"> +<link rel="canonical" href="{{ settings.CANONICAL_URL + '/' + CANONICAL_LANG + canonical_path }}"> +{% endblock %} {% block page_title %}Newsletter Country or Region{% endblock page_title %} diff --git a/bedrock/newsletter/templates/newsletter/fxa-error.html b/bedrock/newsletter/templates/newsletter/fxa-error.html index df9f73bba81..a436dd5ec81 100644 --- a/bedrock/newsletter/templates/newsletter/fxa-error.html +++ b/bedrock/newsletter/templates/newsletter/fxa-error.html @@ -6,8 +6,10 @@ {% extends 'newsletter/base.html' %} -{# "noindex" pages should not have the canonical or hreflang tags: bug 1442331 #} -{% block canonical_urls %}<meta name="robots" content="noindex,follow">{% endblock %} +{% block canonical_urls %} +<meta name="robots" content="noindex,follow"> +<link rel="canonical" href="{{ settings.CANONICAL_URL + '/' + CANONICAL_LANG + canonical_path }}"> +{% endblock %} {% block content %} <main class="mzp-l-content mzp-t-content-sm"> diff --git a/bedrock/newsletter/templates/newsletter/management.html b/bedrock/newsletter/templates/newsletter/management.html index ba4665c4d9d..5f375d4ccec 100644 --- a/bedrock/newsletter/templates/newsletter/management.html +++ b/bedrock/newsletter/templates/newsletter/management.html @@ -8,8 +8,10 @@ {# Template used for a user to manage their subscriptions #} -{# "noindex" pages should not have the canonical or hreflang tags: bug 1442331 #} -{% block canonical_urls %}<meta name="robots" content="noindex,follow">{% endblock %} +{% block canonical_urls %} +<meta name="robots" content="noindex,follow"> +<link rel="canonical" href="{{ settings.CANONICAL_URL + '/' + CANONICAL_LANG + canonical_path }}"> +{% endblock %} {% block page_css %} {{ css_bundle('protocol-emphasis-box') }} diff --git a/bedrock/newsletter/templates/newsletter/opt-out-confirmation.html b/bedrock/newsletter/templates/newsletter/opt-out-confirmation.html index e04489b9168..693fc5f60f6 100644 --- a/bedrock/newsletter/templates/newsletter/opt-out-confirmation.html +++ b/bedrock/newsletter/templates/newsletter/opt-out-confirmation.html @@ -9,7 +9,10 @@ {% block page_title %}{{ ftl('opt-out-confirmation-cool-we-hear') }}{% endblock page_title %} {% block page_desc %}{{ ftl('opt-out-confirmation-youre-now-opted') }}{% endblock %} -{% block canonical_urls %}<meta name="robots" content="noindex">{% endblock %} +{% block canonical_urls %} +<meta name="robots" content="noindex"> +<link rel="canonical" href="{{ settings.CANONICAL_URL + '/' + CANONICAL_LANG + canonical_path }}"> +{% endblock %} {% block page_css %} {{ css_bundle('newsletter-opt-out-confirmation') }} diff --git a/bedrock/newsletter/templates/newsletter/recovery.html b/bedrock/newsletter/templates/newsletter/recovery.html index ba8a91c8090..3641ba6a825 100644 --- a/bedrock/newsletter/templates/newsletter/recovery.html +++ b/bedrock/newsletter/templates/newsletter/recovery.html @@ -6,8 +6,10 @@ {% extends 'newsletter/base.html' %} -{# "noindex" pages should not have the canonical or hreflang tags: bug 1442331 #} -{% block canonical_urls %}<meta name="robots" content="noindex,follow">{% endblock %} +{% block canonical_urls %} +<meta name="robots" content="noindex,follow"> +<link rel="canonical" href="{{ settings.CANONICAL_URL + '/' + CANONICAL_LANG + canonical_path }}"> +{% endblock %} {% block page_title %}{{ ftl('newsletters-newsletter-email-recovery') }}{% endblock page_title %} diff --git a/bedrock/newsletter/templates/newsletter/updated.html b/bedrock/newsletter/templates/newsletter/updated.html index 7f2db46f6a8..caf21859dd6 100644 --- a/bedrock/newsletter/templates/newsletter/updated.html +++ b/bedrock/newsletter/templates/newsletter/updated.html @@ -8,8 +8,10 @@ {% extends 'newsletter/base.html' %} -{# "noindex" pages should not have the canonical or hreflang tags: bug 1442331 #} -{% block canonical_urls %}<meta name="robots" content="noindex,follow">{% endblock %} +{% block canonical_urls %} +<meta name="robots" content="noindex,follow"> +<link rel="canonical" href="{{ settings.CANONICAL_URL + '/' + CANONICAL_LANG + canonical_path }}"> +{% endblock %} {% block page_css %} {{ css_bundle('protocol-card') }} diff --git a/bedrock/privacy/templates/privacy/data-preferences.html b/bedrock/privacy/templates/privacy/data-preferences.html index 8ac36255344..a1ab0832c41 100644 --- a/bedrock/privacy/templates/privacy/data-preferences.html +++ b/bedrock/privacy/templates/privacy/data-preferences.html @@ -9,7 +9,10 @@ {% block page_title %}{{ ftl('data-preferences-page-title') }}{% endblock %} {% block page_desc %}{{ ftl('data-preferences-page-desc') }}{% endblock %} -{% block canonical_urls %}<meta name="robots" content="noindex,follow">{% endblock %} +{% block canonical_urls %} +<meta name="robots" content="noindex,follow"> +<link rel="canonical" href="{{ settings.CANONICAL_URL + '/' + CANONICAL_LANG + canonical_path }}"> +{% endblock %} {% block page_css %} {{ css_bundle('protocol-article') }} diff --git a/bedrock/products/templates/products/monitor/waitlist/base.html b/bedrock/products/templates/products/monitor/waitlist/base.html index 1209c1002dd..ef3bf894cea 100644 --- a/bedrock/products/templates/products/monitor/waitlist/base.html +++ b/bedrock/products/templates/products/monitor/waitlist/base.html @@ -6,7 +6,10 @@ {% extends 'base-protocol-mozilla.html' %} -{% block canonical_urls %}<meta name="robots" content="noindex,follow">{% endblock %} +{% block canonical_urls %} +<meta name="robots" content="noindex,follow"> +<link rel="canonical" href="{{ settings.CANONICAL_URL + '/' + CANONICAL_LANG + canonical_path }}"> +{% endblock %} {% block body_class %}{{ super() }} monitor-waitlist{% endblock %} diff --git a/bedrock/products/templates/products/vpn/mac-download.html b/bedrock/products/templates/products/vpn/mac-download.html index e22477d36cf..b14ab915f1a 100644 --- a/bedrock/products/templates/products/vpn/mac-download.html +++ b/bedrock/products/templates/products/vpn/mac-download.html @@ -6,7 +6,10 @@ {% extends "products/vpn/download.html" %} -{% block canonical_urls %}<meta name="robots" content="noindex,nofollow">{% endblock %} +{% block canonical_urls %} +<meta name="robots" content="noindex,nofollow"> +<link rel="canonical" href="{{ settings.CANONICAL_URL + '/' + CANONICAL_LANG + canonical_path }}"> +{% endblock %} {% block page_title_full %}{{ ftl('vpn-mac-download-page-title') }}{% endblock %} diff --git a/bedrock/products/templates/products/vpn/pricing-refresh.html b/bedrock/products/templates/products/vpn/pricing-refresh.html index bcb2871577d..275fd5ed613 100644 --- a/bedrock/products/templates/products/vpn/pricing-refresh.html +++ b/bedrock/products/templates/products/vpn/pricing-refresh.html @@ -14,7 +14,10 @@ {% block body_class %}mozilla-vpn-pricing-page{% endblock %} -{% block canonical_urls %}<meta name="robots" content="noindex,follow">{% endblock %} +{% block canonical_urls %} +<meta name="robots" content="noindex,follow"> +<link rel="canonical" href="{{ settings.CANONICAL_URL + '/' + CANONICAL_LANG + canonical_path }}"> +{% endblock %} {% set _utm_source = 'www.mozilla.org-vpn-product-page' %} {% set _utm_campaign = 'vpn-pricing-page' %} diff --git a/bedrock/products/templates/products/vpn/windows-download.html b/bedrock/products/templates/products/vpn/windows-download.html index 81a381687da..02aabe84373 100644 --- a/bedrock/products/templates/products/vpn/windows-download.html +++ b/bedrock/products/templates/products/vpn/windows-download.html @@ -6,7 +6,10 @@ {% extends "products/vpn/download.html" %} -{% block canonical_urls %}<meta name="robots" content="noindex,nofollow">{% endblock %} +{% block canonical_urls %} +<meta name="robots" content="noindex,nofollow"> +<link rel="canonical" href="{{ settings.CANONICAL_URL + '/' + CANONICAL_LANG + canonical_path }}"> +{% endblock %} {% block page_title_full %}{{ ftl('vpn-windows-download-page-title') }}{% endblock %} diff --git a/bedrock/settings/base.py b/bedrock/settings/base.py index 57a2290d049..9a4b02c2e46 100644 --- a/bedrock/settings/base.py +++ b/bedrock/settings/base.py @@ -11,9 +11,7 @@ from pathlib import Path from urllib.parse import urlparse -from django.conf.locale import ( - LANG_INFO, # we patch this in bedrock.base.apps.BaseAppConfig # noqa: F401 -) +from django.conf.locale import LANG_INFO from django.utils.functional import lazy import dj_database_url @@ -145,6 +143,9 @@ def data_path(*args): # http://www.i18nguy.com/unicode/language-identifiers.html LANGUAGE_CODE = "en-US" +# Add a fallback for zh-CN, which doesn't exist in core Django +LANG_INFO["zh-CN"] = {"fallback": ["zh-hans"]} + # Languages using BiDi (right-to-left) layout. Overrides/extends Django default. LANGUAGES_BIDI = ["ar", "ar-dz", "fa", "he", "skr", "ur"] @@ -371,6 +372,9 @@ def get_dev_languages(): "es-AR": "es-ES", "es-CL": "es-ES", "es-MX": "es-ES", + "pt-PT": "pt-BR", + "en-GB": "en-US", + "en-CA": "en-US", } @@ -2580,14 +2584,20 @@ def lazy_wagtail_langs(): # 2) These are the Bedrock-side lang codes. They are mapped to # Smartling-specific ones in the WAGTAIL_LOCALIZE_SMARTLING settings, below ("en-US", "English (US)"), + ("en-GB", "English (Great Britain)"), + ("en-CA", "English (Canada)"), ("de", "German"), ("fr", "French"), + ("es-AR", "Spanish (Argentina)"), ("es-ES", "Spanish (Spain)"), + ("es-CL", "Spanish (Chile)"), + ("es-MX", "Spanish (México)"), ("it", "Italian"), ("ja", "Japanese"), ("nl", "Dutch (Netherlands)"), ("pl", "Polish"), ("pt-BR", "Portuguese (Brazil)"), + ("pt-PT", "Portuguese (Portugal)"), ("ru", "Russian"), ("zh-CN", "Chinese (China-Simplified)"), ] @@ -2658,6 +2668,7 @@ def lazy_wagtail_core_langs(): ), "REFORMAT_LANGUAGE_CODES": False, # don't force language codes into Django's all-lowercase pattern "VISUAL_CONTEXT_CALLBACK": "bedrock.cms.wagtail_localize_smartling.callbacks.visual_context", + "EXCLUDE_LOCALES": list(FALLBACK_LOCALES.keys()), } WAGTAILDRAFTSHARING = { diff --git a/bedrock/urls.py b/bedrock/urls.py index 88d079136ff..9dc8b31e4a3 100644 --- a/bedrock/urls.py +++ b/bedrock/urls.py @@ -8,13 +8,13 @@ from django.utils.module_loading import import_string import wagtaildraftsharing.urls as wagtaildraftsharing_urls -from wagtail import urls as wagtail_urls from wagtail.admin import urls as wagtailadmin_urls from wagtail.documents import urls as wagtaildocs_urls from watchman import views as watchman_views from bedrock.base import views as base_views from bedrock.base.i18n import bedrock_i18n_patterns +from bedrock.cms import wagtail_urls from bedrock.cms.decorators import prefer_cms from bedrock.mozorg import views as mozorg_views @@ -96,8 +96,10 @@ ) # Note that statics are handled via Whitenoise's middleware -# Wagtail is the catch-all route, and it will raise a 404 if needed. -# Note that we're also using localised URLs here +# Wagtail catch-all: uses our custom wagtail_urls module which replaces +# Wagtail's serve view with one that handles alias-locale fallback. +# Because this is in the URL router, it only fires for paths that no other +# Django view (including prefer_cms-decorated views) has claimed. urlpatterns += bedrock_i18n_patterns( path("", include(wagtail_urls)), ) diff --git a/bin/export-db-to-sqlite.sh b/bin/export-db-to-sqlite.sh index 0b44d02185a..7c0780f1f49 100755 --- a/bin/export-db-to-sqlite.sh +++ b/bin/export-db-to-sqlite.sh @@ -212,6 +212,7 @@ export DATABASE_URL=sqlite:///$output_db # Note that the three slashes is key check_status_and_handle_failure "Setting up new output DB at $output_db" PROD_DETAILS_STORAGE=product_details.storage.PDFileStorage \ +SQLITE_EXPORT_MODE=True \ python manage.py migrate || all_well=false check_status_and_handle_failure "Running Django migrations" diff --git a/lib/l10n_utils/__init__.py b/lib/l10n_utils/__init__.py index c8e44bab149..774aec0e685 100644 --- a/lib/l10n_utils/__init__.py +++ b/lib/l10n_utils/__init__.py @@ -110,6 +110,13 @@ def render(request, template, context=None, ftl_files=None, activation_files=Non ftl_files = ftl_files or context.get("ftl_files") locale = get_locale(request) + # For alias fallback pages (for example, if a user requests /es-AR/somepage/, + # but a somepage does not exist in the es-AR locale, so the user is served + # a page from the fallback es-ES locale), the user-facing locale is the + # alias (es-AR), but the content locale is the fallback locale (es-ES). + # Use locale_in_url for URL prefix comparisons to avoid spurious redirects. + locale_in_url, _, _ = split_path_and_normalize_language(request.path) + # is this a non-locale page? name_prefix = request.path_info.split("/", 2)[1] non_locale_url = non_locale_url or name_prefix in settings.SUPPORTED_NONLOCALES or request.path_info in settings.SUPPORTED_LOCALE_IGNORE @@ -164,6 +171,17 @@ def render(request, template, context=None, ftl_files=None, activation_files=Non context["translations"] = get_translations_native_names(translations) + # content_locales: locales with content (not including alias locales that + # serve another locale's content). + # For CMS pages, _content_locales_via_cms only includes aliases that have + # their own translated page. For non-CMS pages, it equals `translations` + # (Fluent content is always real content, never an alias). + if is_cms_page: + content_locales = getattr(request, "_content_locales_via_cms", translations) + else: + content_locales = translations + context["content_locales"] = set(content_locales) + # Ensure the path requires a locale prefix. if not non_locale_url: # If the requested path's locale is different from the best matching @@ -177,10 +195,27 @@ def render(request, template, context=None, ftl_files=None, activation_files=Non # Redirect to the locale if: # - The URL is the root path but is missing the trailing slash OR # - The locale isn't the current prefix in the URL - if request.path == f"/{locale}" or locale != request.path.lstrip("/").partition("/")[0]: - return redirect_to_locale(request, locale) + # Use locale_in_url (the URL prefix) for both the comparison and the + # redirect target so that CMS alias pages (where locale = fallback, e.g. + # pt-BR, but locale_in_url = alias, e.g. pt-PT) don't trigger a spurious + # redirect. + if request.path == f"/{locale_in_url}" or locale_in_url != request.path.lstrip("/").partition("/")[0]: + return redirect_to_locale(request, locale_in_url or locale) else: - return redirect_to_best_locale(request, translations) + # Before redirecting, check whether this locale is a configured alias + # (e.g. en-CA → en-US) and the fallback locale has translations. If + # the fallback locale has content, serve it transparently at the + # alias URL instead of redirecting. + fallback_locale = getattr(settings, "FALLBACK_LOCALES", {}).get(locale_in_url) + if fallback_locale and fallback_locale in translations and not is_root_path_with_no_language_clues(request): + # Serve the fallback locale's content at the alias URL. + request.content_locale = fallback_locale + locale = normalize_language(fallback_locale) + # Reload Fluent with the fallback locale so templates render the + # correct translations instead of falling back to en-US. + context["fluent_l10n"] = fluent_l10n([locale, "en"], ftl_files or settings.FLUENT_DEFAULT_FILES) + else: + return redirect_to_best_locale(request, translations) # Look for locale-specific template in app/templates/ locale_tmpl = f".{locale}".join(splitext(template)) @@ -194,8 +229,12 @@ def render(request, template, context=None, ftl_files=None, activation_files=Non def get_locale(request): + # content_locale is set on the request when serving fallback content at an + # alias locale URL (e.g. pt-BR content at /pt-PT/). Use it as the authoritative + # locale for Fluent loading so that nav/footer strings render in the correct + # language rather than falling back to English. # request.locale is added in bedrock.base.middleware.BedrockLangCodeFixupMiddleware - lang = getattr(request, "locale", None) + lang = getattr(request, "content_locale", None) or getattr(request, "locale", None) if not lang: lang = settings.LANGUAGE_CODE return normalize_language(lang) diff --git a/lib/l10n_utils/tests/test_base.py b/lib/l10n_utils/tests/test_base.py index f3ead5e0be8..e69010869e5 100644 --- a/lib/l10n_utils/tests/test_base.py +++ b/lib/l10n_utils/tests/test_base.py @@ -5,6 +5,7 @@ import os from unittest.mock import ANY, Mock, call, patch +from django.http import HttpResponse from django.test import TestCase from django.test.client import RequestFactory from django.test.utils import override_settings @@ -176,6 +177,90 @@ def test_activation_files(self, fal_mock, dr_mock): any_order=True, ) + @override_settings(FALLBACK_LOCALES={"en-CA": "en-US"}) + def test_alias_locale_served_transparently(self): + """A request to an alias locale URL serves fallback content at that URL (no redirect).""" + # en-US is in translations, en-CA is not — alias fallback should kick in + self._test( + "/en-CA/firefox/new/", + "firefox/new.html", + locale="en-CA", + accept_lang="", + status=200, + active_locales=["en-US", "fr"], + ) + + @override_settings(FALLBACK_LOCALES={"en-CA": "en-US"}) + def test_alias_locale_sets_content_locale_on_request(self): + """render() sets request.content_locale to the fallback locale code.""" + request = RequestFactory().get("/en-CA/firefox/new/") + request.locale = "en-CA" + with patch.object(l10n_utils, "django_render") as mock_render: + mock_render.return_value = HttpResponse() + l10n_utils.render(request, "firefox/new.html", {"active_locales": ["en-US", "fr"]}) + assert getattr(request, "content_locale", None) == "en-US" + + @override_settings(FALLBACK_LOCALES={"en-CA": "en-US"}) + def test_alias_locale_sets_content_locales_in_context(self): + """context['content_locales'] equals translations (no alias expansion) for non-CMS pages.""" + request = RequestFactory().get("/en-CA/firefox/new/") + request.locale = "en-CA" + with patch.object(l10n_utils, "django_render") as mock_render: + mock_render.return_value = HttpResponse() + l10n_utils.render(request, "firefox/new.html", {"active_locales": ["en-US", "fr"]}) + ctx = mock_render.call_args[0][2] + assert ctx["content_locales"] == {"en-US", "fr"} + assert "en-CA" not in ctx["content_locales"] + + @override_settings(FALLBACK_LOCALES={"en-CA": "en-US"}) + def test_alias_locale_redirects_when_fallback_also_missing(self): + """If the fallback locale is also not in translations, still redirect.""" + self._test( + "/en-CA/firefox/new/", + "firefox/new.html", + locale="en-CA", + accept_lang="", + status=302, + destination="/fr/firefox/new/", + active_locales=["fr"], # neither en-CA nor en-US present + ) + + @override_settings(FALLBACK_LOCALES={"en-CA": "en-US"}) + def test_alias_locale_root_url_served_transparently(self): + """GET /en-CA/ (alias locale root with no sub-path) is served transparently — no redirect.""" + self._test( + "/en-CA/", + "firefox/new.html", + locale="en-CA", + accept_lang="", + status=200, + active_locales=["en-US", "fr"], + ) + + @override_settings(FALLBACK_LOCALES={"en-CA": "en-US"}) + def test_alias_locale_served_when_is_cms_page_false(self): + """ + When prefer_cms falls back to the Django view after a CMS 404, it explicitly + sets request.is_cms_page = False before calling the view. Verify that + l10n_utils.render() still performs alias serving in that state. + + This guards against a regression where the FALLBACK_LOCALES check in render() + would accidentally be gated on is_cms_page, which would break the + prefer_cms → Django-fallback → alias-serving path. + """ + request = RequestFactory().get("/en-CA/firefox/new/") + request.locale = "en-CA" + request.is_cms_page = False # explicitly set by prefer_cms after CMS 404 + + with patch.object(l10n_utils, "django_render") as mock_render: + mock_render.return_value = HttpResponse() + l10n_utils.render(request, "firefox/new.html", {"active_locales": ["en-US", "fr"]}) + + # Alias serving fired: django_render was called (no redirect), and + # content_locale was set to the fallback locale. + assert mock_render.called + assert getattr(request, "content_locale", None) == "en-US" + class TestGetAcceptLanguages(TestCase): def _test(self, accept_lang, list): @@ -311,3 +396,45 @@ def test_get_best_translation(translations, accept_languages, expected): def test_get_best_translation__strict(translations, accept_languages, expected): # Strict is used for the root path, to return the list of localized home pages for bots. assert l10n_utils.get_best_translation(translations, accept_languages, strict=True) == expected + + +@pytest.mark.parametrize( + "locale_is_set, locale_value, content_locale_is_set, content_locale_value, language_code_setting, expected", + ( + (True, "es-AR", True, "es-MX", "en-US", "es-MX"), # content_locale wins over locale + (False, "", True, "es-MX", "en-US", "es-MX"), # content_locale wins even without locale + (True, "es-AR", False, "", "en-US", "es-AR"), # locale wins when no content_locale + (False, "", False, "", "en-US", "en-US"), # LANGUAGE_CODE fallback + ), +) +def test_get_locale_preference_order( + locale_is_set, + locale_value, + content_locale_is_set, + content_locale_value, + language_code_setting, + expected, + rf, +): + request = rf.get("/") + if locale_is_set: + request.locale = locale_value + if content_locale_is_set: + request.content_locale = content_locale_value + with override_settings(LANGUAGE_CODE=language_code_setting): + assert l10n_utils.get_locale(request) == expected + + +@patch.object(l10n_utils, "django_render") +def test_render_does_not_redirect_when_content_locale_differs_from_url_locale(render_mock, rf): + """When content_locale is already set on the request (CMS alias page), + render() must not redirect even though locale != locale_in_url.""" + render_mock.return_value = HttpResponse() + request = rf.get("/es-AR/download/") + request.locale = "es-AR" + request.content_locale = "es-MX" # already set by CMS alias serving logic + + response = l10n_utils.render(request, "some.html", {"active_locales": ["es-MX"]}) + + assert response.status_code == 200 # must serve, not redirect to /es-MX/ + render_mock.assert_called_once() diff --git a/media/js/cms/wagtailadmin-locale-badges.js b/media/js/cms/wagtailadmin-locale-badges.js new file mode 100644 index 00000000000..436686e6327 --- /dev/null +++ b/media/js/cms/wagtailadmin-locale-badges.js @@ -0,0 +1,26 @@ +/* + * 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/. + */ + +!(function () { + 'use strict'; + document.addEventListener('DOMContentLoaded', function () { + var e = document.getElementById('locales-list'); + if (e) { + var t = window.WAGTAIL_LOCALE_ALIAS_MAP || {}; + Object.entries(t).forEach(function (t) { + var n = t[0], + a = t[1], + s = e.querySelector('a[href$="/locales/edit/' + n + '/"]'); + if (s) { + var c = document.createElement('span'); + (c.className = 'w-status w-status--label'), + (c.textContent = 'alias → ' + a), + s.insertAdjacentElement('afterend', c); + } + }); + } + }); +})(); diff --git a/media/static-bundles.json b/media/static-bundles.json index 5ef1fa96f4f..bfee459af6a 100644 --- a/media/static-bundles.json +++ b/media/static-bundles.json @@ -1912,6 +1912,12 @@ "js/anonym/anonym-contact-form.js" ], "name": "anonym" + }, + { + "files": [ + "js/cms/wagtailadmin-locale-badges.js" + ], + "name": "wagtailadmin-locale-badges" } ] }