feat(i18n): add da/en locale pipeline and shared backend key resolver
All checks were successful
CI / test-and-quality (push) Successful in 2m21s
CI / test-and-quality (pull_request) Successful in 2m21s

This commit is contained in:
2026-03-01 18:57:45 +00:00
parent fcfb3b21b1
commit 9e47a3a139
6 changed files with 226 additions and 36 deletions

View File

@@ -1,8 +1,12 @@
import json
import logging
from functools import lru_cache
from pathlib import Path
from django.http import JsonResponse
from django.http import HttpRequest, JsonResponse
from django.utils.translation import get_language_from_request
LOGGER = logging.getLogger(__name__)
@lru_cache(maxsize=1)
@@ -12,9 +16,55 @@ def lobby_i18n_catalog() -> dict:
return json.load(handle)
@lru_cache(maxsize=1)
def i18n_locale_config() -> tuple[str, tuple[str, ...]]:
locales = lobby_i18n_catalog().get("locales", {})
default_locale = str(locales.get("default", "en")).strip().lower() or "en"
supported_locales = tuple(
locale.strip().lower() for locale in locales.get("supported", ["en", "da"]) if str(locale).strip()
) or ("en", "da")
return default_locale, supported_locales
def lobby_i18n_errors() -> dict:
return lobby_i18n_catalog().get("backend", {}).get("error_codes", {})
def api_error(*, code: str, message: str, status: int) -> JsonResponse:
return JsonResponse({"error": message, "error_code": code}, status=status)
def lobby_i18n_error_messages() -> dict:
return lobby_i18n_catalog().get("backend", {}).get("errors", {})
def resolve_locale(request: HttpRequest) -> str:
default_locale, supported_locales = i18n_locale_config()
requested = (get_language_from_request(request) or "").split("-", 1)[0].lower()
if requested in supported_locales:
return requested
return default_locale
def resolve_error_message(*, key: str, locale: str) -> str:
default_locale, _supported_locales = i18n_locale_config()
translations = lobby_i18n_error_messages().get(key)
if not isinstance(translations, dict):
LOGGER.warning("i18n key missing in shared catalog", extra={"key": key, "locale": locale})
return key
if locale in translations and translations[locale]:
return translations[locale]
if default_locale in translations and translations[default_locale]:
return translations[default_locale]
LOGGER.warning("i18n translation missing for key", extra={"key": key, "locale": locale})
return key
def api_error(request: HttpRequest, *, key: str, status: int) -> JsonResponse:
locale = resolve_locale(request)
return JsonResponse(
{
"error": resolve_error_message(key=key, locale=locale),
"error_code": key,
"locale": locale,
},
status=status,
)

View File

@@ -19,6 +19,7 @@ from fupogfakta.models import (
RoundConfig,
RoundQuestion,
)
from lobby.i18n import resolve_error_message
User = get_user_model()
@@ -110,6 +111,32 @@ class LobbyFlowTests(TestCase):
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error"], "Session is not joinable")
def test_join_error_localizes_to_danish_with_accept_language_header(self):
response = self.client.post(
reverse("lobby:join_session"),
data={"code": " ", "nickname": "Luna"},
content_type="application/json",
HTTP_ACCEPT_LANGUAGE="da",
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error_code"], "session_code_required")
self.assertEqual(response.json()["locale"], "da")
self.assertEqual(response.json()["error"], "Sessionskode er påkrævet")
def test_join_error_falls_back_to_english_for_unsupported_locale(self):
response = self.client.post(
reverse("lobby:join_session"),
data={"code": " ", "nickname": "Luna"},
content_type="application/json",
HTTP_ACCEPT_LANGUAGE="fr",
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error_code"], "session_code_required")
self.assertEqual(response.json()["locale"], "en")
self.assertEqual(response.json()["error"], "Session code is required")
def test_session_detail_returns_players(self):
session = GameSession.objects.create(host=self.host, code="LMNO45")
Player.objects.create(session=session, nickname="Mia", score=7)
@@ -1174,3 +1201,8 @@ class SmokeStagingCommandTests(TestCase):
"finish_game",
],
)
class I18nResolverTests(TestCase):
def test_missing_backend_key_returns_key_deterministically(self):
self.assertEqual(resolve_error_message(key="missing_key", locale="da"), "missing_key")

View File

@@ -128,15 +128,15 @@ def join_session(request: HttpRequest) -> JsonResponse:
if not code:
return api_error(
code=ERROR_CODES.get("session_code_required", "session_code_required"),
message="Session code is required",
request,
key=ERROR_CODES.get("session_code_required", "session_code_required"),
status=400,
)
if len(nickname) < 2 or len(nickname) > 40:
return api_error(
code=ERROR_CODES.get("nickname_invalid", "nickname_invalid"),
message="Nickname must be between 2 and 40 characters",
request,
key=ERROR_CODES.get("nickname_invalid", "nickname_invalid"),
status=400,
)
@@ -144,22 +144,22 @@ def join_session(request: HttpRequest) -> JsonResponse:
session = GameSession.objects.get(code=code)
except GameSession.DoesNotExist:
return api_error(
code=ERROR_CODES.get("session_not_found", "session_not_found"),
message="Session not found",
request,
key=ERROR_CODES.get("session_not_found", "session_not_found"),
status=404,
)
if session.status not in JOINABLE_STATUSES:
return api_error(
code=ERROR_CODES.get("session_not_joinable", "session_not_joinable"),
message="Session is not joinable",
request,
key=ERROR_CODES.get("session_not_joinable", "session_not_joinable"),
status=400,
)
if Player.objects.filter(session=session, nickname__iexact=nickname).exists():
return api_error(
code=ERROR_CODES.get("nickname_taken", "nickname_taken"),
message="Nickname already taken",
request,
key=ERROR_CODES.get("nickname_taken", "nickname_taken"),
status=409,
)
@@ -190,8 +190,8 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return api_error(
code=ERROR_CODES.get("session_not_found", "session_not_found"),
message="Session not found",
request,
key=ERROR_CODES.get("session_not_found", "session_not_found"),
status=404,
)
@@ -251,8 +251,8 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
if not category_slug:
return api_error(
code=ERROR_CODES.get("category_slug_required", "category_slug_required"),
message="category_slug is required",
request,
key=ERROR_CODES.get("category_slug_required", "category_slug_required"),
status=400,
)
@@ -262,8 +262,8 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return api_error(
code=ERROR_CODES.get("session_not_found", "session_not_found"),
message="Session not found",
request,
key=ERROR_CODES.get("session_not_found", "session_not_found"),
status=404,
)
@@ -272,8 +272,8 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
if session.status != GameSession.Status.LOBBY:
return api_error(
code=ERROR_CODES.get("round_start_invalid_phase", "round_start_invalid_phase"),
message="Round can only be started from lobby",
request,
key=ERROR_CODES.get("round_start_invalid_phase", "round_start_invalid_phase"),
status=400,
)
@@ -281,8 +281,8 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
category = Category.objects.get(slug=category_slug, is_active=True)
except Category.DoesNotExist:
return api_error(
code=ERROR_CODES.get("category_not_found", "category_not_found"),
message="Category not found",
request,
key=ERROR_CODES.get("category_not_found", "category_not_found"),
status=404,
)
@@ -293,8 +293,8 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
session = GameSession.objects.select_for_update().get(pk=session.pk)
if session.status != GameSession.Status.LOBBY:
return api_error(
code=ERROR_CODES.get("round_start_invalid_phase", "round_start_invalid_phase"),
message="Round can only be started from lobby",
request,
key=ERROR_CODES.get("round_start_invalid_phase", "round_start_invalid_phase"),
status=400,
)
@@ -305,8 +305,8 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
)
if not created:
return api_error(
code=ERROR_CODES.get("round_already_configured", "round_already_configured"),
message="Round already configured",
request,
key=ERROR_CODES.get("round_already_configured", "round_already_configured"),
status=409,
)