diff --git a/modules/connectors/providerMsft/connectorMsft.py b/modules/connectors/providerMsft/connectorMsft.py index 50f92249..30caba95 100644 --- a/modules/connectors/providerMsft/connectorMsft.py +++ b/modules/connectors/providerMsft/connectorMsft.py @@ -34,6 +34,9 @@ class _GraphApiMixin: async def _graphPut(self, endpoint: str, data: bytes = None) -> Dict[str, Any]: return await _makeGraphCall(self._accessToken, endpoint, "PUT", data) + async def _graphPatch(self, endpoint: str, data: Any = None) -> Dict[str, Any]: + return await _makeGraphCall(self._accessToken, endpoint, "PATCH", data) + async def _graphDelete(self, endpoint: str) -> Dict[str, Any]: return await _makeGraphCall(self._accessToken, endpoint, "DELETE") @@ -82,6 +85,9 @@ async def _makeGraphCall( elif method == "PUT": async with session.put(url, **kwargs) as resp: return await _handleResponse(resp) + elif method == "PATCH": + async with session.patch(url, **kwargs) as resp: + return await _handleResponse(resp) elif method == "DELETE": async with session.delete(url, **kwargs) as resp: if resp.status in (200, 204): @@ -99,6 +105,10 @@ async def _makeGraphCall( async def _handleResponse(resp: aiohttp.ClientResponse) -> Dict[str, Any]: if resp.status in (200, 201): return await resp.json() + if resp.status == 202: + return {"accepted": True} + if resp.status == 204: + return {} errorText = await resp.text() logger.error(f"Graph API {resp.status}: {errorText}") return {"error": f"{resp.status}: {errorText}"} @@ -449,6 +459,265 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter): return result return {"success": True, "draft": True, "messageId": result.get("id", "")} + # ------------------------------------------------------------------ + # Reply / Reply-All / Forward + # ------------------------------------------------------------------ + # Microsoft Graph distinguishes between "send-immediately" endpoints + # (``/reply``, ``/replyAll``, ``/forward``) and their "create-draft" + # counterparts (``/createReply``, ``/createReplyAll``, ``/createForward``). + # The send-immediately variant accepts a free-text ``comment`` string + # that Graph prepends to the original conversation; the createReply* + # variants return a fully-populated draft message that the caller can + # further edit (e.g. via PATCH /me/messages/{id} with a richer body) + # before posting via /send. We expose both flavours so the agent can + # choose between "draft for review" and "send right now". + + async def replyToMail( + self, messageId: str, comment: str, + replyAll: bool = False, + ) -> Dict[str, Any]: + """Reply (or reply-all) to an existing message immediately. + + Preserves the conversation thread and the ``AW:`` prefix in Outlook -- + unlike sendMail() which creates a brand-new conversation. + """ + import json + endpointAction = "replyAll" if replyAll else "reply" + payload = json.dumps({"comment": comment}).encode("utf-8") + result = await self._graphPost(f"me/messages/{messageId}/{endpointAction}", payload) + if "error" in result: + return result + return {"success": True, "messageId": messageId, "action": endpointAction} + + async def forwardMail( + self, messageId: str, to: List[str], comment: str = "", + ) -> Dict[str, Any]: + """Forward an existing message to new recipients.""" + import json + payload = json.dumps({ + "comment": comment, + "toRecipients": [{"emailAddress": {"address": addr}} for addr in to], + }).encode("utf-8") + result = await self._graphPost(f"me/messages/{messageId}/forward", payload) + if "error" in result: + return result + return {"success": True, "messageId": messageId, "action": "forward"} + + async def createReplyDraft( + self, messageId: str, comment: str = "", + replyAll: bool = False, + ) -> Dict[str, Any]: + """Create a reply-draft (in the Drafts folder) that the user can edit before sending.""" + import json + endpointAction = "createReplyAll" if replyAll else "createReply" + payload = json.dumps({"comment": comment}).encode("utf-8") if comment else b"{}" + result = await self._graphPost(f"me/messages/{messageId}/{endpointAction}", payload) + if "error" in result: + return result + return {"success": True, "draft": True, "messageId": result.get("id", ""), "originalMessageId": messageId} + + async def createForwardDraft( + self, messageId: str, to: Optional[List[str]] = None, comment: str = "", + ) -> Dict[str, Any]: + """Create a forward-draft (in the Drafts folder) that the user can edit before sending.""" + import json + body: Dict[str, Any] = {} + if comment: + body["comment"] = comment + if to: + body["toRecipients"] = [{"emailAddress": {"address": addr}} for addr in to] + payload = json.dumps(body).encode("utf-8") if body else b"{}" + result = await self._graphPost(f"me/messages/{messageId}/createForward", payload) + if "error" in result: + return result + return {"success": True, "draft": True, "messageId": result.get("id", ""), "originalMessageId": messageId} + + # ------------------------------------------------------------------ + # Folder-Management & Mail-Management + # ------------------------------------------------------------------ + + # Mapping of Microsoft Graph "well-known folder names" plus a few common + # localized display names (DE) so the LLM can write natural names like + # "Posteingang", "Archiv", "deletedItems" without having to look up the + # opaque mailbox folder ID first. + _WELL_KNOWN_FOLDERS = { + "inbox": "inbox", + "posteingang": "inbox", + "drafts": "drafts", + "entwürfe": "drafts", + "entwurf": "drafts", + "sentitems": "sentitems", + "gesendet": "sentitems", + "gesendete elemente": "sentitems", + "deleteditems": "deleteditems", + "gelöscht": "deleteditems", + "gelöschte elemente": "deleteditems", + "papierkorb": "deleteditems", + "trash": "deleteditems", + "junkemail": "junkemail", + "spam": "junkemail", + "junk": "junkemail", + "outbox": "outbox", + "postausgang": "outbox", + "archive": "archive", + "archiv": "archive", + "msgfolderroot": "msgfolderroot", + "root": "msgfolderroot", + } + + async def listMailFolders(self) -> List[Dict[str, Any]]: + """List all top-level mail folders with id, name and counts. + + Returns a flat list of dicts so the caller (e.g. an LLM tool) does not + need to know the Graph nesting model. Use ``_resolveFolderId()`` to + translate a user-provided name into a Graph folder ID. + """ + folders: List[Dict[str, Any]] = [] + seenIds: set = set() + endpoint: Optional[str] = "me/mailFolders?$top=100" + while endpoint: + result = await self._graphGet(endpoint) + if "error" in result: + break + for f in result.get("value", []): + fid = f.get("id") + if fid and fid not in seenIds: + seenIds.add(fid) + folders.append({ + "id": fid, + "displayName": f.get("displayName", ""), + "totalItemCount": f.get("totalItemCount", 0), + "unreadItemCount": f.get("unreadItemCount", 0), + "childFolderCount": f.get("childFolderCount", 0), + }) + nextLink = result.get("@odata.nextLink") + endpoint = _stripGraphBase(nextLink) if nextLink else None + return folders + + async def _resolveFolderId(self, folderRef: str) -> Optional[str]: + """Resolve any user-supplied folder reference to a Graph folder ID. + + Resolution order: + 1. If it matches a well-known shortcut (locale-aware), return that + shortcut directly -- Graph accepts ``inbox``, ``drafts`` etc. in + the URL path. + 2. If it looks like a Graph folder ID (long base64-ish string), + return as-is. + 3. Otherwise fall back to a case-insensitive ``displayName`` match + against the user's mail folders. + + Returns ``None`` if nothing matches so the caller can surface a clear + error instead of silently moving mail into the wrong place. + """ + if not folderRef: + return None + ref = folderRef.strip() + wellKnown = self._WELL_KNOWN_FOLDERS.get(ref.lower()) + if wellKnown: + return wellKnown + # Heuristic: Graph folder IDs are long URL-safe base64 strings; never + # contain spaces; and almost always include "==" or AAAAA padding. + if len(ref) > 60 and " " not in ref: + return ref + for f in await self.listMailFolders(): + if (f.get("displayName") or "").strip().lower() == ref.lower(): + return f.get("id") + return None + + async def moveMail( + self, messageId: str, destinationFolder: str, + ) -> Dict[str, Any]: + """Move a message to another folder (well-known name, displayName, or folder id).""" + import json + destId = await self._resolveFolderId(destinationFolder) + if not destId: + return {"error": f"Folder not found: '{destinationFolder}'. Use listMailFolders to inspect available folders."} + payload = json.dumps({"destinationId": destId}).encode("utf-8") + result = await self._graphPost(f"me/messages/{messageId}/move", payload) + if "error" in result: + return result + return {"success": True, "messageId": result.get("id", messageId), "destinationFolder": destinationFolder} + + async def copyMail( + self, messageId: str, destinationFolder: str, + ) -> Dict[str, Any]: + """Copy a message into another folder (original stays in place).""" + import json + destId = await self._resolveFolderId(destinationFolder) + if not destId: + return {"error": f"Folder not found: '{destinationFolder}'. Use listMailFolders to inspect available folders."} + payload = json.dumps({"destinationId": destId}).encode("utf-8") + result = await self._graphPost(f"me/messages/{messageId}/copy", payload) + if "error" in result: + return result + return {"success": True, "newMessageId": result.get("id", ""), "destinationFolder": destinationFolder} + + async def archiveMail(self, messageId: str) -> Dict[str, Any]: + """Move a message to the user's Archive folder. + + Outlook's Archive is a regular mail folder, not a flag, so this is a + thin convenience wrapper around :py:meth:`moveMail`. + """ + return await self.moveMail(messageId, "archive") + + async def deleteMail( + self, messageId: str, + *, + hardDelete: bool = False, + ) -> Dict[str, Any]: + """Delete a message. + + Default behaviour (``hardDelete=False``) moves the message to the + ``Deleted Items`` folder, which mirrors what users see in the Outlook + UI when they press Delete. Set ``hardDelete=True`` to perform an + unrecoverable removal -- agent tools must require an extra + confirmation before invoking this path. + """ + if hardDelete: + result = await self._graphDelete(f"me/messages/{messageId}") + if "error" in result: + return result + return {"success": True, "messageId": messageId, "hardDelete": True} + return await self.moveMail(messageId, "deleteditems") + + async def markMailAsRead(self, messageId: str) -> Dict[str, Any]: + """Mark a message as read (sets ``isRead=true``).""" + import json + payload = json.dumps({"isRead": True}).encode("utf-8") + result = await self._graphPatch(f"me/messages/{messageId}", payload) + if "error" in result: + return result + return {"success": True, "messageId": messageId, "isRead": True} + + async def markMailAsUnread(self, messageId: str) -> Dict[str, Any]: + """Mark a message as unread (sets ``isRead=false``).""" + import json + payload = json.dumps({"isRead": False}).encode("utf-8") + result = await self._graphPatch(f"me/messages/{messageId}", payload) + if "error" in result: + return result + return {"success": True, "messageId": messageId, "isRead": False} + + async def flagMail( + self, messageId: str, + *, + flagStatus: str = "flagged", + ) -> Dict[str, Any]: + """Set or clear the follow-up flag on a message. + + ``flagStatus`` accepts ``"flagged"`` (default), ``"complete"`` or + ``"notFlagged"`` -- the three values Microsoft Graph recognises for + ``followupFlag.flagStatus``. + """ + import json + if flagStatus not in ("flagged", "complete", "notFlagged"): + return {"error": f"Invalid flagStatus '{flagStatus}'. Use one of: flagged, complete, notFlagged."} + payload = json.dumps({"flag": {"flagStatus": flagStatus}}).encode("utf-8") + result = await self._graphPatch(f"me/messages/{messageId}", payload) + if "error" in result: + return result + return {"success": True, "messageId": messageId, "flagStatus": flagStatus} + # --------------------------------------------------------------------------- # Teams Adapter (Stub) diff --git a/modules/datamodels/datamodelFiles.py b/modules/datamodels/datamodelFiles.py index 4cc6beba..c8b0c865 100644 --- a/modules/datamodels/datamodelFiles.py +++ b/modules/datamodels/datamodelFiles.py @@ -60,7 +60,14 @@ class FileItem(PowerOnModel): ) fileSize: int = Field( description="Size of the file in bytes", - json_schema_extra={"label": "Dateigroesse", "frontend_type": "integer", "frontend_readonly": True, "frontend_required": False}, + json_schema_extra={ + "label": "Dateigroesse", + "frontend_type": "integer", + "frontend_readonly": True, + "frontend_required": False, + # Auto-scale byte units (B / KB / MB / GB / TB), right-aligned in tables. + "frontend_format": "R:b", + }, ) tags: Optional[List[str]] = Field( default=None, diff --git a/modules/datamodels/datamodelUam.py b/modules/datamodels/datamodelUam.py index b78e22c5..90dd9452 100644 --- a/modules/datamodels/datamodelUam.py +++ b/modules/datamodels/datamodelUam.py @@ -162,12 +162,25 @@ class Mandate(PowerOnModel): enabled: bool = Field( default=True, description="Indicates whether the mandate is enabled", - json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False, "label": "Aktiviert"}, + json_schema_extra={ + "frontend_type": "checkbox", + "frontend_readonly": False, + "frontend_required": False, + "label": "Aktiviert", + # Render boolean as i18n-translatable label tuple [true, neutral, false]. + "frontend_format_labels": ["Ja", "-", "Nein"], + }, ) isSystem: bool = Field( default=False, description="Whether this is a system mandate (e.g. root mandate). Cannot be deleted.", - json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False, "label": "System-Mandant"}, + json_schema_extra={ + "frontend_type": "checkbox", + "frontend_readonly": True, + "frontend_required": False, + "label": "System-Mandant", + "frontend_format_labels": ["Ja", "-", "Nein"], + }, ) deletedAt: Optional[float] = Field( default=None, @@ -546,7 +559,13 @@ class User(PowerOnModel): enabled: bool = Field( default=True, description="Indicates whether the user is enabled", - json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False, "label": "Aktiviert"}, + json_schema_extra={ + "frontend_type": "checkbox", + "frontend_readonly": False, + "frontend_required": False, + "label": "Aktiviert", + "frontend_format_labels": ["Ja", "-", "Nein"], + }, ) isSysAdmin: bool = Field( diff --git a/modules/features/chatbot/mainChatbot.py b/modules/features/chatbot/mainChatbot.py index 6f3b7dae..7b3b6e0c 100644 --- a/modules/features/chatbot/mainChatbot.py +++ b/modules/features/chatbot/mainChatbot.py @@ -8,18 +8,20 @@ Handles feature initialization and RBAC catalog registration. import logging from typing import Dict, List, Any, Optional +from modules.shared.i18nRegistry import t + logger = logging.getLogger(__name__) # Feature metadata FEATURE_CODE = "chatbot" -FEATURE_LABEL = "Chatbot" +FEATURE_LABEL = t("Chatbot", context="UI") FEATURE_ICON = "mdi-robot" # UI Objects for RBAC catalog UI_OBJECTS = [ { "objectKey": "ui.feature.chatbot.conversations", - "label": "Konversationen", + "label": t("Konversationen", context="UI"), "meta": {"area": "conversations"} } ] @@ -28,22 +30,22 @@ UI_OBJECTS = [ RESOURCE_OBJECTS = [ { "objectKey": "resource.feature.chatbot.startStream", - "label": "Chat starten (Stream)", + "label": t("Chat starten (Stream)", context="UI"), "meta": {"endpoint": "/api/chatbot/{instanceId}/start/stream", "method": "POST"} }, { "objectKey": "resource.feature.chatbot.stop", - "label": "Chat stoppen", + "label": t("Chat stoppen", context="UI"), "meta": {"endpoint": "/api/chatbot/{instanceId}/stop/{workflowId}", "method": "POST"} }, { "objectKey": "resource.feature.chatbot.threads", - "label": "Threads abrufen", + "label": t("Threads abrufen", context="UI"), "meta": {"endpoint": "/api/chatbot/{instanceId}/threads", "method": "GET"} }, { "objectKey": "resource.feature.chatbot.delete", - "label": "Chat löschen", + "label": t("Chat löschen", context="UI"), "meta": {"endpoint": "/api/chatbot/{instanceId}/{workflowId}", "method": "DELETE"} }, ] diff --git a/modules/features/commcoach/mainCommcoach.py b/modules/features/commcoach/mainCommcoach.py index d27b2090..33469a62 100644 --- a/modules/features/commcoach/mainCommcoach.py +++ b/modules/features/commcoach/mainCommcoach.py @@ -8,26 +8,28 @@ Handles feature initialization and RBAC catalog registration. import logging from typing import Dict, List, Any +from modules.shared.i18nRegistry import t + logger = logging.getLogger(__name__) FEATURE_CODE = "commcoach" -FEATURE_LABEL = "Kommunikations-Coach" +FEATURE_LABEL = t("Kommunikations-Coach", context="UI") FEATURE_ICON = "mdi-account-voice" UI_OBJECTS = [ { "objectKey": "ui.feature.commcoach.dashboard", - "label": "Dashboard", + "label": t("Dashboard", context="UI"), "meta": {"area": "dashboard"} }, { "objectKey": "ui.feature.commcoach.coaching", - "label": "Arbeitsthemen", + "label": t("Arbeitsthemen", context="UI"), "meta": {"area": "coaching"} }, { "objectKey": "ui.feature.commcoach.settings", - "label": "Einstellungen", + "label": t("Einstellungen", context="UI"), "meta": {"area": "settings"} }, ] @@ -36,7 +38,7 @@ DATA_OBJECTS = [ # ── Record-Hierarchie: Context → Session → Message/Score, Context → Task ── { "objectKey": "data.feature.commcoach.CoachingContext", - "label": "Coaching-Kontext", + "label": t("Coaching-Kontext", context="UI"), "meta": { "table": "CoachingContext", "fields": ["id", "title", "category", "status", "lastSessionAt"], @@ -46,7 +48,7 @@ DATA_OBJECTS = [ }, { "objectKey": "data.feature.commcoach.CoachingSession", - "label": "Coaching-Session", + "label": t("Coaching-Session", context="UI"), "meta": { "table": "CoachingSession", "fields": ["id", "contextId", "status", "summary", "startedAt", "endedAt", "competenceScore"], @@ -58,7 +60,7 @@ DATA_OBJECTS = [ }, { "objectKey": "data.feature.commcoach.CoachingMessage", - "label": "Coaching-Nachricht", + "label": t("Coaching-Nachricht", context="UI"), "meta": { "table": "CoachingMessage", "fields": ["id", "sessionId", "contextId", "role", "content", "contentType"], @@ -68,7 +70,7 @@ DATA_OBJECTS = [ }, { "objectKey": "data.feature.commcoach.CoachingScore", - "label": "Coaching-Score", + "label": t("Coaching-Score", context="UI"), "meta": { "table": "CoachingScore", "fields": ["id", "sessionId", "contextId", "dimension", "score", "trend"], @@ -78,7 +80,7 @@ DATA_OBJECTS = [ }, { "objectKey": "data.feature.commcoach.CoachingTask", - "label": "Coaching-Aufgabe", + "label": t("Coaching-Aufgabe", context="UI"), "meta": { "table": "CoachingTask", "fields": ["id", "contextId", "title", "status", "priority", "dueDate"], @@ -89,12 +91,12 @@ DATA_OBJECTS = [ # ── Stammdaten (sessionübergreifend, scoped per userId) ────────────────── { "objectKey": "data.feature.commcoach.userData", - "label": "Stammdaten", + "label": t("Stammdaten", context="UI"), "meta": {"isGroup": True} }, { "objectKey": "data.feature.commcoach.CoachingUserProfile", - "label": "Benutzerprofil", + "label": t("Benutzerprofil", context="UI"), "meta": { "table": "CoachingUserProfile", "group": "data.feature.commcoach.userData", @@ -103,7 +105,7 @@ DATA_OBJECTS = [ }, { "objectKey": "data.feature.commcoach.CoachingPersona", - "label": "Coaching-Persona", + "label": t("Coaching-Persona", context="UI"), "meta": { "table": "CoachingPersona", "group": "data.feature.commcoach.userData", @@ -112,7 +114,7 @@ DATA_OBJECTS = [ }, { "objectKey": "data.feature.commcoach.CoachingBadge", - "label": "Coaching-Auszeichnung", + "label": t("Coaching-Auszeichnung", context="UI"), "meta": { "table": "CoachingBadge", "group": "data.feature.commcoach.userData", @@ -121,7 +123,7 @@ DATA_OBJECTS = [ }, { "objectKey": "data.feature.commcoach.*", - "label": "Alle CommCoach-Daten", + "label": t("Alle CommCoach-Daten", context="UI"), "meta": {"wildcard": True} }, ] @@ -129,27 +131,27 @@ DATA_OBJECTS = [ RESOURCE_OBJECTS = [ { "objectKey": "resource.feature.commcoach.context.create", - "label": "Kontext erstellen", + "label": t("Kontext erstellen", context="UI"), "meta": {"endpoint": "/api/commcoach/{instanceId}/contexts", "method": "POST"} }, { "objectKey": "resource.feature.commcoach.context.archive", - "label": "Kontext archivieren", + "label": t("Kontext archivieren", context="UI"), "meta": {"endpoint": "/api/commcoach/{instanceId}/contexts/{contextId}/archive", "method": "POST"} }, { "objectKey": "resource.feature.commcoach.session.start", - "label": "Session starten", + "label": t("Session starten", context="UI"), "meta": {"endpoint": "/api/commcoach/{instanceId}/contexts/{contextId}/sessions/start", "method": "POST"} }, { "objectKey": "resource.feature.commcoach.session.complete", - "label": "Session abschliessen", + "label": t("Session abschliessen", context="UI"), "meta": {"endpoint": "/api/commcoach/{instanceId}/sessions/{sessionId}/complete", "method": "POST"} }, { "objectKey": "resource.feature.commcoach.task.manage", - "label": "Aufgaben verwalten", + "label": t("Aufgaben verwalten", context="UI"), "meta": {"endpoint": "/api/commcoach/{instanceId}/contexts/{contextId}/tasks", "method": "POST"} }, ] diff --git a/modules/features/graphicalEditor/mainGraphicalEditor.py b/modules/features/graphicalEditor/mainGraphicalEditor.py index a2bff9bc..86530123 100644 --- a/modules/features/graphicalEditor/mainGraphicalEditor.py +++ b/modules/features/graphicalEditor/mainGraphicalEditor.py @@ -8,6 +8,8 @@ Minimal bootstrap for feature instance creation. Build from here. import logging from typing import Dict, List, Any, Optional +from modules.shared.i18nRegistry import t + logger = logging.getLogger(__name__) FEATURE_CODE = "graphicalEditor" @@ -21,28 +23,28 @@ REQUIRED_SERVICES = [ {"serviceKey": "clickup", "meta": {"usage": "ClickUp actions"}}, {"serviceKey": "generation", "meta": {"usage": "file.create document rendering"}}, ] -FEATURE_LABEL = "Grafischer Editor" +FEATURE_LABEL = t("Grafischer Editor", context="UI") FEATURE_ICON = "mdi-sitemap" UI_OBJECTS = [ { "objectKey": "ui.feature.graphicalEditor.editor", - "label": "Editor", + "label": t("Editor", context="UI"), "meta": {"area": "editor"} }, { "objectKey": "ui.feature.graphicalEditor.workflows", - "label": "Workflows", + "label": t("Workflows", context="UI"), "meta": {"area": "workflows"} }, { "objectKey": "ui.feature.graphicalEditor.templates", - "label": "Vorlagen", + "label": t("Vorlagen", context="UI"), "meta": {"area": "templates"} }, { "objectKey": "ui.feature.graphicalEditor.workflows-tasks", - "label": "Tasks", + "label": t("Tasks", context="UI"), "meta": {"area": "tasks"} }, ] @@ -50,17 +52,17 @@ UI_OBJECTS = [ RESOURCE_OBJECTS = [ { "objectKey": "resource.feature.graphicalEditor.dashboard", - "label": "Dashboard aufrufen", + "label": t("Dashboard aufrufen", context="UI"), "meta": {"endpoint": "/api/workflows/{instanceId}/info", "method": "GET"} }, { "objectKey": "resource.feature.graphicalEditor.node-types", - "label": "Node-Typen abrufen", + "label": t("Node-Typen abrufen", context="UI"), "meta": {"endpoint": "/api/workflows/{instanceId}/node-types", "method": "GET"} }, { "objectKey": "resource.feature.graphicalEditor.execute", - "label": "Workflow ausführen", + "label": t("Workflow ausführen", context="UI"), "meta": {"endpoint": "/api/workflows/{instanceId}/execute", "method": "POST"} }, ] diff --git a/modules/features/neutralization/mainNeutralization.py b/modules/features/neutralization/mainNeutralization.py index 2c69fe7b..0fe11aea 100644 --- a/modules/features/neutralization/mainNeutralization.py +++ b/modules/features/neutralization/mainNeutralization.py @@ -8,18 +8,20 @@ Handles feature initialization and RBAC catalog registration. import logging from typing import Dict, List, Any +from modules.shared.i18nRegistry import t + logger = logging.getLogger(__name__) # Feature metadata FEATURE_CODE = "neutralization" -FEATURE_LABEL = "Neutralisierung" +FEATURE_LABEL = t("Neutralisierung", context="UI") FEATURE_ICON = "mdi-shield-check" # UI Objects for RBAC catalog UI_OBJECTS = [ { "objectKey": "ui.feature.neutralization.playground", - "label": "Spielwiese", + "label": t("Spielwiese", context="UI"), "meta": {"area": "playground"} } ] @@ -28,17 +30,17 @@ UI_OBJECTS = [ RESOURCE_OBJECTS = [ { "objectKey": "resource.feature.neutralization.process.text", - "label": "Text verarbeiten", + "label": t("Text verarbeiten", context="UI"), "meta": {"endpoint": "/api/neutralization/process/text", "method": "POST"} }, { "objectKey": "resource.feature.neutralization.process.files", - "label": "Dateien verarbeiten", + "label": t("Dateien verarbeiten", context="UI"), "meta": {"endpoint": "/api/neutralization/process/files", "method": "POST"} }, { "objectKey": "resource.feature.neutralization.config.update", - "label": "Konfiguration aktualisieren", + "label": t("Konfiguration aktualisieren", context="UI"), "meta": {"endpoint": "/api/neutralization/config", "method": "PUT"} }, ] diff --git a/modules/features/realEstate/mainRealEstate.py b/modules/features/realEstate/mainRealEstate.py index 0ae29159..061a54c3 100644 --- a/modules/features/realEstate/mainRealEstate.py +++ b/modules/features/realEstate/mainRealEstate.py @@ -8,30 +8,29 @@ This module also handles feature initialization and RBAC catalog registration. import logging -# Feature metadata for RBAC catalog +from modules.shared.i18nRegistry import t + FEATURE_CODE = "realestate" -FEATURE_LABEL = "Immobilien" +FEATURE_LABEL = t("Immobilien", context="UI") FEATURE_ICON = "mdi-home-city" -# UI Objects for RBAC catalog (only map view) UI_OBJECTS = [ { "objectKey": "ui.feature.realestate.dashboard", - "label": "Karte", + "label": t("Karte", context="UI"), "meta": {"area": "dashboard"} }, ] -# Resource Objects for RBAC catalog RESOURCE_OBJECTS = [ { "objectKey": "resource.feature.realestate.project.create", - "label": "Projekt erstellen", + "label": t("Projekt erstellen", context="UI"), "meta": {"endpoint": "/api/realestate/project", "method": "POST"} }, { "objectKey": "resource.feature.realestate.project.delete", - "label": "Projekt löschen", + "label": t("Projekt löschen", context="UI"), "meta": {"endpoint": "/api/realestate/project/{projectId}", "method": "DELETE"} }, ] diff --git a/modules/features/redmine/mainRedmine.py b/modules/features/redmine/mainRedmine.py index ba2225b4..919361c1 100644 --- a/modules/features/redmine/mainRedmine.py +++ b/modules/features/redmine/mainRedmine.py @@ -33,10 +33,10 @@ UI_OBJECTS: List[Dict[str, Any]] = [ DATA_OBJECTS: List[Dict[str, Any]] = [ - {"objectKey": "data.feature.redmine.config", "label": "Konfiguration", "meta": {"isGroup": True}}, + {"objectKey": "data.feature.redmine.config", "label": t("Konfiguration", context="UI"), "meta": {"isGroup": True}}, { "objectKey": "data.feature.redmine.RedmineInstanceConfig", - "label": "Redmine-Verbindung", + "label": t("Redmine-Verbindung", context="UI"), "meta": { "table": "RedmineInstanceConfig", "group": "data.feature.redmine.config", @@ -45,7 +45,7 @@ DATA_OBJECTS: List[Dict[str, Any]] = [ }, { "objectKey": "data.feature.redmine.RedmineTicketMirror", - "label": "Redmine-Tickets (Mirror)", + "label": t("Redmine-Tickets (Mirror)", context="UI"), "meta": { "table": "RedmineTicketMirror", "group": "data.feature.redmine.config", @@ -54,7 +54,7 @@ DATA_OBJECTS: List[Dict[str, Any]] = [ }, { "objectKey": "data.feature.redmine.RedmineRelationMirror", - "label": "Redmine-Beziehungen (Mirror)", + "label": t("Redmine-Beziehungen (Mirror)", context="UI"), "meta": { "table": "RedmineRelationMirror", "group": "data.feature.redmine.config", @@ -63,7 +63,7 @@ DATA_OBJECTS: List[Dict[str, Any]] = [ }, { "objectKey": "data.feature.redmine.*", - "label": "Alle Redmine-Daten", + "label": t("Alle Redmine-Daten", context="UI"), "meta": {"wildcard": True, "description": "Wildcard for all redmine data tables"}, }, ] @@ -72,62 +72,62 @@ DATA_OBJECTS: List[Dict[str, Any]] = [ RESOURCE_OBJECTS: List[Dict[str, Any]] = [ { "objectKey": "resource.feature.redmine.tickets.read", - "label": "Tickets lesen", + "label": t("Tickets lesen", context="UI"), "meta": {"endpoint": "/api/redmine/{instanceId}/tickets", "method": "GET"}, }, { "objectKey": "resource.feature.redmine.tickets.create", - "label": "Tickets erstellen", + "label": t("Tickets erstellen", context="UI"), "meta": {"endpoint": "/api/redmine/{instanceId}/tickets", "method": "POST"}, }, { "objectKey": "resource.feature.redmine.tickets.update", - "label": "Tickets bearbeiten", + "label": t("Tickets bearbeiten", context="UI"), "meta": {"endpoint": "/api/redmine/{instanceId}/tickets/{issueId}", "method": "PUT"}, }, { "objectKey": "resource.feature.redmine.tickets.delete", - "label": "Tickets loeschen / archivieren", + "label": t("Tickets loeschen / archivieren", context="UI"), "meta": {"endpoint": "/api/redmine/{instanceId}/tickets/{issueId}", "method": "DELETE"}, }, { "objectKey": "resource.feature.redmine.relations.manage", - "label": "Beziehungen verwalten", + "label": t("Beziehungen verwalten", context="UI"), "meta": {"endpoint": "/api/redmine/{instanceId}/tickets/{issueId}/relations", "method": "ALL"}, }, { "objectKey": "resource.feature.redmine.stats.read", - "label": "Statistik einsehen", + "label": t("Statistik einsehen", context="UI"), "meta": {"endpoint": "/api/redmine/{instanceId}/stats", "method": "GET"}, }, { "objectKey": "resource.feature.redmine.config.manage", - "label": "Verbindung verwalten", + "label": t("Verbindung verwalten", context="UI"), "meta": {"endpoint": "/api/redmine/{instanceId}/config", "method": "ALL", "admin_only": True}, }, { "objectKey": "resource.feature.redmine.config.test", - "label": "Verbindung testen", + "label": t("Verbindung testen", context="UI"), "meta": {"endpoint": "/api/redmine/{instanceId}/config/test", "method": "POST", "admin_only": True}, }, { "objectKey": "resource.feature.redmine.sync.run", - "label": "Mirror synchronisieren", + "label": t("Mirror synchronisieren", context="UI"), "meta": {"endpoint": "/api/redmine/{instanceId}/sync", "method": "POST", "admin_only": True}, }, { "objectKey": "resource.feature.redmine.sync.status", - "label": "Sync-Status lesen", + "label": t("Sync-Status lesen", context="UI"), "meta": {"endpoint": "/api/redmine/{instanceId}/sync/status", "method": "GET"}, }, { "objectKey": "resource.feature.redmine.workflows.view", - "label": "Workflows einsehen", + "label": t("Workflows einsehen", context="UI"), "meta": {"endpoint": "/api/workflows/{instanceId}/workflows", "method": "GET"}, }, { "objectKey": "resource.feature.redmine.workflows.execute", - "label": "Workflows ausfuehren", + "label": t("Workflows ausfuehren", context="UI"), "meta": {"endpoint": "/api/workflows/{instanceId}/execute", "method": "POST"}, }, ] diff --git a/modules/features/teamsbot/mainTeamsbot.py b/modules/features/teamsbot/mainTeamsbot.py index 02d7c333..c2c271e0 100644 --- a/modules/features/teamsbot/mainTeamsbot.py +++ b/modules/features/teamsbot/mainTeamsbot.py @@ -8,28 +8,30 @@ Handles feature initialization and RBAC catalog registration. import logging from typing import Dict, List, Any +from modules.shared.i18nRegistry import t + logger = logging.getLogger(__name__) # Feature metadata FEATURE_CODE = "teamsbot" -FEATURE_LABEL = "Teams Bot" +FEATURE_LABEL = t("Teams Bot", context="UI") FEATURE_ICON = "mdi-headset" # UI Objects for RBAC catalog UI_OBJECTS = [ { "objectKey": "ui.feature.teamsbot.dashboard", - "label": "Dashboard", + "label": t("Dashboard", context="UI"), "meta": {"area": "dashboard"} }, { "objectKey": "ui.feature.teamsbot.sessions", - "label": "Sitzungen", + "label": t("Sitzungen", context="UI"), "meta": {"area": "sessions"} }, { "objectKey": "ui.feature.teamsbot.settings", - "label": "Einstellungen", + "label": t("Einstellungen", context="UI"), "meta": {"area": "settings", "admin_only": True} }, ] @@ -38,7 +40,7 @@ UI_OBJECTS = [ DATA_OBJECTS = [ { "objectKey": "data.feature.teamsbot.TeamsbotSession", - "label": "Sitzung", + "label": t("Sitzung", context="UI"), "meta": { "table": "TeamsbotSession", "fields": ["id", "meetingLink", "botName", "status", "startedAt", "endedAt"], @@ -48,7 +50,7 @@ DATA_OBJECTS = [ }, { "objectKey": "data.feature.teamsbot.TeamsbotTranscript", - "label": "Transkript", + "label": t("Transkript", context="UI"), "meta": { "table": "TeamsbotTranscript", "fields": ["id", "sessionId", "speaker", "text", "timestamp"], @@ -58,7 +60,7 @@ DATA_OBJECTS = [ }, { "objectKey": "data.feature.teamsbot.TeamsbotBotResponse", - "label": "Bot-Antwort", + "label": t("Bot-Antwort", context="UI"), "meta": { "table": "TeamsbotBotResponse", "fields": ["id", "sessionId", "responseText", "detectedIntent"], @@ -68,7 +70,7 @@ DATA_OBJECTS = [ }, { "objectKey": "data.feature.teamsbot.*", - "label": "Alle Teams Bot Daten", + "label": t("Alle Teams Bot Daten", context="UI"), "meta": {"wildcard": True, "description": "Wildcard for all teamsbot data tables"} }, ] @@ -77,22 +79,22 @@ DATA_OBJECTS = [ RESOURCE_OBJECTS = [ { "objectKey": "resource.feature.teamsbot.session.start", - "label": "Sitzung starten", + "label": t("Sitzung starten", context="UI"), "meta": {"endpoint": "/api/teamsbot/{instanceId}/sessions", "method": "POST"} }, { "objectKey": "resource.feature.teamsbot.session.stop", - "label": "Sitzung beenden", + "label": t("Sitzung beenden", context="UI"), "meta": {"endpoint": "/api/teamsbot/{instanceId}/sessions/{sessionId}/stop", "method": "POST"} }, { "objectKey": "resource.feature.teamsbot.session.delete", - "label": "Sitzung löschen", + "label": t("Sitzung löschen", context="UI"), "meta": {"endpoint": "/api/teamsbot/{instanceId}/sessions/{sessionId}", "method": "DELETE"} }, { "objectKey": "resource.feature.teamsbot.config.edit", - "label": "Konfiguration bearbeiten", + "label": t("Konfiguration bearbeiten", context="UI"), "meta": {"endpoint": "/api/teamsbot/{instanceId}/config", "method": "PUT", "admin_only": True} }, ] diff --git a/modules/features/trustee/datamodelFeatureTrustee.py b/modules/features/trustee/datamodelFeatureTrustee.py index fcf5c8b4..265227a0 100644 --- a/modules/features/trustee/datamodelFeatureTrustee.py +++ b/modules/features/trustee/datamodelFeatureTrustee.py @@ -526,7 +526,8 @@ class TrusteePosition(PowerOnModel): "label": "Buchungsbetrag", "frontend_type": "number", "frontend_readonly": False, - "frontend_required": True + "frontend_required": True, + "frontend_format": "R:#'###.00", } ) originalCurrency: str = Field( @@ -551,7 +552,8 @@ class TrusteePosition(PowerOnModel): "label": "Originalbetrag", "frontend_type": "number", "frontend_readonly": False, - "frontend_required": True + "frontend_required": True, + "frontend_format": "R:#'###.00", } ) vatPercentage: float = Field( @@ -561,7 +563,8 @@ class TrusteePosition(PowerOnModel): "label": "MwSt-Prozentsatz", "frontend_type": "number", "frontend_readonly": False, - "frontend_required": False + "frontend_required": False, + "frontend_format": "R:0.00", } ) vatAmount: float = Field( @@ -571,7 +574,8 @@ class TrusteePosition(PowerOnModel): "label": "MwSt-Betrag", "frontend_type": "number", "frontend_readonly": False, - "frontend_required": False + "frontend_required": False, + "frontend_format": "R:#'###.00", } ) debitAccountNumber: Optional[str] = Field( @@ -750,7 +754,15 @@ class TrusteeDataJournalEntry(PowerOnModel): reference: Optional[str] = Field(default=None, description="Booking reference / voucher number", json_schema_extra={"label": "Referenz"}) description: str = Field(default="", description="Booking text", json_schema_extra={"label": "Beschreibung"}) currency: str = Field(default="CHF", json_schema_extra={"label": "Währung"}) - totalAmount: float = Field(default=0.0, description="Total amount of entry", json_schema_extra={"label": "Betrag"}) + totalAmount: float = Field( + default=0.0, + description="Total amount of entry", + json_schema_extra={ + "label": "Betrag", + # Right-aligned amount with Swiss thousands separator and 2 decimals. + "frontend_format": "R:#'###.00", + }, + ) mandateId: Optional[str] = Field(default=None, json_schema_extra={"label": "Mandat", "fk_target": {"db": "poweron_app", "table": "Mandate"}}) featureInstanceId: Optional[str] = Field(default=None, json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance"}}) @@ -760,8 +772,8 @@ class TrusteeDataJournalLine(PowerOnModel): id: str = Field(default_factory=lambda: str(uuid.uuid4()), json_schema_extra={"label": "ID"}) journalEntryId: str = Field(description="FK → TrusteeDataJournalEntry.id", json_schema_extra={"label": "Buchung", "fk_target": {"db": "poweron_trustee", "table": "TrusteeDataJournalEntry"}}) accountNumber: str = Field(description="Account number", json_schema_extra={"label": "Konto"}) - debitAmount: float = Field(default=0.0, json_schema_extra={"label": "Soll"}) - creditAmount: float = Field(default=0.0, json_schema_extra={"label": "Haben"}) + debitAmount: float = Field(default=0.0, json_schema_extra={"label": "Soll", "frontend_format": "R:#'###.00"}) + creditAmount: float = Field(default=0.0, json_schema_extra={"label": "Haben", "frontend_format": "R:#'###.00"}) currency: str = Field(default="CHF", json_schema_extra={"label": "Währung"}) taxCode: Optional[str] = Field(default=None, json_schema_extra={"label": "Steuercode"}) costCenter: Optional[str] = Field(default=None, json_schema_extra={"label": "Kostenstelle"}) @@ -794,10 +806,10 @@ class TrusteeDataAccountBalance(PowerOnModel): accountNumber: str = Field(description="Account number", json_schema_extra={"label": "Konto"}) periodYear: int = Field(description="Fiscal year", json_schema_extra={"label": "Jahr"}) periodMonth: int = Field(default=0, description="Month (1-12); 0 = annual total", json_schema_extra={"label": "Monat"}) - openingBalance: float = Field(default=0.0, json_schema_extra={"label": "Eröffnungssaldo"}) - debitTotal: float = Field(default=0.0, json_schema_extra={"label": "Soll-Umsatz"}) - creditTotal: float = Field(default=0.0, json_schema_extra={"label": "Haben-Umsatz"}) - closingBalance: float = Field(default=0.0, json_schema_extra={"label": "Schlusssaldo"}) + openingBalance: float = Field(default=0.0, json_schema_extra={"label": "Eröffnungssaldo", "frontend_format": "R:#'###.00"}) + debitTotal: float = Field(default=0.0, json_schema_extra={"label": "Soll-Umsatz", "frontend_format": "R:#'###.00"}) + creditTotal: float = Field(default=0.0, json_schema_extra={"label": "Haben-Umsatz", "frontend_format": "R:#'###.00"}) + closingBalance: float = Field(default=0.0, json_schema_extra={"label": "Schlusssaldo", "frontend_format": "R:#'###.00"}) currency: str = Field(default="CHF", json_schema_extra={"label": "Währung"}) mandateId: Optional[str] = Field(default=None, json_schema_extra={"label": "Mandat", "fk_target": {"db": "poweron_app", "table": "Mandate"}}) featureInstanceId: Optional[str] = Field(default=None, json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance"}}) diff --git a/modules/features/trustee/mainTrustee.py b/modules/features/trustee/mainTrustee.py index ac7b871f..0799fa1c 100644 --- a/modules/features/trustee/mainTrustee.py +++ b/modules/features/trustee/mainTrustee.py @@ -8,11 +8,13 @@ Handles feature initialization and RBAC catalog registration. import logging from typing import Dict, List, Any +from modules.shared.i18nRegistry import t + logger = logging.getLogger(__name__) # Feature metadata FEATURE_CODE = "trustee" -FEATURE_LABEL = "Treuhand" +FEATURE_LABEL = t("Treuhand", context="UI") FEATURE_ICON = "mdi-briefcase" # UI Objects for RBAC catalog @@ -20,7 +22,7 @@ FEATURE_ICON = "mdi-briefcase" UI_OBJECTS = [ { "objectKey": "ui.feature.trustee.dashboard", - "label": "Dashboard", + "label": t("Dashboard", context="UI"), "meta": {"area": "dashboard"} }, # Note: ui.feature.trustee.positions and .documents removed. @@ -30,32 +32,32 @@ UI_OBJECTS = [ # remains and continues to gate per-row access. { "objectKey": "ui.feature.trustee.data-tables", - "label": "Daten-Tabellen", + "label": t("Daten-Tabellen", context="UI"), "meta": {"area": "data-tables"} }, { "objectKey": "ui.feature.trustee.import-process", - "label": "Import & Verarbeitung", + "label": t("Import & Verarbeitung", context="UI"), "meta": {"area": "import-process"} }, { "objectKey": "ui.feature.trustee.analyse", - "label": "Analyse & Reporting", + "label": t("Analyse & Reporting", context="UI"), "meta": {"area": "analyse"} }, { "objectKey": "ui.feature.trustee.abschluss", - "label": "Abschluss & Prüfung", + "label": t("Abschluss & Prüfung", context="UI"), "meta": {"area": "abschluss"} }, { "objectKey": "ui.feature.trustee.settings", - "label": "Buchhaltungs-Einstellungen", + "label": t("Buchhaltungs-Einstellungen", context="UI"), "meta": {"area": "settings", "admin_only": True} }, { "objectKey": "ui.feature.trustee.instance-roles", - "label": "Instanz-Rollen & Berechtigungen", + "label": t("Instanz-Rollen & Berechtigungen", context="UI"), "meta": {"area": "admin", "admin_only": True} }, ] @@ -69,23 +71,23 @@ DATA_OBJECTS = [ # ── Categorical Groups (UDB folders) ───────────────────────────────────── { "objectKey": "data.feature.trustee.localData", - "label": "Lokale Daten", + "label": t("Lokale Daten", context="UI"), "meta": {"isGroup": True} }, { "objectKey": "data.feature.trustee.config", - "label": "Konfiguration", + "label": t("Konfiguration", context="UI"), "meta": {"isGroup": True} }, { "objectKey": "data.feature.trustee.accountingData", - "label": "Daten aus Buchhaltungssystem", + "label": t("Daten aus Buchhaltungssystem", context="UI"), "meta": {"isGroup": True} }, # ── Lokale Daten ───────────────────────────────────────────────────────── { "objectKey": "data.feature.trustee.TrusteePosition", - "label": "Position", + "label": t("Position", context="UI"), "meta": { "table": "TrusteePosition", "group": "data.feature.trustee.localData", @@ -94,7 +96,7 @@ DATA_OBJECTS = [ }, { "objectKey": "data.feature.trustee.TrusteeDocument", - "label": "Dokument", + "label": t("Dokument", context="UI"), "meta": { "table": "TrusteeDocument", "group": "data.feature.trustee.localData", @@ -104,7 +106,7 @@ DATA_OBJECTS = [ # ── Konfiguration ──────────────────────────────────────────────────────── { "objectKey": "data.feature.trustee.TrusteeAccountingConfig", - "label": "Buchhaltungs-Verbindung", + "label": t("Buchhaltungs-Verbindung", context="UI"), "meta": { "table": "TrusteeAccountingConfig", "group": "data.feature.trustee.config", @@ -113,7 +115,7 @@ DATA_OBJECTS = [ }, { "objectKey": "data.feature.trustee.TrusteeAccountingSync", - "label": "Sync-Protokoll", + "label": t("Sync-Protokoll", context="UI"), "meta": { "table": "TrusteeAccountingSync", "group": "data.feature.trustee.config", @@ -123,7 +125,7 @@ DATA_OBJECTS = [ # ── Daten aus Buchhaltungssystem ───────────────────────────────────────── { "objectKey": "data.feature.trustee.TrusteeDataAccount", - "label": "Kontenplan", + "label": t("Kontenplan", context="UI"), "meta": { "table": "TrusteeDataAccount", "group": "data.feature.trustee.accountingData", @@ -132,7 +134,7 @@ DATA_OBJECTS = [ }, { "objectKey": "data.feature.trustee.TrusteeDataJournalEntry", - "label": "Buchungen", + "label": t("Buchungen", context="UI"), "meta": { "table": "TrusteeDataJournalEntry", "group": "data.feature.trustee.accountingData", @@ -141,7 +143,7 @@ DATA_OBJECTS = [ }, { "objectKey": "data.feature.trustee.TrusteeDataJournalLine", - "label": "Buchungszeilen", + "label": t("Buchungszeilen", context="UI"), "meta": { "table": "TrusteeDataJournalLine", "group": "data.feature.trustee.accountingData", @@ -150,7 +152,7 @@ DATA_OBJECTS = [ }, { "objectKey": "data.feature.trustee.TrusteeDataContact", - "label": "Kontakte", + "label": t("Kontakte", context="UI"), "meta": { "table": "TrusteeDataContact", "group": "data.feature.trustee.accountingData", @@ -159,7 +161,7 @@ DATA_OBJECTS = [ }, { "objectKey": "data.feature.trustee.TrusteeDataAccountBalance", - "label": "Kontosalden", + "label": t("Kontosalden", context="UI"), "meta": { "table": "TrusteeDataAccountBalance", "group": "data.feature.trustee.accountingData", @@ -168,7 +170,7 @@ DATA_OBJECTS = [ }, { "objectKey": "data.feature.trustee.*", - "label": "Alle Treuhand-Daten", + "label": t("Alle Treuhand-Daten", context="UI"), "meta": {"wildcard": True, "description": "Wildcard for all trustee data tables"} }, ] @@ -178,67 +180,67 @@ DATA_OBJECTS = [ RESOURCE_OBJECTS = [ { "objectKey": "resource.feature.trustee.documents.create", - "label": "Dokument hochladen", + "label": t("Dokument hochladen", context="UI"), "meta": {"endpoint": "/api/trustee/{instanceId}/documents", "method": "POST"} }, { "objectKey": "resource.feature.trustee.documents.update", - "label": "Dokument aktualisieren", + "label": t("Dokument aktualisieren", context="UI"), "meta": {"endpoint": "/api/trustee/{instanceId}/documents/{documentId}", "method": "PUT"} }, { "objectKey": "resource.feature.trustee.documents.delete", - "label": "Dokument löschen", + "label": t("Dokument löschen", context="UI"), "meta": {"endpoint": "/api/trustee/{instanceId}/documents/{documentId}", "method": "DELETE"} }, { "objectKey": "resource.feature.trustee.positions.create", - "label": "Position erstellen", + "label": t("Position erstellen", context="UI"), "meta": {"endpoint": "/api/trustee/{instanceId}/positions", "method": "POST"} }, { "objectKey": "resource.feature.trustee.positions.update", - "label": "Position aktualisieren", + "label": t("Position aktualisieren", context="UI"), "meta": {"endpoint": "/api/trustee/{instanceId}/positions/{positionId}", "method": "PUT"} }, { "objectKey": "resource.feature.trustee.positions.delete", - "label": "Position löschen", + "label": t("Position löschen", context="UI"), "meta": {"endpoint": "/api/trustee/{instanceId}/positions/{positionId}", "method": "DELETE"} }, { "objectKey": "resource.feature.trustee.instance-roles.manage", - "label": "Instanz-Rollen verwalten", + "label": t("Instanz-Rollen verwalten", context="UI"), "meta": {"endpoint": "/api/trustee/{instanceId}/instance-roles", "method": "ALL", "admin_only": True} }, { "objectKey": "resource.feature.trustee.accounting.manage", - "label": "Buchhaltungs-Integration verwalten", + "label": t("Buchhaltungs-Integration verwalten", context="UI"), "meta": {"endpoint": "/api/trustee/{instanceId}/accounting/config", "method": "ALL", "admin_only": True} }, { "objectKey": "resource.feature.trustee.accounting.sync", - "label": "Buchhaltung synchronisieren", + "label": t("Buchhaltung synchronisieren", context="UI"), "meta": {"endpoint": "/api/trustee/{instanceId}/accounting/sync", "method": "POST"} }, { "objectKey": "resource.feature.trustee.accounting.view", - "label": "Sync-Status einsehen", + "label": t("Sync-Status einsehen", context="UI"), "meta": {"endpoint": "/api/trustee/{instanceId}/accounting/sync-status", "method": "GET"} }, { "objectKey": "resource.feature.trustee.workflows.view", - "label": "Workflows einsehen", + "label": t("Workflows einsehen", context="UI"), "meta": {"endpoint": "/api/workflows/{instanceId}/workflows", "method": "GET"} }, { "objectKey": "resource.feature.trustee.workflows.execute", - "label": "Workflows ausführen", + "label": t("Workflows ausführen", context="UI"), "meta": {"endpoint": "/api/workflows/{instanceId}/execute", "method": "POST"} }, { "objectKey": "resource.feature.trustee.workflows.manage", - "label": "Workflows verwalten", + "label": t("Workflows verwalten", context="UI"), "meta": {"endpoint": "/api/workflows/{instanceId}/workflows", "method": "ALL", "admin_only": True} }, ] @@ -256,7 +258,7 @@ QUICK_ACTION_CATEGORIES = [ QUICK_ACTIONS = [ { "id": "trustee-process-receipts", - "label": "Belege verarbeiten", + "label": t("Belege verarbeiten", context="UI"), "description": "Belege aus SharePoint importieren, klassifizieren und verbuchen", "icon": "mdi-file-document-check-outline", "color": "#4CAF50", @@ -268,7 +270,7 @@ QUICK_ACTIONS = [ }, { "id": "trustee-upload-receipt", - "label": "Beleg hochladen", + "label": t("Beleg hochladen", context="UI"), "description": "Beleg scannen oder als Datei hochladen", "icon": "mdi-camera-document-outline", "color": "#607D8B", @@ -280,7 +282,7 @@ QUICK_ACTIONS = [ }, { "id": "trustee-sync-accounting", - "label": "Daten einlesen", + "label": t("Daten einlesen", context="UI"), "description": "Buchhaltungsdaten aus dem externen System aktualisieren", "icon": "mdi-sync", "color": "#FF9800", @@ -292,7 +294,7 @@ QUICK_ACTIONS = [ }, { "id": "trustee-budget-comparison", - "label": "Budget-Vergleich", + "label": t("Budget-Vergleich", context="UI"), "description": "Soll/Ist-Vergleich der Buchhaltung mit Budget-Excel", "icon": "mdi-chart-bar", "color": "#2196F3", @@ -304,7 +306,7 @@ QUICK_ACTIONS = [ }, { "id": "trustee-kpi-dashboard", - "label": "KPI-Dashboard", + "label": t("KPI-Dashboard", context="UI"), "description": "Kennzahlen berechnen und visualisieren", "icon": "mdi-view-dashboard-outline", "color": "#9C27B0", @@ -316,7 +318,7 @@ QUICK_ACTIONS = [ }, { "id": "trustee-cashflow", - "label": "Cashflow-Rechnung", + "label": t("Cashflow-Rechnung", context="UI"), "description": "Cashflow berechnen und analysieren", "icon": "mdi-cash-multiple", "color": "#009688", @@ -328,7 +330,7 @@ QUICK_ACTIONS = [ }, { "id": "trustee-forecast", - "label": "Prognose erstellen", + "label": t("Prognose erstellen", context="UI"), "description": "Trend-Analyse und Prognose der nächsten Monate", "icon": "mdi-chart-timeline-variant", "color": "#E91E63", @@ -340,7 +342,7 @@ QUICK_ACTIONS = [ }, { "id": "trustee-year-end-check", - "label": "Jahresabschluss prüfen", + "label": t("Jahresabschluss prüfen", context="UI"), "description": "Automatische Prüfungen für den Jahresabschluss", "icon": "mdi-clipboard-check-outline", "color": "#795548", @@ -383,7 +385,7 @@ def _buildAnalysisWorkflowGraph(prompt: str) -> Dict[str, Any]: TEMPLATE_WORKFLOWS = [ { "id": "trustee-receipt-import", - "label": "Beleg-Import Pipeline", + "label": t("Beleg-Import Pipeline", context="UI"), "description": "Belege extrahieren, verarbeiten und in Buchhaltung synchronisieren", "tags": ["feature:trustee", "template:trustee-receipt-import"], "graph": { @@ -405,7 +407,7 @@ TEMPLATE_WORKFLOWS = [ }, { "id": "trustee-sync-accounting", - "label": "Buchhaltung synchronisieren", + "label": t("Buchhaltung synchronisieren", context="UI"), "description": "Buchhaltungsdaten aus dem externen System aktualisieren", "tags": ["feature:trustee", "template:trustee-sync-accounting"], "graph": { @@ -421,7 +423,7 @@ TEMPLATE_WORKFLOWS = [ }, { "id": "trustee-budget-comparison", - "label": "Budget-Vergleich", + "label": t("Budget-Vergleich", context="UI"), "description": "Soll/Ist-Vergleich der Buchhaltung mit Budget-Excel", "tags": ["feature:trustee", "template:trustee-budget-comparison"], "graph": { @@ -454,7 +456,7 @@ TEMPLATE_WORKFLOWS = [ }, { "id": "trustee-kpi-dashboard", - "label": "KPI-Dashboard", + "label": t("KPI-Dashboard", context="UI"), "description": "Kennzahlen berechnen und visualisieren", "tags": ["feature:trustee", "template:trustee-kpi-dashboard"], "graph": _buildAnalysisWorkflowGraph( @@ -471,7 +473,7 @@ TEMPLATE_WORKFLOWS = [ }, { "id": "trustee-cashflow", - "label": "Cashflow-Rechnung", + "label": t("Cashflow-Rechnung", context="UI"), "description": "Cashflow berechnen und analysieren", "tags": ["feature:trustee", "template:trustee-cashflow"], "graph": _buildAnalysisWorkflowGraph( @@ -485,7 +487,7 @@ TEMPLATE_WORKFLOWS = [ }, { "id": "trustee-forecast", - "label": "Prognose erstellen", + "label": t("Prognose erstellen", context="UI"), "description": "Trend-Analyse und Prognose der nächsten Monate", "tags": ["feature:trustee", "template:trustee-forecast"], "graph": _buildAnalysisWorkflowGraph( @@ -500,7 +502,7 @@ TEMPLATE_WORKFLOWS = [ }, { "id": "trustee-year-end-check", - "label": "Jahresabschluss prüfen", + "label": t("Jahresabschluss prüfen", context="UI"), "description": "Automatische Prüfungen für den Jahresabschluss", "tags": ["feature:trustee", "template:trustee-year-end-check"], "graph": _buildAnalysisWorkflowGraph( diff --git a/modules/features/workspace/mainWorkspace.py b/modules/features/workspace/mainWorkspace.py index bb501f21..24307b45 100644 --- a/modules/features/workspace/mainWorkspace.py +++ b/modules/features/workspace/mainWorkspace.py @@ -9,31 +9,33 @@ Unified AI Workspace feature. import logging from typing import Dict, List, Any +from modules.shared.i18nRegistry import t + logger = logging.getLogger(__name__) FEATURE_CODE = "workspace" -FEATURE_LABEL = "AI Workspace" +FEATURE_LABEL = t("AI Workspace", context="UI") FEATURE_ICON = "mdi-brain" UI_OBJECTS = [ { "objectKey": "ui.feature.workspace.dashboard", - "label": "Dashboard", + "label": t("Dashboard", context="UI"), "meta": {"area": "dashboard"} }, { "objectKey": "ui.feature.workspace.editor", - "label": "Editor", + "label": t("Editor", context="UI"), "meta": {"area": "editor"} }, { "objectKey": "ui.feature.workspace.settings", - "label": "Einstellungen", + "label": t("Einstellungen", context="UI"), "meta": {"area": "settings"} }, { "objectKey": "ui.feature.workspace.rag-insights", - "label": "Wissens-Insights", + "label": t("Wissens-Insights", context="UI"), "meta": {"area": "rag-insights"}, }, ] @@ -41,37 +43,37 @@ UI_OBJECTS = [ RESOURCE_OBJECTS = [ { "objectKey": "resource.feature.workspace.start", - "label": "Agent starten", + "label": t("Agent starten", context="UI"), "meta": {"endpoint": "/api/workspace/{instanceId}/start/stream", "method": "POST"} }, { "objectKey": "resource.feature.workspace.stop", - "label": "Agent stoppen", + "label": t("Agent stoppen", context="UI"), "meta": {"endpoint": "/api/workspace/{instanceId}/{workflowId}/stop", "method": "POST"} }, { "objectKey": "resource.feature.workspace.files", - "label": "Dateien verwalten", + "label": t("Dateien verwalten", context="UI"), "meta": {"endpoint": "/api/workspace/{instanceId}/files", "method": "GET"} }, { "objectKey": "resource.feature.workspace.folders", - "label": "Ordner verwalten", + "label": t("Ordner verwalten", context="UI"), "meta": {"endpoint": "/api/workspace/{instanceId}/folders", "method": "GET"} }, { "objectKey": "resource.feature.workspace.datasources", - "label": "Datenquellen", + "label": t("Datenquellen", context="UI"), "meta": {"endpoint": "/api/workspace/{instanceId}/datasources", "method": "GET"} }, { "objectKey": "resource.feature.workspace.voice", - "label": "Spracheingabe/-ausgabe", + "label": t("Spracheingabe/-ausgabe", context="UI"), "meta": {"endpoint": "/api/workspace/{instanceId}/voice/*", "method": "POST"} }, { "objectKey": "resource.feature.workspace.edits", - "label": "Datei-Aenderungen pruefen", + "label": t("Datei-Aenderungen pruefen", context="UI"), "meta": {"endpoint": "/api/workspace/{instanceId}/edit/*", "method": "POST"} }, ] diff --git a/modules/interfaces/interfaceBootstrap.py b/modules/interfaces/interfaceBootstrap.py index 5a556616..e9d78e55 100644 --- a/modules/interfaces/interfaceBootstrap.py +++ b/modules/interfaces/interfaceBootstrap.py @@ -1906,6 +1906,7 @@ def _createStoreResourceRules(db: DatabaseConnector) -> None: "resource.store.workspace", "resource.store.commcoach", "resource.store.trustee", + "resource.store.graphicalEditor", ] storeRules = [] diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py index fc071ad1..a369530b 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py @@ -235,11 +235,15 @@ def _registerDataSourceTools(registry: ToolRegistry, services): registry.register( "browseDataSource", _browseDataSource, description=( - "Browse files and folders in a data source. Accepts either:\n" + "Browse files, folders, or emails in a data source. Accepts either:\n" "- dataSourceId (for attached data sources shown in the prompt), OR\n" "- connectionId + service (for direct connection access via listConnections).\n" - "Default page size is connector-specific (~100 entries). Use the `limit` parameter " - "to request more (e.g. when the user explicitly asks for ALL items in a folder)." + "\n" + "DEFAULT BEHAVIOUR: omit `limit` to get the connector's full default page. " + "For mail folders (Outlook/Gmail) the default returns up to 100 newest " + "messages -- DO NOT pass a smaller limit just to be safe; users almost " + "always want the full default page or explicitly more. Only set `limit` " + "when the user asks for a specific number (e.g. 'show me the latest 5 mails')." ), parameters={ "type": "object", @@ -253,8 +257,11 @@ def _registerDataSourceTools(registry: ToolRegistry, services): "limit": { "type": "integer", "description": ( - "Maximum number of entries to return (max 1000 for mail, " - "connector-specific elsewhere). Omit for the connector's default." + "OPTIONAL. Maximum number of entries to return. OMIT this " + "parameter to get the connector default (100 for mail, " + "connector-specific elsewhere). Only set this when the user " + "explicitly asks for a specific count, OR when the previous " + "browse hit the default and you need MORE (then set up to 1000)." ), "minimum": 1, "maximum": 1000, diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_emailTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_emailTools.py new file mode 100644 index 00000000..a49403cd --- /dev/null +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_emailTools.py @@ -0,0 +1,401 @@ +# Copyright (c) 2026 Patrick Motsch +# All rights reserved. +"""Email management agent tools (reply, forward, move, delete, flag, folder ops). + +Complements ``_connectionTools.sendMail`` -- which only knows how to compose a +brand-new email -- with the full Outlook mail-management surface the agent +needs to actually answer existing threads and curate the inbox. + +All tools resolve their target Outlook adapter via +``ConnectorResolver.resolveService(connectionId, "outlook")`` and require an +``msft`` UserConnection. They live in the ``email`` toolbox. +""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, List, Optional + +from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolResult +from modules.serviceCenter.services.serviceAgent.toolRegistry import ToolRegistry +from modules.serviceCenter.services.serviceAgent.coreTools._helpers import ( + _buildResolverDbFromServices, +) + +logger = logging.getLogger(__name__) + + +async def _resolveOutlookAdapter(services, connectionId: str): + """Resolve an Outlook adapter for ``connectionId``. + + Centralised because every tool here needs the exact same boilerplate. + Raises ``ValueError`` if the connection cannot be resolved so callers can + return a clean ToolResult error. + """ + from modules.connectors.connectorResolver import ConnectorResolver + resolver = ConnectorResolver( + services.getService("security"), + _buildResolverDbFromServices(services), + ) + adapter = await resolver.resolveService(connectionId, "outlook") + if adapter is None: + raise ValueError(f"Could not resolve Outlook adapter for connection '{connectionId}'") + return adapter + + +def _extractMessageId(args: Dict[str, Any]) -> str: + """Pull a Graph message ID from args. + + Accepts both ``messageId`` (preferred) and ``filePath`` -- the latter is + what ``browseDataSource`` returns in its ``path:`` field for mail entries + (``//``). The LLM often passes that verbatim. + """ + messageId = (args.get("messageId") or "").strip() + if messageId: + return messageId + raw = (args.get("filePath") or args.get("path") or "").strip() + if raw: + return raw.strip("/").split("/")[-1] + return "" + + +def _registerEmailTools(registry: ToolRegistry, services): + """Register Outlook reply/forward/move/delete/flag tools on ``registry``.""" + + # ------------------------------------------------------------------ + # Reply / Reply-All / Forward + # ------------------------------------------------------------------ + + async def _replyToMail(args: Dict[str, Any], context: Dict[str, Any]): + connectionId = (args.get("connectionId") or "").strip() + comment = args.get("comment") or args.get("body") or "" + replyAll = bool(args.get("replyAll", False)) + draft = bool(args.get("draft", False)) + messageId = _extractMessageId(args) + if not connectionId or not messageId: + return ToolResult(toolCallId="", toolName="replyToMail", success=False, + error="connectionId and messageId are required (messageId or filePath from browseDataSource)") + try: + adapter = await _resolveOutlookAdapter(services, connectionId) + if draft: + if not hasattr(adapter, "createReplyDraft"): + return ToolResult(toolCallId="", toolName="replyToMail", success=False, error="Adapter does not support reply drafts") + result = await adapter.createReplyDraft(messageId, comment=comment, replyAll=replyAll) + else: + if not hasattr(adapter, "replyToMail"): + return ToolResult(toolCallId="", toolName="replyToMail", success=False, error="Adapter does not support replies") + result = await adapter.replyToMail(messageId, comment=comment, replyAll=replyAll) + if "error" in result: + return ToolResult(toolCallId="", toolName="replyToMail", success=False, error=str(result["error"])) + return ToolResult(toolCallId="", toolName="replyToMail", success=True, data=str(result)) + except Exception as e: + return ToolResult(toolCallId="", toolName="replyToMail", success=False, error=str(e)) + + async def _forwardMail(args: Dict[str, Any], context: Dict[str, Any]): + connectionId = (args.get("connectionId") or "").strip() + comment = args.get("comment") or args.get("body") or "" + to = args.get("to") or [] + draft = bool(args.get("draft", False)) + messageId = _extractMessageId(args) + if not connectionId or not messageId: + return ToolResult(toolCallId="", toolName="forwardMail", success=False, + error="connectionId and messageId are required") + if not draft and not to: + return ToolResult(toolCallId="", toolName="forwardMail", success=False, + error="`to` recipients are required when not creating a draft") + try: + adapter = await _resolveOutlookAdapter(services, connectionId) + if draft: + result = await adapter.createForwardDraft(messageId, to=to or None, comment=comment) + else: + result = await adapter.forwardMail(messageId, to=to, comment=comment) + if "error" in result: + return ToolResult(toolCallId="", toolName="forwardMail", success=False, error=str(result["error"])) + return ToolResult(toolCallId="", toolName="forwardMail", success=True, data=str(result)) + except Exception as e: + return ToolResult(toolCallId="", toolName="forwardMail", success=False, error=str(e)) + + # ------------------------------------------------------------------ + # Move / Copy / Delete / Archive + # ------------------------------------------------------------------ + + async def _moveMail(args: Dict[str, Any], context: Dict[str, Any]): + connectionId = (args.get("connectionId") or "").strip() + destination = (args.get("destinationFolder") or args.get("destination") or "").strip() + copyMode = bool(args.get("copy", False)) + messageId = _extractMessageId(args) + if not connectionId or not messageId or not destination: + return ToolResult(toolCallId="", toolName="moveMail", success=False, + error="connectionId, messageId, and destinationFolder are required") + try: + adapter = await _resolveOutlookAdapter(services, connectionId) + method = adapter.copyMail if copyMode else adapter.moveMail + result = await method(messageId, destination) + if "error" in result: + return ToolResult(toolCallId="", toolName="moveMail", success=False, error=str(result["error"])) + return ToolResult(toolCallId="", toolName="moveMail", success=True, data=str(result)) + except Exception as e: + return ToolResult(toolCallId="", toolName="moveMail", success=False, error=str(e)) + + async def _deleteMail(args: Dict[str, Any], context: Dict[str, Any]): + connectionId = (args.get("connectionId") or "").strip() + hardDelete = bool(args.get("hardDelete", False)) + # Hard-delete is irreversible -- require an explicit confirmation flag + # so a single misroutet LLM call cannot wipe a mailbox. The default + # behaviour (move to Deleted Items) is recoverable from Outlook UI. + confirmed = bool(args.get("confirmedHardDelete", False)) + messageId = _extractMessageId(args) + if not connectionId or not messageId: + return ToolResult(toolCallId="", toolName="deleteMail", success=False, + error="connectionId and messageId are required") + if hardDelete and not confirmed: + return ToolResult(toolCallId="", toolName="deleteMail", success=False, + error="Hard-delete requires confirmedHardDelete=true (irreversible). Default deleteMail (without hardDelete) moves the mail to Deleted Items where it can still be recovered.") + try: + adapter = await _resolveOutlookAdapter(services, connectionId) + result = await adapter.deleteMail(messageId, hardDelete=hardDelete) + if "error" in result: + return ToolResult(toolCallId="", toolName="deleteMail", success=False, error=str(result["error"])) + return ToolResult(toolCallId="", toolName="deleteMail", success=True, data=str(result)) + except Exception as e: + return ToolResult(toolCallId="", toolName="deleteMail", success=False, error=str(e)) + + async def _archiveMail(args: Dict[str, Any], context: Dict[str, Any]): + connectionId = (args.get("connectionId") or "").strip() + messageId = _extractMessageId(args) + if not connectionId or not messageId: + return ToolResult(toolCallId="", toolName="archiveMail", success=False, + error="connectionId and messageId are required") + try: + adapter = await _resolveOutlookAdapter(services, connectionId) + result = await adapter.archiveMail(messageId) + if "error" in result: + return ToolResult(toolCallId="", toolName="archiveMail", success=False, error=str(result["error"])) + return ToolResult(toolCallId="", toolName="archiveMail", success=True, data=str(result)) + except Exception as e: + return ToolResult(toolCallId="", toolName="archiveMail", success=False, error=str(e)) + + # ------------------------------------------------------------------ + # Read-state / Flag + # ------------------------------------------------------------------ + + async def _setMailReadState(args: Dict[str, Any], context: Dict[str, Any]): + connectionId = (args.get("connectionId") or "").strip() + isRead = bool(args.get("isRead", True)) + messageId = _extractMessageId(args) + if not connectionId or not messageId: + return ToolResult(toolCallId="", toolName="setMailReadState", success=False, + error="connectionId and messageId are required") + try: + adapter = await _resolveOutlookAdapter(services, connectionId) + method = adapter.markMailAsRead if isRead else adapter.markMailAsUnread + result = await method(messageId) + if "error" in result: + return ToolResult(toolCallId="", toolName="setMailReadState", success=False, error=str(result["error"])) + return ToolResult(toolCallId="", toolName="setMailReadState", success=True, data=str(result)) + except Exception as e: + return ToolResult(toolCallId="", toolName="setMailReadState", success=False, error=str(e)) + + async def _flagMail(args: Dict[str, Any], context: Dict[str, Any]): + connectionId = (args.get("connectionId") or "").strip() + flagStatus = (args.get("flagStatus") or "flagged").strip() + messageId = _extractMessageId(args) + if not connectionId or not messageId: + return ToolResult(toolCallId="", toolName="flagMail", success=False, + error="connectionId and messageId are required") + try: + adapter = await _resolveOutlookAdapter(services, connectionId) + result = await adapter.flagMail(messageId, flagStatus=flagStatus) + if "error" in result: + return ToolResult(toolCallId="", toolName="flagMail", success=False, error=str(result["error"])) + return ToolResult(toolCallId="", toolName="flagMail", success=True, data=str(result)) + except Exception as e: + return ToolResult(toolCallId="", toolName="flagMail", success=False, error=str(e)) + + # ------------------------------------------------------------------ + # Folder discovery + # ------------------------------------------------------------------ + + async def _listMailFolders(args: Dict[str, Any], context: Dict[str, Any]): + connectionId = (args.get("connectionId") or "").strip() + if not connectionId: + return ToolResult(toolCallId="", toolName="listMailFolders", success=False, + error="connectionId is required") + try: + adapter = await _resolveOutlookAdapter(services, connectionId) + if not hasattr(adapter, "listMailFolders"): + return ToolResult(toolCallId="", toolName="listMailFolders", success=False, error="Adapter does not support listMailFolders") + folders = await adapter.listMailFolders() + if not folders: + return ToolResult(toolCallId="", toolName="listMailFolders", success=True, data="No mail folders found.") + lines = [ + f"- {f['displayName']} id={f['id']} total={f['totalItemCount']} unread={f['unreadItemCount']}" + for f in folders + ] + return ToolResult(toolCallId="", toolName="listMailFolders", success=True, data="\n".join(lines)) + except Exception as e: + return ToolResult(toolCallId="", toolName="listMailFolders", success=False, error=str(e)) + + # ------------------------------------------------------------------ + # Tool registration + # ------------------------------------------------------------------ + + _baseConnParam = { + "connectionId": {"type": "string", "description": "UserConnection UUID for the Outlook account (from listConnections)"}, + } + _baseMessageParam = { + "messageId": { + "type": "string", + "description": "Graph message ID of the target mail. Pass either this OR filePath -- both accepted.", + }, + "filePath": { + "type": "string", + "description": "Mail path as returned by browseDataSource (e.g. '//'). Convenience alias for messageId.", + }, + } + + registry.register( + "replyToMail", _replyToMail, + description=( + "Reply (or reply-all) to an existing email. Preserves the conversation thread " + "and the 'AW:' prefix in Outlook -- USE THIS instead of sendMail when the user " + "asks you to answer an existing message. Set draft=true to create a draft in the " + "Drafts folder for review instead of sending immediately." + ), + parameters={ + "type": "object", + "properties": { + **_baseConnParam, + **_baseMessageParam, + "comment": {"type": "string", "description": "Reply body text (HTML supported by Outlook)"}, + "replyAll": {"type": "boolean", "description": "If true, reply to ALL recipients of the original mail"}, + "draft": {"type": "boolean", "description": "If true, save as draft in Drafts folder instead of sending"}, + }, + "required": ["connectionId", "comment"], + }, + readOnly=False, + ) + + registry.register( + "forwardMail", _forwardMail, + description=( + "Forward an existing email to new recipients. Set draft=true to create a draft " + "(in which case `to` may be omitted and filled in later by the user)." + ), + parameters={ + "type": "object", + "properties": { + **_baseConnParam, + **_baseMessageParam, + "to": {"type": "array", "items": {"type": "string"}, "description": "Recipient email addresses"}, + "comment": {"type": "string", "description": "Optional message to prepend to the forwarded mail"}, + "draft": {"type": "boolean", "description": "If true, save as draft instead of sending"}, + }, + "required": ["connectionId"], + }, + readOnly=False, + ) + + registry.register( + "moveMail", _moveMail, + description=( + "Move (or copy) an existing email into another mail folder. The destination can " + "be a well-known folder name ('inbox', 'archive', 'deleteditems', 'sentitems', " + "'drafts', 'junkemail'), a localized display name ('Posteingang', 'Archiv', " + "'Papierkorb'), or a Graph folder ID from listMailFolders. Set copy=true to copy " + "instead of moving." + ), + parameters={ + "type": "object", + "properties": { + **_baseConnParam, + **_baseMessageParam, + "destinationFolder": {"type": "string", "description": "Target folder: well-known name, displayName, or folder id"}, + "copy": {"type": "boolean", "description": "If true, copy instead of moving (original stays in place)"}, + }, + "required": ["connectionId", "destinationFolder"], + }, + readOnly=False, + ) + + registry.register( + "deleteMail", _deleteMail, + description=( + "Delete an email. By default the message is moved to 'Deleted Items' (the same " + "as pressing Delete in Outlook -- recoverable). Set hardDelete=true together with " + "confirmedHardDelete=true to permanently and irrecoverably remove it -- only do " + "this when the user has explicitly asked for permanent deletion." + ), + parameters={ + "type": "object", + "properties": { + **_baseConnParam, + **_baseMessageParam, + "hardDelete": {"type": "boolean", "description": "Permanently delete instead of moving to Deleted Items"}, + "confirmedHardDelete": {"type": "boolean", "description": "Required confirmation flag when hardDelete=true"}, + }, + "required": ["connectionId"], + }, + readOnly=False, + ) + + registry.register( + "archiveMail", _archiveMail, + description="Move an email to the Archive folder. Convenience wrapper around moveMail with destinationFolder='archive'.", + parameters={ + "type": "object", + "properties": { + **_baseConnParam, + **_baseMessageParam, + }, + "required": ["connectionId"], + }, + readOnly=False, + ) + + registry.register( + "setMailReadState", _setMailReadState, + description="Mark an email as read (isRead=true) or unread (isRead=false).", + parameters={ + "type": "object", + "properties": { + **_baseConnParam, + **_baseMessageParam, + "isRead": {"type": "boolean", "description": "true => mark as read, false => mark as unread"}, + }, + "required": ["connectionId"], + }, + readOnly=False, + ) + + registry.register( + "flagMail", _flagMail, + description="Set or clear the follow-up flag on an email. flagStatus = 'flagged' (default), 'complete', or 'notFlagged'.", + parameters={ + "type": "object", + "properties": { + **_baseConnParam, + **_baseMessageParam, + "flagStatus": {"type": "string", "enum": ["flagged", "complete", "notFlagged"], "description": "Flag state"}, + }, + "required": ["connectionId"], + }, + readOnly=False, + ) + + registry.register( + "listMailFolders", _listMailFolders, + description=( + "List all mail folders in the connected Outlook mailbox with id, displayName, " + "totalItemCount and unreadItemCount. Use this BEFORE moveMail when the user " + "names a non-standard folder so you can resolve the correct folder ID." + ), + parameters={ + "type": "object", + "properties": { + **_baseConnParam, + }, + "required": ["connectionId"], + }, + readOnly=True, + ) diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/registerCore.py b/modules/serviceCenter/services/serviceAgent/coreTools/registerCore.py index 234da7d8..f740f276 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/registerCore.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/registerCore.py @@ -8,6 +8,7 @@ from modules.serviceCenter.services.serviceAgent.coreTools._workspaceTools impor from modules.serviceCenter.services.serviceAgent.coreTools._connectionTools import _registerConnectionTools from modules.serviceCenter.services.serviceAgent.coreTools._dataSourceTools import _registerDataSourceTools from modules.serviceCenter.services.serviceAgent.coreTools._documentTools import _registerDocumentTools +from modules.serviceCenter.services.serviceAgent.coreTools._emailTools import _registerEmailTools from modules.serviceCenter.services.serviceAgent.coreTools._mediaTools import _registerMediaTools from modules.serviceCenter.services.serviceAgent.coreTools._featureSubAgentTools import _registerFeatureSubAgentTools from modules.serviceCenter.services.serviceAgent.coreTools._crossWorkflowTools import _registerCrossWorkflowTools @@ -22,6 +23,7 @@ def registerCoreTools(registry: ToolRegistry, services): _registerConnectionTools(registry, services) _registerDataSourceTools(registry, services) _registerDocumentTools(registry, services) + _registerEmailTools(registry, services) _registerMediaTools(registry, services) _registerFeatureSubAgentTools(registry, services) _registerCrossWorkflowTools(registry, services) diff --git a/modules/serviceCenter/services/serviceAgent/toolboxRegistry.py b/modules/serviceCenter/services/serviceAgent/toolboxRegistry.py index 71c75eb5..dd8c80ec 100644 --- a/modules/serviceCenter/services/serviceAgent/toolboxRegistry.py +++ b/modules/serviceCenter/services/serviceAgent/toolboxRegistry.py @@ -148,11 +148,25 @@ def _registerDefaultToolboxes() -> None: ToolboxDefinition( id="email", label="Email", - description="Send emails or save as draft via Outlook (supports HTML body and file attachments). Use sendMail with draft=true for drafts.", + description=( + "Outlook mail management: send/draft new mails, reply or forward existing " + "messages (preserves the conversation thread), move/copy/delete/archive " + "mails, mark as read/unread, set follow-up flags, and list mail folders. " + "Use replyToMail (NOT sendMail) when answering an existing message so the " + "Outlook thread stays intact." + ), requiresConnection="msft", isDefault=False, tools=[ "sendMail", + "replyToMail", + "forwardMail", + "moveMail", + "deleteMail", + "archiveMail", + "setMailReadState", + "flagMail", + "listMailFolders", ], ), ToolboxDefinition( diff --git a/modules/shared/attributeUtils.py b/modules/shared/attributeUtils.py index 0e4b2cde..d6228854 100644 --- a/modules/shared/attributeUtils.py +++ b/modules/shared/attributeUtils.py @@ -36,6 +36,16 @@ class AttributeDefinition(BaseModel): fkSource: Optional[str] = None fkDisplayField: Optional[str] = None fkModel: Optional[str] = None # DB table / Pydantic model name for server-side FK sort (JOIN) + # ------------------------------------------------------------------ + # Render hints for the frontend FormGenerator / Tables. + # ``frontendFormat`` is an Excel-style format string the FE applies to numeric, + # int, binary or unit values (e.g. "R:#'###.00", "L:0.000", "M:b", "R:@CHF@ #'###.00"). + # ``frontendFormatLabels`` carries i18n-resolved string tokens referenced by the + # format (e.g. boolean labels ["Ja", "-", "Nein"]). They are pre-translated server + # side so the FE can render them as-is without another i18n round-trip. + # ------------------------------------------------------------------ + frontendFormat: Optional[str] = None + frontendFormatLabels: Optional[List[str]] = None def _getModelLabelEntry(modelName: str) -> Dict[str, Any]: @@ -138,6 +148,11 @@ def getModelAttributeDefinitions(modelClass: Type[BaseModel] = None, userLanguag frontend_fk_source = None # FK dropdown source (e.g., "/api/users/") frontend_fk_display_field = None # Which field of the FK target to display (e.g., "username", "name") fk_model = None # Same as fk_model in json_schema_extra — backend JOIN target table name + # Render hints (cf. AttributeDefinition.frontendFormat / frontendFormatLabels). + # Optional Excel-like format string ("R:#'###.00") plus translatable label tokens + # for boolean/categorical render (e.g. ["Ja","-","Nein"] resolved via @i18nModel). + frontend_format = None + frontend_format_labels = None if field_info: # Try direct attributes first (though these won't exist for custom kwargs) @@ -196,6 +211,18 @@ def getModelAttributeDefinitions(modelClass: Type[BaseModel] = None, userLanguag frontend_fk_display_field = json_extra.get("frontend_fk_display_field") if "fk_model" in json_extra: fk_model = json_extra.get("fk_model") + if frontend_format is None and "frontend_format" in json_extra: + frontend_format = json_extra.get("frontend_format") + if frontend_format_labels is None and "frontend_format_labels" in json_extra: + frontend_format_labels = json_extra.get("frontend_format_labels") + + # Render hints can also come via FieldInfo.extra (older Pydantic kwargs path) + if hasattr(field_info, "extra") and isinstance(field_info.extra, dict): + extra_dict = field_info.extra + if frontend_format is None and "frontend_format" in extra_dict: + frontend_format = extra_dict.get("frontend_format") + if frontend_format_labels is None and "frontend_format_labels" in extra_dict: + frontend_format_labels = extra_dict.get("frontend_format_labels") # Use frontend type if available, otherwise detect from Python type if frontend_type: @@ -274,6 +301,18 @@ def getModelAttributeDefinitions(modelClass: Type[BaseModel] = None, userLanguag if fk_model: attr_def["fkModel"] = fk_model + # Render hints (Excel-like format string + i18n-resolved label tokens). + # Labels are resolved server-side via resolveText() so the FE renders them + # verbatim (no double-translation, no missing-key brackets in the table). + if frontend_format: + attr_def["frontendFormat"] = frontend_format + if frontend_format_labels and isinstance(frontend_format_labels, list): + from modules.shared.i18nRegistry import resolveText + attr_def["frontendFormatLabels"] = [ + resolveText(lbl) if isinstance(lbl, (str, dict)) else str(lbl) + for lbl in frontend_format_labels + ] + attributes.append(attr_def) return {"model": model_label, "attributes": attributes} diff --git a/modules/shared/i18nRegistry.py b/modules/shared/i18nRegistry.py index 4b942bd2..d6bbd1e2 100644 --- a/modules/shared/i18nRegistry.py +++ b/modules/shared/i18nRegistry.py @@ -214,6 +214,18 @@ def i18nModel(modelLabel: str, aiContext: str = ""): else: attributes[fieldName] = fieldName + # Render-hint label tokens (frontend_format_labels) are user-visible + # strings that appear in tables/forms (e.g. boolean labels + # ["Ja","-","Nein"], unit suffixes ["KB","MB","GB",...]). Register + # each non-empty token under a per-field context so they appear in + # the xx base set and get AI-translated like every other UI string. + formatLabels = extra.get("frontend_format_labels") + if isinstance(formatLabels, list): + fmtCtx = f"table.{className}.{fieldName}.format" + for token in formatLabels: + if isinstance(token, str) and token.strip(): + t(token, fmtCtx, "") + MODEL_LABELS[className] = { "model": modelLabel, "attributes": attributes, diff --git a/modules/system/mainSystem.py b/modules/system/mainSystem.py index 0558f65b..53405683 100644 --- a/modules/system/mainSystem.py +++ b/modules/system/mainSystem.py @@ -510,9 +510,14 @@ RESOURCE_OBJECTS = [ }, { "objectKey": "resource.store.trustee", - "label": "Store: Trustee", + "label": t("Store: Trustee", context="UI"), "meta": {"category": "store", "featureCode": "trustee"} }, + { + "objectKey": "resource.store.graphicalEditor", + "label": t("Store: Workflow-Automation", context="UI"), + "meta": {"category": "store", "featureCode": "graphicalEditor"} + }, { "objectKey": "resource.system.api.auth", "label": "Authentifizierungs-API", diff --git a/tests/validation/test_featureCatalogLabels_i18n.py b/tests/validation/test_featureCatalogLabels_i18n.py new file mode 100644 index 00000000..d6787c43 --- /dev/null +++ b/tests/validation/test_featureCatalogLabels_i18n.py @@ -0,0 +1,58 @@ +# Copyright (c) 2026 Patrick Motsch +# All rights reserved. +"""Validation: every label in feature ``main*.py`` catalog lists must be wrapped in ``t(...)``. + +Background: + ``UI_OBJECTS``, ``DATA_OBJECTS``, ``RESOURCE_OBJECTS``, ``WORKFLOW_DEFINITIONS`` etc. + in ``gateway/modules/features//main*.py`` define labels that the UDB and other + UI surfaces display. Bare-string labels (``"label": "Konfiguration"``) are not + registered with the i18n catalog at module-import time, so non-DE renders show + them as ``[Konfiguration]`` (the missing-translation marker from + ``modules.shared.i18nRegistry.t()``). + + This test scans every ``main*.py`` under ``gateway/modules/features`` and fails + if it finds bare-string labels, blocking the regression in CI. + +Allowed exceptions: + - Labels that are NOT user-visible (e.g. internal demo seed data, fixtures). + Add their file to ``_ALLOWED_FILES_WITH_BARE_LABELS`` with a justification. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + + +_FEATURES_DIR = Path(__file__).resolve().parents[2] / "modules" / "features" + +_BARE_LABEL_PATTERN = re.compile(r'^\s*"label"\s*:\s*"[^"]+"', re.MULTILINE) + +# mainRealEstate.py contains "label": "AA1704" inside a multi-line f-string +# that is used as a JSON example in an AI prompt -- not a real catalog entry. +_ALLOWED_FILES_WITH_BARE_LABELS: set[str] = { + "mainRealEstate.py", +} + + +def _findFeatureMainFiles() -> list[Path]: + return sorted(_FEATURES_DIR.glob("*/main*.py")) + + +@pytest.mark.parametrize("mainFile", _findFeatureMainFiles(), ids=lambda p: p.name) +def test_noBareLabelsInFeatureCatalog(mainFile: Path) -> None: + if mainFile.name in _ALLOWED_FILES_WITH_BARE_LABELS: + pytest.skip(f"{mainFile.name} explicitly allowed (legacy seed data)") + + text = mainFile.read_text(encoding="utf-8") + matches = _BARE_LABEL_PATTERN.findall(text) + assert not matches, ( + f"\n{mainFile.relative_to(_FEATURES_DIR.parent.parent)} contains " + f"{len(matches)} bare-string labels that are NOT registered with i18n.\n" + f"Wrap each label with t(\"...\", context=\"UI\") so non-DE locales\n" + f"don't render them as [missing-key].\n\n" + f"Sample offending lines:\n " + + "\n ".join(matches[:5]) + )