fixes udb, outlook, workflow

This commit is contained in:
ValueOn AG 2026-04-21 23:49:46 +02:00
parent 908be0511b
commit 71f4265e06
22 changed files with 1016 additions and 157 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1906,6 +1906,7 @@ def _createStoreResourceRules(db: DatabaseConnector) -> None:
"resource.store.workspace",
"resource.store.commcoach",
"resource.store.trustee",
"resource.store.graphicalEditor",
]
storeRules = []

View file

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

View file

@ -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
(``/<folderId>/<messageId>``). 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. '/<folderId>/<messageId>'). 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,
)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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/<x>/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])
)