gemification_admin bekötve
This commit is contained in:
@@ -1,53 +1,332 @@
|
||||
# /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
|
||||
|
||||
class LocaleManager:
|
||||
_locales = {}
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def get(self, key: str, lang: str = "hu", **kwargs) -> str:
|
||||
if not self._locales:
|
||||
self._load()
|
||||
|
||||
data = self._locales.get(lang, self._locales.get("hu", {}))
|
||||
# Biztonságos bejárás a pontokkal elválasztott kulcsokhoz
|
||||
for k in key.split("."):
|
||||
if isinstance(data, dict):
|
||||
data = data.get(k, {})
|
||||
else:
|
||||
return key # Ha elakadunk, adjuk vissza magát a kulcsot
|
||||
|
||||
if isinstance(data, str):
|
||||
return data.format(**kwargs)
|
||||
return key
|
||||
# ── 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/
|
||||
]
|
||||
|
||||
def _load(self):
|
||||
# A konténeren belül ez a biztos útvonal
|
||||
possible_paths = [
|
||||
"/app/app/locales",
|
||||
"app/locales",
|
||||
"backend/app/locales"
|
||||
]
|
||||
|
||||
path = ""
|
||||
for p in possible_paths:
|
||||
if os.path.exists(p):
|
||||
path = p
|
||||
break
|
||||
|
||||
if not path:
|
||||
print("FIGYELEM: Nem található a locales könyvtár!")
|
||||
return
|
||||
# ── In-memory cache ────────────────────────────────────────────────────
|
||||
_locale_cache: Dict[str, Dict[str, Any]] = {}
|
||||
_cache_loaded: bool = False
|
||||
|
||||
for file in os.listdir(path):
|
||||
if file.endswith(".json"):
|
||||
lang = file.split(".")[0]
|
||||
try:
|
||||
with open(os.path.join(path, file), "r", encoding="utf-8") as f:
|
||||
self._locales[lang] = json.load(f)
|
||||
except Exception as e:
|
||||
print(f"Hiba a {file} betöltésekor: {e}")
|
||||
|
||||
locale_manager = LocaleManager()
|
||||
# Rövid alias a könnyebb használathoz
|
||||
t = locale_manager.get
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user