feat(i18n): add browser-language localization with Jinja2 _() and locale middleware
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:
2026-05-10 16:22:06 +03:00
parent 4e6505c598
commit d32ad29abc
18 changed files with 1077 additions and 100 deletions

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

View File

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