Files
service-finder/backend/app/core/i18n.py
2026-06-30 08:56:55 +00:00

333 lines
11 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# /opt/docker/dev/service_finder/backend/app/core/i18n.py
"""
Backend i18n helper module.
Loads locale JSON files from backend/static/locales/ into a cached dictionary,
provides dot-notation key resolution with variable interpolation,
and integrates with the request-scoped context locale system.
Usage:
from app.core.i18n import t, get_locale
# With explicit language
msg = t("AUTH.LOGIN.SUCCESS", lang="en")
# With variable interpolation
msg = t("EMAIL.REG.GREETING", lang="hu", first_name="Péter")
# Using request context locale (set by I18nMiddleware)
msg = t("COMMON.SAVE_SUCCESS")
# Get full locale dictionary
locale_data = get_locale("en")
"""
import json
import os
import logging
from typing import Dict, Any, Optional
logger = logging.getLogger(__name__)
# ── Locale file directory ──────────────────────────────────────────────
# In the Docker container, the static directory is mounted at:
# /app/backend/static/locales/
# We search multiple possible paths to cover both container and local dev.
_LOCALE_PATHS = [
"/app/backend/static/locales", # Docker container (sf_api)
"/opt/docker/dev/service_finder/backend/static/locales", # Host / dev
"backend/static/locales", # Relative from project root
"static/locales", # Relative from backend/
]
# ── In-memory cache ────────────────────────────────────────────────────
_locale_cache: Dict[str, Dict[str, Any]] = {}
_cache_loaded: bool = False
def _discover_locale_path() -> Optional[str]:
"""Find the first existing locale directory."""
for path in _LOCALE_PATHS:
if os.path.isdir(path):
logger.debug("i18n locale directory found: %s", path)
return path
logger.warning("i18n: No locale directory found among %s", _LOCALE_PATHS)
return None
def _load_locales(force: bool = False) -> None:
"""
Load all JSON locale files from the discovered locale directory
into the in-memory cache.
Args:
force: If True, reload even if cache is already populated.
"""
global _cache_loaded
if _cache_loaded and not force:
return
path = _discover_locale_path()
if not path:
_locale_cache.clear()
_cache_loaded = True
return
loaded: Dict[str, Dict[str, Any]] = {}
for filename in os.listdir(path):
if not filename.endswith(".json"):
continue
lang = filename[:-5] # strip ".json"
filepath = os.path.join(path, filename)
try:
with open(filepath, "r", encoding="utf-8") as f:
loaded[lang] = json.load(f)
logger.debug("i18n: Loaded locale '%s' from %s (%d keys)", lang, filepath, _count_keys(loaded[lang]))
except Exception as exc:
logger.error("i18n: Failed to load locale file %s: %s", filepath, exc)
_locale_cache.clear()
_locale_cache.update(loaded)
_cache_loaded = True
logger.info("i18n: %d locale(s) loaded into cache: %s", len(loaded), list(loaded.keys()))
def _count_keys(data: Any) -> int:
"""Count leaf string values in a nested dict structure."""
if isinstance(data, dict):
return sum(_count_keys(v) for v in data.values())
return 1 if isinstance(data, str) else 0
def _interpolate(text: str, variables: Dict[str, Any]) -> str:
"""
Replace ``{{var_name}}`` placeholders with actual values.
The JSON locale files use the ``{{variable}}`` syntax (Jinja2/Mustache style),
which is different from Python's ``str.format()`` that uses ``{variable}``.
"""
if not variables:
return text
for var_name, var_value in variables.items():
text = text.replace("{{" + var_name + "}}", str(var_value))
return text
def _resolve_dot_key(data: Dict[str, Any], key: str) -> Any:
"""
Resolve a dot-notation key against a nested dictionary.
Example:
data = {"AUTH": {"LOGIN": {"SUCCESS": "Hello"}}}
_resolve_dot_key(data, "AUTH.LOGIN.SUCCESS") # -> "Hello"
"""
current: Any = data
for part in key.split("."):
if isinstance(current, dict) and part in current:
current = current[part]
else:
return None
return current
def get_locale(lang: str) -> Dict[str, Any]:
"""
Return the full locale dictionary for the given language code.
Falls back to an empty dict if the language is not loaded.
Args:
lang: Language code, e.g. "en", "hu".
Returns:
The nested dictionary of translations for that locale.
"""
_load_locales()
return _locale_cache.get(lang, {})
def t(key: str, lang: Optional[str] = None, **kwargs: Any) -> str:
"""
Translate a dot-notation key to the localized string.
Resolution order:
1. Explicit ``lang`` parameter.
2. Request-scoped locale from ``context.get_current_locale()``
(set by I18nMiddleware from Accept-Language header or ?lang=).
3. Default locale ``"hu"``.
If the key is not found in any locale, the key itself is returned
as a fallback.
Variable interpolation:
Placeholders in the translation string like ``{{first_name}}``
are replaced with the corresponding keyword argument.
Examples:
>>> t("AUTH.LOGIN.SUCCESS", lang="en")
"Login successful. Welcome back!"
>>> t("EMAIL.REG.GREETING", lang="hu", first_name="Péter")
"Szia Péter!"
>>> t("NONEXISTENT.KEY")
"NONEXISTENT.KEY"
"""
_load_locales()
# 1. Determine language
if lang is None:
try:
from app.core.context import get_current_locale
lang = get_current_locale()
except (ImportError, Exception):
lang = "hu"
# 2. Try requested language
locale_data = _locale_cache.get(lang)
if locale_data is not None:
result = _resolve_dot_key(locale_data, key)
if isinstance(result, str):
return _interpolate(result, kwargs) if kwargs else result
# 3. Fallback to English
if lang != "en":
en_data = _locale_cache.get("en")
if en_data is not None:
result = _resolve_dot_key(en_data, key)
if isinstance(result, str):
return _interpolate(result, kwargs) if kwargs else result
# 4. Fallback to any available locale
for fallback_lang, fallback_data in _locale_cache.items():
if fallback_lang == lang or fallback_lang == "en":
continue
result = _resolve_dot_key(fallback_data, key)
if isinstance(result, str):
return _interpolate(result, kwargs) if kwargs else result
# 5. Key not found return the key itself
logger.debug("i18n: Key '%s' not found for lang='%s'", key, lang)
return key
def reload_locales() -> None:
"""
Force-reload all locale files into the cache.
Useful after a translation update without restarting the server.
"""
global _cache_loaded
_cache_loaded = False
_load_locales(force=True)
logger.info("i18n: Locales reloaded successfully")
# ── Quiz data loader ───────────────────────────────────────────────────
_QUIZ_PATHS = [
"/app/backend/static/quiz", # Docker container (sf_api)
"/opt/docker/dev/service_finder/backend/static/quiz", # Host / dev
"backend/static/quiz", # Relative from project root
"static/quiz", # Relative from backend/
]
_quiz_cache: Dict[str, Dict[str, Any]] = {}
_quiz_cache_loaded: bool = False
def _discover_quiz_path() -> Optional[str]:
"""Find the first existing quiz directory."""
for path in _QUIZ_PATHS:
if os.path.isdir(path):
logger.debug("Quiz directory found: %s", path)
return path
logger.warning("Quiz: No quiz directory found among %s", _QUIZ_PATHS)
return None
def _load_quiz_data(force: bool = False) -> None:
"""
Load all quiz JSON files from the discovered quiz directory
into the in-memory cache.
"""
global _quiz_cache_loaded
if _quiz_cache_loaded and not force:
return
path = _discover_quiz_path()
if not path:
_quiz_cache.clear()
_quiz_cache_loaded = True
return
loaded: Dict[str, Dict[str, Any]] = {}
for filename in os.listdir(path):
if not filename.endswith(".json"):
continue
lang = filename[:-5] # strip ".json"
filepath = os.path.join(path, filename)
try:
with open(filepath, "r", encoding="utf-8") as f:
loaded[lang] = json.load(f)
logger.debug("Quiz: Loaded '%s' from %s", lang, filepath)
except Exception as exc:
logger.error("Quiz: Failed to load file %s: %s", filepath, exc)
_quiz_cache.clear()
_quiz_cache.update(loaded)
_quiz_cache_loaded = True
logger.info("Quiz: %d language(s) loaded into cache: %s", len(loaded), list(loaded.keys()))
def get_quiz_data(lang: Optional[str] = None) -> Dict[str, Any]:
"""
Return the quiz questions for the given language code.
Resolution order:
1. Explicit ``lang`` parameter.
2. Request-scoped locale from ``context.get_current_locale()``.
3. Default locale ``"hu"``.
Falls back to English if the requested language is not available,
then to any available language, then to an empty dict.
Returns:
A dict with a ``questions`` key containing the list of quiz questions.
"""
_load_quiz_data()
# 1. Determine language
if lang is None:
try:
from app.core.context import get_current_locale
lang = get_current_locale()
except (ImportError, Exception):
lang = "hu"
# 2. Try requested language
if lang in _quiz_cache:
return _quiz_cache[lang]
# 3. Fallback to English
if lang != "en" and "en" in _quiz_cache:
return _quiz_cache["en"]
# 4. Fallback to any available
for fallback_lang, fallback_data in _quiz_cache.items():
return fallback_data
# 5. Nothing loaded
logger.warning("Quiz: No quiz data available for lang='%s'", lang)
return {"questions": []}
def reload_quiz_data() -> None:
"""Force-reload all quiz JSON files into the cache."""
global _quiz_cache_loaded
_quiz_cache_loaded = False
_load_quiz_data(force=True)
logger.info("Quiz: Quiz data reloaded successfully")
# ── Preload on import ──────────────────────────────────────────────────
_load_locales()
_load_quiz_data()