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