# app/modules/dev_tools/routes/api/admin_translations.py """ Translation Editor API endpoints. Browse, search, and edit UI translation JSON files across all modules. All endpoints require super-admin authentication. """ import json import logging import re from pathlib import Path from typing import Literal from fastapi import APIRouter, Depends, Query from fastapi.responses import JSONResponse from pydantic import BaseModel, Field from app.api.deps import UserContext, get_current_super_admin_api from app.utils.i18n import ( SUPPORTED_LANGUAGES, clear_translation_cache, get_locales_path, get_module_locale_dirs, ) logger = logging.getLogger(__name__) router = APIRouter(prefix="/translations", tags=["translations"]) # Variable placeholder pattern: {name} VARIABLE_PATTERN = re.compile(r"\{(\w+)\}") # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _flatten_json(data: dict, prefix: str = "") -> dict[str, str]: """Flatten nested dict to dot-notation paths.""" result = {} for key, value in data.items(): path = f"{prefix}.{key}" if prefix else key if isinstance(value, dict): result.update(_flatten_json(value, path)) else: result[path] = value return result def _set_nested_value(data: dict, path: str, value: str) -> None: """Set a value in a nested dict using dot-notation path.""" keys = path.split(".") current = data for key in keys[:-1]: if key not in current or not isinstance(current[key], dict): current[key] = {} current = current[key] current[keys[-1]] = value def _remove_nested_value(data: dict, path: str) -> None: """Remove a value from a nested dict using dot-notation path.""" keys = path.split(".") current = data for key in keys[:-1]: if key not in current or not isinstance(current[key], dict): return current = current[key] current.pop(keys[-1], None) def _load_json(file_path: Path) -> dict: """Load JSON file, return empty dict if missing.""" if not file_path.exists(): return {} try: with open(file_path, encoding="utf-8") as f: return json.load(f) except (json.JSONDecodeError, OSError) as e: logger.error(f"Error loading {file_path}: {e}") return {} def _save_json(file_path: Path, data: dict) -> None: """Write dict to JSON file with pretty formatting.""" file_path.parent.mkdir(parents=True, exist_ok=True) with open(file_path, "w", encoding="utf-8") as f: json.dump(data, f, indent=4, ensure_ascii=False) f.write("\n") def _get_module_file_path(module: str, language: str) -> Path | None: """Get the JSON file path for a module + language, or None if not found.""" if module == "shared": return get_locales_path() / f"{language}.json" for mod_code, locales_dir in get_module_locale_dirs(): if mod_code == module: return locales_dir / f"{language}.json" return None def _extract_variables(text: str) -> list[str]: """Extract {variable} placeholders from a string.""" return VARIABLE_PATTERN.findall(text) def _load_all_keys() -> tuple[list[dict], list[dict], dict]: """ Load all translation keys across all modules and languages. Returns: (modules_list, keys_list, stats_dict) """ modules_info = [] all_keys: dict[tuple[str, str], dict[str, str | None]] = {} # Global translations (static/locales/) shared_keys_per_lang: dict[str, dict[str, str]] = {} for lang in SUPPORTED_LANGUAGES: file_path = get_locales_path() / f"{lang}.json" data = _load_json(file_path) flat = _flatten_json(data) shared_keys_per_lang[lang] = flat for path in flat: key_id = ("shared", path) if key_id not in all_keys: all_keys[key_id] = {la: None for la in SUPPORTED_LANGUAGES} all_keys[key_id][lang] = flat[path] shared_all_paths = set() for flat in shared_keys_per_lang.values(): shared_all_paths.update(flat.keys()) modules_info.append({ "code": "shared", "label": "Shared", "key_count": len(shared_all_paths), }) # Module translations for mod_code, locales_dir in get_module_locale_dirs(): mod_keys_per_lang: dict[str, dict[str, str]] = {} for lang in SUPPORTED_LANGUAGES: file_path = locales_dir / f"{lang}.json" data = _load_json(file_path) flat = _flatten_json(data) mod_keys_per_lang[lang] = flat for path in flat: key_id = (mod_code, path) if key_id not in all_keys: all_keys[key_id] = {la: None for la in SUPPORTED_LANGUAGES} all_keys[key_id][lang] = flat[path] mod_all_paths = set() for flat in mod_keys_per_lang.values(): mod_all_paths.update(flat.keys()) if mod_all_paths: modules_info.append({ "code": mod_code, "label": mod_code.replace("_", " ").replace("-", " ").title(), "key_count": len(mod_all_paths), }) # Build keys list and stats keys_list = [] missing_counts = {lang: 0 for lang in SUPPORTED_LANGUAGES} for (module, path), values in sorted(all_keys.items()): # Detect variables from any non-null value variables = [] has_variables = False for val in values.values(): if val and isinstance(val, str): found = _extract_variables(val) if found: has_variables = True for v in found: if v not in variables: variables.append(v) entry = { "module": module, "path": path, "values": values, "has_variables": has_variables, } if has_variables: entry["variables"] = variables keys_list.append(entry) for lang in SUPPORTED_LANGUAGES: if values[lang] is None: missing_counts[lang] += 1 stats = { "total_keys": len(keys_list), "missing": missing_counts, } return modules_info, keys_list, stats # --------------------------------------------------------------------------- # Schemas # --------------------------------------------------------------------------- class UpdateKeyRequest(BaseModel): module: str = Field(..., min_length=1) path: str = Field(..., min_length=1) language: Literal["en", "fr", "de", "lb"] value: str = Field(...) # --------------------------------------------------------------------------- # Endpoints # --------------------------------------------------------------------------- @router.get("/keys") async def get_translation_keys( ctx: UserContext = Depends(get_current_super_admin_api), ): """Load all translation keys across all modules and languages.""" modules, keys, stats = _load_all_keys() return { "modules": modules, "languages": SUPPORTED_LANGUAGES, "keys": keys, "stats": stats, } @router.put("/key") async def update_translation_key( body: UpdateKeyRequest, ctx: UserContext = Depends(get_current_super_admin_api), ): """Update a single translation value.""" file_path = _get_module_file_path(body.module, body.language) if file_path is None: return JSONResponse( status_code=404, content={"detail": f"Module '{body.module}' not found"}, ) data = _load_json(file_path) if body.value == "": # Empty string means delete the key _remove_nested_value(data, body.path) else: _set_nested_value(data, body.path, body.value) _save_json(file_path, data) clear_translation_cache() # Return updated entry values = {} for lang in SUPPORTED_LANGUAGES: lang_path = _get_module_file_path(body.module, lang) if lang_path: lang_data = _load_json(lang_path) flat = _flatten_json(lang_data) values[lang] = flat.get(body.path) else: values[lang] = None variables = [] has_variables = False for val in values.values(): if val and isinstance(val, str): found = _extract_variables(val) if found: has_variables = True for v in found: if v not in variables: variables.append(v) entry = { "module": body.module, "path": body.path, "values": values, "has_variables": has_variables, } if has_variables: entry["variables"] = variables return entry @router.get("/missing") async def get_missing_translations( module: str | None = Query(None), ctx: UserContext = Depends(get_current_super_admin_api), ): """Return only keys with missing translations in at least one language.""" _, keys, stats = _load_all_keys() missing_keys = [ k for k in keys if any(v is None for v in k["values"].values()) ] if module: missing_keys = [k for k in missing_keys if k["module"] == module] return { "keys": missing_keys, "stats": stats, }