gemification_admin bekötve

This commit is contained in:
Roo
2026-06-30 08:56:55 +00:00
parent df4a0f07ff
commit cbfb955580
313 changed files with 18490 additions and 2895 deletions

View File

@@ -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()