From 61f04a604901dda4a811953b84574faed9fceb3a Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Mon, 13 Apr 2026 01:37:29 +0200 Subject: [PATCH] fixes in node languages and ai workflow --- modules/demoConfigs/investorDemo2026.py | 16 +- .../graphicalEditor/nodeDefinitions/ai.py | 48 ++-- .../nodeDefinitions/clickup.py | 102 ++++---- .../graphicalEditor/nodeDefinitions/data.py | 20 +- .../graphicalEditor/nodeDefinitions/email.py | 56 ++--- .../graphicalEditor/nodeDefinitions/file.py | 16 +- .../graphicalEditor/nodeDefinitions/flow.py | 30 +-- .../graphicalEditor/nodeDefinitions/input.py | 64 ++--- .../nodeDefinitions/sharepoint.py | 56 ++--- .../nodeDefinitions/triggers.py | 18 +- .../nodeDefinitions/trustee.py | 42 ++-- .../features/graphicalEditor/nodeRegistry.py | 33 ++- .../trustee/accounting/accountingRegistry.py | 5 +- .../connectors/accountingConnectorAbacus.py | 9 +- .../connectors/accountingConnectorBexio.py | 7 +- .../connectors/accountingConnectorRma.py | 11 +- modules/routes/routeWorkflowDashboard.py | 226 ++++++++++++++---- .../executors/actionNodeExecutor.py | 4 +- tests/demo/README.md | 35 +++ tests/demo/__init__.py | 0 tests/demo/conftest.py | 64 +++++ tests/demo/test_demo_api.py | 66 +++++ tests/demo/test_demo_bootstrap.py | 133 +++++++++++ tests/demo/test_demo_data_files.py | 44 ++++ tests/demo/test_demo_neutralization.py | 36 +++ tests/demo/test_demo_uc1_trustee.py | 60 +++++ tests/demo/test_demo_uc2_realestate.py | 24 ++ tests/demo/test_demo_uc3_chatbot.py | 37 +++ tests/demo/test_demo_uc4_i18n.py | 51 ++++ 29 files changed, 1016 insertions(+), 297 deletions(-) create mode 100644 tests/demo/README.md create mode 100644 tests/demo/__init__.py create mode 100644 tests/demo/conftest.py create mode 100644 tests/demo/test_demo_api.py create mode 100644 tests/demo/test_demo_bootstrap.py create mode 100644 tests/demo/test_demo_data_files.py create mode 100644 tests/demo/test_demo_neutralization.py create mode 100644 tests/demo/test_demo_uc1_trustee.py create mode 100644 tests/demo/test_demo_uc2_realestate.py create mode 100644 tests/demo/test_demo_uc3_chatbot.py create mode 100644 tests/demo/test_demo_uc4_i18n.py diff --git a/modules/demoConfigs/investorDemo2026.py b/modules/demoConfigs/investorDemo2026.py index e2b9ebdc..bac3d4fe 100644 --- a/modules/demoConfigs/investorDemo2026.py +++ b/modules/demoConfigs/investorDemo2026.py @@ -76,8 +76,8 @@ class InvestorDemo2026(_BaseDemoConfig): self._ensureTrusteeRmaConfig(db, mandateIdHappy, _MANDATE_HAPPYLIFE["label"], summary) self._ensureTrusteeRmaConfig(db, mandateIdAlpina, _MANDATE_ALPINA["label"], summary) - self._ensureNeutralizationConfig(db, mandateIdHappy, summary) - self._ensureNeutralizationConfig(db, mandateIdAlpina, summary) + self._ensureNeutralizationConfig(db, mandateIdHappy, userId, summary) + self._ensureNeutralizationConfig(db, mandateIdAlpina, userId, summary) self._ensureBilling(db, mandateIdHappy, _MANDATE_HAPPYLIFE["label"], summary) self._ensureBilling(db, mandateIdAlpina, _MANDATE_ALPINA["label"], summary) @@ -179,7 +179,8 @@ class InvestorDemo2026(_BaseDemoConfig): return uid def _ensureMembership(self, db, userId: str, mandateId: str, mandateLabel: str, summary: Dict): - from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole, Role + from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole + from modules.datamodels.datamodelRbac import Role existing = db.getRecordset(UserMandate, recordFilter={"userId": userId, "mandateId": mandateId}) if existing: @@ -192,7 +193,7 @@ class InvestorDemo2026(_BaseDemoConfig): summary["created"].append(f"Membership {_USER['username']} -> {mandateLabel}") logger.info(f"Created membership {_USER['username']} -> {mandateLabel}") - adminRoles = db.getRecordset(Role, recordFilter={"mandateId": mandateId, "label": "admin"}) + adminRoles = db.getRecordset(Role, recordFilter={"mandateId": mandateId, "roleLabel": "admin"}) if adminRoles: adminRoleId = adminRoles[0].get("id") existingRole = db.getRecordset(UserMandateRole, recordFilter={"userMandateId": userMandateId, "roleId": adminRoleId}) @@ -205,7 +206,7 @@ class InvestorDemo2026(_BaseDemoConfig): from modules.interfaces.interfaceFeatures import getFeatureInterface fi = getFeatureInterface(db) - existingInstances = fi.getFeatureInstances(mandateId) + existingInstances = fi.getFeatureInstancesForMandate(mandateId) existingCodes = { (inst.featureCode if hasattr(inst, "featureCode") else inst.get("featureCode", "")) for inst in existingInstances @@ -278,8 +279,8 @@ class InvestorDemo2026(_BaseDemoConfig): summary["created"].append(f"RMA accounting config for {mandateLabel}") logger.info(f"Created RMA accounting config for {mandateLabel}") - def _ensureNeutralizationConfig(self, db, mandateId: Optional[str], summary: Dict): - if not mandateId: + def _ensureNeutralizationConfig(self, db, mandateId: Optional[str], userId: Optional[str], summary: Dict): + if not mandateId or not userId: return from modules.datamodels.datamodelFeatures import FeatureInstance @@ -301,6 +302,7 @@ class InvestorDemo2026(_BaseDemoConfig): config = DataNeutraliserConfig( featureInstanceId=instanceId, mandateId=mandateId, + userId=userId, enabled=True, scope="featureInstance", ) diff --git a/modules/features/graphicalEditor/nodeDefinitions/ai.py b/modules/features/graphicalEditor/nodeDefinitions/ai.py index b8a1cc02..08e82340 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/ai.py +++ b/modules/features/graphicalEditor/nodeDefinitions/ai.py @@ -1,18 +1,20 @@ # Copyright (c) 2025 Patrick Motsch # AI node definitions - map to methodAi actions. +from modules.shared.i18nRegistry import t + AI_NODES = [ { "id": "ai.prompt", "category": "ai", - "label": "Prompt", - "description": "Prompt eingeben und KI führt aus", + "label": t("Prompt"), + "description": t("Prompt eingeben und KI führt aus"), "parameters": [ {"name": "aiPrompt", "type": "string", "required": True, "frontendType": "textarea", - "description": "KI-Prompt"}, + "description": t("KI-Prompt")}, {"name": "outputFormat", "type": "string", "required": False, "frontendType": "select", "frontendOptions": {"options": ["text", "json", "emailDraft"]}, - "description": "Ausgabeformat", "default": "text"}, + "description": t("Ausgabeformat"), "default": "text"}, ], "inputs": 1, "outputs": 1, @@ -25,11 +27,11 @@ AI_NODES = [ { "id": "ai.webResearch", "category": "ai", - "label": "Web-Recherche", - "description": "Recherche im Web", + "label": t("Web-Recherche"), + "description": t("Recherche im Web"), "parameters": [ {"name": "prompt", "type": "string", "required": True, "frontendType": "textarea", - "description": "Recherche-Anfrage"}, + "description": t("Recherche-Anfrage")}, ], "inputs": 1, "outputs": 1, @@ -42,12 +44,12 @@ AI_NODES = [ { "id": "ai.summarizeDocument", "category": "ai", - "label": "Dokument zusammenfassen", - "description": "Dokumentinhalt zusammenfassen", + "label": t("Dokument zusammenfassen"), + "description": t("Dokumentinhalt zusammenfassen"), "parameters": [ {"name": "summaryLength", "type": "string", "required": False, "frontendType": "select", "frontendOptions": {"options": ["short", "medium", "long"]}, - "description": "Kurz, mittel oder lang", "default": "medium"}, + "description": t("Kurz, mittel oder lang"), "default": "medium"}, ], "inputs": 1, "outputs": 1, @@ -60,12 +62,12 @@ AI_NODES = [ { "id": "ai.translateDocument", "category": "ai", - "label": "Dokument übersetzen", - "description": "Dokument in Zielsprache übersetzen", + "label": t("Dokument übersetzen"), + "description": t("Dokument in Zielsprache übersetzen"), "parameters": [ {"name": "targetLanguage", "type": "string", "required": True, "frontendType": "select", "frontendOptions": {"options": ["en", "de", "fr", "it", "es", "pt", "nl"]}, - "description": "Zielsprache"}, + "description": t("Zielsprache")}, ], "inputs": 1, "outputs": 1, @@ -78,12 +80,12 @@ AI_NODES = [ { "id": "ai.convertDocument", "category": "ai", - "label": "Dokument konvertieren", - "description": "Dokument in anderes Format konvertieren", + "label": t("Dokument konvertieren"), + "description": t("Dokument in anderes Format konvertieren"), "parameters": [ {"name": "targetFormat", "type": "string", "required": True, "frontendType": "select", "frontendOptions": {"options": ["pdf", "docx", "txt", "html", "md"]}, - "description": "Zielformat"}, + "description": t("Zielformat")}, ], "inputs": 1, "outputs": 1, @@ -96,11 +98,11 @@ AI_NODES = [ { "id": "ai.generateDocument", "category": "ai", - "label": "Dokument generieren", - "description": "Dokument aus Prompt generieren", + "label": t("Dokument generieren"), + "description": t("Dokument aus Prompt generieren"), "parameters": [ {"name": "prompt", "type": "string", "required": True, "frontendType": "textarea", - "description": "Generierungs-Prompt"}, + "description": t("Generierungs-Prompt")}, ], "inputs": 1, "outputs": 1, @@ -113,14 +115,14 @@ AI_NODES = [ { "id": "ai.generateCode", "category": "ai", - "label": "Code generieren", - "description": "Code aus Beschreibung generieren", + "label": t("Code generieren"), + "description": t("Code aus Beschreibung generieren"), "parameters": [ {"name": "prompt", "type": "string", "required": True, "frontendType": "textarea", - "description": "Code-Generierungs-Prompt"}, + "description": t("Code-Generierungs-Prompt")}, {"name": "language", "type": "string", "required": False, "frontendType": "select", "frontendOptions": {"options": ["python", "javascript", "typescript", "java", "csharp", "go"]}, - "description": "Programmiersprache", "default": "python"}, + "description": t("Programmiersprache"), "default": "python"}, ], "inputs": 1, "outputs": 1, diff --git a/modules/features/graphicalEditor/nodeDefinitions/clickup.py b/modules/features/graphicalEditor/nodeDefinitions/clickup.py index 03504f73..3f194e16 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/clickup.py +++ b/modules/features/graphicalEditor/nodeDefinitions/clickup.py @@ -2,30 +2,32 @@ # All rights reserved. """ClickUp nodes — map to MethodClickup actions.""" +from modules.shared.i18nRegistry import t + CLICKUP_NODES = [ { "id": "clickup.searchTasks", "category": "clickup", - "label": "Aufgaben suchen", - "description": "Aufgaben in einem Workspace suchen", + "label": t("Aufgaben suchen"), + "description": t("Aufgaben in einem Workspace suchen"), "parameters": [ {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", - "description": "ClickUp-Verbindung"}, + "description": t("ClickUp-Verbindung")}, {"name": "teamId", "type": "string", "required": True, "frontendType": "text", - "description": "Team-/Workspace-ID"}, + "description": t("Team-/Workspace-ID")}, {"name": "query", "type": "string", "required": True, "frontendType": "text", - "description": "Suchbegriff"}, + "description": t("Suchbegriff")}, {"name": "page", "type": "number", "required": False, "frontendType": "number", - "description": "Seite", "default": 0}, + "description": t("Seite"), "default": 0}, {"name": "listId", "type": "string", "required": False, "frontendType": "clickupList", "frontendOptions": {"dependsOn": "connectionReference"}, - "description": "In dieser Liste suchen"}, + "description": t("In dieser Liste suchen")}, {"name": "includeClosed", "type": "boolean", "required": False, "frontendType": "checkbox", - "description": "Erledigte einbeziehen", "default": False}, + "description": t("Erledigte einbeziehen"), "default": False}, {"name": "fullTaskData", "type": "boolean", "required": False, "frontendType": "checkbox", - "description": "Vollständige Daten", "default": False}, + "description": t("Vollständige Daten"), "default": False}, {"name": "matchNameOnly", "type": "boolean", "required": False, "frontendType": "checkbox", - "description": "Nur Titel", "default": True}, + "description": t("Nur Titel"), "default": True}, ], "inputs": 1, "outputs": 1, @@ -38,18 +40,18 @@ CLICKUP_NODES = [ { "id": "clickup.listTasks", "category": "clickup", - "label": "Aufgaben auflisten", - "description": "Aufgaben einer Liste auflisten", + "label": t("Aufgaben auflisten"), + "description": t("Aufgaben einer Liste auflisten"), "parameters": [ {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", - "description": "ClickUp-Verbindung"}, + "description": t("ClickUp-Verbindung")}, {"name": "pathQuery", "type": "string", "required": True, "frontendType": "clickupList", "frontendOptions": {"dependsOn": "connectionReference"}, - "description": "Pfad zur Liste"}, + "description": t("Pfad zur Liste")}, {"name": "page", "type": "number", "required": False, "frontendType": "number", - "description": "Seite", "default": 0}, + "description": t("Seite"), "default": 0}, {"name": "includeClosed", "type": "boolean", "required": False, "frontendType": "checkbox", - "description": "Erledigte einbeziehen", "default": False}, + "description": t("Erledigte einbeziehen"), "default": False}, ], "inputs": 1, "outputs": 1, @@ -62,15 +64,15 @@ CLICKUP_NODES = [ { "id": "clickup.getTask", "category": "clickup", - "label": "Aufgabe abrufen", - "description": "Eine Aufgabe abrufen", + "label": t("Aufgabe abrufen"), + "description": t("Eine Aufgabe abrufen"), "parameters": [ {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", - "description": "ClickUp-Verbindung"}, + "description": t("ClickUp-Verbindung")}, {"name": "taskId", "type": "string", "required": False, "frontendType": "text", - "description": "Task-ID"}, + "description": t("Task-ID")}, {"name": "pathQuery", "type": "string", "required": False, "frontendType": "text", - "description": "Oder Pfad"}, + "description": t("Oder Pfad")}, ], "inputs": 1, "outputs": 1, @@ -83,39 +85,39 @@ CLICKUP_NODES = [ { "id": "clickup.createTask", "category": "clickup", - "label": "Aufgabe erstellen", - "description": "Aufgabe erstellen", + "label": t("Aufgabe erstellen"), + "description": t("Aufgabe erstellen"), "parameters": [ {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", - "description": "ClickUp-Verbindung"}, + "description": t("ClickUp-Verbindung")}, {"name": "teamId", "type": "string", "required": False, "frontendType": "text", - "description": "Workspace"}, + "description": t("Workspace")}, {"name": "pathQuery", "type": "string", "required": False, "frontendType": "clickupList", "frontendOptions": {"dependsOn": "connectionReference"}, - "description": "Pfad zur Liste"}, + "description": t("Pfad zur Liste")}, {"name": "listId", "type": "string", "required": False, "frontendType": "text", - "description": "Listen-ID"}, + "description": t("Listen-ID")}, {"name": "name", "type": "string", "required": True, "frontendType": "text", - "description": "Name"}, + "description": t("Name")}, {"name": "description", "type": "string", "required": False, "frontendType": "textarea", - "description": "Beschreibung"}, + "description": t("Beschreibung")}, {"name": "taskStatus", "type": "string", "required": False, "frontendType": "text", - "description": "Status"}, + "description": t("Status")}, {"name": "taskPriority", "type": "string", "required": False, "frontendType": "select", "frontendOptions": {"options": ["1", "2", "3", "4"]}, - "description": "Priorität 1-4"}, + "description": t("Priorität 1-4")}, {"name": "taskDueDateMs", "type": "string", "required": False, "frontendType": "text", - "description": "Fälligkeit (ms)"}, + "description": t("Fälligkeit (ms)")}, {"name": "taskAssigneeIds", "type": "object", "required": False, "frontendType": "json", - "description": "Zugewiesene"}, + "description": t("Zugewiesene")}, {"name": "taskTimeEstimateMs", "type": "string", "required": False, "frontendType": "text", - "description": "Zeitschätzung (ms)"}, + "description": t("Zeitschätzung (ms)")}, {"name": "taskTimeEstimateHours", "type": "string", "required": False, "frontendType": "text", - "description": "Zeitschätzung (h)"}, + "description": t("Zeitschätzung (h)")}, {"name": "customFieldValues", "type": "object", "required": False, "frontendType": "json", - "description": "Benutzerdefinierte Felder"}, + "description": t("Benutzerdefinierte Felder")}, {"name": "taskFields", "type": "string", "required": False, "frontendType": "json", - "description": "Zusätzliches JSON"}, + "description": t("Zusätzliches JSON")}, ], "inputs": 1, "outputs": 1, @@ -128,19 +130,19 @@ CLICKUP_NODES = [ { "id": "clickup.updateTask", "category": "clickup", - "label": "Aufgabe aktualisieren", - "description": "Felder der Aufgabe ändern", + "label": t("Aufgabe aktualisieren"), + "description": t("Felder der Aufgabe ändern"), "parameters": [ {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", - "description": "ClickUp-Verbindung"}, + "description": t("ClickUp-Verbindung")}, {"name": "taskId", "type": "string", "required": False, "frontendType": "text", - "description": "Task-ID"}, + "description": t("Task-ID")}, {"name": "path", "type": "string", "required": False, "frontendType": "text", - "description": "Oder Pfad"}, + "description": t("Oder Pfad")}, {"name": "taskUpdateEntries", "type": "object", "required": False, "frontendType": "keyValueRows", - "description": "Zu ändernde Felder"}, + "description": t("Zu ändernde Felder")}, {"name": "taskUpdate", "type": "string", "required": False, "frontendType": "json", - "description": "JSON für API"}, + "description": t("JSON für API")}, ], "inputs": 1, "outputs": 1, @@ -153,17 +155,17 @@ CLICKUP_NODES = [ { "id": "clickup.uploadAttachment", "category": "clickup", - "label": "Anhang hochladen", - "description": "Datei an Task anhängen", + "label": t("Anhang hochladen"), + "description": t("Datei an Task anhängen"), "parameters": [ {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", - "description": "ClickUp-Verbindung"}, + "description": t("ClickUp-Verbindung")}, {"name": "taskId", "type": "string", "required": False, "frontendType": "text", - "description": "Task-ID"}, + "description": t("Task-ID")}, {"name": "path", "type": "string", "required": False, "frontendType": "text", - "description": "Oder Pfad"}, + "description": t("Oder Pfad")}, {"name": "fileName", "type": "string", "required": False, "frontendType": "text", - "description": "Dateiname"}, + "description": t("Dateiname")}, ], "inputs": 1, "outputs": 1, diff --git a/modules/features/graphicalEditor/nodeDefinitions/data.py b/modules/features/graphicalEditor/nodeDefinitions/data.py index e68f3d3d..f5eceb16 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/data.py +++ b/modules/features/graphicalEditor/nodeDefinitions/data.py @@ -1,16 +1,18 @@ # Copyright (c) 2025 Patrick Motsch # Data manipulation node definitions: aggregate, transform, filter. +from modules.shared.i18nRegistry import t + DATA_NODES = [ { "id": "data.aggregate", "category": "data", - "label": "Sammeln", - "description": "Ergebnisse aus Schleifen-Iterationen sammeln", + "label": t("Sammeln"), + "description": t("Ergebnisse aus Schleifen-Iterationen sammeln"), "parameters": [ {"name": "mode", "type": "string", "required": False, "frontendType": "select", "frontendOptions": {"options": ["collect", "concat", "sum", "count"]}, - "description": "Aggregationsmodus", "default": "collect"}, + "description": t("Aggregationsmodus"), "default": "collect"}, ], "inputs": 1, "outputs": 1, @@ -22,11 +24,11 @@ DATA_NODES = [ { "id": "data.transform", "category": "data", - "label": "Umwandeln", - "description": "Daten umstrukturieren", + "label": t("Umwandeln"), + "description": t("Daten umstrukturieren"), "parameters": [ {"name": "mappings", "type": "json", "required": True, "frontendType": "mappingTable", - "description": "Feld-Zuordnungen", "default": []}, + "description": t("Feld-Zuordnungen"), "default": []}, ], "inputs": 1, "outputs": 1, @@ -38,11 +40,11 @@ DATA_NODES = [ { "id": "data.filter", "category": "data", - "label": "Filtern", - "description": "Elemente nach Bedingung filtern", + "label": t("Filtern"), + "description": t("Elemente nach Bedingung filtern"), "parameters": [ {"name": "condition", "type": "string", "required": True, "frontendType": "filterExpression", - "description": "Filterbedingung"}, + "description": t("Filterbedingung")}, ], "inputs": 1, "outputs": 1, diff --git a/modules/features/graphicalEditor/nodeDefinitions/email.py b/modules/features/graphicalEditor/nodeDefinitions/email.py index 8dd6e9c5..1978fdfe 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/email.py +++ b/modules/features/graphicalEditor/nodeDefinitions/email.py @@ -1,27 +1,29 @@ # Copyright (c) 2025 Patrick Motsch # Email node definitions - map to methodOutlook actions. +from modules.shared.i18nRegistry import t + EMAIL_NODES = [ { "id": "email.checkEmail", "category": "email", - "label": "E-Mail prüfen", - "description": "Neue E-Mails prüfen", + "label": t("E-Mail prüfen"), + "description": t("Neue E-Mails prüfen"), "parameters": [ {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", - "description": "E-Mail-Konto Verbindung"}, + "description": t("E-Mail-Konto Verbindung")}, {"name": "folder", "type": "string", "required": False, "frontendType": "text", - "description": "Ordner", "default": "Inbox"}, + "description": t("Ordner"), "default": "Inbox"}, {"name": "limit", "type": "number", "required": False, "frontendType": "number", - "description": "Max E-Mails", "default": 100}, + "description": t("Max E-Mails"), "default": 100}, {"name": "fromAddress", "type": "string", "required": False, "frontendType": "text", - "description": "Nur von dieser Adresse", "default": ""}, + "description": t("Nur von dieser Adresse"), "default": ""}, {"name": "subjectContains", "type": "string", "required": False, "frontendType": "text", - "description": "Betreff muss enthalten", "default": ""}, + "description": t("Betreff muss enthalten"), "default": ""}, {"name": "hasAttachment", "type": "boolean", "required": False, "frontendType": "checkbox", - "description": "Nur mit Anhängen", "default": False}, + "description": t("Nur mit Anhängen"), "default": False}, {"name": "filter", "type": "string", "required": False, "frontendType": "text", - "description": "Erweitert: Filter-Text", "default": ""}, + "description": t("Erweitert: Filter-Text"), "default": ""}, ], "inputs": 1, "outputs": 1, @@ -34,29 +36,29 @@ EMAIL_NODES = [ { "id": "email.searchEmail", "category": "email", - "label": "E-Mail suchen", - "description": "E-Mails suchen", + "label": t("E-Mail suchen"), + "description": t("E-Mails suchen"), "parameters": [ {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", - "description": "E-Mail-Konto Verbindung"}, + "description": t("E-Mail-Konto Verbindung")}, {"name": "query", "type": "string", "required": False, "frontendType": "text", - "description": "Suchbegriff", "default": ""}, + "description": t("Suchbegriff"), "default": ""}, {"name": "folder", "type": "string", "required": False, "frontendType": "text", - "description": "Ordner", "default": "Inbox"}, + "description": t("Ordner"), "default": "Inbox"}, {"name": "limit", "type": "number", "required": False, "frontendType": "number", - "description": "Max E-Mails", "default": 100}, + "description": t("Max E-Mails"), "default": 100}, {"name": "fromAddress", "type": "string", "required": False, "frontendType": "text", - "description": "Von Adresse", "default": ""}, + "description": t("Von Adresse"), "default": ""}, {"name": "toAddress", "type": "string", "required": False, "frontendType": "text", - "description": "An Adresse", "default": ""}, + "description": t("An Adresse"), "default": ""}, {"name": "subjectContains", "type": "string", "required": False, "frontendType": "text", - "description": "Betreff enthält", "default": ""}, + "description": t("Betreff enthält"), "default": ""}, {"name": "bodyContains", "type": "string", "required": False, "frontendType": "text", - "description": "Inhalt enthält", "default": ""}, + "description": t("Inhalt enthält"), "default": ""}, {"name": "hasAttachment", "type": "boolean", "required": False, "frontendType": "checkbox", - "description": "Mit Anhängen", "default": False}, + "description": t("Mit Anhängen"), "default": False}, {"name": "filter", "type": "string", "required": False, "frontendType": "text", - "description": "Erweitert: KQL-Filter", "default": ""}, + "description": t("Erweitert: KQL-Filter"), "default": ""}, ], "inputs": 1, "outputs": 1, @@ -69,17 +71,17 @@ EMAIL_NODES = [ { "id": "email.draftEmail", "category": "email", - "label": "E-Mail entwerfen", - "description": "E-Mail-Entwurf erstellen", + "label": t("E-Mail entwerfen"), + "description": t("E-Mail-Entwurf erstellen"), "parameters": [ {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", - "description": "E-Mail-Konto"}, + "description": t("E-Mail-Konto")}, {"name": "subject", "type": "string", "required": True, "frontendType": "text", - "description": "Betreff"}, + "description": t("Betreff")}, {"name": "body", "type": "string", "required": True, "frontendType": "textarea", - "description": "Inhalt"}, + "description": t("Inhalt")}, {"name": "to", "type": "string", "required": False, "frontendType": "text", - "description": "Empfänger", "default": ""}, + "description": t("Empfänger"), "default": ""}, ], "inputs": 1, "outputs": 1, diff --git a/modules/features/graphicalEditor/nodeDefinitions/file.py b/modules/features/graphicalEditor/nodeDefinitions/file.py index bed2cbc7..f3714741 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/file.py +++ b/modules/features/graphicalEditor/nodeDefinitions/file.py @@ -1,26 +1,28 @@ # Copyright (c) 2025 Patrick Motsch # File node definitions - create files from context (e.g. from AI nodes). +from modules.shared.i18nRegistry import t + FILE_NODES = [ { "id": "file.create", "category": "file", - "label": "Datei erstellen", - "description": "Erstellt eine Datei aus Kontext (Text/Markdown von KI).", + "label": t("Datei erstellen"), + "description": t("Erstellt eine Datei aus Kontext (Text/Markdown von KI)."), "parameters": [ {"name": "contentSources", "type": "json", "required": False, "frontendType": "json", - "description": "Kontext-Quellen", "default": []}, + "description": t("Kontext-Quellen"), "default": []}, {"name": "outputFormat", "type": "string", "required": True, "frontendType": "select", "frontendOptions": {"options": ["docx", "pdf", "txt", "html", "md"]}, - "description": "Ausgabeformat", "default": "docx"}, + "description": t("Ausgabeformat"), "default": "docx"}, {"name": "title", "type": "string", "required": False, "frontendType": "text", - "description": "Dokumenttitel"}, + "description": t("Dokumenttitel")}, {"name": "templateName", "type": "string", "required": False, "frontendType": "select", "frontendOptions": {"options": ["default", "corporate", "minimal"]}, - "description": "Stil-Vorlage"}, + "description": t("Stil-Vorlage")}, {"name": "language", "type": "string", "required": False, "frontendType": "select", "frontendOptions": {"options": ["de", "en", "fr"]}, - "description": "Sprache", "default": "de"}, + "description": t("Sprache"), "default": "de"}, ], "inputs": 1, "outputs": 1, diff --git a/modules/features/graphicalEditor/nodeDefinitions/flow.py b/modules/features/graphicalEditor/nodeDefinitions/flow.py index 087f7391..91faa4e5 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/flow.py +++ b/modules/features/graphicalEditor/nodeDefinitions/flow.py @@ -1,24 +1,26 @@ # Copyright (c) 2025 Patrick Motsch # Flow control node definitions. +from modules.shared.i18nRegistry import t + FLOW_NODES = [ { "id": "flow.ifElse", "category": "flow", - "label": "Wenn / Sonst", - "description": "Verzweigung nach Bedingung", + "label": t("Wenn / Sonst"), + "description": t("Verzweigung nach Bedingung"), "parameters": [ { "name": "condition", "type": "string", "required": True, "frontendType": "condition", - "description": "Bedingung", + "description": t("Bedingung"), }, ], "inputs": 1, "outputs": 2, - "outputLabels": ["Ja", "Nein"], + "outputLabels": [t("Ja"), t("Nein")], "inputPorts": {0: {"accepts": ["Transit"]}}, "outputPorts": {0: {"schema": "Transit"}, 1: {"schema": "Transit"}}, "executor": "flow", @@ -27,22 +29,22 @@ FLOW_NODES = [ { "id": "flow.switch", "category": "flow", - "label": "Switch", - "description": "Mehrere Zweige nach Wert", + "label": t("Switch"), + "description": t("Mehrere Zweige nach Wert"), "parameters": [ { "name": "value", "type": "string", "required": True, "frontendType": "text", - "description": "Zu vergleichender Wert", + "description": t("Zu vergleichender Wert"), }, { "name": "cases", "type": "array", "required": False, "frontendType": "caseList", - "description": "Fälle", + "description": t("Fälle"), }, ], "inputs": 1, @@ -55,15 +57,15 @@ FLOW_NODES = [ { "id": "flow.loop", "category": "flow", - "label": "Schleife / Für Jedes", - "description": "Über Array-Elemente iterieren", + "label": t("Schleife / Für Jedes"), + "description": t("Über Array-Elemente iterieren"), "parameters": [ { "name": "items", "type": "string", "required": True, "frontendType": "text", - "description": "Pfad zum Array", + "description": t("Pfad zum Array"), }, ], "inputs": 1, @@ -76,8 +78,8 @@ FLOW_NODES = [ { "id": "flow.merge", "category": "flow", - "label": "Zusammenführen", - "description": "Mehrere Zweige zusammenführen", + "label": t("Zusammenführen"), + "description": t("Mehrere Zweige zusammenführen"), "parameters": [ { "name": "mode", @@ -85,7 +87,7 @@ FLOW_NODES = [ "required": False, "frontendType": "select", "frontendOptions": {"options": ["first", "all", "append"]}, - "description": "Zusammenführungsmodus", + "description": t("Zusammenführungsmodus"), "default": "first", }, ], diff --git a/modules/features/graphicalEditor/nodeDefinitions/input.py b/modules/features/graphicalEditor/nodeDefinitions/input.py index 20547635..b90efaa2 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/input.py +++ b/modules/features/graphicalEditor/nodeDefinitions/input.py @@ -1,19 +1,21 @@ # Copyright (c) 2025 Patrick Motsch # Input/Human node definitions - nodes that require user action. +from modules.shared.i18nRegistry import t + INPUT_NODES = [ { "id": "input.form", "category": "input", - "label": "Formular", - "description": "Benutzer füllt ein Formular aus", + "label": t("Formular"), + "description": t("Benutzer füllt ein Formular aus"), "parameters": [ { "name": "fields", "type": "json", "required": True, "frontendType": "fieldBuilder", - "description": "Formularfelder", + "description": t("Formularfelder"), "default": [], }, ], @@ -27,16 +29,16 @@ INPUT_NODES = [ { "id": "input.approval", "category": "input", - "label": "Genehmigung", - "description": "Benutzer genehmigt oder lehnt ab", + "label": t("Genehmigung"), + "description": t("Benutzer genehmigt oder lehnt ab"), "parameters": [ {"name": "title", "type": "string", "required": True, "frontendType": "text", - "description": "Genehmigungstitel"}, + "description": t("Genehmigungstitel")}, {"name": "description", "type": "string", "required": False, "frontendType": "textarea", - "description": "Was genehmigt werden soll"}, + "description": t("Was genehmigt werden soll")}, {"name": "approvalType", "type": "string", "required": False, "frontendType": "select", "frontendOptions": {"options": ["generic", "document"]}, - "description": "Typ: document oder generic", "default": "generic"}, + "description": t("Typ: document oder generic"), "default": "generic"}, ], "inputs": 1, "outputs": 1, @@ -48,18 +50,18 @@ INPUT_NODES = [ { "id": "input.upload", "category": "input", - "label": "Upload", - "description": "Benutzer lädt Datei(en) hoch", + "label": t("Upload"), + "description": t("Benutzer lädt Datei(en) hoch"), "parameters": [ {"name": "accept", "type": "string", "required": False, "frontendType": "text", - "description": "Accept-String", "default": ""}, + "description": t("Accept-String"), "default": ""}, {"name": "allowedTypes", "type": "json", "required": False, "frontendType": "multiselect", "frontendOptions": {"options": ["pdf", "docx", "xlsx", "pptx", "txt", "csv", "jpg", "png", "gif"]}, - "description": "Ausgewählte Dateitypen", "default": []}, + "description": t("Ausgewählte Dateitypen"), "default": []}, {"name": "maxSize", "type": "number", "required": False, "frontendType": "number", - "description": "Max. Dateigröße in MB", "default": 10}, + "description": t("Max. Dateigröße in MB"), "default": 10}, {"name": "multiple", "type": "boolean", "required": False, "frontendType": "checkbox", - "description": "Mehrere Dateien erlauben", "default": False}, + "description": t("Mehrere Dateien erlauben"), "default": False}, ], "inputs": 1, "outputs": 1, @@ -71,13 +73,13 @@ INPUT_NODES = [ { "id": "input.comment", "category": "input", - "label": "Kommentar", - "description": "Benutzer fügt einen Kommentar hinzu", + "label": t("Kommentar"), + "description": t("Benutzer fügt einen Kommentar hinzu"), "parameters": [ {"name": "placeholder", "type": "string", "required": False, "frontendType": "text", - "description": "Platzhalter", "default": ""}, + "description": t("Platzhalter"), "default": ""}, {"name": "required", "type": "boolean", "required": False, "frontendType": "checkbox", - "description": "Kommentar erforderlich", "default": True}, + "description": t("Kommentar erforderlich"), "default": True}, ], "inputs": 1, "outputs": 1, @@ -89,14 +91,14 @@ INPUT_NODES = [ { "id": "input.review", "category": "input", - "label": "Prüfung", - "description": "Benutzer prüft Inhalt", + "label": t("Prüfung"), + "description": t("Benutzer prüft Inhalt"), "parameters": [ {"name": "contentRef", "type": "string", "required": True, "frontendType": "text", - "description": "Referenz auf Inhalt"}, + "description": t("Referenz auf Inhalt")}, {"name": "reviewType", "type": "string", "required": False, "frontendType": "select", "frontendOptions": {"options": ["generic", "document"]}, - "description": "Art der Prüfung", "default": "generic"}, + "description": t("Art der Prüfung"), "default": "generic"}, ], "inputs": 1, "outputs": 1, @@ -108,13 +110,13 @@ INPUT_NODES = [ { "id": "input.selection", "category": "input", - "label": "Auswahl", - "description": "Benutzer wählt aus Optionen", + "label": t("Auswahl"), + "description": t("Benutzer wählt aus Optionen"), "parameters": [ {"name": "options", "type": "json", "required": True, "frontendType": "keyValueRows", - "description": "Optionen", "default": []}, + "description": t("Optionen"), "default": []}, {"name": "multiple", "type": "boolean", "required": False, "frontendType": "checkbox", - "description": "Mehrfachauswahl erlauben", "default": False}, + "description": t("Mehrfachauswahl erlauben"), "default": False}, ], "inputs": 1, "outputs": 1, @@ -126,15 +128,15 @@ INPUT_NODES = [ { "id": "input.confirmation", "category": "input", - "label": "Bestätigung", - "description": "Benutzer bestätigt Ja/Nein", + "label": t("Bestätigung"), + "description": t("Benutzer bestätigt Ja/Nein"), "parameters": [ {"name": "question", "type": "string", "required": True, "frontendType": "text", - "description": "Zu bestätigende Frage"}, + "description": t("Zu bestätigende Frage")}, {"name": "confirmLabel", "type": "string", "required": False, "frontendType": "text", - "description": "Label für Bestätigen-Button", "default": "Confirm"}, + "description": t("Label für Bestätigen-Button"), "default": "Confirm"}, {"name": "rejectLabel", "type": "string", "required": False, "frontendType": "text", - "description": "Label für Ablehnen-Button", "default": "Reject"}, + "description": t("Label für Ablehnen-Button"), "default": "Reject"}, ], "inputs": 1, "outputs": 1, diff --git a/modules/features/graphicalEditor/nodeDefinitions/sharepoint.py b/modules/features/graphicalEditor/nodeDefinitions/sharepoint.py index 199285c8..617354d3 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/sharepoint.py +++ b/modules/features/graphicalEditor/nodeDefinitions/sharepoint.py @@ -1,21 +1,23 @@ # Copyright (c) 2025 Patrick Motsch # SharePoint node definitions - map to methodSharepoint actions. +from modules.shared.i18nRegistry import t + SHAREPOINT_NODES = [ { "id": "sharepoint.findFile", "category": "sharepoint", - "label": "Datei finden", - "description": "Datei nach Pfad oder Suche finden", + "label": t("Datei finden"), + "description": t("Datei nach Pfad oder Suche finden"), "parameters": [ {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", - "description": "SharePoint-Verbindung"}, + "description": t("SharePoint-Verbindung")}, {"name": "searchQuery", "type": "string", "required": True, "frontendType": "text", - "description": "Suchanfrage oder Pfad"}, + "description": t("Suchanfrage oder Pfad")}, {"name": "site", "type": "string", "required": False, "frontendType": "text", - "description": "Optionaler Site-Hinweis", "default": ""}, + "description": t("Optionaler Site-Hinweis"), "default": ""}, {"name": "maxResults", "type": "number", "required": False, "frontendType": "number", - "description": "Max Ergebnisse", "default": 1000}, + "description": t("Max Ergebnisse"), "default": 1000}, ], "inputs": 1, "outputs": 1, @@ -28,14 +30,14 @@ SHAREPOINT_NODES = [ { "id": "sharepoint.readFile", "category": "sharepoint", - "label": "Datei lesen", - "description": "Inhalt aus Datei extrahieren", + "label": t("Datei lesen"), + "description": t("Inhalt aus Datei extrahieren"), "parameters": [ {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", - "description": "SharePoint-Verbindung"}, + "description": t("SharePoint-Verbindung")}, {"name": "pathQuery", "type": "string", "required": True, "frontendType": "sharepointFile", "frontendOptions": {"dependsOn": "connectionReference"}, - "description": "Dateipfad"}, + "description": t("Dateipfad")}, ], "inputs": 1, "outputs": 1, @@ -48,14 +50,14 @@ SHAREPOINT_NODES = [ { "id": "sharepoint.uploadFile", "category": "sharepoint", - "label": "Datei hochladen", - "description": "Datei zu SharePoint hochladen", + "label": t("Datei hochladen"), + "description": t("Datei zu SharePoint hochladen"), "parameters": [ {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", - "description": "SharePoint-Verbindung"}, + "description": t("SharePoint-Verbindung")}, {"name": "pathQuery", "type": "string", "required": True, "frontendType": "sharepointFolder", "frontendOptions": {"dependsOn": "connectionReference"}, - "description": "Zielordner-Pfad"}, + "description": t("Zielordner-Pfad")}, ], "inputs": 1, "outputs": 1, @@ -68,14 +70,14 @@ SHAREPOINT_NODES = [ { "id": "sharepoint.listFiles", "category": "sharepoint", - "label": "Dateien auflisten", - "description": "Dateien in Ordner auflisten", + "label": t("Dateien auflisten"), + "description": t("Dateien in Ordner auflisten"), "parameters": [ {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", - "description": "SharePoint-Verbindung"}, + "description": t("SharePoint-Verbindung")}, {"name": "pathQuery", "type": "string", "required": False, "frontendType": "sharepointFolder", "frontendOptions": {"dependsOn": "connectionReference"}, - "description": "Ordnerpfad", "default": "/"}, + "description": t("Ordnerpfad"), "default": "/"}, ], "inputs": 1, "outputs": 1, @@ -88,14 +90,14 @@ SHAREPOINT_NODES = [ { "id": "sharepoint.downloadFile", "category": "sharepoint", - "label": "Datei herunterladen", - "description": "Datei vom Pfad herunterladen", + "label": t("Datei herunterladen"), + "description": t("Datei vom Pfad herunterladen"), "parameters": [ {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", - "description": "SharePoint-Verbindung"}, + "description": t("SharePoint-Verbindung")}, {"name": "pathQuery", "type": "string", "required": True, "frontendType": "sharepointFile", "frontendOptions": {"dependsOn": "connectionReference"}, - "description": "Vollständiger Dateipfad"}, + "description": t("Vollständiger Dateipfad")}, ], "inputs": 1, "outputs": 1, @@ -108,17 +110,17 @@ SHAREPOINT_NODES = [ { "id": "sharepoint.copyFile", "category": "sharepoint", - "label": "Datei kopieren", - "description": "Datei an Ziel kopieren", + "label": t("Datei kopieren"), + "description": t("Datei an Ziel kopieren"), "parameters": [ {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", - "description": "SharePoint-Verbindung"}, + "description": t("SharePoint-Verbindung")}, {"name": "sourcePath", "type": "string", "required": True, "frontendType": "sharepointFile", "frontendOptions": {"dependsOn": "connectionReference"}, - "description": "Quelldatei-Pfad"}, + "description": t("Quelldatei-Pfad")}, {"name": "destPath", "type": "string", "required": True, "frontendType": "sharepointFolder", "frontendOptions": {"dependsOn": "connectionReference"}, - "description": "Zielordner"}, + "description": t("Zielordner")}, ], "inputs": 1, "outputs": 1, diff --git a/modules/features/graphicalEditor/nodeDefinitions/triggers.py b/modules/features/graphicalEditor/nodeDefinitions/triggers.py index c25fffbe..69b1aa17 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/triggers.py +++ b/modules/features/graphicalEditor/nodeDefinitions/triggers.py @@ -1,12 +1,14 @@ # Copyright (c) 2025 Patrick Motsch # Canvas start nodes — variant reflects workflow configuration (gear in editor). +from modules.shared.i18nRegistry import t + TRIGGER_NODES = [ { "id": "trigger.manual", "category": "trigger", - "label": "Start", - "description": "Manuell, API oder Hintergrund-Starts (Webhook, E-Mail, …).", + "label": t("Start"), + "description": t("Manuell, API oder Hintergrund-Starts (Webhook, E-Mail, …)."), "parameters": [], "inputs": 0, "outputs": 1, @@ -18,15 +20,15 @@ TRIGGER_NODES = [ { "id": "trigger.form", "category": "trigger", - "label": "Start (Formular)", - "description": "Felder werden beim Start befüllt; konfigurieren Sie die Felder auf dieser Node.", + "label": t("Start (Formular)"), + "description": t("Felder werden beim Start befüllt; konfigurieren Sie die Felder auf dieser Node."), "parameters": [ { "name": "formFields", "type": "json", "required": False, "frontendType": "fieldBuilder", - "description": "Felddefinitionen", + "description": t("Felddefinitionen"), }, ], "inputs": 0, @@ -39,15 +41,15 @@ TRIGGER_NODES = [ { "id": "trigger.schedule", "category": "trigger", - "label": "Start (Zeitplan)", - "description": "Cron-Ausdruck für geplante Läufe.", + "label": t("Start (Zeitplan)"), + "description": t("Cron-Ausdruck für geplante Läufe."), "parameters": [ { "name": "cron", "type": "string", "required": False, "frontendType": "cron", - "description": "Cron-Ausdruck", + "description": t("Cron-Ausdruck"), }, ], "inputs": 0, diff --git a/modules/features/graphicalEditor/nodeDefinitions/trustee.py b/modules/features/graphicalEditor/nodeDefinitions/trustee.py index c1847d99..5d8a0f21 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/trustee.py +++ b/modules/features/graphicalEditor/nodeDefinitions/trustee.py @@ -1,21 +1,23 @@ # Copyright (c) 2025 Patrick Motsch # Trustee node definitions - map to methodTrustee actions. +from modules.shared.i18nRegistry import t + TRUSTEE_NODES = [ { "id": "trustee.refreshAccountingData", "category": "trustee", - "label": "Buchhaltungsdaten aktualisieren", - "description": "Buchhaltungsdaten aus externem System importieren/aktualisieren.", + "label": t("Buchhaltungsdaten aktualisieren"), + "description": t("Buchhaltungsdaten aus externem System importieren/aktualisieren."), "parameters": [ {"name": "featureInstanceId", "type": "string", "required": True, "frontendType": "hidden", - "description": "Trustee Feature-Instanz-ID"}, + "description": t("Trustee Feature-Instanz-ID")}, {"name": "forceRefresh", "type": "boolean", "required": False, "frontendType": "checkbox", - "description": "Import erzwingen", "default": False}, + "description": t("Import erzwingen"), "default": False}, {"name": "dateFrom", "type": "string", "required": False, "frontendType": "date", - "description": "Startdatum", "default": ""}, + "description": t("Startdatum"), "default": ""}, {"name": "dateTo", "type": "string", "required": False, "frontendType": "date", - "description": "Enddatum", "default": ""}, + "description": t("Enddatum"), "default": ""}, ], "inputs": 1, "outputs": 1, @@ -28,18 +30,18 @@ TRUSTEE_NODES = [ { "id": "trustee.extractFromFiles", "category": "trustee", - "label": "Dokumente extrahieren", - "description": "Dokumenttyp und Daten aus PDF/JPG per AI extrahieren.", + "label": t("Dokumente extrahieren"), + "description": t("Dokumenttyp und Daten aus PDF/JPG per AI extrahieren."), "parameters": [ {"name": "connectionReference", "type": "string", "required": False, "frontendType": "userConnection", - "description": "SharePoint-Verbindung", "default": ""}, + "description": t("SharePoint-Verbindung"), "default": ""}, {"name": "sharepointFolder", "type": "string", "required": False, "frontendType": "sharepointFolder", "frontendOptions": {"dependsOn": "connectionReference"}, - "description": "SharePoint-Ordnerpfad", "default": ""}, + "description": t("SharePoint-Ordnerpfad"), "default": ""}, {"name": "featureInstanceId", "type": "string", "required": True, "frontendType": "hidden", - "description": "Trustee Feature-Instanz-ID"}, + "description": t("Trustee Feature-Instanz-ID")}, {"name": "prompt", "type": "string", "required": False, "frontendType": "textarea", - "description": "AI-Prompt für Extraktion", "default": ""}, + "description": t("AI-Prompt für Extraktion"), "default": ""}, ], "inputs": 1, "outputs": 1, @@ -52,13 +54,13 @@ TRUSTEE_NODES = [ { "id": "trustee.processDocuments", "category": "trustee", - "label": "Dokumente verarbeiten", - "description": "TrusteeDocument + TrusteePosition aus Extraktionsergebnis erstellen.", + "label": t("Dokumente verarbeiten"), + "description": t("TrusteeDocument + TrusteePosition aus Extraktionsergebnis erstellen."), "parameters": [ {"name": "documentList", "type": "string", "required": False, "frontendType": "hidden", - "description": "Automatisch via Wire-Verbindung befüllt"}, + "description": t("Automatisch via Wire-Verbindung befüllt")}, {"name": "featureInstanceId", "type": "string", "required": True, "frontendType": "hidden", - "description": "Trustee Feature-Instanz-ID"}, + "description": t("Trustee Feature-Instanz-ID")}, ], "inputs": 1, "outputs": 1, @@ -71,13 +73,13 @@ TRUSTEE_NODES = [ { "id": "trustee.syncToAccounting", "category": "trustee", - "label": "In Buchhaltung synchronisieren", - "description": "Trustee-Positionen in Buchhaltungssystem übertragen.", + "label": t("In Buchhaltung synchronisieren"), + "description": t("Trustee-Positionen in Buchhaltungssystem übertragen."), "parameters": [ {"name": "documentList", "type": "string", "required": False, "frontendType": "hidden", - "description": "Automatisch via Wire-Verbindung befüllt"}, + "description": t("Automatisch via Wire-Verbindung befüllt")}, {"name": "featureInstanceId", "type": "string", "required": True, "frontendType": "hidden", - "description": "Trustee Feature-Instanz-ID"}, + "description": t("Trustee Feature-Instanz-ID")}, ], "inputs": 1, "outputs": 1, diff --git a/modules/features/graphicalEditor/nodeRegistry.py b/modules/features/graphicalEditor/nodeRegistry.py index 1af0beb7..ea5b67bd 100644 --- a/modules/features/graphicalEditor/nodeRegistry.py +++ b/modules/features/graphicalEditor/nodeRegistry.py @@ -10,7 +10,7 @@ from typing import Dict, List, Any from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG, SYSTEM_VARIABLES -from modules.shared.i18nRegistry import normalizePrimaryLanguageTag +from modules.shared.i18nRegistry import normalizePrimaryLanguageTag, resolveText logger = logging.getLogger(__name__) @@ -41,27 +41,34 @@ def _pickFromLangMap(d: Any, lang: str) -> Any: def _localizeNode(node: Dict[str, Any], language: str) -> Dict[str, Any]: - """Apply language to label/description/parameters. Keep inputPorts/outputPorts.""" + """Apply request language via resolveText (t() keys + multilingual dicts).""" lang = normalizePrimaryLanguageTag(language, "en") out = dict(node) for key in list(out.keys()): if key.startswith("_"): del out[key] - if isinstance(node.get("label"), dict): - out["label"] = _pickFromLangMap(node["label"], lang) or node.get("id", "") - if isinstance(node.get("description"), dict): - out["description"] = _pickFromLangMap(node["description"], lang) or "" + lbl = node.get("label") + if lbl is not None: + out["label"] = resolveText(lbl, lang) or node.get("id", "") + desc = node.get("description") + if desc is not None: + out["description"] = resolveText(desc, lang) ol = node.get("outputLabels") - if isinstance(ol, dict) and ol: - first = next(iter(ol.values()), None) - if isinstance(first, (list, tuple)): - picked = _pickFromLangMap(ol, lang) - out["outputLabels"] = picked if picked is not None else list(first) + if ol is not None: + if isinstance(ol, list): + out["outputLabels"] = [resolveText(x, lang) for x in ol] + elif isinstance(ol, dict) and ol: + first = next(iter(ol.values()), None) + if isinstance(first, (list, tuple)): + picked = _pickFromLangMap(ol, lang) + raw = list(picked) if picked is not None else list(first) + out["outputLabels"] = [resolveText(x, lang) for x in raw] params = [] for p in node.get("parameters", []): pc = dict(p) - if isinstance(p.get("description"), dict): - pc["description"] = _pickFromLangMap(p["description"], lang) or str(p.get("description", "")) + pd = p.get("description") + if pd is not None: + pc["description"] = resolveText(pd, lang) params.append(pc) out["parameters"] = params return out diff --git a/modules/features/trustee/accounting/accountingRegistry.py b/modules/features/trustee/accounting/accountingRegistry.py index b5ce6e80..ca5e27d9 100644 --- a/modules/features/trustee/accounting/accountingRegistry.py +++ b/modules/features/trustee/accounting/accountingRegistry.py @@ -10,7 +10,6 @@ import os from typing import Dict, List, Optional from .accountingConnectorBase import BaseAccountingConnector -from modules.shared.i18nRegistry import resolveText logger = logging.getLogger(__name__) @@ -62,11 +61,11 @@ class AccountingRegistry: fields = [] for f in connector.getRequiredConfigFields(): fd = f.model_dump() - fd["label"] = resolveText(f.label) + fd["label"] = f.label fields.append(fd) result.append({ "connectorType": connectorType, - "label": resolveText(connector.getConnectorLabel()), + "label": connector.getConnectorLabel(), "configFields": fields, }) return result diff --git a/modules/features/trustee/accounting/connectors/accountingConnectorAbacus.py b/modules/features/trustee/accounting/connectors/accountingConnectorAbacus.py index 7763d613..0269a654 100644 --- a/modules/features/trustee/accounting/connectors/accountingConnectorAbacus.py +++ b/modules/features/trustee/accounting/connectors/accountingConnectorAbacus.py @@ -22,6 +22,7 @@ from ..accountingConnectorBase import ( ConnectorConfigField, SyncResult, ) +from modules.shared.i18nRegistry import t logger = logging.getLogger(__name__) @@ -41,27 +42,27 @@ class AccountingConnectorAbacus(BaseAccountingConnector): return [ ConnectorConfigField( key="apiBaseUrl", - label="API Base URL", + label=t("API Base URL"), fieldType="text", secret=False, placeholder="e.g. https://abacus.meinefirma.ch/api/entity/v1/", ), ConnectorConfigField( key="clientName", - label="Mandantenname", + label=t("Mandantenname"), fieldType="text", secret=False, placeholder="e.g. 7777", ), ConnectorConfigField( key="clientId", - label="Client-ID", + label=t("Client-ID"), fieldType="text", secret=False, ), ConnectorConfigField( key="clientSecret", - label="Client-Secret", + label=t("Client-Secret"), fieldType="password", secret=True, ), diff --git a/modules/features/trustee/accounting/connectors/accountingConnectorBexio.py b/modules/features/trustee/accounting/connectors/accountingConnectorBexio.py index 7ef2b0c3..dcb3233d 100644 --- a/modules/features/trustee/accounting/connectors/accountingConnectorBexio.py +++ b/modules/features/trustee/accounting/connectors/accountingConnectorBexio.py @@ -21,6 +21,7 @@ from ..accountingConnectorBase import ( ConnectorConfigField, SyncResult, ) +from modules.shared.i18nRegistry import t logger = logging.getLogger(__name__) @@ -42,21 +43,21 @@ class AccountingConnectorBexio(BaseAccountingConnector): return [ ConnectorConfigField( key="apiBaseUrl", - label="API Base URL", + label=t("API Base URL"), fieldType="text", secret=False, placeholder="https://api.bexio.com/", ), ConnectorConfigField( key="clientName", - label="Mandantenname", + label=t("Mandantenname"), fieldType="text", secret=False, placeholder="e.g. poweronag", ), ConnectorConfigField( key="accessToken", - label="Persönlicher Zugriffstoken", + label=t("Persönlicher Zugriffstoken"), fieldType="password", secret=True, placeholder="PAT from developer.bexio.com", diff --git a/modules/features/trustee/accounting/connectors/accountingConnectorRma.py b/modules/features/trustee/accounting/connectors/accountingConnectorRma.py index b3f3c65b..bcf52561 100644 --- a/modules/features/trustee/accounting/connectors/accountingConnectorRma.py +++ b/modules/features/trustee/accounting/connectors/accountingConnectorRma.py @@ -24,6 +24,7 @@ from ..accountingConnectorBase import ( ConnectorConfigField, SyncResult, ) +from modules.shared.i18nRegistry import t logger = logging.getLogger(__name__) @@ -42,21 +43,21 @@ class AccountingConnectorRma(BaseAccountingConnector): return [ ConnectorConfigField( key="apiBaseUrl", - label="API Base URL", + label=t("API Base URL"), fieldType="text", secret=False, placeholder="https://service.runmyaccounts.com/api/latest/clients/", ), ConnectorConfigField( key="clientName", - label="Mandantenname", + label=t("Mandantenname"), fieldType="text", secret=False, placeholder="e.g. meinefirma", ), ConnectorConfigField( key="apiKey", - label="API-Schlüssel", + label=t("API-Schlüssel"), fieldType="password", secret=True, ), @@ -227,6 +228,10 @@ class AccountingConnectorRma(BaseAccountingConnector): if rawDesc and len(rawDesc) > 80: payload["notes"] = rawDesc[:2000] + logger.debug("RMA pushBooking payload: batch=%s transdate=%s accounts=%s", + batchNumber, transdate, + [(t.get("accno"), t.get("debit_amount"), t.get("credit_amount")) for t in glTransactions]) + async with aiohttp.ClientSession() as session: url = self._buildUrl(config, "gl") async with session.post(url, headers=self._buildHeaders(config), json=payload, timeout=aiohttp.ClientTimeout(total=30)) as resp: diff --git a/modules/routes/routeWorkflowDashboard.py b/modules/routes/routeWorkflowDashboard.py index 2c2f862a..c44951b3 100644 --- a/modules/routes/routeWorkflowDashboard.py +++ b/modules/routes/routeWorkflowDashboard.py @@ -22,7 +22,7 @@ from modules.auth.authentication import getRequestContext, RequestContext from modules.interfaces.interfaceDbApp import getRootInterface from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.shared.configuration import APP_CONFIG -from modules.datamodels.datamodelPagination import PaginationParams +from modules.datamodels.datamodelPagination import PaginationParams, normalize_pagination_dict from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import ( AutoRun, AutoStepLog, AutoWorkflow, AutoTask, ) @@ -152,6 +152,7 @@ def get_workflow_runs( offset: int = Query(0, ge=0), status: Optional[str] = Query(None, description="Filter by status"), mandateId: Optional[str] = Query(None, description="Filter by mandate"), + pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"), context: RequestContext = Depends(getRequestContext), ) -> dict: """List workflow runs with RBAC scoping (SQL-paginated).""" @@ -167,16 +168,27 @@ def get_workflow_runs( if mandateId: recordFilter["mandateId"] = mandateId - page = (offset // limit) + 1 if limit > 0 else 1 - pagination = PaginationParams( - page=page, - pageSize=limit, - sort=[{"field": "sysCreatedAt", "direction": "desc"}], - ) + paginationParams = None + if pagination: + try: + paginationDict = json.loads(pagination) + if paginationDict: + paginationDict = normalize_pagination_dict(paginationDict) + paginationParams = PaginationParams(**paginationDict) + except Exception: + pass + + if not paginationParams: + page = (offset // limit) + 1 if limit > 0 else 1 + paginationParams = PaginationParams( + page=page, + pageSize=limit, + sort=[{"field": "sysCreatedAt", "direction": "desc"}], + ) result = db.getRecordsetPaginated( AutoRun, - pagination=pagination, + pagination=paginationParams, recordFilter=recordFilter if recordFilter else None, ) pageRuns = result.get("items", []) if isinstance(result, dict) else result.items @@ -317,44 +329,6 @@ def get_workflow_metrics( } -@router.get("/{runId}/steps") -@limiter.limit("60/minute") -def get_run_steps( - request: Request, - runId: str = Path(..., description="Run ID"), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """Get step logs for a specific run (with access check).""" - db = _getDb() - if not db._ensureTableExists(AutoRun): - raise HTTPException(status_code=404, detail=routeApiMsg("Run not found")) - - runs = db.getRecordset(AutoRun, recordFilter={"id": runId}) - if not runs: - raise HTTPException(status_code=404, detail=routeApiMsg("Run not found")) - run = dict(runs[0]) - - if not context.hasSysAdminRole: - userId = str(context.user.id) if context.user else None - runOwner = run.get("ownerId") - runMandate = run.get("mandateId") - - if runOwner == userId: - pass - elif runMandate and userId and _isUserMandateAdmin(userId, runMandate): - pass - else: - raise HTTPException(status_code=403, detail=routeApiMsg("Access denied")) - - if not db._ensureTableExists(AutoStepLog): - return {"steps": []} - - records = db.getRecordset(AutoStepLog, recordFilter={"runId": runId}) - steps = [dict(r) for r in records] if records else [] - steps.sort(key=lambda s: s.get("startedAt") or 0) - return {"steps": steps} - - # --------------------------------------------------------------------------- # System-level Workflow listing (all workflows the user can see via RBAC) # --------------------------------------------------------------------------- @@ -385,7 +359,10 @@ def get_system_workflows( paginationParams = None if pagination: try: - paginationParams = PaginationParams(**json.loads(pagination)) + paginationDict = json.loads(pagination) + if paginationDict: + paginationDict = normalize_pagination_dict(paginationDict) + paginationParams = PaginationParams(**paginationDict) except Exception: pass @@ -492,6 +469,161 @@ def get_system_workflows( } +# --------------------------------------------------------------------------- +# Filter-values endpoints (for FormGeneratorTable column filters) +# --------------------------------------------------------------------------- + +def _enrichedFilterValues( + db, context: RequestContext, modelClass, scopeFilter, column: str, +) -> List[str]: + """Return distinct filter values for enriched columns (mandateLabel, instanceLabel) + or delegate to DB-level DISTINCT for raw columns.""" + if column in ("mandateLabel", "mandateId"): + baseFilter = scopeFilter(context) + recordFilter = dict(baseFilter) if baseFilter else {} + if modelClass == AutoWorkflow: + recordFilter["isTemplate"] = False + items = db.getRecordset(modelClass, recordFilter=recordFilter or None, fieldFilter=["mandateId"]) or [] + mandateIds = list({r.get("mandateId") for r in items if r.get("mandateId")}) + if not mandateIds: + return [] + try: + rootIface = getRootInterface() + mMap = rootIface.getMandatesByIds(mandateIds) + labels = sorted({ + getattr(m, "label", None) or getattr(m, "name", mid) or mid + for mid, m in mMap.items() + }, key=lambda v: v.lower()) + return labels + except Exception: + return sorted(mandateIds) + + if column in ("instanceLabel", "featureInstanceId"): + baseFilter = scopeFilter(context) + recordFilter = dict(baseFilter) if baseFilter else {} + if modelClass == AutoWorkflow: + recordFilter["isTemplate"] = False + items = db.getRecordset(modelClass, recordFilter=recordFilter or None, fieldFilter=["featureInstanceId"]) or [] + instanceIds = list({r.get("featureInstanceId") for r in items if r.get("featureInstanceId")}) + else: + items = db.getRecordset(modelClass, recordFilter=recordFilter or None, fieldFilter=["workflowId"]) or [] + wfIds = list({r.get("workflowId") for r in items if r.get("workflowId")}) + instanceIds = [] + if wfIds and db._ensureTableExists(AutoWorkflow): + wfs = db.getRecordset(AutoWorkflow, recordFilter={"id": wfIds}, fieldFilter=["featureInstanceId"]) or [] + instanceIds = list({w.get("featureInstanceId") for w in wfs if w.get("featureInstanceId")}) + if not instanceIds: + return [] + try: + from modules.interfaces.interfaceFeatures import getFeatureInterface + rootIface = getRootInterface() + featureIface = getFeatureInterface(rootIface.db) + labels = [] + for iid in instanceIds: + fi = featureIface.getFeatureInstance(iid) + if fi: + labels.append(fi.label or iid) + return sorted(set(labels), key=lambda v: v.lower()) + except Exception: + return sorted(instanceIds) + + if column == "workflowLabel": + baseFilter = scopeFilter(context) + recordFilter = dict(baseFilter) if baseFilter else {} + items = db.getRecordset(modelClass, recordFilter=recordFilter or None, fieldFilter=["workflowId", "label"]) or [] + labels = set() + wfIds = set() + for r in items: + if r.get("label"): + labels.add(r["label"]) + if r.get("workflowId"): + wfIds.add(r["workflowId"]) + if wfIds and db._ensureTableExists(AutoWorkflow): + wfs = db.getRecordset(AutoWorkflow, recordFilter={"id": list(wfIds)}, fieldFilter=["label"]) or [] + for wf in wfs: + if wf.get("label"): + labels.add(wf["label"]) + return sorted(labels, key=lambda v: v.lower()) + + baseFilter = scopeFilter(context) + recordFilter = dict(baseFilter) if baseFilter else {} + if modelClass == AutoWorkflow: + recordFilter["isTemplate"] = False + return db.getDistinctColumnValues(modelClass, column, recordFilter=recordFilter or None) or [] + + +@router.get("/filter-values") +@limiter.limit("60/minute") +def get_run_filter_values( + request: Request, + column: str = Query(..., description="Column key"), + pagination: Optional[str] = Query(None, description="JSON-encoded current filters"), + context: RequestContext = Depends(getRequestContext), +) -> list: + """Return distinct filter values for a column in workflow runs.""" + db = _getDb() + if not db._ensureTableExists(AutoRun): + return [] + return _enrichedFilterValues(db, context, AutoRun, _scopedRunFilter, column) + + +@router.get("/workflows/filter-values") +@limiter.limit("60/minute") +def get_workflow_filter_values( + request: Request, + column: str = Query(..., description="Column key"), + pagination: Optional[str] = Query(None, description="JSON-encoded current filters"), + context: RequestContext = Depends(getRequestContext), +) -> list: + """Return distinct filter values for a column in workflows.""" + db = _getDb() + if not db._ensureTableExists(AutoWorkflow): + return [] + return _enrichedFilterValues(db, context, AutoWorkflow, _scopedWorkflowFilter, column) + + +# --------------------------------------------------------------------------- +# Run-specific endpoints (path-param routes MUST come after static routes) +# --------------------------------------------------------------------------- + +@router.get("/{runId}/steps") +@limiter.limit("60/minute") +def get_run_steps( + request: Request, + runId: str = Path(..., description="Run ID"), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Get step logs for a specific run (with access check).""" + db = _getDb() + if not db._ensureTableExists(AutoRun): + raise HTTPException(status_code=404, detail=routeApiMsg("Run not found")) + + runs = db.getRecordset(AutoRun, recordFilter={"id": runId}) + if not runs: + raise HTTPException(status_code=404, detail=routeApiMsg("Run not found")) + run = dict(runs[0]) + + if not context.hasSysAdminRole: + userId = str(context.user.id) if context.user else None + runOwner = run.get("ownerId") + runMandate = run.get("mandateId") + + if runOwner == userId: + pass + elif runMandate and userId and _isUserMandateAdmin(userId, runMandate): + pass + else: + raise HTTPException(status_code=403, detail=routeApiMsg("Access denied")) + + if not db._ensureTableExists(AutoStepLog): + return {"steps": []} + + records = db.getRecordset(AutoStepLog, recordFilter={"runId": runId}) + steps = [dict(r) for r in records] if records else [] + steps.sort(key=lambda s: s.get("startedAt") or 0) + return {"steps": steps} + + # --------------------------------------------------------------------------- # SSE stream for live run tracing (system-level, no instanceId required) # --------------------------------------------------------------------------- diff --git a/modules/workflows/automation2/executors/actionNodeExecutor.py b/modules/workflows/automation2/executors/actionNodeExecutor.py index c0d7d0bb..73116a2e 100644 --- a/modules/workflows/automation2/executors/actionNodeExecutor.py +++ b/modules/workflows/automation2/executors/actionNodeExecutor.py @@ -294,7 +294,9 @@ class ActionNodeExecutor: resolvedParams["context"] = ctx # 10. Pass upstream documents as documentList if available - if "documentList" not in resolvedParams and 0 in inputSources: + # Use truthiness check: empty values ([], "", None) from static graph params + # must not block automatic upstream population via wire connections. + if not resolvedParams.get("documentList") and 0 in inputSources: srcId, _ = inputSources[0] upstream = context.get("nodeOutputs", {}).get(srcId) if upstream and isinstance(upstream, dict): diff --git a/tests/demo/README.md b/tests/demo/README.md new file mode 100644 index 00000000..6887f94a --- /dev/null +++ b/tests/demo/README.md @@ -0,0 +1,35 @@ +# Demo Test Suite + +Automated tests for the investor demo configuration. + +## Prerequisites + +1. Gateway DB must be running and accessible +2. Demo config must be loaded first: Admin UI → `/admin/demo-config` → Load "Investor Demo April 2026" +3. RMA credentials must be set in `gateway/config.ini` + +## Run + +```bash +cd gateway/ + +# All demo tests (structural, no AI calls): +pytest tests/demo/ -v + +# Only bootstrap tests: +pytest tests/demo/test_demo_bootstrap.py -v + +# Only UC1 trustee: +pytest tests/demo/test_demo_uc1_trustee.py -v +``` + +## Test files + +| File | What it tests | +|------|--------------| +| `test_demo_bootstrap.py` | Idempotent load/remove, mandates, user, features, RMA, neutralization | +| `test_demo_uc1_trustee.py` | Trustee instances, RMA config, system workflow templates | +| `test_demo_uc2_realestate.py` | Workspace instances for agent demo | +| `test_demo_uc3_chatbot.py` | Chatbot instance, knowledge-base files | +| `test_demo_uc4_i18n.py` | i18n readiness, Spanish not pre-installed | +| `test_demo_neutralization.py` | Neutralization config enabled, test PDF exists | diff --git a/tests/demo/__init__.py b/tests/demo/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/demo/conftest.py b/tests/demo/conftest.py new file mode 100644 index 00000000..79bf452b --- /dev/null +++ b/tests/demo/conftest.py @@ -0,0 +1,64 @@ +""" +Demo test fixtures. + +Provides a live DB connector and helpers for the demo test suite. +All tests assume the gateway is configured and the DB is reachable. +""" + +import pytest +from modules.security.rootAccess import getRootDbAppConnector +from modules.datamodels.datamodelUam import Mandate, UserInDB +from modules.datamodels.datamodelFeatures import FeatureInstance +from modules.datamodels.datamodelMembership import UserMandate + + +@pytest.fixture(scope="session") +def db(): + """Root DB connector (session-scoped, reused across all tests).""" + return getRootDbAppConnector() + + +@pytest.fixture(scope="session") +def demoConfig(): + """The investor demo config instance.""" + from modules.demoConfigs import _getDemoConfigByCode + cfg = _getDemoConfigByCode("investor-demo-2026") + assert cfg is not None, "Demo config 'investor-demo-2026' not found — check modules/demoConfigs/" + return cfg + + +# --------------------------------------------------------------------------- +# Mandate helpers — function-scoped so they always reflect current DB state +# (test_removeAndReload recreates mandates with new IDs mid-session) +# --------------------------------------------------------------------------- + +@pytest.fixture +def mandateHappylife(db): + """HappyLife AG mandate (must exist after bootstrap load).""" + records = db.getRecordset(Mandate, recordFilter={"name": "happylife"}) + assert records, "Mandate 'happylife' not found — run demo config load first" + return records[0] + + +@pytest.fixture +def mandateAlpina(db): + """Alpina Treuhand AG mandate (must exist after bootstrap load).""" + records = db.getRecordset(Mandate, recordFilter={"name": "alpina-treuhand"}) + assert records, "Mandate 'alpina-treuhand' not found — run demo config load first" + return records[0] + + +@pytest.fixture +def demoUser(db): + """Patrick Helvetia user (must exist after bootstrap load).""" + records = db.getRecordset(UserInDB, recordFilter={"username": "patrick.helvetia"}) + assert records, "User 'patrick.helvetia' not found — run demo config load first" + return records[0] + + +def _getFeatureInstances(db, mandateId: str, featureCode: str): + """Helper: get feature instances for a mandate + code.""" + return db.getRecordset(FeatureInstance, recordFilter={ + "mandateId": mandateId, + "featureCode": featureCode, + }) diff --git a/tests/demo/test_demo_api.py b/tests/demo/test_demo_api.py new file mode 100644 index 00000000..edb31086 --- /dev/null +++ b/tests/demo/test_demo_api.py @@ -0,0 +1,66 @@ +""" +T-API: Demo Config API endpoint verification. + +Tests the admin API endpoints for listing, loading, and removing demo configs. +Uses FastAPI TestClient (no running server needed). + +Note: Login requires CSRF + form-data + httpOnly cookies, so we test +unauthenticated rejection and the discovery module directly. +""" + +import pytest + + +class TestDemoConfigDiscovery: + """Test the auto-discovery module (no HTTP needed).""" + + def test_discoveryFindsInvestorConfig(self): + from modules.demoConfigs import _getAvailableDemoConfigs + configs = _getAvailableDemoConfigs() + assert "investor-demo-2026" in configs, f"Available configs: {list(configs.keys())}" + + def test_getByCodeReturnsInstance(self): + from modules.demoConfigs import _getDemoConfigByCode + cfg = _getDemoConfigByCode("investor-demo-2026") + assert cfg is not None + assert cfg.code == "investor-demo-2026" + assert cfg.label == "Investor Demo April 2026" + + def test_getByCodeReturnsNoneForUnknown(self): + from modules.demoConfigs import _getDemoConfigByCode + cfg = _getDemoConfigByCode("nonexistent-config") + assert cfg is None + + def test_toDictHasRequiredFields(self): + from modules.demoConfigs import _getDemoConfigByCode + cfg = _getDemoConfigByCode("investor-demo-2026") + d = cfg.toDict() + assert "code" in d + assert "label" in d + assert "description" in d + assert d["code"] == "investor-demo-2026" + + +class TestDemoConfigApiEndpoints: + """Test API endpoints via TestClient.""" + + @pytest.fixture(scope="class") + def client(self): + try: + from app import app + from fastapi.testclient import TestClient + return TestClient(app) + except Exception as e: + pytest.skip(f"Cannot create TestClient: {e}") + + def test_listEndpointRejectsUnauthenticated(self, client): + response = client.get("/api/admin/demo-config") + assert response.status_code in (401, 403) + + def test_loadEndpointRejectsUnauthenticated(self, client): + response = client.post("/api/admin/demo-config/investor-demo-2026/load") + assert response.status_code in (401, 403) + + def test_removeEndpointRejectsUnauthenticated(self, client): + response = client.post("/api/admin/demo-config/investor-demo-2026/remove") + assert response.status_code in (401, 403) diff --git a/tests/demo/test_demo_bootstrap.py b/tests/demo/test_demo_bootstrap.py new file mode 100644 index 00000000..1d725442 --- /dev/null +++ b/tests/demo/test_demo_bootstrap.py @@ -0,0 +1,133 @@ +""" +T-BOOT: Bootstrap idempotency and demo state verification. + +Tests that the demo config can be loaded twice without errors +and that all expected objects exist afterwards. +""" + +import pytest +from modules.datamodels.datamodelUam import Mandate, UserInDB +from modules.datamodels.datamodelFeatures import FeatureInstance +from modules.datamodels.datamodelMembership import UserMandate +from tests.demo.conftest import _getFeatureInstances + + +class TestDemoBootstrap: + + def test_loadIsIdempotent(self, db, demoConfig): + """Loading the demo config twice must not raise errors.""" + summary1 = demoConfig.load(db) + assert "errors" not in summary1 or len(summary1.get("errors", [])) == 0, f"First load errors: {summary1['errors']}" + + summary2 = demoConfig.load(db) + assert "errors" not in summary2 or len(summary2.get("errors", [])) == 0, f"Second load errors: {summary2['errors']}" + + def test_mandateHappylifeExists(self, db): + records = db.getRecordset(Mandate, recordFilter={"name": "happylife"}) + assert len(records) == 1 + assert records[0].get("label") == "HappyLife AG" + assert records[0].get("enabled") is True + + def test_mandateAlpinaExists(self, db): + records = db.getRecordset(Mandate, recordFilter={"name": "alpina-treuhand"}) + assert len(records) == 1 + assert records[0].get("label") == "Alpina Treuhand AG" + + def test_userPatrickExists(self, db): + records = db.getRecordset(UserInDB, recordFilter={"username": "patrick.helvetia"}) + assert len(records) == 1 + user = records[0] + assert user.get("email") == "p.motsch@poweron.swiss" + assert user.get("isSysAdmin") is True + assert user.get("language") == "en" + + def test_userMembershipBothMandates(self, db, demoUser, mandateHappylife, mandateAlpina): + userId = demoUser.get("id") + for mandate in [mandateHappylife, mandateAlpina]: + mid = mandate.get("id") + memberships = db.getRecordset(UserMandate, recordFilter={"userId": userId, "mandateId": mid}) + assert len(memberships) >= 1, f"User not member of mandate {mandate.get('label')}" + + @pytest.mark.parametrize("featureCode", ["workspace", "trustee", "graphicalEditor", "chatbot", "neutralization"]) + def test_happylifeFeaturesExist(self, db, mandateHappylife, featureCode): + mid = mandateHappylife.get("id") + instances = _getFeatureInstances(db, mid, featureCode) + assert len(instances) >= 1, f"Feature '{featureCode}' missing in HappyLife AG" + + @pytest.mark.parametrize("featureCode", ["workspace", "trustee", "graphicalEditor", "neutralization"]) + def test_alpinaFeaturesExist(self, db, mandateAlpina, featureCode): + mid = mandateAlpina.get("id") + instances = _getFeatureInstances(db, mid, featureCode) + assert len(instances) >= 1, f"Feature '{featureCode}' missing in Alpina Treuhand AG" + + def test_alpinaNoChatbot(self, db, mandateAlpina): + """Alpina should NOT have a chatbot instance.""" + mid = mandateAlpina.get("id") + instances = _getFeatureInstances(db, mid, "chatbot") + assert len(instances) == 0, "Alpina Treuhand should not have chatbot" + + +class TestDemoBootstrapRma: + + def test_trusteeRmaConfigHappylife(self, db, mandateHappylife): + from modules.features.trustee.datamodelFeatureTrustee import TrusteeAccountingConfig + mid = mandateHappylife.get("id") + instances = _getFeatureInstances(db, mid, "trustee") + assert instances, "No trustee instance in HappyLife" + iid = instances[0].get("id") + configs = db.getRecordset(TrusteeAccountingConfig, recordFilter={"featureInstanceId": iid}) + assert len(configs) >= 1, "No RMA config for HappyLife trustee" + assert configs[0].get("connectorType") == "rma" + assert configs[0].get("isActive") is True + + def test_trusteeRmaConfigAlpina(self, db, mandateAlpina): + from modules.features.trustee.datamodelFeatureTrustee import TrusteeAccountingConfig + mid = mandateAlpina.get("id") + instances = _getFeatureInstances(db, mid, "trustee") + assert instances, "No trustee instance in Alpina" + iid = instances[0].get("id") + configs = db.getRecordset(TrusteeAccountingConfig, recordFilter={"featureInstanceId": iid}) + assert len(configs) >= 1, "No RMA config for Alpina trustee" + assert configs[0].get("connectorType") == "rma" + + +class TestDemoBootstrapNeutralization: + + def test_neutralizationConfigHappylife(self, db, mandateHappylife): + from modules.features.neutralization.datamodelFeatureNeutralizer import DataNeutraliserConfig + mid = mandateHappylife.get("id") + instances = _getFeatureInstances(db, mid, "neutralization") + assert instances + iid = instances[0].get("id") + configs = db.getRecordset(DataNeutraliserConfig, recordFilter={"featureInstanceId": iid}) + assert len(configs) >= 1, "No neutralization config for HappyLife" + assert configs[0].get("enabled") is True + + def test_neutralizationConfigAlpina(self, db, mandateAlpina): + from modules.features.neutralization.datamodelFeatureNeutralizer import DataNeutraliserConfig + mid = mandateAlpina.get("id") + instances = _getFeatureInstances(db, mid, "neutralization") + assert instances + iid = instances[0].get("id") + configs = db.getRecordset(DataNeutraliserConfig, recordFilter={"featureInstanceId": iid}) + assert len(configs) >= 1, "No neutralization config for Alpina" + + +class TestDemoRemoveAndReload: + + def test_removeAndReload(self, db, demoConfig): + """Remove all demo data, verify gone, then reload.""" + removeSummary = demoConfig.remove(db) + assert len(removeSummary.get("errors", [])) == 0, f"Remove errors: {removeSummary['errors']}" + + mandates = db.getRecordset(Mandate, recordFilter={"name": "happylife"}) + assert len(mandates) == 0, "HappyLife mandate should be gone after remove" + + users = db.getRecordset(UserInDB, recordFilter={"username": "patrick.helvetia"}) + assert len(users) == 0, "User should be gone after remove" + + loadSummary = demoConfig.load(db) + assert len(loadSummary.get("errors", [])) == 0, f"Reload errors: {loadSummary['errors']}" + + mandates = db.getRecordset(Mandate, recordFilter={"name": "happylife"}) + assert len(mandates) == 1, "HappyLife mandate should exist after reload" diff --git a/tests/demo/test_demo_data_files.py b/tests/demo/test_demo_data_files.py new file mode 100644 index 00000000..4e7a3d40 --- /dev/null +++ b/tests/demo/test_demo_data_files.py @@ -0,0 +1,44 @@ +""" +T-DATA: Demo data files verification. + +Ensures all expected demo data files exist in gateway/demoData/. +""" + +from pathlib import Path + +_DEMO_DATA_ROOT = Path(__file__).resolve().parent.parent.parent / "demoData" + + +class TestDemoDataStructure: + + def test_rootExists(self): + assert _DEMO_DATA_ROOT.exists(), f"demoData root not found: {_DEMO_DATA_ROOT}" + + def test_invoicesNotEmpty(self): + d = _DEMO_DATA_ROOT / "invoices" + assert d.exists(), "invoices/ dir missing" + files = [f for f in d.iterdir() if not f.name.startswith(".")] + assert len(files) >= 1, f"invoices/ is empty: {list(d.iterdir())}" + + def test_expensesNotEmpty(self): + d = _DEMO_DATA_ROOT / "expenses" + assert d.exists(), "expenses/ dir missing" + files = [f for f in d.iterdir() if not f.name.startswith(".")] + assert len(files) >= 1, f"expenses/ is empty: {list(d.iterdir())}" + + def test_knowledgeBaseNotEmpty(self): + d = _DEMO_DATA_ROOT / "knowledge-base" + assert d.exists(), "knowledge-base/ dir missing" + files = [f for f in d.iterdir() if not f.name.startswith(".")] + assert len(files) >= 3, f"knowledge-base/ should have >=3 docs, found {len(files)}" + + def test_neutralizerHasDossier(self): + pdf = _DEMO_DATA_ROOT / "neutralizer" / "tenant-dossier.pdf" + assert pdf.exists(), "tenant-dossier.pdf missing" + assert pdf.stat().st_size > 500, "tenant-dossier.pdf too small" + + def test_trusteeNotEmpty(self): + d = _DEMO_DATA_ROOT / "trustee" + assert d.exists(), "trustee/ dir missing" + files = [f for f in d.iterdir() if not f.name.startswith(".")] + assert len(files) >= 1, f"trustee/ is empty" diff --git a/tests/demo/test_demo_neutralization.py b/tests/demo/test_demo_neutralization.py new file mode 100644 index 00000000..aca54491 --- /dev/null +++ b/tests/demo/test_demo_neutralization.py @@ -0,0 +1,36 @@ +""" +T-NEU: Neutralization config verification. + +Verifies that neutralization is configured and enabled +for both demo mandates. +""" + +import pytest +from tests.demo.conftest import _getFeatureInstances + + +class TestNeutralizationConfig: + + @pytest.mark.parametrize("mandateFixture", ["mandateHappylife", "mandateAlpina"]) + def test_neutralizationEnabled(self, db, mandateFixture, request): + """Neutralization must be enabled for both mandates.""" + mandate = request.getfixturevalue(mandateFixture) + mid = mandate.get("id") + instances = _getFeatureInstances(db, mid, "neutralization") + assert instances, f"No neutralization instance in {mandate.get('label')}" + + from modules.features.neutralization.datamodelFeatureNeutralizer import DataNeutraliserConfig + iid = instances[0].get("id") + configs = db.getRecordset(DataNeutraliserConfig, recordFilter={"featureInstanceId": iid}) + assert configs, f"No neutralization config in {mandate.get('label')}" + assert configs[0].get("enabled") is True, f"Neutralization not enabled in {mandate.get('label')}" + + +class TestNeutralizationTestData: + + def test_tenantDossierExists(self): + """The tenant-dossier.pdf must exist in demoData.""" + from pathlib import Path + dossier = Path(__file__).resolve().parent.parent.parent / "demoData" / "neutralizer" / "tenant-dossier.pdf" + assert dossier.exists(), f"tenant-dossier.pdf not found at {dossier}" + assert dossier.stat().st_size > 500, "tenant-dossier.pdf seems too small" diff --git a/tests/demo/test_demo_uc1_trustee.py b/tests/demo/test_demo_uc1_trustee.py new file mode 100644 index 00000000..54d2ac70 --- /dev/null +++ b/tests/demo/test_demo_uc1_trustee.py @@ -0,0 +1,60 @@ +""" +T-UC1: Trustee — Spesenverarbeitung. + +Verifies that the trustee feature instances are correctly configured +with RMA accounting and that system workflow templates exist. +""" + +import pytest +from tests.demo.conftest import _getFeatureInstances + + +class TestTrusteeSetup: + + def test_trusteeInstancesExist(self, db, mandateHappylife, mandateAlpina): + """Both mandates must have a trustee instance.""" + for mandate in [mandateHappylife, mandateAlpina]: + mid = mandate.get("id") + instances = _getFeatureInstances(db, mid, "trustee") + assert len(instances) >= 1, f"No trustee in {mandate.get('label')}" + + def test_rmaCredentialsEncrypted(self, db, mandateHappylife): + """RMA config must have non-empty encrypted credentials.""" + from modules.features.trustee.datamodelFeatureTrustee import TrusteeAccountingConfig + mid = mandateHappylife.get("id") + instances = _getFeatureInstances(db, mid, "trustee") + iid = instances[0].get("id") + configs = db.getRecordset(TrusteeAccountingConfig, recordFilter={"featureInstanceId": iid}) + assert configs + enc = configs[0].get("encryptedConfig", "") + assert enc and len(enc) > 10, "encryptedConfig should be a non-trivial encrypted blob" + + def test_rmaCredentialsDecryptable(self, db, mandateHappylife): + """Encrypted RMA config must be decryptable and contain expected keys.""" + import json + from modules.features.trustee.datamodelFeatureTrustee import TrusteeAccountingConfig + from modules.shared.configuration import decryptValue + mid = mandateHappylife.get("id") + instances = _getFeatureInstances(db, mid, "trustee") + iid = instances[0].get("id") + configs = db.getRecordset(TrusteeAccountingConfig, recordFilter={"featureInstanceId": iid}) + enc = configs[0].get("encryptedConfig", "") + plain = json.loads(decryptValue(enc, userId="system", keyName="accountingConfig")) + assert "apiBaseUrl" in plain + assert "clientName" in plain + assert "apiKey" in plain + assert plain["apiKey"], "apiKey should not be empty" + + +class TestSystemWorkflowTemplates: + + def test_systemTemplatesExist(self, db): + """System workflow templates should exist (created by system bootstrap, not demo config).""" + from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoWorkflow + try: + templates = db.getRecordset(AutoWorkflow, recordFilter={"isTemplate": True, "templateScope": "system"}) + except Exception: + pytest.skip("AutoWorkflow table not accessible from app DB") + return + if len(templates) == 0: + pytest.skip("No system workflow templates — run full system bootstrap first") diff --git a/tests/demo/test_demo_uc2_realestate.py b/tests/demo/test_demo_uc2_realestate.py new file mode 100644 index 00000000..0d91122e --- /dev/null +++ b/tests/demo/test_demo_uc2_realestate.py @@ -0,0 +1,24 @@ +""" +T-UC2: Immobilien — Machbarkeitsstudie. + +Verifies that the workspace feature is available for the agent-based +real estate demo (UC2 runs via workspace, not a dedicated realestate instance). +""" + +import pytest +from tests.demo.conftest import _getFeatureInstances + + +class TestRealEstateReadiness: + + def test_workspaceInstanceHappylife(self, db, mandateHappylife): + """HappyLife must have a workspace instance for the agent demo.""" + mid = mandateHappylife.get("id") + instances = _getFeatureInstances(db, mid, "workspace") + assert len(instances) >= 1, "No workspace instance in HappyLife for UC2" + + def test_workspaceInstanceAlpina(self, db, mandateAlpina): + """Alpina must have a workspace instance.""" + mid = mandateAlpina.get("id") + instances = _getFeatureInstances(db, mid, "workspace") + assert len(instances) >= 1, "No workspace instance in Alpina" diff --git a/tests/demo/test_demo_uc3_chatbot.py b/tests/demo/test_demo_uc3_chatbot.py new file mode 100644 index 00000000..89c8d7ba --- /dev/null +++ b/tests/demo/test_demo_uc3_chatbot.py @@ -0,0 +1,37 @@ +""" +T-UC3: Knowledge Chatbot. + +Verifies that the chatbot feature instance exists in HappyLife AG +and that knowledge-base documents are available for upload. +Note: The actual RAG demo runs via workspace, not the chatbot's own index. +""" + +import pytest +from pathlib import Path +from tests.demo.conftest import _getFeatureInstances + + +class TestChatbotSetup: + + def test_chatbotInstanceHappylife(self, db, mandateHappylife): + """HappyLife must have a chatbot instance.""" + mid = mandateHappylife.get("id") + instances = _getFeatureInstances(db, mid, "chatbot") + assert len(instances) >= 1, "No chatbot instance in HappyLife" + + def test_chatbotNotInAlpina(self, db, mandateAlpina): + """Alpina should NOT have a chatbot instance.""" + mid = mandateAlpina.get("id") + instances = _getFeatureInstances(db, mid, "chatbot") + assert len(instances) == 0, "Alpina should not have chatbot" + + +class TestKnowledgeBaseFiles: + + def test_knowledgeBaseFilesExist(self): + """Knowledge-base documents must exist in demoData.""" + kbDir = Path(__file__).resolve().parent.parent.parent / "demoData" / "knowledge-base" + assert kbDir.exists(), f"knowledge-base dir not found at {kbDir}" + files = list(kbDir.iterdir()) + docs = [f for f in files if f.suffix in (".md", ".html", ".pdf", ".docx", ".txt")] + assert len(docs) >= 3, f"Expected at least 3 knowledge-base docs, found {len(docs)}: {[f.name for f in docs]}" diff --git a/tests/demo/test_demo_uc4_i18n.py b/tests/demo/test_demo_uc4_i18n.py new file mode 100644 index 00000000..04eba4b9 --- /dev/null +++ b/tests/demo/test_demo_uc4_i18n.py @@ -0,0 +1,51 @@ +""" +T-UC4: Sprach-Deployment — Spanish (es). + +Verifies that the i18n system is ready for the live demo: +- Admin languages page is reachable +- Spanish is available as a choice but NOT pre-installed +- xx base set exists with entries +""" + +import pytest + + +class TestI18nReadiness: + + def test_xxBaseSetExists(self, db): + """The xx (meta/base) language set must exist with entries.""" + try: + from modules.datamodels.datamodelUiLanguage import UiLanguageSet + sets = db.getRecordset(UiLanguageSet, recordFilter={"id": "xx"}) + assert sets, "xx base set not found — run i18n sync first" + entries = sets[0].get("entries") or [] + assert len(entries) > 50, f"xx set has only {len(entries)} entries — expected 50+" + except Exception as e: + pytest.skip(f"i18n table not accessible: {e}") + + def test_spanishNotPreInstalled(self, db): + """Spanish (es) must NOT be pre-installed — it will be created live.""" + try: + from modules.datamodels.datamodelUiLanguage import UiLanguageSet + sets = db.getRecordset(UiLanguageSet, recordFilter={"id": "es"}) + assert len(sets) == 0, "Spanish (es) is already installed — remove it before demo!" + except Exception as e: + pytest.skip(f"i18n table not accessible: {e}") + + def test_germanSetExists(self, db): + """German (de) set must exist and be complete.""" + try: + from modules.datamodels.datamodelUiLanguage import UiLanguageSet + sets = db.getRecordset(UiLanguageSet, recordFilter={"id": "de"}) + assert sets, "German (de) set not found" + except Exception as e: + pytest.skip(f"i18n table not accessible: {e}") + + def test_englishSetExists(self, db): + """English (en) set must exist.""" + try: + from modules.datamodels.datamodelUiLanguage import UiLanguageSet + sets = db.getRecordset(UiLanguageSet, recordFilter={"id": "en"}) + assert sets, "English (en) set not found" + except Exception as e: + pytest.skip(f"i18n table not accessible: {e}")