lib/l10n_utils/__init__.py (192 lines of code) (raw):
# 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 os.path import relpath, splitext
from django.conf import settings
from django.http import HttpResponsePermanentRedirect, HttpResponseRedirect
from django.shortcuts import render as django_render
from django.template import TemplateDoesNotExist, loader
from django.utils.translation.trans_real import parse_accept_lang_header
from django.views.generic import TemplateView
from product_details import product_details
from bedrock.base import metrics
from bedrock.base.i18n import normalize_language, split_path_and_normalize_language
from bedrock.settings.base import language_url_map_with_fallbacks
from .fluent import fluent_l10n, ftl_file_is_active, get_active_locales as ftl_active_locales
def template_source_url(template):
if template in settings.EXCLUDE_EDIT_TEMPLATES:
return None
if template.split("/")[0] in settings.EXCLUDE_EDIT_TEMPLATES_DIRECTORIES:
return None
try:
absolute_path = loader.get_template(template).template.filename
except TemplateDoesNotExist:
return None
relative_path = relpath(absolute_path, settings.ROOT)
return f"{settings.GITHUB_REPO}/tree/master/{relative_path}"
def render_to_string(template_name, context=None, request=None, using=None, ftl_files=None):
if request:
context = context or {}
locale = get_locale(request)
if ftl_files:
if isinstance(ftl_files, str):
ftl_files = [ftl_files]
# do not use list.extend() or += here to avoid modifying
# the original list passed to the function
ftl_files = ftl_files + settings.FLUENT_DEFAULT_FILES
context["fluent_l10n"] = fluent_l10n([locale, "en"], ftl_files)
else:
context["fluent_l10n"] = fluent_l10n([locale, "en"], settings.FLUENT_DEFAULT_FILES)
context["fluent_files"] = ftl_files or settings.FLUENT_DEFAULT_FILES
return loader.render_to_string(template_name, context, request, using)
def is_root_path_with_no_language_clues(request):
return request.path_info == "/" and not request.headers.get("Accept-Language")
def redirect_to_best_locale(request, translations):
# Strict only for the root URL when we have no language clues
strict = is_root_path_with_no_language_clues(request)
# Note that translations is list of locale strings (eg ["en-GB", "ru", "fr"])
locale = get_best_translation(translations, get_accept_languages(request), strict)
if locale:
return redirect_to_locale(request, locale)
return locale_selection(request, translations)
def redirect_to_locale(request, locale, permanent=False):
redirect_class = HttpResponsePermanentRedirect if permanent else HttpResponseRedirect
original_prefix, subpath, _ = split_path_and_normalize_language(request.get_full_path())
response = redirect_class("/" + "/".join([locale, subpath]))
# Record count of redirects to this locale.
metrics.incr("locale.redirect", tags=[f"from_locale:{original_prefix or 'none'}", f"to_locale:{locale}"])
# Add the Vary header to avoid wrong redirects due to a cache
response["Vary"] = "Accept-Language"
return response
def locale_selection(request, available_locales=None):
# We want the root path to return a 200 and slightly adjusted content for search engines.
is_root = request.path_info == "/"
has_header = request.headers.get("Accept-Language") is not None
# If `settings.DEV` is true, make `available_locales` all available locales for l10n testing.
# Or if empty, set it to at least en-US.
if not available_locales:
available_locales = ["en-US"]
if settings.DEV:
available_locales = settings.DEV_LANGUAGES
context = {
"is_root": is_root,
"has_header": has_header,
"fluent_l10n": fluent_l10n(["en"], settings.FLUENT_DEFAULT_FILES),
"languages": product_details.languages,
"available_locales": sorted(set(available_locales)),
}
response = django_render(request, "404-locale.html", context, status=200 if is_root else 404)
# Add the Vary header to avoid improper cache
response["Vary"] = "Accept-Language"
return response
def render(request, template, context=None, ftl_files=None, activation_files=None, **kwargs):
"""
Same as django's render() shortcut, but with l10n template support.
If used like this::
return l10n_utils.render(request, 'myapp/mytemplate.html')
... this helper will render the following template::
l10n/LANG/myapp/mytemplate.html
if present, otherwise, it'll render the specified (en-US) template.
"""
# use copy() here to avoid modifying the dict in a view that will then
# be different on the next call to the view.
context = context.copy() if context else {}
l10n = None
ftl_files = ftl_files or context.get("ftl_files")
locale = get_locale(request)
# is this a non-locale page?
name_prefix = request.path_info.split("/", 2)[1]
non_locale_url = name_prefix in settings.SUPPORTED_NONLOCALES or request.path_info in settings.SUPPORTED_LOCALE_IGNORE
# is this a CMS page?
is_cms_page = hasattr(request, "is_cms_page") and request.is_cms_page
# Make sure we have a single template
if isinstance(template, list):
template = template[0]
if ftl_files:
if isinstance(ftl_files, str):
ftl_files = [ftl_files]
# do not use list.extend() or += here to avoid modifying
# the original list passed to the function
ftl_files = ftl_files + settings.FLUENT_DEFAULT_FILES
context["fluent_l10n"] = l10n = fluent_l10n([locale, "en"], ftl_files)
else:
context["fluent_l10n"] = fluent_l10n([locale, "en"], settings.FLUENT_DEFAULT_FILES)
context["fluent_files"] = ftl_files or settings.FLUENT_DEFAULT_FILES
context["template"] = template
context["template_source_url"] = template_source_url(template)
# if it's a CMS page, draw the active locales from the Page data.
# if `active_locales` is given use it as the full list of active translations
translations = []
if is_cms_page and request._locales_available_via_cms:
translations = request._locales_available_via_cms
elif "active_locales" in context:
translations = context["active_locales"]
del context["active_locales"]
else:
if activation_files:
translations = set()
for af in activation_files:
translations.update(ftl_active_locales(af))
translations = sorted(translations) # `sorted` returns a list.
elif l10n:
translations = l10n.active_locales
# if `add_active_locales` is given then add it to the translations for the template
if "add_active_locales" in context:
translations.extend(context["add_active_locales"])
del context["add_active_locales"]
if not translations:
translations = [settings.LANGUAGE_CODE]
context["translations"] = get_translations_native_names(translations)
# Ensure the path requires a locale prefix.
if not non_locale_url:
# If the requested path's locale is different from the best matching
# locale stored on the `request`, and that locale is one of the active
# translations, redirect to it. Otherwise we need to find the best
# matching locale.
# Does that path's locale match the request's locale?
# AND is it NOT for the root path with no discernable lang?
if locale in translations and not is_root_path_with_no_language_clues(request):
# 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)
else:
return redirect_to_best_locale(request, translations)
# Look for locale-specific template in app/templates/
locale_tmpl = f".{locale}".join(splitext(template))
try:
return django_render(request, locale_tmpl, context, **kwargs)
except TemplateDoesNotExist:
pass
# Render originally requested/default template.
return django_render(request, template, context, **kwargs)
def get_locale(request):
# request.locale is added in bedrock.base.middleware.BedrockLangCodeFixupMiddleware
lang = getattr(request, "locale", None)
if not lang:
lang = settings.LANGUAGE_CODE
return normalize_language(lang)
def get_accept_languages(request):
"""
Parse the user's Accept-Language HTTP header and return a list of languages in ranked order.
"""
ranked = parse_accept_lang_header(request.headers.get("Accept-Language", ""))
return [lang for lang, rank in ranked]
def get_best_translation(translations, accept_languages, strict=False):
"""
Return the best translation available comparing the accept languages against available translations.
This attempts to find a matching translation for each accept language. It
compares each accept language in full, and also the root. For example,
"en-CA" looks for "en-CA" as well as "en", which maps to "en-US".
If none found, it returns the first language code for the first available translation.
"""
lang_map = language_url_map_with_fallbacks()
# translations contains mixed-case items e.g. "en-US" and the keys
# of `lang_map` are (now) also mixed case.
valid_lang_map = {k: v for k, v in lang_map.items() if v in translations}
for lang in accept_languages:
if lang in valid_lang_map:
return valid_lang_map[lang]
pre = lang.split("-")[0]
if pre in valid_lang_map:
return valid_lang_map[pre]
if strict:
# We couldn't find a best locale to return so we return `None`.
return None
else:
# Use the default locale if it is an available translation.
if settings.LANGUAGE_CODE in translations:
return settings.LANGUAGE_CODE
# In the rare case the default language isn't in the list,
# return the first translation in the valid_lang_map.
return list(valid_lang_map.values())[0]
def get_translations_native_names(locales):
"""
Return a dict of locale codes and native language name strings.
Returned dict is suitable for use in view contexts and is filtered to only codes in PROD_LANGUAGES.
:param locales: list of locale codes
:return: dict, like {'en-US': 'English (US)', 'fr': 'Français'}
"""
translations = {}
for locale in locales:
if locale in settings.PROD_LANGUAGES:
language = product_details.languages.get(locale)
translations[locale] = language["native"] if language else locale
return translations
class LangFilesMixin:
"""Generic views mixin that uses l10n_utils to render responses."""
old_template_name = None
active_locales = None
add_active_locales = None
# a list of ftl files to use or a single ftl filename
ftl_files = None
# a dict of template names to ftl files
ftl_files_map = None
# a list of ftl or template files to use to determine the full list of active locales
# mostly useful during a redesign where multiple templates are used for a single URL
activation_files = None
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
if self.active_locales:
ctx["active_locales"] = self.active_locales
if self.add_active_locales:
ctx["add_active_locales"] = self.add_active_locales
return ctx
def get_ftl_files(self, template_names):
if self.ftl_files:
return self.ftl_files
if self.ftl_files_map:
return self.ftl_files_map.get(template_names[0])
return None
def render_to_response(self, context, **response_kwargs):
template_names = self.get_template_names()
return render(
self.request,
template_names,
context,
ftl_files=self.get_ftl_files(template_names),
activation_files=self.activation_files,
**response_kwargs,
)
def get_template_names(self):
template_names = super().get_template_names()
if self.old_template_name is None:
return template_names
ftl_files = self.get_ftl_files(template_names)
if ftl_file_is_active(ftl_files[0]):
return template_names
return [self.old_template_name]
class RequireSafeMixin:
http_method_names = ["get", "head"]
class L10nTemplateView(LangFilesMixin, RequireSafeMixin, TemplateView):
pass