From 9e47a3a1392287a5fa6c6cf645c835df1f3ca78f Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Sun, 1 Mar 2026 18:57:45 +0000 Subject: [PATCH] feat(i18n): add da/en locale pipeline and shared backend key resolver --- docs/ISSUE-205-I18N-FOUNDATION.md | 36 ++++++++++++++ lobby/i18n.py | 56 +++++++++++++++++++-- lobby/tests.py | 32 ++++++++++++ lobby/views.py | 48 +++++++++--------- partyhub/settings.py | 8 ++- shared/i18n/lobby.json | 82 ++++++++++++++++++++++++++++--- 6 files changed, 226 insertions(+), 36 deletions(-) create mode 100644 docs/ISSUE-205-I18N-FOUNDATION.md diff --git a/docs/ISSUE-205-I18N-FOUNDATION.md b/docs/ISSUE-205-I18N-FOUNDATION.md new file mode 100644 index 0000000..0186b34 --- /dev/null +++ b/docs/ISSUE-205-I18N-FOUNDATION.md @@ -0,0 +1,36 @@ +# Issue #205 — Django i18n foundation (da/en) + +## Implemented acceptance checks + +- **Django i18n setup for `en` + `da` with `en` fallback** + - `LANGUAGE_CODE` set to `en`. + - `LANGUAGES = [('en', 'English'), ('da', 'Danish')]`. + - `LocaleMiddleware` enabled in middleware chain. +- **Shared-key resolver/adapter (no ad hoc backend mapping)** + - Backend error responses now resolve from shared catalog keys in `shared/i18n/lobby.json`. + - `lobby.i18n.api_error()` accepts a shared key and resolves locale-specific text. +- **Representative API flow documented with key/locale behavior** + - `POST /lobby/join` with empty code returns: + - `error_code: "session_code_required"` + - localized `error` + - resolved `locale` +- **Missing key handling deterministic and loggable** + - `resolve_error_message()` returns key string when key/translation is missing. + - Warning is logged (`lobby.i18n` logger) for missing key/translation. + +## Example response behavior + +### Request +`POST /lobby/join` with empty code and header `Accept-Language: da` + +### Response (400) + +```json +{ + "error": "Sessionskode er påkrævet", + "error_code": "session_code_required", + "locale": "da" +} +``` + +If locale is unsupported (e.g. `fr`), response uses `locale: "en"` and English message. diff --git a/lobby/i18n.py b/lobby/i18n.py index 7d2f644..220571f 100644 --- a/lobby/i18n.py +++ b/lobby/i18n.py @@ -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, + ) diff --git a/lobby/tests.py b/lobby/tests.py index a7e430e..94010d2 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -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") diff --git a/lobby/views.py b/lobby/views.py index 6790270..3509eb9 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -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, ) diff --git a/partyhub/settings.py b/partyhub/settings.py index 6da5f0f..b0e8e59 100644 --- a/partyhub/settings.py +++ b/partyhub/settings.py @@ -30,6 +30,7 @@ INSTALLED_APPS = [ MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.locale.LocaleMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', @@ -89,7 +90,12 @@ AUTH_PASSWORD_VALIDATORS = [ {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'}, ] -LANGUAGE_CODE = 'da' +LANGUAGE_CODE = 'en' +LANGUAGES = [ + ('en', 'English'), + ('da', 'Danish'), +] +LOCALE_PATHS = [BASE_DIR / 'locale'] TIME_ZONE = 'Europe/Copenhagen' USE_I18N = True USE_TZ = True diff --git a/shared/i18n/lobby.json b/shared/i18n/lobby.json index 630940f..1077e6d 100644 --- a/shared/i18n/lobby.json +++ b/shared/i18n/lobby.json @@ -1,14 +1,42 @@ { + "locales": { + "default": "en", + "supported": ["en", "da"] + }, "frontend": { "errors": { - "session_code_required": "Session code is required.", - "session_fetch_failed": "Could not load lobby status.", - "join_failed": "Join failed. Check code or nickname and try again.", - "start_round_failed": "Could not start round. Refresh the lobby and try again.", - "session_not_found": "Session code is invalid or the session no longer exists.", - "nickname_invalid": "Nickname must be between 2 and 40 characters.", - "nickname_taken": "Nickname is already taken.", - "unknown": "Action failed. Refresh status and try again." + "session_code_required": { + "en": "Session code is required.", + "da": "Sessionskoden er påkrævet." + }, + "session_fetch_failed": { + "en": "Could not load lobby status.", + "da": "Kunne ikke indlæse lobby-status." + }, + "join_failed": { + "en": "Join failed. Check code or nickname and try again.", + "da": "Kunne ikke joine. Tjek kode eller kaldenavn og prøv igen." + }, + "start_round_failed": { + "en": "Could not start round. Refresh the lobby and try again.", + "da": "Kunne ikke starte runden. Opdater lobbyen og prøv igen." + }, + "session_not_found": { + "en": "Session code is invalid or the session no longer exists.", + "da": "Sessionskoden er ugyldig, eller sessionen findes ikke længere." + }, + "nickname_invalid": { + "en": "Nickname must be between 2 and 40 characters.", + "da": "Kaldenavn skal være mellem 2 og 40 tegn." + }, + "nickname_taken": { + "en": "Nickname is already taken.", + "da": "Kaldenavnet er allerede taget." + }, + "unknown": { + "en": "Action failed. Refresh status and try again.", + "da": "Handlingen fejlede. Opdater status og prøv igen." + } } }, "backend": { @@ -22,6 +50,44 @@ "category_not_found": "category_not_found", "round_start_invalid_phase": "round_start_invalid_phase", "round_already_configured": "round_already_configured" + }, + "errors": { + "session_code_required": { + "en": "Session code is required", + "da": "Sessionskode er påkrævet" + }, + "nickname_invalid": { + "en": "Nickname must be between 2 and 40 characters", + "da": "Kaldenavn skal være mellem 2 og 40 tegn" + }, + "session_not_found": { + "en": "Session not found", + "da": "Session blev ikke fundet" + }, + "session_not_joinable": { + "en": "Session is not joinable", + "da": "Sessionen kan ikke joine nu" + }, + "nickname_taken": { + "en": "Nickname already taken", + "da": "Kaldenavnet er allerede taget" + }, + "category_slug_required": { + "en": "category_slug is required", + "da": "category_slug er påkrævet" + }, + "category_not_found": { + "en": "Category not found", + "da": "Kategori blev ikke fundet" + }, + "round_start_invalid_phase": { + "en": "Round can only be started from lobby", + "da": "Runden kan kun startes fra lobbyen" + }, + "round_already_configured": { + "en": "Round already configured", + "da": "Runden er allerede konfigureret" + } } } } -- 2.39.5