# /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()