fixed language logic items

This commit is contained in:
ValueOn AG 2026-04-11 19:44:58 +02:00
parent 0f5d695960
commit 4dfc0afd06
37 changed files with 978 additions and 277 deletions

View file

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

View file

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

View file

@ -22,7 +22,7 @@ UI_OBJECTS = [
},
{
"objectKey": "ui.feature.commcoach.coaching",
"label": "Coaching & Dossier",
"label": "Arbeitsthemen",
"meta": {"area": "coaching"}
},
{

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -29,7 +29,7 @@ from modules.interfaces.interfaceAiObjects import AiObjects
from modules.serviceCenter.core.serviceStreaming import get_event_manager
from modules.serviceCenter.services.serviceAgent.datamodelAgent import AgentEventTypeEnum, PendingFileEdit
from modules.shared.timeUtils import parseTimestamp
from modules.shared.i18nRegistry import apiRouteContext
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)

View file

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

View file

@ -678,11 +678,15 @@ 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]
@ -690,8 +694,6 @@ class ChatObjects:
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):

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,9 +98,7 @@ def _convertParameterSchema(actionParams: Dict[str, Any]) -> Dict[str, Any]:
}
def _pythonTypeToJsonType(pythonType: str) -> str:
"""Map Python type strings to JSON Schema types."""
mapping = {
_TYPE_MAPPING = {
"str": "string",
"int": "integer",
"float": "number",
@ -107,9 +108,27 @@ def _pythonTypeToJsonType(pythonType: str) -> str:
"List[str]": "array",
"List[int]": "array",
"List[dict]": "array",
"List[float]": "array",
"Dict[str, Any]": "object",
}
return mapping.get(pythonType, "string")
}
_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."""
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):

View file

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

View file

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

View file

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

View file

@ -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
if len(parts) == 3 and parts[0] == "connection":
authority = parts[1]
username = parts[2]
# Get user connections through AppObjects interface
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:
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)
for conn in user_connections:
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):

View file

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

View file

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

View file

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

View file

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

View file

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