fixed language logic items

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

View file

@ -2,6 +2,7 @@
# All rights reserved. # All rights reserved.
"""Utility datamodels: Prompt, TextMultilingual.""" """Utility datamodels: Prompt, TextMultilingual."""
import re as _re
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
from modules.datamodels.datamodelBase import PowerOnModel from modules.datamodels.datamodelBase import PowerOnModel
@ -39,68 +40,95 @@ class Prompt(PowerOnModel):
@field_validator('isSystem', mode='before') @field_validator('isSystem', mode='before')
@classmethod @classmethod
def _coerceIsSystem(cls, v): def _coerceIsSystem(cls, v):
"""Existing records may have isSystem=None (field didn't exist). Treat None as False."""
if v is None: if v is None:
return False return False
return v return v
class TextMultilingual(BaseModel): class TextMultilingual(BaseModel):
"""Multilingual text field. Language codes follow ISO 639-1 (en, de, fr, it, …).""" """Multilingual text field stored as JSONB: {"xx": "source text", "de": "...", "en": "...", ...}.
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")
@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 @classmethod
def _validateEnRequired(cls, v): def _validateXxRequired(cls, v):
if not v or not v.strip(): 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 return v
def model_dump(self, **kwargs) -> Dict[str, str]: def model_dump(self, **kwargs) -> Dict[str, str]:
result = {} result = {"xx": self.xx}
for key in self.model_fields: if self.__pydantic_extra__:
value = getattr(self, key, None) for k, v in self.__pydantic_extra__.items():
if value is not None: if v is not None and isinstance(v, str):
result[key] = value result[k] = v
return result return result
@classmethod @classmethod
def from_dict(cls, data: Dict[str, str]) -> 'TextMultilingual': def from_dict(cls, data: Dict[str, str]) -> 'TextMultilingual':
fields = {k: data[k] for k in cls.model_fields if k in data} cleaned = {k: v for k, v in data.items() if v is not None and isinstance(v, str)}
fields.setdefault('en', '') if not cleaned.get('xx'):
return cls(**fields) 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: def get_text(self, lang: str = 'de') -> str:
"""Get text for *lang*. Falls back to English.""" """Get text for a language. Falls back to xx (source text)."""
value = getattr(self, lang, None) if lang == 'xx':
if value: return self.xx
extra = self.__pydantic_extra__ or {}
value = extra.get(lang)
if value and isinstance(value, str):
return value return value
return self.en return self.xx
@classmethod @classmethod
def fromUniform(cls, text: str) -> "TextMultilingual": 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() t = text.strip()
if not t: if not t:
raise ValueError("Text must be non-empty") 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: 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): if isinstance(val, TextMultilingual):
return val return val
if isinstance(val, dict): if isinstance(val, dict):
if not val: if not val:
return TextMultilingual.fromUniform("") return TextMultilingual.fromUniform("")
d = {k: val[k] for k in TextMultilingual.model_fields if k in val and val[k] is not None} cleaned = {k: v for k, v in val.items() if v is not None and isinstance(v, str)}
if not d.get("en"): if not cleaned.get("xx"):
d["en"] = (d.get("de") or d.get("fr") or "").strip() or "" cleaned["xx"] = cleaned.get("de") or next((v for v in cleaned.values() if v), "")
return TextMultilingual(**{k: d[k] for k in TextMultilingual.model_fields if k in d}) return TextMultilingual(**cleaned)
if isinstance(val, str) and val.strip(): 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(val)
return TextMultilingual.fromUniform("") return TextMultilingual.fromUniform("")

View file

@ -13,6 +13,7 @@ from modules.datamodels.datamodelUam import User
from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.shared.timeUtils import getIsoTimestamp from modules.shared.timeUtils import getIsoTimestamp
from modules.shared.configuration import APP_CONFIG from modules.shared.configuration import APP_CONFIG
from modules.shared.i18nRegistry import t
from .datamodelCommcoach import ( from .datamodelCommcoach import (
CoachingContext, CoachingContextStatus, CoachingContext, CoachingContextStatus,
@ -412,9 +413,17 @@ def _calcGoalProgress(goalsRaw) -> Optional[int]:
return round(done / len(goals) * 100) 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]: def _calcLevel(totalSessions: int) -> Dict[str, Any]:
levels = [(50, 5, "Meister"), (25, 4, "Experte"), (10, 3, "Fortgeschritten"), (3, 2, "Engagiert")] for threshold, number, code, _label in _LEVELS:
for threshold, number, label in levels:
if totalSessions >= threshold: if totalSessions >= threshold:
return {"number": number, "label": label, "totalSessions": totalSessions} return {"number": number, "code": code, "label": t(_label), "totalSessions": totalSessions}
return {"number": 1, "label": "Einsteiger", "totalSessions": totalSessions} return {"number": 1, "code": "beginner", "label": t("Einsteiger"), "totalSessions": totalSessions}

View file

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

View file

@ -7,6 +7,7 @@ Checks and awards badges after each session completion.
import logging import logging
from typing import Dict, Any, List, Optional from typing import Dict, Any, List, Optional
from modules.shared.i18nRegistry import t
logger = logging.getLogger(__name__) 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, async def checkAndAwardBadges(interface, userId: str, mandateId: str, instanceId: str,
session: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]: 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) newBadge = interface.awardBadge(badgeData)
definition = BADGE_DEFINITIONS.get(badgeKey, {}) definition = BADGE_DEFINITIONS.get(badgeKey, {})
newBadge["label"] = definition.get("label", badgeKey) newBadge["label"] = t(definition.get("label", badgeKey))
newBadge["description"] = definition.get("description", "") newBadge["description"] = t(definition.get("description", ""))
newBadge["icon"] = definition.get("icon", "star") newBadge["icon"] = definition.get("icon", "star")
awarded.append(newBadge) awarded.append(newBadge)
logger.info(f"Badge '{badgeKey}' awarded to user {userId}") 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]]: def getBadgeDefinitions() -> Dict[str, Dict[str, Any]]:
"""Return all badge definitions for the frontend.""" """Return all badge definitions for the frontend (labels resolved via i18n)."""
return BADGE_DEFINITIONS resolved = {}
for key, defn in BADGE_DEFINITIONS.items():
resolved[key] = {**defn, "label": t(defn["label"]), "description": t(defn["description"])}
return resolved

View file

@ -36,9 +36,10 @@ def default_manual_entry_point() -> Dict[str, Any]:
} }
def _normalize_title(title: Any) -> str: def _normalize_title(title: Any, preferredLang: Optional[str] = None) -> str:
if isinstance(title, dict): 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(): if isinstance(title, str) and title.strip():
return title.strip() return title.strip()
return "Start" return "Start"

View file

@ -481,7 +481,7 @@ def _deriveFormPayloadSchema(node: Dict[str, Any]) -> Optional[PortSchema]:
if isinstance(f, dict) and f.get("name"): if isinstance(f, dict) and f.get("name"):
_lab = f.get("label") _lab = f.get("label")
_desc = ( _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) if isinstance(_lab, dict)
else str(_lab if _lab is not None else f["name"]) else str(_lab if _lab is not None else f["name"])
) )

View file

@ -32,10 +32,20 @@ routeApiMsg = apiRouteContext("routeFeatureGraphicalEditor")
logger = logging.getLogger(__name__) 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( def _build_execute_run_envelope(
body: Dict[str, Any], body: Dict[str, Any],
workflow: Optional[Dict[str, Any]], workflow: Optional[Dict[str, Any]],
user_id: Optional[str], user_id: Optional[str],
requestLang: Optional[str] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Build normalized run envelope from POST /execute body.""" """Build normalized run envelope from POST /execute body."""
if isinstance(body.get("runEnvelope"), dict): if isinstance(body.get("runEnvelope"), dict):
@ -70,11 +80,7 @@ def _build_execute_run_envelope(
} }
trig = trig_map.get(kind, "manual") trig = trig_map.get(kind, "manual")
title = inv.get("title") or {} title = inv.get("title") or {}
label = "" label = _pickInvocationTitleLabel(title, requestLang)
if isinstance(title, dict):
label = title.get("en") or title.get("de") or ""
elif isinstance(title, str):
label = title
base = default_run_envelope( base = default_run_envelope(
trig, trig,
entry_point_id=inv.get("id"), entry_point_id=inv.get("id"),
@ -222,7 +228,12 @@ async def post_execute(
workflowId, workflowId,
mandateId, 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) ge_interface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
result = await executeGraph( result = await executeGraph(
@ -1041,11 +1052,10 @@ async def post_workflow_webhook(
discoverMethods(services) discoverMethods(services)
title = inv.get("title") or {} title = inv.get("title") or {}
label = "" label = _pickInvocationTitleLabel(
if isinstance(title, dict): title,
label = title.get("en") or title.get("de") or "" getattr(context.user, "language", None) if context.user else None,
elif isinstance(title, str): )
label = title
pl = body if isinstance(body, dict) else {} pl = body if isinstance(body, dict) else {}
base = default_run_envelope( base = default_run_envelope(
"webhook", "webhook",
@ -1103,11 +1113,10 @@ async def post_workflow_form_submit(
discoverMethods(services) discoverMethods(services)
title = inv.get("title") or {} title = inv.get("title") or {}
label = "" label = _pickInvocationTitleLabel(
if isinstance(title, dict): title,
label = title.get("en") or title.get("de") or "" getattr(context.user, "language", None) if context.user else None,
elif isinstance(title, str): )
label = title
pl = body if isinstance(body, dict) else {} pl = body if isinstance(body, dict) else {}
base = default_run_envelope( base = default_run_envelope(
"form", "form",

View file

@ -4,7 +4,7 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import List, Optional, Dict, Any from typing import List, Optional, Dict, Any
from pydantic import BaseModel, Field from pydantic import BaseModel
class AccountingBookingLine(BaseModel): class AccountingBookingLine(BaseModel):
@ -51,7 +51,7 @@ class SyncResult(BaseModel):
class ConnectorConfigField(BaseModel): class ConnectorConfigField(BaseModel):
"""Describes a configuration field required by a connector.""" """Describes a configuration field required by a connector."""
key: str key: str
label: Dict[str, str] label: str
fieldType: str = "text" fieldType: str = "text"
secret: bool = False secret: bool = False
required: bool = True required: bool = True
@ -70,8 +70,8 @@ class BaseAccountingConnector(ABC):
"""Unique type identifier, e.g. 'rma', 'bexio', 'abacus'.""" """Unique type identifier, e.g. 'rma', 'bexio', 'abacus'."""
@abstractmethod @abstractmethod
def getConnectorLabel(self) -> Dict[str, str]: def getConnectorLabel(self) -> str:
"""I18n display label.""" """German plaintext label (used as i18n key)."""
@abstractmethod @abstractmethod
def getRequiredConfigFields(self) -> List[ConnectorConfigField]: def getRequiredConfigFields(self) -> List[ConnectorConfigField]:

View file

@ -10,6 +10,7 @@ import os
from typing import Dict, List, Optional from typing import Dict, List, Optional
from .accountingConnectorBase import BaseAccountingConnector from .accountingConnectorBase import BaseAccountingConnector
from modules.shared.i18nRegistry import t
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -58,10 +59,15 @@ class AccountingRegistry:
self.discoverConnectors() self.discoverConnectors()
result = [] result = []
for connectorType, connector in self._connectors.items(): 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({ result.append({
"connectorType": connectorType, "connectorType": connectorType,
"label": connector.getConnectorLabel(), "label": t(connector.getConnectorLabel()),
"configFields": [f.model_dump() for f in connector.getRequiredConfigFields()], "configFields": fields,
}) })
return result return result

View file

@ -34,7 +34,7 @@ class AccountingConnectorAbacus(BaseAccountingConnector):
def getConnectorType(self) -> str: def getConnectorType(self) -> str:
return "abacus" return "abacus"
def getConnectorLabel(self) -> Dict[str, str]: def getConnectorLabel(self) -> str:
return "Abacus ERP" return "Abacus ERP"
def getRequiredConfigFields(self) -> List[ConnectorConfigField]: def getRequiredConfigFields(self) -> List[ConnectorConfigField]:

View file

@ -35,7 +35,7 @@ class AccountingConnectorBexio(BaseAccountingConnector):
def getConnectorType(self) -> str: def getConnectorType(self) -> str:
return "bexio" return "bexio"
def getConnectorLabel(self) -> Dict[str, str]: def getConnectorLabel(self) -> str:
return "Bexio" return "Bexio"
def getRequiredConfigFields(self) -> List[ConnectorConfigField]: def getRequiredConfigFields(self) -> List[ConnectorConfigField]:

View file

@ -35,7 +35,7 @@ class AccountingConnectorRma(BaseAccountingConnector):
def getConnectorType(self) -> str: def getConnectorType(self) -> str:
return "rma" return "rma"
def getConnectorLabel(self) -> Dict[str, str]: def getConnectorLabel(self) -> str:
return "Run My Accounts" return "Run My Accounts"
def getRequiredConfigFields(self) -> List[ConnectorConfigField]: def getRequiredConfigFields(self) -> List[ConnectorConfigField]:

View file

@ -155,7 +155,7 @@ def getQuickActions(
if isinstance(multilingual, str): if isinstance(multilingual, str):
return multilingual return multilingual
if isinstance(multilingual, dict): 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 "" return ""
filteredActions = [] filteredActions = []

View file

@ -29,7 +29,7 @@ from modules.interfaces.interfaceAiObjects import AiObjects
from modules.serviceCenter.core.serviceStreaming import get_event_manager from modules.serviceCenter.core.serviceStreaming import get_event_manager
from modules.serviceCenter.services.serviceAgent.datamodelAgent import AgentEventTypeEnum, PendingFileEdit from modules.serviceCenter.services.serviceAgent.datamodelAgent import AgentEventTypeEnum, PendingFileEdit
from modules.shared.timeUtils import parseTimestamp from modules.shared.timeUtils import parseTimestamp
from modules.shared.i18nRegistry import apiRouteContext from modules.shared.i18nRegistry import apiRouteContext, t
routeApiMsg = apiRouteContext("routeFeatureWorkspace") routeApiMsg = apiRouteContext("routeFeatureWorkspace")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -135,10 +135,11 @@ def _validateInstanceAccess(instanceId: str, context: RequestContext):
return mandateId, instanceConfig 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( return interfaceDbChat.getInterface(
context.user, context.user,
mandateId=str(context.mandateId) if context.mandateId else None, mandateId=effectiveMandateId,
featureInstanceId=featureInstanceId, featureInstanceId=featureInstanceId,
) )
@ -543,7 +544,7 @@ async def streamWorkspaceStart(
): ):
"""Start or continue a Workspace session with SSE streaming via serviceAgent.""" """Start or continue a Workspace session with SSE streaming via serviceAgent."""
mandateId, instanceConfig = _validateInstanceAccess(instanceId, context) mandateId, instanceConfig = _validateInstanceAccess(instanceId, context)
chatInterface = _getChatInterface(context, featureInstanceId=instanceId) chatInterface = _getChatInterface(context, featureInstanceId=instanceId, mandateId=mandateId)
aiObjects = await _getAiObjects() aiObjects = await _getAiObjects()
eventManager = get_event_manager() eventManager = get_event_manager()
@ -907,7 +908,7 @@ async def stopWorkspace(
workflowId: str = Path(...), workflowId: str = Path(...),
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
): ):
_validateInstanceAccess(instanceId, context) _mandateId, _ = _validateInstanceAccess(instanceId, context)
queueId = f"workspace-{workflowId}" queueId = f"workspace-{workflowId}"
eventManager = get_event_manager() eventManager = get_event_manager()
cancelled = await eventManager.cancel_agent(queueId) cancelled = await eventManager.cancel_agent(queueId)
@ -933,8 +934,8 @@ async def listWorkspaceWorkflows(
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
): ):
"""List workspace workflows/conversations for this instance.""" """List workspace workflows/conversations for this instance."""
_validateInstanceAccess(instanceId, context) _mandateId, _ = _validateInstanceAccess(instanceId, context)
chatInterface = _getChatInterface(context, featureInstanceId=instanceId) chatInterface = _getChatInterface(context, featureInstanceId=instanceId, mandateId=_mandateId)
workflows = chatInterface.getWorkflows() or [] workflows = chatInterface.getWorkflows() or []
from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceDbApp import getRootInterface
@ -1007,8 +1008,8 @@ async def resolveRag(
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
): ):
"""Build a RAG summary for a chat (workflow) to inject into the input area.""" """Build a RAG summary for a chat (workflow) to inject into the input area."""
_validateInstanceAccess(instanceId, context) _mandateId, _ = _validateInstanceAccess(instanceId, context)
chatInterface = _getChatInterface(context, featureInstanceId=instanceId) chatInterface = _getChatInterface(context, featureInstanceId=instanceId, mandateId=_mandateId)
messages = chatInterface.getMessages(body.chatId) or [] messages = chatInterface.getMessages(body.chatId) or []
texts = [] texts = []
@ -1037,8 +1038,8 @@ async def patchWorkspaceWorkflow(
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
): ):
"""Update a workspace workflow (e.g. rename).""" """Update a workspace workflow (e.g. rename)."""
_validateInstanceAccess(instanceId, context) _mandateId, _ = _validateInstanceAccess(instanceId, context)
chatInterface = _getChatInterface(context, featureInstanceId=instanceId) chatInterface = _getChatInterface(context, featureInstanceId=instanceId, mandateId=_mandateId)
workflow = chatInterface.getWorkflow(workflowId) workflow = chatInterface.getWorkflow(workflowId)
if not workflow: if not workflow:
raise HTTPException(status_code=404, detail=f"Workflow {workflowId} not found") raise HTTPException(status_code=404, detail=f"Workflow {workflowId} not found")
@ -1071,8 +1072,8 @@ async def deleteWorkspaceWorkflow(
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
): ):
"""Delete a workspace workflow and its messages.""" """Delete a workspace workflow and its messages."""
_validateInstanceAccess(instanceId, context) _mandateId, _ = _validateInstanceAccess(instanceId, context)
chatInterface = _getChatInterface(context, featureInstanceId=instanceId) chatInterface = _getChatInterface(context, featureInstanceId=instanceId, mandateId=_mandateId)
workflow = chatInterface.getWorkflow(workflowId) workflow = chatInterface.getWorkflow(workflowId)
if not workflow: if not workflow:
raise HTTPException(status_code=404, detail=f"Workflow {workflowId} not found") raise HTTPException(status_code=404, detail=f"Workflow {workflowId} not found")
@ -1089,8 +1090,8 @@ async def createWorkspaceWorkflow(
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
): ):
"""Create a new empty workspace workflow.""" """Create a new empty workspace workflow."""
_validateInstanceAccess(instanceId, context) _mandateId, _ = _validateInstanceAccess(instanceId, context)
chatInterface = _getChatInterface(context, featureInstanceId=instanceId) chatInterface = _getChatInterface(context, featureInstanceId=instanceId, mandateId=_mandateId)
name = body.get("name", "Neuer Chat") name = body.get("name", "Neuer Chat")
workflow = chatInterface.createWorkflow({ workflow = chatInterface.createWorkflow({
"featureInstanceId": instanceId, "featureInstanceId": instanceId,
@ -1112,8 +1113,8 @@ async def getWorkspaceMessages(
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
): ):
"""Get all messages for a workspace workflow/conversation.""" """Get all messages for a workspace workflow/conversation."""
_validateInstanceAccess(instanceId, context) _mandateId, _ = _validateInstanceAccess(instanceId, context)
chatInterface = _getChatInterface(context, featureInstanceId=instanceId) chatInterface = _getChatInterface(context, featureInstanceId=instanceId, mandateId=_mandateId)
messages = chatInterface.getMessages(workflowId) or [] messages = chatInterface.getMessages(workflowId) or []
items = [_workspaceMessageToClientDict(m) for m in messages] items = [_workspaceMessageToClientDict(m) for m in messages]
items.sort( items.sort(
@ -1140,7 +1141,7 @@ async def listWorkspaceFiles(
search: Optional[str] = Query(None), search: Optional[str] = Query(None),
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
): ):
_validateInstanceAccess(instanceId, context) _mandateId, _ = _validateInstanceAccess(instanceId, context)
dbMgmt = _getDbManagement(context, featureInstanceId=instanceId) dbMgmt = _getDbManagement(context, featureInstanceId=instanceId)
files = dbMgmt.getAllFiles() files = dbMgmt.getAllFiles()
@ -1172,7 +1173,7 @@ async def getFileContent(
): ):
"""Return the raw content of a file for preview.""" """Return the raw content of a file for preview."""
from fastapi.responses import Response from fastapi.responses import Response
_validateInstanceAccess(instanceId, context) _mandateId, _ = _validateInstanceAccess(instanceId, context)
dbMgmt = _getDbManagement(context, featureInstanceId=instanceId) dbMgmt = _getDbManagement(context, featureInstanceId=instanceId)
fileRecord = dbMgmt.getFile(fileId) fileRecord = dbMgmt.getFile(fileId)
if not fileRecord: if not fileRecord:
@ -1198,13 +1199,13 @@ async def listWorkspaceFolders(
parentId: Optional[str] = Query(None), parentId: Optional[str] = Query(None),
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
): ):
_validateInstanceAccess(instanceId, context) _mandateId, _ = _validateInstanceAccess(instanceId, context)
try: try:
from modules.serviceCenter import getService from modules.serviceCenter import getService
from modules.serviceCenter.context import ServiceCenterContext from modules.serviceCenter.context import ServiceCenterContext
ctx = ServiceCenterContext( ctx = ServiceCenterContext(
user=context.user, user=context.user,
mandate_id=str(context.mandateId) if context.mandateId else None, mandate_id=_mandateId or "",
feature_instance_id=instanceId, feature_instance_id=instanceId,
) )
chatService = getService("chat", ctx) chatService = getService("chat", ctx)
@ -1243,12 +1244,12 @@ async def listWorkspaceConnections(
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
): ):
"""Return the user's active connections (UserConnections).""" """Return the user's active connections (UserConnections)."""
_validateInstanceAccess(instanceId, context) _mandateId, _ = _validateInstanceAccess(instanceId, context)
from modules.serviceCenter import getService from modules.serviceCenter import getService
from modules.serviceCenter.context import ServiceCenterContext from modules.serviceCenter.context import ServiceCenterContext
ctx = ServiceCenterContext( ctx = ServiceCenterContext(
user=context.user, user=context.user,
mandate_id=str(context.mandateId) if context.mandateId else None, mandate_id=_mandateId or "",
feature_instance_id=instanceId, feature_instance_id=instanceId,
) )
chatService = getService("chat", ctx) chatService = getService("chat", ctx)
@ -1290,12 +1291,12 @@ async def createWorkspaceDataSource(
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
): ):
"""Create a new DataSource for this workspace instance.""" """Create a new DataSource for this workspace instance."""
_validateInstanceAccess(instanceId, context) _mandateId, _ = _validateInstanceAccess(instanceId, context)
from modules.serviceCenter import getService from modules.serviceCenter import getService
from modules.serviceCenter.context import ServiceCenterContext from modules.serviceCenter.context import ServiceCenterContext
ctx = ServiceCenterContext( ctx = ServiceCenterContext(
user=context.user, user=context.user,
mandate_id=str(context.mandateId) if context.mandateId else None, mandate_id=_mandateId or "",
feature_instance_id=instanceId, feature_instance_id=instanceId,
) )
chatService = getService("chat", ctx) chatService = getService("chat", ctx)
@ -1319,12 +1320,12 @@ async def deleteWorkspaceDataSource(
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
): ):
"""Delete a DataSource.""" """Delete a DataSource."""
_validateInstanceAccess(instanceId, context) _mandateId, _ = _validateInstanceAccess(instanceId, context)
from modules.serviceCenter import getService from modules.serviceCenter import getService
from modules.serviceCenter.context import ServiceCenterContext from modules.serviceCenter.context import ServiceCenterContext
ctx = ServiceCenterContext( ctx = ServiceCenterContext(
user=context.user, user=context.user,
mandate_id=str(context.mandateId) if context.mandateId else None, mandate_id=_mandateId or "",
feature_instance_id=instanceId, feature_instance_id=instanceId,
) )
chatService = getService("chat", ctx) chatService = getService("chat", ctx)
@ -1466,7 +1467,7 @@ async def listFeatureConnectionTables(
node = { node = {
"objectKey": obj.get("objectKey", ""), "objectKey": obj.get("objectKey", ""),
"tableName": meta.get("table", ""), "tableName": meta.get("table", ""),
"label": obj.get("label", {}), "label": t(obj.get("label", "")),
"fields": meta.get("fields", []), "fields": meta.get("fields", []),
} }
if meta.get("isParent"): if meta.get("isParent"):
@ -1662,7 +1663,7 @@ async def deleteFeatureDataSource(
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
): ):
"""Delete a FeatureDataSource.""" """Delete a FeatureDataSource."""
_validateInstanceAccess(instanceId, context) _mandateId, _ = _validateInstanceAccess(instanceId, context)
from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
@ -1680,14 +1681,14 @@ async def listConnectionServices(
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
): ):
"""Return the available services for a specific UserConnection.""" """Return the available services for a specific UserConnection."""
_validateInstanceAccess(instanceId, context) _mandateId, _ = _validateInstanceAccess(instanceId, context)
try: try:
from modules.connectors.connectorResolver import ConnectorResolver from modules.connectors.connectorResolver import ConnectorResolver
from modules.serviceCenter import getService as getSvc from modules.serviceCenter import getService as getSvc
from modules.serviceCenter.context import ServiceCenterContext from modules.serviceCenter.context import ServiceCenterContext
ctx = ServiceCenterContext( ctx = ServiceCenterContext(
user=context.user, user=context.user,
mandate_id=str(context.mandateId) if context.mandateId else None, mandate_id=_mandateId or "",
feature_instance_id=instanceId, feature_instance_id=instanceId,
) )
chatService = getSvc("chat", ctx) chatService = getSvc("chat", ctx)
@ -1739,14 +1740,14 @@ async def browseConnectionService(
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
): ):
"""Browse folders/items within a connection's service at a given path.""" """Browse folders/items within a connection's service at a given path."""
_validateInstanceAccess(instanceId, context) _mandateId, _ = _validateInstanceAccess(instanceId, context)
try: try:
from modules.connectors.connectorResolver import ConnectorResolver from modules.connectors.connectorResolver import ConnectorResolver
from modules.serviceCenter import getService as getSvc from modules.serviceCenter import getService as getSvc
from modules.serviceCenter.context import ServiceCenterContext from modules.serviceCenter.context import ServiceCenterContext
ctx = ServiceCenterContext( ctx = ServiceCenterContext(
user=context.user, user=context.user,
mandate_id=str(context.mandateId) if context.mandateId else None, mandate_id=_mandateId or "",
feature_instance_id=instanceId, feature_instance_id=instanceId,
) )
chatService = getSvc("chat", ctx) chatService = getSvc("chat", ctx)
@ -1784,7 +1785,7 @@ async def transcribeVoice(
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
): ):
"""Transcribe audio to text using speech-to-text.""" """Transcribe audio to text using speech-to-text."""
_validateInstanceAccess(instanceId, context) _mandateId, _ = _validateInstanceAccess(instanceId, context)
audioBytes = await audio.read() audioBytes = await audio.read()
try: try:
import aiohttp import aiohttp
@ -1813,7 +1814,7 @@ async def synthesizeVoice(
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
): ):
"""Synthesize text to speech audio.""" """Synthesize text to speech audio."""
_validateInstanceAccess(instanceId, context) _mandateId, _ = _validateInstanceAccess(instanceId, context)
text = body.get("text", "") text = body.get("text", "")
if not text: if not text:
raise HTTPException(status_code=400, detail=routeApiMsg("text is required")) raise HTTPException(status_code=400, detail=routeApiMsg("text is required"))
@ -1835,7 +1836,7 @@ async def getPendingEdits(
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
): ):
"""Return all pending file edit proposals for this workspace instance.""" """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()] editList = [e.model_dump() for e in _pendingEditsStore.forInstance(instanceId).getPending()]
return JSONResponse({"edits": editList}) return JSONResponse({"edits": editList})
@ -1849,7 +1850,7 @@ async def acceptEdit(
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
): ):
"""Accept a proposed file edit -- applies the new content to the file.""" """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) edit = _pendingEditsStore.forInstance(instanceId).get(editId)
if not edit: if not edit:
raise HTTPException(status_code=404, detail=f"Edit proposal {editId} not found") raise HTTPException(status_code=404, detail=f"Edit proposal {editId} not found")
@ -1886,7 +1887,7 @@ async def rejectEdit(
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
): ):
"""Reject a proposed file edit -- discards the change.""" """Reject a proposed file edit -- discards the change."""
_validateInstanceAccess(instanceId, context) _mandateId, _ = _validateInstanceAccess(instanceId, context)
edit = _pendingEditsStore.forInstance(instanceId).get(editId) edit = _pendingEditsStore.forInstance(instanceId).get(editId)
if not edit: if not edit:
raise HTTPException(status_code=404, detail=f"Edit proposal {editId} not found") raise HTTPException(status_code=404, detail=f"Edit proposal {editId} not found")
@ -1911,7 +1912,7 @@ async def acceptAllEdits(
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
): ):
"""Accept all pending file edit proposals for this instance.""" """Accept all pending file edit proposals for this instance."""
_validateInstanceAccess(instanceId, context) _mandateId, _ = _validateInstanceAccess(instanceId, context)
instanceEdits = _pendingEditsStore.forInstance(instanceId) instanceEdits = _pendingEditsStore.forInstance(instanceId)
dbMgmt = _getDbManagement(context, instanceId) dbMgmt = _getDbManagement(context, instanceId)
accepted = [] accepted = []
@ -1942,7 +1943,7 @@ async def rejectAllEdits(
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
): ):
"""Reject all pending file edit proposals for this instance.""" """Reject all pending file edit proposals for this instance."""
_validateInstanceAccess(instanceId, context) _mandateId, _ = _validateInstanceAccess(instanceId, context)
instanceEdits = _pendingEditsStore.forInstance(instanceId) instanceEdits = _pendingEditsStore.forInstance(instanceId)
rejected = [] rejected = []
@ -1998,7 +1999,7 @@ async def updateGeneralSettings(
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
): ):
"""Update general workspace settings for the current user.""" """Update general workspace settings for the current user."""
_validateInstanceAccess(instanceId, context) _mandateId, _ = _validateInstanceAccess(instanceId, context)
wsInterface = _getWorkspaceInterface(context, instanceId) wsInterface = _getWorkspaceInterface(context, instanceId)
userId = str(context.user.id) userId = str(context.user.id)

View file

@ -3777,8 +3777,8 @@ class AppObjects:
if conflictingRole and conflictingRole.id != roleId: if conflictingRole and conflictingRole.id != roleId:
raise ValueError(f"Role with label '{role.roleLabel}' already exists") raise ValueError(f"Role with label '{role.roleLabel}' already exists")
# Exclude id from model_dump - the URL roleId is authoritative _IMMUTABLE_ROLE_FIELDS = {"id", "mandateId", "featureInstanceId", "featureCode", "isSystemRole"}
updatedRole = self.db.recordModify(Role, roleId, role.model_dump(exclude={"id"})) updatedRole = self.db.recordModify(Role, roleId, role.model_dump(exclude=_IMMUTABLE_ROLE_FIELDS))
logger.info(f"Updated role with ID {roleId}") logger.info(f"Updated role with ID {roleId}")
return Role(**updatedRole) return Role(**updatedRole)
except Exception as e: except Exception as e:

View file

@ -678,20 +678,22 @@ class ChatObjects:
return list(matchedIds) return list(matchedIds)
def getWorkflow(self, workflowId: str) -> Optional[ChatWorkflow]: def getWorkflow(self, workflowId: str) -> Optional[ChatWorkflow]:
"""Returns a workflow by ID if user has access.""" """Returns a workflow by ID if user has access.
# Use RBAC filtering with featureInstanceId for instance-level isolation
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}) workflows = self._getRecordset(ChatWorkflow, recordFilter={"id": workflowId})
if not workflows: if not workflows:
logger.debug(f"getWorkflow: no record for {workflowId} (RBAC filter or not found)")
return None return None
workflow = workflows[0] workflow = workflows[0]
try: try:
logs = self.getLogs(workflowId) logs = self.getLogs(workflowId)
messages = self.getMessages(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): def _toInt(v, default=0):
try: try:
return int(v) if v is not None else default return int(v) if v is not None else default
@ -719,7 +721,7 @@ class ChatObjects:
messages=messages messages=messages
) )
except Exception as e: 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 return None
def createWorkflow(self, workflowData: Dict[str, Any]) -> ChatWorkflow: def createWorkflow(self, workflowData: Dict[str, Any]) -> ChatWorkflow:
@ -1040,6 +1042,10 @@ class ChatObjects:
workflowId = messageData["workflowId"] workflowId = messageData["workflowId"]
workflow = self.getWorkflow(workflowId) workflow = self.getWorkflow(workflowId)
if not workflow: 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}") raise PermissionError(f"No access to workflow {workflowId}")
if not self.checkRbacPermission(ChatWorkflow, "update", workflowId): if not self.checkRbacPermission(ChatWorkflow, "update", workflowId):

View file

@ -252,7 +252,10 @@ class FeatureInterface:
graph = json.loads(graphJson) graph = json.loads(graphJson)
labelDict = template.get("label", {}) 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({ geInterface.createWorkflow({
"label": label, "label": label,

View file

@ -3263,6 +3263,121 @@
"key": "Überwachen Sie automatisch 100% der Gespräche, um wertvolle Einblicke für Ihr Unternehmen zu erhalten.", "key": "Überwachen Sie automatisch 100% der Gespräche, um wertvolle Einblicke für Ihr Unternehmen zu erhalten.",
"value": "" "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", "context": "ui",
"key": "✓ Mandat eingereicht", "key": "✓ Mandat eingereicht",
@ -6536,6 +6651,121 @@
"key": "Überwachen Sie automatisch 100% der Gespräche, um wertvolle Einblicke für Ihr Unternehmen zu erhalten.", "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." "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", "context": "ui",
"key": "✓ Mandat eingereicht", "key": "✓ Mandat eingereicht",
@ -9634,6 +9864,121 @@
"key": "Überwachen Sie automatisch 100% der Gespräche, um wertvolle Einblicke für Ihr Unternehmen zu erhalten.", "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." "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", "context": "ui",
"key": "✓ Mandat eingereicht", "key": "✓ Mandat eingereicht",
@ -12732,6 +13077,121 @@
"key": "Überwachen Sie automatisch 100% der Gespräche, um wertvolle Einblicke für Ihr Unternehmen zu erhalten.", "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." "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", "context": "ui",
"key": "✓ Mandat eingereicht", "key": "✓ Mandat eingereicht",

View file

@ -33,12 +33,15 @@ routeApiMsg = apiRouteContext("routeAdminFeatures")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _featureLabelPlain(label: Union[str, Dict[str, str], None], fallback: str) -> str: def _featureLabelPlain(label: Union[str, Dict[str, str], None], fallback: str, requestLang: Optional[str] = None) -> str:
"""Catalog feature label as German i18n key string.""" """Catalog feature label as a single display/i18n key string."""
if isinstance(label, str) and label.strip(): if isinstance(label, str) and label.strip():
return label return label
if isinstance(label, dict): 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 return fallback
@ -194,7 +197,11 @@ def get_my_feature_instances(
featureDef = catalogService.getFeatureDefinition(instance.featureCode) featureDef = catalogService.getFeatureDefinition(instance.featureCode)
featuresMap[featureKey] = { featuresMap[featureKey] = {
"code": instance.featureCode, "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", "icon": featureDef.get("icon", "folder") if featureDef else "folder",
"instances": [], "instances": [],
"_mandateId": mandateId # Temporary for grouping "_mandateId": mandateId # Temporary for grouping

View file

@ -23,12 +23,23 @@ from modules.datamodels.datamodelRbac import AccessRuleContext, AccessRule, Role
from modules.datamodels.datamodelMembership import UserMandate from modules.datamodels.datamodelMembership import UserMandate
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
from modules.interfaces.interfaceDbApp import getInterface, getRootInterface from modules.interfaces.interfaceDbApp import getInterface, getRootInterface
from modules.shared.i18nRegistry import apiRouteContext from modules.shared.i18nRegistry import apiRouteContext, t, _getLanguage
routeApiMsg = apiRouteContext("routeAdminRbacRules") routeApiMsg = apiRouteContext("routeAdminRbacRules")
# Configure logger # Configure logger
logger = logging.getLogger(__name__) 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( router = APIRouter(
prefix="/api/rbac", prefix="/api/rbac",
tags=["RBAC"], tags=["RBAC"],
@ -911,7 +922,7 @@ def list_roles(
result.append({ result.append({
"id": role.id, "id": role.id,
"roleLabel": role.roleLabel, "roleLabel": role.roleLabel,
"description": role.description, "description": _resolveTextMultilingual(role.description),
"mandateId": role.mandateId, "mandateId": role.mandateId,
"featureInstanceId": role.featureInstanceId, "featureInstanceId": role.featureInstanceId,
"featureCode": role.featureCode, "featureCode": role.featureCode,
@ -931,14 +942,8 @@ def list_roles(
if searchTerm: if searchTerm:
searchedResult = [] searchedResult = []
for item in result: for item in result:
# Search in roleLabel and description
roleLabel = (item.get("roleLabel") or "").lower() roleLabel = (item.get("roleLabel") or "").lower()
description = item.get("description") descText = (item.get("description") or "").lower()
descText = ""
if isinstance(description, dict):
descText = " ".join(str(v) for v in description.values()).lower()
elif description:
descText = str(description).lower()
scopeType = (item.get("scopeType") or "").lower() scopeType = (item.get("scopeType") or "").lower()
if searchTerm in roleLabel or searchTerm in descText or searchTerm in scopeType: if searchTerm in roleLabel or searchTerm in descText or searchTerm in scopeType:
@ -1046,7 +1051,7 @@ def get_roles_filter_values(
result.append({ result.append({
"id": role.id, "id": role.id,
"roleLabel": role.roleLabel, "roleLabel": role.roleLabel,
"description": role.description, "description": _resolveTextMultilingual(role.description),
"mandateId": role.mandateId, "mandateId": role.mandateId,
"featureInstanceId": role.featureInstanceId, "featureInstanceId": role.featureInstanceId,
"featureCode": role.featureCode, "featureCode": role.featureCode,
@ -1163,7 +1168,7 @@ def get_role(
return { return {
"id": role.id, "id": role.id,
"roleLabel": role.roleLabel, "roleLabel": role.roleLabel,
"description": role.description, "description": _resolveTextMultilingual(role.description),
"mandateId": role.mandateId, "mandateId": role.mandateId,
"featureInstanceId": role.featureInstanceId, "featureInstanceId": role.featureInstanceId,
"featureCode": role.featureCode, "featureCode": role.featureCode,
@ -1362,6 +1367,16 @@ def getCatalogObjects(
except Exception as e: except Exception as e:
logger.warning(f"Could not get active features for mandate {mandateId}: {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: if context:
# Single context filter # Single context filter
try: try:
@ -1383,7 +1398,7 @@ def getCatalogObjects(
if activeFeatures: if activeFeatures:
objects = [obj for obj in objects if obj.get("featureCode") in activeFeatures] objects = [obj for obj in objects if obj.get("featureCode") in activeFeatures]
return {context.upper(): objects} return {context.upper(): _resolveLabels(objects)}
else: else:
# All contexts # All contexts
result = catalog.getAllCatalogObjects(featureCode) result = catalog.getAllCatalogObjects(featureCode)
@ -1393,6 +1408,8 @@ def getCatalogObjects(
for ctxKey in result: for ctxKey in result:
result[ctxKey] = [obj for obj in result[ctxKey] if obj.get("featureCode") in activeFeatures] result[ctxKey] = [obj for obj in result[ctxKey] if obj.get("featureCode") in activeFeatures]
for ctxKey in result:
_resolveLabels(result[ctxKey])
return result return result
except HTTPException: except HTTPException:

View file

@ -24,13 +24,24 @@ from modules.datamodels.datamodelMembership import (
) )
from modules.datamodels.datamodelFeatures import FeatureInstance, Feature from modules.datamodels.datamodelFeatures import FeatureInstance, Feature
from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceDbApp import getRootInterface
from modules.shared.i18nRegistry import apiRouteContext from modules.shared.i18nRegistry import apiRouteContext, t, _getLanguage
routeApiMsg = apiRouteContext("routeAdminUserAccessOverview") routeApiMsg = apiRouteContext("routeAdminUserAccessOverview")
# Configure logger # Configure logger
logger = logging.getLogger(__name__) 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( router = APIRouter(
prefix="/api/admin/user-access-overview", prefix="/api/admin/user-access-overview",
tags=["Admin User Access Overview"], tags=["Admin User Access Overview"],
@ -298,7 +309,7 @@ def getUserAccessOverview(
roleInfo = { roleInfo = {
"id": roleId, "id": roleId,
"roleLabel": role.roleLabel, "roleLabel": role.roleLabel,
"description": role.description or {}, "description": _resolveTextMultilingual(role.description),
"scope": scope, "scope": scope,
"scopePriority": _getRoleScopePriority(scope), "scopePriority": _getRoleScopePriority(scope),
"mandateId": role.mandateId, "mandateId": role.mandateId,
@ -334,7 +345,7 @@ def getUserAccessOverview(
# Get feature info using interface method # Get feature info using interface method
featureCode = instance.featureCode featureCode = instance.featureCode
feature = interface.getFeatureByCode(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 # Get roles for this FeatureAccess using interface method
instanceRoleIds = interface.getRoleIdsForFeatureAccess(faId) instanceRoleIds = interface.getRoleIdsForFeatureAccess(faId)
@ -348,14 +359,14 @@ def getUserAccessOverview(
roleInfo = { roleInfo = {
"id": roleId, "id": roleId,
"roleLabel": role.roleLabel, "roleLabel": role.roleLabel,
"description": role.description or {}, "description": _resolveTextMultilingual(role.description),
"scope": scope, "scope": scope,
"scopePriority": _getRoleScopePriority(scope), "scopePriority": _getRoleScopePriority(scope),
"mandateId": role.mandateId, "mandateId": role.mandateId,
"featureInstanceId": role.featureInstanceId, "featureInstanceId": role.featureInstanceId,
"source": "featureInstance", "source": "featureInstance",
"sourceInstanceId": faInstanceId, "sourceInstanceId": faInstanceId,
"sourceInstanceLabel": instance.label, "sourceInstanceLabel": instance.label,
} }
allRoles.append(roleInfo) allRoles.append(roleInfo)
roleIdToInfo[roleId] = roleInfo roleIdToInfo[roleId] = roleInfo

View file

@ -143,6 +143,8 @@ async def _autoIndexFile(fileId: str, fileName: str, mimeType: str, user):
structure=contentIndex.structure, 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"}) mgmtInterface.updateFile(fileId, {"status": "active"})
logger.info(f"Auto-index complete for file {fileId} ({fileName})") logger.info(f"Auto-index complete for file {fileId} ({fileName})")

View file

@ -779,7 +779,7 @@ def add_user_to_mandate(
f"with roles {data.roleIds}" f"with roles {data.roleIds}"
) )
mname = _mandate_display_name(mandate) mname = _mandate_display_name(mandate, getattr(targetUser, "language", None))
create_access_change_notification( create_access_change_notification(
data.targetUserId, data.targetUserId,
"Mandantenzugriff", "Mandantenzugriff",
@ -877,7 +877,9 @@ def remove_user_from_mandate(
logger.info(f"User {context.user.id} removed user {targetUserId} from mandate {targetMandateId}") 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( create_access_change_notification(
targetUserId, targetUserId,
"Mandantenzugriff", "Mandantenzugriff",
@ -982,7 +984,9 @@ def update_user_roles_in_mandate(
) )
mandate_meta = rootInterface.getMandate(targetMandateId) 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( create_access_change_notification(
targetUserId, targetUserId,
"Mandantenrollen geändert", "Mandantenrollen geändert",
@ -1013,7 +1017,7 @@ def update_user_roles_in_mandate(
# Helper Functions # 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.""" """Human-readable mandate label for notifications."""
if mandate is None: if mandate is None:
return "" return ""
@ -1022,14 +1026,16 @@ def _mandate_display_name(mandate: Any) -> str:
return str(mandate["label"]) return str(mandate["label"])
name = mandate.get("name") name = mandate.get("name")
if isinstance(name, dict): 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", "")) return str(name or mandate.get("id", ""))
label = getattr(mandate, "label", None) label = getattr(mandate, "label", None)
if label: if label:
return str(label) return str(label)
name = getattr(mandate, "name", None) name = getattr(mandate, "name", None)
if isinstance(name, dict): 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: if name is not None:
return str(name) return str(name)
return str(getattr(mandate, "id", "")) return str(getattr(mandate, "id", ""))

View file

@ -71,7 +71,11 @@ def _isAdminForUser(context: RequestContext, targetUserId: str) -> bool:
return False 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.""" """Extract sorted distinct display values for a column from enriched items."""
values = set() values = set()
for item in items: for item in items:
@ -83,7 +87,7 @@ def _extractDistinctValues(items: List[Dict[str, Any]], columnKey: str) -> List[
elif isinstance(val, (int, float)): elif isinstance(val, (int, float)):
values.add(str(val)) values.add(str(val))
elif isinstance(val, dict): 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: if text:
values.add(str(text)) values.add(str(text))
else: else:
@ -95,6 +99,7 @@ def _handleFilterValuesRequest(
items: List[Dict[str, Any]], items: List[Dict[str, Any]],
column: str, column: str,
paginationJson: Optional[str] = None, paginationJson: Optional[str] = None,
requestLang: Optional[str] = None,
) -> List[str]: ) -> List[str]:
""" """
Generic handler for /filter-values endpoints. Generic handler for /filter-values endpoints.
@ -117,7 +122,7 @@ def _handleFilterValuesRequest(
pass pass
crossFiltered = _applyFiltersAndSort(items, crossFilterParams) 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]]: 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) result = appInterface.getUsersByMandate(str(context.mandateId), None)
users = result if isinstance(result, list) else (result.items if hasattr(result, 'items') else []) 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] 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: elif context.hasSysAdminRole:
# SysAdmin: use SQL DISTINCT for DB columns # SysAdmin: use SQL DISTINCT for DB columns
try: try:
@ -519,7 +524,7 @@ def get_user_filter_values(
except Exception: except Exception:
users = appInterface.getAllUsers() users = appInterface.getAllUsers()
items = [u.model_dump() if hasattr(u, 'model_dump') else u for u in users] 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: else:
# Non-admin multi-mandate: aggregate across admin mandates (in-memory) # Non-admin multi-mandate: aggregate across admin mandates (in-memory)
rootInterface = getRootInterface() rootInterface = getRootInterface()
@ -547,7 +552,7 @@ def get_user_filter_values(
}) })
batchUsers = rootInterface.getUsersByIds(uniqueUserIds) if uniqueUserIds else {} batchUsers = rootInterface.getUsersByIds(uniqueUserIds) if uniqueUserIds else {}
items = [u.model_dump() if hasattr(u, 'model_dump') else vars(u) for u in batchUsers.values()] 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: except HTTPException:
raise raise
except Exception as e: except Exception as e:

View file

@ -34,6 +34,8 @@ from modules.datamodels.datamodelAi import (
) )
from modules.datamodels.datamodelUiLanguage import I18nEntry, UiLanguageSet from modules.datamodels.datamodelUiLanguage import I18nEntry, UiLanguageSet
from modules.datamodels.datamodelUam import User 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.datamodels.datamodelNotification import NotificationType
from modules.interfaces.interfaceDbManagement import getInterface as getMgmtInterface from modules.interfaces.interfaceDbManagement import getInterface as getMgmtInterface
from modules.routes.routeNotifications import _createNotification from modules.routes.routeNotifications import _createNotification
@ -532,6 +534,67 @@ def _validate_iso2_code(code: str) -> str:
return c 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: def _run_create_language_job(userId: str, code: str, label: str, currentUser: User, mandateId: str) -> None:
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
try: try:
@ -580,14 +643,17 @@ async def _run_create_language_job_async(userId: str, code: str, label: str, cur
db.recordModify(UiLanguageSet, code, merged) db.recordModify(UiLanguageSet, code, merged)
statusHint = "" if finalStatus == "complete" else f" ({missingCount} Keys ohne Übersetzung)" statusHint = "" if finalStatus == "complete" else f" ({missingCount} Keys ohne Übersetzung)"
tmCount = await _translateTextMultilingualFields(db, code, label, billingCb)
_createNotification( _createNotification(
userId, userId,
NotificationType.SYSTEM, NotificationType.SYSTEM,
title="Sprachset erstellt", 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() 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: except Exception as e:
logger.exception("create language job failed: %s", e) logger.exception("create language job failed: %s", e)
_createNotification( _createNotification(

View file

@ -29,12 +29,15 @@ routeApiMsg = apiRouteContext("routeStore")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _storeLabelText(label: Union[str, Dict[str, str], None], fallback: str) -> str: def _storeLabelText(label: Union[str, Dict[str, str], None], fallback: str, requestLang: Optional[str] = None) -> str:
"""Normalize catalog label to German i18n key string.""" """Normalize catalog label to a single display/i18n key string."""
if isinstance(label, str) and label.strip(): if isinstance(label, str) and label.strip():
return label return label
if isinstance(label, dict): 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 return fallback
router = APIRouter( router = APIRouter(
@ -298,9 +301,9 @@ def listStoreFeatures(
instances = _getUserInstancesForFeature(db, userId, featureCode, userMandateIds) instances = _getUserInstancesForFeature(db, userId, featureCode, userMandateIds)
result.append(StoreFeatureResponse( result.append(StoreFeatureResponse(
featureCode=featureCode, 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"), icon=featureDef.get("icon", "mdi-puzzle"),
description=_storeLabelText(featureDef.get("description"), ""), description=_storeLabelText(featureDef.get("description"), "", getattr(context.user, "language", None)),
instances=instances, instances=instances,
canActivate=True, canActivate=True,
)) ))
@ -388,7 +391,7 @@ def activateStoreFeature(
# ── 3. Provision instance ─────────────────────────────────────── # ── 3. Provision instance ───────────────────────────────────────
featureInterface = getFeatureInterface(db) featureInterface = getFeatureInterface(db)
featureLabel = _storeLabelText(featureDef.get("label"), featureCode) featureLabel = _storeLabelText(featureDef.get("label"), featureCode, getattr(context.user, "language", None))
instance = featureInterface.createFeatureInstance( instance = featureInterface.createFeatureInstance(
featureCode=featureCode, featureCode=featureCode,
mandateId=mandateId, mandateId=mandateId,

View file

@ -8,7 +8,7 @@ Feature-Container register their RBAC objects via mainXxx.py at startup.
""" """
import logging import logging
from typing import Dict, List, Any, Optional, Union from typing import Dict, List, Any, Optional
from threading import Lock from threading import Lock
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -43,7 +43,7 @@ class RbacCatalogService:
self._initialized = True self._initialized = True
logger.info("RBAC Catalog Service initialized") 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.""" """Register a UI object for a feature."""
try: try:
self._uiObjects[objectKey] = {"objectKey": objectKey, "featureCode": featureCode, "label": label, "meta": meta or {}, "type": "UI"} 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}") logger.error(f"Failed to register UI object {objectKey}: {e}")
return False 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.""" """Register a RESOURCE object for a feature."""
try: try:
self._resourceObjects[objectKey] = {"objectKey": objectKey, "featureCode": featureCode, "label": label, "meta": meta or {}, "type": "RESOURCE"} 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}") logger.error(f"Failed to register RESOURCE object {objectKey}: {e}")
return False 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. Register a DATA object (table/entity) for a feature.
Args: Args:
featureCode: Feature code (e.g., "trustee", "system") featureCode: Feature code (e.g., "trustee", "system")
objectKey: Dot-notation key (e.g., "data.feature.trustee.TrusteeContract") 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) meta: Optional metadata (e.g., table name, fields list)
""" """
try: try:
@ -84,7 +84,7 @@ class RbacCatalogService:
logger.error(f"Failed to register DATA object {objectKey}: {e}") logger.error(f"Failed to register DATA object {objectKey}: {e}")
return False 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.""" """Register a feature definition."""
try: try:
self._featureDefinitions[featureCode] = {"code": featureCode, "label": label, "icon": icon} self._featureDefinitions[featureCode] = {"code": featureCode, "label": label, "icon": icon}

View file

@ -80,10 +80,13 @@ def _convertParameterSchema(actionParams: Dict[str, Any]) -> Dict[str, Any]:
paramRequired = paramInfo.get("required", False) if isinstance(paramInfo, dict) else False paramRequired = paramInfo.get("required", False) if isinstance(paramInfo, dict) else False
jsonType = _pythonTypeToJsonType(paramType) jsonType = _pythonTypeToJsonType(paramType)
properties[paramName] = { prop: Dict[str, Any] = {
"type": jsonType, "type": jsonType,
"description": paramDesc "description": paramDesc,
} }
if jsonType == "array":
prop["items"] = _pythonTypeToArrayItems(paramType) or {"type": "string"}
properties[paramName] = prop
if paramRequired: if paramRequired:
required.append(paramName) 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: def _pythonTypeToJsonType(pythonType: str) -> str:
"""Map Python type strings to JSON Schema types.""" """Map Python type strings to JSON Schema types."""
mapping = { return _TYPE_MAPPING.get(pythonType, "string")
"str": "string",
"int": "integer",
"float": "number", def _pythonTypeToArrayItems(pythonType: str) -> Optional[Dict[str, Any]]:
"bool": "boolean", """Return the JSON Schema `items` descriptor for array types, or None."""
"list": "array", return _ARRAY_ITEMS_MAPPING.get(pythonType)
"dict": "object",
"List[str]": "array",
"List[int]": "array",
"List[dict]": "array",
"Dict[str, Any]": "object",
}
return mapping.get(pythonType, "string")
def _createDispatchHandler(actionExecutor, methodName: str, actionName: str): def _createDispatchHandler(actionExecutor, methodName: str, actionName: str):

View file

@ -44,10 +44,12 @@ def _registerConnectionTools(registry: ToolRegistry, services):
return ToolResult(toolCallId="", toolName="listConnections", success=True, data="No connections available.") return ToolResult(toolCallId="", toolName="listConnections", success=True, data="No connections available.")
lines = [] lines = []
for conn in connections: 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", "?") 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", "") 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)) return ToolResult(toolCallId="", toolName="listConnections", success=True, data="\n".join(lines))
except Exception as e: except Exception as e:
return ToolResult(toolCallId="", toolName="listConnections", success=False, error=str(e)) return ToolResult(toolCallId="", toolName="listConnections", success=False, error=str(e))

View file

@ -93,6 +93,11 @@ def _registerFeatureSubAgentTools(registry: ToolRegistry, services):
instanceLabel = instance.label or "" instanceLabel = instance.label or ""
userId = context.get("userId", "") userId = context.get("userId", "")
workspaceInstanceId = context.get("featureInstanceId", "") 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 rootDbConn = rootIf.db if hasattr(rootIf, "db") else None
if rootDbConn is None: if rootDbConn is None:
@ -165,6 +170,7 @@ def _registerFeatureSubAgentTools(registry: ToolRegistry, services):
dbConnector=featureDbConn, dbConnector=featureDbConn,
instanceLabel=instanceLabel, instanceLabel=instanceLabel,
tableFilters=tableFilters, tableFilters=tableFilters,
requestLang=requestLang,
) )
_featureQueryCache[cacheKey] = (time.time(), answer) _featureQueryCache[cacheKey] = (time.time(), answer)

View file

@ -39,6 +39,7 @@ async def runFeatureDataAgent(
dbConnector, dbConnector,
instanceLabel: str = "", instanceLabel: str = "",
tableFilters: Optional[Dict[str, Dict[str, str]]] = None, tableFilters: Optional[Dict[str, Dict[str, str]]] = None,
requestLang: Optional[str] = None,
) -> str: ) -> str:
"""Run the feature data sub-agent and return the textual result. """Run the feature data sub-agent and return the textual result.
@ -53,6 +54,7 @@ async def runFeatureDataAgent(
dbConnector: DatabaseConnector for queries. dbConnector: DatabaseConnector for queries.
instanceLabel: Human-readable instance name for context. instanceLabel: Human-readable instance name for context.
tableFilters: Per-table record filters from FeatureDataSource.recordFilter. tableFilters: Per-table record filters from FeatureDataSource.recordFilter.
requestLang: ISO 639-1 code for resolving multilingual table labels in the schema prompt.
Returns: Returns:
Plain-text answer produced by the sub-agent. Plain-text answer produced by the sub-agent.
@ -69,7 +71,7 @@ async def runFeatureDataAgent(
if realCols: if realCols:
meta["fields"] = realCols meta["fields"] = realCols
systemPrompt = _buildSchemaContext(featureCode, instanceLabel, selectedTables) systemPrompt = _buildSchemaContext(featureCode, instanceLabel, selectedTables, requestLang)
config = AgentConfig( config = AgentConfig(
maxRounds=_MAX_ROUNDS, maxRounds=_MAX_ROUNDS,
@ -293,6 +295,7 @@ def _buildSchemaContext(
featureCode: str, featureCode: str,
instanceLabel: str, instanceLabel: str,
selectedTables: List[Dict[str, Any]], selectedTables: List[Dict[str, Any]],
requestLang: Optional[str] = None,
) -> str: ) -> str:
"""Build a system prompt describing available tables and query strategy.""" """Build a system prompt describing available tables and query strategy."""
tableNames = [] tableNames = []
@ -303,7 +306,11 @@ def _buildSchemaContext(
tbl = meta.get("table", "?") tbl = meta.get("table", "?")
fields = meta.get("fields", []) fields = meta.get("fields", [])
label = obj.get("label", {}) 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) tableNames.append(tbl)
block = f" Table: {tbl} ({labelStr})" block = f" Table: {tbl} ({labelStr})"
if fields: if fields:

View file

@ -371,30 +371,36 @@ class ChatService:
return None return None
def getUserConnectionFromConnectionReference(self, connectionReference: str) -> Optional[UserConnection]: 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: try:
# Parse reference format: connection:{authority}:{username} [status:..., token:...] base_reference = connectionReference.split(' [')[0].strip()
# Remove state information if present
base_reference = connectionReference.split(' [')[0]
parts = base_reference.split(':') parts = base_reference.split(':')
if len(parts) != 3 or parts[0] != "connection": if len(parts) == 3 and parts[0] == "connection":
return None authority = parts[1]
username = parts[2]
authority = parts[1] user_connections = self.interfaceDbApp.getUserConnections(self.user.id)
username = parts[2] for conn in user_connections:
connAuthority = conn.authority.value if hasattr(conn.authority, "value") else str(conn.authority)
# Get user connections through AppObjects interface 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) user_connections = self.interfaceDbApp.getUserConnections(self.user.id)
# Find matching connection by authority and username (no UUID needed)
for conn in user_connections: 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 conn
return None return None
except Exception as e: 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 return None
def getFreshConnectionToken(self, connectionId: str): def getFreshConnectionToken(self, connectionId: str):

View file

@ -146,12 +146,15 @@ class KnowledgeService:
# 3. Chunk text content objects and create embeddings # 3. Chunk text content objects and create embeddings
textObjects = [o for o in contentObjects if o.get("contentType") == "text"] textObjects = [o for o in contentObjects if o.get("contentType") == "text"]
if _shouldNeutralize and textObjects: _neutralSvc = None
_neutralizedObjects = [] if _shouldNeutralize:
try: try:
_neutralSvc = self._getService("neutralization") _neutralSvc = self._getService("neutralization")
except Exception: except Exception:
_neutralSvc = None logger.warning(f"Neutralization service unavailable for file {fileId}")
if _shouldNeutralize and textObjects:
_neutralizedObjects = []
if _neutralSvc: if _neutralSvc:
for _obj in textObjects: for _obj in textObjects:
_textContent = (_obj.get("data", "") or "").strip() _textContent = (_obj.get("data", "") or "").strip()
@ -201,7 +204,7 @@ class KnowledgeService:
# 4. Store non-text content objects (images, etc.) without embedding # 4. Store non-text content objects (images, etc.) without embedding
nonTextObjects = [o for o in contentObjects if o.get("contentType") != "text"] 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 import base64 as _b64
_filteredNonText = [] _filteredNonText = []
for _obj in nonTextObjects: for _obj in nonTextObjects:

View file

@ -57,25 +57,43 @@ def getModelLabels(modelName: str, language: str = "en") -> Dict[str, str]:
result: Dict[str, str] = {} result: Dict[str, str] = {}
for attr, translations in attributeLabels.items(): for attr, translations in attributeLabels.items():
if isinstance(translations, dict): 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): elif isinstance(translations, str):
result[attr] = _resolveLabel(translations, language) result[attr] = _resolveLabel(translations, language)
else: else:
result[attr] = attr result[attr] = f"[{attr}]"
return result return result
def _resolveLabel(germanText: str, language: str) -> str: 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": if language == "de":
return germanText return germanText
try: try:
from modules.shared.i18nRegistry import _CACHE from modules.shared.i18nRegistry import _CACHE
return _CACHE.get(language, {}).get(germanText, germanText) return _CACHE.get(language, {}).get(germanText, f"[{germanText}]")
except ImportError: except ImportError:
return germanText 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]: def _mergedAttributeLabels(modelClass: Type[BaseModel], userLanguage: str) -> Dict[str, str]:
"""Merge attribute labels from model MRO (base classes first, subclass overrides).""" """Merge attribute labels from model MRO (base classes first, subclass overrides)."""
try: try:
@ -88,15 +106,16 @@ def _mergedAttributeLabels(modelClass: Type[BaseModel], userLanguage: str) -> Di
return merged 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).""" """Get the label for a model in the specified language (see getModelLabels)."""
modelData = _getModelLabelEntry(modelName) modelData = _getModelLabelEntry(modelName)
modelLabel = modelData.get("model", {}) modelLabel = modelData.get("model", {})
if isinstance(modelLabel, dict): 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): elif isinstance(modelLabel, str):
return _resolveLabel(modelLabel, language) return _resolveLabel(modelLabel, language)
return modelName return f"[{modelName}]"
def getModelAttributeDefinitions(modelClass: Type[BaseModel] = None, userLanguage: str = "en") -> Dict[str, Any]: 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, "visible": frontend_visible,
"order": len(attributes), "order": len(attributes),
"readonly": frontend_readonly, "readonly": frontend_readonly,
"options": frontend_options, "options": _resolveOptionLabels(frontend_options, userLanguage),
"default": field_default, "default": field_default,
} }

View file

@ -20,6 +20,16 @@ from pydantic import BaseModel
logger = logging.getLogger(__name__) 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) # 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 import time: registers the key with context and AI description.
At runtime: returns the cached translation for _CURRENT_LANGUAGE. 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: if key not in _REGISTRY:
_REGISTRY[key] = _I18nRegistryEntry(context=context, value=value) _REGISTRY[key] = _I18nRegistryEntry(context=context, value=value)
lang = _CURRENT_LANGUAGE.get() lang = _CURRENT_LANGUAGE.get()
if lang == "de": if lang == "de":
return key return key
return _CACHE.get(lang, {}).get(key, key) return _CACHE.get(lang, {}).get(key, f"[{key}]")
def apiRouteContext(routeModuleName: str): def apiRouteContext(routeModuleName: str):
@ -265,7 +275,7 @@ def _registerFeatureUiLabels():
_REGISTRY[lab] = _I18nRegistryEntry(context="nav", value="") _REGISTRY[lab] = _I18nRegistryEntry(context="nav", value="")
added += 1 added += 1
elif isinstance(lab, dict): 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: if base and base not in _REGISTRY:
_REGISTRY[base] = _I18nRegistryEntry(context="nav", value="") _REGISTRY[base] = _I18nRegistryEntry(context="nav", value="")
added += 1 added += 1
@ -279,9 +289,9 @@ def _registerRbacLabels():
context mapping: context mapping:
- DATA_OBJECTS rbac.data - DATA_OBJECTS rbac.data
- RESOURCE_OBJECTS rbac.resource - RESOURCE_OBJECTS rbac.resource
- TEMPLATE_ROLES[].description (de) rbac.role - TEMPLATE_ROLES[].description (xx source) rbac.role
- QUICK_ACTIONS[].label/description (de) rbac.quickaction - QUICK_ACTIONS[].label/description (xx source) rbac.quickaction
- QUICK_ACTION_CATEGORIES[].label (de) rbac.quickaction - QUICK_ACTION_CATEGORIES[].label (xx source) rbac.quickaction
""" """
_systemModule = "modules.system.mainSystem" _systemModule = "modules.system.mainSystem"
_featureModulePaths = ( _featureModulePaths = (
@ -296,13 +306,6 @@ def _registerRbacLabels():
"modules.features.chatbot.mainChatbot", "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 added = 0
for modPath in _featureModulePaths: for modPath in _featureModulePaths:
try: try:
@ -314,32 +317,32 @@ def _registerRbacLabels():
continue continue
for dataObj in getattr(mod, "DATA_OBJECTS", []) or []: 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: if key and key not in _REGISTRY:
_REGISTRY[key] = _I18nRegistryEntry(context="rbac.data", value="") _REGISTRY[key] = _I18nRegistryEntry(context="rbac.data", value="")
added += 1 added += 1
for resObj in getattr(mod, "RESOURCE_OBJECTS", []) or []: 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: if key and key not in _REGISTRY:
_REGISTRY[key] = _I18nRegistryEntry(context="rbac.resource", value="") _REGISTRY[key] = _I18nRegistryEntry(context="rbac.resource", value="")
added += 1 added += 1
for role in getattr(mod, "TEMPLATE_ROLES", []) or []: 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: if key and key not in _REGISTRY:
_REGISTRY[key] = _I18nRegistryEntry(context="rbac.role", value="") _REGISTRY[key] = _I18nRegistryEntry(context="rbac.role", value="")
added += 1 added += 1
for qa in getattr(mod, "QUICK_ACTIONS", []) or []: for qa in getattr(mod, "QUICK_ACTIONS", []) or []:
for field in ("label", "description"): for field in ("label", "description"):
key = _extractDe(qa.get(field)) key = _extractRegistrySourceText(qa.get(field))
if key and key not in _REGISTRY: if key and key not in _REGISTRY:
_REGISTRY[key] = _I18nRegistryEntry(context="rbac.quickaction", value="") _REGISTRY[key] = _I18nRegistryEntry(context="rbac.quickaction", value="")
added += 1 added += 1
for cat in getattr(mod, "QUICK_ACTION_CATEGORIES", []) or []: 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: if key and key not in _REGISTRY:
_REGISTRY[key] = _I18nRegistryEntry(context="rbac.quickaction", value="") _REGISTRY[key] = _I18nRegistryEntry(context="rbac.quickaction", value="")
added += 1 added += 1
@ -351,17 +354,10 @@ def _registerServiceCenterLabels():
"""Register service-center category labels and bootstrap role descriptions.""" """Register service-center category labels and bootstrap role descriptions."""
added = 0 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: try:
from modules.serviceCenter.registry import IMPORTABLE_SERVICES from modules.serviceCenter.registry import IMPORTABLE_SERVICES
for svc in IMPORTABLE_SERVICES.values(): for svc in IMPORTABLE_SERVICES.values():
key = _extractDe(svc.get("label")) key = _extractRegistrySourceText(svc.get("label"))
if key and key not in _REGISTRY: if key and key not in _REGISTRY:
_REGISTRY[key] = _I18nRegistryEntry(context="service", value="") _REGISTRY[key] = _I18nRegistryEntry(context="service", value="")
added += 1 added += 1
@ -387,13 +383,6 @@ def _registerNodeLabels():
output labels, port descriptions, category labels, and entry-point titles.""" output labels, port descriptions, category labels, and entry-point titles."""
added = 0 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): def _reg(key: str, ctx: str):
nonlocal added nonlocal added
if key and key not in _REGISTRY: if key and key not in _REGISTRY:
@ -403,17 +392,19 @@ def _registerNodeLabels():
try: try:
from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES
for nd in STATIC_NODE_TYPES: for nd in STATIC_NODE_TYPES:
_reg(_extractDe(nd.get("label")), "node.label") _reg(_extractRegistrySourceText(nd.get("label")), "node.label")
_reg(_extractDe(nd.get("description")), "node.desc") _reg(_extractRegistrySourceText(nd.get("description")), "node.desc")
for param in nd.get("parameters", []) or []: for param in nd.get("parameters", []) or []:
_reg(_extractDe(param.get("description")), "node.param") _reg(_extractRegistrySourceText(param.get("description")), "node.param")
_reg(_extractDe(param.get("label")), "node.param") _reg(_extractRegistrySourceText(param.get("label")), "node.param")
outLabels = nd.get("outputLabels") outLabels = nd.get("outputLabels")
if isinstance(outLabels, dict): if isinstance(outLabels, dict):
deList = outLabels.get("de") or outLabels.get("en") or [] sourceList = outLabels.get("xx") or next(iter(outLabels.values()), [])
for lbl in deList: if not isinstance(sourceList, list):
sourceList = []
for lbl in sourceList:
_reg(lbl, "node.output") _reg(lbl, "node.output")
elif isinstance(outLabels, list): elif isinstance(outLabels, list):
for lbl in outLabels: for lbl in outLabels:
@ -427,7 +418,7 @@ def _registerNodeLabels():
for field in getattr(schema, "fields", []) or []: for field in getattr(schema, "fields", []) or []:
desc = getattr(field, "description", None) desc = getattr(field, "description", None)
if desc: 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: except ImportError:
pass pass
@ -449,13 +440,6 @@ def _registerDatamodelOptionLabels():
"""Register all frontend_options labels from Pydantic datamodels and subscription plans.""" """Register all frontend_options labels from Pydantic datamodels and subscription plans."""
added = 0 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): def _reg(key: str, ctx: str):
nonlocal added nonlocal added
if key and key not in _REGISTRY: if key and key not in _REGISTRY:
@ -495,13 +479,13 @@ def _registerDatamodelOptionLabels():
ctx = f"option.{cls.__name__}.{fieldName}" ctx = f"option.{cls.__name__}.{fieldName}"
for opt in options: for opt in options:
if isinstance(opt, dict): if isinstance(opt, dict):
_reg(_extractDe(opt.get("label")), ctx) _reg(_extractRegistrySourceText(opt.get("label")), ctx)
try: try:
from modules.datamodels.datamodelSubscription import BUILTIN_PLANS from modules.datamodels.datamodelSubscription import BUILTIN_PLANS
for plan in BUILTIN_PLANS.values(): for plan in BUILTIN_PLANS.values():
_reg(_extractDe(getattr(plan, "title", None)), "subscription.title") _reg(_extractRegistrySourceText(getattr(plan, "title", None)), "subscription.title")
_reg(_extractDe(getattr(plan, "description", None)), "subscription.desc") _reg(_extractRegistrySourceText(getattr(plan, "description", None)), "subscription.desc")
except (ImportError, AttributeError): except (ImportError, AttributeError):
pass pass

View file

@ -6,7 +6,7 @@ Starts/stops cron jobs for workflows with schedule entry points.
import asyncio import asyncio
import logging import logging
from typing import Any, Dict from typing import Any, Dict, Optional
from modules.shared.eventManagement import eventManager from modules.shared.eventManagement import eventManager
@ -213,11 +213,14 @@ def _create_schedule_handler(
discoverMethods(services) discoverMethods(services)
title = (inv or {}).get("title") or {} title = (inv or {}).get("title") or {}
label = "" requestLang: Optional[str] = getattr(event_user, "language", None)
if isinstance(title, dict): 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): elif isinstance(title, str):
label = title label = title
else:
label = ""
run_env = default_run_envelope( run_env = default_run_envelope(
"schedule", "schedule",

View file

@ -11,7 +11,7 @@ Replaces subAutomation2Schedule with v1-style incremental sync patterns:
import asyncio import asyncio
import logging import logging
from typing import Any, Dict from typing import Any, Dict, Optional
from modules.shared.eventManagement import eventManager from modules.shared.eventManagement import eventManager
@ -231,11 +231,14 @@ class WorkflowScheduler:
discoverMethods(services) discoverMethods(services)
title = (inv or {}).get("title") or {} title = (inv or {}).get("title") or {}
label = "" requestLang: Optional[str] = getattr(eventUser, "language", None)
if isinstance(title, dict): 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): elif isinstance(title, str):
label = title label = title
else:
label = ""
runEnv = default_run_envelope( runEnv = default_run_envelope(
"schedule", "schedule",