From 3adbd1da29f9b14a61e34a7e5438ecd638c4dcf2 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Sat, 11 Apr 2026 22:23:41 +0200
Subject: [PATCH] fixed sysuser and removed redundant fallbacks
---
app.py | 4 +-
modules/datamodels/datamodelUam.py | 20 ++----
modules/datamodels/datamodelUtils.py | 35 ++++-----
modules/features/chatbot/mainChatbot.py | 4 +-
.../commcoach/interfaceFeatureCommcoach.py | 20 +++---
modules/features/commcoach/mainCommcoach.py | 4 +-
.../commcoach/serviceCommcoachGamification.py | 41 +++++++++--
.../features/graphicalEditor/entryPoints.py | 7 +-
.../features/graphicalEditor/nodeRegistry.py | 30 ++++++--
modules/features/graphicalEditor/portTypes.py | 10 +--
.../routeFeatureGraphicalEditor.py | 23 ++----
.../features/teamsbot/routeFeatureTeamsbot.py | 32 ++++-----
.../trustee/accounting/accountingRegistry.py | 6 +-
.../features/trustee/routeFeatureTrustee.py | 15 ++--
.../workspace/routeFeatureWorkspace.py | 4 +-
modules/interfaces/interfaceDbApp.py | 2 +-
modules/interfaces/interfaceFeatures.py | 13 ++--
modules/routes/routeAdminFeatures.py | 20 +-----
modules/routes/routeAdminRbacRules.py | 27 ++-----
.../routes/routeAdminUserAccessOverview.py | 19 ++---
modules/routes/routeDataUsers.py | 8 +--
modules/routes/routeI18n.py | 18 +++++
modules/routes/routeStore.py | 20 ++----
.../services/serviceAgent/featureDataAgent.py | 8 +--
.../services/serviceAi/subStructureFilling.py | 4 +-
.../serviceAi/subStructureGeneration.py | 14 ++--
modules/shared/attributeUtils.py | 64 ++++++-----------
modules/shared/i18nRegistry.py | 72 ++++++++++++++++---
.../automation2/subAutomation2Schedule.py | 9 +--
.../methods/methodFile/actions/create.py | 6 +-
modules/workflows/scheduler/mainScheduler.py | 9 +--
31 files changed, 276 insertions(+), 292 deletions(-)
diff --git a/app.py b/app.py
index fa92e882..937cab28 100644
--- a/app.py
+++ b/app.py
@@ -491,12 +491,12 @@ from modules.auth import (
)
# i18n language detection middleware (sets per-request language from Accept-Language header)
-from modules.shared.i18nRegistry import _setLanguage
+from modules.shared.i18nRegistry import _setLanguage, normalizePrimaryLanguageTag
@app.middleware("http")
async def _i18nMiddleware(request: Request, call_next):
acceptLang = request.headers.get("Accept-Language", "")
- lang = acceptLang[:2].lower() if len(acceptLang) >= 2 and acceptLang[:2].isalpha() else "de"
+ lang = normalizePrimaryLanguageTag(acceptLang, "de")
_setLanguage(lang)
return await call_next(request)
diff --git a/modules/datamodels/datamodelUam.py b/modules/datamodels/datamodelUam.py
index 9597eb2f..7bac8672 100644
--- a/modules/datamodels/datamodelUam.py
+++ b/modules/datamodels/datamodelUam.py
@@ -14,7 +14,7 @@ from typing import Optional, List, Dict, Any
from enum import Enum
from pydantic import BaseModel, Field, EmailStr, field_validator, computed_field
from modules.datamodels.datamodelBase import PowerOnModel
-from modules.shared.i18nRegistry import i18nModel
+from modules.shared.i18nRegistry import i18nModel, normalizePrimaryLanguageTag
from modules.shared.timeUtils import getUtcTimestamp
@@ -243,17 +243,12 @@ class User(PowerOnModel):
)
language: str = Field(
default="de",
- description="Preferred language of the user (ISO 639-1 code: de, en, fr, it)",
+ description="Preferred UI language code (must exist as UiLanguageSet; loaded from /api/i18n/user-language-options).",
json_schema_extra={
"frontend_type": "select",
"frontend_readonly": False,
"frontend_required": True,
- "frontend_options": [
- {"value": "de", "label": "Deutsch"},
- {"value": "en", "label": "Englisch"},
- {"value": "fr", "label": "Französisch"},
- {"value": "it", "label": "Italienisch"},
- ],
+ "frontend_options": "/api/i18n/user-language-options",
"label": "Sprache",
},
)
@@ -261,10 +256,9 @@ class User(PowerOnModel):
@field_validator('language', mode='before')
@classmethod
def _normalizeLanguage(cls, v):
- """Normalize language to valid ISO 639-1 code."""
+ """Normalize to primary language subtag (2–8 letters); default remains ``de``."""
if v is None:
return "de"
- # Map common variations to standard codes
langMap = {
'english': 'en', 'englisch': 'en',
'german': 'de', 'deutsch': 'de',
@@ -274,11 +268,7 @@ class User(PowerOnModel):
normalized = str(v).lower().strip()
if normalized in langMap:
return langMap[normalized]
- # If already a valid code, return as-is
- if normalized in ['de', 'en', 'fr', 'it']:
- return normalized
- # Default fallback
- return "de"
+ return normalizePrimaryLanguageTag(normalized, "de")
enabled: bool = Field(
default=True,
diff --git a/modules/datamodels/datamodelUtils.py b/modules/datamodels/datamodelUtils.py
index e9b519d4..bdef6cb2 100644
--- a/modules/datamodels/datamodelUtils.py
+++ b/modules/datamodels/datamodelUtils.py
@@ -2,8 +2,9 @@
# All rights reserved.
"""Utility datamodels: Prompt, TextMultilingual."""
-import re as _re
-from typing import Any, Dict, Optional
+import json
+from typing import Any, Dict
+
from pydantic import BaseModel, Field, field_validator
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.i18nRegistry import i18nModel
@@ -98,21 +99,6 @@ class TextMultilingual(BaseModel):
return cls(xx=t)
-_REPR_PATTERN = _re.compile(r"(\w+)='([^']*)'")
-
-
-def _parseReprString(s: str) -> Optional[Dict[str, str]]:
- """Parse a Pydantic repr string like "en='text' de=None fr=" into a dict."""
- matches = _REPR_PATTERN.findall(s)
- if not matches:
- return None
- result = {}
- for code, text in matches:
- if len(code) <= 5 and text:
- result[code] = text
- return result if result else None
-
-
def coerce_text_multilingual(val: Any) -> TextMultilingual:
"""Normalize str, dict, or TextMultilingual into a valid TextMultilingual instance."""
if isinstance(val, TextMultilingual):
@@ -125,10 +111,13 @@ def coerce_text_multilingual(val: Any) -> TextMultilingual:
cleaned["xx"] = cleaned.get("de") or next((v for v in cleaned.values() if v), "—")
return TextMultilingual(**cleaned)
if isinstance(val, str) and val.strip():
- parsed = _parseReprString(val)
- if parsed:
- if not parsed.get("xx"):
- parsed["xx"] = parsed.get("de") or next((v for v in parsed.values() if v), val.strip())
- return TextMultilingual(**parsed)
- return TextMultilingual.fromUniform(val)
+ s = val.strip()
+ if s.startswith("{") and s.endswith("}"):
+ try:
+ parsed = json.loads(s)
+ if isinstance(parsed, dict):
+ return coerce_text_multilingual(parsed)
+ except json.JSONDecodeError:
+ pass
+ return TextMultilingual.fromUniform(s)
return TextMultilingual.fromUniform("—")
diff --git a/modules/features/chatbot/mainChatbot.py b/modules/features/chatbot/mainChatbot.py
index 79f970c6..b50fef79 100644
--- a/modules/features/chatbot/mainChatbot.py
+++ b/modules/features/chatbot/mainChatbot.py
@@ -191,8 +191,8 @@ def getChatStreamingHelper():
def __get_placeholder_user():
"""Placeholder user for contexts that only need service resolution (e.g. ChatStreamingHelper)."""
- from modules.datamodels.datamodelUam import User
- return User(id="system", username="system", email=None, fullName="System Placeholder")
+ from modules.interfaces.interfaceDbApp import getRootInterface
+ return getRootInterface().currentUser
def getEventManager(user, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None):
diff --git a/modules/features/commcoach/interfaceFeatureCommcoach.py b/modules/features/commcoach/interfaceFeatureCommcoach.py
index d4faa18d..c9b4564e 100644
--- a/modules/features/commcoach/interfaceFeatureCommcoach.py
+++ b/modules/features/commcoach/interfaceFeatureCommcoach.py
@@ -13,7 +13,7 @@ from modules.datamodels.datamodelUam import User
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.shared.timeUtils import getIsoTimestamp
from modules.shared.configuration import APP_CONFIG
-from modules.shared.i18nRegistry import t
+from modules.shared.i18nRegistry import resolveText, t
from .datamodelCommcoach import (
CoachingContext, CoachingContextStatus,
@@ -414,16 +414,20 @@ def _calcGoalProgress(goalsRaw) -> Optional[int]:
_LEVELS = [
- (50, 5, "master", t("Meister")),
- (25, 4, "expert", t("Experte")),
- (10, 3, "advanced", t("Fortgeschritten")),
- (3, 2, "engaged", t("Engagiert")),
+ (50, 5, "master", "Meister"),
+ (25, 4, "expert", "Experte"),
+ (10, 3, "advanced", "Fortgeschritten"),
+ (3, 2, "engaged", "Engagiert"),
]
+t("Meister")
+t("Experte")
+t("Fortgeschritten")
+t("Engagiert")
t("Einsteiger")
def _calcLevel(totalSessions: int) -> Dict[str, Any]:
- for threshold, number, code, _label in _LEVELS:
+ for threshold, number, code, labelKey in _LEVELS:
if totalSessions >= threshold:
- return {"number": number, "code": code, "label": t(_label), "totalSessions": totalSessions}
- return {"number": 1, "code": "beginner", "label": t("Einsteiger"), "totalSessions": totalSessions}
+ return {"number": number, "code": code, "label": resolveText(labelKey), "totalSessions": totalSessions}
+ return {"number": 1, "code": "beginner", "label": resolveText("Einsteiger"), "totalSessions": totalSessions}
diff --git a/modules/features/commcoach/mainCommcoach.py b/modules/features/commcoach/mainCommcoach.py
index 90789ec6..acbd62a6 100644
--- a/modules/features/commcoach/mainCommcoach.py
+++ b/modules/features/commcoach/mainCommcoach.py
@@ -236,9 +236,9 @@ def _seedBuiltinPersonas():
try:
from .serviceCommcoachPersonas import seedBuiltinPersonas
from .interfaceFeatureCommcoach import getInterface
- from modules.datamodels.datamodelUam import User
+ from modules.interfaces.interfaceDbApp import getRootInterface
- systemUser = User(id="system", username="system", email="system@poweron.swiss")
+ systemUser = getRootInterface().currentUser
interface = getInterface(systemUser)
seedBuiltinPersonas(interface)
except Exception as e:
diff --git a/modules/features/commcoach/serviceCommcoachGamification.py b/modules/features/commcoach/serviceCommcoachGamification.py
index 5aa796b7..180706de 100644
--- a/modules/features/commcoach/serviceCommcoachGamification.py
+++ b/modules/features/commcoach/serviceCommcoachGamification.py
@@ -7,7 +7,7 @@ Checks and awards badges after each session completion.
import logging
from typing import Dict, Any, List, Optional
-from modules.shared.i18nRegistry import t
+from modules.shared.i18nRegistry import resolveText, t
logger = logging.getLogger(__name__)
@@ -80,9 +80,32 @@ BADGE_DEFINITIONS: Dict[str, Dict[str, Any]] = {
}
# Register all badge labels/descriptions at import time for i18n xx base set
-for _bd in BADGE_DEFINITIONS.values():
- t(_bd["label"])
- t(_bd["description"])
+t("Erste Session")
+t("Deine erste Coaching-Session abgeschlossen")
+t("3-Tage-Serie")
+t("3 Tage in Folge eine Session absolviert")
+t("Wochenserie")
+t("7 Tage in Folge eine Session absolviert")
+t("Monatsserie")
+t("30 Tage in Folge eine Session absolviert")
+t("Engagiert")
+t("5 Sessions abgeschlossen")
+t("Fortgeschritten")
+t("10 Sessions abgeschlossen")
+t("Experte")
+t("25 Sessions abgeschlossen")
+t("Meister")
+t("50 Sessions abgeschlossen")
+t("Bestleistung")
+t("Durchschnittsscore über 80 in einer Session")
+t("Vielseitig")
+t("3 verschiedene Coaching-Themen aktiv")
+t("Rollenspieler")
+t("Erste Roleplay-Session mit einer Persona abgeschlossen")
+t("Ganzheitlich")
+t("In allen 5 Kompetenz-Dimensionen bewertet")
+t("Umsetzer")
+t("10 Coaching-Aufgaben erledigt")
async def checkAndAwardBadges(interface, userId: str, mandateId: str, instanceId: str,
@@ -141,8 +164,8 @@ async def checkAndAwardBadges(interface, userId: str, mandateId: str, instanceId
}
newBadge = interface.awardBadge(badgeData)
definition = BADGE_DEFINITIONS.get(badgeKey, {})
- newBadge["label"] = t(definition.get("label", badgeKey))
- newBadge["description"] = t(definition.get("description", ""))
+ newBadge["label"] = resolveText(definition.get("label", badgeKey))
+ newBadge["description"] = resolveText(definition.get("description", ""))
newBadge["icon"] = definition.get("icon", "star")
awarded.append(newBadge)
logger.info(f"Badge '{badgeKey}' awarded to user {userId}")
@@ -154,5 +177,9 @@ def getBadgeDefinitions() -> Dict[str, Dict[str, Any]]:
"""Return all badge definitions for the frontend (labels resolved via i18n)."""
resolved = {}
for key, defn in BADGE_DEFINITIONS.items():
- resolved[key] = {**defn, "label": t(defn["label"]), "description": t(defn["description"])}
+ resolved[key] = {
+ **defn,
+ "label": resolveText(defn["label"]),
+ "description": resolveText(defn["description"]),
+ }
return resolved
diff --git a/modules/features/graphicalEditor/entryPoints.py b/modules/features/graphicalEditor/entryPoints.py
index c63ada70..9ade2e96 100644
--- a/modules/features/graphicalEditor/entryPoints.py
+++ b/modules/features/graphicalEditor/entryPoints.py
@@ -36,10 +36,11 @@ def default_manual_entry_point() -> Dict[str, Any]:
}
-def _normalize_title(title: Any, preferredLang: Optional[str] = None) -> str:
+def _normalize_title(title: Any) -> str:
+ """Extract a plain string from a title value for storage (not display)."""
if isinstance(title, dict):
- picked = (preferredLang and title.get(preferredLang)) or title.get("xx") or next(iter(title.values()), "")
- return str(picked).strip() if picked is not None else ""
+ picked = title.get("xx") or next((v for v in title.values() if v), None)
+ return str(picked).strip() if picked else "Start"
if isinstance(title, str) and title.strip():
return title.strip()
return "Start"
diff --git a/modules/features/graphicalEditor/nodeRegistry.py b/modules/features/graphicalEditor/nodeRegistry.py
index 81cce9c7..1af0beb7 100644
--- a/modules/features/graphicalEditor/nodeRegistry.py
+++ b/modules/features/graphicalEditor/nodeRegistry.py
@@ -10,13 +10,14 @@ from typing import Dict, List, Any
from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES
from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG, SYSTEM_VARIABLES
+from modules.shared.i18nRegistry import normalizePrimaryLanguageTag
logger = logging.getLogger(__name__)
def getNodeTypes(
services: Any = None,
- language: str = "en",
+ language: str = "de",
) -> List[Dict[str, Any]]:
"""
Return static node types. No dynamic I/O derivation from methodDiscovery.
@@ -25,27 +26,42 @@ def getNodeTypes(
return list(STATIC_NODE_TYPES)
+def _pickFromLangMap(d: Any, lang: str) -> Any:
+ """Resolve multilingual dict: ``lang`` → ``xx`` → ``de`` → ``en`` → first non-empty value."""
+ if not isinstance(d, dict) or not d:
+ return None
+ for k in (lang, "xx", "de", "en"):
+ v = d.get(k)
+ if v is not None and v != "":
+ return v
+ for v in d.values():
+ if v is not None and v != "":
+ return v
+ return None
+
+
def _localizeNode(node: Dict[str, Any], language: str) -> Dict[str, Any]:
"""Apply language to label/description/parameters. Keep inputPorts/outputPorts."""
- lang = language if language in ("en", "de", "fr") else "en"
+ lang = normalizePrimaryLanguageTag(language, "en")
out = dict(node)
for key in list(out.keys()):
if key.startswith("_"):
del out[key]
if isinstance(node.get("label"), dict):
- out["label"] = node["label"].get(lang, node["label"].get("en", str(node["label"])))
+ out["label"] = _pickFromLangMap(node["label"], lang) or node.get("id", "")
if isinstance(node.get("description"), dict):
- out["description"] = node["description"].get(lang, node["description"].get("en", str(node["description"])))
+ out["description"] = _pickFromLangMap(node["description"], lang) or ""
ol = node.get("outputLabels")
if isinstance(ol, dict) and ol:
first = next(iter(ol.values()), None)
if isinstance(first, (list, tuple)):
- out["outputLabels"] = ol.get(lang, ol.get("en", list(first)))
+ picked = _pickFromLangMap(ol, lang)
+ out["outputLabels"] = picked if picked is not None else list(first)
params = []
for p in node.get("parameters", []):
pc = dict(p)
if isinstance(p.get("description"), dict):
- pc["description"] = p["description"].get(lang, p["description"].get("en", str(p.get("description", ""))))
+ pc["description"] = _pickFromLangMap(p["description"], lang) or str(p.get("description", ""))
params.append(pc)
out["parameters"] = params
return out
@@ -53,7 +69,7 @@ def _localizeNode(node: Dict[str, Any], language: str) -> Dict[str, Any]:
def getNodeTypesForApi(
services: Any,
- language: str = "en",
+ language: str = "de",
) -> Dict[str, Any]:
"""
API-ready response: nodeTypes with localized strings, plus categories, portTypeCatalog, systemVariables.
diff --git a/modules/features/graphicalEditor/portTypes.py b/modules/features/graphicalEditor/portTypes.py
index c7d6e1dd..d9ae2792 100644
--- a/modules/features/graphicalEditor/portTypes.py
+++ b/modules/features/graphicalEditor/portTypes.py
@@ -14,6 +14,8 @@ from typing import Any, Callable, Dict, List, Optional
from pydantic import BaseModel, Field
+from modules.shared.i18nRegistry import resolveText
+
logger = logging.getLogger(__name__)
@@ -480,11 +482,9 @@ def _deriveFormPayloadSchema(node: Dict[str, Any]) -> Optional[PortSchema]:
for f in fields_param:
if isinstance(f, dict) and f.get("name"):
_lab = f.get("label")
- _desc = (
- str(_lab.get("xx") or next(iter(_lab.values()), "") or f["name"])
- if isinstance(_lab, dict)
- else str(_lab if _lab is not None else f["name"])
- )
+ _desc = resolveText(_lab) if _lab is not None else f["name"]
+ if not _desc.strip():
+ _desc = f["name"]
portFields.append(PortField(
name=f["name"],
type=f.get("type", "str"),
diff --git a/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py b/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py
index 91c907b5..7f2139e5 100644
--- a/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py
+++ b/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py
@@ -26,21 +26,12 @@ from modules.workflows.automation2.runEnvelope import (
normalize_run_envelope,
)
from modules.features.graphicalEditor.entryPoints import find_invocation
-from modules.shared.i18nRegistry import apiRouteContext
+from modules.shared.i18nRegistry import apiRouteContext, resolveText
routeApiMsg = apiRouteContext("routeFeatureGraphicalEditor")
logger = logging.getLogger(__name__)
-def _pickInvocationTitleLabel(title: Any, requestLang: Optional[str]) -> str:
- if isinstance(title, str):
- return title
- if isinstance(title, dict) and title:
- picked = (requestLang and title.get(requestLang)) or title.get("xx") or next(iter(title.values()), "")
- return str(picked) if picked else ""
- return ""
-
-
def _build_execute_run_envelope(
body: Dict[str, Any],
workflow: Optional[Dict[str, Any]],
@@ -80,7 +71,7 @@ def _build_execute_run_envelope(
}
trig = trig_map.get(kind, "manual")
title = inv.get("title") or {}
- label = _pickInvocationTitleLabel(title, requestLang)
+ label = resolveText(title)
base = default_run_envelope(
trig,
entry_point_id=inv.get("id"),
@@ -1052,10 +1043,7 @@ async def post_workflow_webhook(
discoverMethods(services)
title = inv.get("title") or {}
- label = _pickInvocationTitleLabel(
- title,
- getattr(context.user, "language", None) if context.user else None,
- )
+ label = resolveText(title)
pl = body if isinstance(body, dict) else {}
base = default_run_envelope(
"webhook",
@@ -1113,10 +1101,7 @@ async def post_workflow_form_submit(
discoverMethods(services)
title = inv.get("title") or {}
- label = _pickInvocationTitleLabel(
- title,
- getattr(context.user, "language", None) if context.user else None,
- )
+ label = resolveText(title)
pl = body if isinstance(body, dict) else {}
base = default_run_envelope(
"form",
diff --git a/modules/features/teamsbot/routeFeatureTeamsbot.py b/modules/features/teamsbot/routeFeatureTeamsbot.py
index c2823a85..d316bde2 100644
--- a/modules/features/teamsbot/routeFeatureTeamsbot.py
+++ b/modules/features/teamsbot/routeFeatureTeamsbot.py
@@ -1252,18 +1252,16 @@ async def postTranscript(
config = _getInstanceConfig(instanceId)
# Load original user context from session
- from modules.datamodels.datamodelUam import User
-
- systemUser = User(id="system", username="system", email="system@poweron.swiss")
- sessionInterface = interfaceDb.getInterface(systemUser, featureInstanceId=instanceId)
+ rootInterface = getRootInterface()
+ rootUser = rootInterface.currentUser
+ sessionInterface = interfaceDb.getInterface(rootUser, featureInstanceId=instanceId)
session = sessionInterface.getSession(sessionId)
mandateId = session.get("mandateId") if session else None
startedByUserId = session.get("startedByUserId") if session else None
- rootInterface = getRootInterface()
originalUser = rootInterface.getUser(startedByUserId) if startedByUserId else None
if not originalUser:
- originalUser = systemUser
+ originalUser = rootUser
# Process transcript through the service pipeline
from .service import TeamsbotService
@@ -1308,18 +1306,16 @@ async def postBotStatus(
try:
config = _getInstanceConfig(instanceId)
- from modules.datamodels.datamodelUam import User
-
- systemUser = User(id="system", username="system", email="system@poweron.swiss")
- sessionInterface = interfaceDb.getInterface(systemUser, featureInstanceId=instanceId)
+ rootInterface = getRootInterface()
+ rootUser = rootInterface.currentUser
+ sessionInterface = interfaceDb.getInterface(rootUser, featureInstanceId=instanceId)
session = sessionInterface.getSession(sessionId)
mandateId = session.get("mandateId") if session else None
startedByUserId = session.get("startedByUserId") if session else None
- rootInterface = getRootInterface()
originalUser = rootInterface.getUser(startedByUserId) if startedByUserId else None
if not originalUser:
- originalUser = systemUser
+ originalUser = rootUser
from .service import TeamsbotService
service = TeamsbotService(originalUser, mandateId, instanceId, config)
@@ -1361,22 +1357,20 @@ async def botWebsocket(
# Load the original user who started the session (has RBAC roles in mandate)
# Bot callbacks have no HTTP auth, so we reconstruct the user context from the session record.
- from modules.datamodels.datamodelUam import User
from modules.interfaces.interfaceDbApp import getRootInterface
- systemUser = User(id="system", username="system", email="system@poweron.swiss")
- sessionInterface = interfaceDb.getInterface(systemUser, featureInstanceId=instanceId)
+ rootInterface = getRootInterface()
+ rootUser = rootInterface.currentUser
+ sessionInterface = interfaceDb.getInterface(rootUser, featureInstanceId=instanceId)
session = sessionInterface.getSession(sessionId)
mandateId = session.get("mandateId") if session else None
startedByUserId = session.get("startedByUserId") if session else None
- # Look up the original user (getRootInterface uses admin context, can load any user)
- rootInterface = getRootInterface()
originalUser = rootInterface.getUser(startedByUserId) if startedByUserId else None
if not originalUser:
- logger.warning(f"Could not load original user {startedByUserId}, falling back to system user")
- originalUser = systemUser
+ logger.warning(f"Could not load original user {startedByUserId}, falling back to root user")
+ originalUser = rootUser
# Build effective config with the session's actual bot name.
# The session stores the resolved bot name (from system bot or user override).
diff --git a/modules/features/trustee/accounting/accountingRegistry.py b/modules/features/trustee/accounting/accountingRegistry.py
index 707d8fbb..b5ce6e80 100644
--- a/modules/features/trustee/accounting/accountingRegistry.py
+++ b/modules/features/trustee/accounting/accountingRegistry.py
@@ -10,7 +10,7 @@ import os
from typing import Dict, List, Optional
from .accountingConnectorBase import BaseAccountingConnector
-from modules.shared.i18nRegistry import t
+from modules.shared.i18nRegistry import resolveText
logger = logging.getLogger(__name__)
@@ -62,11 +62,11 @@ class AccountingRegistry:
fields = []
for f in connector.getRequiredConfigFields():
fd = f.model_dump()
- fd["label"] = t(f.label)
+ fd["label"] = resolveText(f.label)
fields.append(fd)
result.append({
"connectorType": connectorType,
- "label": t(connector.getConnectorLabel()),
+ "label": resolveText(connector.getConnectorLabel()),
"configFields": fields,
})
return result
diff --git a/modules/features/trustee/routeFeatureTrustee.py b/modules/features/trustee/routeFeatureTrustee.py
index 91de0b60..3233523b 100644
--- a/modules/features/trustee/routeFeatureTrustee.py
+++ b/modules/features/trustee/routeFeatureTrustee.py
@@ -151,12 +151,7 @@ def getQuickActions(
if role and role.roleLabel:
userRoleLabels.add(role.roleLabel)
- def _resolveText(multilingual, lang: str) -> str:
- if isinstance(multilingual, str):
- return multilingual
- if isinstance(multilingual, dict):
- return multilingual.get(lang) or multilingual.get("xx") or next(iter(multilingual.values()), "")
- return ""
+ from modules.shared.i18nRegistry import resolveText
filteredActions = []
for action in QUICK_ACTIONS:
@@ -166,8 +161,8 @@ def getQuickActions(
if context.hasSysAdminRole or required.intersection(userRoleLabels):
resolved = {
"id": action["id"],
- "label": _resolveText(action.get("label", {}), language),
- "description": _resolveText(action.get("description", {}), language),
+ "label": resolveText(action.get("label", {})),
+ "description": resolveText(action.get("description", {})),
"icon": action.get("icon", ""),
"color": action.get("color", ""),
"category": action.get("category", ""),
@@ -178,14 +173,14 @@ def getQuickActions(
if resolved["actionType"] == "agentPrompt" and "config" in resolved:
cfg = dict(resolved["config"])
if "uploadHint" in cfg:
- cfg["uploadHint"] = _resolveText(cfg["uploadHint"], language)
+ cfg["uploadHint"] = resolveText(cfg["uploadHint"])
resolved["config"] = cfg
filteredActions.append(resolved)
filteredActions.sort(key=lambda a: a["sortOrder"])
resolvedCategories = [
- {"id": c["id"], "label": _resolveText(c.get("label", {}), language), "sortOrder": c.get("sortOrder", 99)}
+ {"id": c["id"], "label": resolveText(c.get("label", {})), "sortOrder": c.get("sortOrder", 99)}
for c in QUICK_ACTION_CATEGORIES
]
diff --git a/modules/features/workspace/routeFeatureWorkspace.py b/modules/features/workspace/routeFeatureWorkspace.py
index 16235cd3..08216e56 100644
--- a/modules/features/workspace/routeFeatureWorkspace.py
+++ b/modules/features/workspace/routeFeatureWorkspace.py
@@ -29,7 +29,7 @@ from modules.interfaces.interfaceAiObjects import AiObjects
from modules.serviceCenter.core.serviceStreaming import get_event_manager
from modules.serviceCenter.services.serviceAgent.datamodelAgent import AgentEventTypeEnum, PendingFileEdit
from modules.shared.timeUtils import parseTimestamp
-from modules.shared.i18nRegistry import apiRouteContext, t
+from modules.shared.i18nRegistry import apiRouteContext, resolveText
routeApiMsg = apiRouteContext("routeFeatureWorkspace")
logger = logging.getLogger(__name__)
@@ -1467,7 +1467,7 @@ async def listFeatureConnectionTables(
node = {
"objectKey": obj.get("objectKey", ""),
"tableName": meta.get("table", ""),
- "label": t(obj.get("label", "")),
+ "label": resolveText(obj.get("label", "")),
"fields": meta.get("fields", []),
}
if meta.get("isParent"):
diff --git a/modules/interfaces/interfaceDbApp.py b/modules/interfaces/interfaceDbApp.py
index 7e0dd234..d4cb5b08 100644
--- a/modules/interfaces/interfaceDbApp.py
+++ b/modules/interfaces/interfaceDbApp.py
@@ -666,7 +666,7 @@ class AppObjects:
password: str = None,
email: str = None,
fullName: str = None,
- language: str = "en",
+ language: str = "de",
enabled: bool = True,
authenticationAuthority: AuthAuthority = AuthAuthority.LOCAL,
externalId: str = None,
diff --git a/modules/interfaces/interfaceFeatures.py b/modules/interfaces/interfaceFeatures.py
index bb9ead89..943acdb5 100644
--- a/modules/interfaces/interfaceFeatures.py
+++ b/modules/interfaces/interfaceFeatures.py
@@ -17,6 +17,7 @@ from modules.datamodels.datamodelFeatures import Feature, FeatureInstance
from modules.datamodels.datamodelRbac import Role, AccessRule
from modules.datamodels.datamodelUtils import coerce_text_multilingual
from modules.connectors.connectorDbPostgre import DatabaseConnector
+from modules.shared.i18nRegistry import resolveText
logger = logging.getLogger(__name__)
@@ -241,9 +242,9 @@ class FeatureInterface:
return 0
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface
- from modules.auth.authModels import SystemUser
- systemUser = SystemUser()
- geInterface = getGraphicalEditorInterface(systemUser, mandateId, instanceId)
+ from modules.interfaces.interfaceDbApp import getRootInterface
+ rootUser = getRootInterface().currentUser
+ geInterface = getGraphicalEditorInterface(rootUser, mandateId, instanceId)
copied = 0
for template in templateWorkflows:
@@ -251,11 +252,7 @@ class FeatureInterface:
graphJson = graphJson.replace("{{featureInstanceId}}", instanceId)
graph = json.loads(graphJson)
- labelDict = template.get("label", {})
- if isinstance(labelDict, dict):
- label = labelDict.get("xx") or next(iter(labelDict.values()), "") or str(labelDict)
- else:
- label = str(labelDict)
+ label = resolveText(template.get("label"))
geInterface.createWorkflow({
"label": label,
diff --git a/modules/routes/routeAdminFeatures.py b/modules/routes/routeAdminFeatures.py
index 13b47c28..37118426 100644
--- a/modules/routes/routeAdminFeatures.py
+++ b/modules/routes/routeAdminFeatures.py
@@ -27,24 +27,12 @@ from modules.interfaces.interfaceDbApp import getRootInterface
from modules.interfaces.interfaceFeatures import getFeatureInterface
from modules.security.rbacCatalog import getCatalogService
from modules.routes.routeNotifications import create_access_change_notification
-from modules.shared.i18nRegistry import apiRouteContext
+from modules.shared.i18nRegistry import apiRouteContext, resolveText
routeApiMsg = apiRouteContext("routeAdminFeatures")
logger = logging.getLogger(__name__)
-def _featureLabelPlain(label: Union[str, Dict[str, str], None], fallback: str, requestLang: Optional[str] = None) -> str:
- """Catalog feature label as a single display/i18n key string."""
- if isinstance(label, str) and label.strip():
- return label
- if isinstance(label, dict):
- picked = (requestLang and label.get(requestLang)) or label.get("xx") or next(iter(label.values()), "")
- if picked:
- return str(picked)
- return fallback
- return fallback
-
-
def _feature_instance_display_name(instance: Any) -> str:
if instance is None:
return ""
@@ -197,11 +185,7 @@ def get_my_feature_instances(
featureDef = catalogService.getFeatureDefinition(instance.featureCode)
featuresMap[featureKey] = {
"code": instance.featureCode,
- "label": _featureLabelPlain(
- featureDef.get("label") if featureDef else None,
- instance.featureCode,
- getattr(context.user, "language", None),
- ),
+ "label": resolveText(featureDef.get("label") if featureDef else None),
"icon": featureDef.get("icon", "folder") if featureDef else "folder",
"instances": [],
"_mandateId": mandateId # Temporary for grouping
diff --git a/modules/routes/routeAdminRbacRules.py b/modules/routes/routeAdminRbacRules.py
index 92ceaf18..9b756152 100644
--- a/modules/routes/routeAdminRbacRules.py
+++ b/modules/routes/routeAdminRbacRules.py
@@ -23,23 +23,12 @@ from modules.datamodels.datamodelRbac import AccessRuleContext, AccessRule, Role
from modules.datamodels.datamodelMembership import UserMandate
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
from modules.interfaces.interfaceDbApp import getInterface, getRootInterface
-from modules.shared.i18nRegistry import apiRouteContext, t, _getLanguage
+from modules.shared.i18nRegistry import apiRouteContext, t, resolveText
routeApiMsg = apiRouteContext("routeAdminRbacRules")
# Configure logger
logger = logging.getLogger(__name__)
-
-def _resolveTextMultilingual(value) -> str:
- """Resolve a TextMultilingual dict to a single string for the current request language.
- Falls back to xx (source text), then any available value."""
- if isinstance(value, str):
- return value
- if isinstance(value, dict):
- lang = _getLanguage()
- return value.get(lang) or value.get("xx") or next(iter(value.values()), "")
- return str(value) if value else ""
-
router = APIRouter(
prefix="/api/rbac",
tags=["RBAC"],
@@ -922,7 +911,7 @@ def list_roles(
result.append({
"id": role.id,
"roleLabel": role.roleLabel,
- "description": _resolveTextMultilingual(role.description),
+ "description": resolveText(role.description),
"mandateId": role.mandateId,
"featureInstanceId": role.featureInstanceId,
"featureCode": role.featureCode,
@@ -1051,7 +1040,7 @@ def get_roles_filter_values(
result.append({
"id": role.id,
"roleLabel": role.roleLabel,
- "description": _resolveTextMultilingual(role.description),
+ "description": resolveText(role.description),
"mandateId": role.mandateId,
"featureInstanceId": role.featureInstanceId,
"featureCode": role.featureCode,
@@ -1168,7 +1157,7 @@ def get_role(
return {
"id": role.id,
"roleLabel": role.roleLabel,
- "description": _resolveTextMultilingual(role.description),
+ "description": resolveText(role.description),
"mandateId": role.mandateId,
"featureInstanceId": role.featureInstanceId,
"featureCode": role.featureCode,
@@ -1369,12 +1358,8 @@ def getCatalogObjects(
def _resolveLabels(objects: list) -> list:
for obj in objects:
- raw = obj.get("label")
- if isinstance(raw, str):
- obj["label"] = t(raw)
- elif isinstance(raw, dict):
- key = raw.get("xx") or next(iter(raw.values()), "") or obj.get("objectKey", "")
- obj["label"] = t(key) if key else f"[{obj.get('objectKey', '?')}]"
+ resolved = resolveText(obj.get("label"))
+ obj["label"] = resolved if resolved else f"[{obj.get('objectKey', '?')}]"
return objects
if context:
diff --git a/modules/routes/routeAdminUserAccessOverview.py b/modules/routes/routeAdminUserAccessOverview.py
index 4b5e1212..59bf415f 100644
--- a/modules/routes/routeAdminUserAccessOverview.py
+++ b/modules/routes/routeAdminUserAccessOverview.py
@@ -24,24 +24,13 @@ from modules.datamodels.datamodelMembership import (
)
from modules.datamodels.datamodelFeatures import FeatureInstance, Feature
from modules.interfaces.interfaceDbApp import getRootInterface
-from modules.shared.i18nRegistry import apiRouteContext, t, _getLanguage
+from modules.shared.i18nRegistry import apiRouteContext, t, resolveText
routeApiMsg = apiRouteContext("routeAdminUserAccessOverview")
# Configure logger
logger = logging.getLogger(__name__)
-def _resolveTextMultilingual(value) -> str:
- """Resolve a TextMultilingual dict to a single string for the current request language.
- Falls back to xx (source text), then any available value."""
- if isinstance(value, str):
- return value
- if isinstance(value, dict):
- lang = _getLanguage()
- return value.get(lang) or value.get("xx") or next(iter(value.values()), "")
- return str(value) if value else ""
-
-
router = APIRouter(
prefix="/api/admin/user-access-overview",
tags=["Admin User Access Overview"],
@@ -309,7 +298,7 @@ def getUserAccessOverview(
roleInfo = {
"id": roleId,
"roleLabel": role.roleLabel,
- "description": _resolveTextMultilingual(role.description),
+ "description": resolveText(role.description),
"scope": scope,
"scopePriority": _getRoleScopePriority(scope),
"mandateId": role.mandateId,
@@ -345,7 +334,7 @@ def getUserAccessOverview(
# Get feature info using interface method
featureCode = instance.featureCode
feature = interface.getFeatureByCode(featureCode)
- featureLabel = t(feature.label) if feature and feature.label else ""
+ featureLabel = resolveText(feature.label) if feature and feature.label else ""
# Get roles for this FeatureAccess using interface method
instanceRoleIds = interface.getRoleIdsForFeatureAccess(faId)
@@ -359,7 +348,7 @@ def getUserAccessOverview(
roleInfo = {
"id": roleId,
"roleLabel": role.roleLabel,
- "description": _resolveTextMultilingual(role.description),
+ "description": resolveText(role.description),
"scope": scope,
"scopePriority": _getRoleScopePriority(scope),
"mandateId": role.mandateId,
diff --git a/modules/routes/routeDataUsers.py b/modules/routes/routeDataUsers.py
index 07c3d81b..28d11392 100644
--- a/modules/routes/routeDataUsers.py
+++ b/modules/routes/routeDataUsers.py
@@ -24,7 +24,7 @@ from modules.auth import limiter, getRequestContext, RequestContext
from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
-from modules.shared.i18nRegistry import apiRouteContext
+from modules.shared.i18nRegistry import apiRouteContext, resolveText
routeApiMsg = apiRouteContext("routeDataUsers")
# Configure logger
@@ -87,9 +87,9 @@ def _extractDistinctValues(
elif isinstance(val, (int, float)):
values.add(str(val))
elif isinstance(val, dict):
- text = (requestLang and val.get(requestLang)) or val.get("xx") or next(iter(val.values()), None)
+ text = resolveText(val, requestLang)
if text:
- values.add(str(text))
+ values.add(text)
else:
values.add(str(val))
return sorted(values, key=lambda v: v.lower())
@@ -606,7 +606,7 @@ class CreateUserRequest(BaseModel):
username: str
email: Optional[str] = None
fullName: Optional[str] = None
- language: str = "en"
+ language: str = "de"
enabled: bool = True
isSysAdmin: bool = False
password: Optional[str] = None
diff --git a/modules/routes/routeI18n.py b/modules/routes/routeI18n.py
index 070d9c90..41e9645c 100644
--- a/modules/routes/routeI18n.py
+++ b/modules/routes/routeI18n.py
@@ -508,6 +508,24 @@ async def list_language_codes():
return sorted(out, key=lambda x: (not x.get("isDefault"), x["code"]))
+@router.get("/user-language-options")
+async def list_user_language_options():
+ """Select options for User.language: all UiLanguageSets except ``xx`` (basis set).
+
+ Returns ``[{ \"value\": code, \"label\": name }, ...]`` for FormGenerator ``frontend_options`` URL.
+ """
+ db = _publicMgmtDb()
+ rows = db.getRecordset(UiLanguageSet)
+ out: List[Dict[str, str]] = []
+ for r in rows:
+ code = r.get("id")
+ if not code or code == "xx":
+ continue
+ lbl = (r.get("label") or "").strip() or code
+ out.append({"value": code, "label": lbl})
+ return sorted(out, key=lambda x: (x.get("label") or x["value"]).lower())
+
+
@router.get("/sets/{code}")
async def get_language_set(code: str):
db = _publicMgmtDb()
diff --git a/modules/routes/routeStore.py b/modules/routes/routeStore.py
index d60bd1df..04517d9b 100644
--- a/modules/routes/routeStore.py
+++ b/modules/routes/routeStore.py
@@ -23,23 +23,11 @@ from modules.interfaces.interfaceFeatures import getFeatureInterface
from modules.security.rbacCatalog import getCatalogService
from modules.security.rbac import RbacClass
from modules.security.rootAccess import getRootDbAppConnector
-from modules.shared.i18nRegistry import apiRouteContext
+from modules.shared.i18nRegistry import apiRouteContext, resolveText
routeApiMsg = apiRouteContext("routeStore")
logger = logging.getLogger(__name__)
-
-def _storeLabelText(label: Union[str, Dict[str, str], None], fallback: str, requestLang: Optional[str] = None) -> str:
- """Normalize catalog label to a single display/i18n key string."""
- if isinstance(label, str) and label.strip():
- return label
- if isinstance(label, dict):
- picked = (requestLang and label.get(requestLang)) or label.get("xx") or next(iter(label.values()), "")
- if picked:
- return str(picked)
- return fallback
- return fallback
-
router = APIRouter(
prefix="/api/store",
tags=["Store"],
@@ -301,9 +289,9 @@ def listStoreFeatures(
instances = _getUserInstancesForFeature(db, userId, featureCode, userMandateIds)
result.append(StoreFeatureResponse(
featureCode=featureCode,
- label=_storeLabelText(featureDef.get("label"), featureCode, getattr(context.user, "language", None)),
+ label=resolveText(featureDef.get("label")),
icon=featureDef.get("icon", "mdi-puzzle"),
- description=_storeLabelText(featureDef.get("description"), "", getattr(context.user, "language", None)),
+ description=resolveText(featureDef.get("description")),
instances=instances,
canActivate=True,
))
@@ -391,7 +379,7 @@ def activateStoreFeature(
# ── 3. Provision instance ───────────────────────────────────────
featureInterface = getFeatureInterface(db)
- featureLabel = _storeLabelText(featureDef.get("label"), featureCode, getattr(context.user, "language", None))
+ featureLabel = resolveText(featureDef.get("label"))
instance = featureInterface.createFeatureInstance(
featureCode=featureCode,
mandateId=mandateId,
diff --git a/modules/serviceCenter/services/serviceAgent/featureDataAgent.py b/modules/serviceCenter/services/serviceAgent/featureDataAgent.py
index d1726fcd..43dbb9d7 100644
--- a/modules/serviceCenter/services/serviceAgent/featureDataAgent.py
+++ b/modules/serviceCenter/services/serviceAgent/featureDataAgent.py
@@ -21,6 +21,7 @@ from modules.serviceCenter.services.serviceAgent.datamodelAgent import (
)
from modules.serviceCenter.services.serviceAgent.toolRegistry import ToolRegistry
from modules.serviceCenter.services.serviceAgent.featureDataProvider import FeatureDataProvider
+from modules.shared.i18nRegistry import resolveText
logger = logging.getLogger(__name__)
@@ -305,12 +306,7 @@ def _buildSchemaContext(
meta = obj.get("meta", {})
tbl = meta.get("table", "?")
fields = meta.get("fields", [])
- label = obj.get("label", {})
- if isinstance(label, dict):
- picked = (requestLang and label.get(requestLang)) or label.get("xx") or next(iter(label.values()), "")
- labelStr = str(picked) if picked else tbl
- else:
- labelStr = str(label).strip() if isinstance(label, str) and str(label).strip() else tbl
+ labelStr = resolveText(obj.get("label"), requestLang)
tableNames.append(tbl)
block = f" Table: {tbl} ({labelStr})"
if fields:
diff --git a/modules/serviceCenter/services/serviceAi/subStructureFilling.py b/modules/serviceCenter/services/serviceAi/subStructureFilling.py
index 3795f44d..b31bc32d 100644
--- a/modules/serviceCenter/services/serviceAi/subStructureFilling.py
+++ b/modules/serviceCenter/services/serviceAi/subStructureFilling.py
@@ -2051,7 +2051,7 @@ class StructureFiller:
contentPartInstructions: Dict[str, Any],
contentParts: List[ContentPart],
userPrompt: str,
- language: str = "en",
+ language: str = "de",
outputFormat: str = "txt"
) -> str:
"""Baue Prompt für Chapter-Sections-Struktur-Generierung, querying renderer for accepted section types."""
@@ -2187,7 +2187,7 @@ Return only valid JSON. Do not include any explanatory text outside the JSON.
allSections: Optional[List[Dict[str, Any]]] = None,
sectionIndex: Optional[int] = None,
isAggregation: bool = False,
- language: str = "en",
+ language: str = "de",
outputFormat: str = "txt",
preExtractedText: Optional[str] = None
) -> tuple[str, str]:
diff --git a/modules/serviceCenter/services/serviceAi/subStructureGeneration.py b/modules/serviceCenter/services/serviceAi/subStructureGeneration.py
index 72127c92..3d531756 100644
--- a/modules/serviceCenter/services/serviceAi/subStructureGeneration.py
+++ b/modules/serviceCenter/services/serviceAi/subStructureGeneration.py
@@ -14,6 +14,7 @@ from typing import Dict, Any, List, Optional
from modules.datamodels.datamodelExtraction import ContentPart
from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum, PriorityEnum, ProcessingModeEnum
from modules.workflows.processing.shared.stateTools import checkWorkflowStopped
+from modules.shared.i18nRegistry import normalizePrimaryLanguageTag
logger = logging.getLogger(__name__)
@@ -262,18 +263,17 @@ CRITICAL:
# Validation 3.5 & 3.6: Document language
# Use validated currentUserLanguage (always valid, validated during user intention analysis)
# Access via _getUserLanguage() which uses self.services.currentUserLanguage
- userPromptLanguage = self._getUserLanguage() # Uses validated currentUserLanguage infrastructure
-
- if "language" not in doc or not isinstance(doc["language"], str) or len(doc["language"]) != 2:
- # AI didn't return language or invalid format - use validated currentUserLanguage
+ userPromptLanguage = normalizePrimaryLanguageTag(self._getUserLanguage(), "de")
+
+ raw_lang = doc.get("language")
+ if not isinstance(raw_lang, str) or not str(raw_lang).strip():
doc["language"] = userPromptLanguage
if "language" not in doc:
logger.warning(f"Document {doc.get('id')} missing language - using currentUserLanguage: {userPromptLanguage}")
else:
- logger.warning(f"Document {doc.get('id')} has invalid language format from AI: {doc['language']}, using currentUserLanguage")
+ logger.warning(f"Document {doc.get('id')} has invalid language format from AI: {doc.get('language')}, using currentUserLanguage")
else:
- # AI returned valid language format - normalize
- doc["language"] = doc["language"].lower().strip()[:2]
+ doc["language"] = normalizePrimaryLanguageTag(str(raw_lang), userPromptLanguage)
logger.debug(f"Document {doc.get('id')} using AI-determined language: {doc['language']}")
# Validation 3.7: Document missing 'chapters' field
diff --git a/modules/shared/attributeUtils.py b/modules/shared/attributeUtils.py
index b36addf5..898ca85f 100644
--- a/modules/shared/attributeUtils.py
+++ b/modules/shared/attributeUtils.py
@@ -45,77 +45,53 @@ def _getModelLabelEntry(modelName: str) -> Dict[str, Any]:
return i18nModelLabels.get(modelName) or {}
-def getModelLabels(modelName: str, language: str = "en") -> Dict[str, str]:
+def getModelLabels(modelName: str) -> Dict[str, str]:
"""Get labels for a model's attributes in the specified language.
- Reads @i18nModel registration (German base strings); non-German languages use the i18n cache.
- Attribute values are strings; dict-shaped entries are still accepted for unusual callers.
+ Reads @i18nModel registration (German base strings); resolves via resolveText().
"""
modelData = _getModelLabelEntry(modelName)
attributeLabels = modelData.get("attributes", {})
+ from modules.shared.i18nRegistry import resolveText
result: Dict[str, str] = {}
for attr, translations in attributeLabels.items():
- if isinstance(translations, dict):
- germanKey = translations.get("xx") or next(iter(translations.values()), attr)
- result[attr] = _resolveLabel(germanKey, language)
- elif isinstance(translations, str):
- result[attr] = _resolveLabel(translations, language)
- else:
- result[attr] = f"[{attr}]"
+ resolved = resolveText(translations)
+ result[attr] = resolved if resolved else f"[{attr}]"
return result
-def _resolveLabel(germanText: str, language: str) -> str:
- """Resolve a German base label to the requested language via i18n cache.
- Returns [germanText] when no translation exists so missing keys are visible in the UI."""
- if language == "de":
- return germanText
- try:
- from modules.shared.i18nRegistry import _CACHE
- return _CACHE.get(language, {}).get(germanText, f"[{germanText}]")
- except ImportError:
- return germanText
-
-
-def _resolveOptionLabels(options, userLanguage: str):
- """Resolve frontend_options label values to the requested language."""
+def _resolveOptionLabels(options):
+ """Resolve frontend_options label values via resolveText()."""
if not isinstance(options, list):
return options
+ from modules.shared.i18nRegistry import resolveText
for opt in options:
if not isinstance(opt, dict) or "label" not in opt:
continue
- raw = opt["label"]
- if isinstance(raw, dict):
- germanKey = raw.get("xx") or next(iter(raw.values()), str(opt.get("value", "")))
- opt["label"] = _resolveLabel(germanKey, userLanguage)
- elif isinstance(raw, str):
- opt["label"] = _resolveLabel(raw, userLanguage)
+ opt["label"] = resolveText(opt["label"])
return options
-def _mergedAttributeLabels(modelClass: Type[BaseModel], userLanguage: str) -> Dict[str, str]:
+def _mergedAttributeLabels(modelClass: Type[BaseModel]) -> Dict[str, str]:
"""Merge attribute labels from model MRO (base classes first, subclass overrides)."""
try:
baseIdx = modelClass.__mro__.index(BaseModel)
except ValueError:
- return getModelLabels(modelClass.__name__, userLanguage)
+ return getModelLabels(modelClass.__name__)
merged: Dict[str, str] = {}
for cls in reversed(modelClass.__mro__[:baseIdx]):
- merged.update(getModelLabels(cls.__name__, userLanguage))
+ merged.update(getModelLabels(cls.__name__))
return merged
-def getModelLabel(modelName: str, language: str = "de") -> str:
- """Get the label for a model in the specified language (see getModelLabels)."""
+def getModelLabel(modelName: str) -> str:
+ """Get the label for a model via resolveText()."""
modelData = _getModelLabelEntry(modelName)
modelLabel = modelData.get("model", {})
- if isinstance(modelLabel, dict):
- germanKey = modelLabel.get("xx") or next(iter(modelLabel.values()), modelName)
- return _resolveLabel(germanKey, language)
- elif isinstance(modelLabel, str):
- return _resolveLabel(modelLabel, language)
- return f"[{modelName}]"
+ from modules.shared.i18nRegistry import resolveText
+ resolved = resolveText(modelLabel)
+ return resolved if resolved else f"[{modelName}]"
def getModelAttributeDefinitions(modelClass: Type[BaseModel] = None, userLanguage: str = "en") -> Dict[str, Any]:
@@ -134,8 +110,8 @@ def getModelAttributeDefinitions(modelClass: Type[BaseModel] = None, userLanguag
attributes = []
model_name = modelClass.__name__
- labels = _mergedAttributeLabels(modelClass, userLanguage)
- model_label = getModelLabel(model_name, userLanguage)
+ labels = _mergedAttributeLabels(modelClass)
+ model_label = getModelLabel(model_name)
# Pydantic v2 only
fields = modelClass.model_fields
@@ -273,7 +249,7 @@ def getModelAttributeDefinitions(modelClass: Type[BaseModel] = None, userLanguag
"visible": frontend_visible,
"order": len(attributes),
"readonly": frontend_readonly,
- "options": _resolveOptionLabels(frontend_options, userLanguage),
+ "options": _resolveOptionLabels(frontend_options),
"default": field_default,
}
diff --git a/modules/shared/i18nRegistry.py b/modules/shared/i18nRegistry.py
index 7b8c2baf..f681b19f 100644
--- a/modules/shared/i18nRegistry.py
+++ b/modules/shared/i18nRegistry.py
@@ -80,6 +80,50 @@ def t(key: str, context: str = "api", value: str = "") -> str:
return _CACHE.get(lang, {}).get(key, f"[{key}]")
+def resolveText(value: Any, lang: Optional[str] = None) -> str:
+ """Resolve any value to a translated string for the current request language.
+
+ Accepts str, dict, TextMultilingual, or None.
+ - str: translate via t() (treats as i18n key / German plaintext key)
+ - dict: multilingual user content — pick ``lang`` (or current context), then ``xx``, then first value
+ - object with model_dump(): convert to dict first (TextMultilingual)
+ - None/empty: return ""
+
+ If ``lang`` is given, it temporarily overrides the context language for this call
+ (used by schedulers that have an explicit user language).
+
+ Missing i18n translations for string keys use t()'s ``[key]`` fallback.
+ """
+ if lang is not None:
+ token = _CURRENT_LANGUAGE.set(lang)
+ try:
+ return _resolveTextImpl(value)
+ finally:
+ _CURRENT_LANGUAGE.reset(token)
+ return _resolveTextImpl(value)
+
+
+def _resolveTextImpl(value: Any) -> str:
+ if value is None:
+ return ""
+ if isinstance(value, str):
+ if not value.strip():
+ return ""
+ return t(value)
+ if hasattr(value, "model_dump"):
+ value = value.model_dump()
+ if isinstance(value, dict):
+ if not value:
+ return ""
+ lang = _CURRENT_LANGUAGE.get()
+ text = value.get(lang) or value.get("xx")
+ if text:
+ return str(text)
+ first = next((v for v in value.values() if v), None)
+ return str(first) if first else ""
+ return str(value)
+
+
def apiRouteContext(routeModuleName: str):
"""Return a callable that registers + translates HTTPException details.
@@ -155,6 +199,23 @@ def _getLanguage() -> str:
return _CURRENT_LANGUAGE.get()
+def normalizePrimaryLanguageTag(tag: str, fallback: str = "de") -> str:
+ """Primary language subtag from ``Accept-Language`` or a single BCP47 tag.
+
+ Supports 2-letter (ISO 639-1) and 3-letter (ISO 639-2/3) primaries such as ``gsw``.
+ Strips region/variant: ``de-CH`` → ``de``, ``zh-Hans-CN`` → ``zh``.
+ """
+ if not tag or not isinstance(tag, str):
+ return fallback
+ first = tag.split(",")[0].split(";")[0].strip()
+ if not first:
+ return fallback
+ primary = first.split("-")[0].split("_")[0].lower()
+ if primary.isalpha() and 2 <= len(primary) <= 8:
+ return primary
+ return fallback
+
+
# ---------------------------------------------------------------------------
# Boot: scan route files for routeApiMsg("…") calls → register eagerly
# ---------------------------------------------------------------------------
@@ -270,15 +331,10 @@ def _registerFeatureUiLabels():
_REGISTRY[fl] = _I18nRegistryEntry(context="nav", value="")
added += 1
for uiObj in getattr(mod, "UI_OBJECTS", []) or []:
- lab = uiObj.get("label")
- if isinstance(lab, str) and lab and lab not in _REGISTRY:
- _REGISTRY[lab] = _I18nRegistryEntry(context="nav", value="")
+ base = _extractRegistrySourceText(uiObj.get("label"))
+ if base and base not in _REGISTRY:
+ _REGISTRY[base] = _I18nRegistryEntry(context="nav", value="")
added += 1
- elif isinstance(lab, dict):
- base = lab.get("xx") or next(iter(lab.values()), "")
- 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)
diff --git a/modules/workflows/automation2/subAutomation2Schedule.py b/modules/workflows/automation2/subAutomation2Schedule.py
index 01f1efe8..3f42d8f6 100644
--- a/modules/workflows/automation2/subAutomation2Schedule.py
+++ b/modules/workflows/automation2/subAutomation2Schedule.py
@@ -9,6 +9,7 @@ import logging
from typing import Any, Dict, Optional
from modules.shared.eventManagement import eventManager
+from modules.shared.i18nRegistry import resolveText
# Main loop reference for scheduling async work from job executor (may run in thread)
_main_loop = None
@@ -214,13 +215,7 @@ def _create_schedule_handler(
title = (inv or {}).get("title") or {}
requestLang: Optional[str] = getattr(event_user, "language", None)
- if isinstance(title, dict):
- picked = (requestLang and title.get(requestLang)) or title.get("xx") or next(iter(title.values()), "")
- label = str(picked) if picked else ""
- elif isinstance(title, str):
- label = title
- else:
- label = ""
+ label = resolveText(title, requestLang) if title else ""
run_env = default_run_envelope(
"schedule",
diff --git a/modules/workflows/methods/methodFile/actions/create.py b/modules/workflows/methods/methodFile/actions/create.py
index 73816da0..b9aafcd3 100644
--- a/modules/workflows/methods/methodFile/actions/create.py
+++ b/modules/workflows/methods/methodFile/actions/create.py
@@ -7,6 +7,7 @@ from typing import Dict, Any
from modules.datamodels.datamodelChat import ActionResult, ActionDocument
from modules.serviceCenter.services.serviceGeneration.subDocumentUtility import markdownToDocumentJson
+from modules.shared.i18nRegistry import normalizePrimaryLanguageTag
logger = logging.getLogger(__name__)
@@ -84,7 +85,10 @@ async def create(self, parameters: Dict[str, Any]) -> ActionResult:
outputFormat = (parameters.get("outputFormat") or "docx").strip().lower().lstrip(".")
title = (parameters.get("title") or "Document").strip()
templateName = parameters.get("templateName")
- language = (parameters.get("language") or "de").strip()[:2]
+ language = normalizePrimaryLanguageTag(
+ str(parameters.get("language") or "de"),
+ "de",
+ )
try:
structured_content = markdownToDocumentJson(context, title, language)
diff --git a/modules/workflows/scheduler/mainScheduler.py b/modules/workflows/scheduler/mainScheduler.py
index c869bb69..210784a2 100644
--- a/modules/workflows/scheduler/mainScheduler.py
+++ b/modules/workflows/scheduler/mainScheduler.py
@@ -14,6 +14,7 @@ import logging
from typing import Any, Dict, Optional
from modules.shared.eventManagement import eventManager
+from modules.shared.i18nRegistry import resolveText
logger = logging.getLogger(__name__)
@@ -232,13 +233,7 @@ class WorkflowScheduler:
title = (inv or {}).get("title") or {}
requestLang: Optional[str] = getattr(eventUser, "language", None)
- if isinstance(title, dict):
- picked = (requestLang and title.get(requestLang)) or title.get("xx") or next(iter(title.values()), "")
- label = str(picked) if picked else ""
- elif isinstance(title, str):
- label = title
- else:
- label = ""
+ label = resolveText(title, requestLang) if title else ""
runEnv = default_run_envelope(
"schedule",