fixed sysuser and removed redundant fallbacks

This commit is contained in:
ValueOn AG 2026-04-11 22:23:41 +02:00
parent 4dfc0afd06
commit 3adbd1da29
31 changed files with 276 additions and 292 deletions

4
app.py
View file

@ -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)

View file

@ -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 (28 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,

View file

@ -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("")

View file

@ -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):

View file

@ -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}

View file

@ -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:

View file

@ -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

View file

@ -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"

View file

@ -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.

View file

@ -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"),

View file

@ -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",

View file

@ -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).

View file

@ -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

View file

@ -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
]

View file

@ -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"):

View file

@ -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,

View file

@ -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,

View file

@ -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

View file

@ -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:

View file

@ -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,

View file

@ -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

View file

@ -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()

View file

@ -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,

View file

@ -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:

View file

@ -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]:

View file

@ -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
userPromptLanguage = normalizePrimaryLanguageTag(self._getUserLanguage(), "de")
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
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

View file

@ -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,
}

View file

@ -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)

View file

@ -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",

View file

@ -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)

View file

@ -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",