From de9045afcfbc27655c8e1db4c5952aea3875be95 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Fri, 16 Jan 2026 17:08:46 +0100 Subject: [PATCH] Allow to combine language variant with region (fixes #3947, Z#23220951) (#5814) * Allow to combine language variant with region (fixes #3947, Z#23220951) This only affects babel-based formatting (currently: currencies and phone numbers), **not** Django-based formatting (currently: date and time formats). * Remove tests where I don'T actually know whats right * Fix lookup order --- src/pretix/base/i18n.py | 61 +++++++++++++++++++++------ src/pretix/base/middleware.py | 6 ++- src/pretix/base/templatetags/money.py | 14 +++--- src/pretix/presale/views/__init__.py | 3 +- src/tests/base/test_templatetag.py | 3 -- src/tests/helpers/test_i18n.py | 47 ++++++++++++--------- 6 files changed, 89 insertions(+), 45 deletions(-) diff --git a/src/pretix/base/i18n.py b/src/pretix/base/i18n.py index 7c510aa5d..54d62d32a 100644 --- a/src/pretix/base/i18n.py +++ b/src/pretix/base/i18n.py @@ -34,14 +34,13 @@ from contextlib import contextmanager +from asgiref.local import Local from babel import localedata from django.conf import settings from django.utils import translation from django.utils.formats import date_format, number_format from django.utils.translation import gettext -from pretix.base.templatetags.money import money_filter - from i18nfield.fields import ( # noqa I18nCharField, I18nTextarea, I18nTextField, I18nTextInput, ) @@ -51,6 +50,9 @@ from i18nfield.strings import LazyI18nString # noqa from i18nfield.utils import I18nJSONEncoder # noqa +_active_region = Local() + + class LazyDate: def __init__(self, value): self.value = value @@ -86,6 +88,8 @@ class LazyCurrencyNumber: return self.__str__() def __str__(self): + from pretix.base.templatetags.money import money_filter + return money_filter(self.value, self.currency) @@ -105,14 +109,40 @@ ALLOWED_LANGUAGES = dict(settings.LANGUAGES) def get_babel_locale(): - babel_locale = 'en' - # Babel, and therefore django-phonenumberfield, do not support our custom locales such das de_Informal - if translation.get_language(): - if localedata.exists(translation.get_language()): - babel_locale = translation.get_language() - elif localedata.exists(translation.get_language()[:2]): - babel_locale = translation.get_language()[:2] - return babel_locale + # Babel, and therefore also django-phonenumberfield, do not support our custom locales such das de_Informal + # Also, this returns best-effort region information for number formatting etc + current_language = translation.get_language() + current_region = getattr(_active_region, "value", None) + + # Babel only accepts locales that exist on the system. We try combinations in the following order: + # language-languageversion-region + # language-region + # language-languageversion + # language + # fallback to system default + # fallback to english + + try_locales = [] + if "-" in current_language: + lng_parts = current_language.split("-") + if current_region: + try_locales.append(f"{lng_parts[0]}_{lng_parts[1].title()}_{current_region.upper()}") + try_locales.append(f"{lng_parts[0]}_{current_region.upper()}") + try_locales.append(f"{lng_parts[0]}_{lng_parts[1].upper()}") + try_locales.append(f"{lng_parts[0]}_{lng_parts[1].title()}") + try_locales.append(f"{lng_parts[0]}") + else: + if current_region: + try_locales.append(f"{current_language}_{current_region.upper()}") + try_locales.append(f"{current_language}") + + try_locales.append(settings.LANGUAGE_CODE) + + for locale in try_locales: + if localedata.exists(locale): + return locale + + return "en" def get_language_without_region(lng=None): @@ -132,6 +162,10 @@ def get_language_without_region(lng=None): return lng +def set_region(region): + _active_region.value = region + + @contextmanager def language(lng, region=None): """ @@ -143,15 +177,18 @@ def language(lng, region=None): formatting. If you pass a ``lng`` that already contains a region, e.g. ``pt-br``, the ``region`` attribute will be ignored. """ - _lng = translation.get_language() + lng_before = translation.get_language() + region_before = getattr(_active_region, "value", None) lng = lng or settings.LANGUAGE_CODE if '-' not in lng and region: lng += '-' + region.lower() translation.activate(lng) + _active_region.value = region try: yield finally: - translation.activate(_lng) + translation.activate(lng_before) + _active_region.value = region_before class LazyLocaleException(Exception): diff --git a/src/pretix/base/middleware.py b/src/pretix/base/middleware.py index 7e326d90f..677816723 100644 --- a/src/pretix/base/middleware.py +++ b/src/pretix/base/middleware.py @@ -35,7 +35,7 @@ from django.utils.translation.trans_real import ( parse_accept_lang_header, ) -from pretix.base.i18n import get_language_without_region +from pretix.base.i18n import get_language_without_region, set_region from pretix.base.settings import global_settings_object from pretix.multidomain.urlreverse import ( get_event_domain, get_organizer_domain, @@ -92,10 +92,14 @@ class LocaleMiddleware(MiddlewareMixin): ) if '-' not in language and settings_holder.settings.region: language += '-' + settings_holder.settings.region + if settings_holder.settings.region: + set_region(settings_holder.settings.region) else: gs = global_settings_object(request) if '-' not in language and gs.settings.region: language += '-' + gs.settings.region + if gs.settings.region: + set_region(gs.settings.region) translation.activate(language) request.LANGUAGE_CODE = get_language_without_region() diff --git a/src/pretix/base/templatetags/money.py b/src/pretix/base/templatetags/money.py index edda9904e..e45468411 100644 --- a/src/pretix/base/templatetags/money.py +++ b/src/pretix/base/templatetags/money.py @@ -26,7 +26,8 @@ from babel.numbers import format_currency from django import template from django.conf import settings from django.template.defaultfilters import floatformat -from django.utils import translation + +from pretix.base.i18n import get_babel_locale register = template.Library() @@ -59,13 +60,10 @@ def money_filter(value: Decimal, arg='', hide_currency=False): if hide_currency: return floatformat(value, f"{places}g") - locale_parts = translation.get_language().split("-", 1) - locale = locale_parts[0] - if len(locale_parts) > 1 and len(locale_parts[1]) == 2: - try: - locale = Locale(locale_parts[0], locale_parts[1].upper()) - except UnknownLocaleError: - pass + try: + locale = Locale(get_babel_locale()) + except UnknownLocaleError: + locale = "en" try: return format_currency(value, arg, locale=locale) diff --git a/src/pretix/presale/views/__init__.py b/src/pretix/presale/views/__init__.py index f5f192d8c..3046711e2 100644 --- a/src/pretix/presale/views/__init__.py +++ b/src/pretix/presale/views/__init__.py @@ -48,7 +48,7 @@ from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ from django_scopes import scopes_disabled -from pretix.base.i18n import get_language_without_region +from pretix.base.i18n import get_language_without_region, set_region from pretix.base.middleware import get_supported_language from pretix.base.models import ( CartPosition, Customer, InvoiceAddress, ItemAddOn, OrderFee, Question, @@ -544,6 +544,7 @@ def iframe_entry_view_wrapper(view_func): region = request.event.settings.region if '-' not in lng and region: lng += '-' + region.lower() + set_region(region) # with language() is not good enough here – we really need to take the role of LocaleMiddleware and modify # global state, because template rendering might be happening lazily. diff --git a/src/tests/base/test_templatetag.py b/src/tests/base/test_templatetag.py index d310deedd..d2041e5d3 100644 --- a/src/tests/base/test_templatetag.py +++ b/src/tests/base/test_templatetag.py @@ -68,9 +68,6 @@ def test_urlreplace_replace_parameter(): ("en", Decimal("1023"), "JPY", "¥1,023"), - ("pt-pt", Decimal("10.00"), "EUR", "10,00" + NBSP + "€"), - ("pt-br", Decimal("10.00"), "EUR", "€" + NBSP + "10,00"), - # unknown currency ("de", Decimal("1234.56"), "FOO", "1.234,56" + NBSP + "FOO"), ("de", Decimal("1234.567"), "FOO", "1.234,57" + NBSP + "FOO"), diff --git a/src/tests/helpers/test_i18n.py b/src/tests/helpers/test_i18n.py index 1c1306dce..0726af70d 100644 --- a/src/tests/helpers/test_i18n.py +++ b/src/tests/helpers/test_i18n.py @@ -19,9 +19,12 @@ # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # +import pytest from django.utils.translation import get_language -from pretix.base.i18n import get_language_without_region, language +from pretix.base.i18n import ( + get_babel_locale, get_language_without_region, language, +) from pretix.helpers.i18n import get_javascript_format, get_moment_locale @@ -42,22 +45,26 @@ def test_get_locale(): assert get_moment_locale('en-CA') == 'en-ca' -def test_set_region(): - with language('de'): - assert get_language() == 'de' - assert get_language_without_region() == 'de' - with language('de', 'US'): - assert get_language() == 'de-us' - assert get_language_without_region() == 'de' - with language('de', 'DE'): - assert get_language() == 'de-de' - assert get_language_without_region() == 'de' - with language('de-informal', 'DE'): - assert get_language() == 'de-informal' - assert get_language_without_region() == 'de-informal' - with language('pt', 'PT'): - assert get_language() == 'pt-pt' - assert get_language_without_region() == 'pt-pt' - with language('pt-pt', 'BR'): - assert get_language() == 'pt-pt' - assert get_language_without_region() == 'pt-pt' +@pytest.mark.parametrize( + ["lng_in", "region_in", "lng_out", "lng_without_region_out", "babel_out"], + [ + ("en", None, "en", "en", "en"), + ("en-us", None, "en-us", "en", "en_US"), + ("en", "US", "en-us", "en", "en_US"), + ("de", None, "de", "de", "de"), + ("de", "US", "de-us", "de", "de"), + ("de", "DE", "de-de", "de", "de_DE"), + ("de-informal", "DE", "de-informal", "de-informal", "de_DE"), + ("de-informal", "CH", "de-informal", "de-informal", "de_CH"), + ("pt-pt", "PT", "pt-pt", "pt-pt", "pt_PT"), + ("es", "MX", "es-mx", "es", "es_MX"), + ("es-419", "MX", "es-419", "es-419", "es_MX"), + ("zh-hans", "CN", "zh-hans", "zh-hans", "zh_Hans_CN"), + ("zh-hant", "TW", "zh-hant", "zh-hant", "zh_Hant_TW"), + ], +) +def test_set_region(lng_in, region_in, lng_out, lng_without_region_out, babel_out): + with language(lng_in, region_in): + assert get_language() == lng_out + assert get_language_without_region() == lng_without_region_out + assert str(get_babel_locale()) == babel_out