platform-core/modules/system/i18nBootSync.py
ValueOn AG 9be2d8aab5
Some checks failed
Deploy Plattform-Core (Int) / test (push) Failing after 16s
Deploy Plattform-Core (Int) / deploy (push) Has been skipped
refactory workflowAutomation completed as system component reolacing automation2 and graphEditor
2026-06-08 10:31:17 +02:00

558 lines
21 KiB
Python

# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
i18n boot-time logic: label discovery, DB sync, and cache loading.
Called once at app startup (from app.py). This module MAY import from
system, features, serviceCenter, connectors — it runs after all modules
are importable and is never imported at module-level by datamodels or shared.
"""
from __future__ import annotations
import logging
import re
from pathlib import Path
from typing import Any, Dict, List, Type
from pydantic import BaseModel
from modules.shared.i18nRegistry import (
_CACHE,
_CURRENT_LANGUAGE,
_enforceSourcePlaceholders,
_extractRegistrySourceText,
_I18nRegistryEntry,
_REGISTRY,
t,
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Boot: scan route files for routeApiMsg("…") calls → register eagerly
# ---------------------------------------------------------------------------
_ROUTE_API_MSG_RE = None # compiled lazily
def _scanRouteApiMsgKeys():
"""Scan all gateway route/feature Python files for routeApiMsg("") calls
and register the keys in _REGISTRY so they appear in the boot DB sync.
"""
global _ROUTE_API_MSG_RE
if _ROUTE_API_MSG_RE is None:
_ROUTE_API_MSG_RE = re.compile(
r"""routeApiMsg\(\s*(['"])((?:\\.|(?!\1).)+)\1""",
)
gatewayRoot = Path(__file__).resolve().parents[1]
scanDirs = [gatewayRoot / "routes", gatewayRoot / "features"]
_ctxRe = re.compile(r'''apiRouteContext\(\s*['"]([^'"]+)['"]\s*\)''')
for scanDir in scanDirs:
if not scanDir.is_dir():
continue
for pyFile in scanDir.rglob("*.py"):
try:
src = pyFile.read_text(encoding="utf-8", errors="replace")
except OSError:
continue
ctxMatch = _ctxRe.search(src)
if not ctxMatch:
continue
ctx = f"api.{ctxMatch.group(1)}"
for m in _ROUTE_API_MSG_RE.finditer(src):
key = m.group(2).replace("\\'", "'").replace('\\"', '"')
if key and key not in _REGISTRY:
_REGISTRY[key] = _I18nRegistryEntry(context=ctx, value="")
logger.info("i18n route scan: %d api.* keys in registry after scan",
sum(1 for e in _REGISTRY.values() if e.context.startswith("api.")))
def _registerNavLabels():
"""Register all navigation labels from NAVIGATION_SECTIONS as i18n keys."""
try:
from modules.system.mainSystem import NAVIGATION_SECTIONS
except ImportError:
logger.warning("i18n: could not import NAVIGATION_SECTIONS for nav label registration")
return
count = 0
for section in NAVIGATION_SECTIONS:
title = section.get("title", "")
if title and title not in _REGISTRY:
_REGISTRY[title] = _I18nRegistryEntry(context="nav", value="")
count += 1
for item in section.get("items", []):
label = item.get("label", "")
if label and label not in _REGISTRY:
_REGISTRY[label] = _I18nRegistryEntry(context="nav", value="")
count += 1
for subgroup in section.get("subgroups", []):
sgTitle = subgroup.get("title", "")
if sgTitle and sgTitle not in _REGISTRY:
_REGISTRY[sgTitle] = _I18nRegistryEntry(context="nav", value="")
count += 1
for item in subgroup.get("items", []):
label = item.get("label", "")
if label and label not in _REGISTRY:
_REGISTRY[label] = _I18nRegistryEntry(context="nav", value="")
count += 1
logger.info("i18n nav labels: registered %d nav keys", count)
def _registerFeatureUiLabels():
"""Register FEATURE_LABEL and UI_OBJECTS labels from all feature modules."""
try:
from modules.system import mainSystem as _mainSystem
_fl = getattr(_mainSystem, "FEATURE_LABEL", None)
if isinstance(_fl, str) and _fl and _fl not in _REGISTRY:
_REGISTRY[_fl] = _I18nRegistryEntry(context="nav", value="")
except ImportError:
pass
_featureModulePaths = (
"modules.features.trustee.mainTrustee",
"modules.workflowAutomation.mainWorkflowAutomation",
"modules.features.commcoach.mainCommcoach",
"modules.features.teamsbot.mainTeamsbot",
"modules.features.workspace.mainWorkspace",
"modules.features.realEstate.mainRealEstate",
"modules.features.neutralization.mainNeutralization",
)
added = 0
for modPath in _featureModulePaths:
try:
mod = __import__(modPath, fromlist=["FEATURE_LABEL", "UI_OBJECTS"])
except ImportError:
continue
fl = getattr(mod, "FEATURE_LABEL", None)
if isinstance(fl, str) and fl and fl not in _REGISTRY:
_REGISTRY[fl] = _I18nRegistryEntry(context="nav", value="")
added += 1
for uiObj in getattr(mod, "UI_OBJECTS", []) or []:
base = _extractRegistrySourceText(uiObj.get("label"))
if base and base not in _REGISTRY:
_REGISTRY[base] = _I18nRegistryEntry(context="nav", value="")
added += 1
logger.info("i18n feature UI labels: %d new keys (nav context)", added)
def _registerRbacLabels():
"""Register DATA_OBJECTS, RESOURCE_OBJECTS labels and TEMPLATE_ROLES descriptions."""
_featureModulePaths = (
"modules.system.mainSystem",
"modules.features.trustee.mainTrustee",
"modules.workflowAutomation.mainWorkflowAutomation",
"modules.features.commcoach.mainCommcoach",
"modules.features.teamsbot.mainTeamsbot",
"modules.features.workspace.mainWorkspace",
"modules.features.realEstate.mainRealEstate",
"modules.features.neutralization.mainNeutralization",
)
added = 0
for modPath in _featureModulePaths:
try:
mod = __import__(modPath, fromlist=[
"DATA_OBJECTS", "RESOURCE_OBJECTS", "TEMPLATE_ROLES",
"QUICK_ACTIONS", "QUICK_ACTION_CATEGORIES",
])
except ImportError:
continue
for dataObj in getattr(mod, "DATA_OBJECTS", []) or []:
key = _extractRegistrySourceText(dataObj.get("label"))
if key and key not in _REGISTRY:
_REGISTRY[key] = _I18nRegistryEntry(context="rbac.data", value="")
added += 1
for resObj in getattr(mod, "RESOURCE_OBJECTS", []) or []:
key = _extractRegistrySourceText(resObj.get("label"))
if key and key not in _REGISTRY:
_REGISTRY[key] = _I18nRegistryEntry(context="rbac.resource", value="")
added += 1
for role in getattr(mod, "TEMPLATE_ROLES", []) or []:
key = _extractRegistrySourceText(role.get("description"))
if key and key not in _REGISTRY:
_REGISTRY[key] = _I18nRegistryEntry(context="rbac.role", value="")
added += 1
for qa in getattr(mod, "QUICK_ACTIONS", []) or []:
for field in ("label", "description"):
key = _extractRegistrySourceText(qa.get(field))
if key and key not in _REGISTRY:
_REGISTRY[key] = _I18nRegistryEntry(context="rbac.quickaction", value="")
added += 1
for cat in getattr(mod, "QUICK_ACTION_CATEGORIES", []) or []:
key = _extractRegistrySourceText(cat.get("label"))
if key and key not in _REGISTRY:
_REGISTRY[key] = _I18nRegistryEntry(context="rbac.quickaction", value="")
added += 1
logger.info("i18n rbac labels: %d new keys (rbac.* context)", added)
def _registerServiceCenterLabels(serviceLabels: list = None):
"""Register service-center category labels and bootstrap role descriptions.
serviceLabels is injected by app.py (Composition Root) to avoid
system(L4) → serviceCenter(L5) upward import.
"""
added = 0
for label in (serviceLabels or []):
key = _extractRegistrySourceText(label)
if key and key not in _REGISTRY:
_REGISTRY[key] = _I18nRegistryEntry(context="service", value="")
added += 1
_bootstrapRoleDescriptions = [
"Administrator - Benutzer und Ressourcen im Mandanten verwalten",
"Benutzer - Standard-Benutzer mit Zugriff auf eigene Datensätze",
"Betrachter - Nur-Lese-Zugriff auf Gruppen-Datensätze",
"System-Administrator - Vollständiger administrativer Zugriff über alle Mandanten",
]
for desc in _bootstrapRoleDescriptions:
if desc not in _REGISTRY:
_REGISTRY[desc] = _I18nRegistryEntry(context="rbac.role", value="")
added += 1
logger.info("i18n service/bootstrap labels: %d new keys", added)
def _registerNodeLabels():
"""Register all graph-editor node labels, descriptions, parameter descriptions,
output labels, port descriptions, category labels, and entry-point titles."""
added = 0
def _reg(key: str, ctx: str):
nonlocal added
if key and key not in _REGISTRY:
_REGISTRY[key] = _I18nRegistryEntry(context=ctx, value="")
added += 1
try:
from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES
for nd in STATIC_NODE_TYPES:
_reg(_extractRegistrySourceText(nd.get("label")), "node.label")
_reg(_extractRegistrySourceText(nd.get("description")), "node.desc")
for param in nd.get("parameters", []) or []:
_reg(_extractRegistrySourceText(param.get("description")), "node.param")
_reg(_extractRegistrySourceText(param.get("label")), "node.param")
outLabels = nd.get("outputLabels")
if isinstance(outLabels, dict):
sourceList = outLabels.get("xx") or next(iter(outLabels.values()), [])
if not isinstance(sourceList, list):
sourceList = []
for lbl in sourceList:
_reg(lbl, "node.output")
elif isinstance(outLabels, list):
for lbl in outLabels:
_reg(lbl, "node.output")
except ImportError:
pass
try:
from modules.workflowAutomation.editor.portTypes import PORT_TYPE_CATALOG
for schema in PORT_TYPE_CATALOG.values():
for field in getattr(schema, "fields", []) or []:
desc = getattr(field, "description", None)
if desc:
_reg(_extractRegistrySourceText(desc if isinstance(desc, (str, dict)) else None), "port.desc")
except ImportError:
pass
_nodeCategoryLabels = [
"Trigger", "Eingabe/Mensch", "Ablauf", "Daten", "KI",
"Datei", "E-Mail", "SharePoint", "ClickUp", "Treuhand",
]
for lbl in _nodeCategoryLabels:
_reg(lbl, "node.category")
_entryPointTitles = ["Jetzt ausführen", "Start"]
for lbl in _entryPointTitles:
_reg(lbl, "node.entry")
logger.info("i18n node labels: %d new keys (node.*/port.* context)", added)
def _registerAccountingConnectorLabels(accountingLabels: list = None):
"""Register accounting connector configField labels at boot time.
Args:
accountingLabels: List of dicts with keys 'label' and 'connectorType',
injected from app.py to avoid features-import.
"""
if not accountingLabels:
return
added = 0
for entry in accountingLabels:
key = entry.get("label", "")
connectorType = entry.get("connectorType", "unknown")
if not isinstance(key, str) or not key:
continue
if key not in _REGISTRY:
_REGISTRY[key] = _I18nRegistryEntry(
context=f"connector.accounting.{connectorType}",
value="",
)
added += 1
logger.info("i18n accounting connector labels: %d new keys", added)
def _registerDatamodelOptionLabels():
"""Register all frontend_options labels from Pydantic datamodels and subscription plans."""
added = 0
def _reg(key: str, ctx: str):
nonlocal added
if key and key not in _REGISTRY:
_REGISTRY[key] = _I18nRegistryEntry(context=ctx, value="")
added += 1
_datamodelModules = (
"modules.datamodels.datamodelRbac",
"modules.datamodels.datamodelChat",
"modules.datamodels.datamodelMessaging",
"modules.datamodels.datamodelNotification",
"modules.datamodels.datamodelUam",
"modules.datamodels.datamodelFiles",
"modules.datamodels.datamodelDataSource",
"modules.datamodels.datamodelFeatures",
"modules.datamodels.datamodelUiLanguage",
"modules.datamodels.datamodelViews",
"modules.features.trustee.datamodelFeatureTrustee",
"modules.features.neutralization.datamodelFeatureNeutralizer",
)
for modPath in _datamodelModules:
try:
mod = __import__(modPath, fromlist=["__all__"])
except ImportError:
continue
for attrName in dir(mod):
cls = getattr(mod, attrName, None)
if not isinstance(cls, type) or not issubclass(cls, BaseModel):
continue
for fieldName, fieldInfo in cls.model_fields.items():
extra = (fieldInfo.json_schema_extra or {}) if hasattr(fieldInfo, "json_schema_extra") else {}
if not isinstance(extra, dict):
continue
options = extra.get("frontend_options")
if not isinstance(options, list):
continue
ctx = f"option.{cls.__name__}.{fieldName}"
for opt in options:
if isinstance(opt, dict):
_reg(_extractRegistrySourceText(opt.get("label")), ctx)
try:
from modules.datamodels.datamodelSubscription import BUILTIN_PLANS
for plan in BUILTIN_PLANS.values():
_reg(_extractRegistrySourceText(getattr(plan, "title", None)), "subscription.title")
_reg(_extractRegistrySourceText(getattr(plan, "description", None)), "subscription.desc")
except (ImportError, AttributeError):
pass
logger.info("i18n datamodel option labels: %d new keys", added)
# ---------------------------------------------------------------------------
# Public boot API (called by app.py)
# ---------------------------------------------------------------------------
async def syncRegistryToDb(serviceLabels: list = None, accountingLabels: list = None):
"""Boot hook: discover all i18n keys and write them into UiLanguageSet(xx).
Args:
serviceLabels: Service label strings injected from app.py (avoids upward import).
accountingLabels: Accounting connector field labels injected from app.py.
"""
_scanRouteApiMsgKeys()
_registerNavLabels()
_registerFeatureUiLabels()
_registerRbacLabels()
_registerServiceCenterLabels(serviceLabels)
_registerNodeLabels()
_registerDatamodelOptionLabels()
_registerAccountingConnectorLabels(accountingLabels)
if not _REGISTRY:
logger.info("i18n registry: no keys to sync (empty registry)")
return
from modules.datamodels.datamodelUiLanguage import UiLanguageSet
from modules.shared.configuration import APP_CONFIG
from modules.connectors.connectorDbPostgre import getCachedConnector
from modules.shared.timeUtils import getUtcTimestamp
db = getCachedConnector(
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_boot__",
)
rows = db.getRecordset(UiLanguageSet, recordFilter={"id": "xx"})
gatewayEntries = [
{"context": entry.context, "key": key, "value": entry.value}
for key, entry in _REGISTRY.items()
]
gatewayKeys = set(_REGISTRY.keys())
if not rows:
now = getUtcTimestamp()
rec = {
"id": "xx",
"label": "Basisset (Meta)",
"entries": gatewayEntries,
"status": "complete",
"isDefault": True,
"sysCreatedAt": now,
"sysCreatedBy": "__i18n_boot__",
"sysModifiedAt": now,
"sysModifiedBy": "__i18n_boot__",
}
db.recordCreate(UiLanguageSet, rec)
logger.info("i18n boot-sync: created xx set with %d gateway keys", len(gatewayEntries))
return
row = dict(rows[0])
existingEntries: List[dict] = row.get("entries") or []
if not isinstance(existingEntries, list):
existingEntries = []
uiEntries = [e for e in existingEntries if e.get("context", "") == "ui"]
oldGatewayEntries = [
e for e in existingEntries
if e.get("context", "") != "ui"
]
oldGatewayByKey = {e["key"]: e for e in oldGatewayEntries}
added = 0
updated = 0
removed = 0
newGatewayEntries: List[dict] = []
for key, entry in _REGISTRY.items():
newEntry = {"context": entry.context, "key": key, "value": entry.value}
old = oldGatewayByKey.get(key)
if old is None:
added += 1
elif old.get("context") != entry.context or old.get("value") != entry.value:
updated += 1
newGatewayEntries.append(newEntry)
removed = len(set(oldGatewayByKey.keys()) - gatewayKeys)
mergedEntries = uiEntries + newGatewayEntries
if added == 0 and updated == 0 and removed == 0:
logger.info("i18n boot-sync: xx set up-to-date (%d gateway + %d ui keys)", len(newGatewayEntries), len(uiEntries))
return
now = getUtcTimestamp()
row["entries"] = mergedEntries
if "keys" in row:
del row["keys"]
row["sysModifiedAt"] = now
row["sysModifiedBy"] = "__i18n_boot__"
db.recordModify(UiLanguageSet, "xx", row)
logger.info(
"i18n boot-sync: xx updated (+%d added, ~%d updated, -%d removed, total=%d gateway + %d ui)",
added, updated, removed, len(newGatewayEntries), len(uiEntries),
)
async def loadCache():
"""Boot hook: load all UiLanguageSets into the in-memory translation cache.
Also persistently repairs placeholder mismatches in the DB.
After this, t() lookups are O(1) dict access with no DB calls.
"""
from modules.datamodels.datamodelUiLanguage import UiLanguageSet
from modules.shared.configuration import APP_CONFIG
from modules.connectors.connectorDbPostgre import getCachedConnector
db = getCachedConnector(
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_cache__",
)
rows = db.getRecordset(UiLanguageSet)
_CACHE.clear()
repairedTotal = 0
persistedLanguages = 0
for row in rows:
code = row.get("id", "")
if code == "xx":
continue
entries = row.get("entries")
if not isinstance(entries, list):
continue
langDict: Dict[str, str] = {}
repairedInLang = 0
for entry in entries:
key = entry.get("key", "")
val = entry.get("value", "")
if not key or not val:
continue
fixed, changed = _enforceSourcePlaceholders(key, val)
if changed:
entry["value"] = fixed
repairedInLang += 1
langDict[key] = fixed
if langDict:
_CACHE[code] = langDict
if repairedInLang:
repairedTotal += repairedInLang
try:
rowToSave = dict(row)
rowToSave["entries"] = entries
if "keys" in rowToSave:
del rowToSave["keys"]
db.recordModify(UiLanguageSet, code, rowToSave)
persistedLanguages += 1
logger.info(
"i18n boot repair: fixed and persisted %d placeholder mismatches in language '%s'",
repairedInLang, code,
)
except Exception as ex:
logger.warning(
"i18n boot repair: in-memory fixed %d entries in '%s' but DB persist failed: %s",
repairedInLang, code, ex,
)
logger.info(
"i18n cache loaded: %d languages, %d total keys%s",
len(_CACHE), sum(len(v) for v in _CACHE.values()),
(
f" (boot-repaired {repairedTotal} placeholders, "
f"persisted to {persistedLanguages} language sets)"
if repairedTotal else ""
),
)