feat(i18n): add browser-language localization with Jinja2 _() and locale middleware
Some checks failed
ci/woodpecker/pr/pipeline Pipeline failed
Some checks failed
ci/woodpecker/pr/pipeline Pipeline failed
Add i18n support to the blog web UI with 4 languages (en/ru/fr/de),
80 translation keys, automatic Accept-Language detection, persistent
locale cookie, and a language switcher dropdown in the header.
- Infrastructure: TranslationService, translation dicts, convenience _()
- Presentation: locale middleware, /web/lang/{locale} switcher route
- Templates: all 9 templates use {{ _(key, current_locale) }}
- Tests: 26 tests across TranslationService, locale detection helpers
- Docs: TEST_MODEL.md and FEATURE_INFRASTRUCTURE.md updated with TC-UNIT-811-821
This commit is contained in:
72
app/presentation/web/locale.py
Normal file
72
app/presentation/web/locale.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Locale detection and management for i18n support.
|
||||
|
||||
This module provides locale detection from Accept-Language headers and cookies,
|
||||
following the same middleware pattern as the flash message system.
|
||||
"""
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from app.infrastructure.i18n.translator import DEFAULT_LOCALE, SUPPORTED_LOCALES
|
||||
|
||||
LOCALE_COOKIE_NAME = "locale"
|
||||
SUPPORTED_LOCALES_SET: frozenset[str] = SUPPORTED_LOCALES
|
||||
|
||||
|
||||
def _parse_accept_language(header: str) -> list[str]:
|
||||
"""Parse Accept-Language header into ordered list of locale codes.
|
||||
|
||||
Args:
|
||||
header: Raw Accept-Language header value.
|
||||
|
||||
Returns:
|
||||
List of locale codes in preference order, with region subtags removed.
|
||||
"""
|
||||
if not header:
|
||||
return []
|
||||
|
||||
locales: list[str] = []
|
||||
for part in header.split(","):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
locale = part.split(";")[0].strip().split("-")[0]
|
||||
if locale:
|
||||
locales.append(locale)
|
||||
return locales
|
||||
|
||||
|
||||
def _get_best_locale(request: Request) -> str:
|
||||
"""Detect the best locale for the current request.
|
||||
|
||||
Priority order: cookie → Accept-Language header → default.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object.
|
||||
|
||||
Returns:
|
||||
Best matching locale code, defaulting to ``en``.
|
||||
"""
|
||||
cookie_locale = request.cookies.get(LOCALE_COOKIE_NAME)
|
||||
if cookie_locale and cookie_locale in SUPPORTED_LOCALES_SET:
|
||||
return cookie_locale
|
||||
|
||||
accept_language = request.headers.get("accept-language", "")
|
||||
for lang in _parse_accept_language(accept_language):
|
||||
if lang in SUPPORTED_LOCALES_SET:
|
||||
return lang
|
||||
|
||||
return DEFAULT_LOCALE
|
||||
|
||||
|
||||
async def setup_locale_manager(request: Request) -> None:
|
||||
"""Set the detected locale on request state.
|
||||
|
||||
Called early in the request lifecycle so that route handlers and
|
||||
template rendering can access the current locale via
|
||||
``request.state.locale``.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object.
|
||||
"""
|
||||
if not hasattr(request.state, "locale"):
|
||||
request.state.locale = _get_best_locale(request)
|
||||
@@ -33,6 +33,8 @@ from app.domain.exceptions import (
|
||||
)
|
||||
from app.domain.roles import Role, get_effective_role
|
||||
from app.infrastructure.auth import TokenInfo
|
||||
from app.infrastructure.config.settings import settings
|
||||
from app.infrastructure.i18n.translator import DEFAULT_LOCALE, SUPPORTED_LOCALES, _
|
||||
from app.presentation.web.deps import (
|
||||
OptionalUserDep,
|
||||
RequireUserDep,
|
||||
@@ -47,6 +49,22 @@ router = APIRouter(prefix="/web", tags=["web"], route_class=DishkaRoute)
|
||||
templates = Jinja2Templates(directory="app/presentation/templates")
|
||||
|
||||
|
||||
def _jinja_translate(key: str, locale: str = DEFAULT_LOCALE) -> str:
|
||||
"""Jinja2 global function for template translation.
|
||||
|
||||
Args:
|
||||
key: Translation key to look up.
|
||||
locale: Target locale code.
|
||||
|
||||
Returns:
|
||||
Translated string or the key itself if no translation found.
|
||||
"""
|
||||
return _(key, locale)
|
||||
|
||||
|
||||
templates.env.globals["_"] = _jinja_translate
|
||||
|
||||
|
||||
_md = MarkdownIt("commonmark", {"html": False}).enable("table")
|
||||
|
||||
|
||||
@@ -85,14 +103,17 @@ def _get_user_role(user: TokenInfo | None) -> Role:
|
||||
return get_effective_role(user.roles)
|
||||
|
||||
|
||||
def _get_base_context(user: TokenInfo | None) -> dict[str, Any]:
|
||||
def _get_base_context(
|
||||
user: TokenInfo | None, current_locale: str = DEFAULT_LOCALE
|
||||
) -> dict[str, Any]:
|
||||
"""Get base template context with user info and permissions.
|
||||
|
||||
Args:
|
||||
user: Current user or None for guest.
|
||||
current_locale: Active locale code for i18n.
|
||||
|
||||
Returns:
|
||||
Dictionary with user, user_role, and can_create flags.
|
||||
Dictionary with user, user_role, can_create, and current_locale.
|
||||
"""
|
||||
user_role = _get_user_role(user)
|
||||
|
||||
@@ -100,6 +121,7 @@ def _get_base_context(user: TokenInfo | None) -> dict[str, Any]:
|
||||
"user": user,
|
||||
"user_role": user_role.value if user_role else None,
|
||||
"can_create": can_create_post(user),
|
||||
"current_locale": current_locale,
|
||||
}
|
||||
|
||||
|
||||
@@ -173,7 +195,8 @@ async def home(
|
||||
list_use_case, user, _DEFAULT_PAGE_SIZE, offset
|
||||
)
|
||||
|
||||
context = _get_base_context(user)
|
||||
locale = getattr(request.state, "locale", DEFAULT_LOCALE)
|
||||
context = _get_base_context(user, locale)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"pages/index.html",
|
||||
@@ -212,7 +235,8 @@ async def list_posts(
|
||||
list_use_case, user, _DEFAULT_PAGE_SIZE, offset
|
||||
)
|
||||
|
||||
context = _get_base_context(user)
|
||||
locale = getattr(request.state, "locale", DEFAULT_LOCALE)
|
||||
context = _get_base_context(user, locale)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"pages/index.html",
|
||||
@@ -241,7 +265,8 @@ async def new_post_form(
|
||||
Returns:
|
||||
HTMLResponse with rendered post form template.
|
||||
"""
|
||||
context = _get_base_context(user)
|
||||
locale = getattr(request.state, "locale", DEFAULT_LOCALE)
|
||||
context = _get_base_context(user, locale)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
@@ -291,11 +316,12 @@ async def create_post(
|
||||
result = await create_use_case.execute(dto)
|
||||
|
||||
user_role = _get_user_role(user)
|
||||
locale = getattr(request.state, "locale", DEFAULT_LOCALE)
|
||||
if action == "publish":
|
||||
await publish_use_case.publish(result.id, user.user_id, user_role)
|
||||
flash(request, "Post published successfully!", "success")
|
||||
flash(request, _("flash.post_published", locale), "success")
|
||||
else:
|
||||
flash(request, "Post saved as draft!", "success")
|
||||
flash(request, _("flash.post_saved_draft", locale), "success")
|
||||
|
||||
return RedirectResponse(url=f"/web/posts/{result.slug}", status_code=303)
|
||||
except AlreadyExistsException as exc:
|
||||
@@ -335,7 +361,8 @@ async def post_detail(
|
||||
if not post.published and not can_see_draft(user, post.author_id):
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
|
||||
context = _get_base_context(user)
|
||||
locale = getattr(request.state, "locale", DEFAULT_LOCALE)
|
||||
context = _get_base_context(user, locale)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
@@ -379,7 +406,8 @@ async def edit_post_form(
|
||||
if not can_edit_post(user, post.author_id):
|
||||
raise HTTPException(status_code=403, detail="Not authorized to edit this post")
|
||||
|
||||
context = _get_base_context(user)
|
||||
locale = getattr(request.state, "locale", DEFAULT_LOCALE)
|
||||
context = _get_base_context(user, locale)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
@@ -447,7 +475,8 @@ async def update_post(
|
||||
if result.published:
|
||||
await publish_use_case.unpublish(result.id, user.user_id, user_role)
|
||||
|
||||
flash(request, "Post updated successfully!", "success")
|
||||
locale = getattr(request.state, "locale", DEFAULT_LOCALE)
|
||||
flash(request, _("flash.post_updated", locale), "success")
|
||||
return RedirectResponse(url=f"/web/posts/{result.slug}", status_code=303)
|
||||
except (AlreadyExistsException, ValidationException) as exc:
|
||||
flash(request, str(exc), "error")
|
||||
@@ -485,9 +514,11 @@ async def delete_post(
|
||||
try:
|
||||
user_role = _get_user_role(user)
|
||||
await delete_use_case.execute(post.id, user.user_id, user_role)
|
||||
flash(request, "Post deleted successfully!", "success")
|
||||
locale = getattr(request.state, "locale", DEFAULT_LOCALE)
|
||||
flash(request, _("flash.post_deleted", locale), "success")
|
||||
except NotFoundException:
|
||||
flash(request, "Post not found.", "error")
|
||||
locale = getattr(request.state, "locale", DEFAULT_LOCALE)
|
||||
flash(request, _("flash.post_not_found", locale), "error")
|
||||
|
||||
return RedirectResponse(url="/web/", status_code=303)
|
||||
|
||||
@@ -506,7 +537,8 @@ async def profile(
|
||||
Returns:
|
||||
HTMLResponse with rendered profile template.
|
||||
"""
|
||||
context = _get_base_context(user)
|
||||
locale = getattr(request.state, "locale", DEFAULT_LOCALE)
|
||||
context = _get_base_context(user, locale)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
@@ -532,7 +564,8 @@ async def about(
|
||||
Returns:
|
||||
HTMLResponse with rendered about page template.
|
||||
"""
|
||||
context = _get_base_context(user)
|
||||
locale = getattr(request.state, "locale", DEFAULT_LOCALE)
|
||||
context = _get_base_context(user, locale)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
@@ -542,3 +575,37 @@ async def about(
|
||||
"active_page": "about",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/lang/{locale}")
|
||||
async def set_language(
|
||||
request: Request,
|
||||
locale: str,
|
||||
) -> RedirectResponse:
|
||||
"""Set the active language and redirect back to the previous page.
|
||||
|
||||
Stores the locale choice in a persistent cookie so that subsequent
|
||||
requests use the selected language. Falls back to browser preference
|
||||
or English default.
|
||||
|
||||
Args:
|
||||
request: HTTP request object.
|
||||
locale: Target locale code (en, ru, fr, de).
|
||||
|
||||
Returns:
|
||||
RedirectResponse back to the referrer or home page.
|
||||
"""
|
||||
if locale not in SUPPORTED_LOCALES:
|
||||
locale = DEFAULT_LOCALE
|
||||
|
||||
referer = request.headers.get("referer", "/web/")
|
||||
response = RedirectResponse(url=referer, status_code=303)
|
||||
response.set_cookie(
|
||||
key="locale",
|
||||
value=locale,
|
||||
httponly=True,
|
||||
secure=not settings.is_dev,
|
||||
samesite="lax",
|
||||
max_age=365 * 24 * 3600,
|
||||
)
|
||||
return response
|
||||
|
||||
Reference in New Issue
Block a user