fixes in node languages and ai workflow

This commit is contained in:
ValueOn AG 2026-04-13 01:37:29 +02:00
parent 17455688a9
commit 61f04a6049
29 changed files with 1016 additions and 297 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

35
tests/demo/README.md Normal file
View file

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

0
tests/demo/__init__.py Normal file
View file

64
tests/demo/conftest.py Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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