Compare commits

...

3 Commits

Author SHA1 Message Date
391ecaa4b0 Localization (#15)
All checks were successful
ci/woodpecker/push/pipeline Pipeline was successful
2026-05-10 14:19:07 +00:00
de92f73f58 fix(i18n): register _() Jinja2 global and current_locale in error handlers
All checks were successful
ci/woodpecker/pr/pipeline Pipeline was successful
Error handlers had a separate Jinja2Templates instance without the _
global function, causing UndefinedError when rendering base.html
(which now calls {{ _(key, current_locale) }}).

- Register _() from translator module as Jinja2 global on error_handlers templates
- Add current_locale to get_template_context() from request.state.locale
2026-05-10 16:48:56 +03:00
d32ad29abc 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
2026-05-10 16:22:06 +03:00
19 changed files with 1080 additions and 100 deletions

View File

@@ -0,0 +1,9 @@
"""Internationalization support for the blog application.
This package provides translation dictionaries and the translation service
for localizing the web UI into multiple languages.
"""
from app.infrastructure.i18n.translator import SUPPORTED_LOCALES, TranslationService, _
__all__ = ["SUPPORTED_LOCALES", "TranslationService", "_"]

View File

@@ -0,0 +1,337 @@
"""Translation dictionaries for i18n support.
This module provides translation dictionaries for all supported locales.
Translations are organized by feature area for maintainability.
Keys use dot-separated namespacing to avoid collisions.
"""
TRANSLATIONS: dict[str, dict[str, str]] = {
"en": {
"nav.home": "Home",
"nav.posts": "Posts",
"nav.about": "About",
"header.logo": "Blog",
"header.profile": "Profile",
"header.new_post": "New Post",
"header.sign_out": "Sign Out",
"header.sign_in": "Sign In",
"header.toggle_menu": "Toggle menu",
"header.toggle_theme": "Toggle dark mode",
"header.lang_switcher": "Language",
"footer.copyright": "© 2026 Blog. All rights reserved.",
"footer.about": "About",
"footer.privacy": "Privacy",
"footer.terms": "Terms",
"footer.api": "API",
"home.title": "Blog - Home",
"home.meta_description": "Discover stories, thinking, and expertise from writers on any topic. A modern blog built with FastAPI.",
"home.meta_keywords": "blog, articles, posts, writing, fastapi, python",
"home.page_title": "Latest Posts",
"home.page_subtitle": "Discover stories, thinking, and expertise from writers on any topic.",
"home.write_post": "Write a Post",
"home.status_published": "Published",
"home.status_draft": "Draft",
"home.read_more": "Read more",
"home.pagination_previous": "Previous",
"home.pagination_next": "Next",
"home.empty_title": "No posts yet",
"home.empty_description": "Be the first to write a post!",
"home.empty_action": "Create your first post",
"post.status_published": "Published",
"post.status_draft": "Draft",
"post.back_to_posts": "Back to posts",
"post.edit": "Edit",
"post.delete": "Delete",
"post.delete_confirm": "Are you sure you want to delete this post?",
"post_form.title_edit": "Edit Post",
"post_form.title_new": "New Post",
"post_form.page_title_edit": "Edit Post",
"post_form.page_title_new": "Create New Post",
"post_form.label_title": "Title",
"post_form.placeholder_title": "Enter post title",
"post_form.hint_title": "A catchy title for your post",
"post_form.label_content": "Content",
"post_form.placeholder_content": "Write your post content here...",
"post_form.hint_content": "The main content of your post. Markdown is supported.",
"post_form.label_tags": "Tags",
"post_form.placeholder_tags": "python, fastapi, tutorial",
"post_form.hint_tags": "Comma-separated list of tags",
"post_form.cancel": "Cancel",
"post_form.save_draft": "Save as Draft",
"post_form.update_post": "Update Post",
"post_form.publish_post": "Publish Post",
"profile.title": "User Profile",
"profile.email": "Email:",
"profile.not_provided": "Not provided",
"profile.user_id": "User ID:",
"profile.name": "Name:",
"profile.back_home": "Back to Home",
"profile.new_post": "New Post",
"about.title": "About",
"about.page_title": "About",
"about.description": "A modern blog built with FastAPI and Domain-Driven Design architecture.",
"about.signed_in": "Signed in as {username}.",
"about.browsing_guest": "You are browsing as a guest.",
"about.back_home": "Back to Home",
"flash.post_published": "Post published successfully!",
"flash.post_saved_draft": "Post saved as draft!",
"flash.post_updated": "Post updated successfully!",
"flash.post_deleted": "Post deleted successfully!",
"flash.post_not_found": "Post not found.",
"base.close_message": "Close message",
"base.default_title": "Blog",
"base.meta_description": "Blog - A modern blogging platform built with FastAPI",
"base.meta_keywords": "blog, articles, posts, writing",
"base.meta_author": "Blog Team",
"lang.en": "English",
"lang.ru": "Русский",
"lang.fr": "Français",
"lang.de": "Deutsch",
},
"ru": {
"nav.home": "Главная",
"nav.posts": "Статьи",
"nav.about": "О нас",
"header.logo": "Блог",
"header.profile": "Профиль",
"header.new_post": "Новая статья",
"header.sign_out": "Выйти",
"header.sign_in": "Войти",
"header.toggle_menu": "Открыть меню",
"header.toggle_theme": "Сменить тему",
"header.lang_switcher": "Язык",
"footer.copyright": "© 2026 Блог. Все права защищены.",
"footer.about": "О нас",
"footer.privacy": "Конфиденциальность",
"footer.terms": "Условия",
"footer.api": "API",
"home.title": "Блог — Главная",
"home.meta_description": "Откройте для себя истории, мысли и опыт авторов на любую тему. Современный блог на FastAPI.",
"home.meta_keywords": "блог, статьи, посты, fastapi, python",
"home.page_title": "Последние статьи",
"home.page_subtitle": "Откройте для себя истории, мысли и опыт авторов на любую тему.",
"home.write_post": "Написать статью",
"home.status_published": "Опубликовано",
"home.status_draft": "Черновик",
"home.read_more": "Читать далее",
"home.pagination_previous": "Назад",
"home.pagination_next": "Вперёд",
"home.empty_title": "Статей пока нет",
"home.empty_description": "Будьте первым, кто напишет статью!",
"home.empty_action": "Создать первую статью",
"post.status_published": "Опубликовано",
"post.status_draft": "Черновик",
"post.back_to_posts": "К списку статей",
"post.edit": "Редактировать",
"post.delete": "Удалить",
"post.delete_confirm": "Вы уверены, что хотите удалить эту статью?",
"post_form.title_edit": "Редактировать статью",
"post_form.title_new": "Новая статья",
"post_form.page_title_edit": "Редактировать статью",
"post_form.page_title_new": "Создать новую статью",
"post_form.label_title": "Заголовок",
"post_form.placeholder_title": "Введите заголовок статьи",
"post_form.hint_title": "Запоминающийся заголовок для вашей статьи",
"post_form.label_content": "Содержание",
"post_form.placeholder_content": "Напишите содержание статьи здесь...",
"post_form.hint_content": "Основное содержание вашей статьи. Поддерживается Markdown.",
"post_form.label_tags": "Теги",
"post_form.placeholder_tags": "python, fastapi, tutorial",
"post_form.hint_tags": "Список тегов через запятую",
"post_form.cancel": "Отмена",
"post_form.save_draft": "Сохранить черновик",
"post_form.update_post": "Обновить статью",
"post_form.publish_post": "Опубликовать статью",
"profile.title": "Профиль пользователя",
"profile.email": "Email:",
"profile.not_provided": "Не указан",
"profile.user_id": "ID пользователя:",
"profile.name": "Имя:",
"profile.back_home": "На главную",
"profile.new_post": "Новая статья",
"about.title": "О нас",
"about.page_title": "О нас",
"about.description": "Современный блог на FastAPI с архитектурой Domain-Driven Design.",
"about.signed_in": "Вы вошли как {username}.",
"about.browsing_guest": "Вы просматриваете как гость.",
"about.back_home": "На главную",
"flash.post_published": "Статья успешно опубликована!",
"flash.post_saved_draft": "Статья сохранена как черновик!",
"flash.post_updated": "Статья успешно обновлена!",
"flash.post_deleted": "Статья успешно удалена!",
"flash.post_not_found": "Статья не найдена.",
"base.close_message": "Закрыть сообщение",
"base.default_title": "Блог",
"base.meta_description": "Блог — современная платформа для блогов на FastAPI",
"base.meta_keywords": "блог, статьи, посты, письмо",
"base.meta_author": "Команда блога",
"lang.en": "English",
"lang.ru": "Русский",
"lang.fr": "Français",
"lang.de": "Deutsch",
},
"fr": {
"nav.home": "Accueil",
"nav.posts": "Articles",
"nav.about": "À propos",
"header.logo": "Blog",
"header.profile": "Profil",
"header.new_post": "Nouvel article",
"header.sign_out": "Déconnexion",
"header.sign_in": "Connexion",
"header.toggle_menu": "Menu",
"header.toggle_theme": "Changer le thème",
"header.lang_switcher": "Langue",
"footer.copyright": "© 2026 Blog. Tous droits réservés.",
"footer.about": "À propos",
"footer.privacy": "Confidentialité",
"footer.terms": "Conditions",
"footer.api": "API",
"home.title": "Blog — Accueil",
"home.meta_description": "Découvrez des histoires, réflexions et expertises d'auteurs sur tous les sujets. Un blog moderne avec FastAPI.",
"home.meta_keywords": "blog, articles, posts, écriture, fastapi, python",
"home.page_title": "Derniers articles",
"home.page_subtitle": "Découvrez des histoires, réflexions et expertises d'auteurs sur tous les sujets.",
"home.write_post": "Écrire un article",
"home.status_published": "Publié",
"home.status_draft": "Brouillon",
"home.read_more": "Lire la suite",
"home.pagination_previous": "Précédent",
"home.pagination_next": "Suivant",
"home.empty_title": "Aucun article pour le moment",
"home.empty_description": "Soyez le premier à écrire un article !",
"home.empty_action": "Créer votre premier article",
"post.status_published": "Publié",
"post.status_draft": "Brouillon",
"post.back_to_posts": "Retour aux articles",
"post.edit": "Modifier",
"post.delete": "Supprimer",
"post.delete_confirm": "Êtes-vous sûr de vouloir supprimer cet article ?",
"post_form.title_edit": "Modifier l'article",
"post_form.title_new": "Nouvel article",
"post_form.page_title_edit": "Modifier l'article",
"post_form.page_title_new": "Créer un nouvel article",
"post_form.label_title": "Titre",
"post_form.placeholder_title": "Entrez le titre de l'article",
"post_form.hint_title": "Un titre accrocheur pour votre article",
"post_form.label_content": "Contenu",
"post_form.placeholder_content": "Écrivez votre article ici...",
"post_form.hint_content": "Le contenu principal de votre article. Markdown est supporté.",
"post_form.label_tags": "Tags",
"post_form.placeholder_tags": "python, fastapi, tutorial",
"post_form.hint_tags": "Liste de tags séparés par des virgules",
"post_form.cancel": "Annuler",
"post_form.save_draft": "Sauvegarder le brouillon",
"post_form.update_post": "Mettre à jour",
"post_form.publish_post": "Publier",
"profile.title": "Profil utilisateur",
"profile.email": "Email :",
"profile.not_provided": "Non fourni",
"profile.user_id": "ID utilisateur :",
"profile.name": "Nom :",
"profile.back_home": "Retour à l'accueil",
"profile.new_post": "Nouvel article",
"about.title": "À propos",
"about.page_title": "À propos",
"about.description": "Un blog moderne construit avec FastAPI et une architecture Domain-Driven Design.",
"about.signed_in": "Connecté en tant que {username}.",
"about.browsing_guest": "Vous naviguez en tant qu'invité.",
"about.back_home": "Retour à l'accueil",
"flash.post_published": "Article publié avec succès !",
"flash.post_saved_draft": "Article sauvegardé comme brouillon !",
"flash.post_updated": "Article mis à jour avec succès !",
"flash.post_deleted": "Article supprimé avec succès !",
"flash.post_not_found": "Article non trouvé.",
"base.close_message": "Fermer le message",
"base.default_title": "Blog",
"base.meta_description": "Blog — Une plateforme de blog moderne construite avec FastAPI",
"base.meta_keywords": "blog, articles, posts, écriture",
"base.meta_author": "Équipe du blog",
"lang.en": "English",
"lang.ru": "Русский",
"lang.fr": "Français",
"lang.de": "Deutsch",
},
"de": {
"nav.home": "Startseite",
"nav.posts": "Beiträge",
"nav.about": "Über uns",
"header.logo": "Blog",
"header.profile": "Profil",
"header.new_post": "Neuer Beitrag",
"header.sign_out": "Abmelden",
"header.sign_in": "Anmelden",
"header.toggle_menu": "Menü umschalten",
"header.toggle_theme": "Design umschalten",
"header.lang_switcher": "Sprache",
"footer.copyright": "© 2026 Blog. Alle Rechte vorbehalten.",
"footer.about": "Über uns",
"footer.privacy": "Datenschutz",
"footer.terms": "AGB",
"footer.api": "API",
"home.title": "Blog — Startseite",
"home.meta_description": "Entdecken Sie Geschichten, Gedanken und Fachwissen von Autoren zu jedem Thema. Ein moderner Blog mit FastAPI.",
"home.meta_keywords": "Blog, Artikel, Beiträge, Schreiben, Fastapi, Python",
"home.page_title": "Neueste Beiträge",
"home.page_subtitle": "Entdecken Sie Geschichten, Gedanken und Fachwissen von Autoren zu jedem Thema.",
"home.write_post": "Beitrag schreiben",
"home.status_published": "Veröffentlicht",
"home.status_draft": "Entwurf",
"home.read_more": "Weiterlesen",
"home.pagination_previous": "Zurück",
"home.pagination_next": "Weiter",
"home.empty_title": "Noch keine Beiträge",
"home.empty_description": "Schreiben Sie den ersten Beitrag!",
"home.empty_action": "Ersten Beitrag erstellen",
"post.status_published": "Veröffentlicht",
"post.status_draft": "Entwurf",
"post.back_to_posts": "Zurück zu den Beiträgen",
"post.edit": "Bearbeiten",
"post.delete": "Löschen",
"post.delete_confirm": "Sind Sie sicher, dass Sie diesen Beitrag löschen möchten?",
"post_form.title_edit": "Beitrag bearbeiten",
"post_form.title_new": "Neuer Beitrag",
"post_form.page_title_edit": "Beitrag bearbeiten",
"post_form.page_title_new": "Neuen Beitrag erstellen",
"post_form.label_title": "Titel",
"post_form.placeholder_title": "Geben Sie den Beitragstitel ein",
"post_form.hint_title": "Ein eingängiger Titel für Ihren Beitrag",
"post_form.label_content": "Inhalt",
"post_form.placeholder_content": "Schreiben Sie Ihren Beitrag hier...",
"post_form.hint_content": "Der Hauptinhalt Ihres Beitrags. Markdown wird unterstützt.",
"post_form.label_tags": "Tags",
"post_form.placeholder_tags": "python, fastapi, tutorial",
"post_form.hint_tags": "Kommagetrennte Tag-Liste",
"post_form.cancel": "Abbrechen",
"post_form.save_draft": "Als Entwurf speichern",
"post_form.update_post": "Beitrag aktualisieren",
"post_form.publish_post": "Beitrag veröffentlichen",
"profile.title": "Benutzerprofil",
"profile.email": "E-Mail:",
"profile.not_provided": "Nicht angegeben",
"profile.user_id": "Benutzer-ID:",
"profile.name": "Name:",
"profile.back_home": "Zurück zur Startseite",
"profile.new_post": "Neuer Beitrag",
"about.title": "Über uns",
"about.page_title": "Über uns",
"about.description": "Ein moderner Blog, erstellt mit FastAPI und Domain-Driven Design Architektur.",
"about.signed_in": "Angemeldet als {username}.",
"about.browsing_guest": "Sie surfen als Gast.",
"about.back_home": "Zurück zur Startseite",
"flash.post_published": "Beitrag erfolgreich veröffentlicht!",
"flash.post_saved_draft": "Beitrag als Entwurf gespeichert!",
"flash.post_updated": "Beitrag erfolgreich aktualisiert!",
"flash.post_deleted": "Beitrag erfolgreich gelöscht!",
"flash.post_not_found": "Beitrag nicht gefunden.",
"base.close_message": "Nachricht schließen",
"base.default_title": "Blog",
"base.meta_description": "Blog — Eine moderne Blogging-Plattform mit FastAPI",
"base.meta_keywords": "Blog, Artikel, Beiträge, Schreiben",
"base.meta_author": "Blog-Team",
"lang.en": "English",
"lang.ru": "Русский",
"lang.fr": "Français",
"lang.de": "Deutsch",
},
}

View File

@@ -0,0 +1,78 @@
"""Translation service for i18n support.
This module provides the translation service that resolves translation keys
to localized strings using in-memory translation dictionaries. Falls back
from requested locale through English to the raw key.
"""
from app.infrastructure.i18n.translations import TRANSLATIONS
SUPPORTED_LOCALES = frozenset({"en", "ru", "fr", "de"})
DEFAULT_LOCALE = "en"
class TranslationService:
"""Service for resolving translation keys to localized strings.
Provides a singleton-like interface for translating UI strings
across the application. Falls back through requested locale to
English and finally to the raw key if no translation exists.
Attributes:
translations: Dictionary of locale to key to string mappings.
"""
_instance: "TranslationService | None" = None
def __init__(self) -> None:
"""Initialize translation service with translation data."""
self.translations = TRANSLATIONS
@classmethod
def get_instance(cls) -> "TranslationService":
"""Get or create the singleton instance.
Returns:
The shared TranslationService instance.
"""
if cls._instance is None:
cls._instance = cls()
return cls._instance
def get_text(self, key: str, locale: str = DEFAULT_LOCALE) -> str:
"""Get translated text for a given key and locale.
Resolves the key through the locale chain: requested locale,
then English fallback, then the raw key itself.
Args:
key: Translation key (e.g. ``nav.home``).
locale: Target locale code (e.g. ``en``, ``ru``, ``fr``, ``de``).
Returns:
Translated string if found, otherwise the English version
or the key itself as last resort.
"""
locale_translations = self.translations.get(locale)
if locale_translations is not None and key in locale_translations:
return locale_translations[key]
if locale != DEFAULT_LOCALE:
fallback = self.translations.get(DEFAULT_LOCALE, {}).get(key)
if fallback is not None:
return fallback
return key
def _(key: str, locale: str = DEFAULT_LOCALE) -> str:
"""Convenience function for translating a single key.
Args:
key: Translation key to look up.
locale: Target locale code.
Returns:
Translated string or the key itself if no translation found.
"""
return TranslationService.get_instance().get_text(key, locale)

View File

@@ -28,6 +28,7 @@ from app.presentation.web import auth_router
from app.presentation.web import router as web_router from app.presentation.web import router as web_router
from app.presentation.web.error_handlers import register_error_handlers from app.presentation.web.error_handlers import register_error_handlers
from app.presentation.web.flash import setup_flash_manager from app.presentation.web.flash import setup_flash_manager
from app.presentation.web.locale import setup_locale_manager
@asynccontextmanager @asynccontextmanager
@@ -86,6 +87,15 @@ def app_factory() -> FastAPI:
request.state.flash_manager.set_cookie(response) request.state.flash_manager.set_cookie(response)
return response return response
@app.middleware("http")
async def locale_middleware(
request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
"""Middleware to detect and set locale for each request."""
await setup_locale_manager(request)
response = await call_next(request)
return response
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=["*"],

View File

@@ -1,11 +1,11 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" data-testid="html-root"> <html lang="{{ current_locale }}" data-testid="html-root">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="{% block meta_description %}Blog - A modern blogging platform built with FastAPI{% endblock %}"> <meta name="description" content="{% block meta_description %}{{ _('base.meta_description', current_locale) }}{% endblock %}">
<meta name="keywords" content="{% block meta_keywords %}blog, articles, posts, writing{% endblock %}"> <meta name="keywords" content="{% block meta_keywords %}{{ _('base.meta_keywords', current_locale) }}{% endblock %}">
<meta name="author" content="{% block meta_author %}Blog Team{% endblock %}"> <meta name="author" content="{% block meta_author %}{{ _('base.meta_author', current_locale) }}{% endblock %}">
<meta name="robots" content="{% block meta_robots %}index, follow{% endblock %}"> <meta name="robots" content="{% block meta_robots %}index, follow{% endblock %}">
<!-- Canonical URL --> <!-- Canonical URL -->
@@ -30,7 +30,7 @@
<link rel="icon" type="image/svg+xml" href="/static/images/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/static/images/favicon.svg">
<link rel="alternate icon" href="/static/images/favicon.ico"> <link rel="alternate icon" href="/static/images/favicon.ico">
<title data-testid="page-title">{% block title %}Blog{% endblock %}</title> <title data-testid="page-title">{% block title %}{{ _('base.default_title', current_locale) }}{% endblock %}</title>
<link rel="stylesheet" href="/static/css/themes/theme-light.css" data-testid="theme-light-stylesheet"> <link rel="stylesheet" href="/static/css/themes/theme-light.css" data-testid="theme-light-stylesheet">
<link rel="stylesheet" href="/static/css/themes/theme-dark.css" data-testid="theme-dark-stylesheet"> <link rel="stylesheet" href="/static/css/themes/theme-dark.css" data-testid="theme-dark-stylesheet">
@@ -51,7 +51,7 @@
{% for msg in flash_messages %} {% for msg in flash_messages %}
<div class="flash-message flash-{{ msg.category }}" data-testid="flash-message-{{ msg.category }}" role="alert"> <div class="flash-message flash-{{ msg.category }}" data-testid="flash-message-{{ msg.category }}" role="alert">
<span class="flash-text" data-testid="flash-text">{{ msg.message }}</span> <span class="flash-text" data-testid="flash-text">{{ msg.message }}</span>
<button type="button" class="flash-close" data-testid="flash-close" aria-label="Close message">&times;</button> <button type="button" class="flash-close" data-testid="flash-close" aria-label="{{ _('base.close_message', current_locale) }}">&times;</button>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>

View File

@@ -1,26 +1,26 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}About - Blog{% endblock %} {% block title %}{{ _('about.title', current_locale) }} - {{ _('base.default_title', current_locale) }}{% endblock %}
{% block meta_description %}A modern blog built with FastAPI and DDD architecture.{% endblock %} {% block meta_description %}{{ _('about.description', current_locale) }}{% endblock %}
{% block content %} {% block content %}
<div class="page-header" data-testid="page-header-about"> <div class="page-header" data-testid="page-header-about">
<h1 class="page-title" data-testid="page-title-about">About</h1> <h1 class="page-title" data-testid="page-title-about">{{ _('about.page_title', current_locale) }}</h1>
</div> </div>
<div class="card" data-testid="about-card"> <div class="card" data-testid="about-card">
<div class="card-body" data-testid="about-card-body"> <div class="card-body" data-testid="about-card-body">
<p data-testid="about-description"> <p data-testid="about-description">
A modern blog built with FastAPI and Domain-Driven Design architecture. {{ _('about.description', current_locale) }}
</p> </p>
<div class="divider" data-testid="about-divider"></div> <div class="divider" data-testid="about-divider"></div>
<p data-testid="about-user"> <p data-testid="about-user">
{% if user %} {% if user %}
Signed in as <strong>{{ user.username }}</strong>. {{ _('about.signed_in', current_locale).format(username=user.username) }}
{% else %} {% else %}
You are browsing as a guest. {{ _('about.browsing_guest', current_locale) }}
{% endif %} {% endif %}
</p> </p>
</div> </div>
@@ -30,7 +30,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;"> <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
<path d="M10 12L6 8L10 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <path d="M10 12L6 8L10 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>
Back to Home {{ _('about.back_home', current_locale) }}
</a> </a>
</div> </div>
</div> </div>

View File

@@ -1,29 +1,29 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Blog - Home{% endblock %} {% block title %}{{ _('home.title', current_locale) }}{% endblock %}
{% block meta_description %}Discover stories, thinking, and expertise from writers on any topic. A modern blog built with FastAPI.{% endblock %} {% block meta_description %}{{ _('home.meta_description', current_locale) }}{% endblock %}
{% block meta_keywords %}blog, articles, posts, writing, fastapi, python{% endblock %} {% block meta_keywords %}{{ _('home.meta_keywords', current_locale) }}{% endblock %}
{% block og_type %}website{% endblock %} {% block og_type %}website{% endblock %}
{% block og_title %}Blog - Home{% endblock %} {% block og_title %}{{ _('home.title', current_locale) }}{% endblock %}
{% block og_description %}Discover stories, thinking, and expertise from writers on any topic.{% endblock %} {% block og_description %}{{ _('home.meta_description', current_locale) }}{% endblock %}
{% block twitter_title %}Blog - Home{% endblock %} {% block twitter_title %}{{ _('home.title', current_locale) }}{% endblock %}
{% block twitter_description %}Discover stories, thinking, and expertise from writers on any topic.{% endblock %} {% block twitter_description %}{{ _('home.meta_description', current_locale) }}{% endblock %}
{% block content %} {% block content %}
<section class="page-header" data-testid="page-header-home"> <section class="page-header" data-testid="page-header-home">
<div class="page-header-flex"> <div class="page-header-flex">
<div data-testid="page-header-content"> <div data-testid="page-header-content">
<h1 class="page-title" data-testid="page-title-home">Latest Posts</h1> <h1 class="page-title" data-testid="page-title-home">{{ _('home.page_title', current_locale) }}</h1>
<p class="page-subtitle" data-testid="page-subtitle-home">Discover stories, thinking, and expertise from writers on any topic.</p> <p class="page-subtitle" data-testid="page-subtitle-home">{{ _('home.page_subtitle', current_locale) }}</p>
</div> </div>
{% if can_create %} {% if can_create %}
<a href="/web/posts/new" class="btn btn-primary" data-testid="btn-create-post-header"> <a href="/web/posts/new" class="btn btn-primary" data-testid="btn-create-post-header">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;"> <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
<path d="M8 2v12M2 8h12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <path d="M8 2v12M2 8h12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>
Write a Post {{ _('home.write_post', current_locale) }}
</a> </a>
{% endif %} {% endif %}
</div> </div>
@@ -38,9 +38,9 @@
<a href="/web/posts/{{ post.slug }}" data-testid="post-title-link-{{ post.id }}">{{ post.title }}</a> <a href="/web/posts/{{ post.slug }}" data-testid="post-title-link-{{ post.id }}">{{ post.title }}</a>
</h2> </h2>
{% if post.published %} {% if post.published %}
<span class="badge badge-success" data-testid="post-status-{{ post.id }}">Published</span> <span class="badge badge-success" data-testid="post-status-{{ post.id }}">{{ _('home.status_published', current_locale) }}</span>
{% else %} {% else %}
<span class="badge" data-testid="post-status-{{ post.id }}">Draft</span> <span class="badge" data-testid="post-status-{{ post.id }}">{{ _('home.status_draft', current_locale) }}</span>
{% endif %} {% endif %}
</div> </div>
@@ -65,7 +65,7 @@
{% endfor %} {% endfor %}
</div> </div>
<a href="/web/posts/{{ post.slug }}" class="btn btn-sm" data-testid="btn-read-more-{{ post.id }}"> <a href="/web/posts/{{ post.slug }}" class="btn btn-sm" data-testid="btn-read-more-{{ post.id }}">
Read more {{ _('home.read_more', current_locale) }}
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-left: 0.25rem;"> <svg width="14" height="14" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-left: 0.25rem;">
<path d="M6 12L10 8L6 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <path d="M6 12L10 8L6 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>
@@ -77,26 +77,26 @@
<nav class="pagination" data-testid="pagination" aria-label="Pagination"> <nav class="pagination" data-testid="pagination" aria-label="Pagination">
{% if has_prev %} {% if has_prev %}
<a href="{{ request.url.path }}?page={{ current_page - 1 }}" class="pagination-item" data-testid="pagination-prev">Previous</a> <a href="{{ request.url.path }}?page={{ current_page - 1 }}" class="pagination-item" data-testid="pagination-prev">{{ _('home.pagination_previous', current_locale) }}</a>
{% else %} {% else %}
<span class="pagination-item disabled" data-testid="pagination-prev">Previous</span> <span class="pagination-item disabled" data-testid="pagination-prev">{{ _('home.pagination_previous', current_locale) }}</span>
{% endif %} {% endif %}
<span class="pagination-item active" data-testid="pagination-current">{{ current_page }}</span> <span class="pagination-item active" data-testid="pagination-current">{{ current_page }}</span>
{% if has_next %} {% if has_next %}
<a href="{{ request.url.path }}?page={{ current_page + 1 }}" class="pagination-item" data-testid="pagination-next">Next</a> <a href="{{ request.url.path }}?page={{ current_page + 1 }}" class="pagination-item" data-testid="pagination-next">{{ _('home.pagination_next', current_locale) }}</a>
{% else %} {% else %}
<span class="pagination-item disabled" data-testid="pagination-next">Next</span> <span class="pagination-item disabled" data-testid="pagination-next">{{ _('home.pagination_next', current_locale) }}</span>
{% endif %} {% endif %}
</nav> </nav>
{% else %} {% else %}
<div class="empty-state" data-testid="empty-state"> <div class="empty-state" data-testid="empty-state">
<div class="empty-state-icon" data-testid="empty-state-icon">📝</div> <div class="empty-state-icon" data-testid="empty-state-icon">📝</div>
<h3 data-testid="empty-state-title">No posts yet</h3> <h3 data-testid="empty-state-title">{{ _('home.empty_title', current_locale) }}</h3>
<p data-testid="empty-state-description">Be the first to write a post!</p> <p data-testid="empty-state-description">{{ _('home.empty_description', current_locale) }}</p>
<a href="/web/posts/new" class="btn btn-primary" data-testid="btn-create-first-post">Create your first post</a> <a href="/web/posts/new" class="btn btn-primary" data-testid="btn-create-first-post">{{ _('home.empty_action', current_locale) }}</a>
</div> </div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@@ -29,9 +29,9 @@
{{ post.created_at.strftime('%B %d, %Y') }} {{ post.created_at.strftime('%B %d, %Y') }}
</span> </span>
{% if post.published %} {% if post.published %}
<span class="badge badge-success" data-testid="post-detail-status">Published</span> <span class="badge badge-success" data-testid="post-detail-status">{{ _('post.status_published', current_locale) }}</span>
{% else %} {% else %}
<span class="badge" data-testid="post-detail-status">Draft</span> <span class="badge" data-testid="post-detail-status">{{ _('post.status_draft', current_locale) }}</span>
{% endif %} {% endif %}
</div> </div>
</header> </header>
@@ -54,26 +54,26 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;"> <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
<path d="M10 12L6 8L10 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <path d="M10 12L6 8L10 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>
Back to posts {{ _('post.back_to_posts', current_locale) }}
</a> </a>
{% if can_edit or can_delete %} {% if can_edit or can_delete %}
<div class="flex gap-2" data-testid="post-detail-edit-actions"> <div class="flex gap-2" data-testid="post-detail-edit-actions">
{% if can_edit %} {% if can_edit %}
<a href="/web/posts/{{ post.slug }}/edit" class="btn" data-testid="btn-edit-post"> <a href="/web/posts/{{ post.slug }}/edit" class="btn" data-testid="btn-edit-post">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;"> <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
<path d="M11 2L14 5M2 14L3 10L12 1L15 4L6 13L2 14Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <path d="M11 2L14 5M2 14L3 10L12 1L15 4L6 13L2 14Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>
Edit {{ _('post.edit', current_locale) }}
</a> </a>
{% endif %} {% endif %}
{% if can_delete %} {% if can_delete %}
<form action="/web/posts/{{ post.slug }}/delete" method="POST" style="display: inline;" data-testid="form-delete-post"> <form action="/web/posts/{{ post.slug }}/delete" method="POST" style="display: inline;" data-testid="form-delete-post">
<button type="submit" class="btn btn-danger" data-testid="btn-delete-post" onclick="return confirm('Are you sure you want to delete this post?');"> <button type="submit" class="btn btn-danger" data-testid="btn-delete-post" onclick="return confirm('{{ _('post.delete_confirm', current_locale) }}');">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;"> <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
<path d="M2 4h12M6 4V2a2 2 0 012-2h0a2 2 0 012 2v2m3 0v10a2 2 0 01-2 2H5a2 2 0 01-2-2V4h9z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <path d="M2 4h12M6 4V2a2 2 0 012-2h0a2 2 0 012 2v2m3 0v10a2 2 0 01-2 2H5a2 2 0 01-2-2V4h9z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>
Delete {{ _('post.delete', current_locale) }}
</button> </button>
</form> </form>
{% endif %} {% endif %}

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{% if is_edit %}Edit Post{% else %}New Post{% endif %} - Blog{% endblock %} {% block title %}{% if is_edit %}{{ _('post_form.title_edit', current_locale) }}{% else %}{{ _('post_form.title_new', current_locale) }}{% endif %} - {{ _('base.default_title', current_locale) }}{% endblock %}
{% block extra_css %} {% block extra_css %}
<link rel="stylesheet" href="/static/css/easymde.min.css" data-testid="easymde-stylesheet"> <link rel="stylesheet" href="/static/css/easymde.min.css" data-testid="easymde-stylesheet">
@@ -9,7 +9,7 @@
{% block content %} {% block content %}
<section class="page-header" data-testid="page-header-form"> <section class="page-header" data-testid="page-header-form">
<h1 class="page-title" data-testid="page-title-form"> <h1 class="page-title" data-testid="page-title-form">
{% if is_edit %}Edit Post{% else %}Create New Post{% endif %} {% if is_edit %}{{ _('post_form.page_title_edit', current_locale) }}{% else %}{{ _('post_form.page_title_new', current_locale) }}{% endif %}
</h1> </h1>
</section> </section>
@@ -22,7 +22,7 @@
<div class="card-body" data-testid="form-post-body"> <div class="card-body" data-testid="form-post-body">
<div class="form-group" data-testid="form-group-title"> <div class="form-group" data-testid="form-group-title">
<label for="title" class="form-label form-label-required" data-testid="label-title"> <label for="title" class="form-label form-label-required" data-testid="label-title">
Title {{ _('post_form.label_title', current_locale) }}
</label> </label>
<input <input
type="text" type="text"
@@ -30,31 +30,31 @@
name="title" name="title"
class="input input-lg" class="input input-lg"
value="{% if post %}{{ post.title }}{% endif %}" value="{% if post %}{{ post.title }}{% endif %}"
placeholder="Enter post title" placeholder="{{ _('post_form.placeholder_title', current_locale) }}"
required required
data-testid="input-title" data-testid="input-title"
> >
<span class="form-hint" data-testid="hint-title">A catchy title for your post</span> <span class="form-hint" data-testid="hint-title">{{ _('post_form.hint_title', current_locale) }}</span>
</div> </div>
<div class="form-group" data-testid="form-group-content"> <div class="form-group" data-testid="form-group-content">
<label for="content" class="form-label form-label-required" data-testid="label-content"> <label for="content" class="form-label form-label-required" data-testid="label-content">
Content {{ _('post_form.label_content', current_locale) }}
</label> </label>
<textarea <textarea
id="content" id="content"
name="content" name="content"
rows="12" rows="12"
placeholder="Write your post content here..." placeholder="{{ _('post_form.placeholder_content', current_locale) }}"
required required
data-testid="textarea-content" data-testid="textarea-content"
>{% if post %}{{ post.content }}{% endif %}</textarea> >{% if post %}{{ post.content }}{% endif %}</textarea>
<span class="form-hint" data-testid="hint-content">The main content of your post. Markdown is supported.</span> <span class="form-hint" data-testid="hint-content">{{ _('post_form.hint_content', current_locale) }}</span>
</div> </div>
<div class="form-group" data-testid="form-group-tags"> <div class="form-group" data-testid="form-group-tags">
<label for="tags" class="form-label" data-testid="label-tags"> <label for="tags" class="form-label" data-testid="label-tags">
Tags {{ _('post_form.label_tags', current_locale) }}
</label> </label>
<input <input
type="text" type="text"
@@ -62,10 +62,10 @@
name="tags" name="tags"
class="input" class="input"
value="{% if post %}{{ post.tags|join(', ') }}{% endif %}" value="{% if post %}{{ post.tags|join(', ') }}{% endif %}"
placeholder="python, fastapi, tutorial" placeholder="{{ _('post_form.placeholder_tags', current_locale) }}"
data-testid="input-tags" data-testid="input-tags"
> >
<span class="form-hint" data-testid="hint-tags">Comma-separated list of tags</span> <span class="form-hint" data-testid="hint-tags">{{ _('post_form.hint_tags', current_locale) }}</span>
</div> </div>
</div> </div>
@@ -73,15 +73,15 @@
<div class="card-footer" data-testid="form-post-footer"> <div class="card-footer" data-testid="form-post-footer">
<div class="flex justify-between items-center" data-testid="form-actions"> <div class="flex justify-between items-center" data-testid="form-actions">
<a href="{% if is_edit %}/web/posts/{{ post.slug }}{% else %}/web/{% endif %}" class="btn" data-testid="btn-cancel"> <a href="{% if is_edit %}/web/posts/{{ post.slug }}{% else %}/web/{% endif %}" class="btn" data-testid="btn-cancel">
Cancel {{ _('post_form.cancel', current_locale) }}
</a> </a>
<div class="flex gap-2" data-testid="form-submit-actions"> <div class="flex gap-2" data-testid="form-submit-actions">
<button type="submit" name="action" value="draft" class="btn" data-testid="btn-save-draft"> <button type="submit" name="action" value="draft" class="btn" data-testid="btn-save-draft">
Save as Draft {{ _('post_form.save_draft', current_locale) }}
</button> </button>
<button type="submit" name="action" value="publish" class="btn btn-primary" data-testid="btn-publish-post"> <button type="submit" name="action" value="publish" class="btn btn-primary" data-testid="btn-publish-post">
{% if is_edit %}Update Post{% else %}Publish Post{% endif %} {% if is_edit %}{{ _('post_form.update_post', current_locale) }}{% else %}{{ _('post_form.publish_post', current_locale) }}{% endif %}
</button> </button>
</div> </div>
</div> </div>
@@ -99,7 +99,7 @@
spellChecker: false, spellChecker: false,
status: false, status: false,
minHeight: '300px', minHeight: '300px',
placeholder: 'Write your post content here...', placeholder: '{{ _('post_form.placeholder_content', current_locale) }}',
toolbar: [ toolbar: [
'bold', 'italic', 'heading', '|', 'bold', 'italic', 'heading', '|',
'code', 'quote', 'unordered-list', 'ordered-list', '|', 'code', 'quote', 'unordered-list', 'ordered-list', '|',

View File

@@ -4,7 +4,7 @@
{% block content %} {% block content %}
<div class="page-header" data-testid="page-header-profile"> <div class="page-header" data-testid="page-header-profile">
<h1 class="page-title" data-testid="page-title-profile">User Profile</h1> <h1 class="page-title" data-testid="page-title-profile">{{ _('profile.title', current_locale) }}</h1>
</div> </div>
<div class="card" data-testid="profile-card"> <div class="card" data-testid="profile-card">
@@ -25,18 +25,18 @@
<div class="profile-details" data-testid="profile-details"> <div class="profile-details" data-testid="profile-details">
<div class="profile-field" data-testid="profile-field-email"> <div class="profile-field" data-testid="profile-field-email">
<span class="profile-label" data-testid="profile-label-email">Email:</span> <span class="profile-label" data-testid="profile-label-email">{{ _('profile.email', current_locale) }}</span>
<span class="profile-value" data-testid="profile-value-email">{{ user.email or 'Not provided' }}</span> <span class="profile-value" data-testid="profile-value-email">{{ user.email or _('profile.not_provided', current_locale) }}</span>
</div> </div>
<div class="profile-field" data-testid="profile-field-userid"> <div class="profile-field" data-testid="profile-field-userid">
<span class="profile-label" data-testid="profile-label-userid">User ID:</span> <span class="profile-label" data-testid="profile-label-userid">{{ _('profile.user_id', current_locale) }}</span>
<span class="profile-value" data-testid="profile-value-userid">{{ user.user_id }}</span> <span class="profile-value" data-testid="profile-value-userid">{{ user.user_id }}</span>
</div> </div>
{% if user.first_name or user.last_name %} {% if user.first_name or user.last_name %}
<div class="profile-field" data-testid="profile-field-name"> <div class="profile-field" data-testid="profile-field-name">
<span class="profile-label" data-testid="profile-label-name">Name:</span> <span class="profile-label" data-testid="profile-label-name">{{ _('profile.name', current_locale) }}</span>
<span class="profile-value" data-testid="profile-value-name"> <span class="profile-value" data-testid="profile-value-name">
{{ user.first_name or '' }} {{ user.last_name or '' }} {{ user.first_name or '' }} {{ user.last_name or '' }}
</span> </span>
@@ -51,7 +51,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;"> <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
<path d="M10 12L6 8L10 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <path d="M10 12L6 8L10 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>
Back to Home {{ _('profile.back_home', current_locale) }}
</a> </a>
{% if can_create %} {% if can_create %}
@@ -59,7 +59,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;"> <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
<path d="M8 2v12M2 8h12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <path d="M8 2v12M2 8h12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>
New Post {{ _('profile.new_post', current_locale) }}
</a> </a>
{% endif %} {% endif %}
</div> </div>

View File

@@ -1,14 +1,14 @@
<footer class="site-footer" data-testid="site-footer"> <footer class="site-footer" data-testid="site-footer">
<div class="container" data-testid="footer-container"> <div class="container" data-testid="footer-container">
<div class="footer-copyright" data-testid="footer-copyright"> <div class="footer-copyright" data-testid="footer-copyright">
<span data-testid="copyright-text">© 2026 Blog. All rights reserved.</span> <span data-testid="copyright-text">{{ _('footer.copyright', current_locale) }}</span>
</div> </div>
<nav class="footer-links" data-testid="footer-nav" aria-label="Footer navigation"> <nav class="footer-links" data-testid="footer-nav" aria-label="Footer navigation">
<a href="/about" class="footer-link" data-testid="footer-link-about">About</a> <a href="/about" class="footer-link" data-testid="footer-link-about">{{ _('footer.about', current_locale) }}</a>
<a href="/privacy" class="footer-link" data-testid="footer-link-privacy">Privacy</a> <a href="/privacy" class="footer-link" data-testid="footer-link-privacy">{{ _('footer.privacy', current_locale) }}</a>
<a href="/terms" class="footer-link" data-testid="footer-link-terms">Terms</a> <a href="/terms" class="footer-link" data-testid="footer-link-terms">{{ _('footer.terms', current_locale) }}</a>
<a href="/api/docs" class="footer-link" data-testid="footer-link-api">API</a> <a href="/api/docs" class="footer-link" data-testid="footer-link-api">{{ _('footer.api', current_locale) }}</a>
</nav> </nav>
</div> </div>
</footer> </footer>

View File

@@ -5,7 +5,7 @@
<rect width="32" height="32" rx="6" fill="var(--color-primary)"/> <rect width="32" height="32" rx="6" fill="var(--color-primary)"/>
<path d="M8 12h16M8 16h12M8 20h8" stroke="white" stroke-width="2" stroke-linecap="round"/> <path d="M8 12h16M8 16h12M8 20h8" stroke="white" stroke-width="2" stroke-linecap="round"/>
</svg> </svg>
<span data-testid="logo-text">Blog</span> <span data-testid="logo-text">{{ _('header.logo', current_locale) }}</span>
</a> </a>
{% include "partials/nav.html" %} {% include "partials/nav.html" %}
@@ -15,7 +15,7 @@
type="button" type="button"
class="mobile-menu-btn" class="mobile-menu-btn"
data-testid="mobile-menu-toggle" data-testid="mobile-menu-toggle"
aria-label="Toggle menu" aria-label="{{ _('header.toggle_menu', current_locale) }}"
aria-expanded="false" aria-expanded="false"
aria-controls="mobile-nav" aria-controls="mobile-nav"
> >
@@ -30,8 +30,8 @@
type="button" type="button"
class="theme-toggle" class="theme-toggle"
data-testid="theme-toggle" data-testid="theme-toggle"
aria-label="Toggle dark mode" aria-label="{{ _('header.toggle_theme', current_locale) }}"
title="Toggle dark mode" title="{{ _('header.toggle_theme', current_locale) }}"
> >
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" data-testid="theme-light-icon" style="display: none;"> <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" data-testid="theme-light-icon" style="display: none;">
<path d="M10 2v2M10 16v2M4.22 4.22l1.42 1.42M14.36 14.36l1.42 1.42M2 10h2M16 10h2M4.22 15.78l1.42-1.42M14.36 5.64l1.42-1.42" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <path d="M10 2v2M10 16v2M4.22 4.22l1.42 1.42M14.36 14.36l1.42 1.42M2 10h2M16 10h2M4.22 15.78l1.42-1.42M14.36 5.64l1.42-1.42" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
@@ -41,7 +41,27 @@
<path d="M17.293 13.293A8 8 0 116.707 2.707a8.003 8.003 0 0010.586 10.586z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <path d="M17.293 13.293A8 8 0 116.707 2.707a8.003 8.003 0 0010.586 10.586z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>
</button> </button>
<div class="lang-switcher" data-testid="lang-switcher">
<button
type="button"
class="lang-switcher-toggle"
data-testid="lang-switcher-toggle"
aria-haspopup="true"
aria-expanded="false"
title="{{ _('header.lang_switcher', current_locale) }}"
>
<span data-testid="current-lang-code">{{ current_locale|upper }}</span>
</button>
<div class="lang-switcher-dropdown" data-testid="lang-switcher-dropdown">
{% for code in ('en', 'ru', 'fr', 'de') %}
<a href="/web/lang/{{ code }}" class="lang-switcher-item {% if code == current_locale %}active{% endif %}" data-testid="lang-option-{{ code }}">
{{ _('lang.' + code, current_locale) }}
</a>
{% endfor %}
</div>
</div>
{% if user %} {% if user %}
<div class="user-menu" data-testid="user-menu"> <div class="user-menu" data-testid="user-menu">
<button <button
@@ -63,14 +83,14 @@
<circle cx="8" cy="6" r="3" stroke="currentColor" stroke-width="2"/> <circle cx="8" cy="6" r="3" stroke="currentColor" stroke-width="2"/>
<path d="M2 14c0-3 3-5 6-5s6 2 6 5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> <path d="M2 14c0-3 3-5 6-5s6 2 6 5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg> </svg>
Profile {{ _('header.profile', current_locale) }}
</a> </a>
{% if can_create %} {% if can_create %}
<a href="/web/posts/new" class="user-menu-item" data-testid="user-menu-new-post"> <a href="/web/posts/new" class="user-menu-item" data-testid="user-menu-new-post">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;"> <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
<path d="M8 2v12M2 8h12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <path d="M8 2v12M2 8h12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>
New Post {{ _('header.new_post', current_locale) }}
</a> </a>
{% endif %} {% endif %}
<div class="user-menu-divider" data-testid="user-menu-divider"></div> <div class="user-menu-divider" data-testid="user-menu-divider"></div>
@@ -78,13 +98,13 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;"> <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
<path d="M10 12h2a2 2 0 002-2V6a2 2 0 00-2-2h-2M6 12l-3-3m0 0l3-3m-3 3h8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <path d="M10 12h2a2 2 0 002-2V6a2 2 0 00-2-2h-2M6 12l-3-3m0 0l3-3m-3 3h8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>
Sign Out {{ _('header.sign_out', current_locale) }}
</a> </a>
</div> </div>
</div> </div>
{% else %} {% else %}
<a href="/auth/login" class="btn btn-primary btn-sm" data-testid="btn-login"> <a href="/auth/login" class="btn btn-primary btn-sm" data-testid="btn-login">
Sign In {{ _('header.sign_in', current_locale) }}
</a> </a>
{% endif %} {% endif %}
</div> </div>
@@ -241,6 +261,84 @@
} }
} }
.lang-switcher {
position: relative;
}
.lang-switcher-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
padding: 0;
background: transparent;
border: 1px solid var(--color-border);
border-radius: 6px;
color: var(--color-text);
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.lang-switcher-toggle:hover {
background-color: var(--color-hover);
border-color: var(--color-secondary-dark-2);
}
.lang-switcher-dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: 0.5rem;
min-width: 140px;
background-color: var(--color-box-body);
border: 1px solid var(--color-border);
border-radius: 8px;
box-shadow: 0 4px 12px var(--color-shadow);
opacity: 0;
visibility: hidden;
transform: translateY(-10px);
transition: all 0.2s ease;
z-index: 1000;
}
.lang-switcher:hover .lang-switcher-dropdown,
.lang-switcher-toggle:focus + .lang-switcher-dropdown,
.lang-switcher-dropdown:hover {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.lang-switcher-item {
display: block;
padding: 0.6rem 1rem;
color: var(--color-text);
text-decoration: none;
font-size: 0.875rem;
transition: background-color 0.2s ease;
}
.lang-switcher-item:first-child {
border-radius: 8px 8px 0 0;
}
.lang-switcher-item:last-child {
border-radius: 0 0 8px 8px;
}
.lang-switcher-item:hover {
background-color: var(--color-hover);
text-decoration: none;
}
.lang-switcher-item.active {
font-weight: 600;
color: var(--color-primary);
}
@media (min-width: 769px) { @media (min-width: 769px) {
.mobile-menu-btn { .mobile-menu-btn {
display: none; display: none;
@@ -255,13 +353,13 @@
<!-- Mobile Navigation Menu --> <!-- Mobile Navigation Menu -->
<nav class="mobile-nav" id="mobile-nav" data-testid="mobile-nav" aria-label="Mobile navigation"> <nav class="mobile-nav" id="mobile-nav" data-testid="mobile-nav" aria-label="Mobile navigation">
<a href="/web/" class="nav-link {% if active_page == 'home' %}active{% endif %}" data-testid="mobile-nav-link-home"> <a href="/web/" class="nav-link {% if active_page == 'home' %}active{% endif %}" data-testid="mobile-nav-link-home">
Home {{ _('nav.home', current_locale) }}
</a> </a>
<a href="/web/posts" class="nav-link {% if active_page == 'posts' %}active{% endif %}" data-testid="mobile-nav-link-posts"> <a href="/web/posts" class="nav-link {% if active_page == 'posts' %}active{% endif %}" data-testid="mobile-nav-link-posts">
Posts {{ _('nav.posts', current_locale) }}
</a> </a>
<a href="/web/about" class="nav-link {% if active_page == 'about' %}active{% endif %}" data-testid="mobile-nav-link-about"> <a href="/web/about" class="nav-link {% if active_page == 'about' %}active{% endif %}" data-testid="mobile-nav-link-about">
About {{ _('nav.about', current_locale) }}
</a> </a>
</nav> </nav>

View File

@@ -1,11 +1,11 @@
<nav class="main-nav" data-testid="main-nav" aria-label="Main navigation"> <nav class="main-nav" data-testid="main-nav" aria-label="Main navigation">
<a href="/web/" class="nav-link {% if active_page == 'home' %}active{% endif %}" data-testid="nav-link-home"> <a href="/web/" class="nav-link {% if active_page == 'home' %}active{% endif %}" data-testid="nav-link-home">
Home {{ _('nav.home', current_locale) }}
</a> </a>
<a href="/web/posts" class="nav-link {% if active_page == 'posts' %}active{% endif %}" data-testid="nav-link-posts"> <a href="/web/posts" class="nav-link {% if active_page == 'posts' %}active{% endif %}" data-testid="nav-link-posts">
Posts {{ _('nav.posts', current_locale) }}
</a> </a>
<a href="/web/about" class="nav-link {% if active_page == 'about' %}active{% endif %}" data-testid="nav-link-about"> <a href="/web/about" class="nav-link {% if active_page == 'about' %}active{% endif %}" data-testid="nav-link-about">
About {{ _('nav.about', current_locale) }}
</a> </a>
</nav> </nav>

View File

@@ -10,9 +10,11 @@ from fastapi import HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from app.infrastructure.i18n.translator import DEFAULT_LOCALE, _
from app.presentation.web.flash import FlashManager, get_flash_messages from app.presentation.web.flash import FlashManager, get_flash_messages
templates = Jinja2Templates(directory="app/presentation/templates") templates = Jinja2Templates(directory="app/presentation/templates")
templates.env.globals["_"] = _
async def setup_flash_manager(request: Request) -> None: async def setup_flash_manager(request: Request) -> None:
@@ -55,6 +57,7 @@ def get_template_context(request: Request) -> dict[str, Any]:
"user_role": user_role.value if user_role else None, "user_role": user_role.value if user_role else None,
"can_create": can_create_post(user), "can_create": can_create_post(user),
"flash_messages": get_flash_messages(request), "flash_messages": get_flash_messages(request),
"current_locale": getattr(request.state, "locale", DEFAULT_LOCALE),
} }

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.domain.roles import Role, get_effective_role
from app.infrastructure.auth import TokenInfo 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 ( from app.presentation.web.deps import (
OptionalUserDep, OptionalUserDep,
RequireUserDep, RequireUserDep,
@@ -47,6 +49,22 @@ router = APIRouter(prefix="/web", tags=["web"], route_class=DishkaRoute)
templates = Jinja2Templates(directory="app/presentation/templates") 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") _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) 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. """Get base template context with user info and permissions.
Args: Args:
user: Current user or None for guest. user: Current user or None for guest.
current_locale: Active locale code for i18n.
Returns: 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) user_role = _get_user_role(user)
@@ -100,6 +121,7 @@ def _get_base_context(user: TokenInfo | None) -> dict[str, Any]:
"user": user, "user": user,
"user_role": user_role.value if user_role else None, "user_role": user_role.value if user_role else None,
"can_create": can_create_post(user), "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 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( return templates.TemplateResponse(
request, request,
"pages/index.html", "pages/index.html",
@@ -212,7 +235,8 @@ async def list_posts(
list_use_case, user, _DEFAULT_PAGE_SIZE, offset 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( return templates.TemplateResponse(
request, request,
"pages/index.html", "pages/index.html",
@@ -241,7 +265,8 @@ async def new_post_form(
Returns: Returns:
HTMLResponse with rendered post form template. 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( return templates.TemplateResponse(
request, request,
@@ -291,11 +316,12 @@ async def create_post(
result = await create_use_case.execute(dto) result = await create_use_case.execute(dto)
user_role = _get_user_role(user) user_role = _get_user_role(user)
locale = getattr(request.state, "locale", DEFAULT_LOCALE)
if action == "publish": if action == "publish":
await publish_use_case.publish(result.id, user.user_id, user_role) 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: 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) return RedirectResponse(url=f"/web/posts/{result.slug}", status_code=303)
except AlreadyExistsException as exc: 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): if not post.published and not can_see_draft(user, post.author_id):
raise HTTPException(status_code=404, detail="Post not found") 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( return templates.TemplateResponse(
request, request,
@@ -379,7 +406,8 @@ async def edit_post_form(
if not can_edit_post(user, post.author_id): if not can_edit_post(user, post.author_id):
raise HTTPException(status_code=403, detail="Not authorized to edit this post") 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( return templates.TemplateResponse(
request, request,
@@ -447,7 +475,8 @@ async def update_post(
if result.published: if result.published:
await publish_use_case.unpublish(result.id, user.user_id, user_role) 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) return RedirectResponse(url=f"/web/posts/{result.slug}", status_code=303)
except (AlreadyExistsException, ValidationException) as exc: except (AlreadyExistsException, ValidationException) as exc:
flash(request, str(exc), "error") flash(request, str(exc), "error")
@@ -485,9 +514,11 @@ async def delete_post(
try: try:
user_role = _get_user_role(user) user_role = _get_user_role(user)
await delete_use_case.execute(post.id, user.user_id, user_role) 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: 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) return RedirectResponse(url="/web/", status_code=303)
@@ -506,7 +537,8 @@ async def profile(
Returns: Returns:
HTMLResponse with rendered profile template. 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( return templates.TemplateResponse(
request, request,
@@ -532,7 +564,8 @@ async def about(
Returns: Returns:
HTMLResponse with rendered about page template. 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( return templates.TemplateResponse(
request, request,
@@ -542,3 +575,37 @@ async def about(
"active_page": "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

View File

@@ -314,6 +314,113 @@ supports the domain and application layers.
- **Expected:** Calls `session.rollback` once - **Expected:** Calls `session.rollback` once
- **Last Verified:** 2026-05-07 - **Last Verified:** 2026-05-07
### i18n Localization
#### Translation Service
##### TC-UNIT-811: TranslationService — Existing key returns translation
- **Type:** Positive
- **Layer:** Unit
- **File:** `unit/infrastructure/test_i18n.py::TestTranslationService::test_get_text_returns_translation_for_existing_key`
- **Expected:** Returns correct localized string when key exists in requested locale
- **Last Verified:** 2026-05-10
##### TC-UNIT-812: TranslationService — Fallback chain
- **Type:** Positive
- **Layer:** Unit
- **File:** `unit/infrastructure/test_i18n.py::TestTranslationService::{test_get_text_returns_english_fallback_for_missing_key, test_get_text_returns_key_when_neither_locale_nor_en_has_it, test_get_text_returns_english_fallback_for_unknown_locale}`
- **Preconditions:** Key missing in requested locale
- **Steps:** Call get_text with partially-available keys
- **Expected:**
- Falls back to English when key exists in `en` but not in requested locale
- Falls back to raw key when neither requested locale nor `en` has it
- Falls back to English when locale is completely unknown
- **Last Verified:** 2026-05-10
##### TC-UNIT-813: TranslationService — English locale
- **Type:** Positive
- **Layer:** Unit
- **File:** `unit/infrastructure/test_i18n.py::TestTranslationService::test_get_text_returns_en_when_requested_locale_is_en`
- **Expected:** Returns English string when locale is explicitly `en`
- **Last Verified:** 2026-05-10
##### TC-UNIT-814: TranslationService — Singleton pattern
- **Type:** Positive
- **Layer:** Unit
- **File:** `unit/infrastructure/test_i18n.py::TestTranslationService::{test_singleton_returns_same_instance, test_singleton_shares_translations}`
- **Expected:** Multiple calls to `get_instance()` return the same object with shared data
- **Last Verified:** 2026-05-10
#### Convenience Function
##### TC-UNIT-815: Convenience _() function
- **Type:** Positive
- **Layer:** Unit
- **File:** `unit/infrastructure/test_i18n.py::TestConvenienceFunction::{test_convenience_function_returns_translation, test_convenience_function_defaults_to_english, test_convenience_function_returns_key_on_missing}`
- **Expected:**
- Returns translation when key and locale are given
- Defaults to `DEFAULT_LOCALE` ("en") when locale omitted
- Returns raw key when no translation exists
- **Last Verified:** 2026-05-10
#### Locale Detection
##### TC-UNIT-816: Parse Accept-Language — Empty and single locale
- **Type:** Positive
- **Layer:** Unit
- **File:** `unit/infrastructure/test_i18n.py::TestParseAcceptLanguage::{test_empty_header_returns_empty_list, test_single_locale}`
- **Expected:**
- Empty header returns empty list
- Single locale returns single-element list
- **Last Verified:** 2026-05-10
##### TC-UNIT-817: Parse Accept-Language — Multi-locale with quality
- **Type:** Positive
- **Layer:** Unit
- **File:** `unit/infrastructure/test_i18n.py::TestParseAcceptLanguage::test_multiple_locales_with_quality`
- **Expected:** Locales sorted by descending q-value, quality values stripped
- **Last Verified:** 2026-05-10
##### TC-UNIT-818: Parse Accept-Language — Region codes and complex input
- **Type:** Positive
- **Layer:** Unit
- **File:** `unit/infrastructure/test_i18n.py::TestParseAcceptLanguage::{test_region_code_strips_to_base_language, test_complex_header, test_whitespace_around_locales}`
- **Expected:**
- `fr-FR` normalised to `fr`
- Realistic headers with region codes parsed correctly
- Whitespace around locale codes handled gracefully
- **Last Verified:** 2026-05-10
##### TC-UNIT-819: Get best locale — Cookie priority
- **Type:** Positive
- **Layer:** Unit
- **File:** `unit/infrastructure/test_i18n.py::TestGetBestLocale::{test_cookie_takes_priority, test_invalid_cookie_falls_back_to_header}`
- **Expected:**
- Cookie value used when it is a supported locale
- Unsupported locale in cookie falls back to Accept-Language header
- **Last Verified:** 2026-05-10
##### TC-UNIT-820: Get best locale — Header fallback
- **Type:** Positive
- **Layer:** Unit
- **File:** `unit/infrastructure/test_i18n.py::TestGetBestLocale::{test_no_cookie_uses_accept_language, test_no_cookie_no_header_returns_default, test_accept_language_unsupported_returns_default, test_missing_cookie_key_uses_header}`
- **Expected:**
- Accept-Language used when no cookie present
- Default locale returned when neither cookie nor header matches
- Unsupported language in header falls back to default
- Absent `locale` cookie key treated as no preference
- **Last Verified:** 2026-05-10
##### TC-UNIT-821: Setup locale manager — Middleware helper
- **Type:** Positive
- **Layer:** Unit
- **File:** `unit/infrastructure/test_i18n.py::TestSetupLocaleManager::{test_sets_locale_on_request_state, test_does_not_override_existing_locale, test_default_locale_when_no_match}`
- **Expected:**
- Sets `request.state.locale` from best-match locale
- Does not override an already-set locale
- Falls back to default when nothing matches
- **Last Verified:** 2026-05-10
## Coverage Summary ## Coverage Summary
| Component | Cases | Status | | Component | Cases | Status |
@@ -322,6 +429,7 @@ supports the domain and application layers.
| Settings & Config | 19 | ✅ Defaults, overrides, validation, env checks | | Settings & Config | 19 | ✅ Defaults, overrides, validation, env checks |
| Keycloak Auth Client | 16 | ✅ Token introspection, userinfo, caching, errors | | Keycloak Auth Client | 16 | ✅ Token introspection, userinfo, caching, errors |
| Transaction Manager | 2 | ⚠️ Only commit/rollback; missing nested tx, error handling | | Transaction Manager | 2 | ⚠️ Only commit/rollback; missing nested tx, error handling |
| i18n Localization | 11 | ✅ Translation service, locale detection, middleware helper |
## Gaps (Not Yet Covered) ## Gaps (Not Yet Covered)

View File

@@ -21,6 +21,7 @@ adding new tests.
| Pagination | 40% | — | — | 60% | P1 | ⚠️ Partial | | Pagination | 40% | — | — | 60% | P1 | ⚠️ Partial |
| Post Edit via Web | — | — | — | 40% | P1 | ⚠️ Partial | | Post Edit via Web | — | — | — | 40% | P1 | ⚠️ Partial |
| Post Delete via Web | — | — | — | 40% | P1 | ⚠️ Partial | | Post Delete via Web | — | — | — | 40% | P1 | ⚠️ Partial |
| i18n Localization | 100% | — | — | — | P1 | ✅ Active |
Legend: ✅ Covered / ⚠️ Partial / ❌ Missing / — Not Applicable Legend: ✅ Covered / ⚠️ Partial / ❌ Missing / — Not Applicable
@@ -32,6 +33,7 @@ Legend: ✅ Covered / ⚠️ Partial / ❌ Missing / — Not Applicable
| RBAC & Access Control | [FEATURE_RBAC.md](FEATURE_RBAC.md) | | RBAC & Access Control | [FEATURE_RBAC.md](FEATURE_RBAC.md) |
| Domain Foundation | [FEATURE_DOMAIN_FOUNDATION.md](FEATURE_DOMAIN_FOUNDATION.md) | | Domain Foundation | [FEATURE_DOMAIN_FOUNDATION.md](FEATURE_DOMAIN_FOUNDATION.md) |
| Infrastructure & Bootstrap | [FEATURE_INFRASTRUCTURE.md](FEATURE_INFRASTRUCTURE.md) | | Infrastructure & Bootstrap | [FEATURE_INFRASTRUCTURE.md](FEATURE_INFRASTRUCTURE.md) |
| i18n Localization | [FEATURE_INFRASTRUCTURE.md](FEATURE_INFRASTRUCTURE.md) |
## Test Naming Convention ## Test Naming Convention

View File

@@ -0,0 +1,196 @@
"""Tests for i18n infrastructure.
Covers TranslationService, locale detection helpers, and the
convenience _() function.
"""
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from app.infrastructure.i18n.translator import (
DEFAULT_LOCALE,
TranslationService,
_,
)
from app.presentation.web.locale import (
_get_best_locale,
_parse_accept_language,
setup_locale_manager,
)
def _make_mock_request(
cookies: dict[str, str] | None = None,
headers: dict[str, str] | None = None,
) -> MagicMock:
"""Create a mock FastAPI Request with controlled state, cookies and headers."""
request = MagicMock()
request.state = SimpleNamespace()
request.cookies = cookies or {}
request.headers = headers or {}
return request
class TestTranslationService:
"""Test TranslationService get_text resolution and fallback chain."""
def test_get_text_returns_translation_for_existing_key(self) -> None:
"""Test get_text returns the correct translation for a known key."""
ts = TranslationService()
result = ts.get_text("nav.home", "ru")
assert result == "Главная"
def test_get_text_returns_english_fallback_for_missing_key(self) -> None:
"""Test get_text falls back to English when locale lacks the key."""
ts = TranslationService()
result = ts.get_text("nav.home", "fr")
assert result == "Accueil"
def test_get_text_returns_key_when_neither_locale_nor_en_has_it(self) -> None:
"""Test get_text returns the key itself when no translation exists anywhere."""
ts = TranslationService()
result = ts.get_text("nonexistent.key", "de")
assert result == "nonexistent.key"
def test_get_text_returns_english_fallback_for_unknown_locale(self) -> None:
"""Test get_text returns English when the requested locale does not exist."""
ts = TranslationService()
result = ts.get_text("nav.home", "zz")
assert result == "Home"
def test_get_text_returns_en_when_requested_locale_is_en(self) -> None:
"""Test get_text returns English string when locale is en."""
ts = TranslationService()
result = ts.get_text("nav.home", "en")
assert result == "Home"
def test_get_text_returns_en_fallback_when_requested_locale_missing_key(self) -> None:
"""Test get_text falls back to English when locale is unknown and key exists in en."""
ts = TranslationService()
result = ts.get_text("about.signed_in", "zz")
assert result == "Signed in as {username}."
def test_singleton_returns_same_instance(self) -> None:
"""Test TranslationService.get_instance always returns the same instance."""
instance_a = TranslationService.get_instance()
instance_b = TranslationService.get_instance()
assert instance_a is instance_b
def test_singleton_shares_translations(self) -> None:
"""Test that multiple calls via singleton share the same data."""
instance_a = TranslationService.get_instance()
instance_b = TranslationService.get_instance()
assert instance_a.translations is instance_b.translations
class TestConvenienceFunction:
"""Test the module-level _() convenience function."""
def test_convenience_function_returns_translation(self) -> None:
"""Test _() returns correct translation for a given key."""
result = _("header.logo", "de")
assert result == "Blog"
def test_convenience_function_defaults_to_english(self) -> None:
"""Test _() uses DEFAULT_LOCALE when no locale is given."""
result = _("nav.posts")
assert result == "Posts"
assert DEFAULT_LOCALE == "en"
def test_convenience_function_returns_key_on_missing(self) -> None:
"""Test _() returns key when translation does not exist."""
result = _("utterly.fake.key", "fr")
assert result == "utterly.fake.key"
class TestParseAcceptLanguage:
"""Test the Accept-Language header parser."""
def test_empty_header_returns_empty_list(self) -> None:
"""Test empty Accept-Language returns empty list."""
assert _parse_accept_language("") == []
def test_single_locale(self) -> None:
"""Test a single locale code is returned as a single-element list."""
assert _parse_accept_language("fr") == ["fr"]
def test_multiple_locales_with_quality(self) -> None:
"""Test multiple locales with q-values are returned in order."""
result = _parse_accept_language("fr, en;q=0.9, de;q=0.8")
assert result == ["fr", "en", "de"]
def test_region_code_strips_to_base_language(self) -> None:
"""Test fr-FR is normalised to fr."""
assert _parse_accept_language("fr-FR") == ["fr"]
def test_complex_header(self) -> None:
"""Test a realistic Accept-Language header with multiple locales."""
result = _parse_accept_language("ru-RU, ru;q=0.9, en;q=0.5")
assert result == ["ru", "ru", "en"]
def test_whitespace_around_locales(self) -> None:
"""Test whitespace around locale codes is handled gracefully."""
result = _parse_accept_language(" en , fr;q=0.5 ")
assert result == ["en", "fr"]
class TestGetBestLocale:
"""Test locale detection from cookie and Accept-Language header."""
def test_cookie_takes_priority(self) -> None:
"""Test cookie value is used when it is a supported locale."""
request = _make_mock_request(cookies={"locale": "de"}, headers={"accept-language": "ru"})
assert _get_best_locale(request) == "de"
def test_invalid_cookie_falls_back_to_header(self) -> None:
"""Test unsupported locale in cookie falls back to Accept-Language."""
request = _make_mock_request(cookies={"locale": "zz"}, headers={"accept-language": "fr"})
assert _get_best_locale(request) == "fr"
def test_no_cookie_uses_accept_language(self) -> None:
"""Test Accept-Language is used when no cookie is present."""
request = _make_mock_request(headers={"accept-language": "de"})
assert _get_best_locale(request) == "de"
def test_no_cookie_no_header_returns_default(self) -> None:
"""Test default locale returned when neither cookie nor header matches."""
request = _make_mock_request()
assert _get_best_locale(request) == DEFAULT_LOCALE
def test_accept_language_unsupported_returns_default(self) -> None:
"""Test unsupported language in header falls back to default."""
request = _make_mock_request(headers={"accept-language": "zh"})
assert _get_best_locale(request) == DEFAULT_LOCALE
def test_missing_cookie_key_uses_header(self) -> None:
"""Test absent locale cookie is treated as no preference."""
request = _make_mock_request(cookies={"other": "val"}, headers={"accept-language": "ru"})
assert _get_best_locale(request) == "ru"
class TestSetupLocaleManager:
"""Test the middleware helper that sets locale on request state."""
@pytest.mark.asyncio
async def test_sets_locale_on_request_state(self) -> None:
"""Test setup_locale_manager sets locale when not already present."""
request = _make_mock_request(headers={"accept-language": "fr"})
await setup_locale_manager(request)
assert request.state.locale == "fr"
@pytest.mark.asyncio
async def test_does_not_override_existing_locale(self) -> None:
"""Test setup_locale_manager does not override an already-set locale."""
request = _make_mock_request(headers={"accept-language": "fr"})
request.state.locale = "de"
await setup_locale_manager(request)
assert request.state.locale == "de"
@pytest.mark.asyncio
async def test_default_locale_when_no_match(self) -> None:
"""Test setup_locale_manager uses default when nothing matches."""
request = _make_mock_request()
await setup_locale_manager(request)
assert request.state.locale == DEFAULT_LOCALE