fixes udb, outlook, workflow
This commit is contained in:
parent
908be0511b
commit
71f4265e06
22 changed files with 1016 additions and 157 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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"}})
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1906,6 +1906,7 @@ def _createStoreResourceRules(db: DatabaseConnector) -> None:
|
|||
"resource.store.workspace",
|
||||
"resource.store.commcoach",
|
||||
"resource.store.trustee",
|
||||
"resource.store.graphicalEditor",
|
||||
]
|
||||
|
||||
storeRules = []
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
58
tests/validation/test_featureCatalogLabels_i18n.py
Normal file
58
tests/validation/test_featureCatalogLabels_i18n.py
Normal 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])
|
||||
)
|
||||
Loading…
Reference in a new issue