From 4dfc0afd0602fb1ef684b155916d50e0feb6a220 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Sat, 11 Apr 2026 19:44:58 +0200
Subject: [PATCH] fixed language logic items
---
modules/datamodels/datamodelUtils.py | 88 ++--
.../commcoach/interfaceFeatureCommcoach.py | 17 +-
modules/features/commcoach/mainCommcoach.py | 2 +-
.../commcoach/serviceCommcoachGamification.py | 17 +-
.../features/graphicalEditor/entryPoints.py | 5 +-
modules/features/graphicalEditor/portTypes.py | 2 +-
.../routeFeatureGraphicalEditor.py | 41 +-
.../accounting/accountingConnectorBase.py | 8 +-
.../trustee/accounting/accountingRegistry.py | 10 +-
.../connectors/accountingConnectorAbacus.py | 2 +-
.../connectors/accountingConnectorBexio.py | 2 +-
.../connectors/accountingConnectorRma.py | 2 +-
.../features/trustee/routeFeatureTrustee.py | 2 +-
.../workspace/routeFeatureWorkspace.py | 83 ++--
modules/interfaces/interfaceDbApp.py | 4 +-
modules/interfaces/interfaceDbChat.py | 22 +-
modules/interfaces/interfaceFeatures.py | 5 +-
.../migration/seedData/ui_language_seed.json | 460 ++++++++++++++++++
modules/routes/routeAdminFeatures.py | 15 +-
modules/routes/routeAdminRbacRules.py | 41 +-
.../routes/routeAdminUserAccessOverview.py | 33 +-
modules/routes/routeDataFiles.py | 2 +
modules/routes/routeDataMandates.py | 18 +-
modules/routes/routeDataUsers.py | 17 +-
modules/routes/routeI18n.py | 70 ++-
modules/routes/routeStore.py | 15 +-
modules/security/rbacCatalog.py | 12 +-
.../serviceAgent/actionToolAdapter.py | 49 +-
.../coreTools/_connectionTools.py | 6 +-
.../coreTools/_featureSubAgentTools.py | 6 +
.../services/serviceAgent/featureDataAgent.py | 11 +-
.../services/serviceChat/mainServiceChat.py | 40 +-
.../serviceKnowledge/mainServiceKnowledge.py | 11 +-
modules/shared/attributeUtils.py | 35 +-
modules/shared/i18nRegistry.py | 84 ++--
.../automation2/subAutomation2Schedule.py | 9 +-
modules/workflows/scheduler/mainScheduler.py | 9 +-
37 files changed, 978 insertions(+), 277 deletions(-)
diff --git a/modules/datamodels/datamodelUtils.py b/modules/datamodels/datamodelUtils.py
index d187687e..e9b519d4 100644
--- a/modules/datamodels/datamodelUtils.py
+++ b/modules/datamodels/datamodelUtils.py
@@ -2,6 +2,7 @@
# All rights reserved.
"""Utility datamodels: Prompt, TextMultilingual."""
+import re as _re
from typing import Any, Dict, Optional
from pydantic import BaseModel, Field, field_validator
from modules.datamodels.datamodelBase import PowerOnModel
@@ -39,68 +40,95 @@ class Prompt(PowerOnModel):
@field_validator('isSystem', mode='before')
@classmethod
def _coerceIsSystem(cls, v):
- """Existing records may have isSystem=None (field didn't exist). Treat None as False."""
if v is None:
return False
return v
class TextMultilingual(BaseModel):
- """Multilingual text field. Language codes follow ISO 639-1 (en, de, fr, it, …)."""
- en: str = Field(description="English text (default language, required)")
- de: Optional[str] = Field(None, description="German text")
- fr: Optional[str] = Field(None, description="French text")
- it: Optional[str] = Field(None, description="Italian text")
+ """Multilingual text field stored as JSONB: {"xx": "source text", "de": "...", "en": "...", ...}.
- @field_validator('en')
+ - xx = source/default text (required). Same role as xx in the UI i18n system.
+ - All language codes (de, en, fr, ...) are dynamic, populated via batch translation.
+ - No hardcoded language fields. The DB column is JSONB with arbitrary keys.
+ """
+
+ model_config = {"extra": "allow"}
+
+ xx: str = Field(description="Source/default text (required)")
+
+ @field_validator('xx')
@classmethod
- def _validateEnRequired(cls, v):
+ def _validateXxRequired(cls, v):
if not v or not v.strip():
- raise ValueError("English text (en) is required and cannot be empty")
+ raise ValueError("Source text (xx) is required and cannot be empty")
return v
def model_dump(self, **kwargs) -> Dict[str, str]:
- result = {}
- for key in self.model_fields:
- value = getattr(self, key, None)
- if value is not None:
- result[key] = value
+ result = {"xx": self.xx}
+ if self.__pydantic_extra__:
+ for k, v in self.__pydantic_extra__.items():
+ if v is not None and isinstance(v, str):
+ result[k] = v
return result
@classmethod
def from_dict(cls, data: Dict[str, str]) -> 'TextMultilingual':
- fields = {k: data[k] for k in cls.model_fields if k in data}
- fields.setdefault('en', '')
- return cls(**fields)
+ cleaned = {k: v for k, v in data.items() if v is not None and isinstance(v, str)}
+ if not cleaned.get('xx'):
+ cleaned['xx'] = cleaned.get('de') or next((v for v in cleaned.values() if v), '—')
+ return cls(**cleaned)
- def get_text(self, lang: str = 'en') -> str:
- """Get text for *lang*. Falls back to English."""
- value = getattr(self, lang, None)
- if value:
+ def get_text(self, lang: str = 'de') -> str:
+ """Get text for a language. Falls back to xx (source text)."""
+ if lang == 'xx':
+ return self.xx
+ extra = self.__pydantic_extra__ or {}
+ value = extra.get(lang)
+ if value and isinstance(value, str):
return value
- return self.en
+ return self.xx
@classmethod
def fromUniform(cls, text: str) -> "TextMultilingual":
- """Same string in all languages (bootstrap / i18n key until per-language values exist in DB)."""
+ """Create with source text only. Languages are populated by batch translation."""
t = text.strip()
if not t:
raise ValueError("Text must be non-empty")
- return cls(en=t, de=t, fr=t, it=t)
+ 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 for Role.description and similar fields."""
+ """Normalize str, dict, or TextMultilingual into a valid TextMultilingual instance."""
if isinstance(val, TextMultilingual):
return val
if isinstance(val, dict):
if not val:
return TextMultilingual.fromUniform("—")
- d = {k: val[k] for k in TextMultilingual.model_fields if k in val and val[k] is not None}
- if not d.get("en"):
- d["en"] = (d.get("de") or d.get("fr") or "—").strip() or "—"
- return TextMultilingual(**{k: d[k] for k in TextMultilingual.model_fields if k in d})
+ cleaned = {k: v for k, v in val.items() if v is not None and isinstance(v, str)}
+ if not cleaned.get("xx"):
+ 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)
return TextMultilingual.fromUniform("—")
-
diff --git a/modules/features/commcoach/interfaceFeatureCommcoach.py b/modules/features/commcoach/interfaceFeatureCommcoach.py
index 825fca5d..d4faa18d 100644
--- a/modules/features/commcoach/interfaceFeatureCommcoach.py
+++ b/modules/features/commcoach/interfaceFeatureCommcoach.py
@@ -13,6 +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 .datamodelCommcoach import (
CoachingContext, CoachingContextStatus,
@@ -412,9 +413,17 @@ def _calcGoalProgress(goalsRaw) -> Optional[int]:
return round(done / len(goals) * 100)
+_LEVELS = [
+ (50, 5, "master", t("Meister")),
+ (25, 4, "expert", t("Experte")),
+ (10, 3, "advanced", t("Fortgeschritten")),
+ (3, 2, "engaged", t("Engagiert")),
+]
+t("Einsteiger")
+
+
def _calcLevel(totalSessions: int) -> Dict[str, Any]:
- levels = [(50, 5, "Meister"), (25, 4, "Experte"), (10, 3, "Fortgeschritten"), (3, 2, "Engagiert")]
- for threshold, number, label in levels:
+ for threshold, number, code, _label in _LEVELS:
if totalSessions >= threshold:
- return {"number": number, "label": label, "totalSessions": totalSessions}
- return {"number": 1, "label": "Einsteiger", "totalSessions": totalSessions}
+ return {"number": number, "code": code, "label": t(_label), "totalSessions": totalSessions}
+ return {"number": 1, "code": "beginner", "label": t("Einsteiger"), "totalSessions": totalSessions}
diff --git a/modules/features/commcoach/mainCommcoach.py b/modules/features/commcoach/mainCommcoach.py
index 3b4f66c1..90789ec6 100644
--- a/modules/features/commcoach/mainCommcoach.py
+++ b/modules/features/commcoach/mainCommcoach.py
@@ -22,7 +22,7 @@ UI_OBJECTS = [
},
{
"objectKey": "ui.feature.commcoach.coaching",
- "label": "Coaching & Dossier",
+ "label": "Arbeitsthemen",
"meta": {"area": "coaching"}
},
{
diff --git a/modules/features/commcoach/serviceCommcoachGamification.py b/modules/features/commcoach/serviceCommcoachGamification.py
index 5b8d5eb6..5aa796b7 100644
--- a/modules/features/commcoach/serviceCommcoachGamification.py
+++ b/modules/features/commcoach/serviceCommcoachGamification.py
@@ -7,6 +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
logger = logging.getLogger(__name__)
@@ -78,6 +79,11 @@ 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"])
+
async def checkAndAwardBadges(interface, userId: str, mandateId: str, instanceId: str,
session: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]:
@@ -135,8 +141,8 @@ async def checkAndAwardBadges(interface, userId: str, mandateId: str, instanceId
}
newBadge = interface.awardBadge(badgeData)
definition = BADGE_DEFINITIONS.get(badgeKey, {})
- newBadge["label"] = definition.get("label", badgeKey)
- newBadge["description"] = definition.get("description", "")
+ newBadge["label"] = t(definition.get("label", badgeKey))
+ newBadge["description"] = t(definition.get("description", ""))
newBadge["icon"] = definition.get("icon", "star")
awarded.append(newBadge)
logger.info(f"Badge '{badgeKey}' awarded to user {userId}")
@@ -145,5 +151,8 @@ async def checkAndAwardBadges(interface, userId: str, mandateId: str, instanceId
def getBadgeDefinitions() -> Dict[str, Dict[str, Any]]:
- """Return all badge definitions for the frontend."""
- return BADGE_DEFINITIONS
+ """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"])}
+ return resolved
diff --git a/modules/features/graphicalEditor/entryPoints.py b/modules/features/graphicalEditor/entryPoints.py
index 07129545..c63ada70 100644
--- a/modules/features/graphicalEditor/entryPoints.py
+++ b/modules/features/graphicalEditor/entryPoints.py
@@ -36,9 +36,10 @@ def default_manual_entry_point() -> Dict[str, Any]:
}
-def _normalize_title(title: Any) -> str:
+def _normalize_title(title: Any, preferredLang: Optional[str] = None) -> str:
if isinstance(title, dict):
- return str(title.get("de") or title.get("en") or title.get("fr") or "").strip()
+ 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 ""
if isinstance(title, str) and title.strip():
return title.strip()
return "Start"
diff --git a/modules/features/graphicalEditor/portTypes.py b/modules/features/graphicalEditor/portTypes.py
index 7de0e6fd..c7d6e1dd 100644
--- a/modules/features/graphicalEditor/portTypes.py
+++ b/modules/features/graphicalEditor/portTypes.py
@@ -481,7 +481,7 @@ def _deriveFormPayloadSchema(node: Dict[str, Any]) -> Optional[PortSchema]:
if isinstance(f, dict) and f.get("name"):
_lab = f.get("label")
_desc = (
- str(_lab.get("de") or _lab.get("en") or f["name"])
+ 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"])
)
diff --git a/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py b/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py
index c347f622..91c907b5 100644
--- a/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py
+++ b/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py
@@ -32,10 +32,20 @@ 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]],
user_id: Optional[str],
+ requestLang: Optional[str] = None,
) -> Dict[str, Any]:
"""Build normalized run envelope from POST /execute body."""
if isinstance(body.get("runEnvelope"), dict):
@@ -70,11 +80,7 @@ def _build_execute_run_envelope(
}
trig = trig_map.get(kind, "manual")
title = inv.get("title") or {}
- label = ""
- if isinstance(title, dict):
- label = title.get("en") or title.get("de") or ""
- elif isinstance(title, str):
- label = title
+ label = _pickInvocationTitleLabel(title, requestLang)
base = default_run_envelope(
trig,
entry_point_id=inv.get("id"),
@@ -222,7 +228,12 @@ async def post_execute(
workflowId,
mandateId,
)
- run_env = _build_execute_run_envelope(body, workflow_for_envelope, userId)
+ run_env = _build_execute_run_envelope(
+ body,
+ workflow_for_envelope,
+ userId,
+ getattr(context.user, "language", None) if context.user else None,
+ )
ge_interface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
result = await executeGraph(
@@ -1041,11 +1052,10 @@ async def post_workflow_webhook(
discoverMethods(services)
title = inv.get("title") or {}
- label = ""
- if isinstance(title, dict):
- label = title.get("en") or title.get("de") or ""
- elif isinstance(title, str):
- label = title
+ label = _pickInvocationTitleLabel(
+ title,
+ getattr(context.user, "language", None) if context.user else None,
+ )
pl = body if isinstance(body, dict) else {}
base = default_run_envelope(
"webhook",
@@ -1103,11 +1113,10 @@ async def post_workflow_form_submit(
discoverMethods(services)
title = inv.get("title") or {}
- label = ""
- if isinstance(title, dict):
- label = title.get("en") or title.get("de") or ""
- elif isinstance(title, str):
- label = title
+ label = _pickInvocationTitleLabel(
+ title,
+ getattr(context.user, "language", None) if context.user else None,
+ )
pl = body if isinstance(body, dict) else {}
base = default_run_envelope(
"form",
diff --git a/modules/features/trustee/accounting/accountingConnectorBase.py b/modules/features/trustee/accounting/accountingConnectorBase.py
index 44044729..355b6f34 100644
--- a/modules/features/trustee/accounting/accountingConnectorBase.py
+++ b/modules/features/trustee/accounting/accountingConnectorBase.py
@@ -4,7 +4,7 @@
from abc import ABC, abstractmethod
from typing import List, Optional, Dict, Any
-from pydantic import BaseModel, Field
+from pydantic import BaseModel
class AccountingBookingLine(BaseModel):
@@ -51,7 +51,7 @@ class SyncResult(BaseModel):
class ConnectorConfigField(BaseModel):
"""Describes a configuration field required by a connector."""
key: str
- label: Dict[str, str]
+ label: str
fieldType: str = "text"
secret: bool = False
required: bool = True
@@ -70,8 +70,8 @@ class BaseAccountingConnector(ABC):
"""Unique type identifier, e.g. 'rma', 'bexio', 'abacus'."""
@abstractmethod
- def getConnectorLabel(self) -> Dict[str, str]:
- """I18n display label."""
+ def getConnectorLabel(self) -> str:
+ """German plaintext label (used as i18n key)."""
@abstractmethod
def getRequiredConfigFields(self) -> List[ConnectorConfigField]:
diff --git a/modules/features/trustee/accounting/accountingRegistry.py b/modules/features/trustee/accounting/accountingRegistry.py
index 4d0a3c1c..707d8fbb 100644
--- a/modules/features/trustee/accounting/accountingRegistry.py
+++ b/modules/features/trustee/accounting/accountingRegistry.py
@@ -10,6 +10,7 @@ import os
from typing import Dict, List, Optional
from .accountingConnectorBase import BaseAccountingConnector
+from modules.shared.i18nRegistry import t
logger = logging.getLogger(__name__)
@@ -58,10 +59,15 @@ class AccountingRegistry:
self.discoverConnectors()
result = []
for connectorType, connector in self._connectors.items():
+ fields = []
+ for f in connector.getRequiredConfigFields():
+ fd = f.model_dump()
+ fd["label"] = t(f.label)
+ fields.append(fd)
result.append({
"connectorType": connectorType,
- "label": connector.getConnectorLabel(),
- "configFields": [f.model_dump() for f in connector.getRequiredConfigFields()],
+ "label": t(connector.getConnectorLabel()),
+ "configFields": fields,
})
return result
diff --git a/modules/features/trustee/accounting/connectors/accountingConnectorAbacus.py b/modules/features/trustee/accounting/connectors/accountingConnectorAbacus.py
index 51403f70..7763d613 100644
--- a/modules/features/trustee/accounting/connectors/accountingConnectorAbacus.py
+++ b/modules/features/trustee/accounting/connectors/accountingConnectorAbacus.py
@@ -34,7 +34,7 @@ class AccountingConnectorAbacus(BaseAccountingConnector):
def getConnectorType(self) -> str:
return "abacus"
- def getConnectorLabel(self) -> Dict[str, str]:
+ def getConnectorLabel(self) -> str:
return "Abacus ERP"
def getRequiredConfigFields(self) -> List[ConnectorConfigField]:
diff --git a/modules/features/trustee/accounting/connectors/accountingConnectorBexio.py b/modules/features/trustee/accounting/connectors/accountingConnectorBexio.py
index eadb7d74..7ef2b0c3 100644
--- a/modules/features/trustee/accounting/connectors/accountingConnectorBexio.py
+++ b/modules/features/trustee/accounting/connectors/accountingConnectorBexio.py
@@ -35,7 +35,7 @@ class AccountingConnectorBexio(BaseAccountingConnector):
def getConnectorType(self) -> str:
return "bexio"
- def getConnectorLabel(self) -> Dict[str, str]:
+ def getConnectorLabel(self) -> str:
return "Bexio"
def getRequiredConfigFields(self) -> List[ConnectorConfigField]:
diff --git a/modules/features/trustee/accounting/connectors/accountingConnectorRma.py b/modules/features/trustee/accounting/connectors/accountingConnectorRma.py
index f3b0ac87..b3f3c65b 100644
--- a/modules/features/trustee/accounting/connectors/accountingConnectorRma.py
+++ b/modules/features/trustee/accounting/connectors/accountingConnectorRma.py
@@ -35,7 +35,7 @@ class AccountingConnectorRma(BaseAccountingConnector):
def getConnectorType(self) -> str:
return "rma"
- def getConnectorLabel(self) -> Dict[str, str]:
+ def getConnectorLabel(self) -> str:
return "Run My Accounts"
def getRequiredConfigFields(self) -> List[ConnectorConfigField]:
diff --git a/modules/features/trustee/routeFeatureTrustee.py b/modules/features/trustee/routeFeatureTrustee.py
index f4068e66..91de0b60 100644
--- a/modules/features/trustee/routeFeatureTrustee.py
+++ b/modules/features/trustee/routeFeatureTrustee.py
@@ -155,7 +155,7 @@ def getQuickActions(
if isinstance(multilingual, str):
return multilingual
if isinstance(multilingual, dict):
- return multilingual.get(lang) or multilingual.get("en") or multilingual.get("de") or next(iter(multilingual.values()), "")
+ return multilingual.get(lang) or multilingual.get("xx") or next(iter(multilingual.values()), "")
return ""
filteredActions = []
diff --git a/modules/features/workspace/routeFeatureWorkspace.py b/modules/features/workspace/routeFeatureWorkspace.py
index 9fb8ca40..16235cd3 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
+from modules.shared.i18nRegistry import apiRouteContext, t
routeApiMsg = apiRouteContext("routeFeatureWorkspace")
logger = logging.getLogger(__name__)
@@ -135,10 +135,11 @@ def _validateInstanceAccess(instanceId: str, context: RequestContext):
return mandateId, instanceConfig
-def _getChatInterface(context: RequestContext, featureInstanceId: str = None):
+def _getChatInterface(context: RequestContext, featureInstanceId: str = None, mandateId: str = None):
+ effectiveMandateId = mandateId or (str(context.mandateId) if context.mandateId else None)
return interfaceDbChat.getInterface(
context.user,
- mandateId=str(context.mandateId) if context.mandateId else None,
+ mandateId=effectiveMandateId,
featureInstanceId=featureInstanceId,
)
@@ -543,7 +544,7 @@ async def streamWorkspaceStart(
):
"""Start or continue a Workspace session with SSE streaming via serviceAgent."""
mandateId, instanceConfig = _validateInstanceAccess(instanceId, context)
- chatInterface = _getChatInterface(context, featureInstanceId=instanceId)
+ chatInterface = _getChatInterface(context, featureInstanceId=instanceId, mandateId=mandateId)
aiObjects = await _getAiObjects()
eventManager = get_event_manager()
@@ -907,7 +908,7 @@ async def stopWorkspace(
workflowId: str = Path(...),
context: RequestContext = Depends(getRequestContext),
):
- _validateInstanceAccess(instanceId, context)
+ _mandateId, _ = _validateInstanceAccess(instanceId, context)
queueId = f"workspace-{workflowId}"
eventManager = get_event_manager()
cancelled = await eventManager.cancel_agent(queueId)
@@ -933,8 +934,8 @@ async def listWorkspaceWorkflows(
context: RequestContext = Depends(getRequestContext),
):
"""List workspace workflows/conversations for this instance."""
- _validateInstanceAccess(instanceId, context)
- chatInterface = _getChatInterface(context, featureInstanceId=instanceId)
+ _mandateId, _ = _validateInstanceAccess(instanceId, context)
+ chatInterface = _getChatInterface(context, featureInstanceId=instanceId, mandateId=_mandateId)
workflows = chatInterface.getWorkflows() or []
from modules.interfaces.interfaceDbApp import getRootInterface
@@ -1007,8 +1008,8 @@ async def resolveRag(
context: RequestContext = Depends(getRequestContext),
):
"""Build a RAG summary for a chat (workflow) to inject into the input area."""
- _validateInstanceAccess(instanceId, context)
- chatInterface = _getChatInterface(context, featureInstanceId=instanceId)
+ _mandateId, _ = _validateInstanceAccess(instanceId, context)
+ chatInterface = _getChatInterface(context, featureInstanceId=instanceId, mandateId=_mandateId)
messages = chatInterface.getMessages(body.chatId) or []
texts = []
@@ -1037,8 +1038,8 @@ async def patchWorkspaceWorkflow(
context: RequestContext = Depends(getRequestContext),
):
"""Update a workspace workflow (e.g. rename)."""
- _validateInstanceAccess(instanceId, context)
- chatInterface = _getChatInterface(context, featureInstanceId=instanceId)
+ _mandateId, _ = _validateInstanceAccess(instanceId, context)
+ chatInterface = _getChatInterface(context, featureInstanceId=instanceId, mandateId=_mandateId)
workflow = chatInterface.getWorkflow(workflowId)
if not workflow:
raise HTTPException(status_code=404, detail=f"Workflow {workflowId} not found")
@@ -1071,8 +1072,8 @@ async def deleteWorkspaceWorkflow(
context: RequestContext = Depends(getRequestContext),
):
"""Delete a workspace workflow and its messages."""
- _validateInstanceAccess(instanceId, context)
- chatInterface = _getChatInterface(context, featureInstanceId=instanceId)
+ _mandateId, _ = _validateInstanceAccess(instanceId, context)
+ chatInterface = _getChatInterface(context, featureInstanceId=instanceId, mandateId=_mandateId)
workflow = chatInterface.getWorkflow(workflowId)
if not workflow:
raise HTTPException(status_code=404, detail=f"Workflow {workflowId} not found")
@@ -1089,8 +1090,8 @@ async def createWorkspaceWorkflow(
context: RequestContext = Depends(getRequestContext),
):
"""Create a new empty workspace workflow."""
- _validateInstanceAccess(instanceId, context)
- chatInterface = _getChatInterface(context, featureInstanceId=instanceId)
+ _mandateId, _ = _validateInstanceAccess(instanceId, context)
+ chatInterface = _getChatInterface(context, featureInstanceId=instanceId, mandateId=_mandateId)
name = body.get("name", "Neuer Chat")
workflow = chatInterface.createWorkflow({
"featureInstanceId": instanceId,
@@ -1112,8 +1113,8 @@ async def getWorkspaceMessages(
context: RequestContext = Depends(getRequestContext),
):
"""Get all messages for a workspace workflow/conversation."""
- _validateInstanceAccess(instanceId, context)
- chatInterface = _getChatInterface(context, featureInstanceId=instanceId)
+ _mandateId, _ = _validateInstanceAccess(instanceId, context)
+ chatInterface = _getChatInterface(context, featureInstanceId=instanceId, mandateId=_mandateId)
messages = chatInterface.getMessages(workflowId) or []
items = [_workspaceMessageToClientDict(m) for m in messages]
items.sort(
@@ -1140,7 +1141,7 @@ async def listWorkspaceFiles(
search: Optional[str] = Query(None),
context: RequestContext = Depends(getRequestContext),
):
- _validateInstanceAccess(instanceId, context)
+ _mandateId, _ = _validateInstanceAccess(instanceId, context)
dbMgmt = _getDbManagement(context, featureInstanceId=instanceId)
files = dbMgmt.getAllFiles()
@@ -1172,7 +1173,7 @@ async def getFileContent(
):
"""Return the raw content of a file for preview."""
from fastapi.responses import Response
- _validateInstanceAccess(instanceId, context)
+ _mandateId, _ = _validateInstanceAccess(instanceId, context)
dbMgmt = _getDbManagement(context, featureInstanceId=instanceId)
fileRecord = dbMgmt.getFile(fileId)
if not fileRecord:
@@ -1198,13 +1199,13 @@ async def listWorkspaceFolders(
parentId: Optional[str] = Query(None),
context: RequestContext = Depends(getRequestContext),
):
- _validateInstanceAccess(instanceId, context)
+ _mandateId, _ = _validateInstanceAccess(instanceId, context)
try:
from modules.serviceCenter import getService
from modules.serviceCenter.context import ServiceCenterContext
ctx = ServiceCenterContext(
user=context.user,
- mandate_id=str(context.mandateId) if context.mandateId else None,
+ mandate_id=_mandateId or "",
feature_instance_id=instanceId,
)
chatService = getService("chat", ctx)
@@ -1243,12 +1244,12 @@ async def listWorkspaceConnections(
context: RequestContext = Depends(getRequestContext),
):
"""Return the user's active connections (UserConnections)."""
- _validateInstanceAccess(instanceId, context)
+ _mandateId, _ = _validateInstanceAccess(instanceId, context)
from modules.serviceCenter import getService
from modules.serviceCenter.context import ServiceCenterContext
ctx = ServiceCenterContext(
user=context.user,
- mandate_id=str(context.mandateId) if context.mandateId else None,
+ mandate_id=_mandateId or "",
feature_instance_id=instanceId,
)
chatService = getService("chat", ctx)
@@ -1290,12 +1291,12 @@ async def createWorkspaceDataSource(
context: RequestContext = Depends(getRequestContext),
):
"""Create a new DataSource for this workspace instance."""
- _validateInstanceAccess(instanceId, context)
+ _mandateId, _ = _validateInstanceAccess(instanceId, context)
from modules.serviceCenter import getService
from modules.serviceCenter.context import ServiceCenterContext
ctx = ServiceCenterContext(
user=context.user,
- mandate_id=str(context.mandateId) if context.mandateId else None,
+ mandate_id=_mandateId or "",
feature_instance_id=instanceId,
)
chatService = getService("chat", ctx)
@@ -1319,12 +1320,12 @@ async def deleteWorkspaceDataSource(
context: RequestContext = Depends(getRequestContext),
):
"""Delete a DataSource."""
- _validateInstanceAccess(instanceId, context)
+ _mandateId, _ = _validateInstanceAccess(instanceId, context)
from modules.serviceCenter import getService
from modules.serviceCenter.context import ServiceCenterContext
ctx = ServiceCenterContext(
user=context.user,
- mandate_id=str(context.mandateId) if context.mandateId else None,
+ mandate_id=_mandateId or "",
feature_instance_id=instanceId,
)
chatService = getService("chat", ctx)
@@ -1466,7 +1467,7 @@ async def listFeatureConnectionTables(
node = {
"objectKey": obj.get("objectKey", ""),
"tableName": meta.get("table", ""),
- "label": obj.get("label", {}),
+ "label": t(obj.get("label", "")),
"fields": meta.get("fields", []),
}
if meta.get("isParent"):
@@ -1662,7 +1663,7 @@ async def deleteFeatureDataSource(
context: RequestContext = Depends(getRequestContext),
):
"""Delete a FeatureDataSource."""
- _validateInstanceAccess(instanceId, context)
+ _mandateId, _ = _validateInstanceAccess(instanceId, context)
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
@@ -1680,14 +1681,14 @@ async def listConnectionServices(
context: RequestContext = Depends(getRequestContext),
):
"""Return the available services for a specific UserConnection."""
- _validateInstanceAccess(instanceId, context)
+ _mandateId, _ = _validateInstanceAccess(instanceId, context)
try:
from modules.connectors.connectorResolver import ConnectorResolver
from modules.serviceCenter import getService as getSvc
from modules.serviceCenter.context import ServiceCenterContext
ctx = ServiceCenterContext(
user=context.user,
- mandate_id=str(context.mandateId) if context.mandateId else None,
+ mandate_id=_mandateId or "",
feature_instance_id=instanceId,
)
chatService = getSvc("chat", ctx)
@@ -1739,14 +1740,14 @@ async def browseConnectionService(
context: RequestContext = Depends(getRequestContext),
):
"""Browse folders/items within a connection's service at a given path."""
- _validateInstanceAccess(instanceId, context)
+ _mandateId, _ = _validateInstanceAccess(instanceId, context)
try:
from modules.connectors.connectorResolver import ConnectorResolver
from modules.serviceCenter import getService as getSvc
from modules.serviceCenter.context import ServiceCenterContext
ctx = ServiceCenterContext(
user=context.user,
- mandate_id=str(context.mandateId) if context.mandateId else None,
+ mandate_id=_mandateId or "",
feature_instance_id=instanceId,
)
chatService = getSvc("chat", ctx)
@@ -1784,7 +1785,7 @@ async def transcribeVoice(
context: RequestContext = Depends(getRequestContext),
):
"""Transcribe audio to text using speech-to-text."""
- _validateInstanceAccess(instanceId, context)
+ _mandateId, _ = _validateInstanceAccess(instanceId, context)
audioBytes = await audio.read()
try:
import aiohttp
@@ -1813,7 +1814,7 @@ async def synthesizeVoice(
context: RequestContext = Depends(getRequestContext),
):
"""Synthesize text to speech audio."""
- _validateInstanceAccess(instanceId, context)
+ _mandateId, _ = _validateInstanceAccess(instanceId, context)
text = body.get("text", "")
if not text:
raise HTTPException(status_code=400, detail=routeApiMsg("text is required"))
@@ -1835,7 +1836,7 @@ async def getPendingEdits(
context: RequestContext = Depends(getRequestContext),
):
"""Return all pending file edit proposals for this workspace instance."""
- _validateInstanceAccess(instanceId, context)
+ _mandateId, _ = _validateInstanceAccess(instanceId, context)
editList = [e.model_dump() for e in _pendingEditsStore.forInstance(instanceId).getPending()]
return JSONResponse({"edits": editList})
@@ -1849,7 +1850,7 @@ async def acceptEdit(
context: RequestContext = Depends(getRequestContext),
):
"""Accept a proposed file edit -- applies the new content to the file."""
- _validateInstanceAccess(instanceId, context)
+ _mandateId, _ = _validateInstanceAccess(instanceId, context)
edit = _pendingEditsStore.forInstance(instanceId).get(editId)
if not edit:
raise HTTPException(status_code=404, detail=f"Edit proposal {editId} not found")
@@ -1886,7 +1887,7 @@ async def rejectEdit(
context: RequestContext = Depends(getRequestContext),
):
"""Reject a proposed file edit -- discards the change."""
- _validateInstanceAccess(instanceId, context)
+ _mandateId, _ = _validateInstanceAccess(instanceId, context)
edit = _pendingEditsStore.forInstance(instanceId).get(editId)
if not edit:
raise HTTPException(status_code=404, detail=f"Edit proposal {editId} not found")
@@ -1911,7 +1912,7 @@ async def acceptAllEdits(
context: RequestContext = Depends(getRequestContext),
):
"""Accept all pending file edit proposals for this instance."""
- _validateInstanceAccess(instanceId, context)
+ _mandateId, _ = _validateInstanceAccess(instanceId, context)
instanceEdits = _pendingEditsStore.forInstance(instanceId)
dbMgmt = _getDbManagement(context, instanceId)
accepted = []
@@ -1942,7 +1943,7 @@ async def rejectAllEdits(
context: RequestContext = Depends(getRequestContext),
):
"""Reject all pending file edit proposals for this instance."""
- _validateInstanceAccess(instanceId, context)
+ _mandateId, _ = _validateInstanceAccess(instanceId, context)
instanceEdits = _pendingEditsStore.forInstance(instanceId)
rejected = []
@@ -1998,7 +1999,7 @@ async def updateGeneralSettings(
context: RequestContext = Depends(getRequestContext),
):
"""Update general workspace settings for the current user."""
- _validateInstanceAccess(instanceId, context)
+ _mandateId, _ = _validateInstanceAccess(instanceId, context)
wsInterface = _getWorkspaceInterface(context, instanceId)
userId = str(context.user.id)
diff --git a/modules/interfaces/interfaceDbApp.py b/modules/interfaces/interfaceDbApp.py
index 8fb91fbe..7e0dd234 100644
--- a/modules/interfaces/interfaceDbApp.py
+++ b/modules/interfaces/interfaceDbApp.py
@@ -3777,8 +3777,8 @@ class AppObjects:
if conflictingRole and conflictingRole.id != roleId:
raise ValueError(f"Role with label '{role.roleLabel}' already exists")
- # Exclude id from model_dump - the URL roleId is authoritative
- updatedRole = self.db.recordModify(Role, roleId, role.model_dump(exclude={"id"}))
+ _IMMUTABLE_ROLE_FIELDS = {"id", "mandateId", "featureInstanceId", "featureCode", "isSystemRole"}
+ updatedRole = self.db.recordModify(Role, roleId, role.model_dump(exclude=_IMMUTABLE_ROLE_FIELDS))
logger.info(f"Updated role with ID {roleId}")
return Role(**updatedRole)
except Exception as e:
diff --git a/modules/interfaces/interfaceDbChat.py b/modules/interfaces/interfaceDbChat.py
index 44fff815..874fa589 100644
--- a/modules/interfaces/interfaceDbChat.py
+++ b/modules/interfaces/interfaceDbChat.py
@@ -678,20 +678,22 @@ class ChatObjects:
return list(matchedIds)
def getWorkflow(self, workflowId: str) -> Optional[ChatWorkflow]:
- """Returns a workflow by ID if user has access."""
- # Use RBAC filtering with featureInstanceId for instance-level isolation
+ """Returns a workflow by ID if user has access.
+
+ Returns None when the workflow does not exist / RBAC denies access
+ or when the stored data fails model validation (logged separately).
+ """
workflows = self._getRecordset(ChatWorkflow, recordFilter={"id": workflowId})
-
+
if not workflows:
+ logger.debug(f"getWorkflow: no record for {workflowId} (RBAC filter or not found)")
return None
-
+
workflow = workflows[0]
try:
logs = self.getLogs(workflowId)
messages = self.getMessages(workflowId)
-
- # Validate workflow data against ChatWorkflow model
- # Explicit type coercion: DB may store numeric fields as TEXT on some platforms
+
def _toInt(v, default=0):
try:
return int(v) if v is not None else default
@@ -719,7 +721,7 @@ class ChatObjects:
messages=messages
)
except Exception as e:
- logger.error(f"Error validating workflow data: {str(e)}")
+ logger.error(f"getWorkflow: data validation failed for {workflowId}: {e}")
return None
def createWorkflow(self, workflowData: Dict[str, Any]) -> ChatWorkflow:
@@ -1040,6 +1042,10 @@ class ChatObjects:
workflowId = messageData["workflowId"]
workflow = self.getWorkflow(workflowId)
if not workflow:
+ logger.warning(
+ f"createMessage: workflow {workflowId} returned None "
+ f"(RBAC denied, not found, or data validation failed — see preceding logs)"
+ )
raise PermissionError(f"No access to workflow {workflowId}")
if not self.checkRbacPermission(ChatWorkflow, "update", workflowId):
diff --git a/modules/interfaces/interfaceFeatures.py b/modules/interfaces/interfaceFeatures.py
index ba0f0428..bb9ead89 100644
--- a/modules/interfaces/interfaceFeatures.py
+++ b/modules/interfaces/interfaceFeatures.py
@@ -252,7 +252,10 @@ class FeatureInterface:
graph = json.loads(graphJson)
labelDict = template.get("label", {})
- label = labelDict.get("de") or labelDict.get("en") or str(labelDict) if isinstance(labelDict, dict) else str(labelDict)
+ if isinstance(labelDict, dict):
+ label = labelDict.get("xx") or next(iter(labelDict.values()), "") or str(labelDict)
+ else:
+ label = str(labelDict)
geInterface.createWorkflow({
"label": label,
diff --git a/modules/migration/seedData/ui_language_seed.json b/modules/migration/seedData/ui_language_seed.json
index 9282502a..2d2193c8 100644
--- a/modules/migration/seedData/ui_language_seed.json
+++ b/modules/migration/seedData/ui_language_seed.json
@@ -3263,6 +3263,121 @@
"key": "Überwachen Sie automatisch 100% der Gespräche, um wertvolle Einblicke für Ihr Unternehmen zu erhalten.",
"value": ""
},
+ {
+ "context": "ui",
+ "key": "(gefiltert nach {name})",
+ "value": ""
+ },
+ {
+ "context": "ui",
+ "key": "({count} gefiltert)",
+ "value": ""
+ },
+ {
+ "context": "ui",
+ "key": "Abonnement, Einstellungen und Guthaben pro Mandant",
+ "value": ""
+ },
+ {
+ "context": "ui",
+ "key": "Abrechnung",
+ "value": ""
+ },
+ {
+ "context": "ui",
+ "key": "Aktion",
+ "value": ""
+ },
+ {
+ "context": "ui",
+ "key": "Benutzer-Billing",
+ "value": ""
+ },
+ {
+ "context": "ui",
+ "key": "Benutzer-Guthaben",
+ "value": ""
+ },
+ {
+ "context": "ui",
+ "key": "Benutzer:",
+ "value": ""
+ },
+ {
+ "context": "ui",
+ "key": "Deaktiviert",
+ "value": ""
+ },
+ {
+ "context": "ui",
+ "key": "Du hast Zugriff auf {instanceCount} {instanceWord} in {mandateCount} {mandateWord}.",
+ "value": ""
+ },
+ {
+ "context": "ui",
+ "key": "Einstellungen gespeichert!",
+ "value": ""
+ },
+ {
+ "context": "ui",
+ "key": "Feature-Instanz",
+ "value": ""
+ },
+ {
+ "context": "ui",
+ "key": "Feature-Instanzen",
+ "value": ""
+ },
+ {
+ "context": "ui",
+ "key": "Fehler beim Speichern",
+ "value": ""
+ },
+ {
+ "context": "ui",
+ "key": "Gesamtguthaben",
+ "value": ""
+ },
+ {
+ "context": "ui",
+ "key": "Mandant:",
+ "value": ""
+ },
+ {
+ "context": "ui",
+ "key": "Mandanten",
+ "value": ""
+ },
+ {
+ "context": "ui",
+ "key": "Mandanten-Billing",
+ "value": ""
+ },
+ {
+ "context": "ui",
+ "key": "Mandanten-Guthaben",
+ "value": ""
+ },
+ {
+ "context": "ui",
+ "key": "Mandant",
+ "value": ""
+ },
+ {
+ "context": "ui",
+ "key": "Niedrig",
+ "value": ""
+ },
+ {
+ "context": "ui",
+ "key": "Transaktionen",
+ "value": ""
+ },
+ {
+ "context": "ui",
+ "key": "Warnschwelle",
+ "value": ""
+ },
{
"context": "ui",
"key": "✓ Mandat eingereicht",
@@ -6536,6 +6651,121 @@
"key": "Überwachen Sie automatisch 100% der Gespräche, um wertvolle Einblicke für Ihr Unternehmen zu erhalten.",
"value": "Überwachen Sie automatisch 100% der Gespräche, um wertvolle Einblicke für Ihr Unternehmen zu erhalten."
},
+ {
+ "context": "ui",
+ "key": "(gefiltert nach {name})",
+ "value": "(gefiltert nach {name})"
+ },
+ {
+ "context": "ui",
+ "key": "({count} gefiltert)",
+ "value": "({count} gefiltert)"
+ },
+ {
+ "context": "ui",
+ "key": "Abonnement, Einstellungen und Guthaben pro Mandant",
+ "value": "Abonnement, Einstellungen und Guthaben pro Mandant"
+ },
+ {
+ "context": "ui",
+ "key": "Abrechnung",
+ "value": "Abrechnung"
+ },
+ {
+ "context": "ui",
+ "key": "Aktion",
+ "value": "Aktion"
+ },
+ {
+ "context": "ui",
+ "key": "Benutzer-Billing",
+ "value": "Benutzer-Billing"
+ },
+ {
+ "context": "ui",
+ "key": "Benutzer-Guthaben",
+ "value": "Benutzer-Guthaben"
+ },
+ {
+ "context": "ui",
+ "key": "Benutzer:",
+ "value": "Benutzer:"
+ },
+ {
+ "context": "ui",
+ "key": "Deaktiviert",
+ "value": "Deaktiviert"
+ },
+ {
+ "context": "ui",
+ "key": "Du hast Zugriff auf {instanceCount} {instanceWord} in {mandateCount} {mandateWord}.",
+ "value": "Du hast Zugriff auf {instanceCount} {instanceWord} in {mandateCount} {mandateWord}."
+ },
+ {
+ "context": "ui",
+ "key": "Einstellungen gespeichert!",
+ "value": "Einstellungen gespeichert!"
+ },
+ {
+ "context": "ui",
+ "key": "Feature-Instanz",
+ "value": "Feature-Instanz"
+ },
+ {
+ "context": "ui",
+ "key": "Feature-Instanzen",
+ "value": "Feature-Instanzen"
+ },
+ {
+ "context": "ui",
+ "key": "Fehler beim Speichern",
+ "value": "Fehler beim Speichern"
+ },
+ {
+ "context": "ui",
+ "key": "Gesamtguthaben",
+ "value": "Gesamtguthaben"
+ },
+ {
+ "context": "ui",
+ "key": "Mandant:",
+ "value": "Mandant:"
+ },
+ {
+ "context": "ui",
+ "key": "Mandanten",
+ "value": "Mandanten"
+ },
+ {
+ "context": "ui",
+ "key": "Mandanten-Billing",
+ "value": "Mandanten-Billing"
+ },
+ {
+ "context": "ui",
+ "key": "Mandanten-Guthaben",
+ "value": "Mandanten-Guthaben"
+ },
+ {
+ "context": "ui",
+ "key": "Mandant",
+ "value": "Mandant"
+ },
+ {
+ "context": "ui",
+ "key": "Niedrig",
+ "value": "Niedrig"
+ },
+ {
+ "context": "ui",
+ "key": "Transaktionen",
+ "value": "Transaktionen"
+ },
+ {
+ "context": "ui",
+ "key": "Warnschwelle",
+ "value": "Warnschwelle"
+ },
{
"context": "ui",
"key": "✓ Mandat eingereicht",
@@ -9634,6 +9864,121 @@
"key": "Überwachen Sie automatisch 100% der Gespräche, um wertvolle Einblicke für Ihr Unternehmen zu erhalten.",
"value": "Automatically monitor 100% of conversations to get valuable insights for your business."
},
+ {
+ "context": "ui",
+ "key": "(gefiltert nach {name})",
+ "value": "(filtered by {name})"
+ },
+ {
+ "context": "ui",
+ "key": "({count} gefiltert)",
+ "value": "({count} filtered)"
+ },
+ {
+ "context": "ui",
+ "key": "Abonnement, Einstellungen und Guthaben pro Mandant",
+ "value": "Subscription, settings, and credit per tenant"
+ },
+ {
+ "context": "ui",
+ "key": "Abrechnung",
+ "value": "Billing"
+ },
+ {
+ "context": "ui",
+ "key": "Aktion",
+ "value": "Action"
+ },
+ {
+ "context": "ui",
+ "key": "Benutzer-Billing",
+ "value": "User billing"
+ },
+ {
+ "context": "ui",
+ "key": "Benutzer-Guthaben",
+ "value": "User credits"
+ },
+ {
+ "context": "ui",
+ "key": "Benutzer:",
+ "value": "User:"
+ },
+ {
+ "context": "ui",
+ "key": "Deaktiviert",
+ "value": "Disabled"
+ },
+ {
+ "context": "ui",
+ "key": "Du hast Zugriff auf {instanceCount} {instanceWord} in {mandateCount} {mandateWord}.",
+ "value": "You have access to {instanceCount} {instanceWord} in {mandateCount} {mandateWord}."
+ },
+ {
+ "context": "ui",
+ "key": "Einstellungen gespeichert!",
+ "value": "Settings saved!"
+ },
+ {
+ "context": "ui",
+ "key": "Feature-Instanz",
+ "value": "Feature instance"
+ },
+ {
+ "context": "ui",
+ "key": "Feature-Instanzen",
+ "value": "Feature instances"
+ },
+ {
+ "context": "ui",
+ "key": "Fehler beim Speichern",
+ "value": "Error saving"
+ },
+ {
+ "context": "ui",
+ "key": "Gesamtguthaben",
+ "value": "Total credit"
+ },
+ {
+ "context": "ui",
+ "key": "Mandant:",
+ "value": "Tenant:"
+ },
+ {
+ "context": "ui",
+ "key": "Mandanten",
+ "value": "Tenants"
+ },
+ {
+ "context": "ui",
+ "key": "Mandanten-Billing",
+ "value": "Tenant billing"
+ },
+ {
+ "context": "ui",
+ "key": "Mandanten-Guthaben",
+ "value": "Tenant credits"
+ },
+ {
+ "context": "ui",
+ "key": "Mandant",
+ "value": "Tenant"
+ },
+ {
+ "context": "ui",
+ "key": "Niedrig",
+ "value": "Low"
+ },
+ {
+ "context": "ui",
+ "key": "Transaktionen",
+ "value": "Transactions"
+ },
+ {
+ "context": "ui",
+ "key": "Warnschwelle",
+ "value": "Warning threshold"
+ },
{
"context": "ui",
"key": "✓ Mandat eingereicht",
@@ -12732,6 +13077,121 @@
"key": "Überwachen Sie automatisch 100% der Gespräche, um wertvolle Einblicke für Ihr Unternehmen zu erhalten.",
"value": "Surveillez automatiquement 100% des conversations pour obtenir des insights précieux pour votre entreprise."
},
+ {
+ "context": "ui",
+ "key": "(gefiltert nach {name})",
+ "value": "(filtré par {name})"
+ },
+ {
+ "context": "ui",
+ "key": "({count} gefiltert)",
+ "value": "({count} filtrés)"
+ },
+ {
+ "context": "ui",
+ "key": "Abonnement, Einstellungen und Guthaben pro Mandant",
+ "value": "Abonnement, paramètres et crédits par client"
+ },
+ {
+ "context": "ui",
+ "key": "Abrechnung",
+ "value": "Facturation"
+ },
+ {
+ "context": "ui",
+ "key": "Aktion",
+ "value": "Action"
+ },
+ {
+ "context": "ui",
+ "key": "Benutzer-Billing",
+ "value": "Facturation utilisateurs"
+ },
+ {
+ "context": "ui",
+ "key": "Benutzer-Guthaben",
+ "value": "Crédits utilisateur"
+ },
+ {
+ "context": "ui",
+ "key": "Benutzer:",
+ "value": "Utilisateur :"
+ },
+ {
+ "context": "ui",
+ "key": "Deaktiviert",
+ "value": "Désactivé"
+ },
+ {
+ "context": "ui",
+ "key": "Du hast Zugriff auf {instanceCount} {instanceWord} in {mandateCount} {mandateWord}.",
+ "value": "Vous avez accès à {instanceCount} {instanceWord} sur {mandateCount} {mandateWord}."
+ },
+ {
+ "context": "ui",
+ "key": "Einstellungen gespeichert!",
+ "value": "Paramètres enregistrés !"
+ },
+ {
+ "context": "ui",
+ "key": "Feature-Instanz",
+ "value": "instance de fonctionnalité"
+ },
+ {
+ "context": "ui",
+ "key": "Feature-Instanzen",
+ "value": "instances de fonctionnalité"
+ },
+ {
+ "context": "ui",
+ "key": "Fehler beim Speichern",
+ "value": "Erreur lors de l'enregistrement"
+ },
+ {
+ "context": "ui",
+ "key": "Gesamtguthaben",
+ "value": "Crédit total"
+ },
+ {
+ "context": "ui",
+ "key": "Mandant:",
+ "value": "Client :"
+ },
+ {
+ "context": "ui",
+ "key": "Mandanten",
+ "value": "Clients"
+ },
+ {
+ "context": "ui",
+ "key": "Mandanten-Billing",
+ "value": "Facturation clients"
+ },
+ {
+ "context": "ui",
+ "key": "Mandanten-Guthaben",
+ "value": "Crédits clients"
+ },
+ {
+ "context": "ui",
+ "key": "Mandant",
+ "value": "Client"
+ },
+ {
+ "context": "ui",
+ "key": "Niedrig",
+ "value": "Faible"
+ },
+ {
+ "context": "ui",
+ "key": "Transaktionen",
+ "value": "Transactions"
+ },
+ {
+ "context": "ui",
+ "key": "Warnschwelle",
+ "value": "Seuil d'alerte"
+ },
{
"context": "ui",
"key": "✓ Mandat eingereicht",
diff --git a/modules/routes/routeAdminFeatures.py b/modules/routes/routeAdminFeatures.py
index ddd73ad7..13b47c28 100644
--- a/modules/routes/routeAdminFeatures.py
+++ b/modules/routes/routeAdminFeatures.py
@@ -33,12 +33,15 @@ routeApiMsg = apiRouteContext("routeAdminFeatures")
logger = logging.getLogger(__name__)
-def _featureLabelPlain(label: Union[str, Dict[str, str], None], fallback: str) -> str:
- """Catalog feature label as German i18n key string."""
+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):
- return label.get("de") or label.get("en") or fallback
+ picked = (requestLang and label.get(requestLang)) or label.get("xx") or next(iter(label.values()), "")
+ if picked:
+ return str(picked)
+ return fallback
return fallback
@@ -194,7 +197,11 @@ 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),
+ "label": _featureLabelPlain(
+ featureDef.get("label") if featureDef else None,
+ instance.featureCode,
+ getattr(context.user, "language", 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 14caf29c..92ceaf18 100644
--- a/modules/routes/routeAdminRbacRules.py
+++ b/modules/routes/routeAdminRbacRules.py
@@ -23,12 +23,23 @@ 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
+from modules.shared.i18nRegistry import apiRouteContext, t, _getLanguage
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"],
@@ -911,7 +922,7 @@ def list_roles(
result.append({
"id": role.id,
"roleLabel": role.roleLabel,
- "description": role.description,
+ "description": _resolveTextMultilingual(role.description),
"mandateId": role.mandateId,
"featureInstanceId": role.featureInstanceId,
"featureCode": role.featureCode,
@@ -931,14 +942,8 @@ def list_roles(
if searchTerm:
searchedResult = []
for item in result:
- # Search in roleLabel and description
roleLabel = (item.get("roleLabel") or "").lower()
- description = item.get("description")
- descText = ""
- if isinstance(description, dict):
- descText = " ".join(str(v) for v in description.values()).lower()
- elif description:
- descText = str(description).lower()
+ descText = (item.get("description") or "").lower()
scopeType = (item.get("scopeType") or "").lower()
if searchTerm in roleLabel or searchTerm in descText or searchTerm in scopeType:
@@ -1046,7 +1051,7 @@ def get_roles_filter_values(
result.append({
"id": role.id,
"roleLabel": role.roleLabel,
- "description": role.description,
+ "description": _resolveTextMultilingual(role.description),
"mandateId": role.mandateId,
"featureInstanceId": role.featureInstanceId,
"featureCode": role.featureCode,
@@ -1163,7 +1168,7 @@ def get_role(
return {
"id": role.id,
"roleLabel": role.roleLabel,
- "description": role.description,
+ "description": _resolveTextMultilingual(role.description),
"mandateId": role.mandateId,
"featureInstanceId": role.featureInstanceId,
"featureCode": role.featureCode,
@@ -1362,6 +1367,16 @@ def getCatalogObjects(
except Exception as e:
logger.warning(f"Could not get active features for mandate {mandateId}: {e}")
+ 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', '?')}]"
+ return objects
+
if context:
# Single context filter
try:
@@ -1383,7 +1398,7 @@ def getCatalogObjects(
if activeFeatures:
objects = [obj for obj in objects if obj.get("featureCode") in activeFeatures]
- return {context.upper(): objects}
+ return {context.upper(): _resolveLabels(objects)}
else:
# All contexts
result = catalog.getAllCatalogObjects(featureCode)
@@ -1393,6 +1408,8 @@ def getCatalogObjects(
for ctxKey in result:
result[ctxKey] = [obj for obj in result[ctxKey] if obj.get("featureCode") in activeFeatures]
+ for ctxKey in result:
+ _resolveLabels(result[ctxKey])
return result
except HTTPException:
diff --git a/modules/routes/routeAdminUserAccessOverview.py b/modules/routes/routeAdminUserAccessOverview.py
index 6c122191..4b5e1212 100644
--- a/modules/routes/routeAdminUserAccessOverview.py
+++ b/modules/routes/routeAdminUserAccessOverview.py
@@ -24,13 +24,24 @@ from modules.datamodels.datamodelMembership import (
)
from modules.datamodels.datamodelFeatures import FeatureInstance, Feature
from modules.interfaces.interfaceDbApp import getRootInterface
-from modules.shared.i18nRegistry import apiRouteContext
+from modules.shared.i18nRegistry import apiRouteContext, t, _getLanguage
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"],
@@ -298,7 +309,7 @@ def getUserAccessOverview(
roleInfo = {
"id": roleId,
"roleLabel": role.roleLabel,
- "description": role.description or {},
+ "description": _resolveTextMultilingual(role.description),
"scope": scope,
"scopePriority": _getRoleScopePriority(scope),
"mandateId": role.mandateId,
@@ -334,7 +345,7 @@ def getUserAccessOverview(
# Get feature info using interface method
featureCode = instance.featureCode
feature = interface.getFeatureByCode(featureCode)
- featureLabel = feature.label if feature else {}
+ featureLabel = t(feature.label) if feature and feature.label else ""
# Get roles for this FeatureAccess using interface method
instanceRoleIds = interface.getRoleIdsForFeatureAccess(faId)
@@ -348,14 +359,14 @@ def getUserAccessOverview(
roleInfo = {
"id": roleId,
"roleLabel": role.roleLabel,
- "description": role.description or {},
- "scope": scope,
- "scopePriority": _getRoleScopePriority(scope),
- "mandateId": role.mandateId,
- "featureInstanceId": role.featureInstanceId,
- "source": "featureInstance",
- "sourceInstanceId": faInstanceId,
- "sourceInstanceLabel": instance.label,
+ "description": _resolveTextMultilingual(role.description),
+ "scope": scope,
+ "scopePriority": _getRoleScopePriority(scope),
+ "mandateId": role.mandateId,
+ "featureInstanceId": role.featureInstanceId,
+ "source": "featureInstance",
+ "sourceInstanceId": faInstanceId,
+ "sourceInstanceLabel": instance.label,
}
allRoles.append(roleInfo)
roleIdToInfo[roleId] = roleInfo
diff --git a/modules/routes/routeDataFiles.py b/modules/routes/routeDataFiles.py
index defc0d75..9f3cc9fe 100644
--- a/modules/routes/routeDataFiles.py
+++ b/modules/routes/routeDataFiles.py
@@ -143,6 +143,8 @@ async def _autoIndexFile(fileId: str, fileName: str, mimeType: str, user):
structure=contentIndex.structure,
)
+ # Re-acquire interface after await to avoid stale user context from the singleton
+ mgmtInterface = interfaceDbManagement.getInterface(user)
mgmtInterface.updateFile(fileId, {"status": "active"})
logger.info(f"Auto-index complete for file {fileId} ({fileName})")
diff --git a/modules/routes/routeDataMandates.py b/modules/routes/routeDataMandates.py
index 623627c2..0fcb6303 100644
--- a/modules/routes/routeDataMandates.py
+++ b/modules/routes/routeDataMandates.py
@@ -779,7 +779,7 @@ def add_user_to_mandate(
f"with roles {data.roleIds}"
)
- mname = _mandate_display_name(mandate)
+ mname = _mandate_display_name(mandate, getattr(targetUser, "language", None))
create_access_change_notification(
data.targetUserId,
"Mandantenzugriff",
@@ -877,7 +877,9 @@ def remove_user_from_mandate(
logger.info(f"User {context.user.id} removed user {targetUserId} from mandate {targetMandateId}")
- mname = _mandate_display_name(mandate)
+ removedUser = rootInterface.getUser(targetUserId)
+ notifyLang = getattr(removedUser, "language", None) if removedUser else getattr(context.user, "language", None)
+ mname = _mandate_display_name(mandate, notifyLang)
create_access_change_notification(
targetUserId,
"Mandantenzugriff",
@@ -982,7 +984,9 @@ def update_user_roles_in_mandate(
)
mandate_meta = rootInterface.getMandate(targetMandateId)
- mname = _mandate_display_name(mandate_meta)
+ roleUser = rootInterface.getUser(targetUserId)
+ notifyLang = getattr(roleUser, "language", None) if roleUser else getattr(context.user, "language", None)
+ mname = _mandate_display_name(mandate_meta, notifyLang)
create_access_change_notification(
targetUserId,
"Mandantenrollen geändert",
@@ -1013,7 +1017,7 @@ def update_user_roles_in_mandate(
# Helper Functions
# =============================================================================
-def _mandate_display_name(mandate: Any) -> str:
+def _mandate_display_name(mandate: Any, requestLang: Optional[str] = None) -> str:
"""Human-readable mandate label for notifications."""
if mandate is None:
return ""
@@ -1022,14 +1026,16 @@ def _mandate_display_name(mandate: Any) -> str:
return str(mandate["label"])
name = mandate.get("name")
if isinstance(name, dict):
- return str(name.get("de") or name.get("en") or (next(iter(name.values()), "") if name else ""))
+ picked = (requestLang and name.get(requestLang)) or name.get("xx") or next(iter(name.values()), "")
+ return str(picked) if picked is not None else ""
return str(name or mandate.get("id", ""))
label = getattr(mandate, "label", None)
if label:
return str(label)
name = getattr(mandate, "name", None)
if isinstance(name, dict):
- return str(name.get("de") or name.get("en") or (next(iter(name.values()), "") if name else ""))
+ picked = (requestLang and name.get(requestLang)) or name.get("xx") or next(iter(name.values()), "")
+ return str(picked) if picked is not None else ""
if name is not None:
return str(name)
return str(getattr(mandate, "id", ""))
diff --git a/modules/routes/routeDataUsers.py b/modules/routes/routeDataUsers.py
index 42e65c70..07c3d81b 100644
--- a/modules/routes/routeDataUsers.py
+++ b/modules/routes/routeDataUsers.py
@@ -71,7 +71,11 @@ def _isAdminForUser(context: RequestContext, targetUserId: str) -> bool:
return False
-def _extractDistinctValues(items: List[Dict[str, Any]], columnKey: str) -> List[str]:
+def _extractDistinctValues(
+ items: List[Dict[str, Any]],
+ columnKey: str,
+ requestLang: Optional[str] = None,
+) -> List[str]:
"""Extract sorted distinct display values for a column from enriched items."""
values = set()
for item in items:
@@ -83,7 +87,7 @@ def _extractDistinctValues(items: List[Dict[str, Any]], columnKey: str) -> List[
elif isinstance(val, (int, float)):
values.add(str(val))
elif isinstance(val, dict):
- text = val.get("en") or next((v for v in val.values() if isinstance(v, str) and v), None)
+ text = (requestLang and val.get(requestLang)) or val.get("xx") or next(iter(val.values()), None)
if text:
values.add(str(text))
else:
@@ -95,6 +99,7 @@ def _handleFilterValuesRequest(
items: List[Dict[str, Any]],
column: str,
paginationJson: Optional[str] = None,
+ requestLang: Optional[str] = None,
) -> List[str]:
"""
Generic handler for /filter-values endpoints.
@@ -117,7 +122,7 @@ def _handleFilterValuesRequest(
pass
crossFiltered = _applyFiltersAndSort(items, crossFilterParams)
- return _extractDistinctValues(crossFiltered, column)
+ return _extractDistinctValues(crossFiltered, column, requestLang)
def _applyFiltersAndSort(items: List[Dict[str, Any]], paginationParams: Optional[PaginationParams]) -> List[Dict[str, Any]]:
@@ -507,7 +512,7 @@ def get_user_filter_values(
result = appInterface.getUsersByMandate(str(context.mandateId), None)
users = result if isinstance(result, list) else (result.items if hasattr(result, 'items') else [])
items = [u.model_dump() if hasattr(u, 'model_dump') else u for u in users]
- return _handleFilterValuesRequest(items, column, pagination)
+ return _handleFilterValuesRequest(items, column, pagination, getattr(context.user, "language", None))
elif context.hasSysAdminRole:
# SysAdmin: use SQL DISTINCT for DB columns
try:
@@ -519,7 +524,7 @@ def get_user_filter_values(
except Exception:
users = appInterface.getAllUsers()
items = [u.model_dump() if hasattr(u, 'model_dump') else u for u in users]
- return _handleFilterValuesRequest(items, column, pagination)
+ return _handleFilterValuesRequest(items, column, pagination, getattr(context.user, "language", None))
else:
# Non-admin multi-mandate: aggregate across admin mandates (in-memory)
rootInterface = getRootInterface()
@@ -547,7 +552,7 @@ def get_user_filter_values(
})
batchUsers = rootInterface.getUsersByIds(uniqueUserIds) if uniqueUserIds else {}
items = [u.model_dump() if hasattr(u, 'model_dump') else vars(u) for u in batchUsers.values()]
- return _handleFilterValuesRequest(items, column, pagination)
+ return _handleFilterValuesRequest(items, column, pagination, getattr(context.user, "language", None))
except HTTPException:
raise
except Exception as e:
diff --git a/modules/routes/routeI18n.py b/modules/routes/routeI18n.py
index 5b806d33..070d9c90 100644
--- a/modules/routes/routeI18n.py
+++ b/modules/routes/routeI18n.py
@@ -34,6 +34,8 @@ from modules.datamodels.datamodelAi import (
)
from modules.datamodels.datamodelUiLanguage import I18nEntry, UiLanguageSet
from modules.datamodels.datamodelUam import User
+from modules.datamodels.datamodelRbac import Role
+from modules.datamodels.datamodelFeatures import Feature
from modules.datamodels.datamodelNotification import NotificationType
from modules.interfaces.interfaceDbManagement import getInterface as getMgmtInterface
from modules.routes.routeNotifications import _createNotification
@@ -532,6 +534,67 @@ def _validate_iso2_code(code: str) -> str:
return c
+async def _translateTextMultilingualFields(db, langCode: str, langLabel: str, billingCb=None) -> int:
+ """Batch-translate all TextMultilingual fields (Role.description, Feature.label) for a new language."""
+ textsToTranslate: Dict[str, str] = {}
+
+ roles = db.getRecordset(Role)
+ for r in roles:
+ desc = r.get("description") if isinstance(r, dict) else getattr(r, "description", None)
+ if isinstance(desc, dict):
+ sourceText = desc.get("xx", "")
+ if sourceText and not desc.get(langCode):
+ textsToTranslate[f"role:{r.get('id') if isinstance(r, dict) else r.id}:description"] = sourceText
+
+ features = db.getRecordset(Feature)
+ for f in features:
+ lbl = f.get("label") if isinstance(f, dict) else getattr(f, "label", None)
+ if isinstance(lbl, dict):
+ sourceText = lbl.get("xx", "")
+ if sourceText and not lbl.get(langCode):
+ textsToTranslate[f"feature:{f.get('code') if isinstance(f, dict) else f.code}:label"] = sourceText
+
+ if not textsToTranslate:
+ return 0
+
+ keysForAi = {v: "User-generated content field" for v in textsToTranslate.values()}
+ uniqueTexts = list(set(keysForAi.keys()))
+ keysForAi = {t: "User-generated content field" for t in uniqueTexts}
+ translated = await _translateBatch(keysForAi, langLabel, langCode, billingCallback=billingCb)
+
+ count = 0
+ for compositeKey, deText in textsToTranslate.items():
+ translatedText = translated.get(deText)
+ if not translatedText:
+ continue
+ parts = compositeKey.split(":")
+ if parts[0] == "role":
+ roleId = parts[1]
+ rows = db.getRecordset(Role, recordFilter={"id": roleId})
+ if rows:
+ rec = dict(rows[0]) if not isinstance(rows[0], dict) else rows[0]
+ desc = rec.get("description", {})
+ if isinstance(desc, dict):
+ desc[langCode] = translatedText
+ rec["description"] = desc
+ db.recordModify(Role, roleId, rec)
+ count += 1
+ elif parts[0] == "feature":
+ featureCode = parts[1]
+ rows = db.getRecordset(Feature, recordFilter={"code": featureCode})
+ if rows:
+ rec = dict(rows[0]) if not isinstance(rows[0], dict) else rows[0]
+ lbl = rec.get("label", {})
+ if isinstance(lbl, dict):
+ lbl[langCode] = translatedText
+ rec["label"] = lbl
+ db.recordModify(Feature, featureCode, rec)
+ count += 1
+
+ logger.info("TextMultilingual batch translate: %d fields translated to %s", count, langCode)
+ return count
+
+
def _run_create_language_job(userId: str, code: str, label: str, currentUser: User, mandateId: str) -> None:
loop = asyncio.new_event_loop()
try:
@@ -580,14 +643,17 @@ async def _run_create_language_job_async(userId: str, code: str, label: str, cur
db.recordModify(UiLanguageSet, code, merged)
statusHint = "" if finalStatus == "complete" else f" ({missingCount} Keys ohne Übersetzung)"
+
+ tmCount = await _translateTextMultilingualFields(db, code, label, billingCb)
+
_createNotification(
userId,
NotificationType.SYSTEM,
title="Sprachset erstellt",
- message=f"Die Sprache «{label}» ({code}) wurde per KI übersetzt{statusHint}.",
+ message=f"Die Sprache «{label}» ({code}) wurde per KI übersetzt{statusHint}. {tmCount} Inhaltsfelder übersetzt.",
)
await _reloadI18nCache()
- logger.info("i18n create job done: code=%s, translated=%d/%d", code, len(translated), len(xxEntries))
+ logger.info("i18n create job done: code=%s, translated=%d/%d, tm_fields=%d", code, len(translated), len(xxEntries), tmCount)
except Exception as e:
logger.exception("create language job failed: %s", e)
_createNotification(
diff --git a/modules/routes/routeStore.py b/modules/routes/routeStore.py
index 286eed8b..d60bd1df 100644
--- a/modules/routes/routeStore.py
+++ b/modules/routes/routeStore.py
@@ -29,12 +29,15 @@ routeApiMsg = apiRouteContext("routeStore")
logger = logging.getLogger(__name__)
-def _storeLabelText(label: Union[str, Dict[str, str], None], fallback: str) -> str:
- """Normalize catalog label to German i18n key string."""
+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):
- return label.get("de") or label.get("en") or fallback
+ 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(
@@ -298,9 +301,9 @@ def listStoreFeatures(
instances = _getUserInstancesForFeature(db, userId, featureCode, userMandateIds)
result.append(StoreFeatureResponse(
featureCode=featureCode,
- label=_storeLabelText(featureDef.get("label"), featureCode),
+ label=_storeLabelText(featureDef.get("label"), featureCode, getattr(context.user, "language", None)),
icon=featureDef.get("icon", "mdi-puzzle"),
- description=_storeLabelText(featureDef.get("description"), ""),
+ description=_storeLabelText(featureDef.get("description"), "", getattr(context.user, "language", None)),
instances=instances,
canActivate=True,
))
@@ -388,7 +391,7 @@ def activateStoreFeature(
# ── 3. Provision instance ───────────────────────────────────────
featureInterface = getFeatureInterface(db)
- featureLabel = _storeLabelText(featureDef.get("label"), featureCode)
+ featureLabel = _storeLabelText(featureDef.get("label"), featureCode, getattr(context.user, "language", None))
instance = featureInterface.createFeatureInstance(
featureCode=featureCode,
mandateId=mandateId,
diff --git a/modules/security/rbacCatalog.py b/modules/security/rbacCatalog.py
index 587f6fbd..bc5b0353 100644
--- a/modules/security/rbacCatalog.py
+++ b/modules/security/rbacCatalog.py
@@ -8,7 +8,7 @@ Feature-Container register their RBAC objects via mainXxx.py at startup.
"""
import logging
-from typing import Dict, List, Any, Optional, Union
+from typing import Dict, List, Any, Optional
from threading import Lock
logger = logging.getLogger(__name__)
@@ -43,7 +43,7 @@ class RbacCatalogService:
self._initialized = True
logger.info("RBAC Catalog Service initialized")
- def registerUiObject(self, featureCode: str, objectKey: str, label: Union[str, Dict[str, str]], meta: Optional[Dict[str, Any]] = None) -> bool:
+ def registerUiObject(self, featureCode: str, objectKey: str, label: str, meta: Optional[Dict[str, Any]] = None) -> bool:
"""Register a UI object for a feature."""
try:
self._uiObjects[objectKey] = {"objectKey": objectKey, "featureCode": featureCode, "label": label, "meta": meta or {}, "type": "UI"}
@@ -52,7 +52,7 @@ class RbacCatalogService:
logger.error(f"Failed to register UI object {objectKey}: {e}")
return False
- def registerResourceObject(self, featureCode: str, objectKey: str, label: Dict[str, str], meta: Optional[Dict[str, Any]] = None) -> bool:
+ def registerResourceObject(self, featureCode: str, objectKey: str, label: str, meta: Optional[Dict[str, Any]] = None) -> bool:
"""Register a RESOURCE object for a feature."""
try:
self._resourceObjects[objectKey] = {"objectKey": objectKey, "featureCode": featureCode, "label": label, "meta": meta or {}, "type": "RESOURCE"}
@@ -61,14 +61,14 @@ class RbacCatalogService:
logger.error(f"Failed to register RESOURCE object {objectKey}: {e}")
return False
- def registerDataObject(self, featureCode: str, objectKey: str, label: Dict[str, str], meta: Optional[Dict[str, Any]] = None) -> bool:
+ def registerDataObject(self, featureCode: str, objectKey: str, label: str, meta: Optional[Dict[str, Any]] = None) -> bool:
"""
Register a DATA object (table/entity) for a feature.
Args:
featureCode: Feature code (e.g., "trustee", "system")
objectKey: Dot-notation key (e.g., "data.feature.trustee.TrusteeContract")
- label: Multilingual label dict
+ label: German plaintext label (used as i18n key)
meta: Optional metadata (e.g., table name, fields list)
"""
try:
@@ -84,7 +84,7 @@ class RbacCatalogService:
logger.error(f"Failed to register DATA object {objectKey}: {e}")
return False
- def registerFeatureDefinition(self, featureCode: str, label: Union[str, Dict[str, str]], icon: str) -> bool:
+ def registerFeatureDefinition(self, featureCode: str, label: str, icon: str) -> bool:
"""Register a feature definition."""
try:
self._featureDefinitions[featureCode] = {"code": featureCode, "label": label, "icon": icon}
diff --git a/modules/serviceCenter/services/serviceAgent/actionToolAdapter.py b/modules/serviceCenter/services/serviceAgent/actionToolAdapter.py
index 17e953fd..815be871 100644
--- a/modules/serviceCenter/services/serviceAgent/actionToolAdapter.py
+++ b/modules/serviceCenter/services/serviceAgent/actionToolAdapter.py
@@ -80,10 +80,13 @@ def _convertParameterSchema(actionParams: Dict[str, Any]) -> Dict[str, Any]:
paramRequired = paramInfo.get("required", False) if isinstance(paramInfo, dict) else False
jsonType = _pythonTypeToJsonType(paramType)
- properties[paramName] = {
+ prop: Dict[str, Any] = {
"type": jsonType,
- "description": paramDesc
+ "description": paramDesc,
}
+ if jsonType == "array":
+ prop["items"] = _pythonTypeToArrayItems(paramType) or {"type": "string"}
+ properties[paramName] = prop
if paramRequired:
required.append(paramName)
@@ -95,21 +98,37 @@ def _convertParameterSchema(actionParams: Dict[str, Any]) -> Dict[str, Any]:
}
+_TYPE_MAPPING = {
+ "str": "string",
+ "int": "integer",
+ "float": "number",
+ "bool": "boolean",
+ "list": "array",
+ "dict": "object",
+ "List[str]": "array",
+ "List[int]": "array",
+ "List[dict]": "array",
+ "List[float]": "array",
+ "Dict[str, Any]": "object",
+}
+
+_ARRAY_ITEMS_MAPPING = {
+ "list": {"type": "string"},
+ "List[str]": {"type": "string"},
+ "List[int]": {"type": "integer"},
+ "List[float]": {"type": "number"},
+ "List[dict]": {"type": "object"},
+}
+
+
def _pythonTypeToJsonType(pythonType: str) -> str:
"""Map Python type strings to JSON Schema types."""
- mapping = {
- "str": "string",
- "int": "integer",
- "float": "number",
- "bool": "boolean",
- "list": "array",
- "dict": "object",
- "List[str]": "array",
- "List[int]": "array",
- "List[dict]": "array",
- "Dict[str, Any]": "object",
- }
- return mapping.get(pythonType, "string")
+ return _TYPE_MAPPING.get(pythonType, "string")
+
+
+def _pythonTypeToArrayItems(pythonType: str) -> Optional[Dict[str, Any]]:
+ """Return the JSON Schema `items` descriptor for array types, or None."""
+ return _ARRAY_ITEMS_MAPPING.get(pythonType)
def _createDispatchHandler(actionExecutor, methodName: str, actionName: str):
diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_connectionTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_connectionTools.py
index 3419c8f8..e4018014 100644
--- a/modules/serviceCenter/services/serviceAgent/coreTools/_connectionTools.py
+++ b/modules/serviceCenter/services/serviceAgent/coreTools/_connectionTools.py
@@ -44,10 +44,12 @@ def _registerConnectionTools(registry: ToolRegistry, services):
return ToolResult(toolCallId="", toolName="listConnections", success=True, data="No connections available.")
lines = []
for conn in connections:
- connId = conn.get("id", "?") if isinstance(conn, dict) else getattr(conn, "id", "?")
authority = conn.get("authority", "?") if isinstance(conn, dict) else getattr(conn, "authority", "?")
+ authorityVal = authority.value if hasattr(authority, "value") else str(authority)
+ username = conn.get("externalUsername", "") if isinstance(conn, dict) else getattr(conn, "externalUsername", "")
email = conn.get("externalEmail", "") if isinstance(conn, dict) else getattr(conn, "externalEmail", "")
- lines.append(f"- {authority} ({email}) id: {connId}")
+ ref = f"connection:{authorityVal}:{username}"
+ lines.append(f"- {ref} ({email})")
return ToolResult(toolCallId="", toolName="listConnections", success=True, data="\n".join(lines))
except Exception as e:
return ToolResult(toolCallId="", toolName="listConnections", success=False, error=str(e))
diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py
index a699ab4a..714eab7e 100644
--- a/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py
+++ b/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py
@@ -93,6 +93,11 @@ def _registerFeatureSubAgentTools(registry: ToolRegistry, services):
instanceLabel = instance.label or ""
userId = context.get("userId", "")
workspaceInstanceId = context.get("featureInstanceId", "")
+ requestLang = None
+ if userId:
+ langUser = rootIf.getUser(userId)
+ if langUser:
+ requestLang = getattr(langUser, "language", None)
rootDbConn = rootIf.db if hasattr(rootIf, "db") else None
if rootDbConn is None:
@@ -165,6 +170,7 @@ def _registerFeatureSubAgentTools(registry: ToolRegistry, services):
dbConnector=featureDbConn,
instanceLabel=instanceLabel,
tableFilters=tableFilters,
+ requestLang=requestLang,
)
_featureQueryCache[cacheKey] = (time.time(), answer)
diff --git a/modules/serviceCenter/services/serviceAgent/featureDataAgent.py b/modules/serviceCenter/services/serviceAgent/featureDataAgent.py
index 7ecc41e1..d1726fcd 100644
--- a/modules/serviceCenter/services/serviceAgent/featureDataAgent.py
+++ b/modules/serviceCenter/services/serviceAgent/featureDataAgent.py
@@ -39,6 +39,7 @@ async def runFeatureDataAgent(
dbConnector,
instanceLabel: str = "",
tableFilters: Optional[Dict[str, Dict[str, str]]] = None,
+ requestLang: Optional[str] = None,
) -> str:
"""Run the feature data sub-agent and return the textual result.
@@ -53,6 +54,7 @@ async def runFeatureDataAgent(
dbConnector: DatabaseConnector for queries.
instanceLabel: Human-readable instance name for context.
tableFilters: Per-table record filters from FeatureDataSource.recordFilter.
+ requestLang: ISO 639-1 code for resolving multilingual table labels in the schema prompt.
Returns:
Plain-text answer produced by the sub-agent.
@@ -69,7 +71,7 @@ async def runFeatureDataAgent(
if realCols:
meta["fields"] = realCols
- systemPrompt = _buildSchemaContext(featureCode, instanceLabel, selectedTables)
+ systemPrompt = _buildSchemaContext(featureCode, instanceLabel, selectedTables, requestLang)
config = AgentConfig(
maxRounds=_MAX_ROUNDS,
@@ -293,6 +295,7 @@ def _buildSchemaContext(
featureCode: str,
instanceLabel: str,
selectedTables: List[Dict[str, Any]],
+ requestLang: Optional[str] = None,
) -> str:
"""Build a system prompt describing available tables and query strategy."""
tableNames = []
@@ -303,7 +306,11 @@ def _buildSchemaContext(
tbl = meta.get("table", "?")
fields = meta.get("fields", [])
label = obj.get("label", {})
- labelStr = label.get("en") or label.get("de") or tbl
+ 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
tableNames.append(tbl)
block = f" Table: {tbl} ({labelStr})"
if fields:
diff --git a/modules/serviceCenter/services/serviceChat/mainServiceChat.py b/modules/serviceCenter/services/serviceChat/mainServiceChat.py
index f3a74b1e..b436d3e3 100644
--- a/modules/serviceCenter/services/serviceChat/mainServiceChat.py
+++ b/modules/serviceCenter/services/serviceChat/mainServiceChat.py
@@ -371,30 +371,36 @@ class ChatService:
return None
def getUserConnectionFromConnectionReference(self, connectionReference: str) -> Optional[UserConnection]:
- """Get UserConnection from reference string (handles new format without UUID)"""
+ """Get UserConnection from reference string.
+
+ Supported formats:
+ - connection:{authority}:{username} [status:..., token:...]
+ - A raw UUID (fallback: lookup by connection ID)
+ """
try:
- # Parse reference format: connection:{authority}:{username} [status:..., token:...]
- # Remove state information if present
- base_reference = connectionReference.split(' [')[0]
-
+ base_reference = connectionReference.split(' [')[0].strip()
+
parts = base_reference.split(':')
- if len(parts) != 3 or parts[0] != "connection":
- return None
-
- authority = parts[1]
- username = parts[2]
-
- # Get user connections through AppObjects interface
+ if len(parts) == 3 and parts[0] == "connection":
+ authority = parts[1]
+ username = parts[2]
+ user_connections = self.interfaceDbApp.getUserConnections(self.user.id)
+ for conn in user_connections:
+ connAuthority = conn.authority.value if hasattr(conn.authority, "value") else str(conn.authority)
+ if connAuthority == authority and conn.externalUsername == username:
+ return conn
+
+ # Fallback: treat the reference as a connection ID (UUID)
user_connections = self.interfaceDbApp.getUserConnections(self.user.id)
-
- # Find matching connection by authority and username (no UUID needed)
for conn in user_connections:
- if conn.authority.value == authority and conn.externalUsername == username:
+ connId = conn.get("id") if isinstance(conn, dict) else getattr(conn, "id", None)
+ if connId and str(connId) == base_reference:
return conn
+
return None
-
+
except Exception as e:
- logger.error(f"Error parsing connection reference: {str(e)}")
+ logger.error(f"Error parsing connection reference '{connectionReference}': {str(e)}")
return None
def getFreshConnectionToken(self, connectionId: str):
diff --git a/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py b/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py
index 9404a567..378c83cf 100644
--- a/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py
+++ b/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py
@@ -146,12 +146,15 @@ class KnowledgeService:
# 3. Chunk text content objects and create embeddings
textObjects = [o for o in contentObjects if o.get("contentType") == "text"]
- if _shouldNeutralize and textObjects:
- _neutralizedObjects = []
+ _neutralSvc = None
+ if _shouldNeutralize:
try:
_neutralSvc = self._getService("neutralization")
except Exception:
- _neutralSvc = None
+ logger.warning(f"Neutralization service unavailable for file {fileId}")
+
+ if _shouldNeutralize and textObjects:
+ _neutralizedObjects = []
if _neutralSvc:
for _obj in textObjects:
_textContent = (_obj.get("data", "") or "").strip()
@@ -201,7 +204,7 @@ class KnowledgeService:
# 4. Store non-text content objects (images, etc.) without embedding
nonTextObjects = [o for o in contentObjects if o.get("contentType") != "text"]
- if _shouldNeutralize and nonTextObjects:
+ if _shouldNeutralize and nonTextObjects and _neutralSvc:
import base64 as _b64
_filteredNonText = []
for _obj in nonTextObjects:
diff --git a/modules/shared/attributeUtils.py b/modules/shared/attributeUtils.py
index ea92c0b8..b36addf5 100644
--- a/modules/shared/attributeUtils.py
+++ b/modules/shared/attributeUtils.py
@@ -57,25 +57,43 @@ def getModelLabels(modelName: str, language: str = "en") -> Dict[str, str]:
result: Dict[str, str] = {}
for attr, translations in attributeLabels.items():
if isinstance(translations, dict):
- result[attr] = translations.get(language, translations.get("en", attr))
+ 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] = attr
+ result[attr] = f"[{attr}]"
return result
def _resolveLabel(germanText: str, language: str) -> str:
- """Resolve a German base label to the requested language via i18n cache."""
+ """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, germanText)
+ 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."""
+ if not isinstance(options, list):
+ return options
+ 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)
+ return options
+
+
def _mergedAttributeLabels(modelClass: Type[BaseModel], userLanguage: str) -> Dict[str, str]:
"""Merge attribute labels from model MRO (base classes first, subclass overrides)."""
try:
@@ -88,15 +106,16 @@ def _mergedAttributeLabels(modelClass: Type[BaseModel], userLanguage: str) -> Di
return merged
-def getModelLabel(modelName: str, language: str = "en") -> str:
+def getModelLabel(modelName: str, language: str = "de") -> str:
"""Get the label for a model in the specified language (see getModelLabels)."""
modelData = _getModelLabelEntry(modelName)
modelLabel = modelData.get("model", {})
if isinstance(modelLabel, dict):
- return modelLabel.get(language, modelLabel.get("en", modelName))
+ germanKey = modelLabel.get("xx") or next(iter(modelLabel.values()), modelName)
+ return _resolveLabel(germanKey, language)
elif isinstance(modelLabel, str):
return _resolveLabel(modelLabel, language)
- return modelName
+ return f"[{modelName}]"
def getModelAttributeDefinitions(modelClass: Type[BaseModel] = None, userLanguage: str = "en") -> Dict[str, Any]:
@@ -254,7 +273,7 @@ def getModelAttributeDefinitions(modelClass: Type[BaseModel] = None, userLanguag
"visible": frontend_visible,
"order": len(attributes),
"readonly": frontend_readonly,
- "options": frontend_options,
+ "options": _resolveOptionLabels(frontend_options, userLanguage),
"default": field_default,
}
diff --git a/modules/shared/i18nRegistry.py b/modules/shared/i18nRegistry.py
index c44a65b1..7b8c2baf 100644
--- a/modules/shared/i18nRegistry.py
+++ b/modules/shared/i18nRegistry.py
@@ -20,6 +20,16 @@ from pydantic import BaseModel
logger = logging.getLogger(__name__)
+
+def _extractRegistrySourceText(obj: Any) -> str:
+ """Resolve a str or multilingual dict to one canonical registry key string."""
+ if isinstance(obj, str):
+ return obj
+ if isinstance(obj, dict):
+ return obj.get("xx") or next(iter(obj.values()), "") or ""
+ return ""
+
+
# ---------------------------------------------------------------------------
# Registry (populated at import time by t() and @i18nModel)
# ---------------------------------------------------------------------------
@@ -60,14 +70,14 @@ def t(key: str, context: str = "api", value: str = "") -> str:
At import time: registers the key with context and AI description.
At runtime: returns the cached translation for _CURRENT_LANGUAGE.
- Falls back to the key itself (German base text) if no translation found.
+ Falls back to [key] so missing translations are visible in the UI.
"""
if key not in _REGISTRY:
_REGISTRY[key] = _I18nRegistryEntry(context=context, value=value)
lang = _CURRENT_LANGUAGE.get()
if lang == "de":
return key
- return _CACHE.get(lang, {}).get(key, key)
+ return _CACHE.get(lang, {}).get(key, f"[{key}]")
def apiRouteContext(routeModuleName: str):
@@ -265,7 +275,7 @@ def _registerFeatureUiLabels():
_REGISTRY[lab] = _I18nRegistryEntry(context="nav", value="")
added += 1
elif isinstance(lab, dict):
- base = lab.get("de") or lab.get("en")
+ base = lab.get("xx") or next(iter(lab.values()), "")
if base and base not in _REGISTRY:
_REGISTRY[base] = _I18nRegistryEntry(context="nav", value="")
added += 1
@@ -279,9 +289,9 @@ def _registerRbacLabels():
context mapping:
- DATA_OBJECTS → rbac.data
- RESOURCE_OBJECTS → rbac.resource
- - TEMPLATE_ROLES[].description (de) → rbac.role
- - QUICK_ACTIONS[].label/description (de) → rbac.quickaction
- - QUICK_ACTION_CATEGORIES[].label (de) → rbac.quickaction
+ - TEMPLATE_ROLES[].description (xx source) → rbac.role
+ - QUICK_ACTIONS[].label/description (xx source) → rbac.quickaction
+ - QUICK_ACTION_CATEGORIES[].label (xx source) → rbac.quickaction
"""
_systemModule = "modules.system.mainSystem"
_featureModulePaths = (
@@ -296,13 +306,6 @@ def _registerRbacLabels():
"modules.features.chatbot.mainChatbot",
)
- def _extractDe(obj) -> str:
- if isinstance(obj, str):
- return obj
- if isinstance(obj, dict):
- return obj.get("de") or obj.get("en") or ""
- return ""
-
added = 0
for modPath in _featureModulePaths:
try:
@@ -314,32 +317,32 @@ def _registerRbacLabels():
continue
for dataObj in getattr(mod, "DATA_OBJECTS", []) or []:
- key = _extractDe(dataObj.get("label"))
+ key = _extractRegistrySourceText(dataObj.get("label"))
if key and key not in _REGISTRY:
_REGISTRY[key] = _I18nRegistryEntry(context="rbac.data", value="")
added += 1
for resObj in getattr(mod, "RESOURCE_OBJECTS", []) or []:
- key = _extractDe(resObj.get("label"))
+ key = _extractRegistrySourceText(resObj.get("label"))
if key and key not in _REGISTRY:
_REGISTRY[key] = _I18nRegistryEntry(context="rbac.resource", value="")
added += 1
for role in getattr(mod, "TEMPLATE_ROLES", []) or []:
- key = _extractDe(role.get("description"))
+ key = _extractRegistrySourceText(role.get("description"))
if key and key not in _REGISTRY:
_REGISTRY[key] = _I18nRegistryEntry(context="rbac.role", value="")
added += 1
for qa in getattr(mod, "QUICK_ACTIONS", []) or []:
for field in ("label", "description"):
- key = _extractDe(qa.get(field))
+ key = _extractRegistrySourceText(qa.get(field))
if key and key not in _REGISTRY:
_REGISTRY[key] = _I18nRegistryEntry(context="rbac.quickaction", value="")
added += 1
for cat in getattr(mod, "QUICK_ACTION_CATEGORIES", []) or []:
- key = _extractDe(cat.get("label"))
+ key = _extractRegistrySourceText(cat.get("label"))
if key and key not in _REGISTRY:
_REGISTRY[key] = _I18nRegistryEntry(context="rbac.quickaction", value="")
added += 1
@@ -351,17 +354,10 @@ def _registerServiceCenterLabels():
"""Register service-center category labels and bootstrap role descriptions."""
added = 0
- def _extractDe(obj) -> str:
- if isinstance(obj, str):
- return obj
- if isinstance(obj, dict):
- return obj.get("de") or obj.get("en") or ""
- return ""
-
try:
from modules.serviceCenter.registry import IMPORTABLE_SERVICES
for svc in IMPORTABLE_SERVICES.values():
- key = _extractDe(svc.get("label"))
+ key = _extractRegistrySourceText(svc.get("label"))
if key and key not in _REGISTRY:
_REGISTRY[key] = _I18nRegistryEntry(context="service", value="")
added += 1
@@ -387,13 +383,6 @@ def _registerNodeLabels():
output labels, port descriptions, category labels, and entry-point titles."""
added = 0
- def _extractDe(obj) -> str:
- if isinstance(obj, str):
- return obj
- if isinstance(obj, dict):
- return obj.get("de") or obj.get("en") or ""
- return ""
-
def _reg(key: str, ctx: str):
nonlocal added
if key and key not in _REGISTRY:
@@ -403,17 +392,19 @@ def _registerNodeLabels():
try:
from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES
for nd in STATIC_NODE_TYPES:
- _reg(_extractDe(nd.get("label")), "node.label")
- _reg(_extractDe(nd.get("description")), "node.desc")
+ _reg(_extractRegistrySourceText(nd.get("label")), "node.label")
+ _reg(_extractRegistrySourceText(nd.get("description")), "node.desc")
for param in nd.get("parameters", []) or []:
- _reg(_extractDe(param.get("description")), "node.param")
- _reg(_extractDe(param.get("label")), "node.param")
+ _reg(_extractRegistrySourceText(param.get("description")), "node.param")
+ _reg(_extractRegistrySourceText(param.get("label")), "node.param")
outLabels = nd.get("outputLabels")
if isinstance(outLabels, dict):
- deList = outLabels.get("de") or outLabels.get("en") or []
- for lbl in deList:
+ sourceList = outLabels.get("xx") or next(iter(outLabels.values()), [])
+ if not isinstance(sourceList, list):
+ sourceList = []
+ for lbl in sourceList:
_reg(lbl, "node.output")
elif isinstance(outLabels, list):
for lbl in outLabels:
@@ -427,7 +418,7 @@ def _registerNodeLabels():
for field in getattr(schema, "fields", []) or []:
desc = getattr(field, "description", None)
if desc:
- _reg(_extractDe(desc if isinstance(desc, (str, dict)) else None), "port.desc")
+ _reg(_extractRegistrySourceText(desc if isinstance(desc, (str, dict)) else None), "port.desc")
except ImportError:
pass
@@ -449,13 +440,6 @@ def _registerDatamodelOptionLabels():
"""Register all frontend_options labels from Pydantic datamodels and subscription plans."""
added = 0
- def _extractDe(obj) -> str:
- if isinstance(obj, str):
- return obj
- if isinstance(obj, dict):
- return obj.get("de") or obj.get("en") or ""
- return ""
-
def _reg(key: str, ctx: str):
nonlocal added
if key and key not in _REGISTRY:
@@ -495,13 +479,13 @@ def _registerDatamodelOptionLabels():
ctx = f"option.{cls.__name__}.{fieldName}"
for opt in options:
if isinstance(opt, dict):
- _reg(_extractDe(opt.get("label")), ctx)
+ _reg(_extractRegistrySourceText(opt.get("label")), ctx)
try:
from modules.datamodels.datamodelSubscription import BUILTIN_PLANS
for plan in BUILTIN_PLANS.values():
- _reg(_extractDe(getattr(plan, "title", None)), "subscription.title")
- _reg(_extractDe(getattr(plan, "description", None)), "subscription.desc")
+ _reg(_extractRegistrySourceText(getattr(plan, "title", None)), "subscription.title")
+ _reg(_extractRegistrySourceText(getattr(plan, "description", None)), "subscription.desc")
except (ImportError, AttributeError):
pass
diff --git a/modules/workflows/automation2/subAutomation2Schedule.py b/modules/workflows/automation2/subAutomation2Schedule.py
index 2e551eef..01f1efe8 100644
--- a/modules/workflows/automation2/subAutomation2Schedule.py
+++ b/modules/workflows/automation2/subAutomation2Schedule.py
@@ -6,7 +6,7 @@ Starts/stops cron jobs for workflows with schedule entry points.
import asyncio
import logging
-from typing import Any, Dict
+from typing import Any, Dict, Optional
from modules.shared.eventManagement import eventManager
@@ -213,11 +213,14 @@ def _create_schedule_handler(
discoverMethods(services)
title = (inv or {}).get("title") or {}
- label = ""
+ requestLang: Optional[str] = getattr(event_user, "language", None)
if isinstance(title, dict):
- label = title.get("en") or title.get("de") or ""
+ 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 = ""
run_env = default_run_envelope(
"schedule",
diff --git a/modules/workflows/scheduler/mainScheduler.py b/modules/workflows/scheduler/mainScheduler.py
index 5f888237..c869bb69 100644
--- a/modules/workflows/scheduler/mainScheduler.py
+++ b/modules/workflows/scheduler/mainScheduler.py
@@ -11,7 +11,7 @@ Replaces subAutomation2Schedule with v1-style incremental sync patterns:
import asyncio
import logging
-from typing import Any, Dict
+from typing import Any, Dict, Optional
from modules.shared.eventManagement import eventManager
@@ -231,11 +231,14 @@ class WorkflowScheduler:
discoverMethods(services)
title = (inv or {}).get("title") or {}
- label = ""
+ requestLang: Optional[str] = getattr(eventUser, "language", None)
if isinstance(title, dict):
- label = title.get("en") or title.get("de") or ""
+ 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 = ""
runEnv = default_run_envelope(
"schedule",