896 lines
33 KiB
Python
896 lines
33 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 = German plaintext, 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
|
||
"""
|
||
|
||
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, requireSysAdminRole
|
||
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.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.timeUtils import getUtcTimestamp
|
||
|
||
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
|
||
|
||
_PROTECTED_CODES = frozenset({"xx"})
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# ISO 639-1 label map (used when creating a language without explicit label)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_ISO_LABELS: Dict[str, str] = {
|
||
"de": "Deutsch", "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"Du bist ein professioneller Übersetzer für Software-UI-Texte. "
|
||
f"Du erhältst ein JSON-Array mit Objekten: {{\"key\": \"deutscher Text\", \"context\": \"UI-Kontext\"}}. "
|
||
f"Der Kontext beschreibt, wo der Text in der Anwendung verwendet wird (Datei, Komponente). "
|
||
f"Übersetze jeden «key» ins {targetLanguageLabel} (ISO {targetCode}). "
|
||
f"Behalte Platzhalter wie {{variable}} exakt bei. "
|
||
f"Antworte NUR mit einem JSON-Objekt — Keys = deutsche Originaltexte, Values = Übersetzungen. "
|
||
f"Kein Markdown, kein Kommentar."
|
||
)
|
||
|
||
request = AiCallRequest(
|
||
prompt=f"Übersetze diese UI-Labels:\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
|
||
|
||
try:
|
||
response = await aiObjects.callWithTextContext(request)
|
||
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)
|
||
except json.JSONDecodeError as je:
|
||
logger.error("i18n AI batch %d/%d JSON parse error: %s", batchIdx + 1, totalBatches, je)
|
||
except Exception as e:
|
||
logger.error("i18n AI batch %d/%d failed: %s", batchIdx + 1, totalBatches, e)
|
||
finally:
|
||
aiObjects.billingCallback = None
|
||
|
||
return result
|
||
|
||
|
||
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="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="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="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="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 entries (from build bundle or codebase scan).
|
||
|
||
- Keys in incoming but not in DB -> add
|
||
- Keys in DB but not in incoming -> remove
|
||
- Keys in both -> update context (value)
|
||
"""
|
||
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)
|
||
curByKey = {e["key"]: e for e in curEntries}
|
||
incomingByKey = {e["key"]: e for e in incomingEntries}
|
||
|
||
incomingKeys = set(incomingByKey.keys())
|
||
dbKeys = set(curByKey.keys())
|
||
|
||
added = sorted(incomingKeys - dbKeys)
|
||
removed = sorted(dbKeys - incomingKeys)
|
||
|
||
newEntries = []
|
||
for e in incomingEntries:
|
||
newEntries.append({"context": e["context"], "key": e["key"], "value": e["value"]})
|
||
for e in curEntries:
|
||
if e["key"] not in incomingKeys:
|
||
continue
|
||
|
||
if not added and not removed and all(
|
||
curByKey.get(e["key"], {}).get("value") == e["value"]
|
||
and curByKey.get(e["key"], {}).get("context") == e["context"]
|
||
for e in incomingEntries
|
||
):
|
||
return {"added": [], "removed": [], "entriesCount": len(newEntries)}
|
||
|
||
now = getUtcTimestamp()
|
||
row["entries"] = newEntries
|
||
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, total=%d", len(added), len(removed), len(newEntries))
|
||
return {"added": added, "removed": removed, "entriesCount": len(newEntries)}
|
||
|
||
|
||
# --- Public -----------------------------------------------------------------
|
||
|
||
|
||
@router.get("/codes")
|
||
async def list_language_codes():
|
||
db = _publicMgmtDb()
|
||
rows = db.getRecordset(UiLanguageSet)
|
||
out = []
|
||
for r in rows:
|
||
entries = _rowEntries(r)
|
||
out.append(
|
||
{
|
||
"code": r["id"],
|
||
"label": r.get("label"),
|
||
"status": r.get("status"),
|
||
"isDefault": bool(r.get("isDefault")),
|
||
"entriesCount": len(entries),
|
||
}
|
||
)
|
||
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="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}", c):
|
||
raise HTTPException(
|
||
status_code=400, detail="Nur ISO-639-1 Zwei-Buchstaben-Codes erlaubt."
|
||
)
|
||
return c
|
||
|
||
|
||
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:
|
||
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)"
|
||
_createNotification(
|
||
userId,
|
||
NotificationType.SYSTEM,
|
||
title="Sprachset erstellt",
|
||
message=f"Die Sprache «{label}» ({code}) wurde per KI übersetzt{statusHint}.",
|
||
)
|
||
logger.info("i18n create job done: code=%s, translated=%d/%d", code, len(translated), len(xxEntries))
|
||
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}",
|
||
)
|
||
|
||
|
||
@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="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="Dieses Sprachset existiert bereits.")
|
||
|
||
xxEntries = _loadMasterXxEntries(db)
|
||
if not xxEntries:
|
||
raise HTTPException(status_code=503, detail="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="Das xx-Set wird separat synchronisiert.")
|
||
rows = db.getRecordset(UiLanguageSet, recordFilter={"id": code})
|
||
if not rows:
|
||
raise HTTPException(status_code=404, detail="Sprachset nicht gefunden")
|
||
xx_entries = _loadMasterXxEntries(db)
|
||
if not xx_entries:
|
||
raise HTTPException(status_code=503, detail="Basisset (xx) nicht vorhanden.")
|
||
row = dict(rows[0])
|
||
cur_entries = _rowEntries(row)
|
||
cur_by_key = {e["key"]: e for e in cur_entries}
|
||
xx_by_key = {e["key"]: e for e in xx_entries}
|
||
master_keys = set(xx_by_key.keys())
|
||
current_keys = set(cur_by_key.keys())
|
||
added_count = len(master_keys - current_keys)
|
||
removed_count = len(current_keys - master_keys)
|
||
return {
|
||
"code": code,
|
||
"addedCount": added_count,
|
||
"removedCount": removed_count,
|
||
"masterEntryCount": len(master_keys),
|
||
"currentEntryCount": len(current_keys),
|
||
}
|
||
|
||
|
||
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."""
|
||
if code == "xx":
|
||
raise HTTPException(status_code=400, detail="Das xx-Set wird über 'UI-Keys einlesen' aktualisiert.")
|
||
rows = db.getRecordset(UiLanguageSet, recordFilter={"id": code})
|
||
if not rows:
|
||
raise HTTPException(status_code=404, detail="Sprachset nicht gefunden")
|
||
xxEntries = _loadMasterXxEntries(db)
|
||
if not xxEntries:
|
||
raise HTTPException(status_code=503, detail="Basisset (xx) nicht vorhanden.")
|
||
|
||
row = dict(rows[0])
|
||
curEntries = _rowEntries(row)
|
||
curByKey = {e["key"]: e for e in curEntries}
|
||
xxByKey = {e["key"]: e for e in xxEntries}
|
||
|
||
masterKeys = set(xxByKey.keys())
|
||
currentKeys = set(curByKey.keys())
|
||
removedKeys = sorted(currentKeys - masterKeys)
|
||
addedKeys = sorted(masterKeys - currentKeys)
|
||
|
||
translatedCount = 0
|
||
if addedKeys:
|
||
toTranslate = {k: xxByKey[k].get("value", "") for k in addedKeys}
|
||
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 k in addedKeys if k in translated)
|
||
except Exception as e:
|
||
logger.error("AI translation during sync failed for %s: %s", code, e)
|
||
translated = {}
|
||
|
||
for k in addedKeys:
|
||
curByKey[k] = {
|
||
"context": xxByKey[k]["context"],
|
||
"key": k,
|
||
"value": translated.get(k, f"[{k}]"),
|
||
}
|
||
|
||
for k in removedKeys:
|
||
del curByKey[k]
|
||
|
||
for k in masterKeys & currentKeys:
|
||
curByKey[k]["context"] = xxByKey[k]["context"]
|
||
|
||
newEntries = [curByKey[k] for k in sorted(curByKey.keys(), key=lambda x: x.lower())]
|
||
|
||
now = getUtcTimestamp()
|
||
untranslated = len(addedKeys) - 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": addedKeys,
|
||
"removed": removedKeys,
|
||
"translated": translatedCount,
|
||
"entriesCount": len(newEntries),
|
||
}
|
||
|
||
|
||
@router.put("/sets/sync-xx")
|
||
async def sync_xx_master(
|
||
request: Request,
|
||
adminUser: User = Depends(requireSysAdminRole),
|
||
):
|
||
"""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()
|
||
return _syncXxMaster(db, str(adminUser.id), entries)
|
||
|
||
|
||
@router.put("/sets/update-all")
|
||
async def update_all_language_sets(
|
||
request: Request,
|
||
adminUser: User = Depends(requireSysAdminRole),
|
||
):
|
||
"""Sync xx-master (if body provided), then update ALL language sets via AI."""
|
||
db = getMgmtInterface(adminUser, mandateId=None).db
|
||
|
||
fromBody = await _readOptionalEntriesFromBody(request)
|
||
xxSync: Optional[dict] = None
|
||
if fromBody is not None:
|
||
xxSync = _syncXxMaster(db, str(adminUser.id), fromBody)
|
||
if xxSync.get("error"):
|
||
return {"xxSync": xxSync, "updated": []}
|
||
|
||
rows = db.getRecordset(UiLanguageSet)
|
||
results = []
|
||
for r in rows:
|
||
cid = r["id"]
|
||
if cid == "xx":
|
||
continue
|
||
res = await _syncLanguageWithXx(db, cid, str(adminUser.id), adminUser=adminUser)
|
||
results.append(res)
|
||
return {"xxSync": xxSync, "updated": results}
|
||
|
||
|
||
@router.get("/sets/{code}/sync-diff")
|
||
async def get_language_sync_diff(
|
||
code: str,
|
||
adminUser: User = Depends(requireSysAdminRole),
|
||
):
|
||
"""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="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(requireSysAdminRole),
|
||
):
|
||
c = code.strip().lower()
|
||
if c in ("update-all", "sync-xx", "sync-de"):
|
||
raise HTTPException(status_code=400, detail="Ungültiger Sprachcode.")
|
||
if c == "xx":
|
||
raise HTTPException(status_code=400, detail="Das xx-Set wird über 'UI-Keys einlesen' aktualisiert.")
|
||
db = getMgmtInterface(adminUser, mandateId=None).db
|
||
return await _syncLanguageWithXx(db, c, str(adminUser.id), adminUser=adminUser)
|
||
|
||
|
||
@router.delete("/sets/{code}")
|
||
async def delete_language_set(
|
||
code: str,
|
||
adminUser: User = Depends(requireSysAdminRole),
|
||
):
|
||
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="Sprachset nicht gefunden")
|
||
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="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(requireSysAdminRole),
|
||
):
|
||
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(requireSysAdminRole),
|
||
):
|
||
if not file.filename or not file.filename.endswith(".json"):
|
||
raise HTTPException(status_code=400, detail="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="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)
|
||
return {"created": created, "updated": updated, "totalProcessed": len(created) + len(updated)}
|