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
This commit is contained in:
Raphael Michel
2026-01-16 17:08:46 +01:00
committed by GitHub
parent 6b65cb4e33
commit de9045afcf
6 changed files with 89 additions and 45 deletions

View File

@@ -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):

View File

@@ -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()

View File

@@ -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)

View File

@@ -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.

View File

@@ -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"),

View File

@@ -19,9 +19,12 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
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