Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
13c0e04
Upgrade wagtail-localize-smartling and -dashboard; add zh-CN LANG_INF…
stevejalim Apr 13, 2026
bbace8d
Add alias locale transparent serving (pt-PT, en-GB, en-CA)
stevejalim Apr 13, 2026
042efbf
Fix page.localized and URLs for alias locales (port Springfield f50d3…
stevejalim Apr 13, 2026
30b4e9f
Merge branch 'main' into WT-997--l10n-tech-parity-with-springfield
stevejalim Apr 13, 2026
a9e4b32
Add es-CL, es-MX to WAGTAIL_CONTENT_LANGUAGES; fix EXCLUDE_LOCALES va…
stevejalim Apr 13, 2026
82fa9cd
fix failing tests by correctly adding es-AR as a wagtail lang
stevejalim Apr 13, 2026
60486d3
populate _content_locales_via_cms for requests, so it can be used to …
dchukhin Apr 14, 2026
11b2a24
set up content_locales and alias fallback for non-Wagtail pages
dchukhin Apr 14, 2026
c00fa8b
add CANONICAL_LANG to page context
dchukhin Apr 14, 2026
12291db
update templates to correctly set canonical URL, noindex, and hreflan…
dchukhin Apr 14, 2026
f42f9be
explicitly set SQLITE_EXPORT_MODE when running db export
dchukhin Apr 15, 2026
970d1a7
bundle the admin badge JS like in springfield
dchukhin Apr 15, 2026
6b6dd41
add unit tests for new code that is missing test coverage
dchukhin Apr 15, 2026
1a0a997
add unit tests for fallback scenarios on a prefer_cms() view
dchukhin Apr 15, 2026
804c4f1
auto-exclude alias locales from smartling sync
dchukhin Apr 15, 2026
201d211
Merge branch 'main' into WT-997--l10n-tech-parity-with-springfield
dchukhin Apr 15, 2026
97f747f
add template for newly-added tests
dchukhin Apr 15, 2026
a60f35b
make sure alias locales show up in language picker for static pages i…
dchukhin Apr 15, 2026
dd0e950
set up es-MX as an alias locale to fall back to es-ES
dchukhin Apr 16, 2026
97f0095
make sure es-MX exists as a locale in Wagtail
dchukhin Apr 16, 2026
712e7e5
update tests to not use es-MX as a fallback locale (since it is an al…
dchukhin Apr 16, 2026
b6e8061
update comment to not refer to es-MX as a fallback locale (since it i…
dchukhin Apr 16, 2026
bb3b483
Merge branch 'main' into WT-997--l10n-tech-parity-with-springfield
dchukhin Apr 21, 2026
e822faa
Merge branch 'main' into WT-997--l10n-tech-parity-with-springfield
stevejalim Apr 27, 2026
777706d
make sure that fallback Page content is served for alias locales when…
dchukhin Apr 28, 2026
04b2530
show canonical tag even if a page is not indexed
dchukhin Apr 28, 2026
ae218ea
Merge branch 'main' into WT-997--l10n-tech-parity-with-springfield
dchukhin Apr 28, 2026
a570ecb
Merge branch 'main' into WT-997--l10n-tech-parity-with-springfield
stevejalim Apr 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions bedrock/cms/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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",
Expand Down
106 changes: 106 additions & 0 deletions bedrock/cms/migrations/0005_create_alias_locale_records.py
Original file line number Diff line number Diff line change
@@ -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", "pt-PT", "en-GB", "en-CA"]
Comment thread
dchukhin marked this conversation as resolved.
Outdated

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
# /<alias-locale>/ — 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", "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),
]
56 changes: 55 additions & 1 deletion bedrock/cms/models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
# 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.base.i18n import normalize_language
from bedrock.cms.utils import get_locales_for_cms_page
from lib import l10n_utils

Expand Down Expand Up @@ -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."""
Expand Down
19 changes: 14 additions & 5 deletions bedrock/cms/models/locale.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
12 changes: 6 additions & 6 deletions bedrock/cms/tests/test_decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ def test_decorating_django_view__passing_fallback_lang_codes(
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_available_via_cms == ["en-US", "en-GB", "en-CA"]
assert "This is a CMS page now, with the slug of strings" in resp.text


Expand Down Expand Up @@ -181,7 +181,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


Expand Down Expand Up @@ -223,7 +223,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


Expand Down Expand Up @@ -274,7 +274,7 @@ def test_patching_in_urlconf__standard_django_view__with_locale_list(
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_available_via_cms == ["en-US", "en-GB", "en-CA"]
assert "This is a CMS page now, with the slug of strings" in resp.text


Expand Down Expand Up @@ -305,7 +305,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


Expand Down Expand Up @@ -352,7 +352,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


Expand Down
9 changes: 5 additions & 4 deletions bedrock/cms/tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,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")
Expand All @@ -34,8 +34,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):
Expand All @@ -46,7 +47,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(
Expand Down
Loading
Loading