[READY][i18n][P17] Django i18n foundation: locale pipeline + resolver for shared keys (da/en) #209
36
docs/ISSUE-205-I18N-FOUNDATION.md
Normal file
36
docs/ISSUE-205-I18N-FOUNDATION.md
Normal file
@@ -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.
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from pathlib import Path
|
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)
|
@lru_cache(maxsize=1)
|
||||||
@@ -12,9 +16,55 @@ def lobby_i18n_catalog() -> dict:
|
|||||||
return json.load(handle)
|
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:
|
def lobby_i18n_errors() -> dict:
|
||||||
return lobby_i18n_catalog().get("backend", {}).get("error_codes", {})
|
return lobby_i18n_catalog().get("backend", {}).get("error_codes", {})
|
||||||
|
|
||||||
|
|
||||||
def api_error(*, code: str, message: str, status: int) -> JsonResponse:
|
def lobby_i18n_error_messages() -> dict:
|
||||||
return JsonResponse({"error": message, "error_code": code}, status=status)
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from fupogfakta.models import (
|
|||||||
RoundConfig,
|
RoundConfig,
|
||||||
RoundQuestion,
|
RoundQuestion,
|
||||||
)
|
)
|
||||||
|
from lobby.i18n import resolve_error_message
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
@@ -110,6 +111,32 @@ class LobbyFlowTests(TestCase):
|
|||||||
self.assertEqual(response.status_code, 400)
|
self.assertEqual(response.status_code, 400)
|
||||||
self.assertEqual(response.json()["error"], "Session is not joinable")
|
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):
|
def test_session_detail_returns_players(self):
|
||||||
session = GameSession.objects.create(host=self.host, code="LMNO45")
|
session = GameSession.objects.create(host=self.host, code="LMNO45")
|
||||||
Player.objects.create(session=session, nickname="Mia", score=7)
|
Player.objects.create(session=session, nickname="Mia", score=7)
|
||||||
@@ -1174,3 +1201,8 @@ class SmokeStagingCommandTests(TestCase):
|
|||||||
"finish_game",
|
"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")
|
||||||
|
|||||||
@@ -128,15 +128,15 @@ def join_session(request: HttpRequest) -> JsonResponse:
|
|||||||
|
|
||||||
if not code:
|
if not code:
|
||||||
return api_error(
|
return api_error(
|
||||||
code=ERROR_CODES.get("session_code_required", "session_code_required"),
|
request,
|
||||||
message="Session code is required",
|
key=ERROR_CODES.get("session_code_required", "session_code_required"),
|
||||||
status=400,
|
status=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
if len(nickname) < 2 or len(nickname) > 40:
|
if len(nickname) < 2 or len(nickname) > 40:
|
||||||
return api_error(
|
return api_error(
|
||||||
code=ERROR_CODES.get("nickname_invalid", "nickname_invalid"),
|
request,
|
||||||
message="Nickname must be between 2 and 40 characters",
|
key=ERROR_CODES.get("nickname_invalid", "nickname_invalid"),
|
||||||
status=400,
|
status=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -144,22 +144,22 @@ def join_session(request: HttpRequest) -> JsonResponse:
|
|||||||
session = GameSession.objects.get(code=code)
|
session = GameSession.objects.get(code=code)
|
||||||
except GameSession.DoesNotExist:
|
except GameSession.DoesNotExist:
|
||||||
return api_error(
|
return api_error(
|
||||||
code=ERROR_CODES.get("session_not_found", "session_not_found"),
|
request,
|
||||||
message="Session not found",
|
key=ERROR_CODES.get("session_not_found", "session_not_found"),
|
||||||
status=404,
|
status=404,
|
||||||
)
|
)
|
||||||
|
|
||||||
if session.status not in JOINABLE_STATUSES:
|
if session.status not in JOINABLE_STATUSES:
|
||||||
return api_error(
|
return api_error(
|
||||||
code=ERROR_CODES.get("session_not_joinable", "session_not_joinable"),
|
request,
|
||||||
message="Session is not joinable",
|
key=ERROR_CODES.get("session_not_joinable", "session_not_joinable"),
|
||||||
status=400,
|
status=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
if Player.objects.filter(session=session, nickname__iexact=nickname).exists():
|
if Player.objects.filter(session=session, nickname__iexact=nickname).exists():
|
||||||
return api_error(
|
return api_error(
|
||||||
code=ERROR_CODES.get("nickname_taken", "nickname_taken"),
|
request,
|
||||||
message="Nickname already taken",
|
key=ERROR_CODES.get("nickname_taken", "nickname_taken"),
|
||||||
status=409,
|
status=409,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -190,8 +190,8 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse:
|
|||||||
session = GameSession.objects.get(code=session_code)
|
session = GameSession.objects.get(code=session_code)
|
||||||
except GameSession.DoesNotExist:
|
except GameSession.DoesNotExist:
|
||||||
return api_error(
|
return api_error(
|
||||||
code=ERROR_CODES.get("session_not_found", "session_not_found"),
|
request,
|
||||||
message="Session not found",
|
key=ERROR_CODES.get("session_not_found", "session_not_found"),
|
||||||
status=404,
|
status=404,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -251,8 +251,8 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
|
|||||||
|
|
||||||
if not category_slug:
|
if not category_slug:
|
||||||
return api_error(
|
return api_error(
|
||||||
code=ERROR_CODES.get("category_slug_required", "category_slug_required"),
|
request,
|
||||||
message="category_slug is required",
|
key=ERROR_CODES.get("category_slug_required", "category_slug_required"),
|
||||||
status=400,
|
status=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -262,8 +262,8 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
|
|||||||
session = GameSession.objects.get(code=session_code)
|
session = GameSession.objects.get(code=session_code)
|
||||||
except GameSession.DoesNotExist:
|
except GameSession.DoesNotExist:
|
||||||
return api_error(
|
return api_error(
|
||||||
code=ERROR_CODES.get("session_not_found", "session_not_found"),
|
request,
|
||||||
message="Session not found",
|
key=ERROR_CODES.get("session_not_found", "session_not_found"),
|
||||||
status=404,
|
status=404,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -272,8 +272,8 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
|
|||||||
|
|
||||||
if session.status != GameSession.Status.LOBBY:
|
if session.status != GameSession.Status.LOBBY:
|
||||||
return api_error(
|
return api_error(
|
||||||
code=ERROR_CODES.get("round_start_invalid_phase", "round_start_invalid_phase"),
|
request,
|
||||||
message="Round can only be started from lobby",
|
key=ERROR_CODES.get("round_start_invalid_phase", "round_start_invalid_phase"),
|
||||||
status=400,
|
status=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -281,8 +281,8 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
|
|||||||
category = Category.objects.get(slug=category_slug, is_active=True)
|
category = Category.objects.get(slug=category_slug, is_active=True)
|
||||||
except Category.DoesNotExist:
|
except Category.DoesNotExist:
|
||||||
return api_error(
|
return api_error(
|
||||||
code=ERROR_CODES.get("category_not_found", "category_not_found"),
|
request,
|
||||||
message="Category not found",
|
key=ERROR_CODES.get("category_not_found", "category_not_found"),
|
||||||
status=404,
|
status=404,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -293,8 +293,8 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
|
|||||||
session = GameSession.objects.select_for_update().get(pk=session.pk)
|
session = GameSession.objects.select_for_update().get(pk=session.pk)
|
||||||
if session.status != GameSession.Status.LOBBY:
|
if session.status != GameSession.Status.LOBBY:
|
||||||
return api_error(
|
return api_error(
|
||||||
code=ERROR_CODES.get("round_start_invalid_phase", "round_start_invalid_phase"),
|
request,
|
||||||
message="Round can only be started from lobby",
|
key=ERROR_CODES.get("round_start_invalid_phase", "round_start_invalid_phase"),
|
||||||
status=400,
|
status=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -305,8 +305,8 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
|
|||||||
)
|
)
|
||||||
if not created:
|
if not created:
|
||||||
return api_error(
|
return api_error(
|
||||||
code=ERROR_CODES.get("round_already_configured", "round_already_configured"),
|
request,
|
||||||
message="Round already configured",
|
key=ERROR_CODES.get("round_already_configured", "round_already_configured"),
|
||||||
status=409,
|
status=409,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ INSTALLED_APPS = [
|
|||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
'django.middleware.security.SecurityMiddleware',
|
'django.middleware.security.SecurityMiddleware',
|
||||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
|
'django.middleware.locale.LocaleMiddleware',
|
||||||
'django.middleware.common.CommonMiddleware',
|
'django.middleware.common.CommonMiddleware',
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
@@ -89,7 +90,12 @@ AUTH_PASSWORD_VALIDATORS = [
|
|||||||
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
|
{'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'
|
TIME_ZONE = 'Europe/Copenhagen'
|
||||||
USE_I18N = True
|
USE_I18N = True
|
||||||
USE_TZ = True
|
USE_TZ = True
|
||||||
|
|||||||
@@ -1,14 +1,42 @@
|
|||||||
{
|
{
|
||||||
|
"locales": {
|
||||||
|
"default": "en",
|
||||||
|
"supported": ["en", "da"]
|
||||||
|
},
|
||||||
"frontend": {
|
"frontend": {
|
||||||
"errors": {
|
"errors": {
|
||||||
"session_code_required": "Session code is required.",
|
"session_code_required": {
|
||||||
"session_fetch_failed": "Could not load lobby status.",
|
"en": "Session code is required.",
|
||||||
"join_failed": "Join failed. Check code or nickname and try again.",
|
"da": "Sessionskoden er påkrævet."
|
||||||
"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.",
|
"session_fetch_failed": {
|
||||||
"nickname_invalid": "Nickname must be between 2 and 40 characters.",
|
"en": "Could not load lobby status.",
|
||||||
"nickname_taken": "Nickname is already taken.",
|
"da": "Kunne ikke indlæse lobby-status."
|
||||||
"unknown": "Action failed. Refresh status and try again."
|
},
|
||||||
|
"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": {
|
"backend": {
|
||||||
@@ -22,6 +50,44 @@
|
|||||||
"category_not_found": "category_not_found",
|
"category_not_found": "category_not_found",
|
||||||
"round_start_invalid_phase": "round_start_invalid_phase",
|
"round_start_invalid_phase": "round_start_invalid_phase",
|
||||||
"round_already_configured": "round_already_configured"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user