1071 lines
41 KiB
Python
1071 lines
41 KiB
Python
# Copyright (c) 2025 Patrick Motsch
|
||
# All rights reserved.
|
||
"""
|
||
Public and authenticated routes for UI language sets (DB-backed i18n).
|
||
|
||
Architecture:
|
||
- xx = base set (meta): key = source plaintext (German or English, as written
|
||
in the code via ``t("...")``), value = UI context for AI
|
||
- All languages (incl. de) are AI-generated translations from xx
|
||
- AI translation pipeline uses context from xx to disambiguate translations;
|
||
the prompt forces the output language to be exactly the requested target.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
import math
|
||
import re
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Optional, Set
|
||
|
||
from fastapi import APIRouter, BackgroundTasks, Depends, File, HTTPException, Request, UploadFile, status
|
||
from fastapi.responses import Response
|
||
from pydantic import BaseModel, Field
|
||
|
||
from modules.auth import getCurrentUser, requireSysAdmin, requirePlatformAdmin
|
||
from modules.connectors.connectorDbPostgre import _get_cached_connector
|
||
from modules.datamodels.datamodelAi import (
|
||
AiCallOptions,
|
||
AiCallRequest,
|
||
AiCallResponse,
|
||
OperationTypeEnum,
|
||
PriorityEnum,
|
||
)
|
||
from modules.datamodels.datamodelUiLanguage import I18nEntry, UiLanguageSet
|
||
from modules.datamodels.datamodelUam import User
|
||
from modules.datamodels.datamodelRbac import Role
|
||
from modules.datamodels.datamodelFeatures import Feature
|
||
from modules.datamodels.datamodelNotification import NotificationType
|
||
from modules.interfaces.interfaceDbManagement import getInterface as getMgmtInterface
|
||
from modules.routes.routeNotifications import _createNotification
|
||
from modules.shared.configuration import APP_CONFIG
|
||
from modules.shared.i18nRegistry import _loadCache as _reloadI18nCache, apiRouteContext
|
||
from modules.shared.timeUtils import getUtcTimestamp
|
||
|
||
routeApiMsg = apiRouteContext("routeI18n")
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter(
|
||
prefix="/api/i18n",
|
||
tags=["i18n"],
|
||
responses={404: {"description": "Not found"}},
|
||
)
|
||
|
||
_MIN_AI_BILLING_ESTIMATE_CHF = 0.01
|
||
_TRANSLATE_BATCH_SIZE = 80
|
||
_TRANSLATE_BATCH_PAUSE_S = 2.0
|
||
_TRANSLATE_RATE_LIMIT_MAX_RETRIES = 3
|
||
|
||
_PROTECTED_CODES = frozenset({"xx"})
|
||
|
||
# In-memory set of language codes currently being updated (sync / create).
|
||
_UPDATING_CODES: Set[str] = set()
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# ISO 639-1 label map (used when creating a language without explicit label)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_ISO_LABELS: Dict[str, str] = {
|
||
"de": "Deutsch", "gsw": "Schweizerdeutsch", "en": "English", "fr": "Français", "it": "Italiano",
|
||
"es": "Español", "pt": "Português", "nl": "Nederlands", "pl": "Polski",
|
||
"cs": "Čeština", "sk": "Slovenčina", "sv": "Svenska", "no": "Norsk",
|
||
"da": "Dansk", "fi": "Suomi", "hu": "Magyar", "ro": "Română",
|
||
"bg": "Български", "hr": "Hrvatski", "sl": "Slovenščina", "et": "Eesti",
|
||
"lv": "Latviešu", "lt": "Lietuvių", "el": "Ελληνικά", "tr": "Türkçe",
|
||
"ru": "Русский", "uk": "Українська", "ar": "العربية", "he": "עברית",
|
||
"zh": "中文", "ja": "日本語", "ko": "한국어", "hi": "हिन्दी",
|
||
"th": "ไทย", "vi": "Tiếng Việt", "id": "Bahasa Indonesia", "ms": "Bahasa Melayu",
|
||
"tl": "Filipino", "sw": "Kiswahili", "af": "Afrikaans", "sq": "Shqip",
|
||
"am": "አማርኛ", "hy": "Հայերեն", "az": "Azərbaycan", "eu": "Euskara",
|
||
"be": "Беларуская", "bn": "বাংলা", "bs": "Bosanski", "ca": "Català",
|
||
"cy": "Cymraeg", "eo": "Esperanto", "fa": "فارسی", "ga": "Gaeilge",
|
||
"gl": "Galego", "gu": "ગુજરાતી", "ha": "Hausa", "is": "Íslenska",
|
||
"jv": "Basa Jawa", "ka": "ქართული", "kk": "Қазақ", "km": "ខ្មែរ",
|
||
"kn": "ಕನ್ನಡ", "ku": "Kurdî", "ky": "Кыргызча", "la": "Latina",
|
||
"lb": "Lëtzebuergesch", "lo": "ລາວ", "mk": "Македонски", "ml": "മലയാളം",
|
||
"mn": "Монгол", "mr": "मराठी", "mt": "Malti", "my": "မြန်မာ",
|
||
"ne": "नेपाली", "or": "ଓଡ଼ିଆ", "pa": "ਪੰਜਾਬੀ", "ps": "پښتو",
|
||
"si": "සිංහල", "so": "Soomaali", "sr": "Српски", "su": "Basa Sunda",
|
||
"ta": "தமிழ்", "te": "తెలుగు", "tg": "Тоҷикӣ", "tk": "Türkmen",
|
||
"ur": "اردو", "uz": "Oʻzbek", "yo": "Yorùbá", "zu": "isiZulu",
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# DB helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _publicMgmtDb():
|
||
return _get_cached_connector(
|
||
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
|
||
dbDatabase="poweron_management",
|
||
dbUser=APP_CONFIG.get("DB_USER"),
|
||
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET"),
|
||
dbPort=int(APP_CONFIG.get("DB_PORT", 5432)),
|
||
userId="__i18n_public__",
|
||
)
|
||
|
||
|
||
def _rowEntries(row: dict) -> List[dict]:
|
||
"""Read entries from a DB row, supporting both new (entries) and legacy (keys) format."""
|
||
entries = row.get("entries")
|
||
if isinstance(entries, list) and entries:
|
||
return entries
|
||
keys = row.get("keys")
|
||
if isinstance(keys, dict) and keys:
|
||
return [{"context": "ui", "key": k, "value": v} for k, v in keys.items()]
|
||
return []
|
||
|
||
|
||
def _entriesToKeyValueMap(entries: List[dict]) -> Dict[str, str]:
|
||
"""Convert entries list to a flat key->value map (for frontend consumption)."""
|
||
return {e["key"]: e.get("value", "") for e in entries if e.get("key")}
|
||
|
||
|
||
def _row_to_public(row: dict) -> dict:
|
||
entries = _rowEntries(row)
|
||
return {
|
||
"code": row["id"],
|
||
"label": row.get("label"),
|
||
"status": row.get("status"),
|
||
"entries": entries,
|
||
}
|
||
|
||
|
||
def _loadMasterXxEntries(db) -> List[dict]:
|
||
"""Load the xx base set entries from DB."""
|
||
rows = db.getRecordset(UiLanguageSet, recordFilter={"id": "xx"})
|
||
if not rows:
|
||
return []
|
||
return _rowEntries(rows[0])
|
||
|
||
|
||
def _userMemberMandateIds(currentUser: User) -> List[str]:
|
||
from modules.interfaces.interfaceDbApp import getRootInterface
|
||
|
||
root = getRootInterface()
|
||
memberships = root.getUserMandates(str(currentUser.id))
|
||
out = []
|
||
for um in memberships:
|
||
mid = getattr(um, "mandateId", None) or (
|
||
um.get("mandateId") if isinstance(um, dict) else None
|
||
)
|
||
if mid:
|
||
out.append(str(mid))
|
||
return list(dict.fromkeys(out))
|
||
|
||
|
||
def _mandatePassesAiPoolBilling(currentUser: User, mandateId: str, userId: str) -> bool:
|
||
from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface
|
||
|
||
bi = getBillingInterface(currentUser, mandateId)
|
||
res = bi.checkBalance(mandateId, userId, _MIN_AI_BILLING_ESTIMATE_CHF)
|
||
return bool(res.allowed)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# AI Translation helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_aiObjectsSingleton = None
|
||
|
||
|
||
async def _getAiObjects():
|
||
global _aiObjectsSingleton
|
||
if _aiObjectsSingleton is None:
|
||
from modules.interfaces.interfaceAiObjects import AiObjects
|
||
_aiObjectsSingleton = await AiObjects.create()
|
||
return _aiObjectsSingleton
|
||
|
||
|
||
def _makeBillingCallback(currentUser: User, mandateId: str):
|
||
from modules.serviceCenter.services.serviceBilling.mainServiceBilling import getService as getBillingService
|
||
|
||
billingService = getBillingService(currentUser, mandateId)
|
||
|
||
def _cb(response: AiCallResponse) -> None:
|
||
if not response or getattr(response, "errorCount", 0) > 0:
|
||
return
|
||
basePriceCHF = getattr(response, "priceCHF", 0.0)
|
||
if not basePriceCHF or basePriceCHF <= 0:
|
||
return
|
||
provider = getattr(response, "provider", None) or "unknown"
|
||
modelName = getattr(response, "modelName", None) or "unknown"
|
||
try:
|
||
billingService.recordUsage(
|
||
priceCHF=basePriceCHF,
|
||
aicoreProvider=provider,
|
||
aicoreModel=modelName,
|
||
description=f"i18n translation ({modelName})",
|
||
processingTime=getattr(response, "processingTime", None),
|
||
bytesSent=getattr(response, "bytesSent", None),
|
||
bytesReceived=getattr(response, "bytesReceived", None),
|
||
)
|
||
except Exception as e:
|
||
logger.error("i18n billing callback failed: %s", e)
|
||
|
||
return _cb
|
||
|
||
|
||
async def _translateBatch(
|
||
keysToTranslate: Dict[str, str],
|
||
targetLanguageLabel: str,
|
||
targetCode: str,
|
||
billingCallback=None,
|
||
) -> Dict[str, str]:
|
||
"""Translate German keys into targetLanguageLabel.
|
||
|
||
keysToTranslate: { germanKey: uiContext }
|
||
Returns: { germanKey: translatedValue }
|
||
"""
|
||
if not keysToTranslate:
|
||
return {}
|
||
|
||
aiObjects = await _getAiObjects()
|
||
allKeys = list(keysToTranslate.items())
|
||
totalBatches = math.ceil(len(allKeys) / _TRANSLATE_BATCH_SIZE)
|
||
result: Dict[str, str] = {}
|
||
|
||
for batchIdx in range(totalBatches):
|
||
chunk = allKeys[batchIdx * _TRANSLATE_BATCH_SIZE : (batchIdx + 1) * _TRANSLATE_BATCH_SIZE]
|
||
payload = [{"key": k, "context": v} for k, v in chunk]
|
||
jsonPayload = json.dumps(payload, ensure_ascii=False)
|
||
|
||
systemPrompt = (
|
||
f"You are a professional translator for software UI texts. "
|
||
f"You receive a JSON array of objects: {{\"key\": \"source text\", \"context\": \"UI context\"}}. "
|
||
f"The source text is written in German OR English. "
|
||
f"The context describes where the text is used in the application (file, component). "
|
||
f"\n\n"
|
||
f"HARD REQUIREMENTS (must all be satisfied):\n"
|
||
f"1. OUTPUT LANGUAGE: every translated value MUST be written in {targetLanguageLabel} "
|
||
f"(ISO code \"{targetCode}\"). Never output in German or English if that is not "
|
||
f"the target language. No mixing of languages.\n"
|
||
f"2. If the source is already in the target language, keep it (do not re-translate, "
|
||
f"do not paraphrase).\n"
|
||
f"3. KEEP the exact JSON keys from the input — do NOT translate or modify the keys.\n"
|
||
f"4. KEEP placeholders like {{variable}}, {{count}}, %s, %(name)s exactly as they are.\n"
|
||
f"5. Preserve leading/trailing whitespace, punctuation and capitalisation pattern.\n"
|
||
f"6. Answer ONLY with a JSON object mapping source-key -> translated value in "
|
||
f"{targetLanguageLabel}. No markdown fences, no comments, no explanations.\n"
|
||
f"7. If a key cannot be translated (empty, pure symbols, URLs), return the source unchanged."
|
||
)
|
||
|
||
aiRequest = AiCallRequest(
|
||
prompt=(
|
||
f"Translate the following UI labels into {targetLanguageLabel} "
|
||
f"(ISO {targetCode}). Source may be German or English. "
|
||
f"Respond with a pure JSON object only.\n{jsonPayload}"
|
||
),
|
||
context=systemPrompt,
|
||
options=AiCallOptions(
|
||
operationType=OperationTypeEnum.DATA_GENERATE,
|
||
priority=PriorityEnum.BALANCED,
|
||
compressPrompt=False,
|
||
compressContext=False,
|
||
resultFormat="json",
|
||
temperature=0.2,
|
||
),
|
||
)
|
||
|
||
if billingCallback:
|
||
aiObjects.billingCallback = billingCallback
|
||
|
||
batchDone = False
|
||
for retryAttempt in range(_TRANSLATE_RATE_LIMIT_MAX_RETRIES):
|
||
try:
|
||
response = await aiObjects.callWithTextContext(aiRequest)
|
||
if response and response.content:
|
||
raw = response.content.strip()
|
||
if raw.startswith("```"):
|
||
raw = re.sub(r"^```[a-z]*\n?", "", raw)
|
||
raw = re.sub(r"\n?```$", "", raw)
|
||
parsed = json.loads(raw)
|
||
if isinstance(parsed, dict):
|
||
result.update(parsed)
|
||
else:
|
||
logger.warning("i18n AI batch %d/%d returned non-dict", batchIdx + 1, totalBatches)
|
||
else:
|
||
logger.warning("i18n AI batch %d/%d empty response", batchIdx + 1, totalBatches)
|
||
batchDone = True
|
||
break
|
||
except json.JSONDecodeError as je:
|
||
logger.error("i18n AI batch %d/%d JSON parse error: %s", batchIdx + 1, totalBatches, je)
|
||
batchDone = True
|
||
break
|
||
except Exception as e:
|
||
errStr = str(e)
|
||
if "rate_limit" in errStr.lower() or "429" in errStr or "Rate limit" in errStr:
|
||
waitSec = _TRANSLATE_BATCH_PAUSE_S * (2 ** retryAttempt)
|
||
logger.warning(
|
||
"i18n AI batch %d/%d rate-limited (attempt %d/%d), waiting %.1fs",
|
||
batchIdx + 1, totalBatches, retryAttempt + 1,
|
||
_TRANSLATE_RATE_LIMIT_MAX_RETRIES, waitSec,
|
||
)
|
||
await asyncio.sleep(waitSec)
|
||
continue
|
||
logger.error("i18n AI batch %d/%d failed: %s", batchIdx + 1, totalBatches, e)
|
||
batchDone = True
|
||
break
|
||
finally:
|
||
aiObjects.billingCallback = None
|
||
|
||
if not batchDone:
|
||
logger.error("i18n AI batch %d/%d exhausted rate-limit retries", batchIdx + 1, totalBatches)
|
||
|
||
if batchIdx < totalBatches - 1:
|
||
await asyncio.sleep(_TRANSLATE_BATCH_PAUSE_S)
|
||
|
||
_matchCapitalization(keysToTranslate, result)
|
||
return result
|
||
|
||
|
||
def _matchCapitalization(originals: Dict[str, str], translations: Dict[str, str]) -> None:
|
||
"""Ensure translations preserve the capitalisation pattern of the original key."""
|
||
for key, translated in translations.items():
|
||
if not key or not translated:
|
||
continue
|
||
if key[0].isupper() and translated[0].islower():
|
||
translations[key] = translated[0].upper() + translated[1:]
|
||
elif key[0].islower() and translated[0].isupper():
|
||
translations[key] = translated[0].lower() + translated[1:]
|
||
|
||
|
||
def _resolveMandateIdForAiI18n(request: Request, currentUser: User) -> str:
|
||
userId = str(currentUser.id)
|
||
memberIds = _userMemberMandateIds(currentUser)
|
||
if not memberIds:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail=routeApiMsg("Mindestens eine Mandats-Mitgliedschaft ist für die AI-Nutzung erforderlich."),
|
||
)
|
||
|
||
headerRaw = (
|
||
request.headers.get("X-Mandate-Id") or request.headers.get("x-mandate-id") or ""
|
||
).strip()
|
||
if headerRaw:
|
||
if headerRaw not in memberIds:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_403_FORBIDDEN,
|
||
detail=routeApiMsg("X-Mandate-Id ist kein Mandat Ihrer Mitgliedschaft."),
|
||
)
|
||
if _mandatePassesAiPoolBilling(currentUser, headerRaw, userId):
|
||
return headerRaw
|
||
for mid in memberIds:
|
||
if _mandatePassesAiPoolBilling(currentUser, mid, userId):
|
||
return mid
|
||
raise HTTPException(
|
||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||
detail=routeApiMsg("Nicht genügend AI-Guthaben (Mandats-Pool) für diese Aktion."),
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# xx-Master sync from frontend codebase (local dev fallback)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||
_FRONTEND_SRC = _REPO_ROOT / "frontend_nyla" / "src"
|
||
|
||
_T_CALL_RE = re.compile(r"""\bt\(\s*'((?:\\.|[^'])+)'\s*(?:,|\))""")
|
||
|
||
|
||
def _scanCodebaseKeys() -> List[dict]:
|
||
"""Local dev fallback: scan frontend src for t() calls. Returns entries with context='ui'."""
|
||
keys: Set[str] = set()
|
||
if not _FRONTEND_SRC.is_dir():
|
||
logger.warning("i18n codebase scan: %s not found", _FRONTEND_SRC)
|
||
return []
|
||
|
||
for ext in ("*.tsx", "*.ts"):
|
||
for filepath in _FRONTEND_SRC.rglob(ext):
|
||
try:
|
||
content = filepath.read_text(encoding="utf-8", errors="replace")
|
||
except OSError:
|
||
continue
|
||
for m in _T_CALL_RE.finditer(content):
|
||
raw = m.group(1)
|
||
raw = raw.replace("\\'", "'")
|
||
if raw:
|
||
keys.add(raw)
|
||
return [{"context": "ui", "key": k, "value": ""} for k in sorted(keys)]
|
||
|
||
|
||
async def _readOptionalEntriesFromBody(request: Request) -> Optional[List[dict]]:
|
||
"""Read entries from request body. Accepts {entries: [{context, key, value}, ...]}."""
|
||
body = await request.body()
|
||
if not body or not body.strip():
|
||
return None
|
||
try:
|
||
data = json.loads(body.decode("utf-8"))
|
||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||
return None
|
||
if not isinstance(data, dict) or "entries" not in data:
|
||
return None
|
||
entries = data.get("entries")
|
||
if not isinstance(entries, list):
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail=routeApiMsg("Feld «entries» muss ein JSON-Array sein."),
|
||
)
|
||
result = []
|
||
for e in entries:
|
||
if not isinstance(e, dict) or not e.get("key"):
|
||
continue
|
||
result.append({
|
||
"context": str(e.get("context", "ui")),
|
||
"key": str(e["key"]),
|
||
"value": str(e.get("value", "")),
|
||
})
|
||
return result if result else None
|
||
|
||
|
||
def _syncXxMaster(db, userId: Optional[str], incomingEntries: List[dict]) -> Dict[str, Any]:
|
||
"""Synchronise the xx base set with incoming UI entries.
|
||
|
||
Only touches entries whose context is "ui". Gateway entries (api.*, table.*)
|
||
written by _syncRegistryToDb at boot are preserved untouched.
|
||
"""
|
||
if not incomingEntries:
|
||
logger.warning("i18n xx-sync: no entries — aborting")
|
||
return {"added": [], "removed": [], "entriesCount": 0, "error": "No entries to sync"}
|
||
|
||
rows = db.getRecordset(UiLanguageSet, recordFilter={"id": "xx"})
|
||
if not rows:
|
||
now = getUtcTimestamp()
|
||
rec = {
|
||
"id": "xx",
|
||
"label": "Basisset (Meta)",
|
||
"entries": incomingEntries,
|
||
"status": "complete",
|
||
"isDefault": True,
|
||
"sysCreatedAt": now,
|
||
"sysCreatedBy": userId,
|
||
"sysModifiedAt": now,
|
||
"sysModifiedBy": userId,
|
||
}
|
||
db.recordCreate(UiLanguageSet, rec)
|
||
allKeys = [e["key"] for e in incomingEntries]
|
||
logger.info("i18n xx-master created: %d entries", len(incomingEntries))
|
||
return {"added": allKeys, "removed": [], "entriesCount": len(incomingEntries)}
|
||
|
||
row = dict(rows[0])
|
||
curEntries = _rowEntries(row)
|
||
|
||
gatewayEntries = [e for e in curEntries if e.get("context", "ui") != "ui"]
|
||
curUiByKey = {e["key"]: e for e in curEntries if e.get("context", "ui") == "ui"}
|
||
incomingByKey = {e["key"]: e for e in incomingEntries}
|
||
|
||
incomingKeys = set(incomingByKey.keys())
|
||
dbUiKeys = set(curUiByKey.keys())
|
||
|
||
added = sorted(incomingKeys - dbUiKeys)
|
||
removed = sorted(dbUiKeys - incomingKeys)
|
||
|
||
newUiEntries = [
|
||
{"context": e["context"], "key": e["key"], "value": e["value"]}
|
||
for e in incomingEntries
|
||
]
|
||
|
||
if not added and not removed and all(
|
||
curUiByKey.get(e["key"], {}).get("value") == e["value"]
|
||
and curUiByKey.get(e["key"], {}).get("context") == e["context"]
|
||
for e in incomingEntries
|
||
):
|
||
total = len(newUiEntries) + len(gatewayEntries)
|
||
return {"added": [], "removed": [], "entriesCount": total}
|
||
|
||
mergedEntries = gatewayEntries + newUiEntries
|
||
|
||
now = getUtcTimestamp()
|
||
row["entries"] = mergedEntries
|
||
if "keys" in row:
|
||
del row["keys"]
|
||
row["sysModifiedAt"] = now
|
||
row["sysModifiedBy"] = userId
|
||
db.recordModify(UiLanguageSet, "xx", row)
|
||
|
||
logger.info(
|
||
"i18n xx-master sync: +%d added, -%d removed (ui=%d, gateway=%d, total=%d)",
|
||
len(added), len(removed), len(newUiEntries), len(gatewayEntries), len(mergedEntries),
|
||
)
|
||
return {"added": added, "removed": removed, "entriesCount": len(mergedEntries)}
|
||
|
||
|
||
# --- Public -----------------------------------------------------------------
|
||
|
||
|
||
@router.get("/codes")
|
||
async def list_language_codes():
|
||
db = _publicMgmtDb()
|
||
rows = db.getRecordset(UiLanguageSet)
|
||
out = []
|
||
for r in rows:
|
||
entries = _rowEntries(r)
|
||
uiCount = sum(1 for e in entries if e.get("context", "ui") == "ui")
|
||
gatewayCount = len(entries) - uiCount
|
||
code = r["id"]
|
||
out.append(
|
||
{
|
||
"code": code,
|
||
"label": r.get("label"),
|
||
"status": r.get("status"),
|
||
"isDefault": bool(r.get("isDefault")),
|
||
"entriesCount": len(entries),
|
||
"uiCount": uiCount,
|
||
"gatewayCount": gatewayCount,
|
||
"updating": code in _UPDATING_CODES,
|
||
}
|
||
)
|
||
return sorted(out, key=lambda x: (not x.get("isDefault"), x["code"]))
|
||
|
||
|
||
@router.get("/sets/{code}")
|
||
async def get_language_set(code: str):
|
||
db = _publicMgmtDb()
|
||
rows = db.getRecordset(UiLanguageSet, recordFilter={"id": code})
|
||
if not rows:
|
||
raise HTTPException(status_code=404, detail=routeApiMsg("Sprachset nicht gefunden"))
|
||
return _row_to_public(rows[0])
|
||
|
||
|
||
# --- Auth user --------------------------------------------------------------
|
||
|
||
|
||
class CreateLanguageBody(BaseModel):
|
||
code: str = Field(..., min_length=2, max_length=10)
|
||
label: Optional[str] = Field(default=None, max_length=80)
|
||
|
||
|
||
def _validate_iso2_code(code: str) -> str:
|
||
c = code.strip().lower()
|
||
if not re.fullmatch(r"[a-z]{2,3}", c):
|
||
raise HTTPException(
|
||
status_code=400, detail=routeApiMsg("Nur ISO-639 Sprachcodes (2–3 Buchstaben) erlaubt.")
|
||
)
|
||
return c
|
||
|
||
|
||
async def _translateTextMultilingualFields(db, langCode: str, langLabel: str, billingCb=None) -> int:
|
||
"""Batch-translate all TextMultilingual fields (Role.description, Feature.label) for a new language."""
|
||
textsToTranslate: Dict[str, str] = {}
|
||
|
||
roles = db.getRecordset(Role)
|
||
for r in roles:
|
||
desc = r.get("description") if isinstance(r, dict) else getattr(r, "description", None)
|
||
if isinstance(desc, dict):
|
||
sourceText = desc.get("xx", "")
|
||
if sourceText and not desc.get(langCode):
|
||
textsToTranslate[f"role:{r.get('id') if isinstance(r, dict) else r.id}:description"] = sourceText
|
||
|
||
features = db.getRecordset(Feature)
|
||
for f in features:
|
||
lbl = f.get("label") if isinstance(f, dict) else getattr(f, "label", None)
|
||
if isinstance(lbl, dict):
|
||
sourceText = lbl.get("xx", "")
|
||
if sourceText and not lbl.get(langCode):
|
||
textsToTranslate[f"feature:{f.get('code') if isinstance(f, dict) else f.code}:label"] = sourceText
|
||
|
||
if not textsToTranslate:
|
||
return 0
|
||
|
||
keysForAi = {v: "User-generated content field" for v in textsToTranslate.values()}
|
||
uniqueTexts = list(set(keysForAi.keys()))
|
||
keysForAi = {t: "User-generated content field" for t in uniqueTexts}
|
||
translated = await _translateBatch(keysForAi, langLabel, langCode, billingCallback=billingCb)
|
||
|
||
count = 0
|
||
for compositeKey, deText in textsToTranslate.items():
|
||
translatedText = translated.get(deText)
|
||
if not translatedText:
|
||
continue
|
||
parts = compositeKey.split(":")
|
||
if parts[0] == "role":
|
||
roleId = parts[1]
|
||
rows = db.getRecordset(Role, recordFilter={"id": roleId})
|
||
if rows:
|
||
rec = dict(rows[0]) if not isinstance(rows[0], dict) else rows[0]
|
||
desc = rec.get("description", {})
|
||
if isinstance(desc, dict):
|
||
desc[langCode] = translatedText
|
||
rec["description"] = desc
|
||
db.recordModify(Role, roleId, rec)
|
||
count += 1
|
||
elif parts[0] == "feature":
|
||
featureCode = parts[1]
|
||
rows = db.getRecordset(Feature, recordFilter={"code": featureCode})
|
||
if rows:
|
||
rec = dict(rows[0]) if not isinstance(rows[0], dict) else rows[0]
|
||
lbl = rec.get("label", {})
|
||
if isinstance(lbl, dict):
|
||
lbl[langCode] = translatedText
|
||
rec["label"] = lbl
|
||
db.recordModify(Feature, featureCode, rec)
|
||
count += 1
|
||
|
||
logger.info("TextMultilingual batch translate: %d fields translated to %s", count, langCode)
|
||
return count
|
||
|
||
|
||
def _run_create_language_job(userId: str, code: str, label: str, currentUser: User, mandateId: str) -> None:
|
||
loop = asyncio.new_event_loop()
|
||
try:
|
||
loop.run_until_complete(_run_create_language_job_async(userId, code, label, currentUser, mandateId))
|
||
finally:
|
||
loop.close()
|
||
|
||
|
||
async def _run_create_language_job_async(userId: str, code: str, label: str, currentUser: User, mandateId: str) -> None:
|
||
_UPDATING_CODES.add(code)
|
||
try:
|
||
db = _publicMgmtDb()
|
||
rows = db.getRecordset(UiLanguageSet, recordFilter={"id": code})
|
||
if not rows:
|
||
return
|
||
xxEntries = _loadMasterXxEntries(db)
|
||
if not xxEntries:
|
||
logger.error("i18n create job: no xx master entries found")
|
||
return
|
||
|
||
toTranslate = {e["key"]: e.get("value", "") for e in xxEntries}
|
||
billingCb = _makeBillingCallback(currentUser, mandateId)
|
||
translated = await _translateBatch(toTranslate, label, code, billingCallback=billingCb)
|
||
|
||
finalEntries = []
|
||
for e in xxEntries:
|
||
k = e["key"]
|
||
finalEntries.append({
|
||
"context": e["context"],
|
||
"key": k,
|
||
"value": translated.get(k, f"[{k}]"),
|
||
})
|
||
|
||
missingCount = sum(1 for e in xxEntries if e["key"] not in translated)
|
||
finalStatus = "complete" if missingCount == 0 else "incomplete"
|
||
|
||
now = getUtcTimestamp()
|
||
merged = dict(rows[0])
|
||
merged["entries"] = finalEntries
|
||
if "keys" in merged:
|
||
del merged["keys"]
|
||
merged["status"] = finalStatus
|
||
merged["label"] = label
|
||
merged["sysModifiedAt"] = now
|
||
merged["sysModifiedBy"] = userId
|
||
db.recordModify(UiLanguageSet, code, merged)
|
||
|
||
statusHint = "" if finalStatus == "complete" else f" ({missingCount} Keys ohne Übersetzung)"
|
||
|
||
tmCount = await _translateTextMultilingualFields(db, code, label, billingCb)
|
||
|
||
_createNotification(
|
||
userId,
|
||
NotificationType.SYSTEM,
|
||
title="Sprachset erstellt",
|
||
message=f"Die Sprache «{label}» ({code}) wurde per KI übersetzt{statusHint}. {tmCount} Inhaltsfelder übersetzt.",
|
||
)
|
||
await _reloadI18nCache()
|
||
logger.info("i18n create job done: code=%s, translated=%d/%d, tm_fields=%d", code, len(translated), len(xxEntries), tmCount)
|
||
except Exception as e:
|
||
logger.exception("create language job failed: %s", e)
|
||
_createNotification(
|
||
userId,
|
||
NotificationType.SYSTEM,
|
||
title="Sprachset fehlgeschlagen",
|
||
message=f"Fehler bei «{code}»: {e}",
|
||
)
|
||
finally:
|
||
_UPDATING_CODES.discard(code)
|
||
|
||
|
||
@router.post("/sets")
|
||
async def create_language_set(
|
||
request: Request,
|
||
body: CreateLanguageBody,
|
||
background: BackgroundTasks,
|
||
currentUser: User = Depends(getCurrentUser),
|
||
):
|
||
mandateId = _resolveMandateIdForAiI18n(request, currentUser)
|
||
code = _validate_iso2_code(body.code)
|
||
if code == "xx":
|
||
raise HTTPException(status_code=400, detail=routeApiMsg("Das Basisset «xx» kann nicht manuell angelegt werden."))
|
||
|
||
db = _publicMgmtDb()
|
||
existing = db.getRecordset(UiLanguageSet, recordFilter={"id": code})
|
||
if existing:
|
||
raise HTTPException(status_code=409, detail=routeApiMsg("Dieses Sprachset existiert bereits."))
|
||
|
||
xxEntries = _loadMasterXxEntries(db)
|
||
if not xxEntries:
|
||
raise HTTPException(status_code=503, detail=routeApiMsg("Basisset (xx) nicht vorhanden. Bitte zuerst UI-Keys einlesen."))
|
||
|
||
resolvedLabel = (body.label or "").strip() if body.label else ""
|
||
if not resolvedLabel:
|
||
resolvedLabel = _ISO_LABELS.get(code, code)
|
||
|
||
now = getUtcTimestamp()
|
||
uid = str(currentUser.id)
|
||
rec: dict = {
|
||
"id": code,
|
||
"label": resolvedLabel,
|
||
"entries": [],
|
||
"status": "generating",
|
||
"isDefault": False,
|
||
"sysCreatedAt": now,
|
||
"sysCreatedBy": uid,
|
||
"sysModifiedAt": now,
|
||
"sysModifiedBy": uid,
|
||
}
|
||
db.recordCreate(UiLanguageSet, rec)
|
||
|
||
background.add_task(_run_create_language_job, uid, code, resolvedLabel, currentUser, mandateId)
|
||
_createNotification(
|
||
uid,
|
||
NotificationType.SYSTEM,
|
||
title="Sprachset wird erzeugt",
|
||
message=f"Die Sprache «{code}» wird im Hintergrund per KI übersetzt.",
|
||
)
|
||
return {"status": "accepted", "code": code}
|
||
|
||
|
||
def _compute_language_sync_diff(db, code: str) -> dict:
|
||
"""Return key sync metrics before AI translate (no DB writes)."""
|
||
if code == "xx":
|
||
raise HTTPException(status_code=400, detail=routeApiMsg("Das xx-Set wird separat synchronisiert."))
|
||
rows = db.getRecordset(UiLanguageSet, recordFilter={"id": code})
|
||
if not rows:
|
||
raise HTTPException(status_code=404, detail=routeApiMsg("Sprachset nicht gefunden"))
|
||
xxEntries = _loadMasterXxEntries(db)
|
||
if not xxEntries:
|
||
raise HTTPException(status_code=503, detail=routeApiMsg("Basisset (xx) nicht vorhanden."))
|
||
row = dict(rows[0])
|
||
curEntries = _rowEntries(row)
|
||
masterIds = {_entryId(e) for e in xxEntries}
|
||
currentIds = {_entryId(e) for e in curEntries}
|
||
return {
|
||
"code": code,
|
||
"addedCount": len(masterIds - currentIds),
|
||
"removedCount": len(currentIds - masterIds),
|
||
"masterEntryCount": len(masterIds),
|
||
"currentEntryCount": len(currentIds),
|
||
}
|
||
|
||
|
||
def _entryId(e: dict) -> tuple:
|
||
"""Composite identifier for an i18n entry: (key, context)."""
|
||
return (e["key"], e.get("context", "ui"))
|
||
|
||
|
||
async def _syncLanguageWithXx(db, code: str, userId: Optional[str], adminUser: Optional[User] = None) -> dict:
|
||
"""Synchronise a language set (incl. de) against the xx base set via AI.
|
||
|
||
Entries are identified by (key, context) — the same text can appear
|
||
with different contexts (e.g. "ui" and "api.routeXyz").
|
||
"""
|
||
if code == "xx":
|
||
raise HTTPException(status_code=400, detail=routeApiMsg("Das xx-Set wird über 'UI-Keys einlesen' aktualisiert."))
|
||
_UPDATING_CODES.add(code)
|
||
try:
|
||
rows = db.getRecordset(UiLanguageSet, recordFilter={"id": code})
|
||
if not rows:
|
||
raise HTTPException(status_code=404, detail=routeApiMsg("Sprachset nicht gefunden"))
|
||
xxEntries = _loadMasterXxEntries(db)
|
||
if not xxEntries:
|
||
raise HTTPException(status_code=503, detail=routeApiMsg("Basisset (xx) nicht vorhanden."))
|
||
|
||
row = dict(rows[0])
|
||
curEntries = _rowEntries(row)
|
||
curById = {_entryId(e): e for e in curEntries}
|
||
xxById = {_entryId(e): e for e in xxEntries}
|
||
|
||
masterIds = set(xxById.keys())
|
||
currentIds = set(curById.keys())
|
||
removedIds = currentIds - masterIds
|
||
addedIds = masterIds - currentIds
|
||
|
||
translatedCount = 0
|
||
if addedIds:
|
||
toTranslate = {xxById[eid]["key"]: xxById[eid].get("value", "") for eid in addedIds}
|
||
langLabel = row.get("label") or code
|
||
billingCb = None
|
||
if adminUser:
|
||
memberIds = _userMemberMandateIds(adminUser)
|
||
if memberIds:
|
||
billingCb = _makeBillingCallback(adminUser, memberIds[0])
|
||
try:
|
||
translated = await _translateBatch(toTranslate, langLabel, code, billingCallback=billingCb)
|
||
translatedCount = sum(1 for eid in addedIds if xxById[eid]["key"] in translated)
|
||
except Exception as e:
|
||
logger.error("AI translation during sync failed for %s: %s", code, e)
|
||
translated = {}
|
||
|
||
for eid in addedIds:
|
||
xxEntry = xxById[eid]
|
||
curById[eid] = {
|
||
"context": xxEntry["context"],
|
||
"key": xxEntry["key"],
|
||
"value": translated.get(xxEntry["key"], f"[{xxEntry['key']}]"),
|
||
}
|
||
|
||
for eid in removedIds:
|
||
del curById[eid]
|
||
|
||
for eid in masterIds & currentIds:
|
||
curById[eid]["context"] = xxById[eid]["context"]
|
||
|
||
newEntries = sorted(curById.values(), key=lambda e: (e["key"].lower(), e.get("context", "")))
|
||
|
||
now = getUtcTimestamp()
|
||
untranslated = len(addedIds) - translatedCount
|
||
row["entries"] = newEntries
|
||
if "keys" in row:
|
||
del row["keys"]
|
||
row["status"] = "complete" if untranslated == 0 else "incomplete"
|
||
row["sysModifiedAt"] = now
|
||
row["sysModifiedBy"] = userId
|
||
db.recordModify(UiLanguageSet, code, row)
|
||
return {
|
||
"code": code,
|
||
"added": sorted({xxById[eid]["key"] for eid in addedIds}),
|
||
"removed": sorted({eid[0] for eid in removedIds}),
|
||
"translated": translatedCount,
|
||
"entriesCount": len(newEntries),
|
||
}
|
||
finally:
|
||
_UPDATING_CODES.discard(code)
|
||
|
||
|
||
@router.put("/sets/sync-xx")
|
||
async def sync_xx_master(
|
||
request: Request,
|
||
adminUser: User = Depends(requireSysAdmin),
|
||
):
|
||
"""Synchronise the xx base set from the frontend build artefact.
|
||
|
||
Expects JSON body: {"entries": [{"context":"ui","key":"…","value":"…"}, …]}
|
||
Falls back to local codebase scan if no body provided (dev mode).
|
||
"""
|
||
db = getMgmtInterface(adminUser, mandateId=None).db
|
||
fromBody = await _readOptionalEntriesFromBody(request)
|
||
entries = fromBody if fromBody is not None else _scanCodebaseKeys()
|
||
result = _syncXxMaster(db, str(adminUser.id), entries)
|
||
await _reloadI18nCache()
|
||
return result
|
||
|
||
|
||
@router.get("/sets/{code}/sync-diff")
|
||
async def get_language_sync_diff(
|
||
code: str,
|
||
adminUser: User = Depends(requirePlatformAdmin),
|
||
):
|
||
"""How many keys would be added/removed vs xx before running a full sync (SysAdmin)."""
|
||
c = code.strip().lower()
|
||
if c in ("update-all", "sync-xx", "sync-de"):
|
||
raise HTTPException(status_code=400, detail=routeApiMsg("Ungültiger Sprachcode."))
|
||
db = getMgmtInterface(adminUser, mandateId=None).db
|
||
return _compute_language_sync_diff(db, c)
|
||
|
||
|
||
@router.put("/sets/{code}")
|
||
async def update_language_set(
|
||
code: str,
|
||
adminUser: User = Depends(requirePlatformAdmin),
|
||
):
|
||
c = code.strip().lower()
|
||
if c in ("update-all", "sync-xx", "sync-de"):
|
||
raise HTTPException(status_code=400, detail=routeApiMsg("Ungültiger Sprachcode."))
|
||
if c == "xx":
|
||
raise HTTPException(status_code=400, detail=routeApiMsg("Das xx-Set wird über 'UI-Keys einlesen' aktualisiert."))
|
||
db = getMgmtInterface(adminUser, mandateId=None).db
|
||
result = await _syncLanguageWithXx(db, c, str(adminUser.id), adminUser=adminUser)
|
||
await _reloadI18nCache()
|
||
return result
|
||
|
||
|
||
@router.delete("/sets/{code}")
|
||
async def delete_language_set(
|
||
code: str,
|
||
adminUser: User = Depends(requirePlatformAdmin),
|
||
):
|
||
c = code.strip().lower()
|
||
if c in _PROTECTED_CODES:
|
||
raise HTTPException(status_code=400, detail=f"Das Set «{c}» darf nicht gelöscht werden.")
|
||
db = getMgmtInterface(adminUser, mandateId=None).db
|
||
ok = db.recordDelete(UiLanguageSet, c)
|
||
if not ok:
|
||
raise HTTPException(status_code=404, detail=routeApiMsg("Sprachset nicht gefunden"))
|
||
await _reloadI18nCache()
|
||
return {"deleted": c}
|
||
|
||
|
||
@router.get("/sets/{code}/download", dependencies=[Depends(getCurrentUser)])
|
||
async def download_language_set(
|
||
code: str,
|
||
currentUser: User = Depends(getCurrentUser),
|
||
):
|
||
db = _publicMgmtDb()
|
||
rows = db.getRecordset(UiLanguageSet, recordFilter={"id": code.strip().lower()})
|
||
if not rows:
|
||
raise HTTPException(status_code=404, detail=routeApiMsg("Sprachset nicht gefunden"))
|
||
payload = _row_to_public(rows[0])
|
||
raw = json.dumps(payload, ensure_ascii=False, indent=2)
|
||
return Response(
|
||
content=raw,
|
||
media_type="application/json",
|
||
headers={
|
||
"Content-Disposition": f'attachment; filename="ui-language-{code}.json"'
|
||
},
|
||
)
|
||
|
||
|
||
# --- Export / Import (full DB) -----------------------------------------------
|
||
|
||
|
||
@router.get("/export")
|
||
async def export_all_language_sets(
|
||
adminUser: User = Depends(requirePlatformAdmin),
|
||
):
|
||
db = getMgmtInterface(adminUser, mandateId=None).db
|
||
rows = db.getRecordset(UiLanguageSet)
|
||
payload = []
|
||
for r in rows:
|
||
entries = _rowEntries(r)
|
||
payload.append({
|
||
"id": r["id"],
|
||
"label": r.get("label", ""),
|
||
"entries": entries,
|
||
"status": r.get("status", "complete"),
|
||
"isDefault": bool(r.get("isDefault", False)),
|
||
})
|
||
payload.sort(key=lambda x: (not x.get("isDefault"), x["id"]))
|
||
raw = json.dumps(payload, ensure_ascii=False, indent=2)
|
||
return Response(
|
||
content=raw,
|
||
media_type="application/json",
|
||
headers={
|
||
"Content-Disposition": 'attachment; filename="ui-languages-export.json"'
|
||
},
|
||
)
|
||
|
||
|
||
@router.post("/import")
|
||
async def import_language_sets(
|
||
file: UploadFile = File(...),
|
||
adminUser: User = Depends(requirePlatformAdmin),
|
||
):
|
||
if not file.filename or not file.filename.endswith(".json"):
|
||
raise HTTPException(status_code=400, detail=routeApiMsg("Nur .json-Dateien erlaubt."))
|
||
|
||
try:
|
||
raw = await file.read()
|
||
data = json.loads(raw.decode("utf-8"))
|
||
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
||
raise HTTPException(status_code=400, detail=f"Ungültiges JSON: {e}")
|
||
|
||
if not isinstance(data, list):
|
||
raise HTTPException(status_code=400, detail=routeApiMsg("JSON muss ein Array von Sprachsets sein."))
|
||
|
||
db = getMgmtInterface(adminUser, mandateId=None).db
|
||
now = getUtcTimestamp()
|
||
uid = str(adminUser.id)
|
||
created = []
|
||
updated = []
|
||
|
||
for entry in data:
|
||
if not isinstance(entry, dict):
|
||
continue
|
||
code = str(entry.get("id", "")).strip().lower()
|
||
if not code or len(code) < 2:
|
||
continue
|
||
|
||
entries = entry.get("entries")
|
||
if not isinstance(entries, list):
|
||
keys = entry.get("keys")
|
||
if isinstance(keys, dict):
|
||
entries = [{"context": "ui", "key": k, "value": v} for k, v in keys.items()]
|
||
else:
|
||
continue
|
||
|
||
label = str(entry.get("label", code))
|
||
entryStatus = str(entry.get("status", "complete"))
|
||
isDefault = bool(entry.get("isDefault", False))
|
||
|
||
existing = db.getRecordset(UiLanguageSet, recordFilter={"id": code})
|
||
if existing:
|
||
row = dict(existing[0])
|
||
row["entries"] = entries
|
||
if "keys" in row:
|
||
del row["keys"]
|
||
row["label"] = label
|
||
row["status"] = entryStatus
|
||
row["isDefault"] = isDefault
|
||
row["sysModifiedAt"] = now
|
||
row["sysModifiedBy"] = uid
|
||
db.recordModify(UiLanguageSet, code, row)
|
||
updated.append(code)
|
||
else:
|
||
rec = {
|
||
"id": code,
|
||
"label": label,
|
||
"entries": entries,
|
||
"status": entryStatus,
|
||
"isDefault": isDefault,
|
||
"sysCreatedAt": now,
|
||
"sysCreatedBy": uid,
|
||
"sysModifiedAt": now,
|
||
"sysModifiedBy": uid,
|
||
}
|
||
db.recordCreate(UiLanguageSet, rec)
|
||
created.append(code)
|
||
|
||
logger.info("i18n import: created=%s, updated=%s", created, updated)
|
||
await _reloadI18nCache()
|
||
return {"created": created, "updated": updated, "totalProcessed": len(created) + len(updated)}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Phase 7b: translate-field — on-demand translation for TextMultilingual fields
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_TRANSLATE_FIELD_MAX_LEN = 2000
|
||
|
||
|
||
class _TargetLang(BaseModel):
|
||
code: str = Field(..., min_length=2, max_length=10)
|
||
label: str = Field(default="")
|
||
|
||
|
||
class TranslateFieldRequest(BaseModel):
|
||
sourceText: str = Field(..., min_length=1, max_length=_TRANSLATE_FIELD_MAX_LEN)
|
||
sourceLang: str = Field(default="de", min_length=2, max_length=5)
|
||
targetLangs: List[_TargetLang] = Field(..., min_length=1)
|
||
|
||
|
||
@router.post("/translate-field")
|
||
async def translateField(
|
||
body: TranslateFieldRequest,
|
||
request: Request,
|
||
currentUser: User = Depends(getCurrentUser),
|
||
):
|
||
"""Translate a single text into one or more target languages (for TextMultilingual fields)."""
|
||
targets = [t for t in body.targetLangs if t.code != body.sourceLang]
|
||
if not targets:
|
||
return {"translations": {}}
|
||
|
||
mandateId = _resolveMandateIdForAiI18n(request, currentUser)
|
||
billingCb = _makeBillingCallback(currentUser, mandateId)
|
||
|
||
results: Dict[str, str] = {}
|
||
for target in targets:
|
||
targetLabel = target.label or _ISO_LABELS.get(target.code, target.code)
|
||
keysToTranslate = {body.sourceText: "TextMultilingual field"}
|
||
translated = await _translateBatch(keysToTranslate, targetLabel, target.code, billingCb)
|
||
val = translated.get(body.sourceText, "")
|
||
if val:
|
||
results[target.code] = val
|
||
|
||
return {"translations": results}
|