350 lines
11 KiB
Python
Executable File
350 lines
11 KiB
Python
Executable File
# /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")
|
||
|
||
|
||
# ── LocaleManager wrapper for email_manager compatibility ──────────────
|
||
class _LocaleManager:
|
||
"""
|
||
Wrapper providing a ``.get()`` interface around the ``t()`` function.
|
||
|
||
The ``email_manager`` module imports ``locale_manager`` and calls
|
||
``locale_manager.get(key, lang=lang, **variables)``. This class
|
||
delegates to the existing ``t()`` function so that no import breaks.
|
||
"""
|
||
|
||
@staticmethod
|
||
def get(key: str, lang: Optional[str] = None, **kwargs: Any) -> str:
|
||
return t(key, lang=lang, **kwargs)
|
||
|
||
|
||
locale_manager = _LocaleManager()
|
||
|
||
# ── Preload on import ──────────────────────────────────────────────────
|
||
_load_locales()
|
||
_load_quiz_data()
|