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