Merge pull request #118 from valueonag/feat/unify-automation

Feat/unify automation
This commit is contained in:
Patrick Motsch 2026-04-13 09:44:53 +02:00 committed by GitHub
commit 19f9aa3674
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
258 changed files with 35536 additions and 24437 deletions

86
app.py
View file

@ -20,10 +20,6 @@ from datetime import datetime
from modules.shared.configuration import APP_CONFIG
from modules.shared.eventManagement import eventManager
from modules.workflows.automation import subAutomationSchedule
from modules.workflows.automation2 import subAutomation2Schedule
from modules.features.automation2.emailPoller import start as startAutomation2EmailPoller
from modules.features.automation2.emailPoller import stop as stopAutomation2EmailPoller
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.system.registry import loadFeatureMainModules
@ -246,6 +242,8 @@ def initLogging():
"fastapi.security.oauth2",
"msal",
"azure.core.pipeline.policies.http_logging_policy",
"stripe",
"apscheduler",
]
for loggerName in noisyLoggers:
logging.getLogger(loggerName).setLevel(logging.WARNING)
@ -296,16 +294,7 @@ except Exception as e:
async def lifespan(app: FastAPI):
logger.info("Application is starting up")
# --- Pre-warm AI connectors FIRST (before any other startup work) ---
# Avoids 48 s latency on first chatbot request; must run before first use.
try:
import modules.aicore.aicoreModelRegistry # noqa: F401 - triggers eager pre-warm
from modules.aicore.aicoreModelRegistry import modelRegistry
modelRegistry.ensureConnectorsRegistered()
modelRegistry.refreshModels(force=True)
logger.info("AI connectors and model registry pre-warmed")
except Exception as e:
logger.warning(f"AI pre-warm failed: {e}")
# AI connectors already pre-warmed at module-load via _eager_prewarm() in aicoreModelRegistry.
# Bootstrap database if needed (creates initial users, mandates, roles, etc.)
# This must happen before getting root interface
@ -328,6 +317,15 @@ async def lifespan(app: FastAPI):
except Exception as e:
logger.error(f"Feature catalog registration failed: {e}")
# Sync gateway i18n registry to DB and load translation cache
try:
from modules.shared.i18nRegistry import _syncRegistryToDb, _loadCache
await _syncRegistryToDb()
await _loadCache()
logger.info("i18n registry sync + cache load completed")
except Exception as e:
logger.warning(f"i18n registry sync failed (non-critical): {e}")
# Pre-warm service center modules (avoids first-request import latency)
try:
from modules.serviceCenter import preWarm
@ -360,44 +358,20 @@ async def lifespan(app: FastAPI):
try:
main_loop = asyncio.get_running_loop()
eventManager.set_event_loop(main_loop)
subAutomation2Schedule.set_main_loop(main_loop)
from modules.workflows.scheduler.mainScheduler import setMainLoop as setSchedulerMainLoop
setSchedulerMainLoop(main_loop)
except RuntimeError:
pass
subAutomationSchedule.start(eventUser) # Automation scheduler
subAutomation2Schedule.start(eventUser) # Automation2 schedule trigger (cron)
# Automation2 email poller: started on-demand when a run pauses for email.checkEmail
eventManager.start()
# Register audit log cleanup scheduler
from modules.shared.auditLogger import registerAuditLogCleanupScheduler
registerAuditLogCleanupScheduler()
# Ensure billing settings and accounts exist for all mandates
try:
from modules.interfaces.interfaceDbBilling import _getRootInterface as getBillingRootInterface
billingInterface = getBillingRootInterface()
# Step 1: Ensure all mandates have billing settings (creates defaults if missing)
settingsCreated = billingInterface.ensureAllMandateSettingsExist()
if settingsCreated > 0:
logger.info(f"Billing startup: Created {settingsCreated} missing mandate billing settings")
# Step 2: Ensure all users have billing audit accounts
accountsCreated = billingInterface.ensureAllUserAccountsExist()
if accountsCreated > 0:
logger.info(f"Billing startup: Created {accountsCreated} missing user accounts")
except Exception as e:
logger.warning(f"Failed to ensure billing settings/accounts (non-critical): {e}")
yield
# --- Stop Managers ---
stopAutomation2EmailPoller(eventUser) # Automation2 email poller (no-op if not running)
subAutomation2Schedule.stop(eventUser) # Automation2 schedule
eventManager.stop()
subAutomationSchedule.stop(eventUser) # Automation scheduler
# --- Stop Feature Containers (Plug&Play) ---
try:
@ -516,6 +490,16 @@ from modules.auth import (
ProactiveTokenRefreshMiddleware,
)
# i18n language detection middleware (sets per-request language from Accept-Language header)
from modules.shared.i18nRegistry import _setLanguage, normalizePrimaryLanguageTag
@app.middleware("http")
async def _i18nMiddleware(request: Request, call_next):
acceptLang = request.headers.get("Accept-Language", "")
lang = normalizePrimaryLanguageTag(acceptLang, "de")
_setLanguage(lang)
return await call_next(request)
app.add_middleware(CSRFMiddleware)
# Token refresh middleware (silent refresh for expired OAuth tokens)
@ -586,27 +570,15 @@ app.include_router(voiceGoogleRouter)
from modules.routes.routeVoiceUser import router as voiceUserRouter
app.include_router(voiceUserRouter)
from modules.routes.routeSecurityAdmin import router as adminSecurityRouter
app.include_router(adminSecurityRouter)
from modules.routes.routeSharepoint import router as sharepointRouter
app.include_router(sharepointRouter)
from modules.routes.routeAdminAutomationEvents import router as adminAutomationEventsRouter
app.include_router(adminAutomationEventsRouter)
from modules.routes.routeAdminAutomationLogs import router as adminAutomationLogsRouter
app.include_router(adminAutomationLogsRouter)
from modules.routes.routeAdminLogs import router as adminLogsRouter
app.include_router(adminLogsRouter)
from modules.routes.routeAdminRbacRules import router as rbacAdminRulesRouter
app.include_router(rbacAdminRulesRouter)
from modules.routes.routeMessaging import router as messagingRouter
app.include_router(messagingRouter)
from modules.routes.routeAdminFeatures import router as featuresAdminRouter
app.include_router(featuresAdminRouter)
@ -619,12 +591,15 @@ app.include_router(invitationsRouter)
from modules.routes.routeNotifications import router as notificationsRouter
app.include_router(notificationsRouter)
from modules.routes.routeAdminRbacExport import router as rbacAdminExportRouter
app.include_router(rbacAdminExportRouter)
from modules.routes.routeI18n import router as i18nRouter
app.include_router(i18nRouter)
from modules.routes.routeAdminUserAccessOverview import router as userAccessOverviewRouter
app.include_router(userAccessOverviewRouter)
from modules.routes.routeAdminDemoConfig import router as demoConfigRouter
app.include_router(demoConfigRouter)
from modules.routes.routeGdpr import router as gdprRouter
app.include_router(gdprRouter)
@ -641,6 +616,9 @@ from modules.routes.routeSystem import router as systemRouter, navigationRouter
app.include_router(systemRouter)
app.include_router(navigationRouter)
from modules.routes.routeWorkflowDashboard import router as workflowDashboardRouter
app.include_router(workflowDashboardRouter)
# ============================================================================
# PLUG&PLAY FEATURE ROUTERS
# Dynamically load routers from feature containers in modules/features/

View file

@ -45,6 +45,11 @@ Connector_StacSwisstopo_MAX_RETRIES = 3
Connector_StacSwisstopo_RETRY_DELAY = 1.0
Connector_StacSwisstopo_ENABLE_CACHE = True
# Demo RMA credentials (same for all demo trustee instances)
Demo_RMA_ApiBaseUrl = https://service.int.runmyaccounts.com/api/latest/clients/
Demo_RMA_ClientName = poweronag
Demo_RMA_ApiKey = pat_tipTbnHU26CrMzAnLSjCR_uzHJv4CDNa7obaQGHIA-4
# Operator company information (shown on invoice emails)
Operator_CompanyName = PowerOn AG
Operator_Address = Birmensdorferstrasse 94, 8003 Zürich

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,256 @@
# PowerOn AI Platform - Investoren-Dokumentation
## Stand: 14. Oktober 2025
---
## Executive Summary
PowerOn ist eine Software, die Unternehmen dabei hilft, wiederkehrende Aufgaben zu automatisieren. Statt dass Mitarbeiter manuell Daten sammeln, Dokumente durcharbeiten und Berichte schreiben, übernimmt PowerOn diese Arbeiten.
### Das Problem, das PowerOn löst
Mitarbeiter verbringen 30% ihrer Arbeitszeit damit, Informationen zu suchen. Unternehmen haben Schwierigkeiten, große Dokumente zu analysieren, aktuelle Marktdaten zu sammeln und regelmäßige Berichte zu erstellen. PowerOn automatisiert diese Aufgaben.
### Wie PowerOn funktioniert
Ein Benutzer gibt eine Aufgabe ein, zum Beispiel "Lese meine Mails der internen Mailbox der letzten 2 Wochen, fasse diese pro Thema im Sharepoint Marketing Ordner zusammen und verfasse eine Antwort für die wichtigsten Kunden". PowerOn verbindet sich dann automatisch mit Outlook, SharePoint und anderen Systemen, sammelt die Daten, analysiert sie und erstellt die gewünschten Zusammenfassungen und Antworten.
### Gemessene Verbesserungen
Tests mit Pilotkunden zeigen:
- Marktanalysen: von 3-4 Wochen auf 3-5 Tage
- Berichterstellung: 62% Zeitersparnis
- Prototypenentwicklung: 70% schneller
- Dokumentenanalyse: 80% weniger Zeitaufwand
---
## 1. Kernfunktionen von PowerOn
### 1.1 Was PowerOn tatsächlich macht
PowerOn ist eine KI-gestützte Workflow-Engine, die drei verschiedene Arbeitsabläufe unterstützt:
**Dynamische Workflows**: PowerOn passt sich automatisch an neue Aufgaben an. Ein Benutzer kann jede beliebige Anfrage stellen, und das System findet den besten Weg zur Lösung.
**Action-Plan Workflows**: PowerOn plant komplexe Aufgaben selbstständig. Das System teilt große Projekte in kleinere Schritte auf und führt diese automatisch aus.
**Feste Geschäftsprozesse**: Unternehmen können standardisierte Abläufe definieren, die PowerOn immer gleich ausführt, zum Beispiel monatliche Berichte oder regelmäßige Marktanalysen.
### 1.2 Kernfunktionen
**Dokumentenanalyse**: Das System liest große Dokumente (PDF, Word, Excel) und extrahiert die wichtigsten Informationen. Ein 200-seitiger Vertrag wird automatisch zusammengefasst.
**Web-Recherche**: PowerOn sucht im Internet nach aktuellen Informationen zu einem Thema und sammelt relevante Daten von verschiedenen Websites.
**Berichterstellung**: Basierend auf den gesammelten Daten und Dokumenten erstellt das System fertige Berichte in verschiedenen Formaten (PDF, Word, Excel).
**Code-Generierung**: PowerOn kann einfache Programme und Skripte erstellen, um wiederkehrende Aufgaben zu automatisieren.
### 1.3 Wie der Arbeitsablauf funktioniert
Ein Benutzer gibt eine Aufgabe ein, zum Beispiel "Analysiere die Konkurrenz im E-Mobilitätssektor". PowerOn führt dann automatisch folgende Schritte aus:
1. Sucht im Internet nach aktuellen Informationen über E-Mobilitätsunternehmen
2. Analysiert vorhandene interne Dokumente des Unternehmens
3. Erstellt einen strukturierten Bericht mit den wichtigsten Erkenntnissen
4. Stellt den Bericht in verschiedenen Formaten zur Verfügung
### 1.4 Technische Besonderheiten
**Keine Größenbeschränkungen**: PowerOn kann beliebig große Dokumente verarbeiten und unbegrenzt viele Berichte erstellen. Das System umgeht die normalen Grenzen von KI-Systemen durch intelligente Aufteilung.
**Automatische Datenschutz-Funktion**: Sensible Daten werden automatisch erkannt und vor der Verarbeitung entfernt. Nach der Analyse werden die Daten wieder eingefügt, sodass der Bericht vollständig ist, aber keine vertraulichen Informationen preisgegeben werden.
**Mehrere KI-Anbieter**: PowerOn arbeitet gleichzeitig mit verschiedenen KI-Systemen (OpenAI, Anthropic, Perplexity). Wenn ein System ausfällt oder überlastet ist, übernimmt automatisch ein anderes. Das gewährleistet einen stabilen Betrieb und macht das System unabhängig von einzelnen Anbietern.
**Sicherheit**: Jedes Unternehmen hat einen eigenen, abgeschotteten Bereich. Alle Aktivitäten werden protokolliert.
---
## 2. Warum PowerOn anders ist
### 2.1 Keine technischen Grenzen
Andere KI-Systeme haben strenge Beschränkungen: maximal 50 Seiten Dokument, höchstens 10 Berichte pro Monat. PowerOn hat diese Grenzen nicht. Das System kann 1000-seitige Verträge analysieren und hunderte Berichte erstellen, ohne zusätzliche Kosten.
### 2.2 Automatischer Datenschutz
PowerOn erkennt automatisch sensible Daten wie Namen, Adressen oder Kontonummern und entfernt sie vor der Verarbeitung. Nach der Analyse werden die Daten wieder eingefügt. So entstehen vollständige Berichte ohne Datenschutzverletzungen.
### 2.3 Stabile und unabhängige Technologie
PowerOn arbeitet mit mehreren KI-Anbietern gleichzeitig. Wenn ein System ausfällt, übernimmt automatisch ein anderes. Das reduziert Ausfallzeiten und macht das Unternehmen unabhängig von einzelnen Anbietern.
### 2.4 Direkte Integration in Unternehmenssysteme
PowerOn verbindet sich direkt mit den Systemen, die Unternehmen täglich nutzen:
- **E-Mail-Systeme**: Outlook, Gmail für automatische E-Mail-Analyse
- **Dokumentenmanagement**: SharePoint, Google Drive für Dateizugriff
- **Projektmanagement**: Jira, ClickUp für Aufgabenverwaltung
- **Cloud-Speicher**: OneDrive, Dropbox für Dateiintegration
Statt dass Mitarbeiter Daten manuell zwischen verschiedenen Systemen kopieren, arbeitet PowerOn direkt mit allen Systemen zusammen.
### 2.5 Drei verschiedene Arbeitsweisen
**Dynamisch**: PowerOn passt sich an jede neue Aufgabe an. Ein Benutzer kann jede beliebige Anfrage stellen.
**Action-Plan**: PowerOn plant komplexe Projekte selbstständig und teilt sie in machbare Schritte auf.
**Standardisiert**: Unternehmen können feste Abläufe definieren, die PowerOn immer gleich ausführt.
### 2.6 Einfache Bedienung
Mitarbeiter müssen nicht programmieren können. Sie geben einfach ein, was sie brauchen, und PowerOn macht den Rest. Ein Marketing-Manager kann eine Konkurrenzanalyse bestellen, ohne IT-Kenntnisse zu haben.
---
## 3. Markt und Geschäftsmodell
### 3.1 Zielkunden
PowerOn richtet sich hauptsächlich an mittelständische Unternehmen mit 50-500 Mitarbeitern. Diese Unternehmen haben oft komplexe Datenverarbeitungsanforderungen, aber nicht die Ressourcen, um eigene KI-Systeme zu entwickeln.
Typische Kunden sind Beratungsunternehmen, Banken, Versicherungen, Kliniken und andere Dienstleister, die regelmäßig Analysen und Berichte erstellen müssen.
### 3.2 Nutzen für Kunden
#### Gemessene Verbesserungen
Basierend auf Tests mit Pilotkunden:
- Marktanalysen werden 73% schneller durchgeführt (von 3-4 Wochen auf 3-5 Tage)
- Berichterstellung spart 62% Zeit ein
- Prototypenentwicklung ist 70% schneller
- Dokumentenanalyse reduziert den Zeitaufwand um 80%
- Kosteneinsparung von 5.000-8.000 Euro pro Marktanalyse
#### Praktische Vorteile
Mitarbeiter benötigen keine Programmierkenntnisse, um PowerOn zu nutzen. Das System arbeitet mit vorhandenen Daten und Systemen zusammen, ohne dass große Umstellungen erforderlich sind.
### 3.3 Einnahmemodelle
PowerOn plant verschiedene Einnahmequellen:
1. Monatliche Abonnements pro Benutzer
2. Nutzungsbasierte Abrechnung für Verarbeitungsleistungen
3. Individuelle Lizenzen für große Unternehmen
4. Beratungs- und Implementierungsdienstleistungen
Die genauen Preise werden basierend auf Marktanalysen festgelegt. Das Ziel ist eine Bruttomarge von 75-85% nach der Skalierung.
---
## 4. Risiken und Zukunftssicherheit
### 4.1 Risiken durch bessere KI-Systeme
#### Kurzfristige Risiken (6-12 Monate)
Wenn KI-Systeme besser werden, könnten einfache Aufgaben wie Textgenerierung zur Standardware werden. Dies könnte den Wert einzelner KI-Funktionen reduzieren. PowerOn ist jedoch darauf ausgelegt, verschiedene KI-Systeme zu koordinieren, was auch bei verbesserten Systemen wertvoll bleibt.
#### Mittelfristige Risiken (1-3 Jahre)
Einzelne KI-Systeme könnten in der Lage sein, mehr Aufgaben gleichzeitig zu erledigen. Dies könnte die Notwendigkeit der Koordination reduzieren. PowerOn konzentriert sich jedoch auf spezifische Unternehmensanforderungen und die Integration in bestehende Systeme, was weiterhin wertvoll ist.
#### Langfristige Risiken (3+ Jahre)
Sehr fortgeschrittene KI-Systeme könnten in der Lage sein, komplexe Aufgaben ohne Koordination zu lösen. PowerOn konzentriert sich jedoch auf die spezifischen Anforderungen von Unternehmen, einschließlich Sicherheit, Compliance und Integration, die auch bei fortgeschrittenen KI-Systemen wichtig bleiben.
### 4.2 Was könnte obsolet werden
Einfache Aufgaben wie grundlegende Textgenerierung oder Web-Suche könnten zu Standardfunktionen werden. Auch einfache Datenanalysen könnten automatisiert werden.
### 4.3 Was bleibt wertvoll
Die Koordination verschiedener Systeme, die Integration in Unternehmensprozesse und die Einhaltung von Sicherheits- und Datenschutzbestimmungen bleiben auch bei verbesserten KI-Systemen wichtig. PowerOn ist so aufgebaut, dass es sich an neue Technologien anpassen kann, ohne das gesamte System neu entwickeln zu müssen.
---
## 5. Finanzielle Bewertung
### 5.1 Aktuelle Bewertung der Komponenten
PowerOn besteht aus mehreren wertvollen Komponenten, die einzeln bewertet werden können:
**Frontend-System**: €150.000-250.000
- Modulare Benutzeroberfläche, die einfach erweitert werden kann
- Funktioniert in allen gängigen Browsern
- Anpassbar an verschiedene Unternehmensanforderungen
**Backend-Infrastruktur**: €200.000-300.000
- Stabile Grundstruktur für alle Funktionen
- Schnelle Verarbeitung auch bei großen Datenmengen
- Einfache Integration neuer Funktionen
**Workflow-System**: €250.000-350.000
- Kernfunktion für die Koordination verschiedener Aufgaben
- Drei verschiedene Arbeitsweisen (dynamisch, Action-Plan, standardisiert)
- Automatische Anpassung an neue Anforderungen
**Sicherheits- und Datenschutz-System**: €100.000-150.000
- Automatische Erkennung und Schutz sensibler Daten
- Verschiedene Anmeldeverfahren für Unternehmen
- Vollständige Protokollierung aller Aktivitäten
**Datenverarbeitungs-Engine**: €150.000-200.000
- Verarbeitung beliebig großer Dokumente
- Intelligente Aufteilung zur Umgehung von KI-Grenzen
- Unterstützung aller gängigen Dateiformate
**Multi-Agent-Koordinationssystem**: €300.000-400.000
- Einzigartige Technologie zur Koordination verschiedener KI-Systeme
- Automatische Auswahl des besten KI-Anbieters für jede Aufgabe
- Stabile Ausführung auch bei Ausfällen einzelner Systeme
**Unternehmens-Integration**: €200.000-300.000
- Anpassung an verschiedene Branchen und Anforderungen
- Einfache Integration in bestehende Unternehmenssysteme
- Skalierbare Architektur für wachsende Anforderungen
**Integrations-Framework**: €150.000-200.000
- Verbindungen zu verschiedenen KI-Anbietern (OpenAI, Anthropic, Perplexity)
- Direkte Integration in Unternehmenssysteme (Outlook, SharePoint, Google Drive, Jira)
- Einfache Integration neuer Systeme und Anbieter
- Unabhängigkeit von einzelnen Anbietern
**Workflow-Management-System**: €100.000-150.000
- Plan-Act-Observe-Refine-Zyklus für kontinuierliche Verbesserung
- Echtzeit-Überwachung des Arbeitsfortschritts
- Automatische Fehlerbehandlung und Wiederaufnahme
**Gesamtbewertung**: €1.6-2.4 Mio.
### 5.2 Investitionsbedarf
PowerOn benötigt Investitionsmittel, um die Entwicklung abzuschließen und den Markt zu erschließen. Die Mittel werden hauptsächlich für die Produktentwicklung, den Aufbau eines Vertriebsteams und die Infrastruktur verwendet.
### 5.3 Wachstumspotenzial
Das System ist darauf ausgelegt, mit wachsenden Anforderungen zu skalieren. Die modulare Architektur ermöglicht es, neue Funktionen hinzuzufügen und die Plattform an verschiedene Kundenanforderungen anzupassen.
---
## 6. Marktpotenzial und Ausstiegsmöglichkeiten
### 6.1 Marktpotenzial
Der Markt für KI-basierte Geschäftsanwendungen wächst schnell. Unternehmen suchen nach Lösungen, die komplexe Aufgaben automatisieren und die Effizienz steigern können. PowerOn positioniert sich in diesem wachsenden Markt.
### 6.2 Ausstiegsmöglichkeiten
Langfristig gibt es verschiedene Möglichkeiten für einen Ausstieg, darunter den Verkauf an größere Softwareunternehmen oder den Börsengang. Diese Optionen hängen von der Entwicklung des Unternehmens und des Marktes ab.
---
## 7. Fazit
### 7.1 Stärken von PowerOn
PowerOn bietet eine einzigartige Lösung für die Koordination verschiedener KI-Systeme. Das System ist darauf ausgelegt, sich an neue Technologien anzupassen, und bietet nachgewiesene Verbesserungen bei Geschäftsprozessen.
### 7.2 Risikofaktoren
Die schnelle Entwicklung der KI-Technologie stellt ein Risiko dar, da einfache Aufgaben möglicherweise obsolet werden. Der Wettbewerb durch größere Unternehmen und die Marktakzeptanz sind weitere Faktoren, die berücksichtigt werden müssen.
### 7.3 Investitionsbewertung
PowerOn befindet sich in einer frühen Entwicklungsphase mit einem funktionsfähigen Grundsystem. Das Potenzial für Wachstum ist vorhanden, aber es gibt auch erhebliche Risiken, die mit der Entwicklung neuer Technologien verbunden sind.
---
*Dokument erstellt am 14. Oktober 2025*
*Version: 1.0*
*Autor: PowerOn Development Team*

View file

@ -0,0 +1,175 @@
# PowerOn AI Platform
## Investoren-Summary
### Marktpositionierung
Die PowerOn AI Platform ist eine innovative Enterprise-Lösung für die Automatisierung und Optimierung von komplexen geschäftlichen Prozessen durch einen Multi-Agent-KI-Ansatz. Wir positionieren uns an der Schnittstelle zwischen den schnell wachsenden Märkten für:
- Künstliche Intelligenz (Marktvolumen 2025: $190 Mrd.)
- Business Process Automation (Marktvolumen 2025: $19,6 Mrd.)
- Enterprise Knowledge Management (Marktvolumen 2025: $43 Mrd.)
### Wettbewerbsvorteile
1. **Proprietäre Multi-Agent-Technologie**: Unsere Plattform orchestriert spezifische KI-Agenten für verschiedene Aufgaben, was zu deutlich überlegenen Ergebnissen im Vergleich zu Einzelagenten-Ansätzen führt.
2. **Modellunabhängigkeit**: Integration mit führenden KI-Providern (OpenAI, Anthropic) ohne Vendor Lock-in, wodurch wir immer die besten Modelle für spezifische Aufgaben einsetzen können.
3. **Enterprise-Ready**: Entwickelt mit Multi-Tenant-Architektur, umfassenden Sicherheitsfeatures und Skalierbarkeit für Unternehmensanforderungen.
4. **Anpassbar und erweiterbar**: Modulare Architektur, die kontinuierliche Feature-Erweiterungen und kundenspezifische Anpassungen ermöglicht.
5. **Fortschrittliche Workflow-Orchestrierung**:
- Intelligente Koordination mehrerer spezialisierter Agenten
- Echtzeit-Statusüberwachung und Fortschrittsanzeige
- Robuste Fehlerbehandlung und Wiederaufnahmemechanismen
- Nahtlose Integration von Dateiverarbeitung und Dokumentenmanagement
6. **Umfassende Enterprise-Features**:
- Multi-Tenant-Architektur mit Mandantenverwaltung
- Erweiterte Benutzer- und Berechtigungsverwaltung
- Enterprise-Grade Sicherheitsfeatures
- Skalierbare Infrastruktur
### Finanzielle Highlights
- **Go-to-Market-Strategie**: Initiale Fokussierung auf mittelständische Unternehmen in den Bereichen Professional Services, Finanzdienstleistungen und Gesundheitswesen.
- **Umsatzmodell**: Kombiniertes SaaS-Abonnement (pro Benutzer/Monat) und nutzungsbasierte Abrechnung (pro Verarbeitungseinheit).
- **Erwartete Bruttomarge**: 75-85% nach Erreichen der Skalierung.
- **Erwartetes ARR in Jahr 3**: €4,5 Mio. bei 150 Unternehmenskunden.
- **Kostenstrukturen**:
- 40% Produktentwicklung
- 30% Vertrieb und Marketing
- 20% Betrieb und Support
- 10% Verwaltung
### Wachstumspfad
#### Kurzfristig (12 Monate)
- Markteinführung der Core-Plattform
- Aufbau von 3-5 Schlüsselreferenzkunden
- Entwicklung branchenspezifischer Templates
#### Mittelfristig (24 Monate)
- Erweiterung auf Agentenmarktplatz
- Integration von proprietären Unternehmensmodellen
- Internationale Expansion
#### Langfristig (36+ Monate)
- Entwicklung spezialisierter Branchenlösungen
- KI-Middleware für Unternehmen
- Strategische Partnerschaften mit Enterprise-Software-Anbietern
### Investitionsbedarf
Das aktuelle Finanzierungsziel von CHF 2.5 Mio. ermöglicht:
- Abschluss der Produktentwicklung und Erreichen der Marktreife
- Aufbau eines Vertriebs- und Marketingteams
- Sicherung strategischer Partnerschaften
- 18-monatige Runway bis zur Profitabilität
### Exit-Potenzial
Das Team sieht folgende Exit-Optionen:
1. Strategische Übernahme durch Enterprise-Software-Unternehmen (5-7 Jahre)
2. Erwerb durch grössere KI-Plattform (3-5 Jahre)
3. IPO bei Erreichen von CHF 50+ Mio. ARR (7-10 Jahre)
### Extraktion aus ValueOn AG
Vor einem Exit ist die Extraktion der PowerOn AI Platform aus der ValueOn AG in eine eigenständige Organisation vorgesehen:
1. **Vergütung der Aufwände**:
- Vollständige Vergütung aller übernommenen Entwicklungskosten
- Übernahme der Infrastruktur- und Betriebskosten
- Schadloshaltung für alle bisherigen Investitionen
- Marketing & Sales-Assets verbleiben bei ValueOn AG ohne Vergütung
2. **Schlüsselpersonen**:
- Anrechnung des geschaffenen Mehrwerts für jede Schlüsselperson
- Option auf Auszahlung oder Aktienübernahme
- Individuelle Vereinbarungen basierend auf Beitrag und Verantwortung
- Langfristige Bindung durch Equity-Programme
3. **Investitionskapital**:
- Beschaffung des notwendigen Kapitals zum aktuellen Marktwert
- Berücksichtigung der Extraktionskosten
- Sicherstellung der operativen Liquidität
- Finanzierung des weiteren Wachstums
Die Extraktion wird durchgeführt, sobald:
- Die technische Basis stabil ist
- Erste Referenzkunden gewonnen wurden
- Die Marktpositionierung klar ist
- Die Wachstumsstrategie definiert ist
### Marktwert und Bewertung
#### Aktueller Wert (Juni 2025)
Basierend auf dem aktuellen Entwicklungsstand und der technologischen Basis:
1. **Technologischer Wert**:
- Basis-Frontend-Architektur (modular, aber noch in Entwicklung): CHF 0.15-0.25 Mio.
- Backend-Grundstruktur (FastAPI, Basis-Interfaces): CHF 0.2-0.3 Mio.
- Workflow-System (Grundfunktionalität): CHF 0.25-0.35 Mio.
2. **Funktionaler Wert**:
- Basis-Workflow-Orchestrierung: CHF 0.1-0.15 Mio.
- Einfache Dokumentenverarbeitung: CHF 0.05-0.1 Mio.
- Grundlegende Benutzerverwaltung: CHF 0.05-0.1 Mio.
3. **Entwicklungspotenzial**:
- Erweiterbare Architektur: CHF 0.15-0.2 Mio.
- Modulare Struktur: CHF 0.1-0.15 Mio.
- Basis für zukünftige Erweiterungen: CHF 0.15-0.2 Mio.
**Aktuelle Gesamtbewertung**: CHF 1.2-1.8 Mio.
Diese Bewertung basiert auf:
- Dem aktuellen Entwicklungsstand (Frontend und Backend)
- Der vorhandenen Grundfunktionalität
- Der modularen Basis-Architektur
- Dem Entwicklungspotenzial
#### Wert per Ende 2025
Prognostizierte Bewertung basierend auf:
- Vervollständigung der Core-Funktionalität
- Erste Referenzkunden
- Erweiterte Workflow-Funktionen
- Verbesserte Benutzeroberfläche
**Prognostizierte Bewertung Ende 2025**: CHF 2-3 Mio.
#### Wert per Ende 2026
Prognostizierte Bewertung basierend auf:
- Vollständige Multi-Agent-Implementierung
- Erweiterte Integrationen
- Wachsende Kundenbasis
- Erwartetes ARR von CHF 4,5 Mio.
**Prognostizierte Bewertung Ende 2026**: CHF 4-6 Mio.
Die Wertsteigerung wird getrieben durch:
1. **Technologische Entwicklung**:
- Vervollständigung der Agenten-Implementierung
- Erweiterung der Workflow-Funktionalitäten
- Verbesserung der Integrationen
2. **Marktentwicklung**:
- Aufbau der Kundenbasis
- Entwicklung von Branchenlösungen
- Erste internationale Expansion
3. **Geschäftsentwicklung**:
- Wachsende Umsätze
- Verbesserte Margen
- Neue Geschäftsmodelle
4. **Strategische Positionierung**:
- Etablierung in Nischenmärkten
- Aufbau von Partnerschaften
- Entwicklung proprietärer Technologien

View file

@ -0,0 +1,799 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="/poweron-favicon.png" type="image/png">
<title>PowerOn Platform - Big Picture | PowerON</title>
<meta name="description" content="PowerON Platform Architecture - Big Picture for External Developers">
<meta name="author" content="PowerON">
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,wght@0,400;0,500;0,700;1,400&display=swap" rel="stylesheet">
<!-- Styles -->
<link rel="stylesheet" href="doc_platform_big_picture.css">
</head>
<body>
<div class="header">
<div class="navbar">
<a href="/" class="logo">
<img src="logo2.png" alt="PowerON" class="logo-img" onerror="this.style.display='none'; this.nextElementSibling.style.display='inline';">
</a>
<nav>
<span class="nav-title">Platform Architecture</span>
</nav>
</div>
</div>
<div class="container">
<div class="hero">
<h1>PowerOn Platform - Big Picture</h1>
<p class="subtitle">Enterprise AI Workflow Platform with Integrated Data Privacy Neutralizer</p>
<p class="intro">This document provides an overview of the PowerOn platform architecture, building blocks, and capabilities for external software developers who want to contribute to or integrate with the platform.</p>
</div>
<!-- Tabs Navigation -->
<div class="tabs">
<button class="tab-button active" onclick="openTab(event, 'overview')">Overview</button>
<button class="tab-button" onclick="openTab(event, 'customer-story')">Customer Story</button>
<button class="tab-button" onclick="openTab(event, 'workflows')">Workflows</button>
<button class="tab-button" onclick="openTab(event, 'services')">Microservices</button>
<button class="tab-button" onclick="openTab(event, 'rbac')">RBAC System</button>
<button class="tab-button" onclick="openTab(event, 'ui')">UI Architecture</button>
<button class="tab-button" onclick="openTab(event, 'big-picture')">Big Picture</button>
<button class="tab-button" onclick="openTab(event, 'integration')">Integration</button>
</div>
<!-- Tab Content -->
<div id="overview" class="tab-content active">
<h2>Platform Overview</h2>
<div class="section">
<h3>Core Concept</h3>
<p>PowerOn is a <strong>Multi-Agent AI Platform for Enterprise Workflows</strong> with an integrated data privacy neutralizer. The platform enables companies to accelerate their AI transformation without data privacy risks.</p>
<div class="highlight-box">
<h4>Key Value Propositions</h4>
<ul>
<li><strong>Data Privacy First:</strong> Integrated privacy neutralizer enables safe use of ChatGPT/Copilot without privacy risks</li>
<li><strong>Unlimited Processing:</strong> No token limits - process documents of any size through intelligent chunking</li>
<li><strong>Universal Integration:</strong> Seamless integration of all enterprise data sources</li>
<li><strong>Workflow Automation:</strong> Configure workflows per customer journey with standard automation elements and AI components</li>
<li><strong>Future-Proof Architecture:</strong> Automatically improves with better AI models and larger token limits</li>
<li><strong>Plug & Play Architecture:</strong> Renderers and dynamic AI selection per intention (analyze, generate, web, plan, etc.)</li>
</ul>
</div>
</div>
<div class="section">
<h3>Architecture Layers</h3>
<div class="architecture-diagram">
<div class="layer">
<h4>UI Layer (Playground)</h4>
<p>React-based playground UI as entry point. Additional UIs (chatbots, customer UIs) can be easily integrated via REST API in React, JavaScript, or other languages.</p>
</div>
<div class="layer">
<h4>API Layer</h4>
<p>RESTful API providing full access to platform capabilities. Open API design allows external UIs and integrations.</p>
</div>
<div class="layer">
<h4>Workflow Engine</h4>
<p>Core orchestration engine managing tasks, actions, and state. Supports multiple execution modes (Learning, Actionplan, Automation).</p>
</div>
<div class="layer">
<h4>Microservices Layer</h4>
<p>Modular service architecture with specialized services for AI, data processing, security, and integrations.</p>
</div>
<div class="layer">
<h4>Data Layer</h4>
<p>Multi-tenant database with RBAC-based access control. Mandate isolation ensures secure data separation.</p>
</div>
</div>
</div>
<div class="section">
<h3>Customer Journey → Workflow</h3>
<p>For each customer journey, a workflow can be configured in the workflow editor where:</p>
<ul>
<li>Customers integrate their data sources</li>
<li>Standard automation elements are available</li>
<li>AI components can be used</li>
<li>Workflows can be executed manually or automated (hourly/daily/weekly)</li>
</ul>
</div>
<div class="section">
<h3>Plug & Play Architecture</h3>
<div class="feature-grid">
<div class="feature-card">
<h4>Dynamic Renderers</h4>
<p>Plug & play architecture for document renderers. Support for multiple formats (PDF, DOCX, XLSX, PPTX, HTML, Markdown, JSON, CSV, etc.) with easy extension capabilities.</p>
</div>
<div class="feature-card">
<h4>Dynamic AI Selection</h4>
<p>Intelligent AI model selection per intention type. The system automatically selects the best AI model based on the task: analysis, generation, web research, planning, etc.</p>
</div>
</div>
</div>
<div class="section">
<h3>System Architecture Diagram</h3>
<div class="diagram-image-container">
<img src="doc_platform_01_platform_overview.jpg" alt="PowerON Platform Architecture Diagram" class="diagram-image">
</div>
</div>
</div>
<div id="customer-story" class="tab-content">
<h2>Customer Story</h2>
<div class="section">
<h3>The Journey from Application-Centric to Data-Centric Work</h3>
<p class="lead">PowerOn enables customers to transition from <strong>application-centric</strong> to <strong>data-centric</strong> work. This is a <strong>key differentiator</strong> that transforms how businesses operate.</p>
</div>
<div class="section">
<h3>Step 1: Customer Journey Identification</h3>
<div class="step-card">
<div class="step-number">1</div>
<div class="step-content">
<h4>Identify Business Processes</h4>
<p>Work with customers to identify their key customer journeys and business processes that can benefit from automation and AI.</p>
<ul>
<li>Document analysis workflows</li>
<li>Email processing and routing</li>
<li>Data extraction and transformation</li>
<li>Report generation</li>
<li>Customer communication workflows</li>
</ul>
</div>
</div>
</div>
<div class="section">
<h3>Step 2: MVP Integration with Focus on Data Privacy & Compliance</h3>
<div class="step-card">
<div class="step-number">2</div>
<div class="step-content">
<h4>Simple MVP Integration</h4>
<p>Start with a simple MVP that integrates customer data sources with <strong>strong focus on data privacy and compliance</strong>:</p>
<ul>
<li><strong>Data Privacy Neutralizer:</strong> Automatic anonymization of sensitive data before AI processing</li>
<li><strong>Compliance First:</strong> DSGVO/GDPR compliant processing from day one</li>
<li><strong>Secure Connections:</strong> Encrypted connections to customer data sources (SharePoint, Google Drive, Outlook, etc.)</li>
<li><strong>Mandate Isolation:</strong> Complete data separation between tenants</li>
<li><strong>Audit Logging:</strong> Full traceability of all data access and processing</li>
</ul>
<p class="highlight-text">This step builds trust and demonstrates the platform's commitment to data security.</p>
</div>
</div>
</div>
<div class="section">
<h3>Step 3: Pre-Processing Engine Deployment</h3>
<div class="step-card">
<div class="step-number">3</div>
<div class="step-content">
<h4>Standard API Pre-Processing</h4>
<p>Deploy a pre-processing engine at the customer's location using a <strong>standard API</strong>:</p>
<ul>
<li><strong>On-Premise/Edge Processing:</strong> Data processing happens at the customer's location</li>
<li><strong>Standard API:</strong> Consistent interface for all customers</li>
<li><strong>Data Minimization:</strong> Only necessary data is sent to the platform</li>
<li><strong>Local Neutralization:</strong> Privacy neutralization can happen before data leaves customer premises</li>
<li><strong>Reduced Latency:</strong> Faster processing for large documents</li>
</ul>
<p class="highlight-text">This step further enhances data privacy and gives customers full control over their data processing.</p>
</div>
</div>
</div>
<div class="section">
<h3>Step 4: Gradual Component Integration - The Transformation</h3>
<div class="step-card">
<div class="step-number">4</div>
<div class="step-content">
<h4>From Application-Centric to Data-Centric</h4>
<p>Gradually integrate additional components until the customer works <strong>data-centrically</strong> instead of <strong>application-centrically</strong>:</p>
<div class="transformation-comparison">
<div class="comparison-box old">
<h5>❌ Application-Centric (Old Way)</h5>
<ul>
<li>Work within individual applications (Word, Excel, SharePoint, Outlook)</li>
<li>Manual data transfer between applications</li>
<li>Data silos in different systems</li>
<li>Workflows are application-bound</li>
<li>Difficult to automate across applications</li>
</ul>
</div>
<div class="comparison-box new">
<h5>✅ Data-Centric (PowerOn Way)</h5>
<ul>
<li>Work with data directly, regardless of source application</li>
<li>Automatic data integration across all sources</li>
<li>Unified data view across all systems</li>
<li>Workflows span multiple applications seamlessly</li>
<li>Easy automation across entire data ecosystem</li>
</ul>
</div>
</div>
<p class="highlight-text"><strong>This transformation is a KEY DIFFERENTIATOR!</strong> Customers no longer think in terms of applications, but in terms of their data and business processes.</p>
</div>
</div>
</div>
<div class="section">
<h3>Customer Journey Diagram</h3>
<div class="diagram-image-container">
<img src="doc_platform_02_customer_story.jpg" alt="Customer Story - Journey from Application-Centric to Data-Centric" class="diagram-image">
</div>
</div>
</div>
<div id="workflows" class="tab-content">
<h2>Workflow System</h2>
<div class="section">
<h3>Core Concept: Tasks with Actions</h3>
<p class="lead">The core building block is <strong>workflow elements: tasks with actions</strong>. Each workflow consists of tasks, and each task contains one or more actions that execute specific operations.</p>
<div class="workflow-structure">
<div class="workflow-item">
<h4>Workflow</h4>
<p><strong>Definition:</strong> Top-level container representing a complete customer journey or business process.</p>
<p><strong>Purpose:</strong> Orchestrates multiple tasks to achieve a business goal.</p>
</div>
<div class="workflow-item">
<h4>Task</h4>
<p><strong>Definition:</strong> A logical step in the workflow.</p>
<p><strong>Purpose:</strong> Groups related actions that work together to complete a sub-goal.</p>
</div>
<div class="workflow-item">
<h4>Action</h4>
<p><strong>Definition:</strong> Executable unit that performs a specific operation.</p>
<p><strong>Purpose:</strong> Actions belong to methods (microservices) and are the atomic units of work.</p>
</div>
</div>
</div>
<div class="section">
<h3>Execution Modes</h3>
<p class="lead">PowerOn supports three execution modes, each optimized for different use cases:</p>
<div class="mode-grid">
<div class="mode-card">
<h4>Learning Mode</h4>
<p><strong>Best for:</strong> Exploratory tasks with up to 5 steps</p>
<p><strong>Approach:</strong> Iterative Plan-Act-Observe-Refine loop</p>
<p><strong>Use Case:</strong> When the solution path is not fully known in advance</p>
</div>
<div class="mode-card">
<h4>Actionplan Mode</h4>
<p><strong>Best for:</strong> Structured, sequential processes</p>
<p><strong>Approach:</strong> Batch planning with sequential execution</p>
<p><strong>Use Case:</strong> When the workflow steps are well-defined</p>
</div>
<div class="mode-card">
<h4>Automation Mode</h4>
<p><strong>Best for:</strong> Repetitive, predefined workflows</p>
<p><strong>Approach:</strong> Automated execution (scheduled or event-triggered)</p>
<p><strong>Use Case:</strong> Production workflows that run automatically</p>
</div>
</div>
</div>
<div class="section">
<h3>Available Workflow Methods</h3>
<p class="lead">Workflow methods provide actions that can be executed within workflows. Each method exposes multiple actions accessible via <code>self.services.&lt;method&gt;.&lt;action&gt;</code>:</p>
<ul>
<li><strong>ai.*</strong> - AI operations (process, analyze, generate)</li>
<li><strong>sharepoint.*</strong> - SharePoint integration (search, read, upload)</li>
<li><strong>outlook.*</strong> - Outlook integration (read emails, send emails)</li>
<li><strong>context.*</strong> - Context management (get context, set context)</li>
</ul>
</div>
<div class="section">
<h3>Workflow System Diagram</h3>
<div class="diagram-image-container">
<img src="doc_platform_03_workflow_system.jpg" alt="Workflow System - Structure, Execution Modes, and Available Methods" class="diagram-image">
</div>
</div>
</div>
<div id="services" class="tab-content">
<h2>Microservices Architecture</h2>
<div class="section">
<h3>Service Access Pattern</h3>
<p class="lead">All microservices are accessible via <code>self.services.&lt;serviceName&gt;</code>. Services follow a consistent access pattern and are organized into logical categories.</p>
</div>
<div class="section">
<h3>Services Structure Tree</h3>
<p>Complete overview of all available microservices:</p>
<div class="services-tree">
<div class="service-category">
<h4>Core Services</h4>
<ul>
<li><code>self.services.chat</code> - Chat and conversation management
<ul>
<li>Progress logging</li>
<li>Document management</li>
<li>Connection handling</li>
</ul>
</li>
<li><code>self.services.workflow</code> - Workflow state and management</li>
<li><code>self.services.utils</code> - Utility functions (timestamps, formatting, etc.)</li>
</ul>
</div>
<div class="service-category">
<h4>AI & Processing Services</h4>
<ul>
<li><code>self.services.ai</code> - AI model management and operations
<ul>
<li>Model selection</li>
<li>Prompt processing</li>
<li>Response handling</li>
</ul>
</li>
<li><code>self.services.generation</code> - Document generation
<ul>
<li>Multiple formats (PDF, DOCX, XLSX, PPTX, HTML, Markdown, etc.)</li>
<li>Template-based rendering</li>
<li>JSON schema support</li>
</ul>
</li>
<li><code>self.services.extraction</code> - Document extraction and processing
<ul>
<li>Multiple extractors (PDF, DOCX, XLSX, PPTX, CSV, HTML, XML, JSON, Images, etc.)</li>
<li>Intelligent chunking</li>
<li>Merging strategies</li>
</ul>
</li>
<li><code>self.services.neutralization</code> - Data privacy neutralization
<ul>
<li>PII detection and anonymization</li>
<li>Pattern-based neutralization</li>
<li>Binary and text processing</li>
</ul>
</li>
</ul>
</div>
<div class="service-category">
<h4>Integration Services</h4>
<ul>
<li><code>self.services.sharepoint</code> - SharePoint integration
<ul>
<li>Site discovery</li>
<li>File operations (read, upload, search)</li>
<li>Path resolution</li>
</ul>
</li>
<li><code>self.services.web</code> - Web operations
<ul>
<li>HTTP requests</li>
<li>Web scraping</li>
<li>API integration</li>
</ul>
</li>
<li><code>self.services.ticket</code> - Ticket system integration
<ul>
<li>Jira integration</li>
<li>ClickUp integration</li>
<li>Generic ticket operations</li>
</ul>
</li>
</ul>
</div>
<div class="service-category">
<h4>Security & Infrastructure</h4>
<ul>
<li><code>self.services.security</code> - Security operations
<ul>
<li>Authentication</li>
<li>Authorization</li>
<li>Token management</li>
</ul>
</li>
</ul>
</div>
</div>
</div>
<div class="section">
<h3>Code Examples</h3>
<p>Examples of how to use services in workflow actions or methods:</p>
<pre><code># In workflow actions or methods
result = await self.services.&lt;service&gt;.&lt;method&gt;(parameters)
# Example: Using AI service
response = await self.services.ai.process(prompt="Analyze this document", documents=[...])
# Example: Using SharePoint service
files = await self.services.sharepoint.searchFiles(pathQuery="sites/my-site/documents")
# Example: Using generation service
document = self.services.generation.createDocument(format="pdf", content={...})</code></pre>
</div>
<div class="section">
<h3>Microservices Architecture Diagram</h3>
<div class="diagram-image-container">
<img src="doc_platform_04_microservice_architecture.jpg" alt="Microservices Architecture - Core Services, AI & Processing, Integration Services, and Security" class="diagram-image">
</div>
</div>
</div>
<div id="rbac" class="tab-content">
<h2>RBAC System</h2>
<div class="section">
<h3>Overview</h3>
<p class="lead">The Role-Based Access Control (RBAC) system provides <strong>complete UI configuration per tenant and user</strong>. It enables fine-grained control over data access, UI visibility, and resource availability.</p>
<div class="feature-grid">
<div class="feature-card">
<h4>Data Access</h4>
<p>Table and field-level permissions for database operations. Control who can read, create, update, or delete specific data.</p>
</div>
<div class="feature-card">
<h4>UI Access</h4>
<p>Component and feature visibility management. Configure exactly which UI elements each user or role can see.</p>
</div>
<div class="feature-card">
<h4>Resource Access</h4>
<p>System resource availability control. Manage access to AI models, actions, and other platform resources.</p>
</div>
</div>
</div>
<div class="section">
<h3>Access Levels: Opening Logic</h3>
<p class="lead">For DATA context, the system uses <strong>opening rights</strong> with four access levels. These levels determine what data a user can access:</p>
<div class="access-levels">
<div class="access-level">
<h4>none (n)</h4>
<p>No access - item is completely hidden/disabled</p>
</div>
<div class="access-level">
<h4>my (m)</h4>
<p>My records - only records created by the current user</p>
</div>
<div class="access-level">
<h4>group (g)</h4>
<p>Group records - records within the same mandate (group context)</p>
</div>
<div class="access-level">
<h4>all (a)</h4>
<p>All records - full access to all records in the mandate</p>
</div>
</div>
</div>
<div class="section">
<h3>View Logic: Open + Close</h3>
<p class="lead">The <code>view</code> attribute controls visibility and enablement. This is the fundamental on/off switch for all RBAC contexts:</p>
<ul>
<li><strong>view: true</strong> - Item is visible/enabled</li>
<li><strong>view: false</strong> - Item is hidden/disabled (regardless of other permissions)</li>
</ul>
<p><strong>Key Rule:</strong> Only objects with <code>view: true</code> are shown. This applies to:</p>
<ul>
<li><strong>DATA Context:</strong> Controls whether tables/fields are accessible</li>
<li><strong>UI Context:</strong> Controls whether UI elements are visible</li>
<li><strong>RESOURCE Context:</strong> Controls whether resources are available</li>
</ul>
</div>
<div class="section">
<h3>Rule Specificity & Hierarchy</h3>
<p class="lead">The RBAC system uses a cascading hierarchy where more specific rules override generic ones:</p>
<ol>
<li><strong>Generic Rules</strong> (<code>item = null</code>) - Apply to all items in context</li>
<li><strong>Specific Rules</strong> (<code>item = "table.field"</code> or <code>item = "ui.component.feature"</code>) - Override generic rules</li>
</ol>
<p><strong>Resolution Logic:</strong> Within a single role, the most specific rule wins. Across multiple roles, opening (union) logic applies - if ANY role enables something, it is enabled.</p>
</div>
<div class="section">
<h3>Opening Rights Principle</h3>
<p class="lead">For DATA context, read permission (R) is a prerequisite for create/update/delete operations (CUD). This ensures data integrity and proper access control:</p>
<ul>
<li>If Read = "n": No CUD operations allowed</li>
<li>If Read = "m": CUD operations limited to "m" or "n"</li>
<li>If Read = "g": CUD operations limited to "g", "m", or "n"</li>
<li>If Read = "a": CUD operations can be "a", "g", "m", or "n"</li>
</ul>
<p><strong>Key Rule:</strong> You can ONLY create/update/delete if you have read right.</p>
</div>
<div class="section">
<h3>Context Types</h3>
<p class="lead">RBAC rules apply to three different context types, each serving a specific purpose:</p>
<div class="context-grid">
<div class="context-card">
<h4>DATA</h4>
<p>Database tables and fields. Controls read/create/update/delete permissions.</p>
<p><strong>Example:</strong> <code>item: "UserInDB.email"</code></p>
</div>
<div class="context-card">
<h4>UI</h4>
<p>UI elements and features. Controls component visibility.</p>
<p><strong>Example:</strong> <code>item: "playground.voice.settings"</code></p>
</div>
<div class="context-card">
<h4>RESOURCE</h4>
<p>System resources (AI models, actions, etc.). Controls resource availability.</p>
<p><strong>Example:</strong> <code>item: "ai.model.anthropic"</code></p>
</div>
</div>
</div>
<div class="section">
<h3>RBAC System Diagram</h3>
<div class="diagram-image-container">
<img src="doc_platform_05_rbac_system.jpg" alt="RBAC System - Contexts, Access Levels, View Logic, and Rule Hierarchy" class="diagram-image">
</div>
</div>
</div>
<div id="ui" class="tab-content">
<h2>UI Architecture</h2>
<div class="section">
<h3>Playground UI</h3>
<p class="lead">The <strong>Playground</strong> serves as the main entry point and demonstration UI. It's built with React and provides a comprehensive interface for workflow interaction:</p>
<ul>
<li>Chat interface for workflow interaction</li>
<li>Workflow editor for configuration</li>
<li>Document management</li>
<li>Connection management</li>
<li>Voice input/output capabilities</li>
</ul>
</div>
<div class="section">
<h3>RBAC-Driven UI Configuration</h3>
<p class="lead">The UI is <strong>completely configurable via RBAC rules</strong>. This allows customers to configure exactly the UI they need for their use case:</p>
<ul>
<li>Per tenant configuration</li>
<li>Per user configuration</li>
<li>Component-level visibility control</li>
<li>Feature-level access control</li>
</ul>
<p>This allows customers to configure exactly the UI they need for their use case.</p>
</div>
<div class="section">
<h3>External UI Integration</h3>
<p class="lead">Additional UIs can be easily integrated via the REST API. All UI components communicate with the platform through the standardized REST API, ensuring consistent behavior and security:</p>
<ul>
<li><strong>Chatbots:</strong> Build custom chatbots using the workflow API</li>
<li><strong>Customer UIs:</strong> Create customer-specific interfaces in React, JavaScript, or other languages</li>
<li><strong>Mobile Apps:</strong> Integrate via REST API from mobile applications</li>
<li><strong>Third-Party Tools:</strong> Connect existing tools via webhooks and API</li>
</ul>
<p>All UI components communicate with the platform through the standardized REST API, ensuring consistent behavior and security.</p>
</div>
<div class="section">
<h3>Available UI Components</h3>
<p class="lead">The platform provides reusable UI components that can be configured via RBAC:</p>
<ul>
<li>Chat interface</li>
<li>Document viewer/editor</li>
<li>Workflow editor</li>
<li>Connection manager</li>
<li>Settings panels</li>
<li>Dashboard widgets</li>
</ul>
</div>
<div class="section">
<h3>UI Architecture Diagram</h3>
<div class="diagram-image-container">
<img src="doc_platform_06_ui_architecture.jpg" alt="UI Architecture - RBAC-Driven Configuration, UI Components, UI Layer, and REST API" class="diagram-image">
</div>
</div>
</div>
<div id="big-picture" class="tab-content">
<h2>Big Picture & Future Vision</h2>
<div class="section">
<h3>Vendor-Independent Platform</h3>
<div class="vision-card">
<h4>AI Model Independence</h4>
<p>PowerOn is designed as a <strong>vendor-independent platform</strong> regarding AI models:</p>
<ul>
<li>Support for multiple AI providers (OpenAI, Anthropic, Google, Azure, etc.)</li>
<li>Dynamic model selection based on task requirements</li>
<li>Easy addition of new AI providers</li>
<li>No vendor lock-in - customers can switch providers seamlessly</li>
</ul>
</div>
<div class="vision-card">
<h4>Connector Independence</h4>
<p>Universal connector architecture supporting all major platforms:</p>
<ul>
<li><strong>Microsoft:</strong> SharePoint, Outlook, Teams, OneDrive, Azure</li>
<li><strong>Google:</strong> Drive, Gmail, Workspace, Cloud</li>
<li><strong>Amazon:</strong> AWS services, S3, etc.</li>
<li><strong>Other:</strong> Jira, Slack, Salesforce, and many more</li>
</ul>
<p>Customers are not locked into a single vendor ecosystem.</p>
</div>
</div>
<div class="section">
<h3>Graphical Workflow Modeling</h3>
<div class="vision-card">
<h4>Visual Customer Journey Design</h4>
<p>Future capability to <strong>graphically model workflows</strong> for customer journeys:</p>
<ul>
<li>Drag-and-drop workflow editor</li>
<li>Visual representation of customer journeys</li>
<li>Easy workflow modification without coding</li>
<li>Template library for common workflows</li>
<li>Workflow versioning and testing</li>
</ul>
<p>This makes workflow creation accessible to business users, not just developers.</p>
</div>
</div>
<div class="section">
<h3>MCP Integration in Customer Copilot</h3>
<div class="vision-card">
<h4>Microsoft Copilot Plugin Architecture</h4>
<p>Integration of PowerOn actions as <strong>MCP (Model Context Protocol) plugins</strong> in the customer's Copilot:</p>
<ul>
<li><strong>Native Copilot Integration:</strong> PowerOn workflows accessible directly from Microsoft Copilot</li>
<li><strong>Action Library:</strong> All PowerOn actions available as Copilot plugins</li>
<li><strong>Seamless Experience:</strong> Customers use PowerOn capabilities without leaving Copilot</li>
<li><strong>Enterprise Workflows:</strong> Complex workflows triggered from simple Copilot conversations</li>
<li><strong>Data Privacy:</strong> All PowerOn privacy features work seamlessly in Copilot context</li>
</ul>
<p class="highlight-text">This enables customers to leverage PowerOn's powerful workflow capabilities directly from their familiar Copilot interface.</p>
</div>
</div>
<div class="section">
<h3>Platform Evolution</h3>
<div class="vision-grid">
<div class="vision-item">
<h4>Today</h4>
<ul>
<li>REST API-based workflows</li>
<li>Playground UI</li>
<li>Multiple AI providers</li>
<li>Standard connectors</li>
</ul>
</div>
<div class="vision-item">
<h4>Near Future</h4>
<ul>
<li>Graphical workflow editor</li>
<li>MCP Copilot integration</li>
<li>Enhanced pre-processing</li>
<li>Advanced AI selection</li>
</ul>
</div>
<div class="vision-item">
<h4>Future</h4>
<ul>
<li>AI-powered workflow generation</li>
<li>Multi-platform Copilot support</li>
<li>Edge computing expansion</li>
<li>Federated learning</li>
</ul>
</div>
</div>
</div>
<div class="section">
<h3>Big Picture & Future Vision Diagram</h3>
<div class="diagram-image-container">
<img src="doc_platform_07_big_picture_and_future_vision.jpg" alt="Big Picture & Future Vision - Platform Evolution from Today to Future" class="diagram-image">
</div>
</div>
</div>
<div id="integration" class="tab-content">
<h2>Integration Guide</h2>
<div class="section">
<h3>REST API</h3>
<p class="lead">The platform exposes a comprehensive REST API for all operations. This API serves as the primary integration point for external developers:</p>
<ul>
<li><strong>Workflow API:</strong> Create, execute, and manage workflows</li>
<li><strong>Document API:</strong> Upload, download, and process documents</li>
<li><strong>Connection API:</strong> Manage external connections (SharePoint, Outlook, etc.)</li>
<li><strong>RBAC API:</strong> Manage roles and permissions</li>
<li><strong>Options API:</strong> Dynamic options for UI components</li>
</ul>
</div>
<div class="section">
<h3>Building Blocks for Developers</h3>
<p class="lead">Developers can extend the platform by creating custom components in these areas:</p>
<div class="building-blocks">
<div class="block">
<h4>Workflow Methods</h4>
<p>Create custom workflow methods by extending <code>MethodBase</code> and registering actions.</p>
</div>
<div class="block">
<h4>Services</h4>
<p>Extend the services layer by creating new service modules following the existing pattern.</p>
</div>
<div class="block">
<h4>Connectors</h4>
<p>Build connectors for external systems (databases, APIs, services) using the connector interface.</p>
</div>
<div class="block">
<h4>UI Components</h4>
<p>Create React components that integrate with the REST API and respect RBAC rules.</p>
</div>
</div>
</div>
<div class="section">
<h3>Development Workflow</h3>
<p class="lead">Follow these steps to get started with platform development:</p>
<ol>
<li><strong>Understand the Architecture:</strong> Review this document and codebase structure</li>
<li><strong>Set Up Development Environment:</strong> Clone repository and configure local environment</li>
<li><strong>Choose Integration Point:</strong> Decide whether to extend workflows, services, or UI</li>
<li><strong>Follow Patterns:</strong> Use existing code as reference for consistent implementation</li>
<li><strong>Test with RBAC:</strong> Ensure your changes respect RBAC rules</li>
<li><strong>Document:</strong> Update documentation for your changes</li>
</ol>
</div>
<div class="section">
<h3>Key Integration Points</h3>
<p class="lead">Main directories where developers can add new functionality:</p>
<ul>
<li><code>gateway/modules/workflows/methods/</code> - Add new workflow methods</li>
<li><code>gateway/modules/services/</code> - Add new microservices</li>
<li><code>gateway/modules/connectors/</code> - Add new connectors</li>
<li><code>gateway/modules/routes/</code> - Add new API endpoints</li>
<li><code>gateway/modules/features/</code> - Add new features</li>
</ul>
</div>
</div>
</div>
<div class="footer">
<div class="container">
<p>&copy; 2025 PowerON. All rights reserved.</p>
<p>Platform Architecture Documentation v1.0</p>
</div>
</div>
<script>
function openTab(evt, tabName) {
var i, tabcontent, tablinks;
tabcontent = document.getElementsByClassName("tab-content");
for (i = 0; i < tabcontent.length; i++) {
tabcontent[i].classList.remove("active");
}
tablinks = document.getElementsByClassName("tab-button");
for (i = 0; i < tablinks.length; i++) {
tablinks[i].classList.remove("active");
}
document.getElementById(tabName).classList.add("active");
evt.currentTarget.classList.add("active");
}
</script>
</body>
</html>

View file

@ -0,0 +1,880 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PowerOn Kunden und Nutzereferenzen</title>
<style>
/* PowerOn.swiss Stylesheet */
/* Tailwind CSS Custom Properties & Design Tokens */
:root {
/* Locale */
-webkit-locale: "de";
/* Tailwind Transform Properties */
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-rotate: 0;
--tw-skew-x: 0;
--tw-skew-y: 0;
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-pan-x: ;
--tw-pan-y: ;
--tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity;
/* Tailwind Gradient Properties */
--tw-gradient-from-position: ;
--tw-gradient-via-position: ;
--tw-gradient-to-position: ;
/* Tailwind Typography Properties */
--tw-ordinal: ;
--tw-slashed-zero: ;
--tw-numeric-figure: ;
--tw-numeric-spacing: ;
--tw-numeric-fraction: ;
/* Tailwind Ring/Shadow Properties */
--tw-ring-inset: ;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-color: rgb(59 130 246 / .5);
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-shadow: 0 0 #0000;
--tw-shadow: 0 0 #0000;
--tw-shadow-colored: 0 0 #0000;
/* Tailwind Filter Properties */
--tw-blur: ;
--tw-brightness: ;
--tw-contrast: ;
--tw-grayscale: ;
--tw-hue-rotate: ;
--tw-invert: ;
--tw-saturate: ;
--tw-sepia: ;
--tw-drop-shadow: ;
/* Tailwind Backdrop Filter Properties */
--tw-backdrop-blur: ;
--tw-backdrop-brightness: ;
--tw-backdrop-contrast: ;
--tw-backdrop-grayscale: ;
--tw-backdrop-hue-rotate: ;
--tw-backdrop-invert: ;
--tw-backdrop-opacity: ;
--tw-backdrop-saturate: ;
--tw-backdrop-sepia: ;
/* Tailwind Container Properties */
--tw-contain-size: ;
--tw-contain-layout: ;
--tw-contain-paint: ;
--tw-contain-style: ;
/* Design System Colors (HSL Format) */
/* Base Colors */
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
/* Card Colors */
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
/* Popover Colors */
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
/* Primary Colors (Red Brand Color) */
--primary: 0 84% 42%;
--primary-foreground: 0 0% 100%;
/* Secondary Colors */
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
/* Muted Colors */
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
/* Accent Colors */
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
/* Destructive Colors */
--destructive: 0 84% 42%;
--destructive-foreground: 210 40% 98%;
/* Custom Red Colors */
--red-primary: 0 84% 42%;
--red-primary-hover: 0 53% 23%;
--red-primary-light: 0 84% 60%;
--red-background-light: 0 84% 97%;
/* Border & Input Colors */
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
/* Tool/Brand Specific Colors */
--tool-dark: 0 0% 9.4%;
--tool-dark-light: 0 0% 16.5%;
--tool-dark-medium: 0 0% 12.2%;
--tool-beige: 43 12% 73.7%;
--tool-beige-light: 43 20% 80%;
--tool-beige-dark: 43 8% 67%;
--tool-orange: 9 90% 60.6%;
--tool-orange-light: 9 85% 65%;
--tool-orange-dark: 9 94% 53%;
/* Border Radius */
--radius: 0.5rem;
/* Sidebar Colors */
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
/* Base Reset */
*,
*::before,
*::after {
box-sizing: border-box;
border-width: 0;
border-style: solid;
border-color: hsl(var(--border));
}
/* Body Base Styles */
body {
line-height: 1.5;
-webkit-text-size-adjust: 100%;
tab-size: 4;
font-family: 'DM Sans', sans-serif;
font-feature-settings: normal;
font-variation-settings: normal;
-webkit-tap-highlight-color: transparent;
background-color: hsl(var(--background));
color: hsl(var(--foreground));
}
/* Utility Classes für die Farben */
.bg-background { background-color: hsl(var(--background)); }
.bg-primary { background-color: hsl(var(--primary)); }
.bg-secondary { background-color: hsl(var(--secondary)); }
.bg-muted { background-color: hsl(var(--muted)); }
.bg-card { background-color: hsl(var(--card)); }
.text-foreground { color: hsl(var(--foreground)); }
.text-primary { color: hsl(var(--primary)); }
.text-primary-foreground { color: hsl(var(--primary-foreground)); }
.text-muted-foreground { color: hsl(var(--muted-foreground)); }
.border-border { border-color: hsl(var(--border)); }
/* Custom Red Button */
.btn-red-primary {
background-color: hsl(var(--red-primary));
color: hsl(var(--primary-foreground));
border-radius: var(--radius);
}
.btn-red-primary:hover {
background-color: hsl(var(--red-primary-hover));
}
/* Tool Colors */
.bg-tool-dark { background-color: hsl(var(--tool-dark)); }
.bg-tool-beige { background-color: hsl(var(--tool-beige)); }
.bg-tool-orange { background-color: hsl(var(--tool-orange)); }
/* Custom Layout Styles */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
.header {
background: hsl(var(--red-primary));
color: hsl(var(--primary-foreground));
padding: 60px 0;
text-align: center;
}
.logo {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 20px;
}
.logo a {
color: hsl(var(--primary-foreground));
text-decoration: none;
}
.subtitle {
font-size: 1.2rem;
opacity: 0.9;
max-width: 600px;
margin: 0 auto;
}
.main-content {
padding: 60px 0;
}
.section {
margin-bottom: 50px;
background: hsl(var(--card));
border-radius: var(--radius);
padding: 40px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
border: 1px solid hsl(var(--border));
}
.section h2 {
color: hsl(var(--foreground));
font-size: 2rem;
margin-bottom: 30px;
border-bottom: 3px solid hsl(var(--red-primary));
padding-bottom: 10px;
}
.section h3 {
color: hsl(var(--foreground));
font-size: 1.4rem;
margin: 30px 0 15px 0;
display: flex;
align-items: center;
}
.section h3::before {
content: "▶";
color: hsl(var(--red-primary));
margin-right: 10px;
font-size: 0.8rem;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 30px;
margin-top: 30px;
}
.feature-card {
background: hsl(var(--muted));
border: 1px solid hsl(var(--border));
border-radius: var(--radius);
padding: 25px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.feature-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
}
.feature-card h3 {
color: hsl(var(--foreground));
margin-bottom: 15px;
font-size: 1.2rem;
}
.feature-card ul {
list-style: none;
padding: 0;
}
.feature-card li {
padding: 8px 0;
position: relative;
padding-left: 20px;
}
.feature-card li::before {
content: "✓";
color: hsl(var(--red-primary));
font-weight: bold;
position: absolute;
left: 0;
}
.use-case-card {
background: hsl(var(--card));
border: 2px solid hsl(var(--red-primary));
border-radius: var(--radius);
padding: 30px;
margin-bottom: 30px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.use-case-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
}
.use-case-title {
color: hsl(var(--red-primary));
font-size: 1.3rem;
font-weight: 700;
margin-bottom: 20px;
display: flex;
align-items: center;
}
.use-case-title::before {
content: attr(data-icon);
font-size: 1.5rem;
margin-right: 10px;
}
.use-case-content {
margin-bottom: 20px;
}
.use-case-content h4 {
color: hsl(var(--foreground));
font-size: 1.1rem;
font-weight: 600;
margin: 15px 0 8px 0;
}
.use-case-content p {
color: hsl(var(--muted-foreground));
margin-bottom: 10px;
line-height: 1.6;
}
.process-flow {
background: hsl(var(--red-background-light));
border: 1px solid hsl(var(--red-primary-light));
border-radius: var(--radius);
padding: 20px;
margin: 20px 0;
overflow-x: auto;
}
.process-flow-title {
color: hsl(var(--red-primary));
font-weight: 600;
margin-bottom: 15px;
font-size: 1rem;
}
.process-steps {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.process-step {
background: hsl(var(--red-primary));
color: hsl(var(--primary-foreground));
padding: 8px 16px;
border-radius: 20px;
font-size: 0.9rem;
font-weight: 500;
white-space: nowrap;
transition: transform 0.2s ease;
}
.process-step:hover {
transform: scale(1.05);
}
.process-arrow {
color: hsl(var(--red-primary));
font-size: 1.2rem;
font-weight: bold;
}
.results {
background: hsl(var(--muted));
color: hsl(var(--foreground));
}
.results h2 {
color: hsl(var(--foreground));
border-bottom-color: hsl(var(--red-primary));
}
.results h3 {
color: hsl(var(--foreground));
}
.results .feature-card {
background: hsl(var(--card));
border-color: hsl(var(--border));
color: hsl(var(--foreground));
}
.results .feature-card h3 {
color: hsl(var(--foreground));
}
.results .feature-card li::before {
color: hsl(var(--red-primary));
}
.approach {
background: hsl(var(--red-background-light));
color: hsl(var(--foreground));
}
.approach h2 {
color: hsl(var(--foreground));
border-bottom-color: hsl(var(--red-primary));
}
.approach h3 {
color: hsl(var(--foreground));
}
.approach .feature-card {
background: hsl(var(--card));
border-color: hsl(var(--red-primary));
color: hsl(var(--foreground));
}
.approach .feature-card h3 {
color: hsl(var(--foreground));
}
.approach .feature-card li::before {
color: hsl(var(--red-primary));
}
.cta {
background: hsl(var(--muted));
color: hsl(var(--foreground));
}
.cta h2 {
color: hsl(var(--foreground));
border-bottom-color: hsl(var(--red-primary));
}
.cta h3 {
color: hsl(var(--foreground));
}
.cta .feature-card {
background: hsl(var(--card));
border-color: hsl(var(--border));
color: hsl(var(--foreground));
}
.cta .feature-card h3 {
color: hsl(var(--foreground));
}
.cta .feature-card li::before {
color: hsl(var(--red-primary));
}
.note {
background: hsl(var(--red-background-light));
border: 1px solid hsl(var(--red-primary-light));
border-radius: var(--radius);
padding: 20px;
margin-top: 30px;
color: hsl(var(--red-primary-hover));
}
.note::before {
content: " ";
font-weight: bold;
}
.footer {
background: hsl(var(--tool-dark));
color: hsl(var(--primary-foreground));
text-align: center;
padding: 40px 0;
}
.footer a {
color: hsl(var(--red-primary-light));
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
@media (max-width: 768px) {
.header {
padding: 40px 0;
}
.logo {
font-size: 2rem;
}
.section {
padding: 25px;
margin-bottom: 30px;
}
.feature-grid {
grid-template-columns: 1fr;
gap: 20px;
}
.process-steps {
flex-direction: column;
align-items: stretch;
}
.process-arrow {
transform: rotate(90deg);
}
}
</style>
</head>
<body>
<div class="header">
<div class="container">
<div class="logo">
<a href="https://poweron.swiss/">PowerOn</a>
</div>
<div class="subtitle">
Kunden und Nutzereferenzen (neutralisiert)
</div>
<div style="font-size: 0.9rem; margin-top: 10px; opacity: 0.8;">
Kurzüberblick über realisierte PowerOn-Leistungen ohne Kundennennungen
</div>
</div>
</div>
<div class="main-content">
<div class="container">
<div class="section">
<h2>Leistungsbausteine</h2>
<div class="feature-grid">
<div class="feature-card">
<h3>Impact Sessions</h3>
<ul>
<li>Orientierung für Entscheiderinnen und Entscheider</li>
<li>Klärung Nutzen, Risiken, nächste Schritte</li>
</ul>
</div>
<div class="feature-card">
<h3>Deep Dives & Academy-Module</h3>
<ul>
<li>Hands-on Training mit echten Business Cases</li>
<li>Transfer in konkrete Arbeitsabläufe</li>
</ul>
</div>
<div class="feature-card">
<h3>Workshops / Prototyping</h3>
<ul>
<li>Definition von Use Cases und KPI</li>
<li>Rapid Prototyping bis funktionsfähiges MVP</li>
</ul>
</div>
<div class="feature-card">
<h3>Transformation Labs</h3>
<ul>
<li>Begleitung bis Umsetzung und Go-Live</li>
<li>Skalierung und Betrieb</li>
</ul>
</div>
</div>
</div>
<div class="section">
<h2>Referenz-Use-Cases (ohne Kundendaten)</h2>
<div class="use-case-card">
<div class="use-case-title" data-icon="🔄">Prozessautomatisierung und KPI-Produkt</div>
<div class="use-case-content">
<h4>Kontext</h4>
<p>Hoher manueller Aufwand und intransparente Kosten in Spesen und Controlling bremsen das Tagesgeschäft</p>
<h4>Ziel</h4>
<p>Operative Kosten senken und Steuerungsfähigkeit erhöhen durch standardisierte, schnellere Freigaben</p>
<h4>Lösung</h4>
<p>EndtoEnd Workflow in PowerOn mit automatischer Belegerfassung, Prüfung und KPIAuswertung</p>
<h4>Ergebnis</h4>
<p>Kürzere Durchlaufzeiten und jederzeit transparente Kennzahlen</p>
</div>
<div class="process-flow">
<div class="process-flow-title">Prozessablauf:</div>
<div class="process-steps">
<div class="process-step">Beleg</div>
<div class="process-arrow"></div>
<div class="process-step">Erfassen</div>
<div class="process-arrow"></div>
<div class="process-step">Validieren</div>
<div class="process-arrow"></div>
<div class="process-step">Genehmigen</div>
<div class="process-arrow"></div>
<div class="process-step">Buchen</div>
<div class="process-arrow"></div>
<div class="process-step">KPIDashboard</div>
</div>
</div>
</div>
<div class="use-case-card">
<div class="use-case-title" data-icon="🧱">Enterprise-Features skalieren für bestehende Lösung</div>
<div class="use-case-content">
<h4>Kontext</h4>
<p>Wachsende Nutzerzahlen und steigende Anforderungen gefährden die wahrgenommene Servicequalität</p>
<h4>Ziel</h4>
<p>Verlässliche Skalierbarkeit sicherstellen und Kundenzufriedenheit schützen</p>
<h4>Lösung</h4>
<p>Rollen- und Berechtigungskonzept erweitern, Performance optimieren und Betriebsprozesse festigen</p>
<h4>Ergebnis</h4>
<p>Hohe Stabilität, schnellere Antwortzeiten und sicherer Betrieb</p>
</div>
<div class="process-flow">
<div class="process-flow-title">Prozessablauf:</div>
<div class="process-steps">
<div class="process-step">Users</div>
<div class="process-arrow"></div>
<div class="process-step">Auth/Rollen</div>
<div class="process-arrow"></div>
<div class="process-step">Services</div>
<div class="process-arrow"></div>
<div class="process-step">Queue/Jobs</div>
<div class="process-arrow"></div>
<div class="process-step">Monitoring</div>
<div class="process-arrow"></div>
<div class="process-step">SLO/SLA</div>
</div>
</div>
</div>
<div class="use-case-card">
<div class="use-case-title" data-icon="🧭">Management-Alignment und Entscheidvorbereitung</div>
<div class="use-case-content">
<h4>Kontext</h4>
<p>Strategische Weichenstellung für KI erfordert breite Abstützung und klare Investitionssicht</p>
<h4>Ziel</h4>
<p>Entscheidungssicherheit auf GLEbene schaffen und Investitionen fokussieren</p>
<h4>Lösung</h4>
<p>Kompakte ImpactSession mit Variantenvergleich und klarer Roadmap</p>
<h4>Ergebnis</h4>
<p>Verbindliche Entscheide zu Scope, Budget und Zeitplan</p>
</div>
<div class="process-flow">
<div class="process-flow-title">Prozessablauf:</div>
<div class="process-steps">
<div class="process-step">Ausgangslage</div>
<div class="process-arrow"></div>
<div class="process-step">Optionen</div>
<div class="process-arrow"></div>
<div class="process-step">Kosten/Nutzen</div>
<div class="process-arrow"></div>
<div class="process-step">Roadmap</div>
<div class="process-arrow"></div>
<div class="process-step">Entscheid (GL)</div>
</div>
</div>
</div>
<div class="use-case-card">
<div class="use-case-title" data-icon="🧩">TechWorkshops zu MultiAgentArchitektur</div>
<div class="use-case-content">
<h4>Kontext</h4>
<p>Unterschiedliche Vorgehensweisen und Standards verlangsamen Delivery und erschweren Skalierung</p>
<h4>Ziel</h4>
<p>Gemeinsame Spielregeln schaffen, um TimetoValue zu verkürzen und konsistente Qualität sicherzustellen</p>
<h4>Lösung</h4>
<p>Klare Architekturprinzipien, verbindliche Standards und kollaborative Working Agreements</p>
<h4>Ergebnis</h4>
<p>Einheitliche Regeln, eindeutige Verantwortlichkeiten und eine belastbare SprintRoadmap</p>
</div>
<div class="process-flow">
<div class="process-flow-title">Prozessablauf:</div>
<div class="process-steps">
<div class="process-step">Pain Points</div>
<div class="process-arrow"></div>
<div class="process-step">Prinzipien</div>
<div class="process-arrow"></div>
<div class="process-step">Standards</div>
<div class="process-arrow"></div>
<div class="process-step">Working Agreements</div>
<div class="process-arrow"></div>
<div class="process-step">SprintRoadmap</div>
</div>
</div>
</div>
<div class="use-case-card">
<div class="use-case-title" data-icon="📊">Data & Analytics Demo / Reporting</div>
<div class="use-case-content">
<h4>Kontext</h4>
<p>Entscheidungen werden mit Bauchgefühl statt mit einheitlichen Zahlen getroffen</p>
<h4>Ziel</h4>
<p>Entscheidungen im Fachbereich konsequent datenbasiert treffen</p>
<h4>Lösung</h4>
<p>Schlanke Datenaufbereitung mit PowerOnPipelines und Visualisierung im BITool</p>
<h4>Ergebnis</h4>
<p>Entscheidungsreife KPIs auf einen Blick</p>
</div>
<div class="process-flow">
<div class="process-flow-title">Prozessablauf:</div>
<div class="process-steps">
<div class="process-step">Datenquellen</div>
<div class="process-arrow"></div>
<div class="process-step">Bereinigen/Joinen</div>
<div class="process-arrow"></div>
<div class="process-step">KPIs berechnen</div>
<div class="process-arrow"></div>
<div class="process-step">Dashboard (BI)</div>
</div>
</div>
</div>
<div class="use-case-card">
<div class="use-case-title" data-icon="🛠️">CodeModernisierung und Analyse</div>
<div class="use-case-content">
<h4>Kontext</h4>
<p>Veraltete Codebasis bremst Releases, erhöht Betriebsrisiken und erschwert neue Features</p>
<h4>Ziel</h4>
<p>Risiken in LegacyCode reduzieren und Zukunftsfähigkeit herstellen</p>
<h4>Lösung</h4>
<p>Systematische CodeAnalyse mit klaren Migrationspfaden und schnellen Verbesserungen</p>
<h4>Ergebnis</h4>
<p>Priorisierte Massnahmen mit messbarem Risikoabbau</p>
</div>
<div class="process-flow">
<div class="process-flow-title">Prozessablauf:</div>
<div class="process-steps">
<div class="process-step">Systeme</div>
<div class="process-arrow"></div>
<div class="process-step">CodeAnalyse</div>
<div class="process-arrow"></div>
<div class="process-step">Risiken bewerten</div>
<div class="process-arrow"></div>
<div class="process-step">Migrationspfade</div>
<div class="process-arrow"></div>
<div class="process-step">Quick Wins</div>
<div class="process-arrow"></div>
<div class="process-step">Stabiler Release</div>
</div>
</div>
</div>
</div>
<div class="section results">
<h2>Typische Resultate</h2>
<div class="feature-grid">
<div class="feature-card">
<h3>Effizienzsteigerung</h3>
<ul>
<li>3070% Zeiteinsparung in Zielprozessen (je nach Ausgangslage)</li>
<li>Schnellere Entscheide dank standardisierten Artefakten und Dashboards</li>
</ul>
</div>
<div class="feature-card">
<h3>Risikoreduktion</h3>
<ul>
<li>Reduzierte Betriebsrisiken durch klare Architektur- und Qualitätsstandards</li>
<li>Höhere Akzeptanz durch Einbindung von Stakeholdern früh im Prozess</li>
</ul>
</div>
</div>
</div>
<div class="section approach">
<h2>Vorgehen (Kurz)</h2>
<div class="feature-grid">
<div class="feature-card">
<h3>1. Discovery</h3>
<ul>
<li>Ziele, IstProzess, Datenlage</li>
</ul>
</div>
<div class="feature-card">
<h3>2. Prototyp</h3>
<ul>
<li>Schlanker EndtoEndFlow mit messbarem Nutzen</li>
</ul>
</div>
<div class="feature-card">
<h3>3. Skalierung</h3>
<ul>
<li>Security, Performance, Betrieb</li>
</ul>
</div>
<div class="feature-card">
<h3>4. Transition</h3>
<ul>
<li>Übergabe oder Betrieb durch PowerOnTeam</li>
</ul>
</div>
</div>
</div>
<div class="section cta">
<h2>Gemeinsamer Start</h2>
<div class="feature-grid">
<div class="feature-card">
<h3>Vorbereitung</h3>
<ul>
<li>UseCase shortlist definieren</li>
<li>2h ImpactSession terminieren</li>
</ul>
</div>
<div class="feature-card">
<h3>Umsetzung</h3>
<ul>
<li>MVPScope und Erfolgskriterien festlegen</li>
<li>SprintPlanung starten</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<div class="footer">
<div class="container">
<p>© 2025 <a href="https://poweron.swiss/">PowerOn</a> Intelligente Workflow-Plattform</p>
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,57 @@
"""Generate tenant-dossier.pdf for neutralization demo. Run: python _generateTenantDossierPdf.py
Uses ReportLab so the PDF opens reliably in all viewers (stdlib-only PDFs are fragile).
"""
from pathlib import Path
from reportlab.lib.pagesizes import A4
from reportlab.pdfgen import canvas
def _main():
outPath = Path(__file__).resolve().parent / "tenant-dossier.pdf"
c = canvas.Canvas(str(outPath), pagesize=A4)
_, h = A4
margin = 72
y = h - margin
c.setFont("Helvetica-Bold", 13)
c.drawString(margin, y, "Tenant dossier (demo) - confidential")
y -= 22
c.setFont("Helvetica", 11)
lines = [
"Fictional demo data for neutralization testing.",
"",
"Tenant name: Hans Muster",
"Date of birth: 14.03.1982",
"Nationality: Swiss",
"",
"Residential address:",
"Bahnhofstrasse 1",
"8001 Zurich",
"Switzerland",
"",
"Email: hans.muster@example-mail.demo",
"Phone: +41 79 123 45 67",
"",
"Lease reference: LE-2024-88421",
"Monthly rent: CHF 2450.00",
"Deposit held: CHF 7350.00",
"",
"Employer: Demo Consulting AG, Limmatquai 78, 8001 Zurich",
"",
"Notes: Tenant requested balcony repair (ticket REQ-992).",
]
lineHeight = 14
for line in lines:
if y < margin and line:
c.showPage()
c.setFont("Helvetica", 11)
y = h - margin
c.drawString(margin, y, line)
y -= lineHeight
c.save()
print(f"Wrote {outPath}")
if __name__ == "__main__":
_main()

View file

@ -0,0 +1,74 @@
%PDF-1.3
%“Œ‹ž ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R /F2 3 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/Contents 8 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 7 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
5 0 obj
<<
/PageMode /UseNone /Pages 7 0 R /Type /Catalog
>>
endobj
6 0 obj
<<
/Author (anonymous) /CreationDate (D:20260413002929+02'00') /Creator (anonymous) /Keywords () /ModDate (D:20260413002929+02'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (unspecified) /Title (untitled) /Trapped /False
>>
endobj
7 0 obj
<<
/Count 1 /Kids [ 4 0 R ] /Type /Pages
>>
endobj
8 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 654
>>
stream
Gasam9lnc;&A@g>lnO(2=RrpscmGHAZie8p-5Y3=@t?5.P!"j*HK;Fi@]13b1HoLNhXc)p>lp^JaPgD8!#HB_>8&+nWYS,F`)(;Y<Lk/U.?Nb4Scn<JS30YZ'XG(Oo"<&;)IQU>@)>/R[H=Dq4)8esgGpgXQD3IM$H$"2L[$s#Dk8hf2E>G=!I\)qcAifY?5kL#lX:umL)C2t<$6-:MY6mu9k?#W%2[oR^VsI+.!d4gq#g2k1Vj8HiJIpNf:t7&r:FE<6naroO=f7-A\)mh3K+#;jO=Q5$Z^pXYXcahlq@-EPABR+A_HCPde%4"G)Q2m;h-`b6ENmFFmS1/_)fuc<nk^'7Nd.ZjQ)DX+b?hlicXDh:rg+(CE?=F9Jh2`Gf"K!30mVJj*_6)D.,+<>50.gZ!l8E@]BR[V=I5)R1mE7:'u=chT!!'f^Xe@:2KoYE13<lcbsh;6"Y1<fV1]0>Fj#R5slPDniWfK\<FuOQ"qgBfC(;L0I9t1Xb"J`(keS):7\>L\<E@#kcetHiE:7(*Ytq`N/PVk`NGPS<$a)n8\UEUO8UoBnDWCfD"o\<F$DDi=agk\F*6K4S<-O;FDdo1&LBP6[_hphXpf.)NqIR"9r[LsT9bl'oa`lu]DB/g$G)e?3sEoY""m)B"T~>endstream
endobj
xref
0 9
0000000000 65535 f
0000000061 00000 n
0000000102 00000 n
0000000209 00000 n
0000000321 00000 n
0000000524 00000 n
0000000592 00000 n
0000000853 00000 n
0000000912 00000 n
trailer
<<
/ID
[<fffce794bf59aca4604ad63204977686><fffce794bf59aca4604ad63204977686>]
% ReportLab generated PDF document -- digest (opensource)
/Info 6 0 R
/Root 5 0 R
/Size 9
>>
startxref
1656
%%EOF

Binary file not shown.

View file

@ -4,7 +4,7 @@
APP_ENV_TYPE = dev
APP_ENV_LABEL = Development Instance Patrick
APP_API_URL = http://localhost:8000
APP_KEY_SYSVAR = D:/Athi/Local/Web/poweron/local/key.txt
APP_KEY_SYSVAR = D:/Athi/Local/Web/poweron/local/notes/key.txt
APP_INIT_PASS_ADMIN_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEeFFtRGtQeVUtcjlrU3dab1ZxUm9WSks0MlJVYUtERFlqUElHemZrOGNENk1tcmJNX3Vxc01UMDhlNU40VzZZRVBpUGNmT3podzZrOGhOeEJIUEt4eVlSWG5UYXA3d09DVXlLT21Kb1JYSUU9
APP_INIT_PASS_EVENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERzZjNm56WGVBdjJTeG5Udjd6OGQwUVotYXUzQjJ1YVNyVXVBa3NZVml3ODU0MVNkZjhWWmJwNUFkc19BcHlHMTU1Q3BRcHU0cDBoZkFlR2l6UEZQU3d2U3MtMDh5UDZteGFoQ0EyMUE1ckE9

View file

@ -71,6 +71,7 @@ class AiAnthropic(BaseConnectorAi):
(OperationTypeEnum.DATA_GENERATE, 9),
(OperationTypeEnum.DATA_EXTRACT, 8),
(OperationTypeEnum.AGENT, 9),
(OperationTypeEnum.DATA_QUERY, 9),
),
version="claude-sonnet-4-5-20250929",
calculatepriceCHF=lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.003 + (bytesReceived / 4 / 1000) * 0.015
@ -97,6 +98,7 @@ class AiAnthropic(BaseConnectorAi):
(OperationTypeEnum.DATA_GENERATE, 8),
(OperationTypeEnum.DATA_EXTRACT, 7),
(OperationTypeEnum.AGENT, 7),
(OperationTypeEnum.DATA_QUERY, 10),
),
version="claude-haiku-4-5-20251001",
calculatepriceCHF=lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.001 + (bytesReceived / 4 / 1000) * 0.005
@ -123,6 +125,7 @@ class AiAnthropic(BaseConnectorAi):
(OperationTypeEnum.DATA_GENERATE, 10),
(OperationTypeEnum.DATA_EXTRACT, 9),
(OperationTypeEnum.AGENT, 10),
(OperationTypeEnum.DATA_QUERY, 3),
),
version="claude-opus-4-6",
calculatepriceCHF=lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.005 + (bytesReceived / 4 / 1000) * 0.025

View file

@ -67,6 +67,7 @@ class AiMistral(BaseConnectorAi):
(OperationTypeEnum.DATA_GENERATE, 9),
(OperationTypeEnum.DATA_EXTRACT, 8),
(OperationTypeEnum.AGENT, 8),
(OperationTypeEnum.DATA_QUERY, 7),
),
version="mistral-large-latest",
calculatepriceCHF=lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.0005 + (bytesReceived / 4 / 1000) * 0.0015
@ -93,6 +94,7 @@ class AiMistral(BaseConnectorAi):
(OperationTypeEnum.DATA_GENERATE, 8),
(OperationTypeEnum.DATA_EXTRACT, 7),
(OperationTypeEnum.AGENT, 6),
(OperationTypeEnum.DATA_QUERY, 9),
),
version="mistral-small-latest",
calculatepriceCHF=lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.00006 + (bytesReceived / 4 / 1000) * 0.00018

View file

@ -68,6 +68,7 @@ class AiOpenai(BaseConnectorAi):
(OperationTypeEnum.DATA_GENERATE, 10),
(OperationTypeEnum.DATA_EXTRACT, 7),
(OperationTypeEnum.AGENT, 9),
(OperationTypeEnum.DATA_QUERY, 8),
),
version="gpt-4o",
calculatepriceCHF=lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.0025 + (bytesReceived / 4 / 1000) * 0.01
@ -95,6 +96,7 @@ class AiOpenai(BaseConnectorAi):
(OperationTypeEnum.DATA_GENERATE, 9),
(OperationTypeEnum.DATA_EXTRACT, 7),
(OperationTypeEnum.AGENT, 8),
(OperationTypeEnum.DATA_QUERY, 10),
),
version="gpt-4o-mini",
calculatepriceCHF=lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.00015 + (bytesReceived / 4 / 1000) * 0.0006

View file

@ -946,13 +946,14 @@ class DatabaseConnector:
if recordFilter:
for field, value in recordFilter.items():
if value is None:
# Use IS NULL for None values (= NULL is always false in SQL)
where_conditions.append(f'"{field}" IS NULL')
elif isinstance(value, list):
where_conditions.append(f'"{field}" = ANY(%s)')
where_values.append(value)
else:
where_conditions.append(f'"{field}" = %s')
where_values.append(value)
# Build the query
if where_conditions:
where_clause = " WHERE " + " AND ".join(where_conditions)
else:
@ -1040,7 +1041,7 @@ class DatabaseConnector:
colType = fields.get(key, "TEXT")
logger.debug(f"_buildPaginationClauses: filter key='{key}' val={val!r} type(val)={type(val).__name__} colType={colType}")
if val is None:
where_parts.append(f'"{key}" IS NULL')
where_parts.append(f'("{key}" IS NULL OR "{key}" = \'\')')
continue
if isinstance(val, dict):
op = val.get("operator", "equals")
@ -1113,13 +1114,15 @@ class DatabaseConnector:
orderParts: List[str] = []
if pagination and pagination.sort:
for sf in pagination.sort:
if sf.field in validColumns:
direction = "DESC" if sf.direction.lower() == "desc" else "ASC"
colType = fields.get(sf.field, "TEXT")
sfField = sf.get("field") if isinstance(sf, dict) else getattr(sf, "field", None)
sfDir = sf.get("direction", "asc") if isinstance(sf, dict) else getattr(sf, "direction", "asc")
if sfField and sfField in validColumns:
direction = "DESC" if str(sfDir).lower() == "desc" else "ASC"
colType = fields.get(sfField, "TEXT")
if colType == "BOOLEAN":
orderParts.append(f'COALESCE("{sf.field}", FALSE) {direction}')
orderParts.append(f'COALESCE("{sfField}", FALSE) {direction}')
else:
orderParts.append(f'"{sf.field}" {direction} NULLS LAST')
orderParts.append(f'"{sfField}" {direction} NULLS LAST')
if not orderParts:
orderParts.append('"id"')
order_clause = " ORDER BY " + ", ".join(orderParts)

View file

@ -18,9 +18,13 @@ from modules.shared.configuration import APP_CONFIG
logger = logging.getLogger(__name__)
# Gemini-TTS speaker IDs from voices.list use short names (e.g. "Kore") and require model_name + prompt.
# Gemini-TTS speaker IDs from voices.list use short names (e.g. "Kore") and require
# SynthesisInput.prompt + VoiceSelectionParams.model_name (google-cloud-texttospeech >= 2.24.0).
_GEMINI_TTS_DEFAULT_MODEL = "gemini-2.5-flash-tts"
_GEMINI_TTS_NEUTRAL_PROMPT = "Say the following"
_GEMINI_TTS_MIN_CLIENT_HINT = (
"Gemini-TTS requires google-cloud-texttospeech>=2.24.0 (SynthesisInput.prompt, VoiceSelectionParams.model_name)."
)
class ConnectorGoogleSpeech:
@ -940,7 +944,9 @@ class ConnectorGoogleSpeech:
logger.info(f"Using TTS voice: {selectedVoice} for language: {languageCode}")
if self._isGeminiTtsSpeakerVoiceName(selectedVoice):
isGeminiVoice = self._isGeminiTtsSpeakerVoiceName(selectedVoice)
if isGeminiVoice:
synthesisInput = texttospeech.SynthesisInput(
text=text,
prompt=_GEMINI_TTS_NEUTRAL_PROMPT,
@ -959,12 +965,10 @@ class ConnectorGoogleSpeech:
ssml_gender=texttospeech.SsmlVoiceGender.NEUTRAL,
)
# Select the type of audio file to return
audioConfig = texttospeech.AudioConfig(
audio_encoding=texttospeech.AudioEncoding.MP3
)
# Perform the text-to-speech request
response = self.tts_client.synthesize_speech(
input=synthesisInput,
voice=voice,
@ -982,9 +986,14 @@ class ConnectorGoogleSpeech:
except Exception as e:
logger.error(f"Text-to-Speech error: {e}")
detail = str(e)
extra = ""
low = detail.lower()
if "prompt" in low or "model_name" in low or "unknown field" in low:
extra = f" {_GEMINI_TTS_MIN_CLIENT_HINT}"
return {
"success": False,
"error": f"Text-to-Speech failed: {str(e)}"
"error": f"Text-to-Speech failed: {detail}{extra}",
}
def _getDefaultVoice(self, languageCode: str) -> str:

View file

@ -32,6 +32,7 @@ class OperationTypeEnum(str, Enum):
# Agent Operations
AGENT = "agent" # Agent loop: reasoning + tool use
DATA_QUERY = "dataQuery" # Data query sub-agent: fast model, schema-aware
# Embedding Operations
EMBEDDING = "embedding" # Text → vector conversion for semantic search

View file

@ -20,7 +20,7 @@ from enum import Enum
import uuid
from modules.shared.timeUtils import getUtcTimestamp
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.i18nRegistry import i18nModel
class AuditCategory(str, Enum):
@ -82,6 +82,7 @@ class AuditAction(str, Enum):
CONFIG_CHANGE = "config_change"
@i18nModel("Audit-Log-Eintrag")
class AuditLogEntry(BaseModel):
"""
Audit log entry for database storage.
@ -92,117 +93,94 @@ class AuditLogEntry(BaseModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique identifier for the audit entry",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
# Timestamp
timestamp: float = Field(
default_factory=getUtcTimestamp,
description="UTC timestamp when the event occurred",
json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": True}
json_schema_extra={"label": "Zeitstempel", "frontend_type": "datetime", "frontend_readonly": True, "frontend_required": True}
)
# Actor identification
userId: str = Field(
description="ID of the user who performed the action (or 'system' for system events)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
json_schema_extra={"label": "Benutzer-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
)
username: Optional[str] = Field(
default=None,
description="Username at the time of the event (for historical reference)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "Benutzername", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
# Context
mandateId: Optional[str] = Field(
default=None,
description="Mandate context (if applicable)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "Mandanten-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
featureInstanceId: Optional[str] = Field(
default=None,
description="Feature instance context (if applicable)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "Feature-Instanz-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
# Event classification
category: str = Field(
description="Event category (access, key, data, security, gdpr, permission, system)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
json_schema_extra={"label": "Kategorie", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
)
action: str = Field(
description="Specific action performed",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
json_schema_extra={"label": "Aktion", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
)
# Event details
resourceType: Optional[str] = Field(
default=None,
description="Type of resource affected (e.g., 'User', 'ChatWorkflow', 'TrusteeContract')",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "Ressourcentyp", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
resourceId: Optional[str] = Field(
default=None,
description="ID of the affected resource",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "Ressourcen-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
details: Optional[str] = Field(
default=None,
description="Additional details about the event",
json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "Details", "frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False}
)
# Request metadata
ipAddress: Optional[str] = Field(
default=None,
description="IP address of the client",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "IP-Adresse", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
userAgent: Optional[str] = Field(
default=None,
description="User agent string from the request",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "User-Agent", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
# Outcome
success: bool = Field(
default=True,
description="Whether the action was successful",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": True}
json_schema_extra={"label": "Erfolgreich", "frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": True}
)
errorMessage: Optional[str] = Field(
default=None,
description="Error message if the action failed",
json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "Fehlermeldung", "frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False}
)
# Register labels for internationalization
registerModelLabels(
"AuditLogEntry",
{"en": "Audit Log Entry", "de": "Audit-Log-Eintrag", "fr": "Entrée du journal d'audit"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"timestamp": {"en": "Timestamp", "de": "Zeitstempel", "fr": "Horodatage"},
"userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"},
"username": {"en": "Username", "de": "Benutzername", "fr": "Nom d'utilisateur"},
"mandateId": {"en": "Mandate ID", "de": "Mandanten-ID", "fr": "ID du mandat"},
"featureInstanceId": {"en": "Feature Instance ID", "de": "Feature-Instanz-ID", "fr": "ID de l'instance"},
"category": {"en": "Category", "de": "Kategorie", "fr": "Catégorie"},
"action": {"en": "Action", "de": "Aktion", "fr": "Action"},
"resourceType": {"en": "Resource Type", "de": "Ressourcentyp", "fr": "Type de ressource"},
"resourceId": {"en": "Resource ID", "de": "Ressourcen-ID", "fr": "ID de ressource"},
"details": {"en": "Details", "de": "Details", "fr": "Détails"},
"ipAddress": {"en": "IP Address", "de": "IP-Adresse", "fr": "Adresse IP"},
"userAgent": {"en": "User Agent", "de": "User-Agent", "fr": "Agent utilisateur"},
"success": {"en": "Success", "de": "Erfolgreich", "fr": "Succès"},
"errorMessage": {"en": "Error Message", "de": "Fehlermeldung", "fr": "Message d'erreur"},
},
)

View file

@ -6,14 +6,17 @@ from typing import Optional
from pydantic import BaseModel, Field
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.i18nRegistry import i18nModel
@i18nModel("Basisdatensatz")
class PowerOnModel(BaseModel):
"""Basis-Datenmodell mit System-Audit-Feldern fuer alle DB-Tabellen."""
sysCreatedAt: Optional[float] = Field(
default=None,
description="Record creation timestamp (UTC, set by system)",
json_schema_extra={
"label": "Erstellt am",
"frontend_type": "timestamp",
"frontend_readonly": True,
"frontend_required": False,
@ -25,6 +28,7 @@ class PowerOnModel(BaseModel):
default=None,
description="User ID who created this record (set by system)",
json_schema_extra={
"label": "Erstellt von",
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
@ -36,6 +40,7 @@ class PowerOnModel(BaseModel):
default=None,
description="Record last modification timestamp (UTC, set by system)",
json_schema_extra={
"label": "Geaendert am",
"frontend_type": "timestamp",
"frontend_readonly": True,
"frontend_required": False,
@ -47,6 +52,7 @@ class PowerOnModel(BaseModel):
default=None,
description="User ID who last modified this record (set by system)",
json_schema_extra={
"label": "Geaendert von",
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
@ -54,15 +60,3 @@ class PowerOnModel(BaseModel):
"system": True,
},
)
registerModelLabels(
"PowerOnModel",
{"en": "Base Record", "de": "Basisdatensatz"},
{
"sysCreatedAt": {"en": "Created At", "de": "Erstellt am", "fr": "Cree le"},
"sysCreatedBy": {"en": "Created By", "de": "Erstellt von", "fr": "Cree par"},
"sysModifiedAt": {"en": "Modified At", "de": "Geaendert am", "fr": "Modifie le"},
"sysModifiedBy": {"en": "Modified By", "de": "Geaendert von", "fr": "Modifie par"},
},
)

View file

@ -7,7 +7,7 @@ from enum import Enum
from datetime import date, datetime, timezone
from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.i18nRegistry import i18nModel
import uuid
# End-customer price for storage above plan-included volume (CHF per GB per month).
@ -38,203 +38,170 @@ class PeriodTypeEnum(str, Enum):
YEAR = "YEAR"
@i18nModel("Abrechnungskonto")
class BillingAccount(PowerOnModel):
"""Billing account for mandate or user-mandate combination."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"label": "ID"},
)
mandateId: str = Field(..., description="Foreign key to Mandate")
userId: Optional[str] = Field(None, description="Foreign key to User (None = mandate pool account, set = user audit account)")
balance: float = Field(default=0.0, description="Current balance in CHF")
warningThreshold: float = Field(default=0.0, description="Warning threshold in CHF")
lastWarningAt: Optional[datetime] = Field(None, description="Last warning sent timestamp")
enabled: bool = Field(default=True, description="Account is active")
registerModelLabels(
"BillingAccount",
{"en": "Billing Account", "de": "Abrechnungskonto"},
{
"id": {"en": "ID", "de": "ID"},
"mandateId": {"en": "Mandate ID", "de": "Mandanten-ID"},
"userId": {"en": "User ID", "de": "Benutzer-ID"},
"balance": {"en": "Balance (CHF)", "de": "Guthaben (CHF)"},
"warningThreshold": {"en": "Warning Threshold (CHF)", "de": "Warnschwelle (CHF)"},
"lastWarningAt": {"en": "Last Warning", "de": "Letzte Warnung"},
"enabled": {"en": "Enabled", "de": "Aktiv"},
},
)
mandateId: str = Field(..., description="Foreign key to Mandate", json_schema_extra={"label": "Mandanten-ID"})
userId: Optional[str] = Field(
None,
description="Foreign key to User (None = mandate pool account, set = user audit account)",
json_schema_extra={"label": "Benutzer-ID"},
)
balance: float = Field(default=0.0, description="Current balance in CHF", json_schema_extra={"label": "Guthaben (CHF)"})
warningThreshold: float = Field(
default=0.0,
description="Warning threshold in CHF",
json_schema_extra={"label": "Warnschwelle (CHF)"},
)
lastWarningAt: Optional[datetime] = Field(
None,
description="Last warning sent timestamp",
json_schema_extra={"label": "Letzte Warnung"},
)
enabled: bool = Field(default=True, description="Account is active", json_schema_extra={"label": "Aktiv"})
@i18nModel("Transaktion")
class BillingTransaction(PowerOnModel):
"""Single billing transaction (credit, debit, adjustment)."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"label": "ID"},
)
accountId: str = Field(..., description="Foreign key to BillingAccount")
transactionType: TransactionTypeEnum = Field(..., description="Transaction type")
amount: float = Field(..., description="Amount in CHF (always positive)")
description: str = Field(..., description="Transaction description")
accountId: str = Field(..., description="Foreign key to BillingAccount", json_schema_extra={"label": "Konto-ID"})
transactionType: TransactionTypeEnum = Field(..., description="Transaction type", json_schema_extra={"label": "Typ"})
amount: float = Field(..., description="Amount in CHF (always positive)", json_schema_extra={"label": "Betrag (CHF)"})
description: str = Field(..., description="Transaction description", json_schema_extra={"label": "Beschreibung"})
# Reference to source
referenceType: Optional[ReferenceTypeEnum] = Field(None, description="Reference type")
referenceId: Optional[str] = Field(None, description="Reference ID")
referenceType: Optional[ReferenceTypeEnum] = Field(None, description="Reference type", json_schema_extra={"label": "Referenztyp"})
referenceId: Optional[str] = Field(None, description="Reference ID", json_schema_extra={"label": "Referenz-ID"})
# Context for workflow transactions
workflowId: Optional[str] = Field(None, description="Workflow ID (for WORKFLOW transactions)")
featureInstanceId: Optional[str] = Field(None, description="Feature instance ID")
featureCode: Optional[str] = Field(None, description="Feature code (e.g., automation)")
aicoreProvider: Optional[str] = Field(None, description="AICore provider (anthropic, openai, etc.)")
aicoreModel: Optional[str] = Field(None, description="AICore model name (e.g., claude-4-sonnet, gpt-4o)")
createdByUserId: Optional[str] = Field(None, description="User who created/caused this transaction")
workflowId: Optional[str] = Field(None, description="Workflow ID (for WORKFLOW transactions)", json_schema_extra={"label": "Workflow-ID"})
featureInstanceId: Optional[str] = Field(None, description="Feature instance ID", json_schema_extra={"label": "Feature-Instanz-ID"})
featureCode: Optional[str] = Field(None, description="Feature code (e.g., automation)", json_schema_extra={"label": "Feature-Code"})
aicoreProvider: Optional[str] = Field(None, description="AICore provider (anthropic, openai, etc.)", json_schema_extra={"label": "AI-Anbieter"})
aicoreModel: Optional[str] = Field(None, description="AICore model name (e.g., claude-4-sonnet, gpt-4o)", json_schema_extra={"label": "AI-Modell"})
createdByUserId: Optional[str] = Field(None, description="User who created/caused this transaction", json_schema_extra={"label": "Erstellt von Benutzer"})
# AI call metadata (for per-call analytics)
processingTime: Optional[float] = Field(None, description="Processing time in seconds")
bytesSent: Optional[int] = Field(None, description="Bytes sent to AI model")
bytesReceived: Optional[int] = Field(None, description="Bytes received from AI model")
errorCount: Optional[int] = Field(None, description="Number of errors in this call")
registerModelLabels(
"BillingTransaction",
{"en": "Billing Transaction", "de": "Transaktion"},
{
"id": {"en": "ID", "de": "ID"},
"accountId": {"en": "Account ID", "de": "Konto-ID"},
"transactionType": {"en": "Type", "de": "Typ"},
"amount": {"en": "Amount (CHF)", "de": "Betrag (CHF)"},
"description": {"en": "Description", "de": "Beschreibung"},
"referenceType": {"en": "Reference Type", "de": "Referenztyp"},
"referenceId": {"en": "Reference ID", "de": "Referenz-ID"},
"workflowId": {"en": "Workflow ID", "de": "Workflow-ID"},
"featureInstanceId": {"en": "Feature Instance ID", "de": "Feature-Instanz-ID"},
"featureCode": {"en": "Feature Code", "de": "Feature-Code"},
"aicoreProvider": {"en": "AI Provider", "de": "AI-Anbieter"},
"aicoreModel": {"en": "AI Model", "de": "AI-Modell"},
"createdByUserId": {"en": "Created By User", "de": "Erstellt von Benutzer"},
},
)
processingTime: Optional[float] = Field(None, description="Processing time in seconds", json_schema_extra={"label": "Verarbeitungszeit (s)"})
bytesSent: Optional[int] = Field(None, description="Bytes sent to AI model", json_schema_extra={"label": "Gesendete Bytes"})
bytesReceived: Optional[int] = Field(None, description="Bytes received from AI model", json_schema_extra={"label": "Empfangene Bytes"})
errorCount: Optional[int] = Field(None, description="Number of errors in this call", json_schema_extra={"label": "Fehleranzahl"})
@i18nModel("Abrechnungseinstellungen")
class BillingSettings(BaseModel):
"""Billing settings per mandate. Only PREPAY_MANDATE model."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"label": "ID"},
)
mandateId: str = Field(..., description="Foreign key to Mandate (UNIQUE)")
mandateId: str = Field(..., description="Foreign key to Mandate (UNIQUE)", json_schema_extra={"label": "Mandanten-ID"})
warningThresholdPercent: float = Field(default=10.0, description="Warning threshold as percentage")
warningThresholdPercent: float = Field(
default=10.0,
description="Warning threshold as percentage",
json_schema_extra={"label": "Warnschwelle (%)"},
)
# Stripe
stripeCustomerId: Optional[str] = Field(None, description="Stripe Customer ID (cus_xxx) — one per mandate")
stripeCustomerId: Optional[str] = Field(
None,
description="Stripe Customer ID (cus_xxx) — one per mandate",
json_schema_extra={"label": "Stripe-Kunden-ID"},
)
# Auto-Recharge for AI budget
autoRechargeEnabled: bool = Field(default=False, description="Auto-buy AI budget when low")
rechargeAmountCHF: float = Field(default=10.0, description="Amount per auto-recharge (CHF, prepaid via Stripe)")
rechargeMaxPerMonth: int = Field(default=3, description="Max auto-recharges per month")
rechargesThisMonth: int = Field(default=0, description="Counter: auto-recharges used this month")
monthResetAt: Optional[datetime] = Field(None, description="When rechargesThisMonth was last reset")
autoRechargeEnabled: bool = Field(default=False, description="Auto-buy AI budget when low", json_schema_extra={"label": "Auto-Nachladung"})
rechargeAmountCHF: float = Field(
default=10.0,
description="Amount per auto-recharge (CHF, prepaid via Stripe)",
json_schema_extra={"label": "Nachladebetrag (CHF)"},
)
rechargeMaxPerMonth: int = Field(default=3, description="Max auto-recharges per month", json_schema_extra={"label": "Max. Nachladungen/Monat"})
rechargesThisMonth: int = Field(default=0, description="Counter: auto-recharges used this month", json_schema_extra={"label": "Nachladungen diesen Monat"})
monthResetAt: Optional[datetime] = Field(None, description="When rechargesThisMonth was last reset", json_schema_extra={"label": "Monats-Reset"})
# Notifications
notifyEmails: List[str] = Field(
default_factory=list,
description="Email addresses for billing alerts (pool exhausted, warnings, etc.)",
json_schema_extra={"label": "E-Mails fuer Billing-Alerts (Inhaber/Admin)"},
)
notifyOnWarning: bool = Field(default=True, description="Send email when warning threshold is reached")
notifyOnWarning: bool = Field(default=True, description="Send email when warning threshold is reached", json_schema_extra={"label": "Bei Warnung benachrichtigen"})
# Storage overage (high-watermark within subscription period; resets on new period)
storageHighWatermarkMB: float = Field(
default=0.0, description="Peak indexed data volume MB this billing period"
default=0.0,
description="Peak indexed data volume MB this billing period",
json_schema_extra={"label": "Speicher-Peak (MB)"},
)
storagePeriodStartAt: Optional[datetime] = Field(
None, description="Subscription billing period start used for storage reset"
None,
description="Subscription billing period start used for storage reset",
json_schema_extra={"label": "Speicher-Periodenbeginn"},
)
storageBilledUpToMB: float = Field(
default=0.0,
description="Overage MB already debited this period (above plan-included volume)",
json_schema_extra={"label": "Speicher abgerechneter Überhang (MB)"},
)
registerModelLabels(
"BillingSettings",
{"en": "Billing Settings", "de": "Abrechnungseinstellungen"},
{
"id": {"en": "ID", "de": "ID"},
"mandateId": {"en": "Mandate ID", "de": "Mandanten-ID"},
"warningThresholdPercent": {"en": "Warning Threshold (%)", "de": "Warnschwelle (%)"},
"stripeCustomerId": {"en": "Stripe Customer ID", "de": "Stripe-Kunden-ID"},
"autoRechargeEnabled": {"en": "Auto-Recharge", "de": "Auto-Nachladung"},
"rechargeAmountCHF": {"en": "Recharge Amount (CHF)", "de": "Nachladebetrag (CHF)"},
"rechargeMaxPerMonth": {"en": "Max Recharges/Month", "de": "Max. Nachladungen/Monat"},
"notifyEmails": {
"en": "Billing notification emails (owner / admin)",
"de": "E-Mails fuer Billing-Alerts (Inhaber/Admin)",
},
"notifyOnWarning": {"en": "Notify on Warning", "de": "Bei Warnung benachrichtigen"},
"storageHighWatermarkMB": {"en": "Storage peak (MB)", "de": "Speicher-Peak (MB)"},
"storagePeriodStartAt": {"en": "Storage period start", "de": "Speicher-Periodenbeginn"},
"storageBilledUpToMB": {
"en": "Storage billed overage (MB)",
"de": "Speicher abgerechneter Überhang (MB)",
},
},
)
class StripeWebhookEvent(BaseModel):
"""Stores processed Stripe webhook event IDs for idempotency."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
)
event_id: str = Field(..., description="Stripe event ID (evt_xxx)")
processed_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
description="When the event was processed"
description="When the event was processed",
)
@i18nModel("Nutzungsstatistik")
class UsageStatistics(BaseModel):
"""Aggregated usage statistics for quick retrieval."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"label": "ID"},
)
accountId: str = Field(..., description="Foreign key to BillingAccount")
periodType: PeriodTypeEnum = Field(..., description="Period type")
periodStart: date = Field(..., description="Period start date")
accountId: str = Field(..., description="Foreign key to BillingAccount", json_schema_extra={"label": "Konto-ID"})
periodType: PeriodTypeEnum = Field(..., description="Period type", json_schema_extra={"label": "Periodentyp"})
periodStart: date = Field(..., description="Period start date", json_schema_extra={"label": "Periodenbeginn"})
# Aggregated values
totalCostCHF: float = Field(default=0.0, description="Total cost in CHF")
transactionCount: int = Field(default=0, description="Number of transactions")
totalCostCHF: float = Field(default=0.0, description="Total cost in CHF", json_schema_extra={"label": "Gesamtkosten (CHF)"})
transactionCount: int = Field(default=0, description="Number of transactions", json_schema_extra={"label": "Anzahl Transaktionen"})
# Breakdown by provider
costByProvider: Dict[str, float] = Field(
default_factory=dict,
description="Cost breakdown by provider (e.g., {'anthropic': 12.50, 'openai': 8.30})"
description="Cost breakdown by provider (e.g., {'anthropic': 12.50, 'openai': 8.30})",
json_schema_extra={"label": "Kosten nach Anbieter"},
)
# Breakdown by feature
costByFeature: Dict[str, float] = Field(
default_factory=dict,
description="Cost breakdown by feature (e.g., {'automation': 5.80, 'workspace': 3.20})"
description="Cost breakdown by feature (e.g., {'automation': 5.80, 'workspace': 3.20})",
json_schema_extra={"label": "Kosten nach Feature"},
)
registerModelLabels(
"UsageStatistics",
{"en": "Usage Statistics", "de": "Nutzungsstatistik"},
{
"id": {"en": "ID", "de": "ID"},
"accountId": {"en": "Account ID", "de": "Konto-ID"},
"periodType": {"en": "Period Type", "de": "Periodentyp"},
"periodStart": {"en": "Period Start", "de": "Periodenbeginn"},
"totalCostCHF": {"en": "Total Cost (CHF)", "de": "Gesamtkosten (CHF)"},
"transactionCount": {"en": "Transaction Count", "de": "Anzahl Transaktionen"},
"costByProvider": {"en": "Cost by Provider", "de": "Kosten nach Anbieter"},
"costByFeature": {"en": "Cost by Feature", "de": "Kosten nach Feature"},
},
)
# ============================================================================
# Response Models for API
# ============================================================================
@ -277,4 +244,3 @@ class BillingCheckResult(BaseModel):
subscriptionUiPath: Optional[str] = None
userAction: Optional[str] = None

File diff suppressed because it is too large Load diff

View file

@ -9,66 +9,81 @@ Google Drive folder, FTP directory, etc.) for agent-accessible data containers.
from typing import Dict, Any, Optional
from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.i18nRegistry import i18nModel
import uuid
@i18nModel("Datenquelle")
class DataSource(PowerOnModel):
"""Configured external data source linked to a UserConnection."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
connectionId: str = Field(description="FK to UserConnection")
sourceType: str = Field(
description="sharepointFolder, googleDriveFolder, outlookFolder, ftpFolder, clickupList (path under /team/...)"
"""Konfigurierte externe Datenquelle verknuepft mit einer UserConnection."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"label": "ID"},
)
connectionId: str = Field(
description="FK to UserConnection",
json_schema_extra={"label": "Verbindungs-ID"},
)
sourceType: str = Field(
description="sharepointFolder, googleDriveFolder, outlookFolder, ftpFolder, clickupList (path under /team/...)",
json_schema_extra={"label": "Quellentyp"},
)
path: str = Field(
description="External path (e.g. '/sites/MySite/Documents/Reports')",
json_schema_extra={"label": "Pfad"},
)
label: str = Field(
description="User-visible label (often the last path segment)",
json_schema_extra={"label": "Bezeichnung"},
)
path: str = Field(description="External path (e.g. '/sites/MySite/Documents/Reports')")
label: str = Field(description="User-visible label (often the last path segment)")
displayPath: Optional[str] = Field(
default=None,
description="Human-readable full path for UI (connection-relative, slash-separated)",
json_schema_extra={"label": "Anzeigepfad"},
)
featureInstanceId: Optional[str] = Field(
default=None,
description="Scoped to feature instance",
json_schema_extra={"label": "Feature-Instanz"},
)
mandateId: Optional[str] = Field(
default=None,
description="Mandate scope",
json_schema_extra={"label": "Mandanten-ID"},
)
userId: str = Field(
default="",
description="Owner user ID",
json_schema_extra={"label": "Benutzer-ID"},
)
autoSync: bool = Field(
default=False,
description="Automatically sync on schedule",
json_schema_extra={"label": "Auto-Sync"},
)
lastSynced: Optional[float] = Field(
default=None,
description="Last sync timestamp",
json_schema_extra={"label": "Letzter Sync"},
)
featureInstanceId: Optional[str] = Field(default=None, description="Scoped to feature instance")
mandateId: Optional[str] = Field(default=None, description="Mandate scope")
userId: str = Field(default="", description="Owner user ID")
autoSync: bool = Field(default=False, description="Automatically sync on schedule")
lastSynced: Optional[float] = Field(default=None, description="Last sync timestamp")
scope: str = Field(
default="personal",
description="Data visibility scope: personal, featureInstance, mandate, global",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "personal", "label": {"en": "Personal", "de": "Persönlich"}},
{"value": "featureInstance", "label": {"en": "Feature Instance", "de": "Feature-Instanz"}},
{"value": "mandate", "label": {"en": "Mandate", "de": "Mandant"}},
{"value": "global", "label": {"en": "Global", "de": "Global"}},
]}
json_schema_extra={"label": "Sichtbarkeit", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "personal", "label": "Persönlich"},
{"value": "featureInstance", "label": "Feature-Instanz"},
{"value": "mandate", "label": "Mandant"},
{"value": "global", "label": "Global"},
]},
)
neutralize: bool = Field(
default=False,
description="Whether this data source should be neutralized before AI processing",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
json_schema_extra={"label": "Neutralisieren", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False},
)
registerModelLabels(
"DataSource",
{"en": "Data Source", "de": "Datenquelle", "fr": "Source de données"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"connectionId": {"en": "Connection ID", "de": "Verbindungs-ID", "fr": "ID de connexion"},
"sourceType": {"en": "Source Type", "de": "Quellentyp", "fr": "Type de source"},
"path": {"en": "Path", "de": "Pfad", "fr": "Chemin"},
"label": {"en": "Label", "de": "Bezeichnung", "fr": "Libellé"},
"displayPath": {"en": "Display path", "de": "Anzeigepfad", "fr": "Chemin affiché"},
"featureInstanceId": {"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance de fonctionnalité"},
"mandateId": {"en": "Mandate ID", "de": "Mandanten-ID", "fr": "ID du mandat"},
"userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"},
"autoSync": {"en": "Auto Sync", "de": "Auto-Sync", "fr": "Synchro auto"},
"lastSynced": {"en": "Last Synced", "de": "Letzter Sync", "fr": "Dernier sync"},
"scope": {"en": "Scope", "de": "Sichtbarkeit"},
"neutralize": {"en": "Neutralize", "de": "Neutralisieren"},
},
)
class ExternalEntry(BaseModel):
"""An item (file or folder) from an external data source."""
name: str = Field(description="Item name")

View file

@ -6,7 +6,7 @@ Document reference models for typed document references in workflows.
from typing import List, Optional
from pydantic import BaseModel, Field
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.i18nRegistry import i18nModel
class DocumentReference(BaseModel):
@ -14,10 +14,18 @@ class DocumentReference(BaseModel):
pass
@i18nModel("Dokumentlisten-Referenz")
class DocumentListReference(DocumentReference):
"""Reference to a document list via message label"""
messageId: Optional[str] = Field(None, description="Optional message ID for cross-round references")
label: str = Field(description="Document list label")
messageId: Optional[str] = Field(
None,
description="Optional message ID for cross-round references",
json_schema_extra={"label": "Nachrichten-ID"},
)
label: str = Field(
description="Document list label",
json_schema_extra={"label": "Bezeichnung"},
)
def to_string(self) -> str:
"""Convert to string format: docList:messageId:label or docList:label"""
@ -26,10 +34,18 @@ class DocumentListReference(DocumentReference):
return f"docList:{self.label}"
@i18nModel("Dokumentelement-Referenz")
class DocumentItemReference(DocumentReference):
"""Reference to a specific document item"""
documentId: str = Field(description="Document ID")
fileName: Optional[str] = Field(None, description="Optional file name")
documentId: str = Field(
description="Document ID",
json_schema_extra={"label": "Dokument-ID"},
)
fileName: Optional[str] = Field(
None,
description="Optional file name",
json_schema_extra={"label": "Dateiname"},
)
def to_string(self) -> str:
"""Convert to string format: docItem:documentId:fileName or docItem:documentId"""
@ -38,11 +54,13 @@ class DocumentItemReference(DocumentReference):
return f"docItem:{self.documentId}"
@i18nModel("Dokumentreferenz-Liste")
class DocumentReferenceList(BaseModel):
"""List of document references with conversion methods"""
references: List[DocumentReference] = Field(
default_factory=list,
description="List of document references"
description="List of document references",
json_schema_extra={"label": "Referenzen"},
)
def to_string_list(self) -> List[str]:
@ -97,24 +115,3 @@ class DocumentReferenceList(BaseModel):
references.append(DocumentListReference(label=refStr))
return cls(references=references)
registerModelLabels(
"DocumentReference",
{"en": "Document Reference", "fr": "Référence de document"},
{
"messageId": {"en": "Message ID", "fr": "ID du message"},
"label": {"en": "Label", "fr": "Étiquette"},
"documentId": {"en": "Document ID", "fr": "ID du document"},
"fileName": {"en": "File Name", "fr": "Nom du fichier"},
},
)
registerModelLabels(
"DocumentReferenceList",
{"en": "Document Reference List", "fr": "Liste de références de documents"},
{
"references": {"en": "References", "fr": "Références"},
},
)

View file

@ -1,7 +1,7 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
from typing import Any, Dict, List, Optional, Literal, Union
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_serializer
from datetime import datetime
@ -117,10 +117,11 @@ class RenderedDocument(BaseModel):
documentType: Optional[str] = Field(default=None, description="Type of document (e.g., 'report', 'invoice', 'analysis')")
metadata: Optional[Dict[str, Any]] = Field(default=None, description="Document metadata (title, author, etc.)")
class Config:
json_encoders = {
bytes: lambda v: v.decode('utf-8', errors='replace') if isinstance(v, bytes) else v
}
@field_serializer("documentData")
def _serializeDocumentData(self, v: bytes) -> str:
if isinstance(v, bytes):
return v.decode("utf-8", errors="replace")
return str(v)
# Update forward references

View file

@ -9,54 +9,69 @@ so the agent can query structured feature data (e.g. TrusteePosition rows).
from typing import Dict, Optional
from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.i18nRegistry import i18nModel
import uuid
@i18nModel("Feature-Datenquelle")
class FeatureDataSource(PowerOnModel):
"""A feature-instance table attached as data source in the AI workspace."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
featureInstanceId: str = Field(description="FK to FeatureInstance")
featureCode: str = Field(description="Feature code (e.g. trustee, commcoach)")
tableName: str = Field(description="Table name from DATA_OBJECTS meta (e.g. TrusteePosition)")
objectKey: str = Field(description="RBAC object key (e.g. data.feature.trustee.TrusteePosition)")
label: str = Field(description="User-visible label")
mandateId: str = Field(default="", description="Mandate scope")
userId: str = Field(default="", description="Owner user ID")
workspaceInstanceId: str = Field(description="Workspace instance where this source is used")
"""Feature-Instanz-Tabelle als Datenquelle im AI-Workspace."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"label": "ID"},
)
featureInstanceId: str = Field(
description="FK to FeatureInstance",
json_schema_extra={"label": "Feature-Instanz"},
)
featureCode: str = Field(
description="Feature code (e.g. trustee, commcoach)",
json_schema_extra={"label": "Feature"},
)
tableName: str = Field(
description="Table name from DATA_OBJECTS meta (e.g. TrusteePosition)",
json_schema_extra={"label": "Tabelle"},
)
objectKey: str = Field(
description="RBAC object key (e.g. data.feature.trustee.TrusteePosition)",
json_schema_extra={"label": "Objekt-Schluessel"},
)
label: str = Field(
description="User-visible label",
json_schema_extra={"label": "Bezeichnung"},
)
mandateId: str = Field(
default="",
description="Mandate scope",
json_schema_extra={"label": "Mandant"},
)
userId: str = Field(
default="",
description="Owner user ID",
json_schema_extra={"label": "Benutzer"},
)
workspaceInstanceId: str = Field(
description="Workspace instance where this source is used",
json_schema_extra={"label": "Workspace"},
)
scope: str = Field(
default="personal",
description="Data visibility scope: personal, featureInstance, mandate, global",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "personal", "label": {"en": "Personal", "de": "Persönlich"}},
{"value": "featureInstance", "label": {"en": "Feature Instance", "de": "Feature-Instanz"}},
{"value": "mandate", "label": {"en": "Mandate", "de": "Mandant"}},
{"value": "global", "label": {"en": "Global", "de": "Global"}},
]}
json_schema_extra={"label": "Sichtbarkeit", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "personal", "label": "Persönlich"},
{"value": "featureInstance", "label": "Feature-Instanz"},
{"value": "mandate", "label": "Mandant"},
{"value": "global", "label": "Global"},
]},
)
neutralize: bool = Field(
default=False,
description="Whether this data source should be neutralized before AI processing",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
json_schema_extra={"label": "Neutralisieren", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False},
)
recordFilter: Optional[Dict[str, str]] = Field(
default=None,
description="Record-level filter applied when querying this table, e.g. {'sessionId': 'abc-123'}",
json_schema_extra={"label": "Datensatzfilter"},
)
registerModelLabels(
"FeatureDataSource",
{"en": "Feature Data Source", "de": "Feature-Datenquelle", "fr": "Source de données fonctionnalité"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"featureInstanceId": {"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance"},
"featureCode": {"en": "Feature", "de": "Feature", "fr": "Fonctionnalité"},
"tableName": {"en": "Table", "de": "Tabelle", "fr": "Table"},
"objectKey": {"en": "Object Key", "de": "Objekt-Schlüssel", "fr": "Clé objet"},
"label": {"en": "Label", "de": "Bezeichnung", "fr": "Libellé"},
"mandateId": {"en": "Mandate", "de": "Mandant", "fr": "Mandat"},
"userId": {"en": "User", "de": "Benutzer", "fr": "Utilisateur"},
"workspaceInstanceId": {"en": "Workspace", "de": "Workspace", "fr": "Espace de travail"},
},
)

View file

@ -6,85 +6,56 @@ import uuid
from typing import Optional, Dict, Any
from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.i18nRegistry import i18nModel
from modules.datamodels.datamodelUtils import TextMultilingual
@i18nModel("Feature")
class Feature(PowerOnModel):
"""
Feature-Definition (global, z.B. 'trustee', 'chatbot').
Features sind die verfügbaren Funktionalitäten der Plattform.
"""
"""Feature-Definition (global, z.B. 'trustee', 'chatbot'). Verfuegbare Funktionalitaeten der Plattform."""
code: str = Field(
description="Unique feature code (Primary Key), z.B. 'trustee', 'chatbot'",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}
json_schema_extra={"label": "Code", "frontend_type": "text", "frontend_readonly": False, "frontend_required": True}
)
label: TextMultilingual = Field(
description="Feature label in multiple languages (I18n)",
json_schema_extra={"frontend_type": "multilingual", "frontend_readonly": False, "frontend_required": True}
json_schema_extra={"label": "Bezeichnung", "frontend_type": "multilingual", "frontend_readonly": False, "frontend_required": True}
)
icon: str = Field(
default="",
description="Icon identifier for the feature",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}
json_schema_extra={"label": "Symbol", "frontend_type": "text", "frontend_readonly": False, "frontend_required": False}
)
registerModelLabels(
"Feature",
{"en": "Feature", "de": "Feature", "fr": "Fonctionnalité"},
{
"code": {"en": "Code", "de": "Code", "fr": "Code"},
"label": {"en": "Label", "de": "Bezeichnung", "fr": "Libellé"},
"icon": {"en": "Icon", "de": "Symbol", "fr": "Icône"},
},
)
@i18nModel("Feature-Instanz")
class FeatureInstance(PowerOnModel):
"""
Instanz eines Features in einem Mandanten.
Ein Mandant kann mehrere Instanzen desselben Features haben.
"""
"""Instanz eines Features in einem Mandanten. Ein Mandant kann mehrere Instanzen desselben Features haben."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the feature instance",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
featureCode: str = Field(
description="FK Feature.code",
json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": True}
description="FK -> Feature.code",
json_schema_extra={"label": "Feature", "frontend_type": "select", "frontend_readonly": True, "frontend_required": True}
)
mandateId: str = Field(
description="FK Mandate.id (CASCADE DELETE)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
description="FK -> Mandate.id (CASCADE DELETE)",
json_schema_extra={"label": "Mandant", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
)
label: str = Field(
default="",
description="Instance label, z.B. 'Buchhaltung 2025'",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}
json_schema_extra={"label": "Bezeichnung", "frontend_type": "text", "frontend_readonly": False, "frontend_required": True}
)
enabled: bool = Field(
default=True,
description="Whether this feature instance is enabled",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
json_schema_extra={"label": "Aktiviert", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
)
config: Optional[Dict[str, Any]] = Field(
default=None,
description="Instance-specific configuration (JSONB). Structure depends on featureCode.",
json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False}
json_schema_extra={"label": "Konfiguration", "frontend_type": "json", "frontend_readonly": False, "frontend_required": False}
)
registerModelLabels(
"FeatureInstance",
{"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance de fonctionnalité"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"featureCode": {"en": "Feature", "de": "Feature", "fr": "Fonctionnalité"},
"mandateId": {"en": "Mandate", "de": "Mandant", "fr": "Mandat"},
"label": {"en": "Label", "de": "Bezeichnung", "fr": "Libellé"},
"enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"},
"config": {"en": "Configuration", "de": "Konfiguration", "fr": "Configuration"},
},
)

View file

@ -5,26 +5,34 @@
from typing import Optional
from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.i18nRegistry import i18nModel
import uuid
@i18nModel("Dateiordner")
class FileFolder(PowerOnModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
name: str = Field(description="Folder name", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True})
parentId: Optional[str] = Field(default=None, description="Parent folder ID (null = root)", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
mandateId: Optional[str] = Field(default=None, description="Mandate context", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
featureInstanceId: Optional[str] = Field(default=None, description="Feature instance context", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
registerModelLabels(
"FileFolder",
{"en": "File Folder", "fr": "Dossier de fichiers"},
{
"id": {"en": "ID", "fr": "ID"},
"name": {"en": "Name", "fr": "Nom"},
"parentId": {"en": "Parent Folder", "fr": "Dossier parent"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance"},
},
)
"""Hierarchischer Ordner fuer die Dateiverwaltung."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
)
name: str = Field(
description="Folder name",
json_schema_extra={"label": "Name", "frontend_type": "text", "frontend_readonly": False, "frontend_required": True},
)
parentId: Optional[str] = Field(
default=None,
description="Parent folder ID (null = root)",
json_schema_extra={"label": "Uebergeordneter Ordner", "frontend_type": "text", "frontend_readonly": False, "frontend_required": False},
)
mandateId: Optional[str] = Field(
default=None,
description="Mandate context",
json_schema_extra={"label": "Mandanten-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
)
featureInstanceId: Optional[str] = Field(
default=None,
description="Feature instance context",
json_schema_extra={"label": "Feature-Instanz-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
)

View file

@ -5,66 +5,110 @@
from typing import Dict, Any, List, Optional, Union
from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.i18nRegistry import i18nModel
import uuid
import base64
@i18nModel("Datei")
class FileItem(PowerOnModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
mandateId: Optional[str] = Field(default="", description="ID of the mandate this file belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
featureInstanceId: Optional[str] = Field(default="", description="ID of the feature instance this file belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "frontend_fk_source": "/api/features/instances", "frontend_fk_display_field": "label"})
fileName: str = Field(description="Name of the file", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True})
mimeType: str = Field(description="MIME type of the file", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
fileHash: str = Field(description="Hash of the file", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
fileSize: int = Field(description="Size of the file in bytes", json_schema_extra={"frontend_type": "integer", "frontend_readonly": True, "frontend_required": False})
tags: Optional[List[str]] = Field(default=None, description="Tags for categorization and search", json_schema_extra={"frontend_type": "tags", "frontend_readonly": False, "frontend_required": False})
folderId: Optional[str] = Field(default=None, description="ID of the parent folder", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
description: Optional[str] = Field(default=None, description="User-provided description of the file", json_schema_extra={"frontend_type": "textarea", "frontend_readonly": False, "frontend_required": False})
status: Optional[str] = Field(default=None, description="Processing status: pending, extracted, embedding, indexed, failed", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
"""Metadaten einer gespeicherten Datei."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
)
fileName: str = Field(
description="Name of the file",
json_schema_extra={"label": "Dateiname", "frontend_type": "text", "frontend_readonly": False, "frontend_required": True},
)
mandateId: Optional[str] = Field(
default="",
description="ID of the mandate this file belongs to",
json_schema_extra={"label": "Mandant", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "frontend_fk_source": "/api/mandates/", "frontend_fk_display_field": "label"},
)
featureInstanceId: Optional[str] = Field(
default="",
description="ID of the feature instance this file belongs to",
json_schema_extra={"label": "Feature-Instanz", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "frontend_fk_source": "/api/features/instances", "frontend_fk_display_field": "label"},
)
mimeType: str = Field(
description="MIME type of the file",
json_schema_extra={"label": "MIME-Typ", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
)
fileHash: str = Field(
description="Hash of the file",
json_schema_extra={"label": "Datei-Hash", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
)
fileSize: int = Field(
description="Size of the file in bytes",
json_schema_extra={"label": "Dateigroesse", "frontend_type": "integer", "frontend_readonly": True, "frontend_required": False},
)
tags: Optional[List[str]] = Field(
default=None,
description="Tags for categorization and search",
json_schema_extra={"label": "Tags", "frontend_type": "tags", "frontend_readonly": False, "frontend_required": False},
)
folderId: Optional[str] = Field(
default=None,
description="ID of the parent folder",
json_schema_extra={"label": "Ordner-ID", "frontend_type": "text", "frontend_readonly": False, "frontend_required": False},
)
description: Optional[str] = Field(
default=None,
description="User-provided description of the file",
json_schema_extra={"label": "Beschreibung", "frontend_type": "textarea", "frontend_readonly": False, "frontend_required": False},
)
status: Optional[str] = Field(
default=None,
description="Processing status: pending, extracted, embedding, indexed, failed",
json_schema_extra={"label": "Status", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
)
scope: str = Field(
default="personal",
description="Data visibility scope: personal, featureInstance, mandate, global",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "personal", "label": {"en": "Personal", "de": "Persönlich"}},
{"value": "featureInstance", "label": {"en": "Feature Instance", "de": "Feature-Instanz"}},
{"value": "mandate", "label": {"en": "Mandate", "de": "Mandant"}},
{"value": "global", "label": {"en": "Global", "de": "Global"}},
]}
json_schema_extra={"label": "Sichtbarkeit", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "personal", "label": "Persönlich"},
{"value": "featureInstance", "label": "Feature-Instanz"},
{"value": "mandate", "label": "Mandant"},
{"value": "global", "label": "Global"},
]},
)
neutralize: bool = Field(
default=False,
description="Whether this file should be neutralized before AI processing",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
json_schema_extra={"label": "Neutralisieren", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False},
)
registerModelLabels(
"FileItem",
{"en": "File Item", "fr": "Élément de fichier"},
{
"id": {"en": "ID", "fr": "ID"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
"featureInstanceId": {"en": "Feature Instance", "fr": "Instance de fonctionnalité"},
"fileName": {"en": "fileName", "fr": "Nom de fichier"},
"mimeType": {"en": "MIME Type", "fr": "Type MIME"},
"fileHash": {"en": "File Hash", "fr": "Hash du fichier"},
"fileSize": {"en": "File Size", "fr": "Taille du fichier"},
"tags": {"en": "Tags", "fr": "Tags"},
"folderId": {"en": "Folder ID", "fr": "ID du dossier"},
"description": {"en": "Description", "fr": "Description"},
"status": {"en": "Status", "fr": "Statut"},
"scope": {"en": "Scope", "de": "Sichtbarkeit"},
"neutralize": {"en": "Neutralize", "de": "Neutralisieren"},
},
)
@i18nModel("Datei-Vorschau")
class FilePreview(BaseModel):
content: Union[str, bytes] = Field(description="File content (text or binary)")
mimeType: str = Field(description="MIME type of the file")
fileName: str = Field(description="Original fileName")
isText: bool = Field(description="Whether the content is text (True) or binary (False)")
encoding: Optional[str] = Field(None, description="Text encoding if content is text")
size: int = Field(description="Size of the content in bytes")
"""Vorschau-Inhalt einer Datei fuer die Anzeige."""
content: Union[str, bytes] = Field(
description="File content (text or binary)",
json_schema_extra={"label": "Inhalt"},
)
mimeType: str = Field(
description="MIME type of the file",
json_schema_extra={"label": "MIME-Typ"},
)
fileName: str = Field(
description="Original fileName",
json_schema_extra={"label": "Dateiname"},
)
isText: bool = Field(
description="Whether the content is text (True) or binary (False)",
json_schema_extra={"label": "Ist Text"},
)
encoding: Optional[str] = Field(
None,
description="Text encoding if content is text",
json_schema_extra={"label": "Kodierung"},
)
size: int = Field(
description="Size of the content in bytes",
json_schema_extra={"label": "Groesse"},
)
def toDictWithBase64Encoding(self) -> Dict[str, Any]:
"""Convert to dictionary with base64 encoding for binary content."""
@ -72,29 +116,21 @@ class FilePreview(BaseModel):
if isinstance(data.get("content"), bytes):
data["content"] = base64.b64encode(data["content"]).decode("utf-8")
return data
registerModelLabels(
"FilePreview",
{"en": "File Preview", "fr": "Aperçu du fichier"},
{
"content": {"en": "Content", "fr": "Contenu"},
"mimeType": {"en": "MIME Type", "fr": "Type MIME"},
"fileName": {"en": "fileName", "fr": "Nom de fichier"},
"isText": {"en": "Is Text", "fr": "Est du texte"},
"encoding": {"en": "Encoding", "fr": "Encodage"},
"size": {"en": "Size", "fr": "Taille"},
},
)
@i18nModel("Dateidaten")
class FileData(PowerOnModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
data: str = Field(description="File data content")
base64Encoded: bool = Field(description="Whether the data is base64 encoded")
registerModelLabels(
"FileData",
{"en": "File Data", "fr": "Données de fichier"},
{
"id": {"en": "ID", "fr": "ID"},
"data": {"en": "Data", "fr": "Données"},
"base64Encoded": {"en": "Base64 Encoded", "fr": "Encodé en Base64"},
},
)
"""Rohdaten einer Datei (z.B. Base64)."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"label": "ID"},
)
data: str = Field(
description="File data content",
json_schema_extra={"label": "Daten"},
)
base64Encoded: bool = Field(
description="Whether the data is base64 encoded",
json_schema_extra={"label": "Base64-kodiert"},
)

View file

@ -10,9 +10,10 @@ import secrets
from typing import Optional, List
from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.i18nRegistry import i18nModel
@i18nModel("Einladung")
class Invitation(PowerOnModel):
"""
Einladungs-Token für neue User.
@ -21,103 +22,76 @@ class Invitation(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the invitation",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
token: str = Field(
default_factory=lambda: secrets.token_urlsafe(32),
description="Secure invitation token",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "Token", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
# Ziel der Einladung
mandateId: str = Field(
description="FK → Mandate.id - Target mandate for the invitation",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
json_schema_extra={"label": "Mandant", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
)
featureInstanceId: Optional[str] = Field(
default=None,
description="Optional FK → FeatureInstance.id - Direct access to specific feature",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "Feature-Instanz", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
roleIds: List[str] = Field(
default_factory=list,
description="List of Role IDs to assign to the invited user",
json_schema_extra={"frontend_type": "multiselect", "frontend_readonly": False, "frontend_required": True}
json_schema_extra={"label": "Rollen", "frontend_type": "multiselect", "frontend_readonly": False, "frontend_required": True}
)
# Einladungs-Details
targetUsername: Optional[str] = Field(
default=None,
description="Username of the invited user (must match on acceptance)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}
json_schema_extra={"label": "Ziel-Benutzername", "frontend_type": "text", "frontend_readonly": False, "frontend_required": False}
)
email: Optional[str] = Field(
default=None,
description="Email address to send invitation link (optional)",
json_schema_extra={"frontend_type": "email", "frontend_readonly": False, "frontend_required": False}
json_schema_extra={"label": "E-Mail (optional)", "frontend_type": "email", "frontend_readonly": False, "frontend_required": False}
)
expiresAt: float = Field(
description="When the invitation expires (UTC timestamp)",
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": True}
json_schema_extra={"label": "Gueltig bis", "frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": True}
)
# Status
usedBy: Optional[str] = Field(
default=None,
description="User ID of the person who used the invitation",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "Verwendet von", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
usedAt: Optional[float] = Field(
default=None,
description="When the invitation was used (UTC timestamp)",
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "Verwendet am", "frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
)
revokedAt: Optional[float] = Field(
default=None,
description="When the invitation was revoked (UTC timestamp)",
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "Widerrufen am", "frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
)
# Email-Status
emailSent: Optional[bool] = Field(
default=False,
description="Whether the invitation email was successfully sent",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "E-Mail gesendet", "frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False}
)
# Einschränkungen
maxUses: int = Field(
default=1,
ge=1,
le=100,
description="Maximum number of times this invitation can be used",
json_schema_extra={"frontend_type": "number", "frontend_readonly": False, "frontend_required": False}
json_schema_extra={"label": "Max. Verwendungen", "frontend_type": "number", "frontend_readonly": False, "frontend_required": False}
)
currentUses: int = Field(
default=0,
ge=0,
description="Current number of times this invitation has been used",
json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "Aktuelle Verwendungen", "frontend_type": "number", "frontend_readonly": True, "frontend_required": False}
)
registerModelLabels(
"Invitation",
{"en": "Invitation", "de": "Einladung", "fr": "Invitation"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"token": {"en": "Token", "de": "Token", "fr": "Jeton"},
"mandateId": {"en": "Mandate", "de": "Mandant", "fr": "Mandat"},
"featureInstanceId": {"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance"},
"roleIds": {"en": "Roles", "de": "Rollen", "fr": "Rôles"},
"targetUsername": {"en": "Target Username", "de": "Ziel-Benutzername", "fr": "Nom d'utilisateur cible"},
"email": {"en": "Email (optional)", "de": "E-Mail (optional)", "fr": "Email (optionnel)"},
"expiresAt": {"en": "Expires At", "de": "Gültig bis", "fr": "Expire le"},
"usedBy": {"en": "Used By", "de": "Verwendet von", "fr": "Utilisé par"},
"usedAt": {"en": "Used At", "de": "Verwendet am", "fr": "Utilisé le"},
"revokedAt": {"en": "Revoked At", "de": "Widerrufen am", "fr": "Révoqué le"},
"emailSent": {"en": "Email Sent", "de": "E-Mail gesendet", "fr": "Email envoyé"},
"maxUses": {"en": "Max Uses", "de": "Max. Verwendungen", "fr": "Utilisations max"},
"currentUses": {"en": "Current Uses", "de": "Aktuelle Verwendungen", "fr": "Utilisations actuelles"},
},
)

View file

@ -15,173 +15,231 @@ Vector fields use json_schema_extra={"db_type": "vector(1536)"} for pgvector.
from typing import Dict, Any, List, Optional
from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.i18nRegistry import i18nModel
from modules.shared.timeUtils import getUtcTimestamp
import uuid
@i18nModel("Datei-Inhaltsindex")
class FileContentIndex(PowerOnModel):
"""Structural index of a file's content objects. Created without AI.
Scope is mirrored from FileItem (poweron_management) at indexing time."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key (typically = fileId)")
userId: str = Field(description="Owner user ID")
featureInstanceId: str = Field(default="", description="Feature instance scope")
mandateId: str = Field(default="", description="Mandate scope")
fileName: str = Field(description="Original file name")
mimeType: str = Field(description="MIME type of the file")
containerPath: Optional[str] = Field(default=None, description="Path within a container (e.g. 'archive.zip/folder/report.pdf')")
totalObjects: int = Field(default=0, description="Total number of content objects extracted")
totalSize: int = Field(default=0, description="Total size of all content objects in bytes")
structure: Dict[str, Any] = Field(default_factory=dict, description="Structural overview (pages, sections, hierarchy)")
objectSummary: List[Dict[str, Any]] = Field(default_factory=list, description="Compact summary per content object")
extractedAt: float = Field(default_factory=getUtcTimestamp, description="Extraction timestamp")
status: str = Field(default="pending", description="Processing status: pending, extracted, embedding, indexed, failed")
"""Struktureller Index der Inhaltsobjekte einer Datei."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key (typically = fileId)",
json_schema_extra={"label": "ID"},
)
userId: str = Field(
description="Owner user ID",
json_schema_extra={"label": "Benutzer-ID"},
)
featureInstanceId: str = Field(
default="",
description="Feature instance scope",
json_schema_extra={"label": "Feature-Instanz-ID"},
)
mandateId: str = Field(
default="",
description="Mandate scope",
json_schema_extra={"label": "Mandanten-ID"},
)
fileName: str = Field(
description="Original file name",
json_schema_extra={"label": "Dateiname"},
)
mimeType: str = Field(
description="MIME type of the file",
json_schema_extra={"label": "MIME-Typ"},
)
containerPath: Optional[str] = Field(
default=None,
description="Path within a container (e.g. 'archive.zip/folder/report.pdf')",
json_schema_extra={"label": "Container-Pfad"},
)
totalObjects: int = Field(
default=0,
description="Total number of content objects extracted",
json_schema_extra={"label": "Anzahl Objekte"},
)
totalSize: int = Field(
default=0,
description="Total size of all content objects in bytes",
json_schema_extra={"label": "Gesamtgroesse"},
)
structure: Dict[str, Any] = Field(
default_factory=dict,
description="Structural overview (pages, sections, hierarchy)",
json_schema_extra={"label": "Struktur"},
)
objectSummary: List[Dict[str, Any]] = Field(
default_factory=list,
description="Compact summary per content object",
json_schema_extra={"label": "Objekt-Zusammenfassung"},
)
extractedAt: float = Field(
default_factory=getUtcTimestamp,
description="Extraction timestamp",
json_schema_extra={"label": "Extrahiert am"},
)
status: str = Field(
default="pending",
description="Processing status: pending, extracted, embedding, indexed, failed",
json_schema_extra={"label": "Status"},
)
scope: str = Field(
default="personal",
description="Data visibility scope: personal, featureInstance, mandate, global",
json_schema_extra={"label": "Sichtbarkeit"},
)
neutralizationStatus: Optional[str] = Field(
default=None,
description="Neutralization status: completed, failed, skipped, None = not required",
json_schema_extra={"label": "Neutralisierungsstatus"},
)
isNeutralized: bool = Field(
default=False,
description="True if content was neutralized before indexing",
json_schema_extra={"label": "Neutralisiert"},
)
registerModelLabels(
"FileContentIndex",
{"en": "File Content Index", "fr": "Index du contenu de fichier"},
{
"id": {"en": "ID", "fr": "ID"},
"userId": {"en": "User ID", "fr": "ID utilisateur"},
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
"fileName": {"en": "File Name", "fr": "Nom de fichier"},
"mimeType": {"en": "MIME Type", "fr": "Type MIME"},
"containerPath": {"en": "Container Path", "fr": "Chemin du conteneur"},
"totalObjects": {"en": "Total Objects", "fr": "Nombre total d'objets"},
"totalSize": {"en": "Total Size", "fr": "Taille totale"},
"structure": {"en": "Structure", "fr": "Structure"},
"objectSummary": {"en": "Object Summary", "fr": "Résumé des objets"},
"extractedAt": {"en": "Extracted At", "fr": "Extrait le"},
"status": {"en": "Status", "fr": "Statut"},
"scope": {"en": "Scope", "de": "Sichtbarkeit"},
"neutralizationStatus": {"en": "Neutralization Status", "de": "Neutralisierungsstatus"},
"isNeutralized": {"en": "Is Neutralized", "de": "Neutralisiert"},
},
)
@i18nModel("Inhalts-Chunk")
class ContentChunk(PowerOnModel):
"""Persisted content chunk with embedding vector. Reusable across workflows.
Scalar content object (or chunk thereof) with pgvector embedding."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
contentObjectId: str = Field(description="Reference to the content object within FileContentIndex")
fileId: str = Field(description="FK to the source file")
userId: str = Field(description="Owner user ID")
featureInstanceId: str = Field(default="", description="Feature instance scope")
contentType: str = Field(description="Content type: text, image, videostream, audiostream, other")
data: str = Field(description="Content data (text, base64, URL)")
contextRef: Dict[str, Any] = Field(default_factory=dict, description="Context reference (page, position, label)")
summary: Optional[str] = Field(default=None, description="AI-generated summary (on demand)")
chunkMetadata: Dict[str, Any] = Field(default_factory=dict, description="Additional metadata")
"""Persistierter Inhalts-Chunk mit Embedding-Vektor."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"label": "ID"},
)
contentObjectId: str = Field(
description="Reference to the content object within FileContentIndex",
json_schema_extra={"label": "Inhaltsobjekt-ID"},
)
fileId: str = Field(
description="FK to the source file",
json_schema_extra={"label": "Datei-ID"},
)
userId: str = Field(
description="Owner user ID",
json_schema_extra={"label": "Benutzer-ID"},
)
featureInstanceId: str = Field(
default="",
description="Feature instance scope",
json_schema_extra={"label": "Feature-Instanz-ID"},
)
contentType: str = Field(
description="Content type: text, image, videostream, audiostream, other",
json_schema_extra={"label": "Inhaltstyp"},
)
data: str = Field(
description="Content data (text, base64, URL)",
json_schema_extra={"label": "Daten"},
)
contextRef: Dict[str, Any] = Field(
default_factory=dict,
description="Context reference (page, position, label)",
json_schema_extra={"label": "Kontext-Referenz"},
)
summary: Optional[str] = Field(
default=None,
description="AI-generated summary (on demand)",
json_schema_extra={"label": "Zusammenfassung"},
)
chunkMetadata: Dict[str, Any] = Field(
default_factory=dict,
description="Additional metadata",
json_schema_extra={"label": "Metadaten"},
)
embedding: Optional[List[float]] = Field(
default=None, description="pgvector embedding (NOT NULL for text chunks)",
json_schema_extra={"db_type": "vector(1536)"}
default=None,
description="pgvector embedding (NOT NULL for text chunks)",
json_schema_extra={"label": "Embedding", "db_type": "vector(1536)"},
)
registerModelLabels(
"ContentChunk",
{"en": "Content Chunk", "fr": "Fragment de contenu"},
{
"id": {"en": "ID", "fr": "ID"},
"contentObjectId": {"en": "Content Object ID", "fr": "ID de l'objet de contenu"},
"fileId": {"en": "File ID", "fr": "ID du fichier"},
"userId": {"en": "User ID", "fr": "ID utilisateur"},
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance"},
"contentType": {"en": "Content Type", "fr": "Type de contenu"},
"data": {"en": "Data", "fr": "Données"},
"contextRef": {"en": "Context Reference", "fr": "Référence contextuelle"},
"summary": {"en": "Summary", "fr": "Résumé"},
"chunkMetadata": {"en": "Metadata", "fr": "Métadonnées"},
"embedding": {"en": "Embedding", "fr": "Vecteur d'embedding"},
},
)
@i18nModel("Runden-Speicher")
class RoundMemory(PowerOnModel):
"""Persistent per-round memory for agent tool results, file refs, and decisions.
Stored after each agent round so that RAG can retrieve relevant context
even after the ConversationManager summarises older messages away.
"""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
workflowId: str = Field(description="FK to the workflow")
roundNumber: int = Field(default=0, description="Agent round that produced this memory")
memoryType: str = Field(
description="Category: file_ref, tool_result, decision, data_source_ref"
"""Persistenter Speicher pro Agenten-Runde."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"label": "ID"},
)
workflowId: str = Field(
description="FK to the workflow",
json_schema_extra={"label": "Workflow-ID"},
)
roundNumber: int = Field(
default=0,
description="Agent round that produced this memory",
json_schema_extra={"label": "Rundennummer"},
)
memoryType: str = Field(
description="Category: file_ref, tool_result, decision, data_source_ref",
json_schema_extra={"label": "Speichertyp"},
)
key: str = Field(
description="Dedup key, e.g. 'readFile:<fileId>' or 'plan'",
json_schema_extra={"label": "Schluessel"},
)
summary: str = Field(
default="",
description="Compact summary (max ~2000 chars)",
json_schema_extra={"label": "Zusammenfassung"},
)
key: str = Field(description="Dedup key, e.g. 'readFile:<fileId>' or 'plan'")
summary: str = Field(default="", description="Compact summary (max ~2000 chars)")
fullData: Optional[str] = Field(
default=None,
description="Full tool output when small enough (max ~8000 chars)",
json_schema_extra={"label": "Volldaten"},
)
fileIds: List[str] = Field(
default_factory=list,
description="Referenced file IDs",
json_schema_extra={"label": "Datei-IDs"},
)
fileIds: List[str] = Field(default_factory=list, description="Referenced file IDs")
embedding: Optional[List[float]] = Field(
default=None,
description="Embedding of summary for semantic retrieval",
json_schema_extra={"db_type": "vector(1536)"},
json_schema_extra={"label": "Embedding", "db_type": "vector(1536)"},
)
registerModelLabels(
"RoundMemory",
{"en": "Round Memory", "fr": "Mémoire de tour"},
{
"id": {"en": "ID", "fr": "ID"},
"workflowId": {"en": "Workflow ID", "fr": "ID du workflow"},
"roundNumber": {"en": "Round Number", "fr": "Numéro de tour"},
"memoryType": {"en": "Memory Type", "fr": "Type de mémoire"},
"key": {"en": "Key", "fr": "Clé"},
"summary": {"en": "Summary", "fr": "Résumé"},
"fullData": {"en": "Full Data", "fr": "Données complètes"},
"fileIds": {"en": "File IDs", "fr": "IDs de fichier"},
"embedding": {"en": "Embedding", "fr": "Vecteur d'embedding"},
},
)
@i18nModel("Workflow-Speicher")
class WorkflowMemory(PowerOnModel):
"""Workflow-scoped key-value cache for entities and facts.
Extracted during agent rounds, persisted for cross-round and cross-workflow reuse."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
workflowId: str = Field(description="FK to the workflow")
userId: str = Field(description="Owner user ID")
featureInstanceId: str = Field(default="", description="Feature instance scope")
key: str = Field(description="Key identifier (e.g. 'entity:companyName')")
value: str = Field(description="Extracted value")
source: str = Field(default="extraction", description="Origin: extraction, tool, conversation, summary")
embedding: Optional[List[float]] = Field(
default=None, description="Optional embedding for semantic lookup",
json_schema_extra={"db_type": "vector(1536)"}
"""Workflow-spezifischer Key-Value-Cache fuer Entitaeten und Fakten."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"label": "ID"},
)
workflowId: str = Field(
description="FK to the workflow",
json_schema_extra={"label": "Workflow-ID"},
)
userId: str = Field(
description="Owner user ID",
json_schema_extra={"label": "Benutzer-ID"},
)
featureInstanceId: str = Field(
default="",
description="Feature instance scope",
json_schema_extra={"label": "Feature-Instanz-ID"},
)
key: str = Field(
description="Key identifier (e.g. 'entity:companyName')",
json_schema_extra={"label": "Schluessel"},
)
value: str = Field(
description="Extracted value",
json_schema_extra={"label": "Wert"},
)
source: str = Field(
default="extraction",
description="Origin: extraction, tool, conversation, summary",
json_schema_extra={"label": "Quelle"},
)
embedding: Optional[List[float]] = Field(
default=None,
description="Optional embedding for semantic lookup",
json_schema_extra={"label": "Embedding", "db_type": "vector(1536)"},
)
registerModelLabels(
"WorkflowMemory",
{"en": "Workflow Memory", "fr": "Mémoire de workflow"},
{
"id": {"en": "ID", "fr": "ID"},
"workflowId": {"en": "Workflow ID", "fr": "ID du workflow"},
"userId": {"en": "User ID", "fr": "ID utilisateur"},
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance"},
"key": {"en": "Key", "fr": "Clé"},
"value": {"en": "Value", "fr": "Valeur"},
"source": {"en": "Source", "fr": "Source"},
"embedding": {"en": "Embedding", "fr": "Vecteur d'embedding"},
},
)

View file

@ -10,9 +10,10 @@ Rollen werden über Junction Tables verknüpft für saubere CASCADE DELETE.
import uuid
from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.i18nRegistry import i18nModel
@i18nModel("Benutzer-Mandant")
class UserMandate(PowerOnModel):
"""
User-Mitgliedschaft in einem Mandanten.
@ -21,36 +22,24 @@ class UserMandate(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the user-mandate membership",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
)
userId: str = Field(
description="FK → User.id (CASCADE DELETE)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/users/", "frontend_fk_display_field": "username"}
json_schema_extra={"label": "Benutzer", "frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/users/", "frontend_fk_display_field": "username"}
)
mandateId: str = Field(
description="FK → Mandate.id (CASCADE DELETE)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/mandates/", "frontend_fk_display_field": "label"}
json_schema_extra={"label": "Mandant", "frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/mandates/", "frontend_fk_display_field": "label"}
)
enabled: bool = Field(
default=True,
description="Whether this membership is enabled",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
json_schema_extra={"label": "Aktiviert", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
)
# Rollen werden via Junction Table UserMandateRole verknüpft
registerModelLabels(
"UserMandate",
{"en": "User Mandate", "de": "Benutzer-Mandant", "fr": "Mandat utilisateur"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"userId": {"en": "User", "de": "Benutzer", "fr": "Utilisateur"},
"mandateId": {"en": "Mandate", "de": "Mandant", "fr": "Mandat"},
"enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"},
},
)
@i18nModel("Feature-Zugang")
class FeatureAccess(PowerOnModel):
"""
User-Zugriff auf eine Feature-Instanz.
@ -59,36 +48,24 @@ class FeatureAccess(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the feature access",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
)
userId: str = Field(
description="FK → User.id (CASCADE DELETE)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/users/", "frontend_fk_display_field": "username"}
json_schema_extra={"label": "Benutzer", "frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/users/", "frontend_fk_display_field": "username"}
)
featureInstanceId: str = Field(
description="FK → FeatureInstance.id (CASCADE DELETE)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/feature-instances/", "frontend_fk_display_field": "name"}
json_schema_extra={"label": "Feature-Instanz", "frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/features/instances", "frontend_fk_display_field": "label"}
)
enabled: bool = Field(
default=True,
description="Whether this feature access is enabled",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
json_schema_extra={"label": "Aktiviert", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
)
# Rollen werden via Junction Table FeatureAccessRole verknüpft
registerModelLabels(
"FeatureAccess",
{"en": "Feature Access", "de": "Feature-Zugang", "fr": "Accès fonctionnalité"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"userId": {"en": "User", "de": "Benutzer", "fr": "Utilisateur"},
"featureInstanceId": {"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance"},
"enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"},
},
)
@i18nModel("Benutzer-Mandant-Rolle")
class UserMandateRole(PowerOnModel):
"""
Junction Table: UserMandate zu Role.
@ -97,29 +74,19 @@ class UserMandateRole(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the junction record",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
)
userMandateId: str = Field(
description="FK → UserMandate.id (CASCADE DELETE)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/user-mandates/", "frontend_fk_display_field": "userId"}
json_schema_extra={"label": "Benutzer-Mandant", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
)
roleId: str = Field(
description="FK → Role.id (CASCADE DELETE)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/rbac/roles", "frontend_fk_display_field": "roleLabel"}
json_schema_extra={"label": "Rolle", "frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/rbac/roles", "frontend_fk_display_field": "roleLabel"}
)
registerModelLabels(
"UserMandateRole",
{"en": "User Mandate Role", "de": "Benutzer-Mandant-Rolle", "fr": "Rôle mandat utilisateur"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"userMandateId": {"en": "User Mandate", "de": "Benutzer-Mandant", "fr": "Mandat utilisateur"},
"roleId": {"en": "Role", "de": "Rolle", "fr": "Rôle"},
},
)
@i18nModel("Feature-Zugang-Rolle")
class FeatureAccessRole(PowerOnModel):
"""
Junction Table: FeatureAccess zu Role.
@ -128,24 +95,13 @@ class FeatureAccessRole(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the junction record",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
)
featureAccessId: str = Field(
description="FK → FeatureAccess.id (CASCADE DELETE)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/feature-access/", "frontend_fk_display_field": "userId"}
json_schema_extra={"label": "Feature-Zugang", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
)
roleId: str = Field(
description="FK → Role.id (CASCADE DELETE)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/rbac/roles", "frontend_fk_display_field": "roleLabel"}
json_schema_extra={"label": "Rolle", "frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/rbac/roles", "frontend_fk_display_field": "roleLabel"}
)
registerModelLabels(
"FeatureAccessRole",
{"en": "Feature Access Role", "de": "Feature-Zugang-Rolle", "fr": "Rôle accès fonctionnalité"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"featureAccessId": {"en": "Feature Access", "de": "Feature-Zugang", "fr": "Accès fonctionnalité"},
"roleId": {"en": "Role", "de": "Rolle", "fr": "Rôle"},
},
)

View file

@ -7,7 +7,7 @@ from typing import Optional
from enum import Enum
from pydantic import BaseModel, Field, ConfigDict
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.i18nRegistry import i18nModel
class MessagingChannel(str, Enum):
@ -26,86 +26,137 @@ class DeliveryStatus(str, Enum):
FAILED = "failed"
@i18nModel("Messaging-Abonnement")
class MessagingSubscription(PowerOnModel):
"""Data model for messaging subscriptions"""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the subscription",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"label": "ID",
},
)
subscriptionId: str = Field(
description="Unique subscription identifier (e.g., 'system_errors', 'audit_login')",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": False,
"frontend_required": True,
"label": "Abonnement-ID",
},
)
subscriptionLabel: str = Field(
description="Display name of the subscription",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": False,
"frontend_required": True,
"label": "Bezeichnung",
},
)
mandateId: str = Field(
description="ID of the mandate this subscription belongs to",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"label": "Mandanten-ID",
},
)
featureInstanceId: str = Field(
description="ID of the feature instance this subscription belongs to",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"label": "Feature-Instanz-ID",
},
)
description: Optional[str] = Field(
default=None,
description="Description of the subscription",
json_schema_extra={"frontend_type": "textarea", "frontend_readonly": False, "frontend_required": False}
json_schema_extra={
"frontend_type": "textarea",
"frontend_readonly": False,
"frontend_required": False,
"label": "Beschreibung",
},
)
isSystemSubscription: bool = Field(
default=False,
description="Whether this is a system subscription (only admin can create)",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={
"frontend_type": "checkbox",
"frontend_readonly": True,
"frontend_required": False,
"label": "System-Abonnement",
},
)
enabled: bool = Field(
default=True,
description="Whether the subscription is enabled",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
json_schema_extra={
"frontend_type": "checkbox",
"frontend_readonly": False,
"frontend_required": False,
"label": "Aktiviert",
},
)
model_config = ConfigDict(use_enum_values=True)
registerModelLabels(
"MessagingSubscription",
{"en": "Messaging Subscription", "fr": "Abonnement de messagerie"},
{
"id": {"en": "ID", "fr": "ID"},
"subscriptionId": {"en": "Subscription ID", "fr": "ID d'abonnement"},
"subscriptionLabel": {"en": "Subscription Label", "fr": "Label d'abonnement"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
"description": {"en": "Description", "fr": "Description"},
"isSystemSubscription": {"en": "System Subscription", "fr": "Abonnement système"},
"enabled": {"en": "Enabled", "fr": "Activé"},
},
)
@i18nModel("Messaging-Registrierung")
class MessagingSubscriptionRegistration(BaseModel):
"""Data model for user registrations to messaging subscriptions"""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the registration",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"label": "ID",
},
)
mandateId: str = Field(
description="ID of the mandate this registration belongs to",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"label": "Mandanten-ID",
},
)
featureInstanceId: str = Field(
description="ID of the feature instance this registration belongs to",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"label": "Feature-Instanz-ID",
},
)
subscriptionId: str = Field(
description="ID of the subscription this registration belongs to",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": False,
"frontend_required": True,
"label": "Abonnement-ID",
},
)
userId: str = Field(
description="ID of the user registered to this subscription",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"label": "Benutzer-ID",
},
)
channel: MessagingChannel = Field(
description="Channel type for this registration",
@ -114,65 +165,86 @@ class MessagingSubscriptionRegistration(BaseModel):
"frontend_readonly": False,
"frontend_required": True,
"frontend_options": [
{"value": "email", "label": {"en": "Email", "fr": "Email"}},
{"value": "sms", "label": {"en": "SMS", "fr": "SMS"}},
{"value": "whatsapp", "label": {"en": "WhatsApp", "fr": "WhatsApp"}},
{"value": "teams_chat", "label": {"en": "Teams Chat", "fr": "Chat Teams"}}
]
}
{"value": "email", "label": "Email"},
{"value": "sms", "label": "SMS"},
{"value": "whatsapp", "label": "WhatsApp"},
{"value": "teams_chat", "label": "Teams Chat"},
],
"label": "Kanal",
},
)
channelConfig: str = Field(
default="",
description="Channel-specific configuration (e.g., email address, phone number, Teams user ID)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": False,
"frontend_required": False,
"label": "Kanal-Konfiguration",
},
)
enabled: bool = Field(
default=True,
description="Whether this registration is enabled",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
json_schema_extra={
"frontend_type": "checkbox",
"frontend_readonly": False,
"frontend_required": False,
"label": "Aktiviert",
},
)
model_config = ConfigDict(use_enum_values=True)
registerModelLabels(
"MessagingSubscriptionRegistration",
{"en": "Messaging Registration", "fr": "Inscription à la messagerie"},
{
"id": {"en": "ID", "fr": "ID"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
"subscriptionId": {"en": "Subscription ID", "fr": "ID d'abonnement"},
"userId": {"en": "User ID", "fr": "ID utilisateur"},
"channel": {"en": "Channel", "fr": "Canal"},
"channelConfig": {"en": "Channel Config", "fr": "Configuration du canal"},
"enabled": {"en": "Enabled", "fr": "Activé"},
},
)
@i18nModel("Messaging-Zustellung")
class MessagingDelivery(BaseModel):
"""Data model for individual message deliveries"""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the delivery",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"label": "ID",
},
)
mandateId: str = Field(
description="ID of the mandate this delivery belongs to",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"label": "Mandanten-ID",
},
)
featureInstanceId: str = Field(
description="ID of the feature instance this delivery belongs to",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"label": "Feature-Instanz-ID",
},
)
subscriptionId: str = Field(
description="ID of the subscription this delivery belongs to",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"label": "Abonnement-ID",
},
)
userId: str = Field(
description="ID of the user receiving this delivery",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"label": "Benutzer-ID",
},
)
channel: MessagingChannel = Field(
description="Channel used for this delivery",
@ -181,12 +253,13 @@ class MessagingDelivery(BaseModel):
"frontend_readonly": True,
"frontend_required": False,
"frontend_options": [
{"value": "email", "label": {"en": "Email", "fr": "Email"}},
{"value": "sms", "label": {"en": "SMS", "fr": "SMS"}},
{"value": "whatsapp", "label": {"en": "WhatsApp", "fr": "WhatsApp"}},
{"value": "teams_chat", "label": {"en": "Teams Chat", "fr": "Chat Teams"}}
]
}
{"value": "email", "label": "Email"},
{"value": "sms", "label": "SMS"},
{"value": "whatsapp", "label": "WhatsApp"},
{"value": "teams_chat", "label": "Teams Chat"},
],
"label": "Kanal",
},
)
status: DeliveryStatus = Field(
default=DeliveryStatus.PENDING,
@ -196,114 +269,115 @@ class MessagingDelivery(BaseModel):
"frontend_readonly": True,
"frontend_required": False,
"frontend_options": [
{"value": "pending", "label": {"en": "Pending", "fr": "En attente"}},
{"value": "sent", "label": {"en": "Sent", "fr": "Envoyé"}},
{"value": "failed", "label": {"en": "Failed", "fr": "Échoué"}}
]
}
{"value": "pending", "label": "Pending"},
{"value": "sent", "label": "Sent"},
{"value": "failed", "label": "Failed"},
],
"label": "Status",
},
)
errorMessage: Optional[str] = Field(
default=None,
description="Error message if delivery failed",
json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={
"frontend_type": "textarea",
"frontend_readonly": True,
"frontend_required": False,
"label": "Fehlermeldung",
},
)
sentAt: Optional[float] = Field(
default=None,
description="When the delivery was sent (UTC timestamp in seconds)",
json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={
"frontend_type": "datetime",
"frontend_readonly": True,
"frontend_required": False,
"label": "Gesendet am",
},
)
model_config = ConfigDict(use_enum_values=True)
registerModelLabels(
"MessagingDelivery",
{"en": "Messaging Delivery", "fr": "Livraison de messagerie"},
{
"id": {"en": "ID", "fr": "ID"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
"subscriptionId": {"en": "Subscription ID", "fr": "ID d'abonnement"},
"userId": {"en": "User ID", "fr": "ID utilisateur"},
"channel": {"en": "Channel", "fr": "Canal"},
"status": {"en": "Status", "fr": "Statut"},
"errorMessage": {"en": "Error Message", "fr": "Message d'erreur"},
"sentAt": {"en": "Sent At", "fr": "Envoyé le"},
},
)
@i18nModel("Messaging-Ereignisparameter")
class MessagingEventParameters(BaseModel):
"""Data model for event parameters passed to subscription functions"""
triggerData: dict = Field(
default_factory=dict,
description="Event data from trigger as dictionary/JSON",
json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False}
json_schema_extra={
"frontend_type": "json",
"frontend_readonly": False,
"frontend_required": False,
"label": "Trigger-Daten",
},
)
registerModelLabels(
"MessagingEventParameters",
{"en": "Messaging Event Parameters", "fr": "Paramètres d'événement de messagerie"},
{
"triggerData": {"en": "Trigger Data", "fr": "Données de déclenchement"},
},
)
registerModelLabels(
"MessagingSendResult",
{"en": "Messaging Send Result", "fr": "Résultat d'envoi de messagerie"},
{
"success": {"en": "Success", "fr": "Succès"},
"deliveryId": {"en": "Delivery ID", "fr": "ID de livraison"},
"errorMessage": {"en": "Error Message", "fr": "Message d'erreur"},
},
)
registerModelLabels(
"MessagingSubscriptionExecutionResult",
{"en": "Messaging Subscription Execution Result", "fr": "Résultat d'exécution d'abonnement"},
{
"success": {"en": "Success", "fr": "Succès"},
"messagesSent": {"en": "Messages Sent", "fr": "Messages envoyés"},
"errorMessage": {"en": "Error Message", "fr": "Message d'erreur"},
},
)
@i18nModel("Messaging-Sendeergebnis")
class MessagingSendResult(BaseModel):
"""Data model for sendMessage result"""
success: bool = Field(
description="Whether the message was sent successfully",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": True}
json_schema_extra={
"frontend_type": "checkbox",
"frontend_readonly": True,
"frontend_required": True,
"label": "Erfolg",
},
)
deliveryId: Optional[str] = Field(
default=None,
description="ID of the created MessagingDelivery record",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"label": "Zustellungs-ID",
},
)
errorMessage: Optional[str] = Field(
default=None,
description="Error message if sending failed",
json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={
"frontend_type": "textarea",
"frontend_readonly": True,
"frontend_required": False,
"label": "Fehlermeldung",
},
)
@i18nModel("Messaging-Abonnement-Ausführung")
class MessagingSubscriptionExecutionResult(BaseModel):
"""Data model for subscription function execution result"""
success: bool = Field(
description="Whether the subscription execution was successful",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": True}
json_schema_extra={
"frontend_type": "checkbox",
"frontend_readonly": True,
"frontend_required": True,
"label": "Erfolg",
},
)
messagesSent: int = Field(
default=0,
description="Number of messages sent",
json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={
"frontend_type": "number",
"frontend_readonly": True,
"frontend_required": False,
"label": "Gesendete Nachrichten",
},
)
errorMessage: Optional[str] = Field(
default=None,
description="Error message if execution failed",
json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={
"frontend_type": "textarea",
"frontend_readonly": True,
"frontend_required": False,
"label": "Fehlermeldung",
},
)

View file

@ -10,7 +10,7 @@ from typing import Optional, List
from enum import Enum
from pydantic import BaseModel, Field, ConfigDict
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.i18nRegistry import i18nModel
class NotificationType(str, Enum):
@ -29,20 +29,25 @@ class NotificationStatus(str, Enum):
DISMISSED = "dismissed" # Verworfen/Geschlossen
@i18nModel("Benachrichtigungs-Aktion")
class NotificationAction(BaseModel):
"""Possible action for a notification"""
actionId: str = Field(
description="Unique identifier for the action (e.g., 'accept', 'decline')"
description="Unique identifier for the action (e.g., 'accept', 'decline')",
json_schema_extra={"label": "Aktions-ID"},
)
label: str = Field(
description="Display label for the action button"
description="Display label for the action button",
json_schema_extra={"label": "Bezeichnung"},
)
style: str = Field(
default="default",
description="Button style: 'primary', 'danger', 'default'"
description="Button style: 'primary', 'danger', 'default'",
json_schema_extra={"label": "Stil"},
)
@i18nModel("Benachrichtigung")
class UserNotification(PowerOnModel):
"""
In-app notification for a user.
@ -51,26 +56,26 @@ class UserNotification(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the notification",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
userId: str = Field(
description="Target user ID for this notification",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
json_schema_extra={"label": "Benutzer", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
)
# Notification type and status
type: NotificationType = Field(
default=NotificationType.SYSTEM,
description="Type of notification",
json_schema_extra={
"label": "Typ",
"frontend_type": "select",
"frontend_readonly": True,
"frontend_required": True,
"frontend_options": [
{"value": "invitation", "label": {"en": "Invitation", "de": "Einladung"}},
{"value": "system", "label": {"en": "System", "de": "System"}},
{"value": "workflow", "label": {"en": "Workflow", "de": "Workflow"}},
{"value": "mention", "label": {"en": "Mention", "de": "Erwähnung"}}
{"value": "invitation", "label": "Einladung"},
{"value": "system", "label": "System"},
{"value": "workflow", "label": "Workflow"},
{"value": "mention", "label": "Erwähnung"}
]
}
)
@ -78,126 +83,75 @@ class UserNotification(PowerOnModel):
default=NotificationStatus.UNREAD,
description="Current status of the notification",
json_schema_extra={
"label": "Status",
"frontend_type": "select",
"frontend_readonly": True,
"frontend_required": False,
"frontend_options": [
{"value": "unread", "label": {"en": "Unread", "de": "Ungelesen"}},
{"value": "read", "label": {"en": "Read", "de": "Gelesen"}},
{"value": "actioned", "label": {"en": "Actioned", "de": "Bearbeitet"}},
{"value": "dismissed", "label": {"en": "Dismissed", "de": "Verworfen"}}
{"value": "unread", "label": "Ungelesen"},
{"value": "read", "label": "Gelesen"},
{"value": "actioned", "label": "Bearbeitet"},
{"value": "dismissed", "label": "Verworfen"}
]
}
)
# Content
title: str = Field(
description="Notification title",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
json_schema_extra={"label": "Titel", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
)
message: str = Field(
description="Notification message/body",
json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": True}
json_schema_extra={"label": "Nachricht", "frontend_type": "textarea", "frontend_readonly": True, "frontend_required": True}
)
icon: Optional[str] = Field(
default=None,
description="Optional icon identifier (e.g., 'mail', 'warning', 'info')",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "Symbol", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
# Reference to triggering object (for actionable notifications)
referenceType: Optional[str] = Field(
default=None,
description="Type of referenced object (e.g., 'Invitation', 'Workflow')",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "Referenz-Typ", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
referenceId: Optional[str] = Field(
default=None,
description="ID of referenced object",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "Referenz-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
# Actions (for actionable notifications like invitations)
actions: Optional[List[NotificationAction]] = Field(
default=None,
description="List of possible actions for this notification",
json_schema_extra={"frontend_type": "json", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "Aktionen", "frontend_type": "json", "frontend_readonly": True, "frontend_required": False}
)
# Action result (when user takes action)
actionTaken: Optional[str] = Field(
default=None,
description="Which action was taken (actionId)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "Durchgefuehrte Aktion", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
actionResult: Optional[str] = Field(
default=None,
description="Result message from the action",
json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "Aktions-Ergebnis", "frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False}
)
# Timestamps
readAt: Optional[float] = Field(
default=None,
description="When the notification was read (UTC timestamp)",
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "Gelesen am", "frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
)
actionedAt: Optional[float] = Field(
default=None,
description="When action was taken (UTC timestamp)",
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "Bearbeitet am", "frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
)
expiresAt: Optional[float] = Field(
default=None,
description="When the notification expires (optional, UTC timestamp)",
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "Gueltig bis", "frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
)
model_config = ConfigDict(use_enum_values=True)
registerModelLabels(
"UserNotification",
{"en": "Notification", "de": "Benachrichtigung", "fr": "Notification"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"userId": {"en": "User", "de": "Benutzer", "fr": "Utilisateur"},
"type": {"en": "Type", "de": "Typ", "fr": "Type"},
"status": {"en": "Status", "de": "Status", "fr": "Statut"},
"title": {"en": "Title", "de": "Titel", "fr": "Titre"},
"message": {"en": "Message", "de": "Nachricht", "fr": "Message"},
"icon": {"en": "Icon", "de": "Symbol", "fr": "Icône"},
"referenceType": {"en": "Reference Type", "de": "Referenz-Typ", "fr": "Type de référence"},
"referenceId": {"en": "Reference ID", "de": "Referenz-ID", "fr": "ID de référence"},
"actions": {"en": "Actions", "de": "Aktionen", "fr": "Actions"},
"actionTaken": {"en": "Action Taken", "de": "Durchgeführte Aktion", "fr": "Action effectuée"},
"actionResult": {"en": "Action Result", "de": "Aktions-Ergebnis", "fr": "Résultat de l'action"},
"readAt": {"en": "Read At", "de": "Gelesen am", "fr": "Lu le"},
"actionedAt": {"en": "Actioned At", "de": "Bearbeitet am", "fr": "Traité le"},
"expiresAt": {"en": "Expires At", "de": "Gültig bis", "fr": "Expire le"},
},
)
registerModelLabels(
"NotificationType",
{"en": "Notification Type", "de": "Benachrichtigungs-Typ", "fr": "Type de notification"},
{
"invitation": {"en": "Invitation", "de": "Einladung", "fr": "Invitation"},
"system": {"en": "System", "de": "System", "fr": "Système"},
"workflow": {"en": "Workflow", "de": "Workflow", "fr": "Workflow"},
"mention": {"en": "Mention", "de": "Erwähnung", "fr": "Mention"},
},
)
registerModelLabels(
"NotificationStatus",
{"en": "Notification Status", "de": "Benachrichtigungs-Status", "fr": "Statut de notification"},
{
"unread": {"en": "Unread", "de": "Ungelesen", "fr": "Non lu"},
"read": {"en": "Read", "de": "Gelesen", "fr": "Lu"},
"actioned": {"en": "Actioned", "de": "Bearbeitet", "fr": "Traité"},
"dismissed": {"en": "Dismissed", "de": "Verworfen", "fr": "Rejeté"},
},
)

View file

@ -14,7 +14,7 @@ from typing import Optional
from enum import Enum
from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.i18nRegistry import i18nModel
from modules.datamodels.datamodelUtils import TextMultilingual
from modules.datamodels.datamodelUam import AccessLevel
@ -26,6 +26,7 @@ class AccessRuleContext(str, Enum):
RESOURCE = "RESOURCE" # System resources (AI models, actions, etc.)
@i18nModel("Rolle")
class Role(PowerOnModel):
"""
Data model for RBAC roles.
@ -41,56 +42,42 @@ class Role(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the role",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
)
roleLabel: str = Field(
description="Unique role label identifier (e.g., 'admin', 'user', 'viewer')",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}
json_schema_extra={"label": "Rollen-Label", "frontend_type": "text", "frontend_readonly": False, "frontend_required": True}
)
description: TextMultilingual = Field(
description="Role description in multiple languages",
json_schema_extra={"frontend_type": "multilingual", "frontend_readonly": False, "frontend_required": True}
json_schema_extra={"label": "Beschreibung", "frontend_type": "multilingual", "frontend_readonly": False, "frontend_required": True}
)
# KONTEXT - IMMUTABLE nach Create (nur Create/Delete, kein Update!)
mandateId: Optional[str] = Field(
default=None,
description="FK → Mandate.id (CASCADE DELETE). Null = Global/Template role.",
json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_visible": True, "frontend_required": False, "frontend_fk_source": "/api/mandates/", "frontend_fk_display_field": "label"}
json_schema_extra={"label": "Mandant", "frontend_type": "select", "frontend_readonly": True, "frontend_visible": True, "frontend_required": False, "frontend_fk_source": "/api/mandates/", "frontend_fk_display_field": "label"}
)
featureInstanceId: Optional[str] = Field(
default=None,
description="FK → FeatureInstance.id (CASCADE DELETE). Null = Mandate-level or Global role.",
json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_visible": True, "frontend_required": False, "frontend_fk_source": "/api/feature-instances/", "frontend_fk_display_field": "name"}
json_schema_extra={"label": "Feature-Instanz", "frontend_type": "select", "frontend_readonly": True, "frontend_visible": True, "frontend_required": False, "frontend_fk_source": "/api/features/instances", "frontend_fk_display_field": "label"}
)
featureCode: Optional[str] = Field(
default=None,
description="Feature code (z.B. 'trustee') - für Template-Rollen",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
json_schema_extra={"label": "Feature-Code", "frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
)
isSystemRole: bool = Field(
default=False,
description="Whether this is a system role that cannot be deleted",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"label": "System-Rolle", "frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False}
)
registerModelLabels(
"Role",
{"en": "Role", "de": "Rolle", "fr": "Rôle"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"roleLabel": {"en": "Role Label", "de": "Rollen-Label", "fr": "Label du rôle"},
"description": {"en": "Description", "de": "Beschreibung", "fr": "Description"},
"mandateId": {"en": "Mandate", "de": "Mandant", "fr": "Mandat"},
"featureInstanceId": {"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance"},
"featureCode": {"en": "Feature Code", "de": "Feature-Code", "fr": "Code fonctionnalité"},
"isSystemRole": {"en": "System Role", "de": "System-Rolle", "fr": "Rôle système"},
},
)
@i18nModel("Zugriffsregel")
class AccessRule(PowerOnModel):
"""
Data model for access control rules.
@ -101,89 +88,72 @@ class AccessRule(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the access rule",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
)
roleId: str = Field(
description="FK → Role.id (CASCADE DELETE!)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": True, "frontend_fk_source": "/api/rbac/roles", "frontend_fk_display_field": "roleLabel"}
json_schema_extra={"label": "Rolle", "frontend_type": "select", "frontend_readonly": True, "frontend_required": True, "frontend_fk_source": "/api/rbac/roles", "frontend_fk_display_field": "roleLabel"}
)
context: AccessRuleContext = Field(
description="Context type: DATA (database), UI (interface), RESOURCE (system resources). IMMUTABLE!",
json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": True, "frontend_options": [
{"value": "DATA", "label": {"en": "Data", "de": "Daten", "fr": "Données"}},
{"value": "UI", "label": {"en": "UI", "de": "Oberfläche", "fr": "Interface"}},
{"value": "RESOURCE", "label": {"en": "Resource", "de": "Ressource", "fr": "Ressource"}}
json_schema_extra={"label": "Kontext", "frontend_type": "select", "frontend_readonly": True, "frontend_required": True, "frontend_options": [
{"value": "DATA", "label": "Daten"},
{"value": "UI", "label": "Oberfläche"},
{"value": "RESOURCE", "label": "Ressource"}
]}
)
item: Optional[str] = Field(
default=None,
description="Item identifier (null = all items in context). Format: DATA: '<table>' or '<table>.<field>', UI: cascading string (e.g., 'playground.voice.settings'), RESOURCE: cascading string (e.g., 'ai.model.anthropic')",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}
json_schema_extra={"label": "Element", "frontend_type": "text", "frontend_readonly": False, "frontend_required": False}
)
view: bool = Field(
default=False,
description="View permission: if true, item is visible/enabled. Only objects with view=true are shown.",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": True}
json_schema_extra={"label": "Anzeigen", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": True}
)
read: Optional[AccessLevel] = Field(
default=None,
description="Read permission level (only for DATA context)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "a", "label": {"en": "All Records", "de": "Alle Datensätze", "fr": "Tous les enregistrements"}},
{"value": "m", "label": {"en": "My Records", "de": "Meine Datensätze", "fr": "Mes enregistrements"}},
{"value": "g", "label": {"en": "Group Records", "de": "Gruppen-Datensätze", "fr": "Enregistrements du groupe"}},
{"value": "n", "label": {"en": "No Access", "de": "Kein Zugriff", "fr": "Aucun accès"}}
json_schema_extra={"label": "Lesen", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "a", "label": "Alle Datensätze"},
{"value": "m", "label": "Meine Datensätze"},
{"value": "g", "label": "Gruppen-Datensätze"},
{"value": "n", "label": "Kein Zugriff"}
]}
)
create: Optional[AccessLevel] = Field(
default=None,
description="Create permission level (only for DATA context)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "a", "label": {"en": "All Records", "de": "Alle Datensätze", "fr": "Tous les enregistrements"}},
{"value": "m", "label": {"en": "My Records", "de": "Meine Datensätze", "fr": "Mes enregistrements"}},
{"value": "g", "label": {"en": "Group Records", "de": "Gruppen-Datensätze", "fr": "Enregistrements du groupe"}},
{"value": "n", "label": {"en": "No Access", "de": "Kein Zugriff", "fr": "Aucun accès"}}
json_schema_extra={"label": "Erstellen", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "a", "label": "Alle Datensätze"},
{"value": "m", "label": "Meine Datensätze"},
{"value": "g", "label": "Gruppen-Datensätze"},
{"value": "n", "label": "Kein Zugriff"}
]}
)
update: Optional[AccessLevel] = Field(
default=None,
description="Update permission level (only for DATA context)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "a", "label": {"en": "All Records", "de": "Alle Datensätze", "fr": "Tous les enregistrements"}},
{"value": "m", "label": {"en": "My Records", "de": "Meine Datensätze", "fr": "Mes enregistrements"}},
{"value": "g", "label": {"en": "Group Records", "de": "Gruppen-Datensätze", "fr": "Enregistrements du groupe"}},
{"value": "n", "label": {"en": "No Access", "de": "Kein Zugriff", "fr": "Aucun accès"}}
json_schema_extra={"label": "Aktualisieren", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "a", "label": "Alle Datensätze"},
{"value": "m", "label": "Meine Datensätze"},
{"value": "g", "label": "Gruppen-Datensätze"},
{"value": "n", "label": "Kein Zugriff"}
]}
)
delete: Optional[AccessLevel] = Field(
default=None,
description="Delete permission level (only for DATA context)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "a", "label": {"en": "All Records", "de": "Alle Datensätze", "fr": "Tous les enregistrements"}},
{"value": "m", "label": {"en": "My Records", "de": "Meine Datensätze", "fr": "Mes enregistrements"}},
{"value": "g", "label": {"en": "Group Records", "de": "Gruppen-Datensätze", "fr": "Enregistrements du groupe"}},
{"value": "n", "label": {"en": "No Access", "de": "Kein Zugriff", "fr": "Aucun accès"}}
json_schema_extra={"label": "Loeschen", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "a", "label": "Alle Datensätze"},
{"value": "m", "label": "Meine Datensätze"},
{"value": "g", "label": "Gruppen-Datensätze"},
{"value": "n", "label": "Kein Zugriff"}
]}
)
registerModelLabels(
"AccessRule",
{"en": "Access Rule", "de": "Zugriffsregel", "fr": "Règle d'accès"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"roleId": {"en": "Role", "de": "Rolle", "fr": "Rôle"},
"context": {"en": "Context", "de": "Kontext", "fr": "Contexte"},
"item": {"en": "Item", "de": "Element", "fr": "Élément"},
"view": {"en": "View", "de": "Anzeigen", "fr": "Vue"},
"read": {"en": "Read", "de": "Lesen", "fr": "Lecture"},
"create": {"en": "Create", "de": "Erstellen", "fr": "Créer"},
"update": {"en": "Update", "de": "Aktualisieren", "fr": "Mettre à jour"},
"delete": {"en": "Delete", "de": "Löschen", "fr": "Supprimer"},
},
)
# IMMUTABLE Fields Definition - für Enforcement auf Application-Level
IMMUTABLE_FIELDS = {
"Role": ["mandateId", "featureInstanceId", "featureCode"],

View file

@ -12,7 +12,7 @@ Multi-Tenant Design:
from typing import Optional, Any
from pydantic import BaseModel, Field, ConfigDict, model_validator
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.i18nRegistry import i18nModel
from modules.shared.timeUtils import getUtcTimestamp
from .datamodelUam import AuthAuthority
from enum import Enum
@ -31,6 +31,7 @@ class TokenPurpose(str, Enum):
DATA_CONNECTION = "dataConnection"
@i18nModel("Token")
class Token(PowerOnModel):
"""
Authentication Token model.
@ -40,37 +41,69 @@ class Token(PowerOnModel):
- Ermöglicht parallele Arbeit in mehreren Mandanten
- Mandant-Kontext wird per Request-Header bestimmt
"""
id: Optional[str] = None
userId: str
authority: AuthAuthority
id: Optional[str] = Field(
default=None,
json_schema_extra={"label": "ID"},
)
userId: str = Field(
...,
json_schema_extra={"label": "Benutzer-ID"},
)
authority: AuthAuthority = Field(
...,
json_schema_extra={"label": "Autoritaet"},
)
connectionId: Optional[str] = Field(
None, description="ID of the connection this token belongs to"
None,
description="ID of the connection this token belongs to",
json_schema_extra={"label": "Verbindungs-ID"},
)
tokenPurpose: Optional[TokenPurpose] = Field(
default=None,
description="authSession = gateway login JWT; dataConnection = provider OAuth for a connection",
json_schema_extra={"label": "Token-Verwendung"},
)
tokenAccess: str = Field(
...,
json_schema_extra={"label": "Zugriffstoken"},
)
tokenType: str = Field(
default="bearer",
json_schema_extra={"label": "Token-Typ"},
)
tokenAccess: str
tokenType: str = "bearer"
expiresAt: float = Field(
description="When the token expires (UTC timestamp in seconds)"
description="When the token expires (UTC timestamp in seconds)",
json_schema_extra={"label": "Laeuft ab am"},
)
tokenRefresh: Optional[str] = Field(
default=None,
json_schema_extra={"label": "Refresh-Token"},
)
tokenRefresh: Optional[str] = None
status: TokenStatus = Field(
default=TokenStatus.ACTIVE, description="Token status: active/revoked"
default=TokenStatus.ACTIVE,
description="Token status: active/revoked",
json_schema_extra={"label": "Status"},
)
revokedAt: Optional[float] = Field(
None, description="When the token was revoked (UTC timestamp in seconds)"
None,
description="When the token was revoked (UTC timestamp in seconds)",
json_schema_extra={"label": "Widerrufen am"},
)
revokedBy: Optional[str] = Field(
None, description="User ID who revoked the token (admin/self)"
None,
description="User ID who revoked the token (admin/self)",
json_schema_extra={"label": "Widerrufen von"},
)
reason: Optional[str] = Field(
None,
description="Optional revocation reason",
json_schema_extra={"label": "Grund"},
)
reason: Optional[str] = Field(None, description="Optional revocation reason")
sessionId: Optional[str] = Field(
None, description="Logical session grouping for logout revocation"
None,
description="Logical session grouping for logout revocation",
json_schema_extra={"label": "Sitzungs-ID"},
)
# ENTFERNT: mandateId - Token ist nicht mehr Mandant-spezifisch
# Mandant-Kontext wird per Request-Header (X-Mandate-Id) bestimmt
model_config = ConfigDict(use_enum_values=True)
@ -91,51 +124,44 @@ class Token(PowerOnModel):
return data
registerModelLabels(
"Token",
{"en": "Token", "de": "Token", "fr": "Jeton"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"},
"authority": {"en": "Authority", "de": "Autorität", "fr": "Autorité"},
"connectionId": {"en": "Connection ID", "de": "Verbindungs-ID", "fr": "ID de connexion"},
"tokenPurpose": {"en": "Token purpose", "de": "Token-Verwendung", "fr": "Usage du jeton"},
"tokenAccess": {"en": "Access Token", "de": "Zugriffstoken", "fr": "Jeton d'accès"},
"tokenType": {"en": "Token Type", "de": "Token-Typ", "fr": "Type de jeton"},
"expiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"},
"tokenRefresh": {"en": "Refresh Token", "de": "Refresh-Token", "fr": "Jeton de rafraîchissement"},
"status": {"en": "Status", "de": "Status", "fr": "Statut"},
"revokedAt": {"en": "Revoked At", "de": "Widerrufen am", "fr": "Révoqué le"},
"revokedBy": {"en": "Revoked By", "de": "Widerrufen von", "fr": "Révoqué par"},
"reason": {"en": "Reason", "de": "Grund", "fr": "Raison"},
"sessionId": {"en": "Session ID", "de": "Sitzungs-ID", "fr": "ID de session"},
},
)
@i18nModel("Authentifizierungsereignis")
class AuthEvent(PowerOnModel):
"""Authentication event for audit logging."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the auth event", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
userId: str = Field(description="ID of the user this event belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
eventType: str = Field(description="Type of authentication event (e.g., 'login', 'logout', 'token_refresh')", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
timestamp: float = Field(default_factory=getUtcTimestamp, description="Unix timestamp when the event occurred", json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": True})
ipAddress: Optional[str] = Field(default=None, description="IP address from which the event originated", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
userAgent: Optional[str] = Field(default=None, description="User agent string from the request", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
success: bool = Field(default=True, description="Whether the authentication event was successful", json_schema_extra={"frontend_type": "boolean", "frontend_readonly": True, "frontend_required": True})
details: Optional[str] = Field(default=None, description="Additional details about the event", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
registerModelLabels(
"AuthEvent",
{"en": "Authentication Event", "de": "Authentifizierungsereignis", "fr": "Événement d'authentification"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"},
"eventType": {"en": "Event Type", "de": "Ereignistyp", "fr": "Type d'événement"},
"timestamp": {"en": "Timestamp", "de": "Zeitstempel", "fr": "Horodatage"},
"ipAddress": {"en": "IP Address", "de": "IP-Adresse", "fr": "Adresse IP"},
"userAgent": {"en": "User Agent", "de": "User-Agent", "fr": "Agent utilisateur"},
"success": {"en": "Success", "de": "Erfolgreich", "fr": "Succès"},
"details": {"en": "Details", "de": "Details", "fr": "Détails"},
},
)
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the auth event",
json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
)
userId: str = Field(
description="ID of the user this event belongs to",
json_schema_extra={"label": "Benutzer-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
)
eventType: str = Field(
description="Type of authentication event (e.g., 'login', 'logout', 'token_refresh')",
json_schema_extra={"label": "Ereignistyp", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
)
timestamp: float = Field(
default_factory=getUtcTimestamp,
description="Unix timestamp when the event occurred",
json_schema_extra={"label": "Zeitstempel", "frontend_type": "datetime", "frontend_readonly": True, "frontend_required": True},
)
ipAddress: Optional[str] = Field(
default=None,
description="IP address from which the event originated",
json_schema_extra={"label": "IP-Adresse", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
)
userAgent: Optional[str] = Field(
default=None,
description="User agent string from the request",
json_schema_extra={"label": "User-Agent", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
)
success: bool = Field(
default=True,
description="Whether the authentication event was successful",
json_schema_extra={"label": "Erfolgreich", "frontend_type": "boolean", "frontend_readonly": True, "frontend_required": True},
)
details: Optional[str] = Field(
default=None,
description="Additional details about the event",
json_schema_extra={"label": "Details", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
)

View file

@ -11,7 +11,7 @@ from enum import Enum
from datetime import datetime, timezone
from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.i18nRegistry import i18nModel, t
import uuid
@ -55,123 +55,234 @@ class BillingPeriodEnum(str, Enum):
# Catalog: SubscriptionPlan (static, in-memory)
# ============================================================================
@i18nModel("Abonnement-Plan")
class SubscriptionPlan(BaseModel):
"""Plan definition (catalog entry). Not stored per mandate — static."""
planKey: str = Field(..., description="Unique plan identifier")
selectableByUser: bool = Field(default=True, description="Whether users can choose this plan in the UI")
"""Plan-Definition (Katalog). Nicht pro Mandat gespeichert — statisch."""
planKey: str = Field(
...,
description="Unique plan identifier",
json_schema_extra={"label": "Plan"},
)
selectableByUser: bool = Field(
default=True,
description="Whether users can choose this plan in the UI",
json_schema_extra={"label": "Waehlbar"},
)
title: Dict[str, str] = Field(default_factory=dict, description="Multilingual title (en/de/fr)")
description: Dict[str, str] = Field(default_factory=dict, description="Multilingual description")
title: str = Field(
default="",
description="Plan title (i18n key)",
json_schema_extra={"label": "Titel"},
)
description: str = Field(
default="",
description="Plan description (i18n key)",
json_schema_extra={"label": "Beschreibung"},
)
currency: str = Field(default="CHF", description="Billing currency")
billingPeriod: BillingPeriodEnum = Field(default=BillingPeriodEnum.MONTHLY, description="Recurring interval")
pricePerUserCHF: float = Field(default=0.0, description="Price per active user per period")
pricePerFeatureInstanceCHF: float = Field(default=0.0, description="Price per active feature instance per period")
autoRenew: bool = Field(default=True, description="Stripe renews automatically at period end")
currency: str = Field(
default="CHF",
description="Billing currency",
json_schema_extra={"label": "Waehrung"},
)
billingPeriod: BillingPeriodEnum = Field(
default=BillingPeriodEnum.MONTHLY,
description="Recurring interval",
json_schema_extra={"label": "Abrechnungszeitraum"},
)
pricePerUserCHF: float = Field(
default=0.0,
description="Price per active user per period",
json_schema_extra={"label": "Preis pro User (CHF)"},
)
pricePerFeatureInstanceCHF: float = Field(
default=0.0,
description="Price per additional module beyond included (monthly, CHF)",
json_schema_extra={"label": "Preis pro Modul (CHF)"},
)
autoRenew: bool = Field(
default=True,
description="Stripe renews automatically at period end",
json_schema_extra={"label": "Auto-Verlaengerung"},
)
maxUsers: Optional[int] = Field(None, description="Hard cap on active users (None = unlimited)")
maxFeatureInstances: Optional[int] = Field(None, description="Hard cap on active feature instances (None = unlimited)")
trialDays: Optional[int] = Field(None, description="Trial duration in days (only for trial plans)")
maxDataVolumeMB: Optional[int] = Field(None, description="Soft-limit for data volume in MB per mandate (None = unlimited)")
budgetAiCHF: float = Field(default=0.0, description="AI budget (CHF) included in subscription price per billing period")
successorPlanKey: Optional[str] = Field(None, description="Plan to transition to when trial ends")
registerModelLabels(
"SubscriptionPlan",
{"en": "Subscription Plan", "de": "Abonnement-Plan", "fr": "Plan d'abonnement"},
{
"planKey": {"en": "Plan", "de": "Plan", "fr": "Plan"},
"selectableByUser": {"en": "Selectable", "de": "Wählbar", "fr": "Sélectionnable"},
"billingPeriod": {"en": "Billing Period", "de": "Abrechnungszeitraum", "fr": "Période de facturation"},
"pricePerUserCHF": {"en": "Price per User (CHF)", "de": "Preis pro User (CHF)"},
"pricePerFeatureInstanceCHF": {"en": "Price per Instance (CHF)", "de": "Preis pro Instanz (CHF)"},
"maxUsers": {"en": "Max Users", "de": "Max. Benutzer", "fr": "Max. utilisateurs"},
"maxFeatureInstances": {"en": "Max Instances", "de": "Max. Instanzen", "fr": "Max. instances"},
"maxDataVolumeMB": {"en": "Data Volume (MB)", "de": "Datenvolumen (MB)"},
"budgetAiCHF": {"en": "AI Budget (CHF)", "de": "AI-Budget (CHF)"},
},
)
maxUsers: Optional[int] = Field(
None,
description="Hard cap on active users (None = unlimited)",
json_schema_extra={"label": "Max. Benutzer"},
)
maxFeatureInstances: Optional[int] = Field(
None,
description="Hard cap on active modules (None = unlimited)",
json_schema_extra={"label": "Max. Module"},
)
includedModules: int = Field(
default=0,
description="Number of modules included in plan at no extra charge",
json_schema_extra={"label": "Inkl. Module"},
)
trialDays: Optional[int] = Field(
None,
description="Trial duration in days (only for trial plans)",
json_schema_extra={"label": "Probentage"},
)
maxDataVolumeMB: Optional[int] = Field(
None,
description="Soft-limit for data volume in MB per mandate (None = unlimited)",
json_schema_extra={"label": "Datenvolumen (MB)"},
)
budgetAiCHF: float = Field(
default=0.0,
description="AI budget (CHF) total per billing period (users * budgetAiPerUserCHF at activation)",
json_schema_extra={"label": "AI-Budget (CHF)"},
)
budgetAiPerUserCHF: float = Field(
default=0.0,
description="AI budget per user per month (CHF). Total = users * this value.",
json_schema_extra={"label": "AI-Budget pro User (CHF)"},
)
successorPlanKey: Optional[str] = Field(
None,
description="Plan to transition to when trial ends",
json_schema_extra={"label": "Nachfolge-Plan"},
)
# ============================================================================
# Stripe Price mapping (persisted in DB, auto-created at bootstrap)
# ============================================================================
@i18nModel("Stripe-Planpreise")
class StripePlanPrice(BaseModel):
"""Persisted mapping from planKey to Stripe Product/Price IDs.
Auto-created at startup no manual configuration needed.
Uses separate Stripe Products for users and instances for clear invoice labels."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
planKey: str = Field(..., description="Reference to SubscriptionPlan.planKey")
stripeProductId: str = Field("", description="Legacy single-product ID (unused)")
stripeProductIdUsers: Optional[str] = Field(None, description="Stripe Product ID for user licenses")
stripeProductIdInstances: Optional[str] = Field(None, description="Stripe Product ID for feature instances")
stripePriceIdUsers: Optional[str] = Field(None, description="Stripe Price ID for user-seat line item")
stripePriceIdInstances: Optional[str] = Field(None, description="Stripe Price ID for instance line item")
registerModelLabels(
"StripePlanPrice",
{"en": "Stripe Plan Prices", "de": "Stripe-Planpreise"},
{
"planKey": {"en": "Plan", "de": "Plan"},
"stripeProductIdUsers": {"en": "Product (Users)", "de": "Produkt (User)"},
"stripeProductIdInstances": {"en": "Product (Instances)", "de": "Produkt (Instanzen)"},
"stripePriceIdUsers": {"en": "Price ID (Users)", "de": "Preis-ID (User)"},
"stripePriceIdInstances": {"en": "Price ID (Instances)", "de": "Preis-ID (Instanzen)"},
},
)
"""Persistierte Zuordnung planKey zu Stripe Product/Price IDs."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"label": "ID"},
)
planKey: str = Field(
...,
description="Reference to SubscriptionPlan.planKey",
json_schema_extra={"label": "Plan"},
)
stripeProductId: str = Field(
"",
description="Legacy single-product ID (unused)",
json_schema_extra={"label": "Stripe-Produkt-ID (Legacy)"},
)
stripeProductIdUsers: Optional[str] = Field(
None,
description="Stripe Product ID for user licenses",
json_schema_extra={"label": "Produkt (User)"},
)
stripeProductIdInstances: Optional[str] = Field(
None,
description="Stripe Product ID for modules",
json_schema_extra={"label": "Produkt (Module)"},
)
stripePriceIdUsers: Optional[str] = Field(
None,
description="Stripe Price ID for user-seat line item",
json_schema_extra={"label": "Preis-ID (User)"},
)
stripePriceIdInstances: Optional[str] = Field(
None,
description="Stripe Price ID for module line item",
json_schema_extra={"label": "Preis-ID (Module)"},
)
# ============================================================================
# Instance: MandateSubscription
# ============================================================================
@i18nModel("Mandanten-Abonnement")
class MandateSubscription(PowerOnModel):
"""A subscription instance bound to a specific mandate.
See wiki/concepts/Subscription-State-Machine.md for state transitions."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
mandateId: str = Field(..., description="Foreign key to Mandate")
planKey: str = Field(..., description="Reference to SubscriptionPlan.planKey")
"""Abonnement-Instanz gebunden an einen Mandanten."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"label": "ID"},
)
mandateId: str = Field(
...,
description="Foreign key to Mandate",
json_schema_extra={"label": "Mandanten-ID"},
)
planKey: str = Field(
...,
description="Reference to SubscriptionPlan.planKey",
json_schema_extra={"label": "Plan"},
)
status: SubscriptionStatusEnum = Field(default=SubscriptionStatusEnum.PENDING, description="Current lifecycle status")
recurring: bool = Field(default=True, description="True: auto-renews at period end. False: expires at period end (gekuendigt).")
status: SubscriptionStatusEnum = Field(
default=SubscriptionStatusEnum.PENDING,
description="Current lifecycle status",
json_schema_extra={"label": "Status"},
)
recurring: bool = Field(
default=True,
description="True: auto-renews at period end. False: expires at period end (gekuendigt).",
json_schema_extra={"label": "Wiederkehrend"},
)
startedAt: datetime = Field(default_factory=lambda: datetime.now(timezone.utc), description="Record creation timestamp")
effectiveFrom: Optional[datetime] = Field(None, description="When this subscription becomes operative. None = immediate. Set for SCHEDULED subs.")
endedAt: Optional[datetime] = Field(None, description="When subscription ended (terminal)")
currentPeriodStart: Optional[datetime] = Field(None, description="Current billing period start (synced from Stripe)")
currentPeriodEnd: Optional[datetime] = Field(None, description="Current billing period end (synced from Stripe)")
trialEndsAt: Optional[datetime] = Field(None, description="Trial expiry timestamp")
startedAt: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
description="Record creation timestamp",
json_schema_extra={"label": "Gestartet"},
)
effectiveFrom: Optional[datetime] = Field(
None,
description="When this subscription becomes operative. None = immediate. Set for SCHEDULED subs.",
json_schema_extra={"label": "Wirksam ab"},
)
endedAt: Optional[datetime] = Field(
None,
description="When subscription ended (terminal)",
json_schema_extra={"label": "Beendet"},
)
currentPeriodStart: Optional[datetime] = Field(
None,
description="Current billing period start (synced from Stripe)",
json_schema_extra={"label": "Periodenbeginn"},
)
currentPeriodEnd: Optional[datetime] = Field(
None,
description="Current billing period end (synced from Stripe)",
json_schema_extra={"label": "Periodenende"},
)
trialEndsAt: Optional[datetime] = Field(
None,
description="Trial expiry timestamp",
json_schema_extra={"label": "Trial endet"},
)
snapshotPricePerUserCHF: float = Field(default=0.0, description="Price snapshot at activation (for invoice history)")
snapshotPricePerInstanceCHF: float = Field(default=0.0, description="Price snapshot at activation")
snapshotPricePerUserCHF: float = Field(
default=0.0,
description="Price snapshot at activation (for invoice history)",
json_schema_extra={"label": "Preis/User (CHF)"},
)
snapshotPricePerInstanceCHF: float = Field(
default=0.0,
description="Price snapshot at activation (per additional module)",
json_schema_extra={"label": "Preis/Modul (CHF)"},
)
stripeSubscriptionId: Optional[str] = Field(None, description="Stripe Subscription ID (sub_xxx)")
stripeItemIdUsers: Optional[str] = Field(None, description="Stripe Subscription Item ID for user seats")
stripeItemIdInstances: Optional[str] = Field(None, description="Stripe Subscription Item ID for feature instances")
registerModelLabels(
"MandateSubscription",
{"en": "Mandate Subscription", "de": "Mandanten-Abonnement", "fr": "Abonnement du mandat"},
{
"id": {"en": "ID", "de": "ID"},
"mandateId": {"en": "Mandate ID", "de": "Mandanten-ID"},
"planKey": {"en": "Plan", "de": "Plan"},
"status": {"en": "Status", "de": "Status"},
"recurring": {"en": "Recurring", "de": "Wiederkehrend"},
"startedAt": {"en": "Started", "de": "Gestartet"},
"effectiveFrom": {"en": "Effective From", "de": "Wirksam ab"},
"endedAt": {"en": "Ended", "de": "Beendet"},
"currentPeriodStart": {"en": "Period Start", "de": "Periodenbeginn"},
"currentPeriodEnd": {"en": "Period End", "de": "Periodenende"},
"trialEndsAt": {"en": "Trial Ends", "de": "Trial endet"},
"snapshotPricePerUserCHF": {"en": "Price/User (CHF)", "de": "Preis/User (CHF)"},
"snapshotPricePerInstanceCHF": {"en": "Price/Instance (CHF)", "de": "Preis/Instanz (CHF)"},
},
)
stripeSubscriptionId: Optional[str] = Field(
None,
description="Stripe Subscription ID (sub_xxx)",
json_schema_extra={"label": "Stripe-Abonnement-ID"},
)
stripeItemIdUsers: Optional[str] = Field(
None,
description="Stripe Subscription Item ID for user seats",
json_schema_extra={"label": "Stripe-Item (User)"},
)
stripeItemIdInstances: Optional[str] = Field(
None,
description="Stripe Subscription Item ID for feature instances",
json_schema_extra={"label": "Stripe-Item (Instanzen)"},
)
# ============================================================================
@ -182,59 +293,116 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
"ROOT": SubscriptionPlan(
planKey="ROOT",
selectableByUser=False,
title={"en": "Root (System)", "de": "Root (System)", "fr": "Root (Système)"},
description={"en": "Internal system plan — no billing.", "de": "Interner Systemplan — keine Verrechnung."},
title=t("Root (System)"),
description=t("Interner Systemplan — keine Verrechnung."),
billingPeriod=BillingPeriodEnum.NONE,
autoRenew=False,
maxUsers=None,
maxFeatureInstances=None,
includedModules=0,
maxDataVolumeMB=None,
budgetAiCHF=0.0,
budgetAiPerUserCHF=0.0,
),
"TRIAL_7D": SubscriptionPlan(
planKey="TRIAL_7D",
"TRIAL_14D": SubscriptionPlan(
planKey="TRIAL_14D",
selectableByUser=False,
title={"en": "Free Trial (7 days)", "de": "Gratis-Testphase (7 Tage)", "fr": "Essai gratuit (7 jours)"},
description={
"en": "Try the platform for 7 days — 1 user, up to 3 feature instances, 5 CHF AI budget included.",
"de": "Plattform 7 Tage testen — 1 User, bis zu 3 Feature-Instanzen, 5 CHF AI-Budget inklusive.",
},
title=t("Gratis-Testphase (14 Tage)"),
description=t("14 Tage kostenlos testen — 1 User, 2 Module inklusive, CHF 25 AI-Budget."),
billingPeriod=BillingPeriodEnum.NONE,
autoRenew=False,
maxUsers=1,
maxFeatureInstances=3,
trialDays=7,
maxDataVolumeMB=500,
budgetAiCHF=5.0,
successorPlanKey="STANDARD_MONTHLY",
maxFeatureInstances=2,
includedModules=2,
trialDays=14,
maxDataVolumeMB=1024,
budgetAiCHF=25.0,
budgetAiPerUserCHF=25.0,
successorPlanKey="STARTER_MONTHLY",
),
"STANDARD_MONTHLY": SubscriptionPlan(
planKey="STANDARD_MONTHLY",
"STARTER_MONTHLY": SubscriptionPlan(
planKey="STARTER_MONTHLY",
selectableByUser=True,
title={"en": "Standard (Monthly)", "de": "Standard (Monatlich)", "fr": "Standard (Mensuel)"},
description={
"en": "Usage-based billing per active user and feature instance, billed monthly. Includes 10 CHF AI budget.",
"de": "Nutzungsbasierte Abrechnung pro aktivem User und Feature-Instanz, monatlich. Inkl. 10 CHF AI-Budget.",
},
title=t("Starter (Monatlich)"),
description=t("CHF 69 pro User/Monat. 2 Module inklusive, CHF 25 AI-Budget pro User."),
billingPeriod=BillingPeriodEnum.MONTHLY,
pricePerUserCHF=79.0,
pricePerFeatureInstanceCHF=119.0,
pricePerUserCHF=69.0,
pricePerFeatureInstanceCHF=39.0,
maxUsers=None,
includedModules=2,
maxDataVolumeMB=1024,
budgetAiCHF=10.0,
budgetAiCHF=0.0,
budgetAiPerUserCHF=25.0,
),
"STANDARD_YEARLY": SubscriptionPlan(
planKey="STANDARD_YEARLY",
"STARTER_YEARLY": SubscriptionPlan(
planKey="STARTER_YEARLY",
selectableByUser=True,
title={"en": "Standard (Yearly)", "de": "Standard (Jährlich)", "fr": "Standard (Annuel)"},
description={
"en": "Usage-based billing per active user and feature instance, billed yearly. Includes 120 CHF AI budget.",
"de": "Nutzungsbasierte Abrechnung pro aktivem User und Feature-Instanz, jährlich. Inkl. 120 CHF AI-Budget.",
},
title=t("Starter (Jaehrlich)"),
description=t("CHF 690 pro User/Jahr (-17%). 2 Module inklusive, CHF 25 AI-Budget pro User/Monat."),
billingPeriod=BillingPeriodEnum.YEARLY,
pricePerUserCHF=948.0,
pricePerFeatureInstanceCHF=1428.0,
pricePerUserCHF=690.0,
pricePerFeatureInstanceCHF=39.0,
maxUsers=None,
includedModules=2,
maxDataVolumeMB=1024,
budgetAiCHF=120.0,
budgetAiCHF=0.0,
budgetAiPerUserCHF=25.0,
),
"PROFESSIONAL_MONTHLY": SubscriptionPlan(
planKey="PROFESSIONAL_MONTHLY",
selectableByUser=True,
title=t("Professional (Monatlich)"),
description=t("CHF 99 pro User/Monat. 5 Module inklusive, CHF 50 AI-Budget pro User."),
billingPeriod=BillingPeriodEnum.MONTHLY,
pricePerUserCHF=99.0,
pricePerFeatureInstanceCHF=29.0,
maxUsers=None,
includedModules=5,
maxDataVolumeMB=5120,
budgetAiCHF=0.0,
budgetAiPerUserCHF=50.0,
),
"PROFESSIONAL_YEARLY": SubscriptionPlan(
planKey="PROFESSIONAL_YEARLY",
selectableByUser=True,
title=t("Professional (Jaehrlich)"),
description=t("CHF 990 pro User/Jahr (-17%). 5 Module inklusive, CHF 50 AI-Budget pro User/Monat."),
billingPeriod=BillingPeriodEnum.YEARLY,
pricePerUserCHF=990.0,
pricePerFeatureInstanceCHF=29.0,
maxUsers=None,
includedModules=5,
maxDataVolumeMB=5120,
budgetAiCHF=0.0,
budgetAiPerUserCHF=50.0,
),
"MAX_MONTHLY": SubscriptionPlan(
planKey="MAX_MONTHLY",
selectableByUser=True,
title=t("Max (Monatlich)"),
description=t("CHF 145 pro User/Monat. 15 Module inklusive, CHF 100 AI-Budget pro User."),
billingPeriod=BillingPeriodEnum.MONTHLY,
pricePerUserCHF=145.0,
pricePerFeatureInstanceCHF=19.0,
maxUsers=None,
includedModules=15,
maxDataVolumeMB=25600,
budgetAiCHF=0.0,
budgetAiPerUserCHF=100.0,
),
"MAX_YEARLY": SubscriptionPlan(
planKey="MAX_YEARLY",
selectableByUser=True,
title=t("Max (Jaehrlich)"),
description=t("CHF 1450 pro User/Jahr (-17%). 15 Module inklusive, CHF 100 AI-Budget pro User/Monat."),
billingPeriod=BillingPeriodEnum.YEARLY,
pricePerUserCHF=1450.0,
pricePerFeatureInstanceCHF=19.0,
maxUsers=None,
includedModules=15,
maxDataVolumeMB=25600,
budgetAiCHF=0.0,
budgetAiPerUserCHF=100.0,
),
}

View file

@ -14,7 +14,7 @@ from typing import Optional, List, Dict, Any
from enum import Enum
from pydantic import BaseModel, Field, EmailStr, field_validator, computed_field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.i18nRegistry import i18nModel, normalizePrimaryLanguageTag
from modules.shared.timeUtils import getUtcTimestamp
@ -61,6 +61,7 @@ class UserPermissions(BaseModel):
)
@i18nModel("Mandant")
class Mandate(PowerOnModel):
"""
Mandate (Mandant/Tenant) model.
@ -69,31 +70,31 @@ class Mandate(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the mandate",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False, "label": "ID"},
)
name: str = Field(
description="Name of the mandate",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True, "label": "Name"},
)
label: Optional[str] = Field(
default=None,
description="Display label of the mandate",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False, "label": "Label"},
)
enabled: bool = Field(
default=True,
description="Indicates whether the mandate is enabled",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False, "label": "Aktiviert"},
)
isSystem: bool = Field(
default=False,
description="Whether this is a system mandate (e.g. root mandate). Cannot be deleted.",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False, "label": "System-Mandant"},
)
deletedAt: Optional[float] = Field(
default=None,
description="Timestamp when the mandate was soft-deleted. After 30 days, hard-delete is triggered.",
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Gelöscht am"},
)
@field_validator('isSystem', mode='before')
@ -104,38 +105,91 @@ class Mandate(PowerOnModel):
return False
return v
registerModelLabels(
"Mandate",
{"en": "Mandate", "de": "Mandant", "fr": "Mandat"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"name": {"en": "Name", "de": "Name", "fr": "Nom"},
"label": {"en": "Label", "de": "Label", "fr": "Libellé"},
"enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"},
"isSystem": {"en": "System Mandate", "de": "System-Mandant", "fr": "Mandat système"},
"deletedAt": {"en": "Deleted at", "de": "Gelöscht am", "fr": "Supprimé le"},
},
)
@i18nModel("Benutzerverbindung")
class UserConnection(PowerOnModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the connection", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
userId: str = Field(description="ID of the user this connection belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
authority: AuthAuthority = Field(description="Authentication authority", json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": False, "frontend_options": "/api/connections/authorities/options"})
externalId: str = Field(description="User ID in the external system", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
externalUsername: str = Field(description="Username in the external system", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
externalEmail: Optional[EmailStr] = Field(None, description="Email in the external system", json_schema_extra={"frontend_type": "email", "frontend_readonly": False, "frontend_required": False})
status: ConnectionStatus = Field(default=ConnectionStatus.ACTIVE, description="Connection status", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": "/api/connections/statuses/options"})
connectedAt: float = Field(default_factory=getUtcTimestamp, description="When the connection was established (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
lastChecked: float = Field(default_factory=getUtcTimestamp, description="When the connection was last verified (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
expiresAt: Optional[float] = Field(None, description="When the connection expires (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
tokenStatus: Optional[str] = Field(None, description="Current token status: active, expired, none", json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": False, "frontend_options": [
{"value": "active", "label": {"en": "Active", "fr": "Actif"}},
{"value": "expired", "label": {"en": "Expired", "fr": "Expiré"}},
{"value": "none", "label": {"en": "None", "fr": "Aucun"}},
]})
tokenExpiresAt: Optional[float] = Field(None, description="When the current token expires (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
grantedScopes: Optional[List[str]] = Field(None, description="OAuth scopes granted for this connection", json_schema_extra={"frontend_type": "list", "frontend_readonly": True, "frontend_required": False})
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the connection",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "ID"},
)
userId: str = Field(
description="ID of the user this connection belongs to",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Benutzer-ID"},
)
authority: AuthAuthority = Field(
description="Authentication authority",
json_schema_extra={
"frontend_type": "select",
"frontend_readonly": True,
"frontend_required": False,
"frontend_options": "/api/connections/authorities/options",
"label": "Autorität",
},
)
externalId: str = Field(
description="User ID in the external system",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Externe ID"},
)
externalUsername: str = Field(
description="Username in the external system",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False, "label": "Externer Benutzername"},
)
externalEmail: Optional[EmailStr] = Field(
None,
description="Email in the external system",
json_schema_extra={"frontend_type": "email", "frontend_readonly": False, "frontend_required": False, "label": "Externe E-Mail"},
)
status: ConnectionStatus = Field(
default=ConnectionStatus.ACTIVE,
description="Connection status",
json_schema_extra={
"frontend_type": "select",
"frontend_readonly": False,
"frontend_required": False,
"frontend_options": "/api/connections/statuses/options",
"label": "Status",
},
)
connectedAt: float = Field(
default_factory=getUtcTimestamp,
description="When the connection was established (UTC timestamp in seconds)",
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Verbunden am"},
)
lastChecked: float = Field(
default_factory=getUtcTimestamp,
description="When the connection was last verified (UTC timestamp in seconds)",
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Zuletzt geprüft"},
)
expiresAt: Optional[float] = Field(
None,
description="When the connection expires (UTC timestamp in seconds)",
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Läuft ab am"},
)
tokenStatus: Optional[str] = Field(
None,
description="Current token status: active, expired, none",
json_schema_extra={
"frontend_type": "select",
"frontend_readonly": True,
"frontend_required": False,
"frontend_options": [
{"value": "active", "label": "Active"},
{"value": "expired", "label": "Expired"},
{"value": "none", "label": "None"},
],
"label": "Verbindungsstatus",
},
)
tokenExpiresAt: Optional[float] = Field(
None,
description="When the current token expires (UTC timestamp in seconds)",
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Token läuft ab am"},
)
grantedScopes: Optional[List[str]] = Field(
None,
description="OAuth scopes granted for this connection",
json_schema_extra={"frontend_type": "list", "frontend_readonly": True, "frontend_required": False, "label": "Gewährte Berechtigungen"},
)
@computed_field
@computed_field
@ -157,29 +211,7 @@ class UserConnection(PowerOnModel):
return f"{authorityLabels.get(self.authority.value, self.authority.value)}: {self.externalUsername}"
registerModelLabels(
"UserConnection",
{"en": "User Connection", "de": "Benutzerverbindung", "fr": "Connexion utilisateur"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"},
"authority": {"en": "Authority", "de": "Autorität", "fr": "Autorité"},
"externalId": {"en": "External ID", "de": "Externe ID", "fr": "ID externe"},
"externalUsername": {"en": "External Username", "de": "Externer Benutzername", "fr": "Nom d'utilisateur externe"},
"externalEmail": {"en": "External Email", "de": "Externe E-Mail", "fr": "Email externe"},
"status": {"en": "Status", "de": "Status", "fr": "Statut"},
"connectedAt": {"en": "Connected At", "de": "Verbunden am", "fr": "Connecté le"},
"lastChecked": {"en": "Last Checked", "de": "Zuletzt geprüft", "fr": "Dernière vérification"},
"expiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"},
"tokenStatus": {"en": "Connection Status", "de": "Verbindungsstatus", "fr": "Statut de connexion"},
"tokenExpiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"},
"grantedScopes": {"en": "Granted Scopes", "de": "Gewährte Berechtigungen", "fr": "Autorisations accordées"},
"connectionReference": {"en": "Connection Reference", "de": "Verbindungsreferenz", "fr": "Référence de connexion"},
"displayLabel": {"en": "Display Label", "de": "Anzeigebezeichnung", "fr": "Libellé d'affichage"},
},
)
@i18nModel("Benutzer")
class User(PowerOnModel):
"""
User model.
@ -193,40 +225,40 @@ class User(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the user",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False, "label": "ID"},
)
username: str = Field(
description="Username for login (immutable after creation)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Benutzername"},
)
email: Optional[EmailStr] = Field(
default=None,
description="Email address of the user",
json_schema_extra={"frontend_type": "email", "frontend_readonly": False, "frontend_required": True}
json_schema_extra={"frontend_type": "email", "frontend_readonly": False, "frontend_required": True, "label": "E-Mail"},
)
fullName: Optional[str] = Field(
default=None,
description="Full name of the user",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False, "label": "Vollständiger Name"},
)
language: str = Field(
default="de",
description="Preferred language of the user (ISO 639-1 code: de, en, fr, it)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_options": [
{"value": "de", "label": {"en": "Deutsch", "de": "Deutsch", "fr": "Allemand"}},
{"value": "en", "label": {"en": "English", "de": "Englisch", "fr": "Anglais"}},
{"value": "fr", "label": {"en": "Français", "de": "Französisch", "fr": "Français"}},
{"value": "it", "label": {"en": "Italiano", "de": "Italienisch", "fr": "Italien"}},
]}
description="Preferred UI language code (must exist as UiLanguageSet).",
json_schema_extra={
"frontend_type": "select",
"frontend_readonly": False,
"frontend_required": True,
"frontend_options": "/api/i18n/codes",
"label": "Sprache",
},
)
@field_validator('language', mode='before')
@classmethod
def _normalizeLanguage(cls, v):
"""Normalize language to valid ISO 639-1 code."""
"""Normalize to primary language subtag (28 letters); default remains ``de``."""
if v is None:
return "de"
# Map common variations to standard codes
langMap = {
'english': 'en', 'englisch': 'en',
'german': 'de', 'deutsch': 'de',
@ -236,22 +268,18 @@ class User(PowerOnModel):
normalized = str(v).lower().strip()
if normalized in langMap:
return langMap[normalized]
# If already a valid code, return as-is
if normalized in ['de', 'en', 'fr', 'it']:
return normalized
# Default fallback
return "de"
return normalizePrimaryLanguageTag(normalized, "de")
enabled: bool = Field(
default=True,
description="Indicates whether the user is enabled",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False, "label": "Aktiviert"},
)
isSysAdmin: bool = Field(
default=False,
description="Global SysAdmin flag. SysAdmin = System-Zugriff, KEIN Daten-Zugriff!",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False, "label": "System-Admin"},
)
@field_validator('isSysAdmin', mode='before')
@ -265,48 +293,45 @@ class User(PowerOnModel):
authenticationAuthority: AuthAuthority = Field(
default=AuthAuthority.LOCAL,
description="Primary authentication authority",
json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": False, "frontend_options": "/api/connections/authorities/options"}
json_schema_extra={
"frontend_type": "select",
"frontend_readonly": True,
"frontend_required": False,
"frontend_options": "/api/connections/authorities/options",
"label": "Authentifizierung",
},
)
roleLabels: List[str] = Field(
default_factory=list,
description="Role labels (from DB or enriched when loading users)",
json_schema_extra={"frontend_type": "multiselect", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False},
json_schema_extra={
"frontend_type": "multiselect",
"frontend_readonly": True,
"frontend_visible": False,
"frontend_required": False,
"label": "Rollen-Labels",
},
)
registerModelLabels(
"User",
{"en": "User", "de": "Benutzer", "fr": "Utilisateur"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"username": {"en": "Username", "de": "Benutzername", "fr": "Nom d'utilisateur"},
"email": {"en": "Email", "de": "E-Mail", "fr": "Email"},
"fullName": {"en": "Full Name", "de": "Vollständiger Name", "fr": "Nom complet"},
"language": {"en": "Language", "de": "Sprache", "fr": "Langue"},
"enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"},
"isSysAdmin": {"en": "System Admin", "de": "System-Admin", "fr": "Admin système"},
"authenticationAuthority": {"en": "Auth Authority", "de": "Authentifizierung", "fr": "Autorité d'authentification"},
"roleLabels": {"en": "Role Labels", "de": "Rollen-Labels", "fr": "Libellés de rôles"},
},
)
@i18nModel("Benutzerzugang")
class UserInDB(User):
"""User model with password hash for database storage."""
hashedPassword: Optional[str] = Field(None, description="Hash of the user password")
resetToken: Optional[str] = Field(None, description="Password reset token (UUID)")
resetTokenExpires: Optional[float] = Field(None, description="Reset token expiration (UTC timestamp in seconds)")
registerModelLabels(
"UserInDB",
{"en": "User Access", "de": "Benutzerzugang", "fr": "Accès de l'utilisateur"},
{
"hashedPassword": {"en": "Password hash", "de": "Passwort-Hash", "fr": "Hachage de mot de passe"},
"resetToken": {"en": "Reset Token", "de": "Reset-Token", "fr": "Jeton de réinitialisation"},
"resetTokenExpires": {"en": "Reset Token Expires", "de": "Token läuft ab", "fr": "Expiration du jeton"},
},
)
hashedPassword: Optional[str] = Field(
None,
description="Hash of the user password",
json_schema_extra={"label": "Passwort-Hash"},
)
resetToken: Optional[str] = Field(
None,
description="Password reset token (UUID)",
json_schema_extra={"label": "Reset-Token"},
)
resetTokenExpires: Optional[float] = Field(
None,
description="Reset token expiration (UTC timestamp in seconds)",
json_schema_extra={"label": "Token läuft ab"},
)
def _normalizeTtsVoiceMap(value: Any) -> Optional[Dict[str, str]]:
@ -336,17 +361,50 @@ def _normalizeTtsVoiceMap(value: Any) -> Optional[Dict[str, str]]:
return out if out else None
@i18nModel("Spracheinstellungen")
class UserVoicePreferences(PowerOnModel):
"""User-level voice/language preferences, shared across all features."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
userId: str = Field(description="User ID")
mandateId: Optional[str] = Field(default=None, description="Mandate scope (None = global for user)")
sttLanguage: str = Field(default="de-DE", description="Speech-to-text language code")
ttsLanguage: str = Field(default="de-DE", description="Text-to-speech language code")
ttsVoice: Optional[str] = Field(default=None, description="Preferred TTS voice identifier")
ttsVoiceMap: Optional[Dict[str, str]] = Field(default=None, description="Language-to-voice mapping")
translationSourceLanguage: Optional[str] = Field(default=None, description="Source language for translations")
translationTargetLanguage: Optional[str] = Field(default=None, description="Target language for translations")
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"label": "ID"},
)
userId: str = Field(description="User ID", json_schema_extra={"label": "Benutzer-ID"})
mandateId: Optional[str] = Field(
default=None,
description="Mandate scope (None = global for user)",
json_schema_extra={"label": "Mandanten-ID"},
)
sttLanguage: str = Field(
default="de-DE",
description="Speech-to-text language code",
json_schema_extra={"label": "STT-Sprache"},
)
ttsLanguage: str = Field(
default="de-DE",
description="Text-to-speech language code",
json_schema_extra={"label": "TTS-Sprache"},
)
ttsVoice: Optional[str] = Field(
default=None,
description="Preferred TTS voice identifier",
json_schema_extra={"label": "TTS-Stimme"},
)
ttsVoiceMap: Optional[Dict[str, str]] = Field(
default=None,
description="Language-to-voice mapping",
json_schema_extra={"label": "Stimmen-Zuordnung"},
)
translationSourceLanguage: Optional[str] = Field(
default=None,
description="Source language for translations",
json_schema_extra={"label": "Übersetzung Quelle"},
)
translationTargetLanguage: Optional[str] = Field(
default=None,
description="Target language for translations",
json_schema_extra={"label": "Übersetzung Ziel"},
)
@field_validator("ttsVoiceMap", mode="before")
@classmethod
@ -354,18 +412,3 @@ class UserVoicePreferences(PowerOnModel):
return _normalizeTtsVoiceMap(value)
registerModelLabels(
"UserVoicePreferences",
{"en": "Voice Preferences", "de": "Spracheinstellungen", "fr": "Préférences vocales"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"},
"mandateId": {"en": "Mandate ID", "de": "Mandanten-ID", "fr": "ID du mandat"},
"sttLanguage": {"en": "STT Language", "de": "STT-Sprache", "fr": "Langue STT"},
"ttsLanguage": {"en": "TTS Language", "de": "TTS-Sprache", "fr": "Langue TTS"},
"ttsVoice": {"en": "TTS Voice", "de": "TTS-Stimme", "fr": "Voix TTS"},
"ttsVoiceMap": {"en": "Voice Map", "de": "Stimmen-Zuordnung", "fr": "Carte des voix"},
"translationSourceLanguage": {"en": "Translation Source", "de": "Übersetzung Quelle", "fr": "Langue source"},
"translationTargetLanguage": {"en": "Translation Target", "de": "Übersetzung Ziel", "fr": "Langue cible"},
},
)

View file

@ -0,0 +1,98 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""UI language sets: structured i18n entries (context, key, value)."""
from typing import List, Literal
from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.i18nRegistry import i18nModel
UiLanguageStatus = Literal["complete", "incomplete", "generating"]
class I18nEntry(BaseModel):
"""Single translation entry within a language set.
context: origin of the key, e.g. "ui" for frontend elements,
"db.management.files.name" for backend data objects.
key: German plaintext (the canonical identifier across all sets).
value: For xx (base set): UI context description for AI translation.
For language sets (de, en, ...): the translated text.
"""
context: str = Field(
...,
description="Origin: 'ui' for frontend, 'db.<schema>.<table>.<field>' for backend objects",
)
key: str = Field(
...,
description="German plaintext key (canonical identifier)",
)
value: str = Field(
default="",
description="Translation (language sets) or context description (xx base set)",
)
@i18nModel("UI-Sprachset")
class UiLanguageSet(PowerOnModel):
"""Ein Sprachset pro Sprache. id = ISO 639-1 Code oder 'xx' (Basisset). Enthaelt alle Uebersetzungen."""
id: str = Field(
...,
description="ISO 639-1 language code or 'xx' for the base set",
json_schema_extra={
"label": "Code",
"frontend_type": "text",
"frontend_readonly": False,
"frontend_required": True,
},
)
label: str = Field(
...,
description="Human-readable language name",
json_schema_extra={
"label": "Bezeichnung",
"frontend_type": "text",
"frontend_readonly": False,
"frontend_required": True,
},
)
entries: List[I18nEntry] = Field(
default_factory=list,
description="Translation entries: list of {context, key, value}",
json_schema_extra={
"label": "Eintraege",
"frontend_type": "textarea",
"frontend_readonly": False,
"frontend_required": False,
},
)
status: UiLanguageStatus = Field(
default="complete",
description="complete | incomplete | generating",
json_schema_extra={
"label": "Status",
"frontend_type": "select",
"frontend_readonly": False,
"frontend_required": True,
"frontend_options": [
{"value": "complete", "label": "Vollständig"},
{"value": "incomplete", "label": "Unvollständig"},
{"value": "generating", "label": "Wird erzeugt"},
],
},
)
isDefault: bool = Field(
default=False,
description="True only for the xx base set",
json_schema_extra={
"label": "Standard",
"frontend_type": "boolean",
"frontend_readonly": False,
"frontend_required": False,
},
)

View file

@ -2,83 +2,139 @@
# All rights reserved.
"""Utility datamodels: Prompt, TextMultilingual."""
from typing import Dict, Optional
from pydantic import BaseModel, Field, field_validator
import json
from typing import Any, Dict
from pydantic import BaseModel, Field, field_validator, model_validator
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.i18nRegistry import i18nModel
import uuid
@i18nModel("Prompt")
class Prompt(PowerOnModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
mandateId: str = Field(default="", description="ID of the mandate this prompt belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
isSystem: bool = Field(default=False, description="System prompt visible to all users (read-only for non-SysAdmin)", json_schema_extra={"frontend_type": "boolean", "frontend_readonly": True, "frontend_required": False})
content: str = Field(description="Content of the prompt", json_schema_extra={"frontend_type": "textarea", "frontend_readonly": False, "frontend_required": True})
name: str = Field(description="Name of the prompt", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True})
"""Benutzer- oder System-Prompt fuer die KI."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
)
mandateId: str = Field(
default="",
description="ID of the mandate this prompt belongs to",
json_schema_extra={"label": "Mandanten-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
)
isSystem: bool = Field(
default=False,
description="System prompt visible to all users (read-only for non-SysAdmin)",
json_schema_extra={"label": "System", "frontend_type": "boolean", "frontend_readonly": True, "frontend_required": False},
)
content: str = Field(
description="Content of the prompt",
json_schema_extra={"label": "Inhalt", "frontend_type": "textarea", "frontend_readonly": False, "frontend_required": True},
)
name: str = Field(
description="Name of the prompt",
json_schema_extra={"label": "Name", "frontend_type": "text", "frontend_readonly": False, "frontend_required": True},
)
@field_validator('isSystem', mode='before')
@classmethod
def _coerceIsSystem(cls, v):
"""Existing records may have isSystem=None (field didn't exist). Treat None as False."""
if v is None:
return False
return v
registerModelLabels(
"Prompt",
{"en": "Prompt", "fr": "Invite"},
{
"id": {"en": "ID", "fr": "ID"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
"isSystem": {"en": "System", "fr": "Système"},
"content": {"en": "Content", "fr": "Contenu"},
"name": {"en": "Name", "fr": "Nom"},
},
)
class TextMultilingual(BaseModel):
"""
Multilingual text field supporting multiple languages.
Default languages: en (English), ge (German), fr (French), it (Italian)
English (en) is the default/required language.
"""
en: str = Field(description="English text (default language, required)")
ge: Optional[str] = Field(None, description="German text")
fr: Optional[str] = Field(None, description="French text")
it: Optional[str] = Field(None, description="Italian text")
"""Multilingual text field stored as JSONB: {"xx": "source text", "de": "...", "en": "...", ...}.
@field_validator('en')
- xx = source/default text (required). Same role as xx in the UI i18n system.
- All language codes (de, en, fr, ...) are dynamic, populated via batch translation.
- No hardcoded language fields. The DB column is JSONB with arbitrary keys.
"""
model_config = {"extra": "allow"}
xx: str = Field(description="Source/default text (required)")
@model_validator(mode='before')
@classmethod
def validate_en_required(cls, v):
"""Ensure English text is not empty"""
def _ensureXx(cls, data: Any) -> Any:
"""Derive xx from existing language keys when missing (legacy DB rows)."""
if not isinstance(data, dict):
return data
if data.get('xx') and isinstance(data['xx'], str) and data['xx'].strip():
return data
fallback = data.get('de') or data.get('en')
if not fallback or not isinstance(fallback, str) or not fallback.strip():
for v in data.values():
if v and isinstance(v, str) and v.strip():
fallback = v
break
data['xx'] = fallback.strip() if fallback and isinstance(fallback, str) else ''
return data
@field_validator('xx')
@classmethod
def _validateXxRequired(cls, v):
if not v or not v.strip():
raise ValueError("English text (en) is required and cannot be empty")
raise ValueError("Source text (xx) is required and cannot be empty")
return v
def model_dump(self, **kwargs) -> Dict[str, str]:
"""Return as dictionary, filtering out None values"""
result = {}
for lang in ['en', 'ge', 'fr', 'it']:
value = getattr(self, lang, None)
if value is not None:
result[lang] = value
result = {"xx": self.xx}
if self.__pydantic_extra__:
for k, v in self.__pydantic_extra__.items():
if v is not None and isinstance(v, str):
result[k] = v
return result
@classmethod
def from_dict(cls, data: Dict[str, str]) -> 'TextMultilingual':
"""Create TextMultilingual from dictionary"""
return cls(
en=data.get('en', ''),
ge=data.get('ge'),
fr=data.get('fr'),
it=data.get('it')
)
cleaned = {k: v for k, v in data.items() if v is not None and isinstance(v, str)}
if not cleaned.get('xx'):
cleaned['xx'] = cleaned.get('de') or next((v for v in cleaned.values() if v), '')
return cls(**cleaned)
def get_text(self, lang: str = 'en') -> str:
"""Get text for a specific language, fallback to English if not available"""
value = getattr(self, lang, None)
if value:
def get_text(self, lang: str = 'de') -> str:
"""Get text for a language. Falls back to xx (source text)."""
if lang == 'xx':
return self.xx
extra = self.__pydantic_extra__ or {}
value = extra.get(lang)
if value and isinstance(value, str):
return value
return self.en # Fallback to English
return self.xx
@classmethod
def fromUniform(cls, text: str) -> "TextMultilingual":
"""Create with source text only. Languages are populated by batch translation."""
t = text.strip()
if not t:
raise ValueError("Text must be non-empty")
return cls(xx=t)
def coerce_text_multilingual(val: Any) -> TextMultilingual:
"""Normalize str, dict, or TextMultilingual into a valid TextMultilingual instance."""
if isinstance(val, TextMultilingual):
return val
if isinstance(val, dict):
if not val:
return TextMultilingual.fromUniform("")
cleaned = {k: v for k, v in val.items() if v is not None and isinstance(v, str)}
if not cleaned.get("xx"):
cleaned["xx"] = cleaned.get("de") or next((v for v in cleaned.values() if v), "")
return TextMultilingual(**cleaned)
if isinstance(val, str) and val.strip():
s = val.strip()
if s.startswith("{") and s.endswith("}"):
try:
parsed = json.loads(s)
if isinstance(parsed, dict):
return coerce_text_multilingual(parsed)
except json.JSONDecodeError:
pass
return TextMultilingual.fromUniform(s)
return TextMultilingual.fromUniform("")

View file

@ -6,45 +6,52 @@ Workflow execution models for action definitions, AI responses, and workflow-lev
from typing import Dict, Any, List, Optional, TYPE_CHECKING
from pydantic import BaseModel, Field
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.i18nRegistry import i18nModel
from modules.shared.jsonUtils import extractJsonString, tryParseJson, repairBrokenJson
# Import DocumentReferenceList at runtime (needed for ActionDefinition)
from modules.datamodels.datamodelDocref import DocumentReferenceList
@i18nModel("Aktionsdefinition")
class ActionDefinition(BaseModel):
"""Action definition with selection and parameters from planning phase"""
# Core action selection (Stage 1)
action: str = Field(description="Compound action name (method.action)")
actionObjective: str = Field(description="Objective for this action")
action: str = Field(description="Compound action name (method.action)", json_schema_extra={"label": "Aktion"})
actionObjective: str = Field(description="Objective for this action", json_schema_extra={"label": "Aktionsziel"})
userMessage: Optional[str] = Field(
None,
description="User-friendly message in user's language explaining what this action will do (generated by AI in prompts)"
description="User-friendly message in user's language explaining what this action will do (generated by AI in prompts)",
json_schema_extra={"label": "Benutzernachricht"},
)
parametersContext: Optional[str] = Field(
None,
description="Context for parameter generation"
description="Context for parameter generation",
json_schema_extra={"label": "Parameter-Kontext"},
)
learnings: List[str] = Field(
default_factory=list,
description="Learnings from previous actions"
description="Learnings from previous actions",
json_schema_extra={"label": "Erkenntnisse"},
)
# Resources (ALWAYS defined in Stage 1 if action needs them)
documentList: Optional[DocumentReferenceList] = Field(
None,
description="Document references (ALWAYS defined in Stage 1 if action needs documents)"
description="Document references (ALWAYS defined in Stage 1 if action needs documents)",
json_schema_extra={"label": "Dokumentenliste"},
)
connectionReference: Optional[str] = Field(
None,
description="Connection reference (ALWAYS defined in Stage 1 if action needs connection)"
description="Connection reference (ALWAYS defined in Stage 1 if action needs connection)",
json_schema_extra={"label": "Verbindungsreferenz"},
)
# Parameters (may be defined in Stage 1 OR Stage 2, depending on action and actionObjective)
parameters: Optional[Dict[str, Any]] = Field(
None,
description="Action-specific parameters (generated in Stage 2 for complex actions, or inferred from actionObjective for simple actions)"
description="Action-specific parameters (generated in Stage 2 for complex actions, or inferred from actionObjective for simple actions)",
json_schema_extra={"label": "Parameter"},
)
def hasParameters(self) -> bool:
@ -75,34 +82,47 @@ class ActionDefinition(BaseModel):
self.connectionReference = connectionRef
@i18nModel("KI-Antwort-Metadaten")
class AiResponseMetadata(BaseModel):
"""Metadata for AI response (varies by operation type)."""
# Document Generation Metadata
title: Optional[str] = Field(None, description="Document title")
filename: Optional[str] = Field(None, description="Document filename")
title: Optional[str] = Field(None, description="Document title", json_schema_extra={"label": "Titel"})
filename: Optional[str] = Field(None, description="Document filename", json_schema_extra={"label": "Dateiname"})
# Operation-Specific Metadata
operationType: Optional[str] = Field(None, description="Type of operation performed")
schemaVersion: Optional[str] = Field(None, description="Schema version (e.g., 'parameters_v1')", alias="schema")
extractionMethod: Optional[str] = Field(None, description="Method used for extraction")
sourceDocuments: Optional[List[str]] = Field(None, description="Source document references")
operationType: Optional[str] = Field(None, description="Type of operation performed", json_schema_extra={"label": "Vorgangstyp"})
schemaVersion: Optional[str] = Field(
None,
description="Schema version (e.g., 'parameters_v1')",
alias="schema",
json_schema_extra={"label": "Schema-Version"},
)
extractionMethod: Optional[str] = Field(None, description="Method used for extraction", json_schema_extra={"label": "Extraktionsmethode"})
sourceDocuments: Optional[List[str]] = Field(None, description="Source document references", json_schema_extra={"label": "Quelldokumente"})
# Additional metadata (for extensibility)
additionalData: Optional[Dict[str, Any]] = Field(None, description="Additional operation-specific metadata")
class DocumentData(BaseModel):
"""Single document in response"""
documentName: str = Field(description="Document name")
documentData: Any = Field(description="Document data (can be str, bytes, dict, etc.)")
mimeType: str = Field(description="MIME type of the document")
sourceJson: Optional[Dict[str, Any]] = Field(
additionalData: Optional[Dict[str, Any]] = Field(
None,
description="Source JSON structure (preserved when rendering to xlsx/docx/pdf)"
description="Additional operation-specific metadata",
json_schema_extra={"label": "Zusätzliche Daten"},
)
@i18nModel("Dokumentdaten")
class DocumentData(BaseModel):
"""Single document in response"""
documentName: str = Field(description="Document name", json_schema_extra={"label": "Dokumentname"})
documentData: Any = Field(description="Document data (can be str, bytes, dict, etc.)", json_schema_extra={"label": "Dokumentdaten"})
mimeType: str = Field(description="MIME type of the document", json_schema_extra={"label": "MIME-Typ"})
sourceJson: Optional[Dict[str, Any]] = Field(
None,
description="Source JSON structure (preserved when rendering to xlsx/docx/pdf)",
json_schema_extra={"label": "Quell-JSON"},
)
@i18nModel("Extraktionsparameter")
class ExtractContentParameters(BaseModel):
"""Parameters for extraction action.
@ -110,24 +130,34 @@ class ExtractContentParameters(BaseModel):
All action parameter models follow this pattern: defined in the same module as the action.
However, since this is a workflow-level model used across the system, it's defined here.
"""
documentList: DocumentReferenceList = Field(description="Document references to extract content from")
documentList: DocumentReferenceList = Field(
description="Document references to extract content from",
json_schema_extra={"label": "Dokumentenliste"},
)
extractionOptions: Optional[Any] = Field( # ExtractionOptions - forward reference
None,
description="Extraction options (determined dynamically based on task and document characteristics)"
description="Extraction options (determined dynamically based on task and document characteristics)",
json_schema_extra={"label": "Extraktionsoptionen"},
)
@i18nModel("KI-Antwort")
class AiResponse(BaseModel):
"""Unified response from all AI calls (planning, text, documents)"""
content: str = Field(description="Response content (JSON string for planning, text for analysis, unified JSON for documents)")
content: str = Field(
description="Response content (JSON string for planning, text for analysis, unified JSON for documents)",
json_schema_extra={"label": "Inhalt"},
)
metadata: Optional[AiResponseMetadata] = Field(
None,
description="Response metadata (varies by operation type)"
description="Response metadata (varies by operation type)",
json_schema_extra={"label": "Metadaten"},
)
documents: Optional[List[DocumentData]] = Field(
None,
description="Generated documents (only for document generation operations)"
description="Generated documents (only for document generation operations)",
json_schema_extra={"label": "Dokumente"},
)
def toJson(self) -> Dict[str, Any]:
@ -186,278 +216,88 @@ class AiResponse(BaseModel):
# Workflow-level models
@i18nModel("Anfragekontext")
class RequestContext(BaseModel):
"""Normalized request context from user input"""
originalPrompt: str = Field(description="Original user prompt")
originalPrompt: str = Field(description="Original user prompt", json_schema_extra={"label": "Ursprüngliche Eingabe"})
documents: List[Any] = Field( # ChatDocument - forward reference
default_factory=list,
description="Documents provided by user"
description="Documents provided by user",
json_schema_extra={"label": "Dokumente"},
)
userLanguage: str = Field(description="User's language")
userLanguage: str = Field(description="User's language", json_schema_extra={"label": "Benutzersprache"})
detectedComplexity: str = Field(
description="Complexity level: simple, moderate, complex"
description="Complexity level: simple, moderate, complex",
json_schema_extra={"label": "Erkannte Komplexität"},
)
requiresDocuments: bool = Field(default=False, description="Whether request requires documents")
requiresWebResearch: bool = Field(default=False, description="Whether request requires web research")
requiresAnalysis: bool = Field(default=False, description="Whether request requires analysis")
expectedOutputFormat: Optional[str] = Field(None, description="Expected output format")
expectedOutputType: Optional[str] = Field(None, description="Expected output type: answer, document, analysis")
requiresDocuments: bool = Field(default=False, description="Whether request requires documents", json_schema_extra={"label": "Benötigt Dokumente"})
requiresWebResearch: bool = Field(default=False, description="Whether request requires web research", json_schema_extra={"label": "Benötigt Web-Recherche"})
requiresAnalysis: bool = Field(default=False, description="Whether request requires analysis", json_schema_extra={"label": "Benötigt Analyse"})
expectedOutputFormat: Optional[str] = Field(None, description="Expected output format", json_schema_extra={"label": "Erwartetes Ausgabeformat"})
expectedOutputType: Optional[str] = Field(None, description="Expected output type: answer, document, analysis", json_schema_extra={"label": "Erwarteter Ausgabetyp"})
@i18nModel("Verständnis-Ergebnis")
class UnderstandingResult(BaseModel):
"""Result from initial understanding phase (combined AI call)"""
parameters: Dict[str, Any] = Field(
default_factory=dict,
description="Basic parameters (language, format, detail level)"
description="Basic parameters (language, format, detail level)",
json_schema_extra={"label": "Parameter"},
)
intention: Dict[str, Any] = Field(
default_factory=dict,
description="User intention (primaryGoal, secondaryGoals, intentionType)"
description="User intention (primaryGoal, secondaryGoals, intentionType)",
json_schema_extra={"label": "Absicht"},
)
context: Dict[str, Any] = Field(
default_factory=dict,
description="Extracted context (topics, requirements, constraints)"
description="Extracted context (topics, requirements, constraints)",
json_schema_extra={"label": "Kontext"},
)
documentReferences: List[Dict[str, Any]] = Field(
default_factory=list,
description="Document references with purpose and relevance"
description="Document references with purpose and relevance",
json_schema_extra={"label": "Dokumentenreferenzen"},
)
tasks: List["TaskDefinition"] = Field( # Forward reference
default_factory=list,
description="Task definitions with deliverables"
description="Task definitions with deliverables",
json_schema_extra={"label": "Aufgaben"},
)
@i18nModel("Aufgabenbeschreibung")
class TaskDefinition(BaseModel):
"""Task definition from understanding phase"""
id: str = Field(description="Task identifier")
objective: str = Field(description="Task objective")
id: str = Field(description="Task identifier", json_schema_extra={"label": "Aufgaben-ID"})
objective: str = Field(description="Task objective", json_schema_extra={"label": "Ziel"})
deliverable: Dict[str, Any] = Field(
description="Deliverable specification (type, format, style, detailLevel)"
description="Deliverable specification (type, format, style, detailLevel)",
json_schema_extra={"label": "Lieferobjekt"},
)
requiresWebResearch: bool = Field(default=False, description="Whether task requires web research")
requiresDocumentAnalysis: bool = Field(default=False, description="Whether task requires document analysis")
requiresContentGeneration: bool = Field(default=True, description="Whether task requires content generation")
requiresWebResearch: bool = Field(default=False, description="Whether task requires web research", json_schema_extra={"label": "Benötigt Web-Recherche"})
requiresDocumentAnalysis: bool = Field(default=False, description="Whether task requires document analysis", json_schema_extra={"label": "Benötigt Dokumentenanalyse"})
requiresContentGeneration: bool = Field(default=True, description="Whether task requires content generation", json_schema_extra={"label": "Benötigt Inhaltserstellung"})
requiredDocuments: List[str] = Field(
default_factory=list,
description="Document references needed for this task"
description="Document references needed for this task",
json_schema_extra={"label": "Benötigte Dokumente"},
)
extractionOptions: Optional[Any] = Field( # ExtractionOptions - forward reference
None,
description="Extraction options for document processing (determined dynamically based on task and document characteristics)"
description="Extraction options for document processing (determined dynamically based on task and document characteristics)",
json_schema_extra={"label": "Extraktionsoptionen"},
)
class TaskResult(BaseModel):
@i18nModel("Workflow-Aufgabenergebnis")
class WorkflowTaskResult(BaseModel):
"""Result from task execution"""
taskId: str = Field(description="Task identifier")
actionResult: Any = Field(description="ActionResult from task execution") # ActionResult - forward reference
# Register model labels for UI
registerModelLabels(
"RequestContext",
{"en": "Request Context", "fr": "Contexte de la demande"},
{
"originalPrompt": {"en": "Original Prompt", "fr": "Invite originale"},
"documents": {"en": "Documents", "fr": "Documents"},
"userLanguage": {"en": "User Language", "fr": "Langue de l'utilisateur"},
"detectedComplexity": {"en": "Detected Complexity", "fr": "Complexité détectée"},
"requiresDocuments": {"en": "Requires Documents", "fr": "Nécessite des documents"},
"requiresWebResearch": {"en": "Requires Web Research", "fr": "Nécessite une recherche web"},
"requiresAnalysis": {"en": "Requires Analysis", "fr": "Nécessite une analyse"},
"expectedOutputFormat": {"en": "Expected Output Format", "fr": "Format de sortie attendu"},
"expectedOutputType": {"en": "Expected Output Type", "fr": "Type de sortie attendu"},
},
)
registerModelLabels(
"UnderstandingResult",
{"en": "Understanding Result", "fr": "Résultat de compréhension"},
{
"parameters": {"en": "Parameters", "fr": "Paramètres"},
"intention": {"en": "Intention", "fr": "Intention"},
"context": {"en": "Context", "fr": "Contexte"},
"documentReferences": {"en": "Document References", "fr": "Références de documents"},
"tasks": {"en": "Tasks", "fr": "Tâches"},
},
)
registerModelLabels(
"TaskDefinition",
{"en": "Task Definition", "fr": "Définition de tâche"},
{
"id": {"en": "Task ID", "fr": "ID de la tâche"},
"objective": {"en": "Objective", "fr": "Objectif"},
"deliverable": {"en": "Deliverable", "fr": "Livrable"},
"requiresWebResearch": {"en": "Requires Web Research", "fr": "Nécessite une recherche web"},
"requiresDocumentAnalysis": {"en": "Requires Document Analysis", "fr": "Nécessite une analyse de documents"},
"requiresContentGeneration": {"en": "Requires Content Generation", "fr": "Nécessite une génération de contenu"},
"requiredDocuments": {"en": "Required Documents", "fr": "Documents requis"},
"extractionOptions": {"en": "Extraction Options", "fr": "Options d'extraction"},
},
)
registerModelLabels(
"TaskResult",
{"en": "Task Result", "fr": "Résultat de tâche"},
{
"taskId": {"en": "Task ID", "fr": "ID de la tâche"},
"actionResult": {"en": "Action Result", "fr": "Résultat de l'action"},
},
)
registerModelLabels(
"RequestContext",
{"en": "Request Context", "fr": "Contexte de la demande"},
{
"originalPrompt": {"en": "Original Prompt", "fr": "Invite originale"},
"documents": {"en": "Documents", "fr": "Documents"},
"userLanguage": {"en": "User Language", "fr": "Langue de l'utilisateur"},
"detectedComplexity": {"en": "Detected Complexity", "fr": "Complexité détectée"},
"requiresDocuments": {"en": "Requires Documents", "fr": "Nécessite des documents"},
"requiresWebResearch": {"en": "Requires Web Research", "fr": "Nécessite une recherche web"},
"requiresAnalysis": {"en": "Requires Analysis", "fr": "Nécessite une analyse"},
"expectedOutputFormat": {"en": "Expected Output Format", "fr": "Format de sortie attendu"},
"expectedOutputType": {"en": "Expected Output Type", "fr": "Type de sortie attendu"},
},
)
registerModelLabels(
"UnderstandingResult",
{"en": "Understanding Result", "fr": "Résultat de compréhension"},
{
"parameters": {"en": "Parameters", "fr": "Paramètres"},
"intention": {"en": "Intention", "fr": "Intention"},
"context": {"en": "Context", "fr": "Contexte"},
"documentReferences": {"en": "Document References", "fr": "Références de documents"},
"tasks": {"en": "Tasks", "fr": "Tâches"},
},
)
registerModelLabels(
"TaskDefinition",
{"en": "Task Definition", "fr": "Définition de tâche"},
{
"id": {"en": "Task ID", "fr": "ID de la tâche"},
"objective": {"en": "Objective", "fr": "Objectif"},
"deliverable": {"en": "Deliverable", "fr": "Livrable"},
"requiresWebResearch": {"en": "Requires Web Research", "fr": "Nécessite une recherche web"},
"requiresDocumentAnalysis": {"en": "Requires Document Analysis", "fr": "Nécessite une analyse de documents"},
"requiresContentGeneration": {"en": "Requires Content Generation", "fr": "Nécessite une génération de contenu"},
"requiredDocuments": {"en": "Required Documents", "fr": "Documents requis"},
"extractionOptions": {"en": "Extraction Options", "fr": "Options d'extraction"},
},
)
registerModelLabels(
"TaskResult",
{"en": "Task Result", "fr": "Résultat de tâche"},
{
"taskId": {"en": "Task ID", "fr": "ID de la tâche"},
"actionResult": {"en": "Action Result", "fr": "Résultat de l'action"},
},
)
# Register model labels for UI
registerModelLabels(
"ActionDefinition",
{"en": "Action Definition", "fr": "Définition d'action"},
{
"action": {"en": "Action", "fr": "Action"},
"actionObjective": {"en": "Action Objective", "fr": "Objectif de l'action"},
"parametersContext": {"en": "Parameters Context", "fr": "Contexte des paramètres"},
"learnings": {"en": "Learnings", "fr": "Apprentissages"},
"documentList": {"en": "Document List", "fr": "Liste de documents"},
"connectionReference": {"en": "Connection Reference", "fr": "Référence de connexion"},
"parameters": {"en": "Parameters", "fr": "Paramètres"},
},
)
registerModelLabels(
"AiResponse",
{"en": "AI Response", "fr": "Réponse IA"},
{
"content": {"en": "Content", "fr": "Contenu"},
"metadata": {"en": "Metadata", "fr": "Métadonnées"},
"documents": {"en": "Documents", "fr": "Documents"},
},
)
registerModelLabels(
"AiResponseMetadata",
{"en": "AI Response Metadata", "fr": "Métadonnées de réponse IA"},
{
"title": {"en": "Title", "fr": "Titre"},
"filename": {"en": "Filename", "fr": "Nom de fichier"},
"operationType": {"en": "Operation Type", "fr": "Type d'opération"},
"schemaVersion": {"en": "Schema Version", "fr": "Version du schéma"},
"extractionMethod": {"en": "Extraction Method", "fr": "Méthode d'extraction"},
"sourceDocuments": {"en": "Source Documents", "fr": "Documents sources"},
},
)
registerModelLabels(
"DocumentData",
{"en": "Document Data", "fr": "Données de document"},
{
"documentName": {"en": "Document Name", "fr": "Nom du document"},
"documentData": {"en": "Document Data", "fr": "Données du document"},
"mimeType": {"en": "MIME Type", "fr": "Type MIME"},
},
)
registerModelLabels(
"RequestContext",
{"en": "Request Context", "fr": "Contexte de requête"},
{
"originalPrompt": {"en": "Original Prompt", "fr": "Invite originale"},
"documents": {"en": "Documents", "fr": "Documents"},
"userLanguage": {"en": "User Language", "fr": "Langue de l'utilisateur"},
"detectedComplexity": {"en": "Detected Complexity", "fr": "Complexité détectée"},
"requiresDocuments": {"en": "Requires Documents", "fr": "Nécessite des documents"},
"requiresWebResearch": {"en": "Requires Web Research", "fr": "Nécessite une recherche web"},
"requiresAnalysis": {"en": "Requires Analysis", "fr": "Nécessite une analyse"},
},
)
registerModelLabels(
"UnderstandingResult",
{"en": "Understanding Result", "fr": "Résultat de compréhension"},
{
"parameters": {"en": "Parameters", "fr": "Paramètres"},
"intention": {"en": "Intention", "fr": "Intention"},
"context": {"en": "Context", "fr": "Contexte"},
"documentReferences": {"en": "Document References", "fr": "Références de documents"},
"tasks": {"en": "Tasks", "fr": "Tâches"},
},
)
registerModelLabels(
"TaskDefinition",
{"en": "Task Definition", "fr": "Définition de tâche"},
{
"id": {"en": "ID", "fr": "ID"},
"objective": {"en": "Objective", "fr": "Objectif"},
"deliverable": {"en": "Deliverable", "fr": "Livrable"},
"requiresWebResearch": {"en": "Requires Web Research", "fr": "Nécessite une recherche web"},
"requiresDocumentAnalysis": {"en": "Requires Document Analysis", "fr": "Nécessite une analyse de document"},
"requiresContentGeneration": {"en": "Requires Content Generation", "fr": "Nécessite une génération de contenu"},
"requiredDocuments": {"en": "Required Documents", "fr": "Documents requis"},
"extractionOptions": {"en": "Extraction Options", "fr": "Options d'extraction"},
},
)
registerModelLabels(
"TaskResult",
{"en": "Task Result", "fr": "Résultat de tâche"},
{
"taskId": {"en": "Task ID", "fr": "ID de tâche"},
"actionResult": {"en": "Action Result", "fr": "Résultat d'action"},
},
)
taskId: str = Field(description="Task identifier", json_schema_extra={"label": "Aufgaben-ID"})
actionResult: Any = Field(description="ActionResult from task execution", json_schema_extra={"label": "Aktionsergebnis"}) # ActionResult - forward reference

View file

@ -6,9 +6,10 @@ from typing import Optional, Any, Union, List, Dict, Callable, Awaitable
from pydantic import BaseModel, Field
from modules.datamodels.datamodelChat import ActionResult
from modules.shared.frontendTypes import FrontendType
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.i18nRegistry import i18nModel
@i18nModel("Workflow-Aktionsparameter")
class WorkflowActionParameter(BaseModel):
"""
Parameter schema definition for a workflow action.
@ -16,22 +17,46 @@ class WorkflowActionParameter(BaseModel):
This defines the structure and UI rendering for a single action parameter,
NOT the actual parameter values (those are in ActionDefinition.parameters).
"""
name: str = Field(description="Parameter name")
type: str = Field(description="Python type as string: 'str', 'int', 'bool', 'List[str]', etc.")
frontendType: FrontendType = Field(description="UI rendering type (from global FrontendType enum)")
name: str = Field(
description="Parameter name",
json_schema_extra={"label": "Name"},
)
type: str = Field(
description="Python type as string: 'str', 'int', 'bool', 'List[str]', etc.",
json_schema_extra={"label": "Typ"},
)
frontendType: FrontendType = Field(
description="UI rendering type (from global FrontendType enum)",
json_schema_extra={"label": "Frontend-Typ"},
)
frontendOptions: Optional[Union[str, List[str]]] = Field(
None,
description="Options for select/multiselect/custom types. String reference (e.g., 'user.connection') or list of strings (e.g., ['txt', 'json']). For custom types, this is automatically set to the API endpoint."
description="Options for select/multiselect/custom types. String reference (e.g., 'user.connection') or list of strings (e.g., ['txt', 'json']). For custom types, this is automatically set to the API endpoint.",
json_schema_extra={"label": "Frontend-Optionen"},
)
required: bool = Field(
False,
description="Whether parameter is required",
json_schema_extra={"label": "Pflichtfeld"},
)
default: Optional[Any] = Field(
None,
description="Default value",
json_schema_extra={"label": "Standard"},
)
description: str = Field(
"",
description="Parameter description",
json_schema_extra={"label": "Beschreibung"},
)
required: bool = Field(False, description="Whether parameter is required")
default: Optional[Any] = Field(None, description="Default value")
description: str = Field("", description="Parameter description")
validation: Optional[Dict[str, Any]] = Field(
None,
description="Validation rules (e.g., {'min': 1, 'max': 100})"
description="Validation rules (e.g., {'min': 1, 'max': 100})",
json_schema_extra={"label": "Validierung"},
)
@i18nModel("Workflow-Aktionsdefinition")
class WorkflowActionDefinition(BaseModel):
"""
Complete schema definition of a workflow action.
@ -43,48 +68,35 @@ class WorkflowActionDefinition(BaseModel):
This class defines the ACTION SCHEMA, not the execution plan.
"""
actionId: str = Field(
description="Unique action identifier for RBAC (format: 'module.actionName', e.g., 'outlook.readEmails')"
description="Unique action identifier for RBAC (format: 'module.actionName', e.g., 'outlook.readEmails')",
json_schema_extra={"label": "Aktions-ID"},
)
description: str = Field(
description="Action description",
json_schema_extra={"label": "Beschreibung"},
)
description: str = Field(description="Action description")
parameters: Dict[str, WorkflowActionParameter] = Field(
default_factory=dict,
description="Parameter schema definitions"
description="Parameter schema definitions",
json_schema_extra={"label": "Parameter"},
)
execute: Optional[Callable] = Field(
None,
description="Execution function - async function that takes parameters dict and returns ActionResult. Set dynamically."
description="Execution function - async function that takes parameters dict and returns ActionResult. Set dynamically.",
json_schema_extra={"label": "Ausfuehrung"},
)
category: Optional[str] = Field(
None,
description="Action category for grouping",
json_schema_extra={"label": "Kategorie"},
)
tags: List[str] = Field(
default_factory=list,
description="Tags for search/filtering",
json_schema_extra={"label": "Tags"},
)
dynamicMode: bool = Field(
False,
description="Whether this action is available in dynamic workflow mode (only tagged actions are visible in action planning and refinement prompts)",
json_schema_extra={"label": "Dynamischer Modus"},
)
category: Optional[str] = Field(None, description="Action category for grouping")
tags: List[str] = Field(default_factory=list, description="Tags for search/filtering")
dynamicMode: bool = Field(False, description="Whether this action is available in dynamic workflow mode (only tagged actions are visible in action planning and refinement prompts)")
# Register model labels for UI
registerModelLabels(
"WorkflowActionDefinition",
{"en": "Workflow Action Definition", "fr": "Définition d'action de workflow"},
{
"actionId": {"en": "Action ID", "fr": "ID d'action"},
"description": {"en": "Description", "fr": "Description"},
"parameters": {"en": "Parameters", "fr": "Paramètres"},
"category": {"en": "Category", "fr": "Catégorie"},
"tags": {"en": "Tags", "fr": "Étiquettes"},
"dynamicMode": {"en": "Dynamic Mode", "fr": "Mode dynamique"},
},
)
registerModelLabels(
"WorkflowActionParameter",
{"en": "Workflow Action Parameter", "fr": "Paramètre d'action de workflow"},
{
"name": {"en": "Name", "fr": "Nom"},
"type": {"en": "Type", "fr": "Type"},
"frontendType": {"en": "Frontend Type", "fr": "Type frontend"},
"frontendOptions": {"en": "Frontend Options", "fr": "Options frontend"},
"required": {"en": "Required", "fr": "Requis"},
"default": {"en": "Default", "fr": "Par défaut"},
"description": {"en": "Description", "fr": "Description"},
"validation": {"en": "Validation", "fr": "Validation"},
},
)

View file

@ -0,0 +1,49 @@
"""
Demo Configs Auto-Discovery Module
Scans this folder for Python files that contain subclasses of _BaseDemoConfig
and exposes them via _getAvailableDemoConfigs().
"""
import importlib
import inspect
import logging
import pkgutil
from typing import Dict
from modules.demoConfigs._baseDemoConfig import _BaseDemoConfig
logger = logging.getLogger(__name__)
_configCache: Dict[str, _BaseDemoConfig] = {}
def _getAvailableDemoConfigs() -> Dict[str, _BaseDemoConfig]:
"""Return a dict of code -> instance for every discovered demo config."""
if _configCache:
return _configCache
package = __name__
packagePath = __path__
for importer, moduleName, isPkg in pkgutil.iter_modules(packagePath):
if moduleName.startswith("_"):
continue
try:
module = importlib.import_module(f"{package}.{moduleName}")
for name, obj in inspect.getmembers(module, inspect.isclass):
if issubclass(obj, _BaseDemoConfig) and obj is not _BaseDemoConfig:
instance = obj()
if instance.code:
_configCache[instance.code] = instance
logger.info(f"Discovered demo config: {instance.code} ({instance.label})")
except Exception as e:
logger.warning(f"Failed to load demo config module '{moduleName}': {e}")
return _configCache
def _getDemoConfigByCode(code: str) -> _BaseDemoConfig | None:
"""Get a specific demo config by its code."""
configs = _getAvailableDemoConfigs()
return configs.get(code)

View file

@ -0,0 +1,38 @@
"""
Base class for demo configurations.
Each demo config file in this folder extends _BaseDemoConfig and provides
idempotent load() and remove() methods for setting up / tearing down
a complete demo environment (mandates, users, features, test data, etc.).
"""
import logging
from abc import ABC, abstractmethod
from typing import Dict, Any
logger = logging.getLogger(__name__)
class _BaseDemoConfig(ABC):
"""Abstract base for demo configurations."""
code: str = ""
label: str = ""
description: str = ""
@abstractmethod
def load(self, db) -> Dict[str, Any]:
"""Create all demo data (idempotent). Returns summary dict."""
raise NotImplementedError
@abstractmethod
def remove(self, db) -> Dict[str, Any]:
"""Remove all demo data. Returns summary dict."""
raise NotImplementedError
def toDict(self) -> Dict[str, Any]:
return {
"code": self.code,
"label": self.label,
"description": self.description,
}

View file

@ -0,0 +1,350 @@
"""
Investor Demo April 2026
Creates a complete demo environment with two mandates, one user,
and all feature instances needed for the investor live demo.
Mandates:
- HappyLife AG (happylife) workspace, trustee(RMA), graphEditor, chatbot, neutralization
- Alpina Treuhand AG (alpina) workspace, trustee(RMA), graphEditor, neutralization
User:
- Patrick Helvetia (p.motsch@poweron.swiss) SysAdmin, member of both mandates
"""
import json
import logging
import uuid
from typing import Dict, Any, Optional, List
from modules.demoConfigs._baseDemoConfig import _BaseDemoConfig
logger = logging.getLogger(__name__)
_DEMO_PREFIX = "demo-inv2026"
_MANDATE_HAPPYLIFE = {
"name": "happylife",
"label": "HappyLife AG",
}
_MANDATE_ALPINA = {
"name": "alpina-treuhand",
"label": "Alpina Treuhand AG",
}
_USER = {
"username": "patrick.helvetia",
"email": "p.motsch@poweron.swiss",
"fullName": "Patrick Helvetia",
"password": "patrick.helvetia",
"language": "en",
}
_FEATURES_HAPPYLIFE = ["workspace", "trustee", "graphicalEditor", "chatbot", "neutralization"]
_FEATURES_ALPINA = ["workspace", "trustee", "graphicalEditor", "neutralization"]
class InvestorDemo2026(_BaseDemoConfig):
code = "investor-demo-2026"
label = "Investor Demo April 2026"
description = (
"Two mandates (HappyLife AG + Alpina Treuhand AG), one SysAdmin user, "
"trustee with RMA, workspace, graph editor, chatbot, and neutralization."
)
# ------------------------------------------------------------------
# load
# ------------------------------------------------------------------
def load(self, db) -> Dict[str, Any]:
summary: Dict[str, Any] = {"created": [], "skipped": [], "errors": []}
try:
mandateIdHappy = self._ensureMandate(db, _MANDATE_HAPPYLIFE, summary)
mandateIdAlpina = self._ensureMandate(db, _MANDATE_ALPINA, summary)
userId = self._ensureUser(db, summary)
if mandateIdHappy:
self._ensureMembership(db, userId, mandateIdHappy, _MANDATE_HAPPYLIFE["label"], summary)
self._ensureFeatures(db, mandateIdHappy, _MANDATE_HAPPYLIFE["label"], _FEATURES_HAPPYLIFE, summary)
if mandateIdAlpina:
self._ensureMembership(db, userId, mandateIdAlpina, _MANDATE_ALPINA["label"], summary)
self._ensureFeatures(db, mandateIdAlpina, _MANDATE_ALPINA["label"], _FEATURES_ALPINA, summary)
self._ensureTrusteeRmaConfig(db, mandateIdHappy, _MANDATE_HAPPYLIFE["label"], summary)
self._ensureTrusteeRmaConfig(db, mandateIdAlpina, _MANDATE_ALPINA["label"], 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)
except Exception as e:
logger.error(f"Demo load failed: {e}", exc_info=True)
summary["errors"].append(str(e))
return summary
# ------------------------------------------------------------------
# remove
# ------------------------------------------------------------------
def remove(self, db) -> Dict[str, Any]:
summary: Dict[str, Any] = {"removed": [], "errors": []}
from modules.datamodels.datamodelUam import Mandate, UserInDB
from modules.datamodels.datamodelMembership import UserMandate
for mandateDef in [_MANDATE_HAPPYLIFE, _MANDATE_ALPINA]:
try:
existing = db.getRecordset(Mandate, recordFilter={"name": mandateDef["name"]})
for m in existing:
mid = m.get("id")
db.recordDelete(Mandate, mid)
summary["removed"].append(f"Mandate {mandateDef['label']} ({mid})")
logger.info(f"Removed mandate {mandateDef['label']} ({mid})")
except Exception as e:
summary["errors"].append(f"Remove mandate {mandateDef['label']}: {e}")
try:
existing = db.getRecordset(UserInDB, recordFilter={"username": _USER["username"]})
for u in existing:
uid = u.get("id")
memberships = db.getRecordset(UserMandate, recordFilter={"userId": uid})
for mem in memberships:
try:
db.recordDelete(UserMandate, mem.get("id"))
except Exception:
pass
db.recordDelete(UserInDB, uid)
summary["removed"].append(f"User {_USER['username']} ({uid})")
logger.info(f"Removed user {_USER['username']} ({uid})")
except Exception as e:
summary["errors"].append(f"Remove user: {e}")
self._removeLanguageSet(db, "es", summary)
return summary
# ------------------------------------------------------------------
# helpers
# ------------------------------------------------------------------
def _ensureMandate(self, db, mandateDef: Dict, summary: Dict) -> Optional[str]:
from modules.datamodels.datamodelUam import Mandate
from modules.interfaces.interfaceBootstrap import copySystemRolesToMandate
existing = db.getRecordset(Mandate, recordFilter={"name": mandateDef["name"]})
if existing:
mid = existing[0].get("id")
summary["skipped"].append(f"Mandate {mandateDef['label']} exists ({mid})")
return mid
mandate = Mandate(name=mandateDef["name"], label=mandateDef["label"], enabled=True)
created = db.recordCreate(Mandate, mandate)
mid = created.get("id")
logger.info(f"Created mandate {mandateDef['label']} ({mid})")
summary["created"].append(f"Mandate {mandateDef['label']}")
copySystemRolesToMandate(db, mid)
return mid
def _ensureUser(self, db, summary: Dict) -> Optional[str]:
from modules.datamodels.datamodelUam import UserInDB, AuthAuthority
from passlib.context import CryptContext
existing = db.getRecordset(UserInDB, recordFilter={"username": _USER["username"]})
if existing:
uid = existing[0].get("id")
summary["skipped"].append(f"User {_USER['username']} exists ({uid})")
return uid
pwdContext = CryptContext(schemes=["argon2"], deprecated="auto")
user = UserInDB(
username=_USER["username"],
email=_USER["email"],
fullName=_USER["fullName"],
enabled=True,
language=_USER["language"],
isSysAdmin=True,
authenticationAuthority=AuthAuthority.LOCAL,
hashedPassword=pwdContext.hash(_USER["password"]),
)
created = db.recordCreate(UserInDB, user)
uid = created.get("id")
logger.info(f"Created user {_USER['username']} ({uid})")
summary["created"].append(f"User {_USER['fullName']}")
return uid
def _ensureMembership(self, db, userId: str, mandateId: str, mandateLabel: str, summary: Dict):
from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
from modules.datamodels.datamodelRbac import Role
existing = db.getRecordset(UserMandate, recordFilter={"userId": userId, "mandateId": mandateId})
if existing:
userMandateId = existing[0].get("id")
summary["skipped"].append(f"Membership {_USER['username']} -> {mandateLabel} exists")
else:
um = UserMandate(userId=userId, mandateId=mandateId, enabled=True)
created = db.recordCreate(UserMandate, um)
userMandateId = created.get("id")
summary["created"].append(f"Membership {_USER['username']} -> {mandateLabel}")
logger.info(f"Created membership {_USER['username']} -> {mandateLabel}")
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})
if not existingRole:
umr = UserMandateRole(userMandateId=userMandateId, roleId=adminRoleId)
db.recordCreate(UserMandateRole, umr)
logger.info(f"Assigned admin role in {mandateLabel}")
def _ensureFeatures(self, db, mandateId: str, mandateLabel: str, featureCodes: List[str], summary: Dict):
from modules.interfaces.interfaceFeatures import getFeatureInterface
fi = getFeatureInterface(db)
existingInstances = fi.getFeatureInstancesForMandate(mandateId)
existingCodes = {
(inst.featureCode if hasattr(inst, "featureCode") else inst.get("featureCode", ""))
for inst in existingInstances
}
for code in featureCodes:
if code in existingCodes:
summary["skipped"].append(f"Feature {code} in {mandateLabel} exists")
continue
try:
fi.createFeatureInstance(
featureCode=code,
mandateId=mandateId,
label=f"{code} ({mandateLabel})",
enabled=True,
copyTemplateRoles=True,
)
summary["created"].append(f"Feature {code} in {mandateLabel}")
logger.info(f"Created feature instance {code} in {mandateLabel}")
except Exception as e:
summary["errors"].append(f"Feature {code} in {mandateLabel}: {e}")
logger.error(f"Failed to create feature {code} in {mandateLabel}: {e}")
def _ensureTrusteeRmaConfig(self, db, mandateId: Optional[str], mandateLabel: str, summary: Dict):
if not mandateId:
return
from modules.datamodels.datamodelFeatures import FeatureInstance
from modules.features.trustee.datamodelFeatureTrustee import TrusteeAccountingConfig
from modules.shared.configuration import APP_CONFIG, encryptValue
instances = db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId, "featureCode": "trustee"})
if not instances:
summary["skipped"].append(f"No trustee instance in {mandateLabel} for RMA config")
return
instanceId = instances[0].get("id")
existing = db.getRecordset(TrusteeAccountingConfig, recordFilter={"featureInstanceId": instanceId})
if existing:
summary["skipped"].append(f"RMA config for {mandateLabel} exists")
return
apiBaseUrl = APP_CONFIG.get("Demo_RMA_ApiBaseUrl", "")
clientName = APP_CONFIG.get("Demo_RMA_ClientName", "")
apiKey = APP_CONFIG.get("Demo_RMA_ApiKey", "")
if not apiBaseUrl or not apiKey:
summary["errors"].append(
f"RMA credentials missing in config.ini (Demo_RMA_ApiBaseUrl, Demo_RMA_ClientName, Demo_RMA_ApiKey) for {mandateLabel}"
)
return
plainConfig = {
"apiBaseUrl": apiBaseUrl,
"clientName": clientName,
"apiKey": apiKey,
}
configRecord = {
"id": str(uuid.uuid4()),
"featureInstanceId": instanceId,
"connectorType": "rma",
"displayLabel": "Run My Accounts",
"encryptedConfig": encryptValue(json.dumps(plainConfig), keyName="accountingConfig"),
"isActive": True,
"mandateId": mandateId,
}
db.recordCreate(TrusteeAccountingConfig, configRecord)
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], userId: Optional[str], summary: Dict):
if not mandateId or not userId:
return
from modules.datamodels.datamodelFeatures import FeatureInstance
instances = db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId, "featureCode": "neutralization"})
if not instances:
return
instanceId = instances[0].get("id")
try:
from modules.features.neutralization.datamodelFeatureNeutralizer import DataNeutraliserConfig
existing = db.getRecordset(DataNeutraliserConfig, recordFilter={"featureInstanceId": instanceId})
if existing:
summary["skipped"].append(f"Neutralization config for mandate {mandateId} exists")
return
config = DataNeutraliserConfig(
featureInstanceId=instanceId,
mandateId=mandateId,
userId=userId,
enabled=True,
scope="featureInstance",
)
db.recordCreate(DataNeutraliserConfig, config)
summary["created"].append(f"Neutralization config for mandate {mandateId}")
logger.info(f"Created neutralization config for mandate {mandateId}")
except Exception as e:
summary["errors"].append(f"Neutralization config: {e}")
def _ensureBilling(self, db, mandateId: Optional[str], mandateLabel: str, summary: Dict):
if not mandateId:
return
try:
from modules.interfaces.interfaceDbBilling import _getRootInterface
from modules.datamodels.datamodelBilling import BillingSettings
billingInterface = _getRootInterface()
existingSettings = billingInterface.getSettings(mandateId)
if existingSettings:
summary["skipped"].append(f"Billing for {mandateLabel} exists")
return
settings = BillingSettings(
mandateId=mandateId,
warningThresholdPercent=10.0,
notifyOnWarning=True,
)
billingInterface.db.recordCreate(BillingSettings, settings)
summary["created"].append(f"Billing settings for {mandateLabel}")
logger.info(f"Created billing settings for {mandateLabel}")
except Exception as e:
summary["errors"].append(f"Billing for {mandateLabel}: {e}")
def _removeLanguageSet(self, db, code: str, summary: Dict):
"""Remove a language set if it was created during demo (e.g. 'es' from UC4)."""
try:
from modules.datamodels.datamodelUiLanguage import UiLanguageSet
existing = db.getRecordset(UiLanguageSet, recordFilter={"id": code})
if existing:
db.recordDelete(UiLanguageSet, code)
summary["removed"].append(f"Language set '{code}'")
logger.info(f"Removed language set '{code}'")
except Exception as e:
logger.debug(f"Could not remove language set '{code}': {e}")

View file

@ -1,97 +0,0 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""Automation models: AutomationDefinition, AutomationTemplate."""
from typing import List, Dict, Any, Optional
from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
from modules.datamodels.datamodelUtils import TextMultilingual
import uuid
class AutomationDefinition(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
mandateId: str = Field(description="Mandate ID", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
featureInstanceId: str = Field(description="ID of the feature instance this automation belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
label: str = Field(description="User-friendly name", json_schema_extra={"frontend_type": "text", "frontend_required": True})
schedule: str = Field(description="Cron schedule pattern", json_schema_extra={"frontend_type": "select", "frontend_required": True, "frontend_options": [
{"value": "0 */4 * * *", "label": {"en": "Every 4 hours", "fr": "Toutes les 4 heures"}},
{"value": "0 22 * * *", "label": {"en": "Daily at 22:00", "fr": "Quotidien à 22:00"}},
{"value": "0 10 * * 1", "label": {"en": "Weekly Monday 10:00", "fr": "Hebdomadaire lundi 10:00"}}
]})
template: str = Field(description="JSON template with placeholders (format: {{KEY:PLACEHOLDER_NAME}})", json_schema_extra={"frontend_type": "textarea", "frontend_required": True})
placeholders: Dict[str, str] = Field(default_factory=dict, description="Dictionary of placeholder key/value pairs (e.g., {'connectionName': 'MyConnection', 'sharepointFolderNameSource': '/folder/path', 'webResearchUrl': 'https://...', 'webResearchPrompt': '...', 'documentPrompt': '...'})", json_schema_extra={"frontend_type": "textarea"})
active: bool = Field(default=False, description="Whether automation should be launched in event handler", json_schema_extra={"frontend_type": "checkbox", "frontend_required": False})
eventId: Optional[str] = Field(None, description="Event ID from event management (None if not registered)", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
status: Optional[str] = Field(None, description="Status: 'active' if event is registered, 'inactive' if not (computed, readonly)", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
executionLogs: List[Dict[str, Any]] = Field(default_factory=list, description="List of execution logs, each containing timestamp, workflowId, status, and messages", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
allowedProviders: List[str] = Field(default_factory=list, description="List of allowed AICore providers (e.g., 'anthropic', 'openai'). Empty means all RBAC-permitted providers are allowed.", json_schema_extra={"frontend_type": "multiselect", "frontend_readonly": False, "frontend_required": False})
registerModelLabels(
"AutomationDefinition",
{"en": "Automation Definition", "ge": "Automatisierungs-Definition", "fr": "Définition d'automatisation"},
{
"id": {"en": "ID", "ge": "ID", "fr": "ID"},
"mandateId": {"en": "Mandate ID", "ge": "Mandanten-ID", "fr": "ID du mandat"},
"featureInstanceId": {"en": "Feature Instance ID", "ge": "Feature-Instanz-ID", "fr": "ID de l'instance de fonctionnalité"},
"label": {"en": "Label", "ge": "Bezeichnung", "fr": "Libellé"},
"schedule": {"en": "Schedule", "ge": "Zeitplan", "fr": "Planification"},
"template": {"en": "Template", "ge": "Vorlage", "fr": "Modèle"},
"placeholders": {"en": "Placeholders", "ge": "Platzhalter", "fr": "Espaces réservés"},
"active": {"en": "Active", "ge": "Aktiv", "fr": "Actif"},
"eventId": {"en": "Event ID", "ge": "Event-ID", "fr": "ID de l'événement"},
"status": {"en": "Status", "ge": "Status", "fr": "Statut"},
"executionLogs": {"en": "Execution Logs", "ge": "Ausführungsprotokolle", "fr": "Journaux d'exécution"},
"allowedProviders": {"en": "Allowed Providers", "ge": "Erlaubte Provider", "fr": "Fournisseurs autorisés"},
},
)
class AutomationTemplate(PowerOnModel):
"""Automation-Vorlage ohne scharfe Placeholder-Werte (DB-persistiert).
System-Templates (isSystem=True): Nur durch SysAdmin aenderbar. Alle User koennen lesen.
Instance-Templates (isSystem=False, featureInstanceId gesetzt): CRUD durch Instance-Admin/Editor.
"""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True}
)
label: TextMultilingual = Field(
description="Template name (multilingual)",
json_schema_extra={"frontend_type": "multilingual", "frontend_required": True}
)
overview: Optional[TextMultilingual] = Field(
None,
description="Short description (multilingual)",
json_schema_extra={"frontend_type": "multilingual", "frontend_required": False}
)
template: str = Field(
description="JSON workflow structure with {{KEY:...}} placeholders",
json_schema_extra={"frontend_type": "textarea", "frontend_required": True}
)
isSystem: bool = Field(
default=False,
description="System template (only SysAdmin can modify, all users can read)",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False}
)
featureInstanceId: Optional[str] = Field(
None,
description="Feature instance ID (null for system templates, set for instance-scoped templates)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
registerModelLabels(
"AutomationTemplate",
{"en": "Automation Template", "ge": "Automation-Vorlage", "fr": "Modèle d'automatisation"},
{
"id": {"en": "ID", "ge": "ID", "fr": "ID"},
"label": {"en": "Label", "ge": "Bezeichnung", "fr": "Libellé"},
"overview": {"en": "Overview", "ge": "Übersicht", "fr": "Aperçu"},
"template": {"en": "Template", "ge": "Vorlage", "fr": "Modèle"},
"isSystem": {"en": "System Template", "ge": "System-Vorlage", "fr": "Modèle système"},
"featureInstanceId": {"en": "Feature Instance", "ge": "Feature-Instanz", "fr": "Instance de fonctionnalité"},
},
)

View file

@ -1,872 +0,0 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Interface for Automation feature - manages AutomationDefinition and AutomationTemplate.
Uses the PostgreSQL connector for data access with user/mandate filtering.
"""
import logging
import uuid
import math
from typing import Dict, Any, List, Optional, Union
from modules.security.rbac import RbacClass
from modules.datamodels.datamodelRbac import AccessRuleContext
from modules.datamodels.datamodelUam import AccessLevel, User
from modules.features.automation.datamodelFeatureAutomation import AutomationDefinition, AutomationTemplate
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResult
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC, buildDataObjectKey
from modules.shared.configuration import APP_CONFIG
logger = logging.getLogger(__name__)
def _automationDefinitionPayload(data: Dict[str, Any]) -> Dict[str, Any]:
"""Strip connector/enrichment keys; only fields defined on AutomationDefinition."""
allowed = AutomationDefinition.model_fields.keys()
return {k: v for k, v in (data or {}).items() if k in allowed}
# Singleton factory for Automation instances
_automationInterfaces = {}
class AutomationObjects:
"""
Interface for Automation database operations.
Manages AutomationDefinition and AutomationTemplate with RBAC support.
"""
def __init__(self, currentUser: User, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None):
self.currentUser = currentUser
self.mandateId = mandateId
self.featureInstanceId = featureInstanceId
self.userId = currentUser.id if currentUser else None
# Initialize database with proper configuration
self._initializeDatabase()
# Initialize RBAC - AccessRules are in poweron_app, not poweron_automation!
from modules.security.rootAccess import getRootDbAppConnector
dbApp = getRootDbAppConnector()
self.rbac = RbacClass(self.db, dbApp=dbApp)
# Update database context
self.db.updateContext(self.userId)
def _initializeDatabase(self):
"""Initializes the database connection with proper configuration."""
# Get configuration values
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
dbDatabase = "poweron_automation"
dbUser = APP_CONFIG.get("DB_USER")
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
# Create database connector with full configuration
self.db = DatabaseConnector(
dbHost=dbHost,
dbDatabase=dbDatabase,
dbUser=dbUser,
dbPassword=dbPassword,
dbPort=dbPort,
userId=self.userId,
)
logger.debug(f"Automation database initialized for user {self.userId}")
def setUserContext(self, currentUser: User, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None):
"""Update user context for the interface."""
self.currentUser = currentUser
self.mandateId = mandateId
self.featureInstanceId = featureInstanceId
self.userId = currentUser.id if currentUser else None
if hasattr(self.db, 'updateContext'):
self.db.updateContext(self.userId)
def checkRbacPermission(self, model, action: str, recordId: str = None) -> bool:
"""Check RBAC permission for a specific action on a model."""
objectKey = buildDataObjectKey(model.__name__)
permissions = self.rbac.getUserPermissions(
user=self.currentUser,
context=AccessRuleContext.DATA,
item=objectKey,
mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId
)
accessLevel = getattr(permissions, action, AccessLevel.NONE)
if accessLevel == AccessLevel.ALL:
return True
elif accessLevel == AccessLevel.GROUP:
return True
elif accessLevel == AccessLevel.MY:
if recordId:
record = self.db.getRecordset(model, recordFilter={"id": recordId})
if record:
return record[0].get("sysCreatedBy") == self.userId
else:
return False # Record not found = no access
return True # No recordId needed (e.g., for CREATE)
return False
# =========================================================================
# AutomationDefinition CRUD methods
# =========================================================================
def _computeAutomationStatus(self, automation: Dict[str, Any]) -> str:
"""Compute status field based on eventId presence"""
eventId = automation.get("eventId")
return "Running" if eventId else "Idle"
def _enrichAutomationsWithUserAndMandate(self, automations: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
Batch enrich automations with user names, mandate names and feature instance labels.
Uses direct DB lookup (no RBAC) because this is purely cosmetic enrichment
the user already has RBAC-verified access to the automations themselves.
"""
if not automations:
return automations
# Collect all unique IDs
userIds = set()
mandateIds = set()
featureInstanceIds = set()
for automation in automations:
createdBy = automation.get("sysCreatedBy")
if createdBy:
userIds.add(createdBy)
mandateId = automation.get("mandateId")
if mandateId:
mandateIds.add(mandateId)
featureInstanceId = automation.get("featureInstanceId")
if featureInstanceId:
featureInstanceIds.add(featureInstanceId)
# Use root DB connector for display-only lookups (no RBAC needed)
usersMap = {}
mandatesMap = {}
featureInstancesMap = {}
try:
from modules.datamodels.datamodelUam import UserInDB, Mandate
from modules.datamodels.datamodelFeatures import FeatureInstance
from modules.security.rootAccess import getRootDbAppConnector
dbAppConn = getRootDbAppConnector()
# Batch fetch user display names
if userIds:
for userId in userIds:
users = dbAppConn.getRecordset(UserInDB, recordFilter={"id": userId})
if users:
user = users[0]
displayName = user.get("fullName") or user.get("username") or user.get("email") or None
if displayName:
usersMap[userId] = displayName
# Batch fetch mandate display names
if mandateIds:
for mandateId in mandateIds:
mandates = dbAppConn.getRecordset(Mandate, recordFilter={"id": mandateId})
if mandates:
label = mandates[0].get("label") or mandates[0].get("name") or None
if label:
mandatesMap[mandateId] = label
# Batch fetch feature instance labels
if featureInstanceIds:
for fiId in featureInstanceIds:
instances = dbAppConn.getRecordset(FeatureInstance, recordFilter={"id": fiId})
if instances:
fi = instances[0]
label = fi.get("label") or fi.get("featureCode") or None
if label:
featureInstancesMap[fiId] = label
except Exception as e:
logger.warning(f"Could not enrich automations with display names: {e}")
# Enrich each automation with the fetched data
# SECURITY: Never show a fallback name — if lookup fails, show empty string
for automation in automations:
createdBy = automation.get("sysCreatedBy")
automation["sysCreatedByUserName"] = usersMap.get(createdBy, "") if createdBy else ""
mandateId = automation.get("mandateId")
automation["mandateName"] = mandatesMap.get(mandateId, "") if mandateId else ""
featureInstanceId = automation.get("featureInstanceId")
automation["featureInstanceName"] = featureInstancesMap.get(featureInstanceId, "") if featureInstanceId else ""
return automations
def _enrichAutomationWithUserAndMandate(self, automation: Dict[str, Any]) -> Dict[str, Any]:
"""
Enrich a single automation with user name and mandate name for display.
For multiple automations, use _enrichAutomationsWithUserAndMandate for better performance.
"""
return self._enrichAutomationsWithUserAndMandate([automation])[0]
def getAllAutomationDefinitions(self, pagination: Optional[PaginationParams] = None) -> Union[List[Dict[str, Any]], PaginatedResult]:
"""
Returns automation definitions based on user access level.
Supports optional pagination, sorting, and filtering.
Computes status field for each automation.
"""
# AutomationDefinitions can belong to any feature instance within a mandate.
# Filter by mandateId only — not by featureInstanceId — to show all definitions across features.
filteredAutomations = getRecordsetWithRBAC(
self.db,
AutomationDefinition,
self.currentUser,
mandateId=self.mandateId
)
# Compute status for each automation and normalize executionLogs
for automation in filteredAutomations:
automation["status"] = self._computeAutomationStatus(automation)
# Ensure executionLogs is always a list, not None
if automation.get("executionLogs") is None:
automation["executionLogs"] = []
# Batch enrich with user and mandate names
self._enrichAutomationsWithUserAndMandate(filteredAutomations)
# If no pagination requested, return all items
if pagination is None:
return filteredAutomations
# Apply filtering (if filters provided)
if pagination.filters:
filteredAutomations = self._applyFilters(filteredAutomations, pagination.filters)
# Apply sorting (in order of sortFields)
if pagination.sort:
filteredAutomations = self._applySorting(filteredAutomations, pagination.sort)
# Count total items after filters
totalItems = len(filteredAutomations)
totalPages = math.ceil(totalItems / pagination.pageSize) if totalItems > 0 else 0
# Apply pagination (skip/limit)
startIdx = (pagination.page - 1) * pagination.pageSize
endIdx = startIdx + pagination.pageSize
pagedAutomations = filteredAutomations[startIdx:endIdx]
return PaginatedResult(
items=pagedAutomations,
totalItems=totalItems,
totalPages=totalPages
)
def _applyFilters(self, items: List[Dict], filters: Dict[str, Any]) -> List[Dict]:
"""Apply filters to a list of items."""
if not filters:
return items
filtered = []
for item in items:
match = True
for key, value in filters.items():
itemValue = item.get(key)
if isinstance(value, str) and isinstance(itemValue, str):
if value.lower() not in itemValue.lower():
match = False
break
elif str(itemValue).lower() != str(value).lower():
match = False
break
if match:
filtered.append(item)
return filtered
def _applySorting(self, items: List[Dict], sortFields: List[Dict]) -> List[Dict]:
"""Apply sorting to a list of items."""
if not sortFields:
return items
for sortField in reversed(sortFields):
field = sortField.get("field", "")
direction = sortField.get("direction", "asc")
reverse = direction.lower() == "desc"
items = sorted(items, key=lambda x: x.get(field, ""), reverse=reverse)
return items
def getAutomationDefinition(self, automationId: str, includeSystemFields: bool = False) -> Optional[AutomationDefinition]:
"""Returns an automation definition by ID if user has access, with computed status.
Args:
automationId: ID of the automation to get
includeSystemFields: If True, returns raw dict with system fields (sysCreatedBy, etc).
If False (default), returns Pydantic model without system fields.
"""
try:
# AutomationDefinitions can belong to any feature instance within a mandate.
# Filter by mandateId only — not by featureInstanceId.
filtered = getRecordsetWithRBAC(
self.db,
AutomationDefinition,
self.currentUser,
recordFilter={"id": automationId},
mandateId=self.mandateId
)
if not filtered:
return None
automation = filtered[0]
automation["status"] = self._computeAutomationStatus(automation)
# Ensure executionLogs is always a list, not None
if automation.get("executionLogs") is None:
automation["executionLogs"] = []
# Enrich with user and mandate names
self._enrichAutomationWithUserAndMandate(automation)
# For internal use (execution), return raw dict with system fields
if includeSystemFields:
# Return as simple namespace object so getattr works
class AutomationWithSystemFields:
def __init__(self, data):
for key, value in data.items():
setattr(self, key, value)
return AutomationWithSystemFields(automation)
# Clean metadata fields and return Pydantic model
cleanedRecord = _automationDefinitionPayload(automation)
return AutomationDefinition(**cleanedRecord)
except Exception as e:
logger.error(f"Error getting automation definition: {str(e)}")
return None
def createAutomationDefinition(self, automationData: Dict[str, Any]) -> AutomationDefinition:
"""Creates a new automation definition, then triggers sync."""
try:
# Ensure ID is present
if "id" not in automationData or not automationData["id"]:
automationData["id"] = str(uuid.uuid4())
# Ensure mandateId and featureInstanceId are set for proper data isolation
if "mandateId" not in automationData or not automationData.get("mandateId"):
# Use request context mandateId, or fall back to Root mandate
effectiveMandateId = self.mandateId
if not effectiveMandateId:
# Fall back to Root mandate (first mandate in system)
try:
from modules.datamodels.datamodelUam import Mandate
from modules.security.rootAccess import getRootDbAppConnector
dbAppConn = getRootDbAppConnector()
allMandates = dbAppConn.getRecordset(Mandate)
if allMandates:
effectiveMandateId = allMandates[0].get("id")
logger.debug(f"createAutomationDefinition: Using Root mandate {effectiveMandateId}")
except Exception as e:
logger.warning(f"Could not get Root mandate: {e}")
automationData["mandateId"] = effectiveMandateId
if "featureInstanceId" not in automationData:
automationData["featureInstanceId"] = self.featureInstanceId
# Ensure database connector has correct userId context
if not self.userId:
logger.error(f"createAutomationDefinition: userId is not set! Cannot set sysCreatedBy. currentUser={self.currentUser}")
elif hasattr(self.db, 'updateContext'):
try:
self.db.updateContext(self.userId)
logger.debug(f"createAutomationDefinition: Updated database context with userId={self.userId}")
except Exception as e:
logger.warning(f"Could not update database context: {e}")
# Create automation in database
createdAutomation = self.db.recordCreate(AutomationDefinition, automationData)
# Compute status
createdAutomation["status"] = self._computeAutomationStatus(createdAutomation)
# Ensure executionLogs is always a list, not None
if createdAutomation.get("executionLogs") is None:
createdAutomation["executionLogs"] = []
# Trigger automation change callback
self._notifyAutomationChanged()
# Clean metadata fields and return Pydantic model
cleanedRecord = _automationDefinitionPayload(createdAutomation)
return AutomationDefinition(**cleanedRecord)
except Exception as e:
logger.error(f"Error creating automation definition: {str(e)}")
raise
def _saveExecutionLog(self, automationId: str, executionLogs: List[Dict[str, Any]]) -> None:
"""
Save execution logs to an automation definition WITHOUT RBAC check.
This is a system-level operation: when a user executes an automation,
the execution log must be saved regardless of whether the user has
'update' permission on the AutomationDefinition. The user already
proved they have execute/read access by loading the automation.
"""
try:
self.db.recordModify(AutomationDefinition, automationId, {"executionLogs": executionLogs})
logger.debug(f"Saved execution log for automation {automationId}")
except Exception as e:
logger.warning(f"Could not save execution log for automation {automationId}: {e}")
def updateAutomationDefinition(self, automationId: str, automationData: Dict[str, Any]) -> AutomationDefinition:
"""Updates an automation definition, then triggers sync."""
try:
# Check access
existing = self.getAutomationDefinition(automationId)
if not existing:
raise PermissionError(f"No access to automation {automationId}")
if not self.checkRbacPermission(AutomationDefinition, "update", automationId):
raise PermissionError(f"No permission to modify automation {automationId}")
automationData.pop("executionLogs", None)
# If deactivating: immediately remove scheduler job (don't rely on async callback)
isBeingDeactivated = "active" in automationData and not automationData["active"]
if isBeingDeactivated:
existingEventId = getattr(existing, "eventId", None) if not isinstance(existing, dict) else existing.get("eventId")
if existingEventId:
try:
from modules.shared.eventManagement import eventManager
eventManager.remove(existingEventId)
logger.info(f"Removed scheduler job {existingEventId} (automation deactivated)")
except Exception as e:
logger.warning(f"Could not remove scheduler job {existingEventId}: {e}")
automationData["eventId"] = None
# Update automation in database
updatedAutomation = self.db.recordModify(AutomationDefinition, automationId, automationData)
# Compute status
updatedAutomation["status"] = self._computeAutomationStatus(updatedAutomation)
# Ensure executionLogs is always a list, not None
if updatedAutomation.get("executionLogs") is None:
updatedAutomation["executionLogs"] = []
# Trigger automation change callback
self._notifyAutomationChanged()
# Clean metadata fields and return Pydantic model
cleanedRecord = _automationDefinitionPayload(updatedAutomation)
return AutomationDefinition(**cleanedRecord)
except Exception as e:
logger.error(f"Error updating automation definition: {str(e)}")
raise
def deleteAutomationDefinition(self, automationId: str) -> bool:
"""Deletes an automation definition, then triggers sync."""
try:
# Check access
existing = self.getAutomationDefinition(automationId)
if not existing:
raise PermissionError(f"No access to automation {automationId}")
if not self.checkRbacPermission(AutomationDefinition, "delete", automationId):
raise PermissionError(f"No permission to delete automation {automationId}")
# Delete automation from database
self.db.recordDelete(AutomationDefinition, automationId)
# Trigger automation change callback
self._notifyAutomationChanged()
return True
except Exception as e:
logger.error(f"Error deleting automation definition: {str(e)}")
raise
def getAllAutomationDefinitionsWithRBAC(self, user: User) -> List[Dict[str, Any]]:
"""
Get all automation definitions filtered by RBAC for a specific user.
This method encapsulates getRecordsetWithRBAC() to avoid exposing the connector.
Args:
user: User object for RBAC filtering
Returns:
List of automation definition dictionaries filtered by RBAC
"""
return getRecordsetWithRBAC(
self.db,
AutomationDefinition,
user,
mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId
)
# =========================================================================
# AutomationTemplate CRUD methods
# =========================================================================
def getAllAutomationTemplates(self, pagination: Optional[PaginationParams] = None) -> Union[List[Dict[str, Any]], PaginatedResult]:
"""
Returns automation templates: system templates + instance templates for current instance.
System templates (isSystem=True) are always included (read-only for non-SysAdmin).
Instance templates (featureInstanceId matches) are included with RBAC filtering.
"""
# Load ALL templates and filter in Python.
# Reason: seeded/legacy templates may have isSystem=NULL (not False/True),
# which breaks SQL equality filters (NULL != True AND NULL != False).
allTemplates = self.db.getRecordset(AutomationTemplate)
filteredTemplates = []
for t in allTemplates:
isSystem = t.get("isSystem")
fid = t.get("featureInstanceId")
if isSystem is True:
# System templates — always visible to all users
filteredTemplates.append(t)
elif fid and fid == self.featureInstanceId:
# Instance templates — scoped to current feature instance
filteredTemplates.append(t)
elif not fid:
# Global/legacy templates (no featureInstanceId) — visible to all users
filteredTemplates.append(t)
# Enrich with user names
self._enrichTemplatesWithUserName(filteredTemplates)
# If no pagination requested, return all items
if pagination is None:
return filteredTemplates
# Apply filtering (if filters provided)
if pagination.filters:
filteredTemplates = self._applyFilters(filteredTemplates, pagination.filters)
# Apply sorting (in order of sortFields)
if pagination.sort:
filteredTemplates = self._applySorting(filteredTemplates, pagination.sort)
# Count total items after filters
totalItems = len(filteredTemplates)
totalPages = math.ceil(totalItems / pagination.pageSize) if totalItems > 0 else 0
# Apply pagination (skip/limit)
startIdx = (pagination.page - 1) * pagination.pageSize
endIdx = startIdx + pagination.pageSize
pagedTemplates = filteredTemplates[startIdx:endIdx]
return PaginatedResult(
items=pagedTemplates,
totalItems=totalItems,
totalPages=totalPages
)
def _enrichTemplatesWithUserName(self, templates: List[Dict[str, Any]]) -> None:
"""Batch enrich templates with creator user names."""
if not templates:
return
# Collect unique user IDs
userIds = set()
for template in templates:
createdBy = template.get("sysCreatedBy")
if createdBy:
userIds.add(createdBy)
if not userIds:
return
# Batch fetch users
try:
from modules.datamodels.datamodelUam import UserInDB
from modules.security.rootAccess import getRootDbAppConnector
dbAppConn = getRootDbAppConnector()
userNameMap = {}
for userId in userIds:
users = dbAppConn.getRecordset(UserInDB, recordFilter={"id": userId})
if users:
user = users[0]
displayName = user.get("fullName") or user.get("username") or user.get("email") or None
if displayName:
userNameMap[userId] = displayName
# Apply to templates — SECURITY: no fallback, empty if not found
for template in templates:
createdBy = template.get("sysCreatedBy")
template["sysCreatedByUserName"] = userNameMap.get(createdBy, "") if createdBy else ""
except Exception as e:
logger.warning(f"Could not enrich templates with user names: {e}")
def getAutomationTemplate(self, templateId: str) -> Optional[Dict[str, Any]]:
"""Returns an automation template by ID (system templates always accessible, instance templates scoped)."""
try:
records = self.db.getRecordset(
AutomationTemplate,
recordFilter={"id": templateId}
)
if not records:
return None
template = records[0]
# System templates are readable by everyone
if template.get("isSystem"):
self._enrichTemplatesWithUserName([template])
return template
# Instance templates: must belong to current feature instance
templateInstanceId = template.get("featureInstanceId")
if templateInstanceId and self.featureInstanceId and str(templateInstanceId) != str(self.featureInstanceId):
return None # Not in this instance
self._enrichTemplatesWithUserName([template])
return template
except Exception as e:
logger.error(f"Error getting automation template: {str(e)}")
return None
def createAutomationTemplate(self, templateData: Dict[str, Any], isSysAdmin: bool = False) -> Dict[str, Any]:
"""Creates a new automation template.
System templates (isSystem=True) can only be created by SysAdmin.
Instance templates get featureInstanceId from context.
"""
try:
# Ensure ID is present
if "id" not in templateData or not templateData["id"]:
templateData["id"] = str(uuid.uuid4())
# System template protection
if templateData.get("isSystem") and not isSysAdmin:
raise PermissionError("Only SysAdmin can create system templates")
# Set featureInstanceId for non-system templates
if not templateData.get("isSystem"):
templateData["featureInstanceId"] = self.featureInstanceId
templateData["isSystem"] = False
# RBAC check (for non-system templates)
if not isSysAdmin and not self.checkRbacPermission(AutomationTemplate, "create"):
raise PermissionError("No permission to create template")
# Ensure database connector has correct userId context
if self.userId and hasattr(self.db, 'updateContext'):
try:
self.db.updateContext(self.userId)
except Exception as e:
logger.warning(f"Could not update database context: {e}")
# Convert template field to string if it's a dict (frontend may send parsed JSON)
if "template" in templateData and isinstance(templateData["template"], dict):
import json
templateData["template"] = json.dumps(templateData["template"])
# Validate through Pydantic model to ensure proper type conversion
validatedTemplate = AutomationTemplate(**templateData)
# Create template in database using model_dump for proper serialization
createdTemplate = self.db.recordCreate(AutomationTemplate, validatedTemplate.model_dump())
return createdTemplate
except Exception as e:
logger.error(f"Error creating automation template: {str(e)}")
raise
def updateAutomationTemplate(self, templateId: str, templateData: Dict[str, Any], isSysAdmin: bool = False) -> Dict[str, Any]:
"""Updates an automation template.
System templates can only be updated by SysAdmin.
"""
try:
# Check access
existing = self.getAutomationTemplate(templateId)
if not existing:
raise PermissionError(f"No access to template {templateId}")
# System template protection
if existing.get("isSystem") and not isSysAdmin:
raise PermissionError("Only SysAdmin can modify system templates")
if not isSysAdmin and not self.checkRbacPermission(AutomationTemplate, "update", templateId):
raise PermissionError(f"No permission to modify template {templateId}")
# Prevent changing isSystem/featureInstanceId
templateData.pop("isSystem", None)
templateData.pop("featureInstanceId", None)
# Convert template field to string if it's a dict (frontend may send parsed JSON)
if "template" in templateData and isinstance(templateData["template"], dict):
import json
templateData["template"] = json.dumps(templateData["template"])
# Merge existing data with update data for partial updates
mergedData = {**existing, **templateData}
mergedData["id"] = templateId # Ensure ID is preserved
# Validate through Pydantic model to ensure proper type conversion
validatedTemplate = AutomationTemplate(**mergedData)
# Update template in database using model_dump for proper serialization
updatedTemplate = self.db.recordModify(AutomationTemplate, templateId, validatedTemplate.model_dump())
return updatedTemplate
except Exception as e:
logger.error(f"Error updating automation template: {str(e)}")
raise
def deleteAutomationTemplate(self, templateId: str, isSysAdmin: bool = False) -> bool:
"""Deletes an automation template.
System templates can only be deleted by SysAdmin.
"""
try:
# Check access
existing = self.getAutomationTemplate(templateId)
if not existing:
return False
# System template protection
if existing.get("isSystem") and not isSysAdmin:
raise PermissionError("Only SysAdmin can delete system templates")
if not isSysAdmin and not self.checkRbacPermission(AutomationTemplate, "delete", templateId):
raise PermissionError(f"No permission to delete template {templateId}")
# Delete template from database
self.db.recordDelete(AutomationTemplate, templateId)
return True
except Exception as e:
logger.error(f"Error deleting automation template: {str(e)}")
raise
def duplicateAutomationTemplate(self, templateId: str) -> Dict[str, Any]:
"""Duplicates a template into the current feature instance.
Creates a copy with new ID, isSystem=False, featureInstanceId from context.
Works for both system and instance templates.
"""
try:
existing = self.getAutomationTemplate(templateId)
if not existing:
raise PermissionError(f"Template {templateId} not found")
# RBAC check for creating templates
if not self.checkRbacPermission(AutomationTemplate, "create"):
raise PermissionError("No permission to create templates")
# Build duplicate data
duplicateData = {
"id": str(uuid.uuid4()),
"label": existing.get("label", {}),
"overview": existing.get("overview"),
"template": existing.get("template", ""),
"isSystem": False,
"featureInstanceId": self.featureInstanceId,
}
# Append "(Kopie)" to label
label = duplicateData["label"]
if isinstance(label, dict):
for lang in label:
if label[lang]:
label[lang] = f"{label[lang]} (Kopie)"
# Ensure database connector has correct userId context
if self.userId and hasattr(self.db, 'updateContext'):
self.db.updateContext(self.userId)
validatedTemplate = AutomationTemplate(**duplicateData)
createdTemplate = self.db.recordCreate(AutomationTemplate, validatedTemplate.model_dump())
logger.info(f"Duplicated template {templateId} -> {duplicateData['id']}")
return createdTemplate
except Exception as e:
logger.error(f"Error duplicating template: {str(e)}")
raise
def duplicateAutomationDefinition(self, definitionId: str) -> Dict[str, Any]:
"""Duplicates an automation definition within the same feature instance.
Creates a copy with new ID, active=False, no eventId.
"""
try:
existing = self.getAutomationDefinition(definitionId)
if not existing:
raise PermissionError(f"Definition {definitionId} not found")
# RBAC check for creating definitions
if not self.checkRbacPermission(AutomationDefinition, "create"):
raise PermissionError("No permission to create definitions")
# getAutomationDefinition returns Pydantic model; convert to dict for .get() access
existing_data = existing.model_dump() if hasattr(existing, "model_dump") else existing
# Build duplicate data
duplicateData = {
"id": str(uuid.uuid4()),
"mandateId": existing_data.get("mandateId"),
"featureInstanceId": existing_data.get("featureInstanceId"),
"label": f"{existing_data.get('label', '')} (Kopie)",
"schedule": existing_data.get("schedule", ""),
"template": existing_data.get("template", ""),
"placeholders": existing_data.get("placeholders", {}),
"active": False,
"eventId": None,
"status": None,
"executionLogs": [],
"allowedProviders": existing_data.get("allowedProviders", []),
}
# Ensure database connector has correct userId context
if self.userId and hasattr(self.db, 'updateContext'):
self.db.updateContext(self.userId)
validatedDefinition = AutomationDefinition(**duplicateData)
createdDefinition = self.db.recordCreate(AutomationDefinition, validatedDefinition.model_dump())
logger.info(f"Duplicated definition {definitionId} -> {duplicateData['id']}")
return createdDefinition
except Exception as e:
logger.error(f"Error duplicating definition: {str(e)}")
raise
def _notifyAutomationChanged(self):
"""Notify registered callbacks about automation changes (decoupled from features).
Sync-safe: works from both sync and async contexts."""
try:
from modules.shared.callbackRegistry import callbackRegistry
# Trigger callbacks without knowing which features are listening
callbackRegistry.trigger('automation.changed', self)
except Exception as e:
logger.error(f"Error notifying automation change: {str(e)}")
def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> 'AutomationObjects':
"""
Returns an AutomationObjects instance for the current user.
Handles initialization of database and records.
Args:
currentUser: The authenticated user
mandateId: The mandate ID from RequestContext (X-Mandate-Id header).
featureInstanceId: The feature instance ID from RequestContext (X-Feature-Instance-Id header).
"""
if not currentUser:
raise ValueError("Invalid user context: user is required")
effectiveMandateId = str(mandateId) if mandateId else None
effectiveFeatureInstanceId = str(featureInstanceId) if featureInstanceId else None
# Create context key including featureInstanceId for proper isolation
contextKey = f"automation_{effectiveMandateId}_{effectiveFeatureInstanceId}_{currentUser.id}"
# Create new instance if not exists
if contextKey not in _automationInterfaces:
_automationInterfaces[contextKey] = AutomationObjects(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId)
else:
# Update user context if needed
_automationInterfaces[contextKey].setUserContext(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId)
return _automationInterfaces[contextKey]

View file

@ -1,446 +0,0 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Automation Feature Container - Main Module.
Handles feature initialization and RBAC catalog registration.
"""
import logging
from typing import Dict, List, Any, Optional
logger = logging.getLogger(__name__)
# Feature metadata
FEATURE_CODE = "automation"
FEATURE_LABEL = {"en": "Automation", "de": "Automatisierung", "fr": "Automatisation"}
FEATURE_ICON = "mdi-cog-clockwise"
# UI Objects for RBAC catalog
UI_OBJECTS = [
{
"objectKey": "ui.feature.automation.definitions",
"label": {"en": "Automation Definitions", "de": "Automatisierungs-Definitionen", "fr": "Définitions d'automatisation"},
"meta": {"area": "definitions"}
},
{
"objectKey": "ui.feature.automation.templates",
"label": {"en": "Templates", "de": "Vorlagen", "fr": "Modèles"},
"meta": {"area": "templates"}
},
]
# Resource Objects for RBAC catalog
RESOURCE_OBJECTS = [
{
"objectKey": "resource.feature.automation.create",
"label": {"en": "Create Automation", "de": "Automatisierung erstellen", "fr": "Créer automatisation"},
"meta": {"endpoint": "/api/automations", "method": "POST"}
},
{
"objectKey": "resource.feature.automation.update",
"label": {"en": "Update Automation", "de": "Automatisierung aktualisieren", "fr": "Modifier automatisation"},
"meta": {"endpoint": "/api/automations/{automationId}", "method": "PUT"}
},
{
"objectKey": "resource.feature.automation.delete",
"label": {"en": "Delete Automation", "de": "Automatisierung löschen", "fr": "Supprimer automatisation"},
"meta": {"endpoint": "/api/automations/{automationId}", "method": "DELETE"}
},
{
"objectKey": "resource.feature.automation.execute",
"label": {"en": "Execute Automation", "de": "Automatisierung ausführen", "fr": "Exécuter automatisation"},
"meta": {"endpoint": "/api/automations/{automationId}/execute", "method": "POST"}
},
]
# Template roles for this feature
TEMPLATE_ROLES = [
{
"roleLabel": "automation-admin",
"description": {
"en": "Automation Administrator - Full access to automation configuration and execution",
"de": "Automatisierungs-Administrator - Vollzugriff auf Automatisierungs-Konfiguration und Ausführung",
"fr": "Administrateur automatisation - Accès complet à la configuration et exécution"
},
"accessRules": [
# Full UI access
{"context": "UI", "item": None, "view": True},
# Full DATA access
{"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"},
]
},
{
"roleLabel": "automation-editor",
"description": {
"en": "Automation Editor - Create and modify automations",
"de": "Automatisierungs-Editor - Automatisierungen erstellen und bearbeiten",
"fr": "Éditeur automatisation - Créer et modifier les automatisations"
},
"accessRules": [
# UI access to definitions and templates - vollqualifizierte ObjectKeys
{"context": "UI", "item": "ui.feature.automation.definitions", "view": True},
{"context": "UI", "item": "ui.feature.automation.templates", "view": True},
{"context": "UI", "item": "ui.feature.automation.logs", "view": True},
# Group-level DATA access
{"context": "DATA", "item": None, "view": True, "read": "g", "create": "g", "update": "g", "delete": "n"},
]
},
{
"roleLabel": "automation-user",
"description": {
"en": "Automation User - Create and manage own automations",
"de": "Automatisierungs-Benutzer - Eigene Automatisierungen erstellen und verwalten",
"fr": "Utilisateur automatisation - Créer et gérer ses propres automatisations"
},
"accessRules": [
{"context": "UI", "item": "ui.feature.automation.definitions", "view": True},
{"context": "UI", "item": "ui.feature.automation.templates", "view": True},
{"context": "UI", "item": "ui.feature.automation.logs", "view": True},
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"},
]
},
{
"roleLabel": "automation-viewer",
"description": {
"en": "Automation Viewer - View automations and execution results",
"de": "Automatisierungs-Betrachter - Automatisierungen und Ausführungsergebnisse einsehen",
"fr": "Visualiseur automatisation - Consulter les automatisations et résultats"
},
"accessRules": [
# UI access to view only
{"context": "UI", "item": "ui.feature.automation.definitions", "view": True},
{"context": "UI", "item": "ui.feature.automation.logs", "view": True},
# Read-only DATA access (my level)
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
]
},
]
# Service requirements - services this feature needs from the service center
REQUIRED_SERVICES = [
{"serviceKey": "chat", "meta": {"usage": "Workflow CRUD, messages, logs"}},
{"serviceKey": "ai", "meta": {"usage": "AI planning for workflow execution"}},
{"serviceKey": "utils", "meta": {"usage": "Timestamps, utilities"}},
{"serviceKey": "billing", "meta": {"usage": "AI call billing"}},
{"serviceKey": "extraction", "meta": {"usage": "Workflow method actions"}},
{"serviceKey": "sharepoint", "meta": {"usage": "SharePoint actions (listDocuments, uploadDocument, etc.)"}},
{"serviceKey": "generation", "meta": {"usage": "Action completion messages, document creation from results"}},
]
def getRequiredServiceKeys() -> List[str]:
"""Return list of service keys this feature requires."""
return [s["serviceKey"] for s in REQUIRED_SERVICES]
def getAutomationServices(
user,
mandateId: Optional[str] = None,
featureInstanceId: Optional[str] = None,
workflow=None,
) -> "_AutomationServiceHub":
"""
Get a service hub for the automation feature using the service center.
Resolves only the services declared in REQUIRED_SERVICES.
No legacy fallback - service center only.
Returns a hub-like object with: chat, ai, utils, billing, extraction,
sharepoint, rbac, interfaceDbApp, interfaceDbComponent, interfaceDbChat,
interfaceDbAutomation.
"""
from modules.serviceCenter import getService
from modules.serviceCenter.context import ServiceCenterContext
from modules.features.automation.interfaceFeatureAutomation import getInterface as getAutomationInterface
_workflow = workflow
if _workflow is None:
# Placeholder must have 'id' and 'workflowMode' to avoid AttributeError when services use context.workflow
_workflow = type("_Placeholder", (), {"featureCode": FEATURE_CODE, "id": None, "workflowMode": None})()
ctx = ServiceCenterContext(
user=user,
mandate_id=mandateId,
feature_instance_id=featureInstanceId,
workflow=_workflow,
)
hub = _AutomationServiceHub()
hub.user = user
hub.mandateId = mandateId
hub.featureInstanceId = featureInstanceId
hub._service_context = ctx # Store context so workflow updates propagate to services
hub.workflow = workflow
hub.featureCode = FEATURE_CODE
hub.allowedProviders = None
for spec in REQUIRED_SERVICES:
key = spec["serviceKey"]
try:
svc = getService(key, ctx)
setattr(hub, key, svc)
except Exception as e:
logger.warning(f"Could not resolve service '{key}' for automation: {e}")
setattr(hub, key, None)
# Copy interfaces from chat service for WorkflowManager compatibility
if hub.chat:
hub.interfaceDbApp = getattr(hub.chat, "interfaceDbApp", None)
hub.interfaceDbComponent = getattr(hub.chat, "interfaceDbComponent", None)
hub.interfaceDbChat = getattr(hub.chat, "interfaceDbChat", None)
# RBAC for MethodBase action permission checks (workflow methods)
hub.rbac = getattr(hub.interfaceDbApp, "rbac", None) if hub.interfaceDbApp else None
# Set interfaceDbAutomation from feature interface
hub.interfaceDbAutomation = getAutomationInterface(
user, mandateId=mandateId, featureInstanceId=featureInstanceId
)
return hub
class _AutomationServiceHub:
"""Lightweight hub exposing only services required by the automation feature."""
user = None
mandateId = None
featureInstanceId = None
_service_context = None # ServiceCenterContext; when workflow is set, context.workflow is updated
workflow = None
featureCode = "automation"
allowedProviders = None
interfaceDbApp = None
interfaceDbComponent = None
interfaceDbChat = None
interfaceDbAutomation = None
rbac = None
chat = None
ai = None
utils = None
billing = None
extraction = None
sharepoint = None
def getFeatureDefinition() -> Dict[str, Any]:
"""Return the feature definition for registration."""
return {
"code": FEATURE_CODE,
"label": FEATURE_LABEL,
"icon": FEATURE_ICON,
"autoCreateInstance": False,
}
def getUiObjects() -> List[Dict[str, Any]]:
"""Return UI objects for RBAC catalog registration."""
return UI_OBJECTS
def getResourceObjects() -> List[Dict[str, Any]]:
"""Return resource objects for RBAC catalog registration."""
return RESOURCE_OBJECTS
def getTemplateRoles() -> List[Dict[str, Any]]:
"""Return template roles for this feature."""
return TEMPLATE_ROLES
def registerFeature(catalogService) -> bool:
"""
Register this feature's RBAC objects in the catalog.
Args:
catalogService: The RBAC catalog service instance
Returns:
True if registration was successful
"""
try:
# Register UI objects
for uiObj in UI_OBJECTS:
catalogService.registerUiObject(
featureCode=FEATURE_CODE,
objectKey=uiObj["objectKey"],
label=uiObj["label"],
meta=uiObj.get("meta")
)
# Register Resource objects
for resObj in RESOURCE_OBJECTS:
catalogService.registerResourceObject(
featureCode=FEATURE_CODE,
objectKey=resObj["objectKey"],
label=resObj["label"],
meta=resObj.get("meta")
)
# Sync template roles to database
_syncTemplateRolesToDb()
# Mark existing templates without isSystem field as system templates (migration)
_migrateExistingTemplates()
logger.info(f"Feature '{FEATURE_CODE}' registered {len(UI_OBJECTS)} UI objects and {len(RESOURCE_OBJECTS)} resource objects")
return True
except Exception as e:
logger.error(f"Failed to register feature '{FEATURE_CODE}': {e}")
return False
def _syncTemplateRolesToDb() -> int:
"""
Sync template roles and their AccessRules to the database.
Creates global template roles (mandateId=None) if they don't exist.
Returns:
Number of roles created/updated
"""
try:
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext
rootInterface = getRootInterface()
# Get existing template roles for this feature (Pydantic models)
existingRoles = rootInterface.getRolesByFeatureCode(FEATURE_CODE)
# Filter to template roles (mandateId is None)
templateRoles = [r for r in existingRoles if r.mandateId is None]
existingRoleLabels = {r.roleLabel: str(r.id) for r in templateRoles}
createdCount = 0
for roleTemplate in TEMPLATE_ROLES:
roleLabel = roleTemplate["roleLabel"]
if roleLabel in existingRoleLabels:
roleId = existingRoleLabels[roleLabel]
# Ensure AccessRules exist for this role
_ensureAccessRulesForRole(rootInterface, roleId, roleTemplate.get("accessRules", []))
else:
# Create new template role
newRole = Role(
roleLabel=roleLabel,
description=roleTemplate.get("description", {}),
featureCode=FEATURE_CODE,
mandateId=None, # Global template
featureInstanceId=None,
isSystemRole=False
)
createdRole = rootInterface.db.recordCreate(Role, newRole.model_dump())
roleId = createdRole.get("id")
# Create AccessRules for this role
_ensureAccessRulesForRole(rootInterface, roleId, roleTemplate.get("accessRules", []))
logger.info(f"Created template role '{roleLabel}' with ID {roleId}")
createdCount += 1
if createdCount > 0:
logger.info(f"Feature '{FEATURE_CODE}': Created {createdCount} template roles")
return createdCount
except Exception as e:
logger.error(f"Error syncing template roles for feature '{FEATURE_CODE}': {e}")
return 0
def _ensureAccessRulesForRole(rootInterface, roleId: str, ruleTemplates: List[Dict[str, Any]]) -> int:
"""
Ensure AccessRules exist for a role based on templates.
Args:
rootInterface: Root interface instance
roleId: Role ID
ruleTemplates: List of rule templates
Returns:
Number of rules created
"""
from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext
# Get existing rules for this role (Pydantic models)
existingRules = rootInterface.getAccessRulesByRole(roleId)
# Create a set of existing rule signatures to avoid duplicates
# IMPORTANT: Use .value for enum comparison, not str() which gives "AccessRuleContext.DATA" in Python 3.11+
existingSignatures = set()
for rule in existingRules:
sig = (rule.context.value if rule.context else None, rule.item)
existingSignatures.add(sig)
createdCount = 0
for template in ruleTemplates:
context = template.get("context", "UI")
item = template.get("item")
sig = (context, item)
if sig in existingSignatures:
continue
# Map context string to enum
if context == "UI":
contextEnum = AccessRuleContext.UI
elif context == "DATA":
contextEnum = AccessRuleContext.DATA
elif context == "RESOURCE":
contextEnum = AccessRuleContext.RESOURCE
else:
contextEnum = context
newRule = AccessRule(
roleId=roleId,
context=contextEnum,
item=item,
view=template.get("view", False),
read=template.get("read"),
create=template.get("create"),
update=template.get("update"),
delete=template.get("delete"),
)
rootInterface.db.recordCreate(AccessRule, newRule.model_dump())
createdCount += 1
if createdCount > 0:
logger.debug(f"Created {createdCount} AccessRules for role {roleId}")
return createdCount
def _migrateExistingTemplates() -> None:
"""
Migration: Mark existing templates that have no isSystem/featureInstanceId fields
as system templates (isSystem=True). This runs idempotently during feature registration.
"""
try:
from modules.features.automation.interfaceFeatureAutomation import getInterface
from modules.security.rootAccess import getRootUser
from modules.features.automation.datamodelFeatureAutomation import AutomationTemplate
rootUser = getRootUser()
automationInterface = getInterface(rootUser)
# Get all templates from DB
allTemplates = automationInterface.db.getRecordset(AutomationTemplate)
migratedCount = 0
for template in allTemplates:
templateId = template.get("id")
isSystem = template.get("isSystem")
featureInstanceId = template.get("featureInstanceId")
# Templates without isSystem set (old templates) → mark as system
if isSystem is None and featureInstanceId is None:
automationInterface.db.recordModify(
AutomationTemplate,
templateId,
{"isSystem": True, "featureInstanceId": None}
)
migratedCount += 1
if migratedCount > 0:
logger.info(f"Migrated {migratedCount} existing templates to isSystem=True")
except Exception as e:
logger.warning(f"Template migration check failed (non-critical): {e}")

File diff suppressed because it is too large Load diff

View file

@ -1,433 +0,0 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Automation templates for workflow definitions.
Contains predefined workflow templates that can be used to create automation definitions.
"""
from typing import Dict, Any, List
# Automation templates structure
AUTOMATION_TEMPLATES: Dict[str, Any] = {
"sets": [
{
"template": {
"overview": "SharePoint Themen Zusammenfassung",
"tasks": [
{
"id": "Task01",
"title": "SharePoint Themen Zusammenfassung",
"description": "Erstellt eine Zusammenfassung aller SharePoint Sites und deren Inhalte",
"objective": "Erstelle eine Zusammenfassung aller SharePoint Themen (Sites) und deren Inhalte als Word-Dokument",
"actionList": [
{
"execMethod": "sharepoint",
"execAction": "findDocumentPath",
"execParameters": {
"connectionReference": "{{KEY:connectionName}}",
"searchQuery": "*",
"maxResults": 100
},
"execResultLabel": "sharepoint_sites_found"
},
{
"execMethod": "sharepoint",
"execAction": "listDocuments",
"execParameters": {
"connectionReference": "{{KEY:connectionName}}",
"pathQuery": "{{KEY:sharepointBasePath}}",
"includeSubfolders": True
},
"execResultLabel": "sharepoint_structure"
},
{
"execMethod": "ai",
"execAction": "process",
"execParameters": {
"aiPrompt": "{{KEY:summaryPrompt}}",
"documentList": ["sharepoint_sites_found", "sharepoint_structure"],
"resultType": "docx"
},
"execResultLabel": "sharepoint_summary"
},
{
"execMethod": "sharepoint",
"execAction": "uploadDocument",
"execParameters": {
"connectionReference": "{{KEY:connectionName}}",
"documentList": ["sharepoint_summary"],
"pathQuery": "{{KEY:sharepointFolderNameDestination}}"
},
"execResultLabel": "sharepoint_upload_result"
}
]
}
]
},
"parameters": {
"connectionName": "connection:msft:p.motsch@valueon.ch",
"sharepointBasePath": "/sites/company-share",
"sharepointFolderNameDestination": "/sites/company-share/Freigegebene Dokumente/15. Persoenliche Ordner/Patrick Motsch/output",
"summaryPrompt": "Erstelle eine umfassende Zusammenfassung aller SharePoint Sites und deren Inhalte. Strukturiere das Dokument nach Sites und fasse für jede Site die wichtigsten Themen, Ordnerstrukturen und Dokumente zusammen. Erstelle ein professionelles Word-Dokument mit Überschriften, Abschnitten und einer klaren Gliederung. Berücksichtige alle gefundenen Sites, deren Ordnerstrukturen und dokumentiere die wichtigsten Inhalte pro Site."
}
},
{
"template": {
"overview": "Immobilienrecherche Zürich",
"tasks": [
{
"id": "Task02",
"title": "Immobilienrecherche Zürich",
"description": "Webrecherche nach Immobilien im Kanton Zürich und Speicherung in Excel",
"objective": "Immobilienrecherche im Kanton Zürich zum Verkauf (5-20 Mio. CHF) und speichere Ergebnisse in Excel-Liste auf SharePoint",
"actionList": [
{
"execMethod": "ai",
"execAction": "webResearch",
"execParameters": {
"prompt": "{{KEY:immobilienResearchPrompt}}",
"urlList": ["{{KEY:immobilienResearchUrl}}"]
},
"execResultLabel": "immobilien_research_results"
},
{
"execMethod": "ai",
"execAction": "process",
"execParameters": {
"aiPrompt": "{{KEY:excelFormatPrompt}}",
"documentList": ["immobilien_research_results"],
"resultType": "xlsx"
},
"execResultLabel": "immobilien_excel_list"
},
{
"execMethod": "sharepoint",
"execAction": "uploadDocument",
"execParameters": {
"connectionReference": "{{KEY:connectionName}}",
"documentList": ["immobilien_excel_list"],
"pathQuery": "{{KEY:sharepointFolderNameDestination}}"
},
"execResultLabel": "immobilien_upload_result"
}
]
}
]
},
"parameters": {
"connectionName": "connection:msft:p.motsch@valueon.ch",
"sharepointFolderNameDestination": "/sites/company-share/Freigegebene Dokumente/15. Persoenliche Ordner/Patrick Motsch/output",
"immobilienResearchUrl": ["https://www.homegate.ch", "https://www.immoscout24.ch", "https://www.immowelt.ch"],
"immobilienResearchPrompt": "Suche nach Immobilien zum Verkauf im Kanton Zürich, Schweiz, im Preisbereich von 5-20 Millionen CHF. Sammle Informationen zu: Ort, Preis, Beschreibung, URL zu Bildern, Verkäufer/Kontaktinformationen.",
"excelFormatPrompt": "Erstelle eine Excel-Datei mit den recherchierten Immobilien. Jede Immobilie soll eine Zeile sein mit den folgenden Spalten: Ort, Preis (in CHF), Beschreibung, URL zu Bild, Verkäufer. Verwende die Daten aus der Webrecherche."
}
},
{
"template": {
"overview": "Spesenbelege Zusammenfassung",
"tasks": [
{
"id": "Task03",
"title": "Spesenbelege CSV Zusammenfassung",
"description": "Liest PDF-Spesenbelege aus SharePoint-Ordner und erstellt CSV-Zusammenfassung",
"objective": "Extrahiere alle PDF-Spesenbelege aus einem SharePoint-Ordner und erstelle eine CSV-Datei mit allen Spesendaten im selben Ordner",
"actionList": [
{
"execMethod": "sharepoint",
"execAction": "findDocumentPath",
"execParameters": {
"connectionReference": "{{KEY:connectionName}}",
"searchQuery": "{{KEY:sharepointFolderNameSource}}:files:.pdf",
"maxResults": 100
},
"execResultLabel": "sharepoint_pdf_files"
},
{
"execMethod": "sharepoint",
"execAction": "readDocuments",
"execParameters": {
"connectionReference": "{{KEY:connectionName}}",
"pathObject": "sharepoint_pdf_files"
},
"execResultLabel": "spesenbelege_documents"
},
{
"execMethod": "ai",
"execAction": "process",
"execParameters": {
"aiPrompt": "{{KEY:expenseExtractionPrompt}}",
"documentList": ["spesenbelege_documents"],
"resultType": "csv"
},
"execResultLabel": "spesenbelege_csv"
},
{
"execMethod": "sharepoint",
"execAction": "uploadDocument",
"execParameters": {
"connectionReference": "{{KEY:connectionName}}",
"documentList": ["spesenbelege_csv"],
"pathQuery": "{{KEY:sharepointFolderNameDestination}}"
},
"execResultLabel": "spesenbelege_upload_result"
}
]
}
]
},
"parameters": {
"connectionName": "connection:msft:p.motsch@valueon.ch",
"sharepointFolderNameSource": "/sites/company-share/Freigegebene Dokumente/15. Persoenliche Ordner/Patrick Motsch/expenses",
"sharepointFolderNameDestination": "/sites/company-share/Freigegebene Dokumente/15. Persoenliche Ordner/Patrick Motsch/output",
"expenseExtractionPrompt": "Verarbeite alle bereitgestellten Dokumente, aber extrahiere nur Daten aus PDF-Spesenbelegen (ignoriere andere Dateitypen). Für jeden gefundenen PDF-Spesenbeleg extrahiere als separaten Datensatz: Datum, Betrag, MWST %, Währung, Kategorie, Beschreibung, Rechnungsnummer, Händler/Verkäufer, Steuerbetrag. Erstelle eine CSV-Datei mit einer Zeile pro Spesenbeleg. Verwende die folgenden Spaltenüberschriften: Datum, Betrag, Währung, Kategorie, Beschreibung, Rechnungsnummer, Händler, Steuerbetrag. Stelle sicher, dass alle Beträge numerisch sind und Datumswerte im Format YYYY-MM-DD vorliegen. Wenn ein Dokument kein Spesenbeleg ist, ignoriere es."
}
},
{
"template": {
"overview": "Preprocessing Server Data Update",
"tasks": [
{
"id": "Task04",
"title": "Trigger Preprocessing Server",
"description": "Triggers the preprocessing server at customer tenant to update database with configuration",
"objective": "Call preprocessing server endpoint to update database with provided configuration JSON",
"actionList": [
{
"execMethod": "context",
"execAction": "triggerPreprocessingServer",
"execParameters": {
"endpoint": "{{KEY:endpoint}}",
"configJson": "{{KEY:configJson}}",
"authSecretConfigKey": "{{KEY:authSecretConfigKey}}"
},
"execResultLabel": "preprocessing_server_result"
}
]
}
]
},
"parameters": {
"endpoint": "https://poweron-althaus-preprocess-prod-e3fegaatc7faency.switzerlandnorth-01.azurewebsites.net/api/v1/dataprocessor/update-db-with-config",
"authSecretConfigKey": "PREPROCESS_ALTHAUS_CHAT_SECRET",
"configJson": "{\"tables\":[{\"name\":\"Artikel\",\"powerbi_table_name\":\"Artikel\",\"steps\":[{\"keep\":{\"columns\":[\"I_ID\",\"Artikelbeschrieb\",\"Artikelbezeichnung\",\"Artikelgruppe\",\"Artikelkategorie\",\"Artikelkürzel\",\"Artikelnummer\",\"Einheit\",\"Gesperrt\",\"Keywords\",\"Lieferant\",\"Warengruppe\"]}},{\"fillna\":{\"column\":\"Lieferant\",\"value\":\"Unbekannt\"}}]},{\"name\":\"Einkaufspreis\",\"powerbi_table_name\":\"Einkaufspreis\",\"steps\":[{\"to_numeric\":{\"column\":\"EP_CHF\",\"errors\":\"coerce\"}},{\"dropna\":{\"subset\":[\"EP_CHF\"]}}]}]}"
}
},
{
"template": {
"overview": "JIRA to SharePoint Ticket Synchronization",
"tasks": [
{
"id": "Task01",
"title": "Sync JIRA Tickets to SharePoint",
"description": "Export JIRA tickets, merge with SharePoint file, upload back, and import changes to JIRA",
"objective": "Synchronize JIRA tickets with SharePoint file (bidirectional sync)",
"actionList": [
{
"execMethod": "sharepoint",
"execAction": "findSiteByUrl",
"execParameters": {
"connectionReference": "{{KEY:sharepointConnection}}",
"hostname": "{{KEY:sharepointHostname}}",
"sitePath": "{{KEY:sharepointSitePath}}"
},
"execResultLabel": "sharepoint_site"
},
{
"execMethod": "jira",
"execAction": "connectJira",
"execParameters": {
"apiUsername": "{{KEY:jiraUsername}}",
"apiTokenConfigKey": "{{KEY:jiraTokenConfigKey}}",
"apiUrl": "{{KEY:jiraUrl}}",
"projectCode": "{{KEY:jiraProjectCode}}",
"issueType": "{{KEY:jiraIssueType}}",
"taskSyncDefinition": "{{KEY:taskSyncDefinition}}"
},
"execResultLabel": "jira_connection"
},
{
"execMethod": "jira",
"execAction": "exportTicketsAsJson",
"execParameters": {
"connectionId": "jira_connection",
"taskSyncDefinition": "{{KEY:taskSyncDefinition}}"
},
"execResultLabel": "jira_exported_tickets"
},
{
"execMethod": "sharepoint",
"execAction": "downloadFileByPath",
"execParameters": {
"connectionReference": "{{KEY:sharepointConnection}}",
"siteId": "sharepoint_site",
"filePath": "{{KEY:sharepointMainFolder}}/{{KEY:syncFileName}}"
},
"execResultLabel": "existing_file_content"
},
{
"execMethod": "jira",
"execAction": "parseExcelContent",
"execParameters": {
"excelContent": "existing_file_content",
"skipRows": 3,
"hasCustomHeaders": True
},
"execResultLabel": "existing_parsed_data"
},
{
"execMethod": "jira",
"execAction": "mergeTicketData",
"execParameters": {
"jiraData": "jira_exported_tickets",
"existingData": "existing_parsed_data",
"taskSyncDefinition": "{{KEY:taskSyncDefinition}}",
"idField": "ID"
},
"execResultLabel": "merged_ticket_data"
},
{
"execMethod": "sharepoint",
"execAction": "copyFile",
"execParameters": {
"connectionReference": "{{KEY:sharepointConnection}}",
"siteId": "sharepoint_site",
"sourceFolder": "{{KEY:sharepointMainFolder}}",
"sourceFile": "{{KEY:syncFileName}}",
"destFolder": "{{KEY:sharepointBackupFolder}}",
"destFile": "backup_{{TIMESTAMP}}_{{KEY:syncFileName}}"
},
"execResultLabel": "file_backup"
},
{
"execMethod": "jira",
"execAction": "createExcelContent",
"execParameters": {
"data": "merged_ticket_data",
"headers": "existing_parsed_data",
"taskSyncDefinition": "{{KEY:taskSyncDefinition}}"
},
"execResultLabel": "new_file_content"
},
{
"execMethod": "sharepoint",
"execAction": "uploadFile",
"execParameters": {
"connectionReference": "{{KEY:sharepointConnection}}",
"siteId": "sharepoint_site",
"folderPath": "{{KEY:sharepointMainFolder}}",
"fileName": "{{KEY:syncFileName}}",
"content": "new_file_content"
},
"execResultLabel": "uploaded_file"
},
{
"execMethod": "sharepoint",
"execAction": "downloadFileByPath",
"execParameters": {
"connectionReference": "{{KEY:sharepointConnection}}",
"siteId": "sharepoint_site",
"filePath": "{{KEY:sharepointMainFolder}}/{{KEY:syncFileName}}"
},
"execResultLabel": "uploaded_file_content"
},
{
"execMethod": "jira",
"execAction": "parseExcelContent",
"execParameters": {
"excelContent": "uploaded_file_content",
"skipRows": 3,
"hasCustomHeaders": True
},
"execResultLabel": "import_data"
},
{
"execMethod": "jira",
"execAction": "importTicketsFromJson",
"execParameters": {
"connectionId": "jira_connection",
"ticketData": "import_data",
"taskSyncDefinition": "{{KEY:taskSyncDefinition}}"
},
"execResultLabel": "import_result"
}
]
}
]
},
"parameters": {
"sharepointConnection": "connection:msft:patrick.motsch@delta.ch",
"sharepointHostname": "deltasecurityag.sharepoint.com",
"sharepointSitePath": "SteeringBPM",
"sharepointMainFolder": "/General/50 Docs hosted by SELISE",
"sharepointBackupFolder": "/General/50 Docs hosted by SELISE/SyncHistory",
"syncFileName": "DELTAgroup x SELISE Ticket Exchange List.xlsx",
"jiraUsername": "p.motsch@valueon.ch",
"jiraTokenConfigKey": "Feature_SyncDelta_JIRA_DELTA_TOKEN_SECRET",
"jiraUrl": "https://deltasecurity.atlassian.net",
"jiraProjectCode": "DCS",
"jiraIssueType": "Task",
"taskSyncDefinition": "{\"ID\":[\"get\",[\"key\"]],\"Module Category\":[\"get\",[\"fields\",\"customfield_10058\",\"value\"]],\"Summary\":[\"get\",[\"fields\",\"summary\"]],\"Description\":[\"get\",[\"fields\",\"description\"]],\"References\":[\"get\",[\"fields\",\"customfield_10066\"]],\"Priority\":[\"get\",[\"fields\",\"priority\",\"name\"]],\"Issue Status\":[\"get\",[\"fields\",\"status\",\"name\"]],\"Assignee\":[\"get\",[\"fields\",\"assignee\",\"displayName\"]],\"Issue Created\":[\"get\",[\"fields\",\"created\"]],\"Due Date\":[\"get\",[\"fields\",\"duedate\"]],\"DELTA Comments\":[\"get\",[\"fields\",\"customfield_10167\"]],\"SELISE Ticket References\":[\"put\",[\"fields\",\"customfield_10067\"]],\"SELISE Status Values\":[\"put\",[\"fields\",\"customfield_10065\"]],\"SELISE Comments\":[\"put\",[\"fields\",\"customfield_10168\"]]}"
}
},
{
"template": {
"overview": "Expenses PDF to Trustee Position",
"tasks": [
{
"id": "Task01",
"title": "Run trustee pipeline on SharePoint files",
"description": "Extract expenses from SharePoint PDFs, create positions + documents, sync to accounting",
"objective": "End-to-end: SharePoint folder → AI extraction → Trustee DB → Accounting sync",
"actionList": [
{
"execMethod": "trustee",
"execAction": "extractFromFiles",
"execParameters": {
"connectionReference": "{{KEY:connectionName}}",
"sharepointFolder": "{{KEY:sharepointFolder}}",
"featureInstanceId": "{{KEY:featureInstanceId}}"
},
"execResultLabel": "extract_result"
},
{
"execMethod": "trustee",
"execAction": "processDocuments",
"execParameters": {
"documentList": "docList:{{PREV_MESSAGE_ID}}:extract_result",
"featureInstanceId": "{{KEY:featureInstanceId}}"
},
"execResultLabel": "process_result"
},
{
"execMethod": "trustee",
"execAction": "syncToAccounting",
"execParameters": {
"documentList": "docList:{{PREV_MESSAGE_ID}}:process_result",
"featureInstanceId": "{{KEY:featureInstanceId}}"
},
"execResultLabel": "sync_result"
}
]
}
]
},
"parameters": {
"connectionName": "",
"sharepointFolder": "",
"featureInstanceId": ""
}
}
]
}
def getAutomationTemplates() -> Dict[str, Any]:
"""
Get automation templates.
Returns:
Dict containing the automation templates structure with 'sets' key.
"""
return AUTOMATION_TEMPLATES

View file

@ -1,118 +0,0 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Utility functions for automation feature.
Moved from interfaces/interfaceDbChat.py.
"""
import json
from typing import Dict, Any
from datetime import datetime, UTC
def parseScheduleToCron(schedule: str) -> Dict[str, Any]:
"""Parse schedule string to cron kwargs for APScheduler"""
parts = schedule.split()
if len(parts) != 5:
raise ValueError(f"Invalid schedule format: {schedule}")
return {
"minute": parts[0],
"hour": parts[1],
"day": parts[2],
"month": parts[3],
"day_of_week": parts[4]
}
def planToPrompt(plan: Dict) -> str:
"""Convert plan structure to prompt string for workflow execution"""
return plan.get("userMessage", plan.get("overview", "Execute automation workflow"))
def replacePlaceholders(template: str, placeholders: Dict[str, str]) -> str:
"""Replace placeholders in template with actual values. Placeholder format: {{KEY:PLACEHOLDER_NAME}} or {{TIMESTAMP}}"""
result = template
# Replace TIMESTAMP placeholder first (calculated placeholder, not from parameters)
timestampPattern = "{{TIMESTAMP}}"
if timestampPattern in result:
timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
result = result.replace(timestampPattern, timestamp)
for placeholderName, value in placeholders.items():
pattern = f"{{{{KEY:{placeholderName}}}}}"
# Check if placeholder is in an array context like ["{{KEY:...}}"]
# If value is a JSON array/dict, we should replace the entire ["{{KEY:...}}"] with the array
arrayPattern = f'["{pattern}"]'
if arrayPattern in result:
# Check if value is a JSON array/dict
isArrayValue = False
arrayValue = None
if isinstance(value, (list, dict)):
isArrayValue = True
arrayValue = json.dumps(value)
elif isinstance(value, str):
try:
parsed = json.loads(value)
if isinstance(parsed, (list, dict)):
isArrayValue = True
arrayValue = value # Already valid JSON string
except (json.JSONDecodeError, ValueError):
pass
if isArrayValue:
# Replace ["{{KEY:...}}"] with the array value
result = result.replace(arrayPattern, arrayValue)
continue # Skip the regular replacement below
# Regular replacement - check if in quoted context
patternStart = result.find(pattern)
isQuoted = False
if patternStart > 0:
charBefore = result[patternStart - 1] if patternStart > 0 else None
patternEnd = patternStart + len(pattern)
charAfter = result[patternEnd] if patternEnd < len(result) else None
if charBefore == '"' and charAfter == '"':
isQuoted = True
# Handle different value types
if isinstance(value, (list, dict)):
# Python list/dict - convert to JSON
replacement = json.dumps(value)
elif isinstance(value, str):
# String value - check if it's a JSON string representing list/dict
try:
parsed = json.loads(value)
if isinstance(parsed, (list, dict)):
# It's a JSON string of a list/dict
if isQuoted:
# In quoted context, escape the JSON string
escaped = json.dumps(value)
replacement = escaped[1:-1] # Remove outer quotes
else:
# In unquoted context, use JSON directly
replacement = value
else:
# It's a JSON string of a primitive
if isQuoted:
escaped = json.dumps(value)
replacement = escaped[1:-1]
else:
replacement = value
except (json.JSONDecodeError, ValueError):
# Not valid JSON - treat as plain string
if isQuoted:
escaped = json.dumps(value)
replacement = escaped[1:-1]
else:
replacement = value
else:
# Numbers, booleans, None - convert to string
replacement = str(value)
result = result.replace(pattern, replacement)
return result

View file

@ -1,2 +0,0 @@
# Copyright (c) 2025 Patrick Motsch
# Automation2 feature - n8n-style flow automation (backup/parallel to legacy automation)

View file

@ -1,166 +0,0 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""Automation2 models: Automation2Workflow, Automation2WorkflowRun, Automation2HumanTask."""
from typing import Dict, Any, List, Optional
from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
import uuid
class Automation2Workflow(BaseModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
)
mandateId: str = Field(
description="Mandate ID",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
)
featureInstanceId: str = Field(
description="Feature instance ID",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
)
label: str = Field(
description="User-friendly workflow name",
json_schema_extra={"frontend_type": "text", "frontend_required": True},
)
graph: Dict[str, Any] = Field(
default_factory=dict,
description="Graph with nodes and connections (incl. node parameters)",
json_schema_extra={"frontend_type": "textarea", "frontend_required": True},
)
active: bool = Field(
default=True,
description="Whether workflow is active",
json_schema_extra={"frontend_type": "checkbox", "frontend_required": False},
)
invocations: List[Dict[str, Any]] = Field(
default_factory=list,
description="Entry points / starts (manual, form, schedule, webhook, …) configured outside the canvas",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False},
)
registerModelLabels(
"Automation2Workflow",
{"en": "Automation2 Workflow", "de": "Automation2 Workflow", "fr": "Workflow Automation2"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"mandateId": {"en": "Mandate ID", "de": "Mandanten-ID", "fr": "ID du mandat"},
"featureInstanceId": {"en": "Feature Instance ID", "de": "Feature-Instanz-ID", "fr": "ID instance"},
"label": {"en": "Label", "de": "Bezeichnung", "fr": "Libellé"},
"graph": {"en": "Graph", "de": "Graph", "fr": "Graphe"},
"active": {"en": "Active", "de": "Aktiv", "fr": "Actif"},
"invocations": {"en": "Starts / Entry points", "de": "Starts / Einstiegspunkte", "fr": "Points d'entrée"},
},
)
class Automation2WorkflowRun(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
)
workflowId: str = Field(
description="Workflow ID",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
)
status: str = Field(
default="running",
description="Status: running|paused|completed|failed",
json_schema_extra={"frontend_type": "text", "frontend_required": False},
)
nodeOutputs: Dict[str, Any] = Field(
default_factory=dict,
description="Outputs from executed nodes",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False},
)
currentNodeId: Optional[str] = Field(
default=None,
description="Node ID when paused (human task)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
)
context: Dict[str, Any] = Field(
default_factory=dict,
description="Context for resume (connectionMap, inputSources, etc.)",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False},
)
registerModelLabels(
"Automation2WorkflowRun",
{"en": "Automation2 Workflow Run", "de": "Automation2 Workflow-Ausführung", "fr": "Exécution workflow"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"workflowId": {"en": "Workflow ID", "de": "Workflow-ID", "fr": "ID workflow"},
"status": {"en": "Status", "de": "Status", "fr": "Statut"},
"nodeOutputs": {"en": "Node Outputs", "de": "Node-Ausgaben", "fr": "Sorties nœuds"},
"currentNodeId": {"en": "Current Node", "de": "Aktueller Knoten", "fr": "Nœud actuel"},
"context": {"en": "Context", "de": "Kontext", "fr": "Contexte"},
},
)
class Automation2HumanTask(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
)
runId: str = Field(
description="Workflow run ID",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
)
workflowId: str = Field(
description="Workflow ID",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
)
nodeId: str = Field(
description="Node ID in the graph",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
)
nodeType: str = Field(
description="Node type: form|approval|upload|comment|review|selection|confirmation",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
)
config: Dict[str, Any] = Field(
default_factory=dict,
description="Node config (form schema, approval text, etc.)",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False},
)
assigneeId: Optional[str] = Field(
default=None,
description="User ID assigned to complete the task",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False},
)
status: str = Field(
default="pending",
description="Status: pending|completed|rejected",
json_schema_extra={"frontend_type": "text", "frontend_required": False},
)
result: Optional[Dict[str, Any]] = Field(
default=None,
description="Task result (form data, approval decision, etc.)",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False},
)
registerModelLabels(
"Automation2HumanTask",
{"en": "Automation2 Human Task", "de": "Automation2 Benutzer-Aufgabe", "fr": "Tâche utilisateur"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"runId": {"en": "Run ID", "de": "Lauf-ID", "fr": "ID exécution"},
"workflowId": {"en": "Workflow ID", "de": "Workflow-ID", "fr": "ID workflow"},
"nodeId": {"en": "Node ID", "de": "Knoten-ID", "fr": "ID nœud"},
"nodeType": {"en": "Node Type", "de": "Knotentyp", "fr": "Type nœud"},
"config": {"en": "Config", "de": "Konfiguration", "fr": "Configuration"},
"assigneeId": {"en": "Assignee", "de": "Zugewiesen an", "fr": "Assigné à"},
"status": {"en": "Status", "de": "Status", "fr": "Statut"},
"result": {"en": "Result", "de": "Ergebnis", "fr": "Résultat"},
},
)

View file

@ -1,111 +0,0 @@
# Copyright (c) 2025 Patrick Motsch
# AI node definitions - map to methodAi actions.
AI_NODES = [
{
"id": "ai.prompt",
"category": "ai",
"label": {"en": "Prompt", "de": "Prompt", "fr": "Invite"},
"description": {"en": "Enter a prompt and AI does something", "de": "Prompt eingeben und KI führt aus", "fr": "Entrer une invite et l'IA exécute"},
"parameters": [
{"name": "prompt", "type": "string", "required": True, "description": {"en": "AI prompt", "de": "KI-Prompt", "fr": "Invite IA"}},
],
"inputs": 1,
"outputs": 1,
"meta": {"icon": "mdi-robot", "color": "#9C27B0"},
"_method": "ai",
"_action": "process",
"_paramMap": {"prompt": "aiPrompt"},
},
{
"id": "ai.webResearch",
"category": "ai",
"label": {"en": "Web Research", "de": "Web-Recherche", "fr": "Recherche web"},
"description": {"en": "Research on the web", "de": "Recherche im Web", "fr": "Recherche sur le web"},
"parameters": [
{"name": "query", "type": "string", "required": True, "description": {"en": "Research query", "de": "Recherche-Anfrage", "fr": "Requête de recherche"}},
],
"inputs": 1,
"outputs": 1,
"meta": {"icon": "mdi-magnify", "color": "#9C27B0"},
"_method": "ai",
"_action": "webResearch",
"_paramMap": {"query": "prompt"},
},
{
"id": "ai.summarizeDocument",
"category": "ai",
"label": {"en": "Summarize Document", "de": "Dokument zusammenfassen", "fr": "Résumer document"},
"description": {"en": "Summarize document content", "de": "Dokumentinhalt zusammenfassen", "fr": "Résumer le contenu du document"},
"parameters": [
{"name": "summaryLength", "type": "string", "required": False, "description": {"en": "Short, medium, or long", "de": "Kurz, mittel oder lang", "fr": "Court, moyen ou long"}, "default": "medium"},
],
"inputs": 1,
"outputs": 1,
"meta": {"icon": "mdi-file-document-outline", "color": "#9C27B0"},
"_method": "ai",
"_action": "summarizeDocument",
"_paramMap": {},
},
{
"id": "ai.translateDocument",
"category": "ai",
"label": {"en": "Translate Document", "de": "Dokument übersetzen", "fr": "Traduire document"},
"description": {"en": "Translate document to target language", "de": "Dokument in Zielsprache übersetzen", "fr": "Traduire le document"},
"parameters": [
{"name": "targetLanguage", "type": "string", "required": True, "description": {"en": "Target language (e.g. en, de, fr)", "de": "Zielsprache", "fr": "Langue cible"}},
],
"inputs": 1,
"outputs": 1,
"meta": {"icon": "mdi-translate", "color": "#9C27B0"},
"_method": "ai",
"_action": "translateDocument",
"_paramMap": {"targetLanguage": "targetLanguage"},
},
{
"id": "ai.convertDocument",
"category": "ai",
"label": {"en": "Convert Document", "de": "Dokument konvertieren", "fr": "Convertir document"},
"description": {"en": "Convert document to another format", "de": "Dokument in anderes Format konvertieren", "fr": "Convertir le document"},
"parameters": [
{"name": "targetFormat", "type": "string", "required": True, "description": {"en": "Target format (pdf, docx, txt, etc.)", "de": "Zielformat", "fr": "Format cible"}},
],
"inputs": 1,
"outputs": 1,
"meta": {"icon": "mdi-file-convert", "color": "#9C27B0"},
"_method": "ai",
"_action": "convertDocument",
"_paramMap": {"targetFormat": "targetFormat"},
},
{
"id": "ai.generateDocument",
"category": "ai",
"label": {"en": "Generate Document", "de": "Dokument generieren", "fr": "Générer document"},
"description": {"en": "Generate document from prompt", "de": "Dokument aus Prompt generieren", "fr": "Générer un document"},
"parameters": [
{"name": "prompt", "type": "string", "required": True, "description": {"en": "Generation prompt", "de": "Generierungs-Prompt", "fr": "Invite de génération"}},
],
"inputs": 1,
"outputs": 1,
"meta": {"icon": "mdi-file-plus", "color": "#9C27B0"},
"_method": "ai",
"_action": "generateDocument",
"_paramMap": {"prompt": "prompt", "format": "format"},
},
{
"id": "ai.generateCode",
"category": "ai",
"label": {"en": "Generate Code", "de": "Code generieren", "fr": "Générer code"},
"description": {"en": "Generate code from description", "de": "Code aus Beschreibung generieren", "fr": "Générer du code"},
"parameters": [
{"name": "prompt", "type": "string", "required": True, "description": {"en": "Code generation prompt", "de": "Code-Generierungs-Prompt", "fr": "Invite de génération de code"}},
{"name": "language", "type": "string", "required": False, "description": {"en": "Programming language", "de": "Programmiersprache", "fr": "Langage de programmation"}, "default": "python"},
],
"inputs": 1,
"outputs": 1,
"meta": {"icon": "mdi-code-tags", "color": "#9C27B0"},
"_method": "ai",
"_action": "generateCode",
"_paramMap": {"prompt": "prompt", "language": "language"},
},
]

View file

@ -1,227 +0,0 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""ClickUp nodes — map to MethodClickup actions."""
CLICKUP_NODES = [
{
"id": "clickup.searchTasks",
"category": "clickup",
"label": {"en": "Search tasks", "de": "Aufgaben suchen", "fr": "Rechercher tâches"},
"description": {
"en": "Search tasks in a workspace (team)",
"de": "Aufgaben in einem Workspace suchen",
"fr": "Rechercher des tâches dans un espace",
},
"parameters": [
{"name": "connectionId", "type": "string", "required": True, "description": {"en": "ClickUp connection", "de": "ClickUp-Verbindung", "fr": "Connexion ClickUp"}},
{"name": "teamId", "type": "string", "required": True, "description": {"en": "Workspace (team) ID", "de": "Team-/Workspace-ID", "fr": "ID équipe"}},
{"name": "query", "type": "string", "required": True, "description": {"en": "Search query", "de": "Suchbegriff", "fr": "Requête"}},
{"name": "page", "type": "number", "required": False, "description": {"en": "Page", "de": "Seite", "fr": "Page"}, "default": 0},
{
"name": "listId",
"type": "string",
"required": False,
"description": {
"en": "If set, search this list via list API (not team search).",
"de": "Wenn gesetzt: Suche in dieser Liste (Listen-API, nicht Team-Suche).",
"fr": "Si défini : recherche dans cette liste (API liste).",
},
},
{
"name": "includeClosed",
"type": "boolean",
"required": False,
"default": False,
"description": {
"en": "With listId: include closed tasks.",
"de": "Mit Liste: erledigte Aufgaben einbeziehen.",
"fr": "Avec liste : inclure les tâches terminées.",
},
},
{
"name": "fullTaskData",
"type": "boolean",
"required": False,
"default": False,
"description": {
"en": "Return full ClickUp API JSON per task (very large). Default: slim fields only.",
"de": "Vollständige ClickUp-Rohdaten pro Task (sehr groß). Standard: nur schlanke Felder.",
"fr": "Réponse brute complète (très volumineuse). Par défaut : champs réduits.",
},
},
{
"name": "matchNameOnly",
"type": "boolean",
"required": False,
"default": True,
"description": {
"en": "Keep only tasks whose title contains the search query (default: on).",
"de": "Nur Aufgaben, deren Titel den Suchbegriff enthält (Standard: an).",
"fr": "Ne garder que les tâches dont le titre contient la requête (défaut : oui).",
},
},
],
"inputs": 1,
"outputs": 1,
"meta": {"icon": "mdi-magnify", "color": "#7B68EE"},
"_method": "clickup",
"_action": "searchTasks",
"_paramMap": {
"connectionId": "connectionReference",
"teamId": "teamId",
"query": "query",
"page": "page",
"listId": "listId",
"fullTaskData": "fullTaskData",
"matchNameOnly": "matchNameOnly",
"includeClosed": "includeClosed",
},
},
{
"id": "clickup.listTasks",
"category": "clickup",
"label": {"en": "List tasks", "de": "Aufgaben auflisten", "fr": "Lister les tâches"},
"description": {
"en": "List tasks in a list (pick list path from browse)",
"de": "Aufgaben einer Liste auflisten (Pfad aus Browse)",
"fr": "Lister les tâches d'une liste",
},
"parameters": [
{"name": "connectionId", "type": "string", "required": True, "description": {"en": "ClickUp connection", "de": "ClickUp-Verbindung", "fr": "Connexion ClickUp"}},
{"name": "path", "type": "string", "required": True, "description": {"en": "Virtual path to list /team/.../list/...", "de": "Pfad zur Liste", "fr": "Chemin vers la liste"}},
{"name": "page", "type": "number", "required": False, "description": {"en": "Page", "de": "Seite", "fr": "Page"}, "default": 0},
{"name": "includeClosed", "type": "boolean", "required": False, "description": {"en": "Include closed", "de": "Erledigte einbeziehen", "fr": "Inclure terminées"}, "default": False},
],
"inputs": 1,
"outputs": 1,
"meta": {"icon": "mdi-format-list-bulleted", "color": "#7B68EE"},
"_method": "clickup",
"_action": "listTasks",
"_paramMap": {
"connectionId": "connectionReference",
"path": "pathQuery",
"page": "page",
"includeClosed": "includeClosed",
},
},
{
"id": "clickup.getTask",
"category": "clickup",
"label": {"en": "Get task", "de": "Aufgabe abrufen", "fr": "Obtenir la tâche"},
"description": {"en": "Get one task by ID or path", "de": "Eine Aufgabe abrufen", "fr": "Obtenir une tâche"},
"parameters": [
{"name": "connectionId", "type": "string", "required": True, "description": {"en": "ClickUp connection", "de": "ClickUp-Verbindung", "fr": "Connexion ClickUp"}},
{"name": "taskId", "type": "string", "required": False, "description": {"en": "Task ID", "de": "Task-ID", "fr": "ID tâche"}},
{"name": "path", "type": "string", "required": False, "description": {"en": "Or path .../task/{id}", "de": "Oder Pfad .../task/{id}", "fr": "Ou chemin .../task/{id}"}},
],
"inputs": 1,
"outputs": 1,
"meta": {"icon": "mdi-file-document-outline", "color": "#7B68EE"},
"_method": "clickup",
"_action": "getTask",
"_paramMap": {"connectionId": "connectionReference", "taskId": "taskId", "path": "pathQuery"},
},
{
"id": "clickup.createTask",
"category": "clickup",
"label": {"en": "Create task", "de": "Aufgabe erstellen", "fr": "Créer une tâche"},
"description": {"en": "Create a task in a list", "de": "Aufgabe in einer Liste erstellen", "fr": "Créer une tâche dans une liste"},
"parameters": [
{"name": "connectionId", "type": "string", "required": True, "description": {"en": "ClickUp connection", "de": "ClickUp-Verbindung", "fr": "Connexion ClickUp"}},
{"name": "teamId", "type": "string", "required": False, "description": {"en": "Workspace (team) for list picker", "de": "Workspace für Listen-Auswahl", "fr": "Équipe"}},
{"name": "path", "type": "string", "required": False, "description": {"en": "Optional path /team/.../list/...", "de": "Optional: Pfad zur Liste", "fr": "Chemin optionnel"}},
{"name": "listId", "type": "string", "required": False, "description": {"en": "List ID", "de": "Listen-ID", "fr": "ID liste"}},
{"name": "name", "type": "string", "required": True, "description": {"en": "Task name", "de": "Name", "fr": "Nom"}},
{"name": "description", "type": "string", "required": False, "description": {"en": "Description", "de": "Beschreibung", "fr": "Description"}},
{"name": "taskStatus", "type": "string", "required": False, "description": {"en": "Status (list status name)", "de": "Status (wie in der Liste)", "fr": "Statut"}},
{"name": "taskPriority", "type": "string", "required": False, "description": {"en": "14 or empty", "de": "14 oder leer", "fr": "14"}},
{"name": "taskDueDateMs", "type": "string", "required": False, "description": {"en": "Due date (Unix ms)", "de": "Fälligkeit (ms)", "fr": "Échéance (ms)"}},
{"name": "taskAssigneeIds", "type": "object", "required": False, "description": {"en": "Assignee user ids", "de": "Zugewiesene (User-IDs)", "fr": "Assignés"}},
{"name": "taskTimeEstimateMs", "type": "string", "required": False, "description": {"en": "Time estimate (ms)", "de": "Zeitschätzung (ms)", "fr": "Estimation (ms)"}},
{"name": "taskTimeEstimateHours", "type": "string", "required": False, "description": {"en": "Time estimate (hours)", "de": "Zeitschätzung (Stunden)", "fr": "Heures"}},
{"name": "customFieldValues", "type": "object", "required": False, "description": {"en": "Custom field id → value", "de": "Benutzerdefinierte Felder", "fr": "Champs personnalisés"}},
{"name": "taskFields", "type": "string", "required": False, "description": {"en": "Extra JSON (advanced)", "de": "Zusätzliches JSON (fortgeschritten)", "fr": "JSON avancé"}},
],
"inputs": 1,
"outputs": 1,
"meta": {"icon": "mdi-plus-circle-outline", "color": "#7B68EE"},
"_method": "clickup",
"_action": "createTask",
"_paramMap": {
"connectionId": "connectionReference",
"teamId": "teamId",
"path": "pathQuery",
"listId": "listId",
"name": "name",
"description": "description",
"taskStatus": "taskStatus",
"taskPriority": "taskPriority",
"taskDueDateMs": "taskDueDateMs",
"taskAssigneeIds": "taskAssigneeIds",
"taskTimeEstimateMs": "taskTimeEstimateMs",
"taskTimeEstimateHours": "taskTimeEstimateHours",
"customFieldValues": "customFieldValues",
"taskFields": "taskFields",
},
},
{
"id": "clickup.updateTask",
"category": "clickup",
"label": {"en": "Update task", "de": "Aufgabe aktualisieren", "fr": "Mettre à jour la tâche"},
"description": {
"en": "Update task fields (rows or JSON)",
"de": "Felder der Aufgabe ändern (Zeilen oder JSON)",
"fr": "Mettre à jour les champs (lignes ou JSON)",
},
"parameters": [
{"name": "connectionId", "type": "string", "required": True, "description": {"en": "ClickUp connection", "de": "ClickUp-Verbindung", "fr": "Connexion ClickUp"}},
{"name": "taskId", "type": "string", "required": False, "description": {"en": "Task ID", "de": "Task-ID", "fr": "ID tâche"}},
{"name": "path", "type": "string", "required": False, "description": {"en": "Or path to task", "de": "Oder Pfad", "fr": "Ou chemin"}},
{
"name": "taskUpdateEntries",
"type": "object",
"required": False,
"description": {
"en": "List of {fieldKey, value, customFieldId?}",
"de": "Liste der zu ändernden Felder (fieldKey, value, optional customFieldId)",
"fr": "Liste de champs à mettre à jour",
},
},
{"name": "taskUpdate", "type": "string", "required": False, "description": {"en": "JSON body for API (optional if rows set)", "de": "JSON für API (optional wenn Zeilen gesetzt)", "fr": "Corps JSON"}},
],
"inputs": 1,
"outputs": 1,
"meta": {"icon": "mdi-pencil-outline", "color": "#7B68EE"},
"_method": "clickup",
"_action": "updateTask",
"_paramMap": {
"connectionId": "connectionReference",
"taskId": "taskId",
"path": "path",
"taskUpdate": "taskUpdate",
},
},
{
"id": "clickup.uploadAttachment",
"category": "clickup",
"label": {"en": "Upload attachment", "de": "Anhang hochladen", "fr": "Téléverser pièce jointe"},
"description": {"en": "Upload file to a task (upstream file)", "de": "Datei an Task anhängen", "fr": "Joindre un fichier à la tâche"},
"parameters": [
{"name": "connectionId", "type": "string", "required": True, "description": {"en": "ClickUp connection", "de": "ClickUp-Verbindung", "fr": "Connexion ClickUp"}},
{"name": "taskId", "type": "string", "required": False, "description": {"en": "Task ID", "de": "Task-ID", "fr": "ID tâche"}},
{"name": "path", "type": "string", "required": False, "description": {"en": "Or path to task", "de": "Oder Pfad", "fr": "Ou chemin"}},
{"name": "fileName", "type": "string", "required": False, "description": {"en": "File name", "de": "Dateiname", "fr": "Nom du fichier"}},
],
"inputs": 1,
"outputs": 1,
"meta": {"icon": "mdi-attachment", "color": "#7B68EE"},
"_method": "clickup",
"_action": "uploadAttachment",
"_paramMap": {
"connectionId": "connectionReference",
"taskId": "taskId",
"path": "path",
"fileName": "fileName",
},
},
]

View file

@ -1,70 +0,0 @@
# Copyright (c) 2025 Patrick Motsch
# Email node definitions - map to methodOutlook actions.
# Use connectionId from user connections (like AI workspace sources).
EMAIL_NODES = [
{
"id": "email.checkEmail",
"category": "email",
"label": {"en": "Check Email", "de": "E-Mail prüfen", "fr": "Vérifier email"},
"description": {"en": "Check for new emails (general or from specific account)", "de": "Neue E-Mails prüfen", "fr": "Vérifier les nouveaux emails"},
"parameters": [
{"name": "connectionId", "type": "string", "required": True, "description": {"en": "Email account connection", "de": "E-Mail-Konto Verbindung", "fr": "Connexion compte email"}},
{"name": "folder", "type": "string", "required": False, "description": {"en": "Folder (e.g. Inbox)", "de": "Ordner (z.B. Posteingang)", "fr": "Dossier (ex. Boîte de réception)"}, "default": "Inbox"},
{"name": "limit", "type": "number", "required": False, "description": {"en": "Max emails to fetch", "de": "Max E-Mails", "fr": "Max emails"}, "default": 100},
{"name": "fromAddress", "type": "string", "required": False, "description": {"en": "Only emails from this address", "de": "Nur E-Mails von dieser Adresse", "fr": "Seulement les e-mails de cette adresse"}, "default": ""},
{"name": "subjectContains", "type": "string", "required": False, "description": {"en": "Subject must contain this text", "de": "Betreff muss diesen Text enthalten", "fr": "Le sujet doit contenir ce texte"}, "default": ""},
{"name": "hasAttachment", "type": "boolean", "required": False, "description": {"en": "Only emails with attachments", "de": "Nur E-Mails mit Anhängen", "fr": "Seulement les e-mails avec pièces jointes"}, "default": False},
{"name": "filter", "type": "string", "required": False, "description": {"en": "Advanced: raw filter (overrides above if set)", "de": "Erweitert: Filter-Text (überschreibt obige)", "fr": "Avancé: filtre brut"}, "default": ""},
],
"inputs": 1,
"outputs": 1,
"meta": {"icon": "mdi-email-check", "color": "#1976D2"},
"_method": "outlook",
"_action": "readEmails",
"_paramMap": {"connectionId": "connectionReference", "folder": "folder", "limit": "limit", "filter": "filter"},
},
{
"id": "email.searchEmail",
"category": "email",
"label": {"en": "Search Email", "de": "E-Mail suchen", "fr": "Rechercher email"},
"description": {"en": "Search or find emails", "de": "E-Mails suchen oder finden", "fr": "Rechercher des emails"},
"parameters": [
{"name": "connectionId", "type": "string", "required": True, "description": {"en": "Email account connection", "de": "E-Mail-Konto Verbindung", "fr": "Connexion compte email"}},
{"name": "query", "type": "string", "required": False, "description": {"en": "General search term (searches subject, body, from)", "de": "Suchbegriff (durchsucht Betreff, Inhalt, Absender)", "fr": "Terme de recherche (sujet, corps, expéditeur)"}, "default": ""},
{"name": "folder", "type": "string", "required": False, "description": {"en": "Folder to search", "de": "Ordner zum Suchen", "fr": "Dossier à rechercher"}, "default": "Inbox"},
{"name": "limit", "type": "number", "required": False, "description": {"en": "Max emails to return", "de": "Max E-Mails", "fr": "Max emails"}, "default": 100},
{"name": "fromAddress", "type": "string", "required": False, "description": {"en": "Only emails from this address", "de": "Nur E-Mails von dieser Adresse", "fr": "Seulement les e-mails de cette adresse"}, "default": ""},
{"name": "toAddress", "type": "string", "required": False, "description": {"en": "Only emails to this recipient", "de": "Nur E-Mails an diesen Empfänger", "fr": "Seulement les e-mails à ce destinataire"}, "default": ""},
{"name": "subjectContains", "type": "string", "required": False, "description": {"en": "Subject must contain this text", "de": "Betreff muss diesen Text enthalten", "fr": "Le sujet doit contenir ce texte"}, "default": ""},
{"name": "bodyContains", "type": "string", "required": False, "description": {"en": "Body/content must contain this text", "de": "Inhalt muss diesen Text enthalten", "fr": "Le corps doit contenir ce texte"}, "default": ""},
{"name": "hasAttachment", "type": "boolean", "required": False, "description": {"en": "Only emails with attachments", "de": "Nur E-Mails mit Anhängen", "fr": "Seulement les e-mails avec pièces jointes"}, "default": False},
{"name": "filter", "type": "string", "required": False, "description": {"en": "Advanced: raw KQL (overrides above if set)", "de": "Erweitert: KQL-Filter (überschreibt obige)", "fr": "Avancé: filtre KQL brut"}, "default": ""},
],
"inputs": 1,
"outputs": 1,
"meta": {"icon": "mdi-email-search", "color": "#1976D2"},
"_method": "outlook",
"_action": "searchEmails",
"_paramMap": {"connectionId": "connectionReference", "query": "query", "folder": "folder", "limit": "limit", "filter": "filter"},
},
{
"id": "email.draftEmail",
"category": "email",
"label": {"en": "Draft Email", "de": "E-Mail entwerfen", "fr": "Brouillon email"},
"description": {"en": "Create a draft email", "de": "E-Mail-Entwurf erstellen", "fr": "Créer un brouillon d'email"},
"parameters": [
{"name": "connectionId", "type": "string", "required": True, "description": {"en": "Email account connection", "de": "E-Mail-Konto Verbindung", "fr": "Connexion compte email"}},
{"name": "subject", "type": "string", "required": True, "description": {"en": "Email subject", "de": "E-Mail-Betreff", "fr": "Sujet"}},
{"name": "body", "type": "string", "required": True, "description": {"en": "Email body", "de": "E-Mail-Text", "fr": "Corps de l'email"}},
{"name": "to", "type": "string", "required": False, "description": {"en": "Recipient(s)", "de": "Empfänger", "fr": "Destinataire(s)"}, "default": ""},
],
"inputs": 1,
"outputs": 1,
"meta": {"icon": "mdi-email-edit", "color": "#1976D2"},
"_method": "outlook",
"_action": "composeAndDraftEmailWithContext",
"_paramMap": {"connectionId": "connectionReference", "to": "to"},
"_contextFrom": ["subject", "body"],
},
]

View file

@ -1,60 +0,0 @@
# Copyright (c) 2025 Patrick Motsch
# File node definitions - create files from context (e.g. from AI nodes).
FILE_NODES = [
{
"id": "file.create",
"category": "file",
"label": {"en": "Create File", "de": "Datei erstellen", "fr": "Créer fichier"},
"description": {
"en": "Create a file from context (text/markdown from AI). Configurable format and style.",
"de": "Erstellt eine Datei aus Kontext (Text/Markdown von KI). Format und Stil konfigurierbar.",
"fr": "Crée un fichier à partir du contexte. Format et style configurables.",
},
"parameters": [
{
"name": "contentSources",
"type": "json",
"required": False,
"description": {
"en": "Array of context refs (e.g. AI, form). Concatenated in order. Empty = from connected node.",
"de": "Liste von Kontext-Quellen (z.B. KI, Formular). Werden nacheinander zusammengefügt. Leer = vom verbundenen Node.",
"fr": "Liste de sources de contexte. Concaténées dans l'ordre. Vide = du noeud connecté.",
},
"default": [],
},
{
"name": "outputFormat",
"type": "string",
"required": True,
"description": {"en": "Output format", "de": "Ausgabeformat", "fr": "Format de sortie"},
"default": "docx",
},
{
"name": "title",
"type": "string",
"required": False,
"description": {"en": "Document title", "de": "Dokumenttitel", "fr": "Titre du document"},
},
{
"name": "templateName",
"type": "string",
"required": False,
"description": {"en": "Style preset: default, corporate, minimal", "de": "Stil-Vorlage", "fr": "Prését style"},
},
{
"name": "language",
"type": "string",
"required": False,
"description": {"en": "Language code (de, en, fr)", "de": "Sprachcode", "fr": "Code langue"},
"default": "de",
},
],
"inputs": 1,
"outputs": 1,
"meta": {"icon": "mdi-file-plus-outline", "color": "#2196F3"},
"_method": "file",
"_action": "create",
"_paramMap": {},
},
]

View file

@ -1,46 +0,0 @@
# Copyright (c) 2025 Patrick Motsch
# Flow control node definitions.
FLOW_NODES = [
{
"id": "flow.ifElse",
"category": "flow",
"label": {"en": "If / Else", "de": "Wenn / Sonst", "fr": "Si / Sinon"},
"description": {"en": "Branch based on condition", "de": "Verzweigung nach Bedingung", "fr": "Branche selon condition"},
"parameters": [
{"name": "condition", "type": "string", "required": True, "description": {"en": "Expression to evaluate (e.g. {{value}} > 0)", "de": "Bedingung", "fr": "Condition"}},
],
"inputs": 1,
"outputs": 2,
"outputLabels": {"en": ["Yes", "No"], "de": ["Ja", "Nein"], "fr": ["Oui", "Non"]},
"executor": "flow",
"meta": {"icon": "mdi-source-branch", "color": "#FF9800"},
},
{
"id": "flow.switch",
"category": "flow",
"label": {"en": "Switch", "de": "Switch", "fr": "Switch"},
"description": {"en": "Multiple branches based on value", "de": "Mehrere Zweige nach Wert", "fr": "Branches multiples selon valeur"},
"parameters": [
{"name": "value", "type": "string", "required": True, "description": {"en": "Value to match", "de": "Zu vergleichender Wert", "fr": "Valeur à comparer"}},
{"name": "cases", "type": "array", "required": False, "description": {"en": "List of cases", "de": "Fälle", "fr": "Cas"}},
],
"inputs": 1,
"outputs": 1,
"executor": "flow",
"meta": {"icon": "mdi-swap-horizontal", "color": "#FF9800"},
},
{
"id": "flow.loop",
"category": "flow",
"label": {"en": "Loop / For Each", "de": "Schleife / Für Jedes", "fr": "Boucle / Pour Chaque"},
"description": {"en": "Iterate over array items", "de": "Über Array-Elemente iterieren", "fr": "Itérer sur les éléments"},
"parameters": [
{"name": "items", "type": "string", "required": True, "description": {"en": "Path to array (e.g. {{input.items}})", "de": "Pfad zum Array", "fr": "Chemin vers le tableau"}},
],
"inputs": 1,
"outputs": 1,
"executor": "flow",
"meta": {"icon": "mdi-repeat", "color": "#FF9800"},
},
]

View file

@ -1,122 +0,0 @@
# Copyright (c) 2025 Patrick Motsch
# Input/Human node definitions - nodes that require user action.
INPUT_NODES = [
{
"id": "input.form",
"category": "input",
"label": {"en": "Form", "de": "Formular", "fr": "Formulaire"},
"description": {"en": "User fills out a form", "de": "Benutzer füllt ein Formular aus", "fr": "L'utilisateur remplit un formulaire"},
"parameters": [
{
"name": "fields",
"type": "json",
"required": True,
"description": {
"en": "Form fields: [{name, type, label, required, options?}]. type may include clickup_tasks with clickupConnectionId + clickupListId for a ClickUp task dropdown (value {add, rem}).",
"de": "Formularfelder. type: u. a. clickup_tasks mit clickupConnectionId und clickupListId für ClickUp-Aufgaben-Dropdown (Wert wie Relationship-Feld).",
"fr": "Champs du formulaire",
},
"default": [],
},
],
"inputs": 1,
"outputs": 1,
"executor": "input",
"meta": {"icon": "mdi-form-textbox", "color": "#9C27B0"},
},
{
"id": "input.approval",
"category": "input",
"label": {"en": "Approval", "de": "Genehmigung", "fr": "Approbation"},
"description": {"en": "User approves or rejects", "de": "Benutzer genehmigt oder lehnt ab", "fr": "L'utilisateur approuve ou rejette"},
"parameters": [
{"name": "title", "type": "string", "required": True, "description": {"en": "Approval title", "de": "Genehmigungstitel", "fr": "Titre"}},
{"name": "description", "type": "string", "required": False, "description": {"en": "What to approve", "de": "Was genehmigt werden soll", "fr": "Ce qu'il faut approuver"}},
{"name": "approvalType", "type": "string", "required": False, "description": {"en": "Type: document or generic", "de": "Typ: document oder generic", "fr": "Type: document ou generic"}, "default": "generic"},
],
"inputs": 1,
"outputs": 1,
"executor": "input",
"meta": {"icon": "mdi-check-decagram", "color": "#4CAF50"},
},
{
"id": "input.upload",
"category": "input",
"label": {"en": "Upload", "de": "Upload", "fr": "Téléversement"},
"description": {"en": "User uploads file(s)", "de": "Benutzer lädt Datei(en) hoch", "fr": "L'utilisateur téléverse des fichiers"},
"parameters": [
{"name": "accept", "type": "string", "required": False, "description": {"en": "Accept string for file input (e.g. .pdf,image/*)", "de": "Accept-String für Dateiauswahl", "fr": "Chaîne accept"}, "default": ""},
{"name": "allowedTypes", "type": "json", "required": False, "description": {"en": "Selected file types (from UI multi-select)", "de": "Ausgewählte Dateitypen", "fr": "Types sélectionnés"}, "default": []},
{"name": "maxSize", "type": "number", "required": False, "description": {"en": "Max file size in MB", "de": "Max. Dateigröße in MB", "fr": "Taille max en Mo"}, "default": 10},
{"name": "multiple", "type": "boolean", "required": False, "description": {"en": "Allow multiple files", "de": "Mehrere Dateien erlauben", "fr": "Autoriser plusieurs fichiers"}, "default": False},
],
"inputs": 1,
"outputs": 1,
"executor": "input",
"meta": {"icon": "mdi-upload", "color": "#2196F3"},
},
{
"id": "input.comment",
"category": "input",
"label": {"en": "Comment", "de": "Kommentar", "fr": "Commentaire"},
"description": {"en": "User adds a comment", "de": "Benutzer fügt einen Kommentar hinzu", "fr": "L'utilisateur ajoute un commentaire"},
"parameters": [
{"name": "placeholder", "type": "string", "required": False, "description": {"en": "Placeholder text", "de": "Platzhalter", "fr": "Texte indicatif"}, "default": ""},
{"name": "required", "type": "boolean", "required": False, "description": {"en": "Comment required", "de": "Kommentar erforderlich", "fr": "Commentaire requis"}, "default": True},
],
"inputs": 1,
"outputs": 1,
"executor": "input",
"meta": {"icon": "mdi-comment-text", "color": "#FF9800"},
},
{
"id": "input.review",
"category": "input",
"label": {"en": "Review", "de": "Prüfung", "fr": "Revue"},
"description": {"en": "User reviews content", "de": "Benutzer prüft Inhalt", "fr": "L'utilisateur révise le contenu"},
"parameters": [
{"name": "contentRef", "type": "string", "required": True, "description": {"en": "Reference to content (e.g. {{nodeId.field}})", "de": "Referenz auf Inhalt", "fr": "Référence au contenu"}},
{"name": "reviewType", "type": "string", "required": False, "description": {"en": "Type of review", "de": "Art der Prüfung", "fr": "Type de revue"}, "default": "generic"},
],
"inputs": 1,
"outputs": 1,
"executor": "input",
"meta": {"icon": "mdi-magnify-scan", "color": "#673AB7"},
},
{
"id": "input.selection",
"category": "input",
"label": {"en": "Selection", "de": "Auswahl", "fr": "Sélection"},
"description": {"en": "User selects from options", "de": "Benutzer wählt aus Optionen", "fr": "L'utilisateur choisit parmi les options"},
"parameters": [
{
"name": "options",
"type": "json",
"required": True,
"description": {"en": "Options: [{value, label}]", "de": "Optionen", "fr": "Options"},
"default": [],
},
{"name": "multiple", "type": "boolean", "required": False, "description": {"en": "Allow multiple selection", "de": "Mehrfachauswahl erlauben", "fr": "Sélection multiple"}, "default": False},
],
"inputs": 1,
"outputs": 1,
"executor": "input",
"meta": {"icon": "mdi-format-list-checks", "color": "#009688"},
},
{
"id": "input.confirmation",
"category": "input",
"label": {"en": "Confirmation", "de": "Bestätigung", "fr": "Confirmation"},
"description": {"en": "User confirms yes/no", "de": "Benutzer bestätigt Ja/Nein", "fr": "L'utilisateur confirme oui/non"},
"parameters": [
{"name": "question", "type": "string", "required": True, "description": {"en": "Question to confirm", "de": "Zu bestätigende Frage", "fr": "Question à confirmer"}},
{"name": "confirmLabel", "type": "string", "required": False, "description": {"en": "Label for confirm button", "de": "Label für Bestätigen-Button", "fr": "Libellé du bouton confirmer"}, "default": "Confirm"},
{"name": "rejectLabel", "type": "string", "required": False, "description": {"en": "Label for reject button", "de": "Label für Ablehnen-Button", "fr": "Libellé du bouton refuser"}, "default": "Reject"},
],
"inputs": 1,
"outputs": 1,
"executor": "input",
"meta": {"icon": "mdi-checkbox-marked-circle", "color": "#8BC34A"},
},
]

View file

@ -1,105 +0,0 @@
# Copyright (c) 2025 Patrick Motsch
# SharePoint node definitions - map to methodSharepoint actions.
# Use connectionId and path from connection selector (like workflow folder view).
SHAREPOINT_NODES = [
{
"id": "sharepoint.findFile",
"category": "sharepoint",
"label": {"en": "Find File", "de": "Datei finden", "fr": "Trouver fichier"},
"description": {"en": "Find file by path or search", "de": "Datei nach Pfad oder Suche finden", "fr": "Trouver fichier par chemin ou recherche"},
"parameters": [
{"name": "connectionId", "type": "string", "required": True, "description": {"en": "SharePoint connection", "de": "SharePoint-Verbindung", "fr": "Connexion SharePoint"}},
{"name": "searchQuery", "type": "string", "required": True, "description": {"en": "Search query or path", "de": "Suchanfrage oder Pfad", "fr": "Requête ou chemin"}},
{"name": "site", "type": "string", "required": False, "description": {"en": "Optional site hint", "de": "Optionaler Site-Hinweis", "fr": "Indication de site"}, "default": ""},
{"name": "maxResults", "type": "number", "required": False, "description": {"en": "Max results", "de": "Max Ergebnisse", "fr": "Max résultats"}, "default": 1000},
],
"inputs": 1,
"outputs": 1,
"meta": {"icon": "mdi-file-search", "color": "#0078D4"},
"_method": "sharepoint",
"_action": "findDocumentPath",
"_paramMap": {"connectionId": "connectionReference", "searchQuery": "searchQuery", "site": "site", "maxResults": "maxResults"},
},
{
"id": "sharepoint.readFile",
"category": "sharepoint",
"label": {"en": "Read File", "de": "Datei lesen", "fr": "Lire fichier"},
"description": {"en": "Extract content from file", "de": "Inhalt aus Datei extrahieren", "fr": "Extraire le contenu du fichier"},
"parameters": [
{"name": "connectionId", "type": "string", "required": True, "description": {"en": "SharePoint connection", "de": "SharePoint-Verbindung", "fr": "Connexion SharePoint"}},
{"name": "path", "type": "string", "required": True, "description": {"en": "File path or documentList from find file", "de": "Dateipfad oder documentList von Find", "fr": "Chemin ou documentList"}},
],
"inputs": 1,
"outputs": 1,
"meta": {"icon": "mdi-file-document", "color": "#0078D4"},
"_method": "sharepoint",
"_action": "readDocuments",
"_paramMap": {"connectionId": "connectionReference", "path": "pathQuery"},
},
{
"id": "sharepoint.uploadFile",
"category": "sharepoint",
"label": {"en": "Upload File", "de": "Datei hochladen", "fr": "Téléverser fichier"},
"description": {"en": "Upload file to SharePoint", "de": "Datei zu SharePoint hochladen", "fr": "Téléverser fichier vers SharePoint"},
"parameters": [
{"name": "connectionId", "type": "string", "required": True, "description": {"en": "SharePoint connection", "de": "SharePoint-Verbindung", "fr": "Connexion SharePoint"}},
{"name": "path", "type": "string", "required": True, "description": {"en": "Target folder path (e.g. /sites/.../Folder)", "de": "Zielordner-Pfad", "fr": "Chemin du dossier cible"}},
],
"inputs": 1,
"outputs": 1,
"meta": {"icon": "mdi-upload", "color": "#0078D4"},
"_method": "sharepoint",
"_action": "uploadFile",
"_paramMap": {"connectionId": "connectionReference", "path": "pathQuery"},
},
{
"id": "sharepoint.listFiles",
"category": "sharepoint",
"label": {"en": "List Files", "de": "Dateien auflisten", "fr": "Lister fichiers"},
"description": {"en": "List files in folder or SharePoint", "de": "Dateien in Ordner oder SharePoint auflisten", "fr": "Lister les fichiers dans un dossier"},
"parameters": [
{"name": "connectionId", "type": "string", "required": True, "description": {"en": "SharePoint connection", "de": "SharePoint-Verbindung", "fr": "Connexion SharePoint"}},
{"name": "path", "type": "string", "required": False, "description": {"en": "Folder path (e.g. /sites/SiteName/Shared Documents)", "de": "Ordnerpfad", "fr": "Chemin du dossier"}, "default": "/"},
],
"inputs": 1,
"outputs": 1,
"meta": {"icon": "mdi-folder-open", "color": "#0078D4"},
"_method": "sharepoint",
"_action": "listDocuments",
"_paramMap": {"connectionId": "connectionReference", "path": "pathQuery"},
},
{
"id": "sharepoint.downloadFile",
"category": "sharepoint",
"label": {"en": "Download File", "de": "Datei herunterladen", "fr": "Télécharger fichier"},
"description": {"en": "Download file from path (e.g. /sites/SiteName/Shared Documents/file.pdf)", "de": "Datei vom Pfad herunterladen", "fr": "Télécharger le fichier"},
"parameters": [
{"name": "connectionId", "type": "string", "required": True, "description": {"en": "SharePoint connection", "de": "SharePoint-Verbindung", "fr": "Connexion SharePoint"}},
{"name": "path", "type": "string", "required": True, "description": {"en": "Full file path (e.g. /sites/SiteName/Shared Documents/file.pdf)", "de": "Vollständiger Dateipfad", "fr": "Chemin complet du fichier"}},
],
"inputs": 1,
"outputs": 1,
"meta": {"icon": "mdi-download", "color": "#0078D4"},
"_method": "sharepoint",
"_action": "downloadFileByPath",
"_paramMap": {"connectionId": "connectionReference", "path": "pathQuery", "siteId": "siteId", "filePath": "filePath"},
},
{
"id": "sharepoint.copyFile",
"category": "sharepoint",
"label": {"en": "Copy File", "de": "Datei kopieren", "fr": "Copier fichier"},
"description": {"en": "Copy file to destination", "de": "Datei an Ziel kopieren", "fr": "Copier le fichier"},
"parameters": [
{"name": "connectionId", "type": "string", "required": True, "description": {"en": "SharePoint connection", "de": "SharePoint-Verbindung", "fr": "Connexion SharePoint"}},
{"name": "sourcePath", "type": "string", "required": True, "description": {"en": "Source file path (from browse)", "de": "Quelldatei-Pfad", "fr": "Chemin fichier source"}},
{"name": "destPath", "type": "string", "required": True, "description": {"en": "Destination folder path (from browse)", "de": "Zielordner-Pfad", "fr": "Chemin dossier cible"}},
],
"inputs": 1,
"outputs": 1,
"meta": {"icon": "mdi-content-copy", "color": "#0078D4"},
"_method": "sharepoint",
"_action": "copyFile",
"_paramMap": {"connectionId": "connectionReference", "sourcePath": "sourcePath", "destPath": "destPath"},
},
]

View file

@ -1,64 +0,0 @@
# Copyright (c) 2025 Patrick Motsch
# Canvas start nodes — variant reflects workflow configuration (gear in editor).
TRIGGER_NODES = [
{
"id": "trigger.manual",
"category": "trigger",
"label": {"en": "Start", "de": "Start", "fr": "Départ"},
"description": {
"en": "Manual, API, or background triggers (webhook, email, …).",
"de": "Manuell, API oder Hintergrund-Starts (Webhook, E-Mail, …).",
"fr": "Manuel, API ou déclencheurs en arrière-plan.",
},
"parameters": [],
"inputs": 0,
"outputs": 1,
"executor": "trigger",
"meta": {"icon": "mdi-play", "color": "#4CAF50"},
},
{
"id": "trigger.form",
"category": "trigger",
"label": {"en": "Start (form)", "de": "Start (Formular)", "fr": "Départ (formulaire)"},
"description": {
"en": "Form fields are filled at run time; configure fields on this node.",
"de": "Felder werden beim Start befüllt; konfigurieren Sie die Felder auf dieser Node.",
"fr": "Les champs sont remplis au démarrage.",
},
"parameters": [
{
"name": "formFields",
"type": "json",
"required": False,
"description": {"en": "Field definitions", "de": "Felddefinitionen", "fr": "Définitions"},
},
],
"inputs": 0,
"outputs": 1,
"executor": "trigger",
"meta": {"icon": "mdi-form-select", "color": "#9C27B0"},
},
{
"id": "trigger.schedule",
"category": "trigger",
"label": {"en": "Start (schedule)", "de": "Start (Zeitplan)", "fr": "Départ (planification)"},
"description": {
"en": "Cron expression for scheduled runs (configure on this node).",
"de": "Cron-Ausdruck für geplante Läufe.",
"fr": "Expression cron pour les exécutions planifiées.",
},
"parameters": [
{
"name": "cron",
"type": "string",
"required": False,
"description": {"en": "Cron expression", "de": "Cron-Ausdruck", "fr": "Expression cron"},
},
],
"inputs": 0,
"outputs": 1,
"executor": "trigger",
"meta": {"icon": "mdi-clock", "color": "#2196F3"},
},
]

View file

@ -1,88 +0,0 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Node Type Registry for automation2 - static node definitions (ai, email, sharepoint, trigger, flow, data, input).
Nodes are defined first; IO/method actions are used at execution time.
"""
import logging
from typing import Dict, List, Any
from modules.features.automation2.nodeDefinitions import STATIC_NODE_TYPES
logger = logging.getLogger(__name__)
def getNodeTypes(
services: Any = None,
language: str = "en",
) -> List[Dict[str, Any]]:
"""
Return static node types. No dynamic I/O derivation from methodDiscovery.
services: Optional (kept for API compatibility, not used).
"""
return list(STATIC_NODE_TYPES)
def _localizeNode(node: Dict[str, Any], language: str) -> Dict[str, Any]:
"""Apply language to label/description/parameters."""
lang = language if language in ("en", "de", "fr") else "en"
out = dict(node)
# Strip internal keys for API response
for key in list(out.keys()):
if key.startswith("_"):
del out[key]
if isinstance(node.get("label"), dict):
out["label"] = node["label"].get(lang, node["label"].get("en", str(node["label"])))
if isinstance(node.get("description"), dict):
out["description"] = node["description"].get(lang, node["description"].get("en", str(node["description"])))
ol = node.get("outputLabels")
if isinstance(ol, dict) and ol:
first = next(iter(ol.values()), None)
if isinstance(first, (list, tuple)):
out["outputLabels"] = ol.get(lang, ol.get("en", list(first)))
params = []
for p in node.get("parameters", []):
pc = dict(p)
if isinstance(p.get("description"), dict):
pc["description"] = p["description"].get(lang, p["description"].get("en", str(p.get("description", ""))))
params.append(pc)
out["parameters"] = params
return out
def getNodeTypesForApi(
services: Any,
language: str = "en",
) -> Dict[str, Any]:
"""
API-ready response: nodeTypes with localized strings, plus categories list.
"""
nodes = getNodeTypes(services, language)
localized = [_localizeNode(n, language) for n in nodes]
categories = [
{"id": "trigger", "label": {"en": "Trigger", "de": "Trigger", "fr": "Déclencheur"}},
{"id": "input", "label": {"en": "Input/Human", "de": "Eingabe/Mensch", "fr": "Entrée/Humain"}},
{"id": "flow", "label": {"en": "Flow", "de": "Ablauf", "fr": "Flux"}},
{"id": "data", "label": {"en": "Data", "de": "Daten", "fr": "Données"}},
{"id": "ai", "label": {"en": "AI", "de": "KI", "fr": "IA"}},
{"id": "file", "label": {"en": "File", "de": "Datei", "fr": "Fichier"}},
{"id": "email", "label": {"en": "Email", "de": "E-Mail", "fr": "Email"}},
{"id": "sharepoint", "label": {"en": "SharePoint", "de": "SharePoint", "fr": "SharePoint"}},
{"id": "clickup", "label": {"en": "ClickUp", "de": "ClickUp", "fr": "ClickUp"}},
]
return {"nodeTypes": localized, "categories": categories}
def getNodeTypeToMethodAction() -> Dict[str, tuple]:
"""
Mapping from node type id to (method, action) for execution.
Used by ActionNodeExecutor.
"""
mapping = {}
for node in STATIC_NODE_TYPES:
method = node.get("_method")
action = node.get("_action")
if method and action:
mapping[node["id"]] = (method, action)
return mapping

View file

@ -1,854 +0,0 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Automation2 routes - node-types, execute, workflows, runs, tasks, connections, browse.
"""
import logging
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, Path, Query, Body, Request, HTTPException
from fastapi.responses import JSONResponse
from modules.auth import limiter, getRequestContext, RequestContext
from modules.features.automation2.mainAutomation2 import getAutomation2Services
from modules.features.automation2.nodeRegistry import getNodeTypesForApi
from modules.features.automation2.interfaceFeatureAutomation2 import getAutomation2Interface
from modules.workflows.automation2.executionEngine import executeGraph
from modules.workflows.automation2.runEnvelope import (
default_run_envelope,
merge_run_envelope,
normalize_run_envelope,
)
from modules.features.automation2.entryPoints import find_invocation
logger = logging.getLogger(__name__)
def _build_execute_run_envelope(
body: Dict[str, Any],
workflow: Optional[Dict[str, Any]],
user_id: Optional[str],
) -> Dict[str, Any]:
"""Build normalized run envelope from POST /execute body."""
if isinstance(body.get("runEnvelope"), dict):
env = normalize_run_envelope(body["runEnvelope"], user_id=user_id)
pl = body.get("payload")
if isinstance(pl, dict):
env = merge_run_envelope(env, {"payload": pl})
return env
entry_point_id = body.get("entryPointId")
if entry_point_id:
if not workflow:
raise HTTPException(
status_code=400,
detail="entryPointId requires a saved workflow (workflowId must refer to a stored workflow)",
)
inv = find_invocation(workflow, entry_point_id)
if not inv:
raise HTTPException(status_code=400, detail="entryPointId not found on workflow")
if not inv.get("enabled", True):
raise HTTPException(status_code=400, detail="entry point is disabled")
kind = inv.get("kind", "manual")
trig_map = {
"manual": "manual",
"form": "form",
"schedule": "schedule",
"always_on": "event",
"email": "email",
"webhook": "webhook",
"api": "api",
"event": "event",
}
trig = trig_map.get(kind, "manual")
title = inv.get("title") or {}
label = ""
if isinstance(title, dict):
label = title.get("en") or title.get("de") or ""
elif isinstance(title, str):
label = title
base = default_run_envelope(
trig,
entry_point_id=inv.get("id"),
entry_point_label=label or None,
)
pl = body.get("payload")
if isinstance(pl, dict):
base = merge_run_envelope(base, {"payload": pl})
return normalize_run_envelope(base, user_id=user_id)
env = normalize_run_envelope(None, user_id=user_id)
pl = body.get("payload")
if isinstance(pl, dict):
env = merge_run_envelope(env, {"payload": pl})
return env
router = APIRouter(
prefix="/api/automation2",
tags=["Automation2"],
responses={404: {"description": "Not found"}, 403: {"description": "Forbidden"}},
)
def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str:
"""Validate user has access to the automation2 feature instance. Returns mandateId."""
from fastapi import HTTPException
from modules.interfaces.interfaceDbApp import getRootInterface
rootInterface = getRootInterface()
instance = rootInterface.getFeatureInstance(instanceId)
if not instance:
raise HTTPException(status_code=404, detail=f"Feature instance {instanceId} not found")
featureAccess = rootInterface.getFeatureAccess(str(context.user.id), instanceId)
if not featureAccess or not featureAccess.enabled:
raise HTTPException(status_code=403, detail="Access denied to this feature instance")
return str(instance.mandateId) if instance.mandateId else ""
@router.get("/{instanceId}/info")
@limiter.limit("60/minute")
def get_automation2_info(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Minimal info endpoint - proves the feature works."""
_validateInstanceAccess(instanceId, context)
return {
"featureCode": "automation2",
"instanceId": instanceId,
"status": "ok",
"message": "Automation2 feature ready. Build from here.",
}
@router.post("/{instanceId}/schedule-sync")
@limiter.limit("10/minute")
def post_schedule_sync(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Manually trigger schedule sync (re-register cron jobs for all schedule workflows)."""
_validateInstanceAccess(instanceId, context)
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.workflows.automation2.subAutomation2Schedule import sync_automation2_schedule_events
root = getRootInterface()
event_user = root.getUserByUsername("event")
if not event_user:
return {"success": False, "error": "Event user not available", "synced": 0}
result = sync_automation2_schedule_events(event_user)
return {"success": True, **result}
@router.get("/{instanceId}/node-types")
@limiter.limit("60/minute")
def get_node_types(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
language: str = Query("en", description="Localization (en, de, fr)"),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Return node types for the flow builder: static + I/O from methodDiscovery."""
logger.info("automation2 node-types request: instanceId=%s language=%s", instanceId, language)
mandateId = _validateInstanceAccess(instanceId, context)
services = getAutomation2Services(
context.user,
mandateId=mandateId,
featureInstanceId=instanceId,
)
result = getNodeTypesForApi(services, language=language)
logger.info(
"automation2 node-types response: %d nodeTypes %d categories",
len(result.get("nodeTypes", [])),
len(result.get("categories", [])),
)
return result
@router.post("/{instanceId}/execute")
@limiter.limit("30/minute")
async def post_execute(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
body: dict = Body(..., description="{ workflowId?, graph: { nodes, connections } }"),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Execute automation2 graph. Body: { workflowId?, graph: { nodes, connections } }."""
userId = str(context.user.id) if context.user else None
logger.info(
"automation2 execute request: instanceId=%s userId=%s body_keys=%s",
instanceId,
userId,
list(body.keys()),
)
mandateId = _validateInstanceAccess(instanceId, context)
services = getAutomation2Services(
context.user,
mandateId=mandateId,
featureInstanceId=instanceId,
)
# Ensure workflow methods (outlook, ai, sharepoint, etc.) are discovered for ActionExecutor
from modules.workflows.processing.shared.methodDiscovery import discoverMethods
discoverMethods(services)
graph = body.get("graph") or body
workflowId = body.get("workflowId")
req_nodes = graph.get("nodes") or []
workflow_for_envelope: Optional[Dict[str, Any]] = None
if workflowId and not str(workflowId).startswith("transient-"):
a2_pre = getAutomation2Interface(context.user, mandateId, instanceId)
workflow_for_envelope = a2_pre.getWorkflow(workflowId)
# When workflowId is set: prefer graph from request (current editor state) if it has nodes.
# Only fall back to stored workflow graph when request graph is empty (e.g. resume from email).
if workflowId and len(req_nodes) == 0:
a2 = getAutomation2Interface(context.user, mandateId, instanceId)
wf = a2.getWorkflow(workflowId)
if wf and wf.get("graph"):
graph = wf["graph"]
logger.info("automation2 execute: loaded graph from workflow %s", workflowId)
workflow_for_envelope = wf
# Use transient workflowId when none provided (e.g. execute from editor without save)
# Required for email.checkEmail pause/resume - run must be created
if not workflowId:
import uuid
workflowId = f"transient-{uuid.uuid4().hex[:12]}"
logger.info("automation2 execute: using transient workflowId=%s", workflowId)
nodes_count = len(graph.get("nodes") or [])
connections_count = len(graph.get("connections") or [])
logger.info(
"automation2 execute: graph nodes=%d connections=%d workflowId=%s mandateId=%s",
nodes_count,
connections_count,
workflowId,
mandateId,
)
run_env = _build_execute_run_envelope(body, workflow_for_envelope, userId)
a2_interface = getAutomation2Interface(context.user, mandateId, instanceId)
result = await executeGraph(
graph=graph,
services=services,
workflowId=workflowId,
instanceId=instanceId,
userId=userId,
mandateId=mandateId,
automation2_interface=a2_interface,
run_envelope=run_env,
)
logger.info(
"automation2 execute result: success=%s error=%s nodeOutputs_keys=%s failedNode=%s paused=%s",
result.get("success"),
result.get("error"),
list(result.get("nodeOutputs", {}).keys()) if result.get("nodeOutputs") else [],
result.get("failedNode"),
result.get("paused"),
)
return result
# -------------------------------------------------------------------------
# Connections and Browse (for Email/SharePoint node config - like workspace)
# -------------------------------------------------------------------------
def _buildResolverDbInterface(chatService):
"""Build a DB adapter that ConnectorResolver can use to load UserConnections."""
class _ResolverDbAdapter:
def __init__(self, appInterface):
self._app = appInterface
def getUserConnection(self, connectionId: str):
if hasattr(self._app, "getUserConnectionById"):
return self._app.getUserConnectionById(connectionId)
return None
appIf = getattr(chatService, "interfaceDbApp", None)
if appIf:
return _ResolverDbAdapter(appIf)
return getattr(chatService, "interfaceDbComponent", None)
@router.get("/{instanceId}/connections")
@limiter.limit("300/minute")
def list_automation2_connections(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Return the user's active connections (UserConnections) for Email/SharePoint node config."""
mandateId = _validateInstanceAccess(instanceId, context)
from modules.serviceCenter import getService
from modules.serviceCenter.context import ServiceCenterContext
ctx = ServiceCenterContext(
user=context.user,
mandate_id=str(context.mandateId) if context.mandateId else mandateId,
feature_instance_id=instanceId,
)
chatService = getService("chat", ctx)
connections = chatService.getUserConnections()
items = []
for c in connections or []:
conn = c if isinstance(c, dict) else (c.model_dump() if hasattr(c, "model_dump") else {})
authority = conn.get("authority")
if hasattr(authority, "value"):
authority = authority.value
status = conn.get("status")
if hasattr(status, "value"):
status = status.value
items.append({
"id": conn.get("id"),
"authority": authority,
"externalUsername": conn.get("externalUsername"),
"externalEmail": conn.get("externalEmail"),
"status": status,
})
return {"connections": items}
@router.get("/{instanceId}/connections/{connectionId}/services")
@limiter.limit("120/minute")
async def list_connection_services(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
connectionId: str = Path(..., description="Connection ID"),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Return the available services for a specific UserConnection."""
mandateId = _validateInstanceAccess(instanceId, context)
try:
from modules.connectors.connectorResolver import ConnectorResolver
from modules.serviceCenter import getService as getSvc
from modules.serviceCenter.context import ServiceCenterContext
ctx = ServiceCenterContext(
user=context.user,
mandate_id=str(context.mandateId) if context.mandateId else mandateId,
feature_instance_id=instanceId,
)
chatService = getSvc("chat", ctx)
securityService = getSvc("security", ctx)
dbInterface = _buildResolverDbInterface(chatService)
resolver = ConnectorResolver(securityService, dbInterface)
provider = await resolver.resolve(connectionId)
services = provider.getAvailableServices()
_serviceLabels = {
"sharepoint": "SharePoint",
"clickup": "ClickUp",
"outlook": "Outlook",
"teams": "Teams",
"onedrive": "OneDrive",
"drive": "Google Drive",
"gmail": "Gmail",
"files": "Files (FTP)",
}
_serviceIcons = {
"sharepoint": "sharepoint",
"clickup": "folder",
"outlook": "mail",
"teams": "chat",
"onedrive": "cloud",
"drive": "cloud",
"gmail": "mail",
"files": "folder",
}
items = [
{"service": s, "label": _serviceLabels.get(s, s), "icon": _serviceIcons.get(s, "folder")}
for s in services
]
return {"services": items}
except Exception as e:
logger.error(f"Error listing services for connection {connectionId}: {e}")
return JSONResponse({"services": [], "error": str(e)}, status_code=400)
@router.get("/{instanceId}/connections/{connectionId}/browse")
@limiter.limit("300/minute")
async def browse_connection_service(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
connectionId: str = Path(..., description="Connection ID"),
service: str = Query(..., description="Service name (e.g. sharepoint, onedrive, outlook)"),
path: str = Query("/", description="Path within the service to browse"),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Browse folders/items within a connection's service at a given path."""
mandateId = _validateInstanceAccess(instanceId, context)
try:
from modules.connectors.connectorResolver import ConnectorResolver
from modules.serviceCenter import getService as getSvc
from modules.serviceCenter.context import ServiceCenterContext
ctx = ServiceCenterContext(
user=context.user,
mandate_id=str(context.mandateId) if context.mandateId else mandateId,
feature_instance_id=instanceId,
)
chatService = getSvc("chat", ctx)
securityService = getSvc("security", ctx)
dbInterface = _buildResolverDbInterface(chatService)
resolver = ConnectorResolver(securityService, dbInterface)
adapter = await resolver.resolveService(connectionId, service)
entries = await adapter.browse(path, filter=None)
items = []
for entry in (entries or []):
items.append({
"name": entry.name,
"path": entry.path,
"isFolder": entry.isFolder,
"size": entry.size,
"mimeType": entry.mimeType,
"metadata": entry.metadata if hasattr(entry, "metadata") else {},
})
return {"items": items, "path": path, "service": service}
except Exception as e:
logger.error(f"Error browsing {service} for connection {connectionId} at '{path}': {e}")
return JSONResponse({"items": [], "error": str(e)}, status_code=400)
# -------------------------------------------------------------------------
# Workflow CRUD
# -------------------------------------------------------------------------
def _get_node_label_from_graph(graph: dict, nodeId: str) -> str:
"""Extract human-readable label for a node from graph."""
if not graph or not nodeId:
return nodeId or ""
nodes = graph.get("nodes") or []
for n in nodes:
if n.get("id") == nodeId:
params = n.get("parameters") or {}
config = params.get("config") or {}
if isinstance(config, dict):
label = config.get("title") or config.get("label")
else:
label = None
return (
n.get("title")
or label
or params.get("title")
or params.get("label")
or n.get("type", "")
or nodeId
)
return nodeId or ""
@router.get("/{instanceId}/workflows")
@limiter.limit("60/minute")
def get_workflows(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
active: Optional[bool] = Query(None, description="Filter by active: true|false"),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""List all workflows for this feature instance.
Enriches each workflow with runCount, isRunning, stuckAtNodeId, stuckAtNodeLabel,
createdAt, lastStartedAt.
Query param active: filter by active status (true|false).
"""
mandateId = _validateInstanceAccess(instanceId, context)
a2 = getAutomation2Interface(context.user, mandateId, instanceId)
items = a2.getWorkflows(active=active)
enriched = []
for wf in items:
wf_id = wf.get("id")
runs = a2.getRunsByWorkflow(wf_id) if wf_id else []
run_count = len(runs)
active_run = None
last_started_at = None
for r in runs:
ts = r.get("sysCreatedAt")
if ts and (last_started_at is None or ts > last_started_at):
last_started_at = ts
if r.get("status") in ("running", "paused"):
active_run = r
stuck_at_node_id = active_run.get("currentNodeId") if active_run else None
stuck_at_node_label = ""
if stuck_at_node_id and wf.get("graph"):
stuck_at_node_label = _get_node_label_from_graph(wf["graph"], stuck_at_node_id)
enriched.append({
**wf,
"runCount": run_count,
"isRunning": active_run is not None,
"runStatus": active_run.get("status") if active_run else None,
"stuckAtNodeId": stuck_at_node_id,
"stuckAtNodeLabel": stuck_at_node_label or stuck_at_node_id or "",
"createdAt": wf.get("sysCreatedAt"),
"lastStartedAt": last_started_at,
})
return {"workflows": enriched}
@router.get("/{instanceId}/workflows/{workflowId}")
@limiter.limit("60/minute")
def get_workflow(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
workflowId: str = Path(..., description="Workflow ID"),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Get a single workflow by ID."""
mandateId = _validateInstanceAccess(instanceId, context)
a2 = getAutomation2Interface(context.user, mandateId, instanceId)
wf = a2.getWorkflow(workflowId)
if not wf:
raise HTTPException(status_code=404, detail="Workflow not found")
return wf
@router.post("/{instanceId}/workflows")
@limiter.limit("30/minute")
def create_workflow(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
body: dict = Body(..., description="{ label, graph }"),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Create a new workflow."""
mandateId = _validateInstanceAccess(instanceId, context)
a2 = getAutomation2Interface(context.user, mandateId, instanceId)
created = a2.createWorkflow(body)
return created
@router.put("/{instanceId}/workflows/{workflowId}")
@limiter.limit("30/minute")
def update_workflow(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
workflowId: str = Path(..., description="Workflow ID"),
body: dict = Body(..., description="{ label?, graph? }"),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Update a workflow."""
mandateId = _validateInstanceAccess(instanceId, context)
a2 = getAutomation2Interface(context.user, mandateId, instanceId)
updated = a2.updateWorkflow(workflowId, body)
if not updated:
raise HTTPException(status_code=404, detail="Workflow not found")
return updated
@router.delete("/{instanceId}/workflows/{workflowId}")
@limiter.limit("30/minute")
def delete_workflow(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
workflowId: str = Path(..., description="Workflow ID"),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Delete a workflow."""
mandateId = _validateInstanceAccess(instanceId, context)
a2 = getAutomation2Interface(context.user, mandateId, instanceId)
if not a2.deleteWorkflow(workflowId):
raise HTTPException(status_code=404, detail="Workflow not found")
return {"success": True}
@router.post("/{instanceId}/workflows/{workflowId}/webhooks/{entryPointId}")
@limiter.limit("60/minute")
async def post_workflow_webhook(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
workflowId: str = Path(..., description="Workflow ID"),
entryPointId: str = Path(..., description="Entry point ID (kind must be webhook)"),
body: dict = Body(default_factory=dict),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""
Invoke a workflow via a webhook entry point. Optional shared secret in
X-Automation2-Webhook-Secret or X-Webhook-Secret when config.webhookSecret is set.
"""
mandateId = _validateInstanceAccess(instanceId, context)
userId = str(context.user.id) if context.user else None
a2 = getAutomation2Interface(context.user, mandateId, instanceId)
wf = a2.getWorkflow(workflowId)
if not wf or not wf.get("graph"):
raise HTTPException(status_code=404, detail="Workflow not found")
inv = find_invocation(wf, entryPointId)
if not inv:
raise HTTPException(status_code=404, detail="Entry point not found")
if inv.get("kind") != "webhook":
raise HTTPException(status_code=400, detail="Entry point is not a webhook")
if not inv.get("enabled", True):
raise HTTPException(status_code=400, detail="Entry point is disabled")
cfg = inv.get("config") or {}
secret = cfg.get("webhookSecret")
if secret:
hdr = request.headers.get("X-Automation2-Webhook-Secret") or request.headers.get(
"X-Webhook-Secret"
)
if hdr != str(secret):
raise HTTPException(status_code=403, detail="Invalid webhook secret")
services = getAutomation2Services(
context.user,
mandateId=mandateId,
featureInstanceId=instanceId,
)
from modules.workflows.processing.shared.methodDiscovery import discoverMethods
discoverMethods(services)
title = inv.get("title") or {}
label = ""
if isinstance(title, dict):
label = title.get("en") or title.get("de") or ""
elif isinstance(title, str):
label = title
pl = body if isinstance(body, dict) else {}
base = default_run_envelope(
"webhook",
entry_point_id=inv.get("id"),
entry_point_label=label or None,
payload=pl,
raw={"httpBody": body},
)
run_env = normalize_run_envelope(base, user_id=userId)
result = await executeGraph(
graph=wf["graph"],
services=services,
workflowId=workflowId,
instanceId=instanceId,
userId=userId,
mandateId=mandateId,
automation2_interface=a2,
run_envelope=run_env,
)
return result
@router.post("/{instanceId}/workflows/{workflowId}/forms/{entryPointId}/submit")
@limiter.limit("60/minute")
async def post_workflow_form_submit(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
workflowId: str = Path(..., description="Workflow ID"),
entryPointId: str = Path(..., description="Entry point ID (kind must be form)"),
body: dict = Body(default_factory=dict),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Form-style submit: same as execute with trigger.type form and payload from body."""
mandateId = _validateInstanceAccess(instanceId, context)
userId = str(context.user.id) if context.user else None
a2 = getAutomation2Interface(context.user, mandateId, instanceId)
wf = a2.getWorkflow(workflowId)
if not wf or not wf.get("graph"):
raise HTTPException(status_code=404, detail="Workflow not found")
inv = find_invocation(wf, entryPointId)
if not inv:
raise HTTPException(status_code=404, detail="Entry point not found")
if inv.get("kind") != "form":
raise HTTPException(status_code=400, detail="Entry point is not a form")
if not inv.get("enabled", True):
raise HTTPException(status_code=400, detail="Entry point is disabled")
services = getAutomation2Services(
context.user,
mandateId=mandateId,
featureInstanceId=instanceId,
)
from modules.workflows.processing.shared.methodDiscovery import discoverMethods
discoverMethods(services)
title = inv.get("title") or {}
label = ""
if isinstance(title, dict):
label = title.get("en") or title.get("de") or ""
elif isinstance(title, str):
label = title
pl = body if isinstance(body, dict) else {}
base = default_run_envelope(
"form",
entry_point_id=inv.get("id"),
entry_point_label=label or None,
payload=pl,
raw={"formBody": body},
)
run_env = normalize_run_envelope(base, user_id=userId)
result = await executeGraph(
graph=wf["graph"],
services=services,
workflowId=workflowId,
instanceId=instanceId,
userId=userId,
mandateId=mandateId,
automation2_interface=a2,
run_envelope=run_env,
)
return result
# -------------------------------------------------------------------------
# Runs and Resume
# -------------------------------------------------------------------------
@router.get("/{instanceId}/runs/completed")
@limiter.limit("60/minute")
def get_completed_runs(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
limit: int = Query(20, ge=1, le=50),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Get recently completed runs with output (for Tasks page output section)."""
mandateId = _validateInstanceAccess(instanceId, context)
a2 = getAutomation2Interface(context.user, mandateId, instanceId)
runs = a2.getRecentCompletedRuns(limit=limit)
return {"runs": runs}
@router.get("/{instanceId}/workflows/{workflowId}/runs")
@limiter.limit("60/minute")
def get_workflow_runs(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
workflowId: str = Path(..., description="Workflow ID"),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Get runs for a workflow."""
mandateId = _validateInstanceAccess(instanceId, context)
a2 = getAutomation2Interface(context.user, mandateId, instanceId)
if not a2.getWorkflow(workflowId):
raise HTTPException(status_code=404, detail="Workflow not found")
runs = a2.getRunsByWorkflow(workflowId)
return {"runs": runs}
@router.post("/{instanceId}/runs/{runId}/resume")
@limiter.limit("30/minute")
async def resume_run(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
runId: str = Path(..., description="Run ID"),
body: dict = Body(..., description="{ taskId, result }"),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Resume a paused run after task completion."""
mandateId = _validateInstanceAccess(instanceId, context)
a2 = getAutomation2Interface(context.user, mandateId, instanceId)
run = a2.getRun(runId)
if not run:
raise HTTPException(status_code=404, detail="Run not found")
taskId = body.get("taskId")
result = body.get("result")
if not taskId or result is None:
raise HTTPException(status_code=400, detail="taskId and result required")
task = a2.getTask(taskId)
if not task or task.get("runId") != runId:
raise HTTPException(status_code=404, detail="Task not found")
if task.get("status") != "pending":
raise HTTPException(status_code=400, detail="Task already completed")
a2.updateTask(taskId, status="completed", result=result)
nodeId = task.get("nodeId")
nodeOutputs = dict(run.get("nodeOutputs") or {})
nodeOutputs[nodeId] = result
runContext = run.get("context") or {}
connectionMap = runContext.get("connectionMap", {})
inputSources = runContext.get("inputSources", {})
workflowId = run.get("workflowId")
wf = a2.getWorkflow(workflowId) if workflowId else None
if not wf or not wf.get("graph"):
raise HTTPException(status_code=400, detail="Workflow graph not found")
graph = wf["graph"]
services = getAutomation2Services(context.user, mandateId=mandateId, featureInstanceId=instanceId)
resume_result = await executeGraph(
graph=graph,
services=services,
workflowId=workflowId,
instanceId=instanceId,
userId=str(context.user.id) if context.user else None,
mandateId=mandateId,
automation2_interface=a2,
initialNodeOutputs=nodeOutputs,
startAfterNodeId=nodeId,
runId=runId,
)
return resume_result
# -------------------------------------------------------------------------
# Tasks
# -------------------------------------------------------------------------
@router.get("/{instanceId}/tasks")
@limiter.limit("60/minute")
def get_tasks(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
workflowId: str = Query(None, description="Filter by workflow ID"),
status: str = Query(None, description="Filter: pending, completed, rejected"),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Get tasks - by default those assigned to current user, or all if no assignee filter.
Enriches each task with workflowLabel and createdAt (from sysCreatedAt).
"""
mandateId = _validateInstanceAccess(instanceId, context)
a2 = getAutomation2Interface(context.user, mandateId, instanceId)
assigneeId = str(context.user.id) if context.user else None
items = a2.getTasks(workflowId=workflowId, status=status, assigneeId=assigneeId)
workflows = {w["id"]: w for w in a2.getWorkflows()}
enriched = []
for t in items:
wf = workflows.get(t.get("workflowId") or "")
enriched.append({
**t,
"workflowLabel": wf.get("label", t.get("workflowId", "")) if wf else t.get("workflowId", ""),
"createdAt": t.get("sysCreatedAt"),
})
return {"tasks": enriched}
@router.post("/{instanceId}/tasks/{taskId}/complete")
@limiter.limit("30/minute")
async def complete_task(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
taskId: str = Path(..., description="Task ID"),
body: dict = Body(..., description="{ result }"),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Complete a task and resume the run."""
mandateId = _validateInstanceAccess(instanceId, context)
a2 = getAutomation2Interface(context.user, mandateId, instanceId)
task = a2.getTask(taskId)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
runId = task.get("runId")
result = body.get("result")
if result is None:
raise HTTPException(status_code=400, detail="result required")
run = a2.getRun(runId)
if not run:
raise HTTPException(status_code=404, detail="Run not found")
if task.get("status") != "pending":
raise HTTPException(status_code=400, detail="Task already completed")
a2.updateTask(taskId, status="completed", result=result)
nodeId = task.get("nodeId")
nodeOutputs = dict(run.get("nodeOutputs") or {})
nodeOutputs[nodeId] = result
workflowId = run.get("workflowId")
wf = a2.getWorkflow(workflowId) if workflowId else None
if not wf or not wf.get("graph"):
raise HTTPException(status_code=400, detail="Workflow graph not found")
graph = wf["graph"]
services = getAutomation2Services(context.user, mandateId=mandateId, featureInstanceId=instanceId)
return await executeGraph(
graph=graph,
services=services,
workflowId=workflowId,
instanceId=instanceId,
userId=str(context.user.id) if context.user else None,
mandateId=mandateId,
automation2_interface=a2,
initialNodeOutputs=nodeOutputs,
startAfterNodeId=nodeId,
runId=runId,
)

View file

@ -462,11 +462,7 @@ class AICenterChatModel(BaseChatModel):
elif isinstance(args_schema, BaseModel):
# It's a Pydantic model instance
if hasattr(args_schema, "model_dump"):
# Pydantic v2
parameters = args_schema.model_dump()
elif hasattr(args_schema, "dict"):
# Pydantic v1
parameters = args_schema.dict()
elif hasattr(args_schema, "schema"):
# Has schema method (might be a class)
try:

View file

@ -12,14 +12,14 @@ logger = logging.getLogger(__name__)
# Feature metadata
FEATURE_CODE = "chatbot"
FEATURE_LABEL = {"en": "Chatbot", "de": "Chatbot", "fr": "Chatbot"}
FEATURE_LABEL = "Chatbot"
FEATURE_ICON = "mdi-robot"
# UI Objects for RBAC catalog
UI_OBJECTS = [
{
"objectKey": "ui.feature.chatbot.conversations",
"label": {"en": "Conversations", "de": "Konversationen", "fr": "Conversations"},
"label": "Konversationen",
"meta": {"area": "conversations"}
}
]
@ -28,22 +28,22 @@ UI_OBJECTS = [
RESOURCE_OBJECTS = [
{
"objectKey": "resource.feature.chatbot.startStream",
"label": {"en": "Start Chat (Stream)", "de": "Chat starten (Stream)", "fr": "Démarrer chat (Stream)"},
"label": "Chat starten (Stream)",
"meta": {"endpoint": "/api/chatbot/{instanceId}/start/stream", "method": "POST"}
},
{
"objectKey": "resource.feature.chatbot.stop",
"label": {"en": "Stop Chat", "de": "Chat stoppen", "fr": "Arrêter chat"},
"label": "Chat stoppen",
"meta": {"endpoint": "/api/chatbot/{instanceId}/stop/{workflowId}", "method": "POST"}
},
{
"objectKey": "resource.feature.chatbot.threads",
"label": {"en": "Get Threads", "de": "Threads abrufen", "fr": "Récupérer threads"},
"label": "Threads abrufen",
"meta": {"endpoint": "/api/chatbot/{instanceId}/threads", "method": "GET"}
},
{
"objectKey": "resource.feature.chatbot.delete",
"label": {"en": "Delete Chat", "de": "Chat löschen", "fr": "Supprimer chat"},
"label": "Chat löschen",
"meta": {"endpoint": "/api/chatbot/{instanceId}/{workflowId}", "method": "DELETE"}
},
]
@ -74,11 +74,7 @@ REQUIRED_SERVICES = [
TEMPLATE_ROLES = [
{
"roleLabel": "chatbot-viewer",
"description": {
"en": "Chatbot Viewer - View chat threads (read-only)",
"de": "Chatbot Betrachter - Chat-Threads ansehen (nur lesen)",
"fr": "Visualiseur Chatbot - Consulter les threads (lecture seule)"
},
"description": "Chatbot Betrachter - Chat-Threads ansehen (nur lesen)",
"accessRules": [
# UI: only threads view, NO active chat
{"context": "UI", "item": "ui.feature.chatbot.threads", "view": True},
@ -90,11 +86,7 @@ TEMPLATE_ROLES = [
},
{
"roleLabel": "chatbot-user",
"description": {
"en": "Chatbot User - Use the chatbot and manage own threads",
"de": "Chatbot Benutzer - Chatbot nutzen und eigene Threads verwalten",
"fr": "Utilisateur Chatbot - Utiliser le chatbot et gérer ses threads"
},
"description": "Chatbot Benutzer - Chatbot nutzen und eigene Threads verwalten",
"accessRules": [
# UI: full access to all views
{"context": "UI", "item": "ui.feature.chatbot.conversations", "view": True},
@ -110,11 +102,7 @@ TEMPLATE_ROLES = [
},
{
"roleLabel": "chatbot-admin",
"description": {
"en": "Chatbot Admin - Full access to all chatbot features",
"de": "Chatbot Admin - Vollzugriff auf alle Chatbot-Funktionen",
"fr": "Administrateur Chatbot - Accès complet à toutes les fonctions chatbot"
},
"description": "Chatbot Admin - Vollzugriff auf alle Chatbot-Funktionen",
"accessRules": [
# Full UI access
{"context": "UI", "item": None, "view": True},
@ -203,8 +191,8 @@ def getChatStreamingHelper():
def __get_placeholder_user():
"""Placeholder user for contexts that only need service resolution (e.g. ChatStreamingHelper)."""
from modules.datamodels.datamodelUam import User
return User(id="system", username="system", email=None, fullName="System Placeholder")
from modules.interfaces.interfaceDbApp import getRootInterface
return getRootInterface().currentUser
def getEventManager(user, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None):
@ -391,6 +379,7 @@ def _syncTemplateRolesToDb() -> int:
try:
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext
from modules.datamodels.datamodelUtils import coerce_text_multilingual
rootInterface = getRootInterface()
@ -412,7 +401,7 @@ def _syncTemplateRolesToDb() -> int:
# Create new template role
newRole = Role(
roleLabel=roleLabel,
description=roleTemplate.get("description", {}),
description=coerce_text_multilingual(roleTemplate.get("description", {})),
featureCode=FEATURE_CODE,
mandateId=None, # Global template
featureInstanceId=None,

View file

@ -32,6 +32,8 @@ from modules.features.chatbot.interfaceFeatureChatbot import ChatbotConversation
# Import chatbot feature
from modules.features.chatbot import chatProcess
from modules.features.chatbot.mainChatbot import getEventManager
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeFeatureChatbot")
# Pre-warm AI connectors when this router loads (before first request).
# Ensures connectors are ready; avoids 48 s delay on first chatbot message.
@ -150,8 +152,6 @@ def get_chatbot_threads(
if hasattr(workflow, 'model_dump'):
workflow_dict = workflow.model_dump()
elif hasattr(workflow, 'dict'):
workflow_dict = workflow.dict()
elif isinstance(workflow, dict):
workflow_dict = dict(workflow)
else:
@ -267,7 +267,7 @@ async def stream_chatbot_start(
if not workflow:
raise HTTPException(
status_code=500,
detail="Failed to create or load workflow"
detail=routeApiMsg("Failed to create or load workflow")
)
# Get event queue for the workflow
@ -317,11 +317,11 @@ async def stream_chatbot_start(
# Emit filtered items
for item in filtered_items:
# Convert Pydantic models to dicts for JSON serialization
_inner = item.get("item")
serializable_item = {
"type": item.get("type"),
"createdAt": item.get("createdAt"),
"item": item.get("item").model_dump() if hasattr(item.get("item"), "model_dump") else (item.get("item").dict() if hasattr(item.get("item"), "dict") else item.get("item"))
"item": _inner.model_dump() if _inner is not None and hasattr(_inner, "model_dump") else _inner,
}
# Emit item directly in exact chatData format: {type, createdAt, item}
yield f"data: {json.dumps(serializable_item)}\n\n"
@ -399,9 +399,6 @@ async def stream_chatbot_start(
if hasattr(item_obj, "model_dump"):
chatdata_item = chatdata_item.copy()
chatdata_item["item"] = item_obj.model_dump()
elif hasattr(item_obj, "dict"):
chatdata_item = chatdata_item.copy()
chatdata_item["item"] = item_obj.dict()
yield f"data: {json.dumps(chatdata_item)}\n\n"
# Handle completion/stopped events to close stream
@ -567,7 +564,7 @@ def delete_chatbot(
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to delete workflow"
detail=routeApiMsg("Failed to delete workflow")
)
return {

View file

@ -278,7 +278,7 @@ async def _update_conversation_name_async(
# Emit stat event so frontend can refresh thread list/title
workflow = interfaceDbChat.getWorkflow(workflowId)
if workflow:
wf_dict = workflow.model_dump() if hasattr(workflow, "model_dump") else workflow.dict()
wf_dict = workflow.model_dump()
await event_manager.emit_event(
context_id=workflowId,
event_type="chatdata",
@ -966,7 +966,7 @@ async def _bridge_chatbot_events(
data={
"type": "message",
"createdAt": message_timestamp,
"item": last_message.dict()
"item": last_message.model_dump()
},
event_category="chat"
)
@ -1005,7 +1005,7 @@ async def _bridge_chatbot_events(
data={
"type": "message",
"createdAt": message_timestamp,
"item": assistant_msg.dict()
"item": assistant_msg.model_dump()
},
event_category="chat"
)
@ -1089,7 +1089,7 @@ async def _bridge_chatbot_events(
data={
"type": "message",
"createdAt": message_timestamp,
"item": error_msg.dict()
"item": error_msg.model_dump()
},
event_category="chat"
)
@ -1490,7 +1490,7 @@ async def _processChatbotMessageLangGraph(
data={
"type": "message",
"createdAt": message_timestamp,
"item": errorMessage.dict()
"item": errorMessage.model_dump()
},
event_category="chat"
)

View file

@ -13,6 +13,7 @@ from modules.datamodels.datamodelUam import User
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.shared.timeUtils import getIsoTimestamp
from modules.shared.configuration import APP_CONFIG
from modules.shared.i18nRegistry import resolveText, t
from .datamodelCommcoach import (
CoachingContext, CoachingContextStatus,
@ -412,9 +413,21 @@ def _calcGoalProgress(goalsRaw) -> Optional[int]:
return round(done / len(goals) * 100)
_LEVELS = [
(50, 5, "master", "Meister"),
(25, 4, "expert", "Experte"),
(10, 3, "advanced", "Fortgeschritten"),
(3, 2, "engaged", "Engagiert"),
]
t("Meister")
t("Experte")
t("Fortgeschritten")
t("Engagiert")
t("Einsteiger")
def _calcLevel(totalSessions: int) -> Dict[str, Any]:
levels = [(50, 5, "Meister"), (25, 4, "Experte"), (10, 3, "Fortgeschritten"), (3, 2, "Engagiert")]
for threshold, number, label in levels:
for threshold, number, code, labelKey in _LEVELS:
if totalSessions >= threshold:
return {"number": number, "label": label, "totalSessions": totalSessions}
return {"number": 1, "label": "Einsteiger", "totalSessions": totalSessions}
return {"number": number, "code": code, "label": resolveText(labelKey), "totalSessions": totalSessions}
return {"number": 1, "code": "beginner", "label": resolveText("Einsteiger"), "totalSessions": totalSessions}

View file

@ -11,23 +11,23 @@ from typing import Dict, List, Any
logger = logging.getLogger(__name__)
FEATURE_CODE = "commcoach"
FEATURE_LABEL = {"en": "Communication Coach", "de": "Kommunikations-Coach", "fr": "Coach Communication"}
FEATURE_LABEL = "Kommunikations-Coach"
FEATURE_ICON = "mdi-account-voice"
UI_OBJECTS = [
{
"objectKey": "ui.feature.commcoach.dashboard",
"label": {"en": "Dashboard", "de": "Dashboard", "fr": "Tableau de bord"},
"label": "Dashboard",
"meta": {"area": "dashboard"}
},
{
"objectKey": "ui.feature.commcoach.coaching",
"label": {"en": "Coaching & Dossier", "de": "Coaching & Dossier", "fr": "Coaching & Dossier"},
"label": "Arbeitsthemen",
"meta": {"area": "coaching"}
},
{
"objectKey": "ui.feature.commcoach.settings",
"label": {"en": "Settings", "de": "Einstellungen", "fr": "Parametres"},
"label": "Einstellungen",
"meta": {"area": "settings"}
},
]
@ -35,7 +35,7 @@ UI_OBJECTS = [
DATA_OBJECTS = [
{
"objectKey": "data.feature.commcoach.CoachingContext",
"label": {"en": "Coaching Context", "de": "Coaching-Kontext", "fr": "Contexte coaching"},
"label": "Coaching-Kontext",
"meta": {
"table": "CoachingContext",
"fields": ["id", "title", "category", "status"],
@ -45,7 +45,7 @@ DATA_OBJECTS = [
},
{
"objectKey": "data.feature.commcoach.CoachingSession",
"label": {"en": "Coaching Session", "de": "Coaching-Session", "fr": "Session coaching"},
"label": "Coaching-Session",
"meta": {
"table": "CoachingSession",
"fields": ["id", "contextId", "status", "summary"],
@ -55,12 +55,12 @@ DATA_OBJECTS = [
},
{
"objectKey": "data.feature.commcoach.CoachingMessage",
"label": {"en": "Coaching Message", "de": "Coaching-Nachricht", "fr": "Message coaching"},
"label": "Coaching-Nachricht",
"meta": {"table": "CoachingMessage", "fields": ["id", "sessionId", "role", "content"]}
},
{
"objectKey": "data.feature.commcoach.CoachingTask",
"label": {"en": "Coaching Task", "de": "Coaching-Aufgabe", "fr": "Tache coaching"},
"label": "Coaching-Aufgabe",
"meta": {
"table": "CoachingTask",
"fields": ["id", "contextId", "title", "status"],
@ -70,27 +70,27 @@ DATA_OBJECTS = [
},
{
"objectKey": "data.feature.commcoach.CoachingScore",
"label": {"en": "Coaching Score", "de": "Coaching-Score", "fr": "Score coaching"},
"label": "Coaching-Score",
"meta": {"table": "CoachingScore", "fields": ["id", "dimension", "score", "trend"]}
},
{
"objectKey": "data.feature.commcoach.CoachingUserProfile",
"label": {"en": "User Profile", "de": "Benutzerprofil", "fr": "Profil utilisateur"},
"label": "Benutzerprofil",
"meta": {"table": "CoachingUserProfile", "fields": ["id", "userId", "dailyReminderEnabled"]}
},
{
"objectKey": "data.feature.commcoach.CoachingPersona",
"label": {"en": "Coaching Persona", "de": "Coaching-Persona", "fr": "Persona coaching"},
"label": "Coaching-Persona",
"meta": {"table": "CoachingPersona", "fields": ["id", "key", "label", "gender"]}
},
{
"objectKey": "data.feature.commcoach.CoachingBadge",
"label": {"en": "Coaching Badge", "de": "Coaching-Auszeichnung", "fr": "Badge coaching"},
"label": "Coaching-Auszeichnung",
"meta": {"table": "CoachingBadge", "fields": ["id", "badgeKey", "awardedAt"]}
},
{
"objectKey": "data.feature.commcoach.*",
"label": {"en": "All CommCoach Data", "de": "Alle CommCoach-Daten", "fr": "Toutes les donnees CommCoach"},
"label": "Alle CommCoach-Daten",
"meta": {"wildcard": True}
},
]
@ -98,27 +98,27 @@ DATA_OBJECTS = [
RESOURCE_OBJECTS = [
{
"objectKey": "resource.feature.commcoach.context.create",
"label": {"en": "Create Context", "de": "Kontext erstellen", "fr": "Creer contexte"},
"label": "Kontext erstellen",
"meta": {"endpoint": "/api/commcoach/{instanceId}/contexts", "method": "POST"}
},
{
"objectKey": "resource.feature.commcoach.context.archive",
"label": {"en": "Archive Context", "de": "Kontext archivieren", "fr": "Archiver contexte"},
"label": "Kontext archivieren",
"meta": {"endpoint": "/api/commcoach/{instanceId}/contexts/{contextId}/archive", "method": "POST"}
},
{
"objectKey": "resource.feature.commcoach.session.start",
"label": {"en": "Start Session", "de": "Session starten", "fr": "Demarrer session"},
"label": "Session starten",
"meta": {"endpoint": "/api/commcoach/{instanceId}/contexts/{contextId}/sessions/start", "method": "POST"}
},
{
"objectKey": "resource.feature.commcoach.session.complete",
"label": {"en": "Complete Session", "de": "Session abschliessen", "fr": "Terminer session"},
"label": "Session abschliessen",
"meta": {"endpoint": "/api/commcoach/{instanceId}/sessions/{sessionId}/complete", "method": "POST"}
},
{
"objectKey": "resource.feature.commcoach.task.manage",
"label": {"en": "Manage Tasks", "de": "Aufgaben verwalten", "fr": "Gerer taches"},
"label": "Aufgaben verwalten",
"meta": {"endpoint": "/api/commcoach/{instanceId}/contexts/{contextId}/tasks", "method": "POST"}
},
]
@ -126,30 +126,22 @@ RESOURCE_OBJECTS = [
TEMPLATE_ROLES = [
{
"roleLabel": "commcoach-viewer",
"description": {
"en": "Communication Coach Viewer - View coaching data (read-only)",
"de": "Kommunikations-Coach Betrachter - Coaching-Daten ansehen (nur lesen)",
"fr": "Visualiseur Coach Communication - Consulter les donnees coaching (lecture seule)",
},
"description": "Kommunikations-Coach Betrachter - Coaching-Daten ansehen (nur lesen)",
"accessRules": [
{"context": "UI", "item": "ui.feature.commcoach.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.commcoach.coaching", "view": True},
{"context": "UI", "item": "ui.feature.commcoach.dossier", "view": True},
{"context": "UI", "item": "ui.feature.commcoach.settings", "view": True},
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
# Viewer: keine RESOURCE-Endpunkte (Mutationen); Regel explizit fuer konsistente Kontext-Matrix
{"context": "RESOURCE", "item": None, "view": False},
],
},
{
"roleLabel": "commcoach-user",
"description": {
"en": "Communication Coach User - Can manage own coaching contexts and sessions",
"de": "Kommunikations-Coach Benutzer - Kann eigene Coaching-Kontexte und Sessions verwalten",
"fr": "Utilisateur Coach Communication - Peut gerer ses propres contextes et sessions",
},
"description": "Kommunikations-Coach Benutzer - Kann eigene Coaching-Kontexte und Sessions verwalten",
"accessRules": [
{"context": "UI", "item": "ui.feature.commcoach.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.commcoach.coaching", "view": True},
{"context": "UI", "item": "ui.feature.commcoach.dossier", "view": True},
{"context": "UI", "item": "ui.feature.commcoach.settings", "view": True},
{"context": "DATA", "item": "data.feature.commcoach.CoachingContext", "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"},
{"context": "DATA", "item": "data.feature.commcoach.CoachingSession", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
@ -166,11 +158,7 @@ TEMPLATE_ROLES = [
},
{
"roleLabel": "commcoach-admin",
"description": {
"en": "Communication Coach Admin - All UI and API actions; data scoped to own records",
"de": "Kommunikations-Coach Admin - Alle UI- und API-Aktionen; Daten nur eigene Datensaetze",
"fr": "Administrateur Coach Communication - Toute l'UI et les API; donnees propres",
},
"description": "Kommunikations-Coach Admin - Alle UI- und API-Aktionen; Daten nur eigene Datensaetze",
"accessRules": [
{"context": "UI", "item": None, "view": True},
{"context": "RESOURCE", "item": None, "view": True},
@ -248,9 +236,9 @@ def _seedBuiltinPersonas():
try:
from .serviceCommcoachPersonas import seedBuiltinPersonas
from .interfaceFeatureCommcoach import getInterface
from modules.datamodels.datamodelUam import User
from modules.interfaces.interfaceDbApp import getRootInterface
systemUser = User(id="system", username="system", email="system@poweron.swiss")
systemUser = getRootInterface().currentUser
interface = getInterface(systemUser)
seedBuiltinPersonas(interface)
except Exception as e:
@ -271,6 +259,7 @@ def _syncTemplateRolesToDb() -> int:
try:
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext
from modules.datamodels.datamodelUtils import coerce_text_multilingual
rootInterface = getRootInterface()
existingRoles = rootInterface.getRolesByFeatureCode(FEATURE_CODE)
@ -287,7 +276,7 @@ def _syncTemplateRolesToDb() -> int:
else:
newRole = Role(
roleLabel=roleLabel,
description=roleTemplate.get("description", {}),
description=coerce_text_multilingual(roleTemplate.get("description", {})),
featureCode=FEATURE_CODE,
mandateId=None,
featureInstanceId=None,

View file

@ -33,6 +33,8 @@ from .datamodelCommcoach import (
StartSessionRequest, CreatePersonaRequest, UpdatePersonaRequest,
)
from .serviceCommcoach import CommcoachService, emitSessionEvent, getSessionEventQueue, cleanupSessionEvents
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeFeatureCommcoach")
logger = logging.getLogger(__name__)
_activeProcessTasks: dict = {}
@ -78,14 +80,14 @@ def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str:
raise HTTPException(status_code=404, detail=f"Feature instance '{instanceId}' not found")
mandateId = instance.get("mandateId") if isinstance(instance, dict) else getattr(instance, "mandateId", None)
if not mandateId:
raise HTTPException(status_code=500, detail="Feature instance has no mandateId")
raise HTTPException(status_code=500, detail=routeApiMsg("Feature instance has no mandateId"))
return str(mandateId)
def _validateOwnership(record: dict, context: RequestContext, fieldName: str = "userId") -> None:
"""Strict ownership check. SysAdmin does NOT bypass for content access."""
if record.get(fieldName) != str(context.user.id):
raise HTTPException(status_code=404, detail="Not found")
raise HTTPException(status_code=404, detail=routeApiMsg("Not found"))
# =========================================================================
@ -158,7 +160,7 @@ async def getContext(
ctx = interface.getContext(contextId)
if not ctx:
raise HTTPException(status_code=404, detail="Context not found")
raise HTTPException(status_code=404, detail=routeApiMsg("Context not found"))
_validateOwnership(ctx, context)
tasks = interface.getTasks(contextId, userId)
@ -187,7 +189,7 @@ async def updateContext(
ctx = interface.getContext(contextId)
if not ctx:
raise HTTPException(status_code=404, detail="Context not found")
raise HTTPException(status_code=404, detail=routeApiMsg("Context not found"))
_validateOwnership(ctx, context)
updates = body.model_dump(exclude_none=True)
@ -208,7 +210,7 @@ async def deleteContext(
ctx = interface.getContext(contextId)
if not ctx:
raise HTTPException(status_code=404, detail="Context not found")
raise HTTPException(status_code=404, detail=routeApiMsg("Context not found"))
_validateOwnership(ctx, context)
interface.deleteContext(contextId)
@ -228,7 +230,7 @@ async def archiveContext(
ctx = interface.getContext(contextId)
if not ctx:
raise HTTPException(status_code=404, detail="Context not found")
raise HTTPException(status_code=404, detail=routeApiMsg("Context not found"))
_validateOwnership(ctx, context)
updated = interface.updateContext(contextId, {"status": CoachingContextStatus.ARCHIVED.value})
@ -249,7 +251,7 @@ async def activateContext(
ctx = interface.getContext(contextId)
if not ctx:
raise HTTPException(status_code=404, detail="Context not found")
raise HTTPException(status_code=404, detail=routeApiMsg("Context not found"))
_validateOwnership(ctx, context)
updated = interface.updateContext(contextId, {"status": CoachingContextStatus.ACTIVE.value})
@ -274,7 +276,7 @@ async def listSessions(
ctx = interface.getContext(contextId)
if not ctx:
raise HTTPException(status_code=404, detail="Context not found")
raise HTTPException(status_code=404, detail=routeApiMsg("Context not found"))
_validateOwnership(ctx, context)
sessions = interface.getSessions(contextId, userId)
@ -297,7 +299,7 @@ async def startSession(
ctx = interface.getContext(contextId)
if not ctx:
raise HTTPException(status_code=404, detail="Context not found")
raise HTTPException(status_code=404, detail=routeApiMsg("Context not found"))
_validateOwnership(ctx, context)
activeSession = interface.getActiveSession(contextId, userId)
@ -420,7 +422,7 @@ async def getSession(
session = interface.getSession(sessionId)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
raise HTTPException(status_code=404, detail=routeApiMsg("Session not found"))
_validateOwnership(session, context)
messages = interface.getMessages(sessionId)
@ -441,7 +443,7 @@ async def completeSession(
session = interface.getSession(sessionId)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
raise HTTPException(status_code=404, detail=routeApiMsg("Session not found"))
_validateOwnership(session, context)
if session.get("status") != CoachingSessionStatus.ACTIVE.value:
@ -466,7 +468,7 @@ async def cancelSession(
session = interface.getSession(sessionId)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
raise HTTPException(status_code=404, detail=routeApiMsg("Session not found"))
_validateOwnership(session, context)
from modules.shared.timeUtils import getIsoTimestamp
@ -496,11 +498,11 @@ async def sendMessageStream(
session = interface.getSession(sessionId)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
raise HTTPException(status_code=404, detail=routeApiMsg("Session not found"))
_validateOwnership(session, context)
if session.get("status") != CoachingSessionStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="Session is not active")
raise HTTPException(status_code=400, detail=routeApiMsg("Session is not active"))
contextId = session.get("contextId")
service = CommcoachService(context.user, mandateId, instanceId)
@ -572,15 +574,15 @@ async def sendAudioStream(
session = interface.getSession(sessionId)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
raise HTTPException(status_code=404, detail=routeApiMsg("Session not found"))
_validateOwnership(session, context)
if session.get("status") != CoachingSessionStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="Session is not active")
raise HTTPException(status_code=400, detail=routeApiMsg("Session is not active"))
audioBody = await request.body()
if not audioBody:
raise HTTPException(status_code=400, detail="No audio data received")
raise HTTPException(status_code=400, detail=routeApiMsg("No audio data received"))
from .serviceCommcoach import _getUserVoicePrefs
language, _ = _getUserVoicePrefs(str(context.user.id), mandateId)
@ -640,7 +642,7 @@ async def streamSession(
session = interface.getSession(sessionId)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
raise HTTPException(status_code=404, detail=routeApiMsg("Session not found"))
_validateOwnership(session, context)
async def _eventGenerator():
@ -708,7 +710,7 @@ async def createTask(
ctx = interface.getContext(contextId)
if not ctx:
raise HTTPException(status_code=404, detail="Context not found")
raise HTTPException(status_code=404, detail=routeApiMsg("Context not found"))
_validateOwnership(ctx, context)
taskData = CoachingTask(
@ -739,7 +741,7 @@ async def updateTask(
task = interface.getTask(taskId)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
raise HTTPException(status_code=404, detail=routeApiMsg("Task not found"))
_validateOwnership(task, context)
updates = body.model_dump(exclude_none=True)
@ -761,7 +763,7 @@ async def updateTaskStatus(
task = interface.getTask(taskId)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
raise HTTPException(status_code=404, detail=routeApiMsg("Task not found"))
_validateOwnership(task, context)
updates = {"status": body.status.value}
@ -786,7 +788,7 @@ async def deleteTask(
task = interface.getTask(taskId)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
raise HTTPException(status_code=404, detail=routeApiMsg("Task not found"))
_validateOwnership(task, context)
interface.deleteTask(taskId)
@ -867,7 +869,7 @@ async def exportDossier(
ctx = interface.getContext(contextId)
if not ctx:
raise HTTPException(status_code=404, detail="Context not found")
raise HTTPException(status_code=404, detail=routeApiMsg("Context not found"))
_validateOwnership(ctx, context)
tasks = interface.getTasks(contextId, userId)
@ -902,7 +904,7 @@ async def exportSession(
session = interface.getSession(sessionId)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
raise HTTPException(status_code=404, detail=routeApiMsg("Session not found"))
_validateOwnership(session, context)
contextId = session.get("contextId")
@ -983,9 +985,9 @@ async def updatePersonaRoute(
persona = interface.getPersona(personaId)
if not persona:
raise HTTPException(status_code=404, detail="Persona not found")
raise HTTPException(status_code=404, detail=routeApiMsg("Persona not found"))
if persona.get("category") == "builtin":
raise HTTPException(status_code=403, detail="Builtin personas cannot be edited")
raise HTTPException(status_code=403, detail=routeApiMsg("Builtin personas cannot be edited"))
_validateOwnership(persona, context)
updates = body.model_dump(exclude_none=True)
@ -1006,9 +1008,9 @@ async def deletePersonaRoute(
persona = interface.getPersona(personaId)
if not persona:
raise HTTPException(status_code=404, detail="Persona not found")
raise HTTPException(status_code=404, detail=routeApiMsg("Persona not found"))
if persona.get("category") == "builtin":
raise HTTPException(status_code=403, detail="Builtin personas cannot be deleted")
raise HTTPException(status_code=403, detail=routeApiMsg("Builtin personas cannot be deleted"))
_validateOwnership(persona, context)
interface.deletePersona(personaId)

View file

@ -7,6 +7,7 @@ Checks and awards badges after each session completion.
import logging
from typing import Dict, Any, List, Optional
from modules.shared.i18nRegistry import resolveText, t
logger = logging.getLogger(__name__)
@ -78,6 +79,34 @@ BADGE_DEFINITIONS: Dict[str, Dict[str, Any]] = {
},
}
# Register all badge labels/descriptions at import time for i18n xx base set
t("Erste Session")
t("Deine erste Coaching-Session abgeschlossen")
t("3-Tage-Serie")
t("3 Tage in Folge eine Session absolviert")
t("Wochenserie")
t("7 Tage in Folge eine Session absolviert")
t("Monatsserie")
t("30 Tage in Folge eine Session absolviert")
t("Engagiert")
t("5 Sessions abgeschlossen")
t("Fortgeschritten")
t("10 Sessions abgeschlossen")
t("Experte")
t("25 Sessions abgeschlossen")
t("Meister")
t("50 Sessions abgeschlossen")
t("Bestleistung")
t("Durchschnittsscore über 80 in einer Session")
t("Vielseitig")
t("3 verschiedene Coaching-Themen aktiv")
t("Rollenspieler")
t("Erste Roleplay-Session mit einer Persona abgeschlossen")
t("Ganzheitlich")
t("In allen 5 Kompetenz-Dimensionen bewertet")
t("Umsetzer")
t("10 Coaching-Aufgaben erledigt")
async def checkAndAwardBadges(interface, userId: str, mandateId: str, instanceId: str,
session: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]:
@ -135,8 +164,8 @@ async def checkAndAwardBadges(interface, userId: str, mandateId: str, instanceId
}
newBadge = interface.awardBadge(badgeData)
definition = BADGE_DEFINITIONS.get(badgeKey, {})
newBadge["label"] = definition.get("label", badgeKey)
newBadge["description"] = definition.get("description", "")
newBadge["label"] = resolveText(definition.get("label", badgeKey))
newBadge["description"] = resolveText(definition.get("description", ""))
newBadge["icon"] = definition.get("icon", "star")
awarded.append(newBadge)
logger.info(f"Badge '{badgeKey}' awarded to user {userId}")
@ -145,5 +174,12 @@ async def checkAndAwardBadges(interface, userId: str, mandateId: str, instanceId
def getBadgeDefinitions() -> Dict[str, Dict[str, Any]]:
"""Return all badge definitions for the frontend."""
return BADGE_DEFINITIONS
"""Return all badge definitions for the frontend (labels resolved via i18n)."""
resolved = {}
for key, defn in BADGE_DEFINITIONS.items():
resolved[key] = {
**defn,
"label": resolveText(defn["label"]),
"description": resolveText(defn["description"]),
}
return resolved

View file

@ -17,9 +17,8 @@ class TestFeatureMetadata:
assert FEATURE_CODE == "commcoach"
def test_featureLabel(self):
assert "de" in FEATURE_LABEL
assert "en" in FEATURE_LABEL
assert "Coach" in FEATURE_LABEL["de"]
assert isinstance(FEATURE_LABEL, str)
assert "Coach" in FEATURE_LABEL
def test_featureIcon(self):
assert FEATURE_ICON.startswith("mdi-")
@ -37,17 +36,17 @@ class TestFeatureDefinition:
class TestRbacObjects:
def test_uiObjectsExist(self):
objs = getUiObjects()
assert len(objs) >= 4
assert len(objs) >= 3
keys = [o["objectKey"] for o in objs]
assert "ui.feature.commcoach.dashboard" in keys
assert "ui.feature.commcoach.coaching" in keys
assert "ui.feature.commcoach.dossier" in keys
assert "ui.feature.commcoach.settings" in keys
def test_uiObjectsHaveLabels(self):
for obj in getUiObjects():
assert "label" in obj
assert "de" in obj["label"]
assert isinstance(obj["label"], str)
assert len(obj["label"]) > 0
def test_dataObjectsExist(self):
objs = getDataObjects()
@ -94,7 +93,7 @@ class TestTemplateRoles:
def test_roleHasDescription(self):
for role in getTemplateRoles():
assert "description" in role
assert "de" in role["description"]
assert isinstance(role["description"], str) and len(role["description"].strip()) > 0
def test_roleHasAccessRules(self):
for role in getTemplateRoles():

View file

@ -0,0 +1,2 @@
# Copyright (c) 2025 Patrick Motsch
# GraphicalEditor feature - n8n-style flow automation with visual editor

View file

@ -0,0 +1,403 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""GraphicalEditor models with Auto-prefix: AutoWorkflow, AutoVersion, AutoRun, AutoStepLog, AutoTask."""
from enum import Enum
from typing import Dict, Any, List, Optional
from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.i18nRegistry import i18nModel
import uuid
# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------
class AutoWorkflowStatus(str, Enum):
DRAFT = "draft"
PUBLISHED = "published"
ARCHIVED = "archived"
class AutoRunStatus(str, Enum):
RUNNING = "running"
PAUSED = "paused"
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
class AutoStepStatus(str, Enum):
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
SKIPPED = "skipped"
class AutoTaskStatus(str, Enum):
PENDING = "pending"
COMPLETED = "completed"
CANCELLED = "cancelled"
EXPIRED = "expired"
class AutoTemplateScope(str, Enum):
USER = "user"
INSTANCE = "instance"
MANDATE = "mandate"
SYSTEM = "system"
# ---------------------------------------------------------------------------
# AutoWorkflow
# ---------------------------------------------------------------------------
@i18nModel("Workflow")
class AutoWorkflow(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "ID"},
)
mandateId: str = Field(
description="Mandate ID",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Mandanten-ID"},
)
featureInstanceId: str = Field(
description="Feature instance ID",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Feature-Instanz-ID"},
)
label: str = Field(
description="User-friendly workflow name",
json_schema_extra={"frontend_type": "text", "frontend_required": True, "label": "Bezeichnung"},
)
description: Optional[str] = Field(
default=None,
description="Workflow description",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Beschreibung"},
)
tags: List[str] = Field(
default_factory=list,
description="Tags for categorization",
json_schema_extra={"frontend_type": "tags", "frontend_required": False, "label": "Tags"},
)
isTemplate: bool = Field(
default=False,
description="Whether this workflow is a template",
json_schema_extra={"frontend_type": "checkbox", "frontend_required": False, "label": "Ist Vorlage"},
)
templateSourceId: Optional[str] = Field(
default=None,
description="ID of the template this workflow was created from",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Vorlagen-Quelle"},
)
templateScope: Optional[str] = Field(
default=None,
description="Template scope: user, instance, mandate, system (AutoTemplateScope)",
json_schema_extra={"frontend_type": "select", "frontend_required": False, "label": "Vorlagen-Bereich"},
)
sharedReadOnly: bool = Field(
default=False,
description="If true, shared template is read-only for non-owners",
json_schema_extra={"frontend_type": "checkbox", "frontend_required": False, "label": "Freigabe nur-lesen"},
)
currentVersionId: Optional[str] = Field(
default=None,
description="ID of the currently published AutoVersion",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Aktuelle Version"},
)
active: bool = Field(
default=True,
description="Whether workflow is active",
json_schema_extra={"frontend_type": "checkbox", "frontend_required": False, "label": "Aktiv"},
)
eventId: Optional[str] = Field(
default=None,
description="Scheduler event ID for incremental sync",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Event-ID"},
)
notifyOnFailure: bool = Field(
default=True,
description="Send notification (in-app + email) when a run fails",
json_schema_extra={"frontend_type": "checkbox", "frontend_required": False, "label": "Bei Fehler benachrichtigen"},
)
# Legacy fields kept for backward compatibility during transition
graph: Dict[str, Any] = Field(
default_factory=dict,
description="Graph with nodes and connections (legacy; prefer AutoVersion.graph)",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Graph"},
)
invocations: List[Dict[str, Any]] = Field(
default_factory=list,
description="Entry points / starts (manual, form, schedule, webhook, ...)",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Starts / Einstiegspunkte"},
)
# ---------------------------------------------------------------------------
# AutoVersion
# ---------------------------------------------------------------------------
@i18nModel("Workflow-Version")
class AutoVersion(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "ID"},
)
workflowId: str = Field(
description="FK -> AutoWorkflow",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Workflow-ID"},
)
versionNumber: int = Field(
default=1,
description="Incrementing version number",
json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Version"},
)
status: str = Field(
default=AutoWorkflowStatus.DRAFT.value,
description="Version status: draft, published, archived",
json_schema_extra={"frontend_type": "select", "frontend_required": False, "label": "Status"},
)
graph: Dict[str, Any] = Field(
default_factory=dict,
description="Graph with nodes and connections (incl. node parameters)",
json_schema_extra={"frontend_type": "textarea", "frontend_required": True, "label": "Graph"},
)
invocations: List[Dict[str, Any]] = Field(
default_factory=list,
description="Entry points / starts for this version",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Einstiegspunkte"},
)
publishedAt: Optional[float] = Field(
default=None,
description="Timestamp when version was published",
json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False, "label": "Veröffentlicht am"},
)
publishedBy: Optional[str] = Field(
default=None,
description="User ID who published this version",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Veröffentlicht von"},
)
# ---------------------------------------------------------------------------
# AutoRun
# ---------------------------------------------------------------------------
@i18nModel("Workflow-Ausführung")
class AutoRun(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "ID"},
)
workflowId: str = Field(
description="Workflow ID",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Workflow-ID"},
)
label: Optional[str] = Field(
default=None,
description="Human-readable run label, set at creation from workflow name or caller",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Bezeichnung"},
)
mandateId: Optional[str] = Field(
default=None,
description="Mandate ID for cross-feature querying",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Mandanten-ID"},
)
ownerId: Optional[str] = Field(
default=None,
description="User ID who triggered this run",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Auslöser"},
)
versionId: Optional[str] = Field(
default=None,
description="AutoVersion ID used for this run",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Versions-ID"},
)
status: str = Field(
default=AutoRunStatus.RUNNING.value,
description="Status: running, paused, completed, failed, cancelled",
json_schema_extra={"frontend_type": "text", "frontend_required": False, "label": "Status"},
)
trigger: Dict[str, Any] = Field(
default_factory=dict,
description="Trigger info (type, entryPointId, payload, etc.)",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Auslöser"},
)
startedAt: Optional[float] = Field(
default=None,
description="Run start timestamp",
json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False, "label": "Gestartet am"},
)
completedAt: Optional[float] = Field(
default=None,
description="Run completion timestamp",
json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False, "label": "Abgeschlossen am"},
)
nodeOutputs: Dict[str, Any] = Field(
default_factory=dict,
description="Outputs from executed nodes",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Node-Ausgaben"},
)
currentNodeId: Optional[str] = Field(
default=None,
description="Node ID when paused (human task / email wait)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Aktueller Knoten"},
)
resumeContext: Dict[str, Any] = Field(
default_factory=dict,
description="Context for resume (connectionMap, inputSources, etc.)",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Wiederaufnahme-Kontext"},
)
error: Optional[str] = Field(
default=None,
description="Error message if failed",
json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False, "label": "Fehler"},
)
costTokens: int = Field(
default=0,
description="Total tokens consumed by AI nodes",
json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Verbrauchte Tokens"},
)
costCredits: float = Field(
default=0.0,
description="Total credits consumed",
json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Verbrauchte Credits"},
)
# ---------------------------------------------------------------------------
# AutoStepLog
# ---------------------------------------------------------------------------
@i18nModel("Schritt-Protokoll")
class AutoStepLog(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "ID"},
)
runId: str = Field(
description="FK -> AutoRun",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Lauf-ID"},
)
nodeId: str = Field(
description="Node ID in the graph",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Knoten-ID"},
)
nodeType: str = Field(
description="Node type (e.g. ai.chat, email.send)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Knotentyp"},
)
status: str = Field(
default=AutoStepStatus.PENDING.value,
description="Step status: pending, running, completed, failed, skipped",
json_schema_extra={"frontend_type": "text", "frontend_required": False, "label": "Status"},
)
inputSnapshot: Dict[str, Any] = Field(
default_factory=dict,
description="Snapshot of inputs at execution time",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Eingabe-Snapshot"},
)
output: Dict[str, Any] = Field(
default_factory=dict,
description="Node output",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Ausgabe"},
)
error: Optional[str] = Field(
default=None,
description="Error message if step failed",
json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False, "label": "Fehler"},
)
startedAt: Optional[float] = Field(
default=None,
description="Step start timestamp",
json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False, "label": "Gestartet am"},
)
completedAt: Optional[float] = Field(
default=None,
description="Step completion timestamp",
json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False, "label": "Abgeschlossen am"},
)
durationMs: Optional[int] = Field(
default=None,
description="Execution duration in milliseconds",
json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Dauer (ms)"},
)
tokensUsed: int = Field(
default=0,
description="Tokens consumed by this step",
json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Verbrauchte Tokens"},
)
retryCount: int = Field(
default=0,
description="Number of retries executed",
json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Wiederholungen"},
)
# ---------------------------------------------------------------------------
# AutoTask
# ---------------------------------------------------------------------------
@i18nModel("Aufgabe")
class AutoTask(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "ID"},
)
runId: str = Field(
description="FK -> AutoRun",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Lauf-ID"},
)
workflowId: str = Field(
description="Workflow ID",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Workflow-ID"},
)
nodeId: str = Field(
description="Node ID in the graph",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Knoten-ID"},
)
nodeType: str = Field(
description="Node type: form, approval, upload, comment, review, selection, confirmation",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Knotentyp"},
)
config: Dict[str, Any] = Field(
default_factory=dict,
description="Node config (form schema, approval text, etc.)",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Konfiguration"},
)
assigneeId: Optional[str] = Field(
default=None,
description="User ID assigned to complete the task",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False, "label": "Zugewiesen an"},
)
status: str = Field(
default=AutoTaskStatus.PENDING.value,
description="Status: pending, completed, cancelled, expired",
json_schema_extra={"frontend_type": "text", "frontend_required": False, "label": "Status"},
)
result: Optional[Dict[str, Any]] = Field(
default=None,
description="Task result (form data, approval decision, etc.)",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Ergebnis"},
)
expiresAt: Optional[float] = Field(
default=None,
description="Expiration timestamp for the task",
json_schema_extra={"frontend_type": "datetime", "frontend_required": False, "label": "Läuft ab am"},
)
# ---------------------------------------------------------------------------
# Backward-compatible aliases for transition period
# ---------------------------------------------------------------------------
Automation2Workflow = AutoWorkflow
Automation2WorkflowRun = AutoRun
Automation2HumanTask = AutoTask

View file

@ -25,8 +25,8 @@ async def _pollEmailWaits(eventUser) -> None:
Stops the poller when no runs are waiting.
"""
try:
from modules.features.automation2.interfaceFeatureAutomation2 import getAutomation2Interface
from modules.features.automation2.mainAutomation2 import getAutomation2Services
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface as getAutomation2Interface
from modules.features.graphicalEditor.mainGraphicalEditor import getGraphicalEditorServices as getAutomation2Services
from modules.workflows.automation2.executionEngine import executeGraph
from modules.workflows.processing.shared.methodDiscovery import discoverMethods
from modules.interfaces.interfaceDbApp import getRootInterface

View file

@ -30,22 +30,20 @@ def default_manual_entry_point() -> Dict[str, Any]:
"kind": "manual",
"category": "on_demand",
"enabled": True,
"title": {
"de": "Jetzt ausführen",
"en": "Run now",
"fr": "Exécuter",
},
"title": "Jetzt ausführen",
"description": {},
"config": {},
}
def _normalize_title(title: Any) -> Dict[str, str]:
def _normalize_title(title: Any) -> str:
"""Extract a plain string from a title value for storage (not display)."""
if isinstance(title, dict):
return {k: str(v) for k, v in title.items() if v is not None}
picked = title.get("xx") or next((v for v in title.values() if v), None)
return str(picked).strip() if picked else "Start"
if isinstance(title, str) and title.strip():
return {"de": title, "en": title, "fr": title}
return {"de": "Start", "en": "Start", "fr": "Départ"}
return title.strip()
return "Start"
def normalize_invocation_entry(raw: Dict[str, Any]) -> Dict[str, Any]:

View file

@ -1,8 +1,8 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Interface for Automation2 feature - Workflows, Runs, Human Tasks.
Uses PostgreSQL poweron_automation2 database.
Interface for GraphicalEditor feature - Workflows, Runs, Human Tasks.
Uses PostgreSQL poweron_graphicaleditor database (Greenfield).
"""
import base64
@ -25,38 +25,50 @@ def _make_json_serializable(obj: Any) -> Any:
return obj
from modules.datamodels.datamodelUam import User
from modules.features.automation2.datamodelFeatureAutomation2 import (
Automation2Workflow,
Automation2WorkflowRun,
Automation2HumanTask,
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import (
AutoWorkflow,
AutoVersion,
AutoRun,
AutoStepLog,
AutoTask,
AutoWorkflow as Automation2Workflow,
AutoRun as Automation2WorkflowRun,
AutoTask as Automation2HumanTask,
)
from modules.features.automation2.entryPoints import normalize_invocations_list
from modules.features.graphicalEditor.entryPoints import normalize_invocations_list
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.shared.configuration import APP_CONFIG
logger = logging.getLogger(__name__)
_GREENFIELD_DB = "poweron_graphicaleditor"
_CALLBACK_WORKFLOW_CHANGED = "graphicalEditor.workflow.changed"
def getAutomation2Interface(
def getGraphicalEditorInterface(
currentUser: User,
mandateId: str,
featureInstanceId: str,
) -> "Automation2Objects":
"""Factory for Automation2 interface with user context."""
return Automation2Objects(
) -> "GraphicalEditorObjects":
"""Factory for GraphicalEditor interface with user context."""
return GraphicalEditorObjects(
currentUser=currentUser,
mandateId=mandateId,
featureInstanceId=featureInstanceId,
)
# Backward-compatible alias used by workflows/automation2/ execution engine
getAutomation2Interface = getGraphicalEditorInterface
def getAllWorkflowsForScheduling() -> List[Dict[str, Any]]:
"""
Get all active Automation2 workflows that have a schedule entry point (primary invocation).
Get all active workflows that have a schedule entry point (primary invocation).
Used by the scheduler to register cron jobs. Does not filter by mandate/instance.
"""
dbHost = APP_CONFIG.get("DB_HOST", "localhost")
dbDatabase = "poweron_automation2"
dbDatabase = _GREENFIELD_DB
dbUser = APP_CONFIG.get("DB_USER")
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD")
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
@ -69,10 +81,8 @@ def getAllWorkflowsForScheduling() -> List[Dict[str, Any]]:
userId=None,
)
if not connector._ensureTableExists(Automation2Workflow):
logger.warning("Automation2 schedule: table Automation2Workflow does not exist")
logger.warning("GraphicalEditor schedule: table Automation2Workflow does not exist yet")
return []
# Don't filter by active in SQL: existing workflows may have active=NULL.
# Treat NULL as active; skip only when active is explicitly False.
records = connector.getRecordset(
Automation2Workflow,
recordFilter=None,
@ -89,7 +99,6 @@ def getAllWorkflowsForScheduling() -> List[Dict[str, Any]]:
if not isinstance(primary, dict):
primary = {}
# Cron comes from graph start node params (trigger.schedule)
graph = wf.get("graph") or {}
nodes = graph.get("nodes") or []
cron = None
@ -103,7 +112,6 @@ def getAllWorkflowsForScheduling() -> List[Dict[str, Any]]:
if not cron or not isinstance(cron, str) or not cron.strip():
continue
# Prefer invocations; if graph has trigger.schedule but invocations say manual, still schedule
if primary.get("kind") == "schedule" and primary.get("enabled", True):
entry_point_id = primary.get("id")
elif invocations and isinstance(invocations[0], dict) and invocations[0].get("id"):
@ -120,15 +128,15 @@ def getAllWorkflowsForScheduling() -> List[Dict[str, Any]]:
"workflow": wf,
})
logger.info(
"Automation2 schedule: DB has %d workflow(s), %d active with trigger.schedule+cron",
"GraphicalEditor schedule: DB has %d workflow(s), %d active with trigger.schedule+cron",
raw_count,
len(result),
)
return result
class Automation2Objects:
"""Interface for Automation2 database operations."""
class GraphicalEditorObjects:
"""Interface for GraphicalEditor database operations (Greenfield DB)."""
def __init__(
self,
@ -145,9 +153,9 @@ class Automation2Objects:
self.db.updateContext(self.userId)
def _init_db(self):
"""Initialize database connection to poweron_automation2."""
"""Initialize database connection to poweron_graphicaleditor (Greenfield)."""
dbHost = APP_CONFIG.get("DB_HOST", "localhost")
dbDatabase = "poweron_automation2"
dbDatabase = _GREENFIELD_DB
dbUser = APP_CONFIG.get("DB_USER")
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD")
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
@ -159,16 +167,14 @@ class Automation2Objects:
dbPort=dbPort,
userId=self.userId,
)
logger.debug("Automation2 database initialized for user %s", self.userId)
logger.debug("GraphicalEditor database initialized for user %s", self.userId)
# -------------------------------------------------------------------------
# Workflow CRUD
# -------------------------------------------------------------------------
def getWorkflows(self, active: Optional[bool] = None) -> List[Dict[str, Any]]:
"""Get all workflows for this mandate and feature instance.
Optional active filter: True=only active, False=only inactive, None=all.
"""
"""Get all workflows for this mandate and feature instance."""
if not self.db._ensureTableExists(Automation2Workflow):
return []
rf: Dict[str, Any] = {
@ -218,7 +224,7 @@ class Automation2Objects:
out["invocations"] = normalize_invocations_list(out.get("invocations"))
try:
from modules.shared.callbackRegistry import callbackRegistry
callbackRegistry.trigger("automation2.workflow.changed")
callbackRegistry.trigger(_CALLBACK_WORKFLOW_CHANGED)
except Exception:
pass
return out
@ -228,7 +234,6 @@ class Automation2Objects:
existing = self.getWorkflow(workflowId)
if not existing:
return None
# Don't overwrite mandateId/featureInstanceId
data.pop("mandateId", None)
data.pop("featureInstanceId", None)
if "invocations" in data:
@ -238,7 +243,7 @@ class Automation2Objects:
out["invocations"] = normalize_invocations_list(out.get("invocations"))
try:
from modules.shared.callbackRegistry import callbackRegistry
callbackRegistry.trigger("automation2.workflow.changed")
callbackRegistry.trigger(_CALLBACK_WORKFLOW_CHANGED)
except Exception:
pass
return out
@ -251,7 +256,7 @@ class Automation2Objects:
self.db.recordDelete(Automation2Workflow, workflowId)
try:
from modules.shared.callbackRegistry import callbackRegistry
callbackRegistry.trigger("automation2.workflow.changed")
callbackRegistry.trigger(_CALLBACK_WORKFLOW_CHANGED)
except Exception:
pass
return True
@ -260,15 +265,24 @@ class Automation2Objects:
# Workflow Runs
# -------------------------------------------------------------------------
def createRun(self, workflowId: str, nodeOutputs: Dict = None, context: Dict = None) -> Dict[str, Any]:
"""Create a new workflow run."""
def createRun(self, workflowId: str, nodeOutputs: Dict = None, context: Dict = None, label: str = None) -> Dict[str, Any]:
"""Create a new workflow run.
*label* human-readable name persisted on the run. Callers should
pass the workflow label or a descriptive name; ``executeGraph`` fills
in a fallback when nothing is provided.
"""
ctx = context or {}
data = {
"id": str(uuid.uuid4()),
"workflowId": workflowId,
"label": label,
"status": "running",
"nodeOutputs": _make_json_serializable(nodeOutputs or {}),
"currentNodeId": None,
"context": context or {},
"context": ctx,
"mandateId": ctx.get("mandateId") or self.mandateId,
"ownerId": ctx.get("userId") or (self.currentUser.id if self.currentUser else None),
}
created = self.db.recordCreate(Automation2WorkflowRun, data)
return dict(created)
@ -322,7 +336,7 @@ class Automation2Objects:
return [dict(r) for r in records] if records else []
def getRecentCompletedRuns(self, limit: int = 20) -> List[Dict[str, Any]]:
"""Get recently completed runs for workflows in this instance (for output display)."""
"""Get recent runs (all statuses) for workflows in this instance."""
if not self.db._ensureTableExists(Automation2WorkflowRun):
return []
workflows = self.getWorkflows()
@ -331,7 +345,7 @@ class Automation2Objects:
return []
records = self.db.getRecordset(
Automation2WorkflowRun,
recordFilter={"status": "completed"},
recordFilter={},
)
if not records:
return []
@ -426,10 +440,7 @@ class Automation2Objects:
status: str = None,
assigneeId: str = None,
) -> List[Dict[str, Any]]:
"""Get tasks with optional filters.
When assigneeId is set: returns tasks assigned to that user OR unassigned (so schedule tasks show up).
When assigneeId is None: returns all tasks.
"""
"""Get tasks with optional filters."""
if not self.db._ensureTableExists(Automation2HumanTask):
return []
base_rf: Dict[str, Any] = {}
@ -461,3 +472,187 @@ class Automation2Objects:
workflows = {w["id"]: w for w in self.getWorkflows()}
filtered = [t for t in items if t.get("workflowId") in workflows]
return filtered
# -------------------------------------------------------------------------
# Versions (AutoVersion Lifecycle)
# -------------------------------------------------------------------------
def getVersions(self, workflowId: str) -> List[Dict[str, Any]]:
"""Get all versions for a workflow, ordered by versionNumber desc."""
if not self.db._ensureTableExists(AutoVersion):
return []
records = self.db.getRecordset(AutoVersion, recordFilter={"workflowId": workflowId})
versions = [dict(r) for r in records] if records else []
versions.sort(key=lambda v: v.get("versionNumber", 0), reverse=True)
return versions
def getVersion(self, versionId: str) -> Optional[Dict[str, Any]]:
"""Get a single version by ID."""
if not self.db._ensureTableExists(AutoVersion):
return None
record = self.db.getRecord(AutoVersion, versionId)
return dict(record) if record else None
def createDraftVersion(self, workflowId: str) -> Optional[Dict[str, Any]]:
"""Create a new draft version from the workflow's current graph."""
wf = self.getWorkflow(workflowId)
if not wf:
return None
existing = self.getVersions(workflowId)
nextNumber = max((v.get("versionNumber", 0) for v in existing), default=0) + 1
import time
data = {
"id": str(uuid.uuid4()),
"workflowId": workflowId,
"versionNumber": nextNumber,
"status": "draft",
"graph": wf.get("graph", {}),
"invocations": wf.get("invocations", []),
}
created = self.db.recordCreate(AutoVersion, data)
return dict(created)
def publishVersion(self, versionId: str, userId: str = None) -> Optional[Dict[str, Any]]:
"""Publish a draft version. Archives the previously published version."""
version = self.getVersion(versionId)
if not version or version.get("status") != "draft":
return None
workflowId = version.get("workflowId")
existing = self.getVersions(workflowId)
for v in existing:
if v.get("status") == "published" and v.get("id") != versionId:
self.db.recordModify(AutoVersion, v["id"], {"status": "archived"})
import time
updated = self.db.recordModify(AutoVersion, versionId, {
"status": "published",
"publishedAt": time.time(),
"publishedBy": userId,
})
if workflowId:
self.db.recordModify(AutoWorkflow, workflowId, {
"currentVersionId": versionId,
"graph": version.get("graph", {}),
"invocations": version.get("invocations", []),
})
return dict(updated)
def unpublishVersion(self, versionId: str) -> Optional[Dict[str, Any]]:
"""Revert a published version back to draft status."""
version = self.getVersion(versionId)
if not version or version.get("status") != "published":
return None
workflowId = version.get("workflowId")
updated = self.db.recordModify(AutoVersion, versionId, {
"status": "draft",
"publishedAt": None,
"publishedBy": None,
})
if workflowId:
self.db.recordModify(AutoWorkflow, workflowId, {"currentVersionId": None})
return dict(updated)
def archiveVersion(self, versionId: str) -> Optional[Dict[str, Any]]:
"""Archive a version."""
version = self.getVersion(versionId)
if not version:
return None
updated = self.db.recordModify(AutoVersion, versionId, {"status": "archived"})
return dict(updated)
# -------------------------------------------------------------------------
# Templates
# -------------------------------------------------------------------------
def getTemplates(self, scope: str = None) -> List[Dict[str, Any]]:
"""Get workflow templates, optionally filtered by scope.
Always includes system-scope templates (mandateId=None) alongside mandate-owned ones.
"""
if not self.db._ensureTableExists(AutoWorkflow):
return []
rf: Dict[str, Any] = {
"mandateId": self.mandateId,
"featureInstanceId": self.featureInstanceId,
"isTemplate": True,
}
if scope:
rf["templateScope"] = scope
records = self.db.getRecordset(AutoWorkflow, recordFilter=rf) or []
if scope is None or scope == "system":
systemFilter: Dict[str, Any] = {
"isTemplate": True,
"templateScope": "system",
"mandateId": None,
}
systemRecords = self.db.getRecordset(AutoWorkflow, recordFilter=systemFilter) or []
seenIds = {(r.get("id") if isinstance(r, dict) else getattr(r, "id", None)) for r in records}
for sr in systemRecords:
srId = sr.get("id") if isinstance(sr, dict) else getattr(sr, "id", None)
if srId not in seenIds:
records.append(sr)
return [dict(r) for r in records] if records else []
def createTemplateFromWorkflow(self, workflowId: str, scope: str = "user") -> Optional[Dict[str, Any]]:
"""Create a template by copying the published AutoVersion's graph (or workflow graph as fallback)."""
wf = self.getWorkflow(workflowId)
if not wf:
return None
graph = wf.get("graph", {})
invocations = wf.get("invocations", [])
currentVersionId = wf.get("currentVersionId")
if currentVersionId:
version = self.getVersion(currentVersionId)
if version:
graph = version.get("graph", graph)
invocations = version.get("invocations", invocations)
data = {
"id": str(uuid.uuid4()),
"mandateId": self.mandateId,
"featureInstanceId": self.featureInstanceId,
"label": f"{wf.get('label', 'Workflow')} (Template)",
"graph": graph,
"invocations": invocations,
"isTemplate": True,
"templateScope": scope,
"templateSourceId": workflowId,
"active": False,
}
created = self.db.recordCreate(AutoWorkflow, data)
return dict(created)
def copyTemplateToUser(self, templateId: str) -> Optional[Dict[str, Any]]:
"""Copy a template to a new user-owned workflow with templateScope='user'."""
template = self.getWorkflow(templateId)
if not template or not template.get("isTemplate"):
return None
data = {
"id": str(uuid.uuid4()),
"mandateId": self.mandateId,
"featureInstanceId": self.featureInstanceId,
"label": template.get("label", "Workflow").replace(" (Template)", ""),
"graph": template.get("graph", {}),
"invocations": template.get("invocations", []),
"isTemplate": False,
"templateSourceId": templateId,
"templateScope": "user",
"active": True,
}
created = self.db.recordCreate(AutoWorkflow, data)
return dict(created)
def shareTemplate(self, templateId: str, scope: str) -> Optional[Dict[str, Any]]:
"""Change a template's scope. Sets sharedReadOnly=True for shared scopes, False for user scope."""
template = self.getWorkflow(templateId)
if not template or not template.get("isTemplate"):
return None
updated = self.db.recordModify(AutoWorkflow, templateId, {
"templateScope": scope,
"sharedReadOnly": scope != "user",
})
return dict(updated)
# Backward-compatible alias
Automation2Objects = GraphicalEditorObjects

View file

@ -1,7 +1,7 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Automation2 Feature - n8n-style flow automation.
GraphicalEditor Feature - n8n-style flow automation.
Minimal bootstrap for feature instance creation. Build from here.
"""
@ -10,9 +10,8 @@ from typing import Dict, List, Any, Optional
logger = logging.getLogger(__name__)
FEATURE_CODE = "automation2"
FEATURE_CODE = "graphicalEditor"
# Services required for automation2 (methodDiscovery, ActionExecutor, etc.)
REQUIRED_SERVICES = [
{"serviceKey": "chat", "meta": {"usage": "Interfaces, RBAC"}},
{"serviceKey": "utils", "meta": {"usage": "Timestamps, utilities"}},
@ -22,83 +21,78 @@ REQUIRED_SERVICES = [
{"serviceKey": "clickup", "meta": {"usage": "ClickUp actions"}},
{"serviceKey": "generation", "meta": {"usage": "file.create document rendering"}},
]
FEATURE_LABEL = {"en": "Automation 2", "de": "Automatisierung 2", "fr": "Automatisation 2"}
FEATURE_LABEL = "Grafischer Editor"
FEATURE_ICON = "mdi-sitemap"
UI_OBJECTS = [
{
"objectKey": "ui.feature.automation2.editor",
"label": {"en": "Editor", "de": "Editor", "fr": "Éditeur"},
"objectKey": "ui.feature.graphicalEditor.editor",
"label": "Editor",
"meta": {"area": "editor"}
},
{
"objectKey": "ui.feature.automation2.workflows",
"label": {"en": "Workflows", "de": "Workflows", "fr": "Workflows"},
"objectKey": "ui.feature.graphicalEditor.workflows",
"label": "Workflows",
"meta": {"area": "workflows"}
},
{
"objectKey": "ui.feature.automation2.workflows-tasks",
"label": {"en": "Tasks", "de": "Tasks", "fr": "Tâches"},
"objectKey": "ui.feature.graphicalEditor.templates",
"label": "Vorlagen",
"meta": {"area": "templates"}
},
{
"objectKey": "ui.feature.graphicalEditor.workflows-tasks",
"label": "Tasks",
"meta": {"area": "tasks"}
},
]
RESOURCE_OBJECTS = [
{
"objectKey": "resource.feature.automation2.dashboard",
"label": {"en": "Access Dashboard", "de": "Dashboard aufrufen", "fr": "Acceder au tableau de bord"},
"meta": {"endpoint": "/api/automation2/{instanceId}/info", "method": "GET"}
"objectKey": "resource.feature.graphicalEditor.dashboard",
"label": "Dashboard aufrufen",
"meta": {"endpoint": "/api/workflows/{instanceId}/info", "method": "GET"}
},
{
"objectKey": "resource.feature.automation2.node-types",
"label": {"en": "Get Node Types", "de": "Node-Typen abrufen", "fr": "Obtenir types de nœuds"},
"meta": {"endpoint": "/api/automation2/{instanceId}/node-types", "method": "GET"}
"objectKey": "resource.feature.graphicalEditor.node-types",
"label": "Node-Typen abrufen",
"meta": {"endpoint": "/api/workflows/{instanceId}/node-types", "method": "GET"}
},
{
"objectKey": "resource.feature.automation2.execute",
"label": {"en": "Execute Workflow", "de": "Workflow ausführen", "fr": "Exécuter le workflow"},
"meta": {"endpoint": "/api/automation2/{instanceId}/execute", "method": "POST"}
"objectKey": "resource.feature.graphicalEditor.execute",
"label": "Workflow ausführen",
"meta": {"endpoint": "/api/workflows/{instanceId}/execute", "method": "POST"}
},
]
TEMPLATE_ROLES = [
{
"roleLabel": "automation2-viewer",
"description": {
"en": "Automation2 Viewer - View workflows (read-only)",
"de": "Automation2 Betrachter - Workflows ansehen (nur lesen)",
"fr": "Visualiseur Automation2 - Consulter les workflows (lecture seule)",
},
"roleLabel": "graphicalEditor-viewer",
"description": "Grafischer Editor Betrachter - Workflows ansehen (nur lesen)",
"accessRules": [
{"context": "UI", "item": "ui.feature.automation2.workflows", "view": True},
{"context": "UI", "item": "ui.feature.automation2.workflows-tasks", "view": True},
{"context": "UI", "item": "ui.feature.graphicalEditor.workflows", "view": True},
{"context": "UI", "item": "ui.feature.graphicalEditor.workflows-tasks", "view": True},
{"context": "UI", "item": "ui.feature.graphicalEditor.templates", "view": True},
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
],
},
{
"roleLabel": "automation2-user",
"description": {
"en": "Automation2 User - Use automation2 flow builder",
"de": "Automation2 Benutzer - Flow-Builder nutzen",
"fr": "Utilisateur Automation2 - Utiliser le flow builder",
},
"roleLabel": "graphicalEditor-user",
"description": "Grafischer Editor Benutzer - Flow-Builder nutzen",
"accessRules": [
{"context": "UI", "item": "ui.feature.automation2.editor", "view": True},
{"context": "UI", "item": "ui.feature.automation2.workflows", "view": True},
{"context": "UI", "item": "ui.feature.automation2.workflows-tasks", "view": True},
{"context": "RESOURCE", "item": "resource.feature.automation2.dashboard", "view": True},
{"context": "RESOURCE", "item": "resource.feature.automation2.node-types", "view": True},
{"context": "RESOURCE", "item": "resource.feature.automation2.execute", "view": True},
{"context": "UI", "item": "ui.feature.graphicalEditor.editor", "view": True},
{"context": "UI", "item": "ui.feature.graphicalEditor.workflows", "view": True},
{"context": "UI", "item": "ui.feature.graphicalEditor.workflows-tasks", "view": True},
{"context": "UI", "item": "ui.feature.graphicalEditor.templates", "view": True},
{"context": "RESOURCE", "item": "resource.feature.graphicalEditor.dashboard", "view": True},
{"context": "RESOURCE", "item": "resource.feature.graphicalEditor.node-types", "view": True},
{"context": "RESOURCE", "item": "resource.feature.graphicalEditor.execute", "view": True},
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"},
],
},
{
"roleLabel": "automation2-admin",
"description": {
"en": "Automation2 Admin - Full UI and API for the instance; data remains user-scoped (MY)",
"de": "Automation2 Admin - Volle UI und API für die Instanz; Daten weiterhin benutzerspezifisch (MY)",
"fr": "Administrateur Automation2 - UI et API complets pour l'instance; donnees limitees a l'utilisateur (MY)",
},
"roleLabel": "graphicalEditor-admin",
"description": "Grafischer Editor Admin - Volle UI und API für die Instanz; Daten weiterhin benutzerspezifisch (MY)",
"accessRules": [
{"context": "UI", "item": None, "view": True},
{"context": "RESOURCE", "item": None, "view": True},
@ -113,14 +107,14 @@ def getRequiredServiceKeys() -> List[str]:
return [s["serviceKey"] for s in REQUIRED_SERVICES]
def getAutomation2Services(
def getGraphicalEditorServices(
user,
mandateId: Optional[str] = None,
featureInstanceId: Optional[str] = None,
workflow=None,
) -> "_Automation2ServiceHub":
) -> "_GraphicalEditorServiceHub":
"""
Get a service hub for automation2 using the service center.
Get a service hub for graphicalEditor using the service center.
Used for methodDiscovery (I/O nodes) and execution (ActionExecutor).
"""
from modules.serviceCenter import getService
@ -128,10 +122,11 @@ def getAutomation2Services(
_workflow = workflow
if _workflow is None:
import uuid as _uuid
_workflow = type(
"_Placeholder",
(),
{"featureCode": FEATURE_CODE, "id": None, "workflowMode": None, "messages": []},
{"featureCode": FEATURE_CODE, "id": f"transient-{_uuid.uuid4().hex[:12]}", "workflowMode": None, "messages": []},
)()
ctx = ServiceCenterContext(
@ -141,12 +136,12 @@ def getAutomation2Services(
workflow=_workflow,
)
hub = _Automation2ServiceHub()
hub = _GraphicalEditorServiceHub()
hub.user = user
hub.mandateId = mandateId
hub.featureInstanceId = featureInstanceId
hub._service_context = ctx
hub.workflow = workflow
hub.workflow = _workflow
hub.featureCode = FEATURE_CODE
for spec in REQUIRED_SERVICES:
@ -155,7 +150,7 @@ def getAutomation2Services(
svc = getService(key, ctx)
setattr(hub, key, svc)
except Exception as e:
logger.warning(f"Could not resolve service '{key}' for automation2: {e}")
logger.warning(f"Could not resolve service '{key}' for graphicalEditor: {e}")
setattr(hub, key, None)
if hub.chat:
@ -167,8 +162,12 @@ def getAutomation2Services(
return hub
class _Automation2ServiceHub:
"""Lightweight hub for automation2 (methodDiscovery, execution)."""
# Backward-compatible alias used by workflows/automation2/ execution engine
getAutomation2Services = getGraphicalEditorServices
class _GraphicalEditorServiceHub:
"""Lightweight hub for graphicalEditor (methodDiscovery, execution)."""
user = None
mandateId = None
@ -190,12 +189,16 @@ class _Automation2ServiceHub:
async def onStart(eventUser) -> None:
"""Feature startup. Email poller is started on-demand when a run pauses for email.checkEmail."""
"""Feature startup: start consolidated scheduler."""
from modules.workflows.scheduler.mainScheduler import start as startScheduler
startScheduler(eventUser)
async def onStop(eventUser) -> None:
"""Feature shutdown - remove email poller if running."""
from modules.features.automation2.emailPoller import stop as stopEmailPoller
"""Feature shutdown - stop scheduler and email poller."""
from modules.workflows.scheduler.mainScheduler import stop as stopScheduler
stopScheduler()
from modules.features.graphicalEditor.emailPoller import stop as stopEmailPoller
stopEmailPoller(eventUser)
@ -257,6 +260,7 @@ def _syncTemplateRolesToDb() -> int:
try:
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelRbac import Role
from modules.datamodels.datamodelUtils import coerce_text_multilingual
rootInterface = getRootInterface()
existingRoles = rootInterface.getRolesByFeatureCode(FEATURE_CODE)
@ -270,7 +274,7 @@ def _syncTemplateRolesToDb() -> int:
else:
newRole = Role(
roleLabel=roleLabel,
description=template.get("description", {}),
description=coerce_text_multilingual(template.get("description", {})),
featureCode=FEATURE_CODE,
mandateId=None,
featureInstanceId=None,
@ -283,7 +287,6 @@ def _syncTemplateRolesToDb() -> int:
_ensureAccessRulesForRole(rootInterface, roleId, template.get("accessRules", []))
# Sync same rules to mandate-specific roles (so Workflows & Tasks etc. appear in sidebar)
for r in existingRoles:
if r.mandateId and r.roleLabel == roleLabel:
added = _ensureAccessRulesForRole(

View file

@ -9,6 +9,8 @@ from .email import EMAIL_NODES
from .sharepoint import SHAREPOINT_NODES
from .clickup import CLICKUP_NODES
from .file import FILE_NODES
from .trustee import TRUSTEE_NODES
from .data import DATA_NODES
STATIC_NODE_TYPES = (
TRIGGER_NODES
@ -19,4 +21,6 @@ STATIC_NODE_TYPES = (
+ SHAREPOINT_NODES
+ CLICKUP_NODES
+ FILE_NODES
+ TRUSTEE_NODES
+ DATA_NODES
)

View file

@ -0,0 +1,135 @@
# 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": t("Prompt"),
"description": t("Prompt eingeben und KI führt aus"),
"parameters": [
{"name": "aiPrompt", "type": "string", "required": True, "frontendType": "textarea",
"description": t("KI-Prompt")},
{"name": "outputFormat", "type": "string", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["text", "json", "emailDraft"]},
"description": t("Ausgabeformat"), "default": "text"},
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "AiResult"}},
"meta": {"icon": "mdi-robot", "color": "#9C27B0"},
"_method": "ai",
"_action": "process",
},
{
"id": "ai.webResearch",
"category": "ai",
"label": t("Web-Recherche"),
"description": t("Recherche im Web"),
"parameters": [
{"name": "prompt", "type": "string", "required": True, "frontendType": "textarea",
"description": t("Recherche-Anfrage")},
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "AiResult"}},
"meta": {"icon": "mdi-magnify", "color": "#9C27B0"},
"_method": "ai",
"_action": "webResearch",
},
{
"id": "ai.summarizeDocument",
"category": "ai",
"label": t("Dokument zusammenfassen"),
"description": t("Dokumentinhalt zusammenfassen"),
"parameters": [
{"name": "summaryLength", "type": "string", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["short", "medium", "long"]},
"description": t("Kurz, mittel oder lang"), "default": "medium"},
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["DocumentList", "Transit"]}},
"outputPorts": {0: {"schema": "AiResult"}},
"meta": {"icon": "mdi-file-document-outline", "color": "#9C27B0"},
"_method": "ai",
"_action": "summarizeDocument",
},
{
"id": "ai.translateDocument",
"category": "ai",
"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": t("Zielsprache")},
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["DocumentList", "Transit"]}},
"outputPorts": {0: {"schema": "AiResult"}},
"meta": {"icon": "mdi-translate", "color": "#9C27B0"},
"_method": "ai",
"_action": "translateDocument",
},
{
"id": "ai.convertDocument",
"category": "ai",
"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": t("Zielformat")},
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["DocumentList", "Transit"]}},
"outputPorts": {0: {"schema": "DocumentList"}},
"meta": {"icon": "mdi-file-convert", "color": "#9C27B0"},
"_method": "ai",
"_action": "convertDocument",
},
{
"id": "ai.generateDocument",
"category": "ai",
"label": t("Dokument generieren"),
"description": t("Dokument aus Prompt generieren"),
"parameters": [
{"name": "prompt", "type": "string", "required": True, "frontendType": "textarea",
"description": t("Generierungs-Prompt")},
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "DocumentList"}},
"meta": {"icon": "mdi-file-plus", "color": "#9C27B0"},
"_method": "ai",
"_action": "generateDocument",
},
{
"id": "ai.generateCode",
"category": "ai",
"label": t("Code generieren"),
"description": t("Code aus Beschreibung generieren"),
"parameters": [
{"name": "prompt", "type": "string", "required": True, "frontendType": "textarea",
"description": t("Code-Generierungs-Prompt")},
{"name": "language", "type": "string", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["python", "javascript", "typescript", "java", "csharp", "go"]},
"description": t("Programmiersprache"), "default": "python"},
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "AiResult"}},
"meta": {"icon": "mdi-code-tags", "color": "#9C27B0"},
"_method": "ai",
"_action": "generateCode",
},
]

View file

@ -0,0 +1,178 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""ClickUp nodes — map to MethodClickup actions."""
from modules.shared.i18nRegistry import t
CLICKUP_NODES = [
{
"id": "clickup.searchTasks",
"category": "clickup",
"label": t("Aufgaben suchen"),
"description": t("Aufgaben in einem Workspace suchen"),
"parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
"description": t("ClickUp-Verbindung")},
{"name": "teamId", "type": "string", "required": True, "frontendType": "text",
"description": t("Team-/Workspace-ID")},
{"name": "query", "type": "string", "required": True, "frontendType": "text",
"description": t("Suchbegriff")},
{"name": "page", "type": "number", "required": False, "frontendType": "number",
"description": t("Seite"), "default": 0},
{"name": "listId", "type": "string", "required": False, "frontendType": "clickupList",
"frontendOptions": {"dependsOn": "connectionReference"},
"description": t("In dieser Liste suchen")},
{"name": "includeClosed", "type": "boolean", "required": False, "frontendType": "checkbox",
"description": t("Erledigte einbeziehen"), "default": False},
{"name": "fullTaskData", "type": "boolean", "required": False, "frontendType": "checkbox",
"description": t("Vollständige Daten"), "default": False},
{"name": "matchNameOnly", "type": "boolean", "required": False, "frontendType": "checkbox",
"description": t("Nur Titel"), "default": True},
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "TaskList"}},
"meta": {"icon": "mdi-magnify", "color": "#7B68EE"},
"_method": "clickup",
"_action": "searchTasks",
},
{
"id": "clickup.listTasks",
"category": "clickup",
"label": t("Aufgaben auflisten"),
"description": t("Aufgaben einer Liste auflisten"),
"parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
"description": t("ClickUp-Verbindung")},
{"name": "pathQuery", "type": "string", "required": True, "frontendType": "clickupList",
"frontendOptions": {"dependsOn": "connectionReference"},
"description": t("Pfad zur Liste")},
{"name": "page", "type": "number", "required": False, "frontendType": "number",
"description": t("Seite"), "default": 0},
{"name": "includeClosed", "type": "boolean", "required": False, "frontendType": "checkbox",
"description": t("Erledigte einbeziehen"), "default": False},
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "TaskList"}},
"meta": {"icon": "mdi-format-list-bulleted", "color": "#7B68EE"},
"_method": "clickup",
"_action": "listTasks",
},
{
"id": "clickup.getTask",
"category": "clickup",
"label": t("Aufgabe abrufen"),
"description": t("Eine Aufgabe abrufen"),
"parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
"description": t("ClickUp-Verbindung")},
{"name": "taskId", "type": "string", "required": False, "frontendType": "text",
"description": t("Task-ID")},
{"name": "pathQuery", "type": "string", "required": False, "frontendType": "text",
"description": t("Oder Pfad")},
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "TaskResult"}},
"meta": {"icon": "mdi-file-document-outline", "color": "#7B68EE"},
"_method": "clickup",
"_action": "getTask",
},
{
"id": "clickup.createTask",
"category": "clickup",
"label": t("Aufgabe erstellen"),
"description": t("Aufgabe erstellen"),
"parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
"description": t("ClickUp-Verbindung")},
{"name": "teamId", "type": "string", "required": False, "frontendType": "text",
"description": t("Workspace")},
{"name": "pathQuery", "type": "string", "required": False, "frontendType": "clickupList",
"frontendOptions": {"dependsOn": "connectionReference"},
"description": t("Pfad zur Liste")},
{"name": "listId", "type": "string", "required": False, "frontendType": "text",
"description": t("Listen-ID")},
{"name": "name", "type": "string", "required": True, "frontendType": "text",
"description": t("Name")},
{"name": "description", "type": "string", "required": False, "frontendType": "textarea",
"description": t("Beschreibung")},
{"name": "taskStatus", "type": "string", "required": False, "frontendType": "text",
"description": t("Status")},
{"name": "taskPriority", "type": "string", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["1", "2", "3", "4"]},
"description": t("Priorität 1-4")},
{"name": "taskDueDateMs", "type": "string", "required": False, "frontendType": "text",
"description": t("Fälligkeit (ms)")},
{"name": "taskAssigneeIds", "type": "object", "required": False, "frontendType": "json",
"description": t("Zugewiesene")},
{"name": "taskTimeEstimateMs", "type": "string", "required": False, "frontendType": "text",
"description": t("Zeitschätzung (ms)")},
{"name": "taskTimeEstimateHours", "type": "string", "required": False, "frontendType": "text",
"description": t("Zeitschätzung (h)")},
{"name": "customFieldValues", "type": "object", "required": False, "frontendType": "json",
"description": t("Benutzerdefinierte Felder")},
{"name": "taskFields", "type": "string", "required": False, "frontendType": "json",
"description": t("Zusätzliches JSON")},
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "TaskResult"}},
"meta": {"icon": "mdi-plus-circle-outline", "color": "#7B68EE"},
"_method": "clickup",
"_action": "createTask",
},
{
"id": "clickup.updateTask",
"category": "clickup",
"label": t("Aufgabe aktualisieren"),
"description": t("Felder der Aufgabe ändern"),
"parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
"description": t("ClickUp-Verbindung")},
{"name": "taskId", "type": "string", "required": False, "frontendType": "text",
"description": t("Task-ID")},
{"name": "path", "type": "string", "required": False, "frontendType": "text",
"description": t("Oder Pfad")},
{"name": "taskUpdateEntries", "type": "object", "required": False, "frontendType": "keyValueRows",
"description": t("Zu ändernde Felder")},
{"name": "taskUpdate", "type": "string", "required": False, "frontendType": "json",
"description": t("JSON für API")},
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["TaskResult", "Transit"]}},
"outputPorts": {0: {"schema": "TaskResult"}},
"meta": {"icon": "mdi-pencil-outline", "color": "#7B68EE"},
"_method": "clickup",
"_action": "updateTask",
},
{
"id": "clickup.uploadAttachment",
"category": "clickup",
"label": t("Anhang hochladen"),
"description": t("Datei an Task anhängen"),
"parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
"description": t("ClickUp-Verbindung")},
{"name": "taskId", "type": "string", "required": False, "frontendType": "text",
"description": t("Task-ID")},
{"name": "path", "type": "string", "required": False, "frontendType": "text",
"description": t("Oder Pfad")},
{"name": "fileName", "type": "string", "required": False, "frontendType": "text",
"description": t("Dateiname")},
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["DocumentList", "Transit"]}},
"outputPorts": {0: {"schema": "ActionResult"}},
"meta": {"icon": "mdi-attachment", "color": "#7B68EE"},
"_method": "clickup",
"_action": "uploadAttachment",
},
]

View file

@ -0,0 +1,56 @@
# 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": 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": t("Aggregationsmodus"), "default": "collect"},
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "AggregateResult"}},
"executor": "data",
"meta": {"icon": "mdi-playlist-plus", "color": "#607D8B"},
},
{
"id": "data.transform",
"category": "data",
"label": t("Umwandeln"),
"description": t("Daten umstrukturieren"),
"parameters": [
{"name": "mappings", "type": "json", "required": True, "frontendType": "mappingTable",
"description": t("Feld-Zuordnungen"), "default": []},
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "ActionResult", "dynamic": True, "deriveFrom": "mappings"}},
"executor": "data",
"meta": {"icon": "mdi-swap-horizontal-bold", "color": "#607D8B"},
},
{
"id": "data.filter",
"category": "data",
"label": t("Filtern"),
"description": t("Elemente nach Bedingung filtern"),
"parameters": [
{"name": "condition", "type": "string", "required": True, "frontendType": "filterExpression",
"description": t("Filterbedingung")},
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["AggregateResult", "FileList", "TaskList", "EmailList", "DocumentList"]}},
"outputPorts": {0: {"schema": "Transit"}},
"executor": "data",
"meta": {"icon": "mdi-filter-outline", "color": "#607D8B"},
},
]

View file

@ -0,0 +1,94 @@
# 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": t("E-Mail prüfen"),
"description": t("Neue E-Mails prüfen"),
"parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
"description": t("E-Mail-Konto Verbindung")},
{"name": "folder", "type": "string", "required": False, "frontendType": "text",
"description": t("Ordner"), "default": "Inbox"},
{"name": "limit", "type": "number", "required": False, "frontendType": "number",
"description": t("Max E-Mails"), "default": 100},
{"name": "fromAddress", "type": "string", "required": False, "frontendType": "text",
"description": t("Nur von dieser Adresse"), "default": ""},
{"name": "subjectContains", "type": "string", "required": False, "frontendType": "text",
"description": t("Betreff muss enthalten"), "default": ""},
{"name": "hasAttachment", "type": "boolean", "required": False, "frontendType": "checkbox",
"description": t("Nur mit Anhängen"), "default": False},
{"name": "filter", "type": "string", "required": False, "frontendType": "text",
"description": t("Erweitert: Filter-Text"), "default": ""},
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "EmailList"}},
"meta": {"icon": "mdi-email-check", "color": "#1976D2"},
"_method": "outlook",
"_action": "readEmails",
},
{
"id": "email.searchEmail",
"category": "email",
"label": t("E-Mail suchen"),
"description": t("E-Mails suchen"),
"parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
"description": t("E-Mail-Konto Verbindung")},
{"name": "query", "type": "string", "required": False, "frontendType": "text",
"description": t("Suchbegriff"), "default": ""},
{"name": "folder", "type": "string", "required": False, "frontendType": "text",
"description": t("Ordner"), "default": "Inbox"},
{"name": "limit", "type": "number", "required": False, "frontendType": "number",
"description": t("Max E-Mails"), "default": 100},
{"name": "fromAddress", "type": "string", "required": False, "frontendType": "text",
"description": t("Von Adresse"), "default": ""},
{"name": "toAddress", "type": "string", "required": False, "frontendType": "text",
"description": t("An Adresse"), "default": ""},
{"name": "subjectContains", "type": "string", "required": False, "frontendType": "text",
"description": t("Betreff enthält"), "default": ""},
{"name": "bodyContains", "type": "string", "required": False, "frontendType": "text",
"description": t("Inhalt enthält"), "default": ""},
{"name": "hasAttachment", "type": "boolean", "required": False, "frontendType": "checkbox",
"description": t("Mit Anhängen"), "default": False},
{"name": "filter", "type": "string", "required": False, "frontendType": "text",
"description": t("Erweitert: KQL-Filter"), "default": ""},
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "EmailList"}},
"meta": {"icon": "mdi-email-search", "color": "#1976D2"},
"_method": "outlook",
"_action": "searchEmails",
},
{
"id": "email.draftEmail",
"category": "email",
"label": t("E-Mail entwerfen"),
"description": t("E-Mail-Entwurf erstellen"),
"parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
"description": t("E-Mail-Konto")},
{"name": "subject", "type": "string", "required": True, "frontendType": "text",
"description": t("Betreff")},
{"name": "body", "type": "string", "required": True, "frontendType": "textarea",
"description": t("Inhalt")},
{"name": "to", "type": "string", "required": False, "frontendType": "text",
"description": t("Empfänger"), "default": ""},
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["EmailDraft", "AiResult", "Transit"]}},
"outputPorts": {0: {"schema": "ActionResult"}},
"meta": {"icon": "mdi-email-edit", "color": "#1976D2"},
"_method": "outlook",
"_action": "composeAndDraftEmailWithContext",
},
]

View file

@ -0,0 +1,35 @@
# 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": t("Datei erstellen"),
"description": t("Erstellt eine Datei aus Kontext (Text/Markdown von KI)."),
"parameters": [
{"name": "contentSources", "type": "json", "required": False, "frontendType": "json",
"description": t("Kontext-Quellen"), "default": []},
{"name": "outputFormat", "type": "string", "required": True, "frontendType": "select",
"frontendOptions": {"options": ["docx", "pdf", "txt", "html", "md"]},
"description": t("Ausgabeformat"), "default": "docx"},
{"name": "title", "type": "string", "required": False, "frontendType": "text",
"description": t("Dokumenttitel")},
{"name": "templateName", "type": "string", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["default", "corporate", "minimal"]},
"description": t("Stil-Vorlage")},
{"name": "language", "type": "string", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["de", "en", "fr"]},
"description": t("Sprache"), "default": "de"},
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["AiResult", "TextResult", "Transit"]}},
"outputPorts": {0: {"schema": "DocumentList"}},
"meta": {"icon": "mdi-file-plus-outline", "color": "#2196F3"},
"_method": "file",
"_action": "create",
},
]

View file

@ -0,0 +1,101 @@
# Copyright (c) 2025 Patrick Motsch
# Flow control node definitions.
from modules.shared.i18nRegistry import t
FLOW_NODES = [
{
"id": "flow.ifElse",
"category": "flow",
"label": t("Wenn / Sonst"),
"description": t("Verzweigung nach Bedingung"),
"parameters": [
{
"name": "condition",
"type": "string",
"required": True,
"frontendType": "condition",
"description": t("Bedingung"),
},
],
"inputs": 1,
"outputs": 2,
"outputLabels": [t("Ja"), t("Nein")],
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "Transit"}, 1: {"schema": "Transit"}},
"executor": "flow",
"meta": {"icon": "mdi-source-branch", "color": "#FF9800"},
},
{
"id": "flow.switch",
"category": "flow",
"label": t("Switch"),
"description": t("Mehrere Zweige nach Wert"),
"parameters": [
{
"name": "value",
"type": "string",
"required": True,
"frontendType": "text",
"description": t("Zu vergleichender Wert"),
},
{
"name": "cases",
"type": "array",
"required": False,
"frontendType": "caseList",
"description": t("Fälle"),
},
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "Transit"}},
"executor": "flow",
"meta": {"icon": "mdi-swap-horizontal", "color": "#FF9800"},
},
{
"id": "flow.loop",
"category": "flow",
"label": t("Schleife / Für Jedes"),
"description": t("Über Array-Elemente iterieren"),
"parameters": [
{
"name": "items",
"type": "string",
"required": True,
"frontendType": "text",
"description": t("Pfad zum Array"),
},
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "LoopItem"}},
"executor": "flow",
"meta": {"icon": "mdi-repeat", "color": "#FF9800"},
},
{
"id": "flow.merge",
"category": "flow",
"label": t("Zusammenführen"),
"description": t("Mehrere Zweige zusammenführen"),
"parameters": [
{
"name": "mode",
"type": "string",
"required": False,
"frontendType": "select",
"frontendOptions": {"options": ["first", "all", "append"]},
"description": t("Zusammenführungsmodus"),
"default": "first",
},
],
"inputs": 2,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}, 1: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "MergeResult"}},
"executor": "flow",
"meta": {"icon": "mdi-call-merge", "color": "#FF9800"},
},
]

View file

@ -0,0 +1,148 @@
# 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": t("Formular"),
"description": t("Benutzer füllt ein Formular aus"),
"parameters": [
{
"name": "fields",
"type": "json",
"required": True,
"frontendType": "fieldBuilder",
"description": t("Formularfelder"),
"default": [],
},
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "FormPayload", "dynamic": True, "deriveFrom": "fields"}},
"executor": "input",
"meta": {"icon": "mdi-form-textbox", "color": "#9C27B0"},
},
{
"id": "input.approval",
"category": "input",
"label": t("Genehmigung"),
"description": t("Benutzer genehmigt oder lehnt ab"),
"parameters": [
{"name": "title", "type": "string", "required": True, "frontendType": "text",
"description": t("Genehmigungstitel")},
{"name": "description", "type": "string", "required": False, "frontendType": "textarea",
"description": t("Was genehmigt werden soll")},
{"name": "approvalType", "type": "string", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["generic", "document"]},
"description": t("Typ: document oder generic"), "default": "generic"},
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "BoolResult"}},
"executor": "input",
"meta": {"icon": "mdi-check-decagram", "color": "#4CAF50"},
},
{
"id": "input.upload",
"category": "input",
"label": t("Upload"),
"description": t("Benutzer lädt Datei(en) hoch"),
"parameters": [
{"name": "accept", "type": "string", "required": False, "frontendType": "text",
"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": t("Ausgewählte Dateitypen"), "default": []},
{"name": "maxSize", "type": "number", "required": False, "frontendType": "number",
"description": t("Max. Dateigröße in MB"), "default": 10},
{"name": "multiple", "type": "boolean", "required": False, "frontendType": "checkbox",
"description": t("Mehrere Dateien erlauben"), "default": False},
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "DocumentList"}},
"executor": "input",
"meta": {"icon": "mdi-upload", "color": "#2196F3"},
},
{
"id": "input.comment",
"category": "input",
"label": t("Kommentar"),
"description": t("Benutzer fügt einen Kommentar hinzu"),
"parameters": [
{"name": "placeholder", "type": "string", "required": False, "frontendType": "text",
"description": t("Platzhalter"), "default": ""},
{"name": "required", "type": "boolean", "required": False, "frontendType": "checkbox",
"description": t("Kommentar erforderlich"), "default": True},
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "TextResult"}},
"executor": "input",
"meta": {"icon": "mdi-comment-text", "color": "#FF9800"},
},
{
"id": "input.review",
"category": "input",
"label": t("Prüfung"),
"description": t("Benutzer prüft Inhalt"),
"parameters": [
{"name": "contentRef", "type": "string", "required": True, "frontendType": "text",
"description": t("Referenz auf Inhalt")},
{"name": "reviewType", "type": "string", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["generic", "document"]},
"description": t("Art der Prüfung"), "default": "generic"},
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "BoolResult"}},
"executor": "input",
"meta": {"icon": "mdi-magnify-scan", "color": "#673AB7"},
},
{
"id": "input.selection",
"category": "input",
"label": t("Auswahl"),
"description": t("Benutzer wählt aus Optionen"),
"parameters": [
{"name": "options", "type": "json", "required": True, "frontendType": "keyValueRows",
"description": t("Optionen"), "default": []},
{"name": "multiple", "type": "boolean", "required": False, "frontendType": "checkbox",
"description": t("Mehrfachauswahl erlauben"), "default": False},
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "TextResult"}},
"executor": "input",
"meta": {"icon": "mdi-format-list-checks", "color": "#009688"},
},
{
"id": "input.confirmation",
"category": "input",
"label": t("Bestätigung"),
"description": t("Benutzer bestätigt Ja/Nein"),
"parameters": [
{"name": "question", "type": "string", "required": True, "frontendType": "text",
"description": t("Zu bestätigende Frage")},
{"name": "confirmLabel", "type": "string", "required": False, "frontendType": "text",
"description": t("Label für Bestätigen-Button"), "default": "Confirm"},
{"name": "rejectLabel", "type": "string", "required": False, "frontendType": "text",
"description": t("Label für Ablehnen-Button"), "default": "Reject"},
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "BoolResult"}},
"executor": "input",
"meta": {"icon": "mdi-checkbox-marked-circle", "color": "#8BC34A"},
},
]

View file

@ -0,0 +1,133 @@
# 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": t("Datei finden"),
"description": t("Datei nach Pfad oder Suche finden"),
"parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
"description": t("SharePoint-Verbindung")},
{"name": "searchQuery", "type": "string", "required": True, "frontendType": "text",
"description": t("Suchanfrage oder Pfad")},
{"name": "site", "type": "string", "required": False, "frontendType": "text",
"description": t("Optionaler Site-Hinweis"), "default": ""},
{"name": "maxResults", "type": "number", "required": False, "frontendType": "number",
"description": t("Max Ergebnisse"), "default": 1000},
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "FileList"}},
"meta": {"icon": "mdi-file-search", "color": "#0078D4"},
"_method": "sharepoint",
"_action": "findDocumentPath",
},
{
"id": "sharepoint.readFile",
"category": "sharepoint",
"label": t("Datei lesen"),
"description": t("Inhalt aus Datei extrahieren"),
"parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
"description": t("SharePoint-Verbindung")},
{"name": "pathQuery", "type": "string", "required": True, "frontendType": "sharepointFile",
"frontendOptions": {"dependsOn": "connectionReference"},
"description": t("Dateipfad")},
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["FileList", "Transit"]}},
"outputPorts": {0: {"schema": "DocumentList"}},
"meta": {"icon": "mdi-file-document", "color": "#0078D4"},
"_method": "sharepoint",
"_action": "readDocuments",
},
{
"id": "sharepoint.uploadFile",
"category": "sharepoint",
"label": t("Datei hochladen"),
"description": t("Datei zu SharePoint hochladen"),
"parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
"description": t("SharePoint-Verbindung")},
{"name": "pathQuery", "type": "string", "required": True, "frontendType": "sharepointFolder",
"frontendOptions": {"dependsOn": "connectionReference"},
"description": t("Zielordner-Pfad")},
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["DocumentList", "Transit"]}},
"outputPorts": {0: {"schema": "ActionResult"}},
"meta": {"icon": "mdi-upload", "color": "#0078D4"},
"_method": "sharepoint",
"_action": "uploadFile",
},
{
"id": "sharepoint.listFiles",
"category": "sharepoint",
"label": t("Dateien auflisten"),
"description": t("Dateien in Ordner auflisten"),
"parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
"description": t("SharePoint-Verbindung")},
{"name": "pathQuery", "type": "string", "required": False, "frontendType": "sharepointFolder",
"frontendOptions": {"dependsOn": "connectionReference"},
"description": t("Ordnerpfad"), "default": "/"},
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "FileList"}},
"meta": {"icon": "mdi-folder-open", "color": "#0078D4"},
"_method": "sharepoint",
"_action": "listDocuments",
},
{
"id": "sharepoint.downloadFile",
"category": "sharepoint",
"label": t("Datei herunterladen"),
"description": t("Datei vom Pfad herunterladen"),
"parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
"description": t("SharePoint-Verbindung")},
{"name": "pathQuery", "type": "string", "required": True, "frontendType": "sharepointFile",
"frontendOptions": {"dependsOn": "connectionReference"},
"description": t("Vollständiger Dateipfad")},
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["FileList", "Transit"]}},
"outputPorts": {0: {"schema": "DocumentList"}},
"meta": {"icon": "mdi-download", "color": "#0078D4"},
"_method": "sharepoint",
"_action": "downloadFileByPath",
},
{
"id": "sharepoint.copyFile",
"category": "sharepoint",
"label": t("Datei kopieren"),
"description": t("Datei an Ziel kopieren"),
"parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
"description": t("SharePoint-Verbindung")},
{"name": "sourcePath", "type": "string", "required": True, "frontendType": "sharepointFile",
"frontendOptions": {"dependsOn": "connectionReference"},
"description": t("Quelldatei-Pfad")},
{"name": "destPath", "type": "string", "required": True, "frontendType": "sharepointFolder",
"frontendOptions": {"dependsOn": "connectionReference"},
"description": t("Zielordner")},
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "ActionResult"}},
"meta": {"icon": "mdi-content-copy", "color": "#0078D4"},
"_method": "sharepoint",
"_action": "copyFile",
},
]

View file

@ -0,0 +1,62 @@
# 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": t("Start"),
"description": t("Manuell, API oder Hintergrund-Starts (Webhook, E-Mail, …)."),
"parameters": [],
"inputs": 0,
"outputs": 1,
"inputPorts": {},
"outputPorts": {0: {"schema": "ActionResult"}},
"executor": "trigger",
"meta": {"icon": "mdi-play", "color": "#4CAF50"},
},
{
"id": "trigger.form",
"category": "trigger",
"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": t("Felddefinitionen"),
},
],
"inputs": 0,
"outputs": 1,
"inputPorts": {},
"outputPorts": {0: {"schema": "FormPayload", "dynamic": True, "deriveFrom": "formFields"}},
"executor": "trigger",
"meta": {"icon": "mdi-form-select", "color": "#9C27B0"},
},
{
"id": "trigger.schedule",
"category": "trigger",
"label": t("Start (Zeitplan)"),
"description": t("Cron-Ausdruck für geplante Läufe."),
"parameters": [
{
"name": "cron",
"type": "string",
"required": False,
"frontendType": "cron",
"description": t("Cron-Ausdruck"),
},
],
"inputs": 0,
"outputs": 1,
"inputPorts": {},
"outputPorts": {0: {"schema": "ActionResult"}},
"executor": "trigger",
"meta": {"icon": "mdi-clock", "color": "#2196F3"},
},
]

View file

@ -0,0 +1,92 @@
# 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": t("Buchhaltungsdaten aktualisieren"),
"description": t("Buchhaltungsdaten aus externem System importieren/aktualisieren."),
"parameters": [
{"name": "featureInstanceId", "type": "string", "required": True, "frontendType": "hidden",
"description": t("Trustee Feature-Instanz-ID")},
{"name": "forceRefresh", "type": "boolean", "required": False, "frontendType": "checkbox",
"description": t("Import erzwingen"), "default": False},
{"name": "dateFrom", "type": "string", "required": False, "frontendType": "date",
"description": t("Startdatum"), "default": ""},
{"name": "dateTo", "type": "string", "required": False, "frontendType": "date",
"description": t("Enddatum"), "default": ""},
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "ActionResult"}},
"meta": {"icon": "mdi-database-refresh", "color": "#4CAF50"},
"_method": "trustee",
"_action": "refreshAccountingData",
},
{
"id": "trustee.extractFromFiles",
"category": "trustee",
"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": t("SharePoint-Verbindung"), "default": ""},
{"name": "sharepointFolder", "type": "string", "required": False, "frontendType": "sharepointFolder",
"frontendOptions": {"dependsOn": "connectionReference"},
"description": t("SharePoint-Ordnerpfad"), "default": ""},
{"name": "featureInstanceId", "type": "string", "required": True, "frontendType": "hidden",
"description": t("Trustee Feature-Instanz-ID")},
{"name": "prompt", "type": "string", "required": False, "frontendType": "textarea",
"description": t("AI-Prompt für Extraktion"), "default": ""},
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["DocumentList", "Transit"]}},
"outputPorts": {0: {"schema": "DocumentList"}},
"meta": {"icon": "mdi-file-document-scan", "color": "#4CAF50"},
"_method": "trustee",
"_action": "extractFromFiles",
},
{
"id": "trustee.processDocuments",
"category": "trustee",
"label": t("Dokumente verarbeiten"),
"description": t("TrusteeDocument + TrusteePosition aus Extraktionsergebnis erstellen."),
"parameters": [
{"name": "documentList", "type": "string", "required": False, "frontendType": "hidden",
"description": t("Automatisch via Wire-Verbindung befüllt")},
{"name": "featureInstanceId", "type": "string", "required": True, "frontendType": "hidden",
"description": t("Trustee Feature-Instanz-ID")},
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["DocumentList", "Transit"]}},
"outputPorts": {0: {"schema": "ActionResult"}},
"meta": {"icon": "mdi-file-document-check", "color": "#4CAF50"},
"_method": "trustee",
"_action": "processDocuments",
},
{
"id": "trustee.syncToAccounting",
"category": "trustee",
"label": t("In Buchhaltung synchronisieren"),
"description": t("Trustee-Positionen in Buchhaltungssystem übertragen."),
"parameters": [
{"name": "documentList", "type": "string", "required": False, "frontendType": "hidden",
"description": t("Automatisch via Wire-Verbindung befüllt")},
{"name": "featureInstanceId", "type": "string", "required": True, "frontendType": "hidden",
"description": t("Trustee Feature-Instanz-ID")},
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "ActionResult"}},
"meta": {"icon": "mdi-calculator", "color": "#4CAF50"},
"_method": "trustee",
"_action": "syncToAccounting",
},
]

View file

@ -0,0 +1,125 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Node Type Registry for graphicalEditor - static node definitions (ai, email, sharepoint, trigger, flow, data, input).
Nodes are defined first; IO/method actions are used at execution time.
"""
import logging
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, resolveText
logger = logging.getLogger(__name__)
def getNodeTypes(
services: Any = None,
language: str = "de",
) -> List[Dict[str, Any]]:
"""
Return static node types. No dynamic I/O derivation from methodDiscovery.
services: Optional (kept for API compatibility, not used).
"""
return list(STATIC_NODE_TYPES)
def _pickFromLangMap(d: Any, lang: str) -> Any:
"""Resolve multilingual dict: ``lang`` → ``xx`` → ``de`` → ``en`` → first non-empty value."""
if not isinstance(d, dict) or not d:
return None
for k in (lang, "xx", "de", "en"):
v = d.get(k)
if v is not None and v != "":
return v
for v in d.values():
if v is not None and v != "":
return v
return None
def _localizeNode(node: Dict[str, Any], language: str) -> Dict[str, Any]:
"""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]
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 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)
pd = p.get("description")
if pd is not None:
pc["description"] = resolveText(pd, lang)
params.append(pc)
out["parameters"] = params
return out
def getNodeTypesForApi(
services: Any,
language: str = "de",
) -> Dict[str, Any]:
"""
API-ready response: nodeTypes with localized strings, plus categories, portTypeCatalog, systemVariables.
"""
nodes = getNodeTypes(services, language)
localized = [_localizeNode(n, language) for n in nodes]
categories = [
{"id": "trigger", "label": "Trigger"},
{"id": "input", "label": "Eingabe/Mensch"},
{"id": "flow", "label": "Ablauf"},
{"id": "data", "label": "Daten"},
{"id": "ai", "label": "KI"},
{"id": "file", "label": "Datei"},
{"id": "email", "label": "E-Mail"},
{"id": "sharepoint", "label": "SharePoint"},
{"id": "clickup", "label": "ClickUp"},
{"id": "trustee", "label": "Treuhand"},
]
catalogSerialized = {}
for name, schema in PORT_TYPE_CATALOG.items():
catalogSerialized[name] = {
"name": schema.name,
"fields": [f.model_dump() for f in schema.fields],
}
return {
"nodeTypes": localized,
"categories": categories,
"portTypeCatalog": catalogSerialized,
"systemVariables": SYSTEM_VARIABLES,
}
def getNodeTypeToMethodAction() -> Dict[str, tuple]:
"""
Mapping from node type id to (method, action) for execution.
Used by ActionNodeExecutor.
"""
mapping = {}
for node in STATIC_NODE_TYPES:
method = node.get("_method")
action = node.get("_action")
if method and action:
mapping[node["id"]] = (method, action)
return mapping

View file

@ -0,0 +1,510 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Typed Port System for the Graphical Editor.
Defines PortSchema, PORT_TYPE_CATALOG, SYSTEM_VARIABLES,
output normalizers, input extractors, and Transit helpers.
"""
import logging
import time
import uuid
from typing import Any, Callable, Dict, List, Optional
from pydantic import BaseModel, Field
from modules.shared.i18nRegistry import resolveText
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Pydantic models
# ---------------------------------------------------------------------------
class PortField(BaseModel):
name: str
type: str # str, int, bool, List[str], List[Document], Dict[str,Any]
description: str = ""
required: bool = True
class PortSchema(BaseModel):
name: str # e.g. "EmailDraft", "AiResult", "Transit"
fields: List[PortField]
class InputPortDef(BaseModel):
accepts: List[str] # list of accepted schema names
class OutputPortDef(BaseModel):
model_config = {"populate_by_name": True}
schema_: str = Field(alias="schema")
dynamic: bool = False
deriveFrom: Optional[str] = None
def model_dump(self, **kw):
d = super().model_dump(**kw)
d["schema"] = d.pop("schema_", d.get("schema"))
return d
# ---------------------------------------------------------------------------
# PORT_TYPE_CATALOG
# ---------------------------------------------------------------------------
PORT_TYPE_CATALOG: Dict[str, PortSchema] = {
"DocumentList": PortSchema(name="DocumentList", fields=[
PortField(name="documents", type="List[Document]",
description="Dokumentenliste"),
]),
"FileList": PortSchema(name="FileList", fields=[
PortField(name="files", type="List[File]",
description="Dateiliste"),
]),
"EmailDraft": PortSchema(name="EmailDraft", fields=[
PortField(name="subject", type="str",
description="Betreff"),
PortField(name="body", type="str",
description="Inhalt"),
PortField(name="to", type="List[str]",
description="Empfänger"),
PortField(name="cc", type="List[str]", required=False,
description="CC"),
PortField(name="attachments", type="List[Document]", required=False,
description="Anhänge"),
]),
"EmailList": PortSchema(name="EmailList", fields=[
PortField(name="emails", type="List[Email]",
description="E-Mails"),
]),
"TaskList": PortSchema(name="TaskList", fields=[
PortField(name="tasks", type="List[Task]",
description="Aufgaben"),
]),
"TaskResult": PortSchema(name="TaskResult", fields=[
PortField(name="success", type="bool",
description="Erfolg"),
PortField(name="taskId", type="str",
description="Aufgaben-ID"),
PortField(name="task", type="Dict",
description="Aufgabendaten"),
]),
"FormPayload": PortSchema(name="FormPayload", fields=[
PortField(name="payload", type="Dict[str,Any]",
description="Formulardaten"),
]),
"AiResult": PortSchema(name="AiResult", fields=[
PortField(name="prompt", type="str",
description="Prompt"),
PortField(name="response", type="str",
description="Antworttext"),
PortField(name="responseData", type="Dict", required=False,
description="Strukturierte Antwort"),
PortField(name="context", type="str",
description="Kontext"),
PortField(name="documents", type="List[Document]",
description="Dokumente"),
]),
"BoolResult": PortSchema(name="BoolResult", fields=[
PortField(name="result", type="bool",
description="Ergebnis"),
PortField(name="reason", type="str", required=False,
description="Begründung"),
]),
"TextResult": PortSchema(name="TextResult", fields=[
PortField(name="text", type="str",
description="Text"),
]),
"LoopItem": PortSchema(name="LoopItem", fields=[
PortField(name="currentItem", type="Any",
description="Aktuelles Element"),
PortField(name="currentIndex", type="int",
description="Aktueller Index"),
PortField(name="items", type="List[Any]",
description="Alle Elemente"),
PortField(name="count", type="int",
description="Gesamtanzahl"),
]),
"AggregateResult": PortSchema(name="AggregateResult", fields=[
PortField(name="items", type="List[Any]",
description="Gesammelte Elemente"),
PortField(name="count", type="int",
description="Anzahl"),
]),
"MergeResult": PortSchema(name="MergeResult", fields=[
PortField(name="inputs", type="Dict[int,Any]",
description="Eingaben nach Port"),
PortField(name="first", type="Any",
description="Erstes verfügbares"),
PortField(name="merged", type="Dict",
description="Zusammengeführte Daten"),
]),
"ActionResult": PortSchema(name="ActionResult", fields=[
PortField(name="success", type="bool",
description="Erfolg"),
PortField(name="error", type="str", required=False,
description="Fehler"),
PortField(name="data", type="Dict", required=False,
description="Ergebnisdaten"),
]),
"Transit": PortSchema(name="Transit", fields=[]),
}
# ---------------------------------------------------------------------------
# SYSTEM_VARIABLES
# ---------------------------------------------------------------------------
SYSTEM_VARIABLES: Dict[str, Dict[str, str]] = {
"system.timestamp": {"type": "int", "description": "Unix timestamp (ms)"},
"system.date": {"type": "str", "description": "ISO date (YYYY-MM-DD)"},
"system.datetime": {"type": "str", "description": "ISO datetime"},
"system.time": {"type": "str", "description": "HH:MM:SS"},
"system.userId": {"type": "str", "description": "Current user ID"},
"system.userName": {"type": "str", "description": "Current user name"},
"system.userEmail": {"type": "str", "description": "Current user email"},
"system.workflowId": {"type": "str", "description": "Workflow ID"},
"system.runId": {"type": "str", "description": "Run ID"},
"system.instanceId": {"type": "str", "description": "Feature instance ID"},
"system.mandateId": {"type": "str", "description": "Mandate ID"},
"system.loopIndex": {"type": "int", "description": "Current loop index (only in loop)"},
"system.loopCount": {"type": "int", "description": "Loop item count (only in loop)"},
"system.uuid": {"type": "str", "description": "Random UUID"},
}
def _resolveSystemVariable(variable: str, context: Dict[str, Any]) -> Any:
"""Resolve a system variable name to its runtime value."""
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
mapping = {
"system.timestamp": lambda: int(now.timestamp() * 1000),
"system.date": lambda: now.strftime("%Y-%m-%d"),
"system.datetime": lambda: now.isoformat(),
"system.time": lambda: now.strftime("%H:%M:%S"),
"system.userId": lambda: context.get("userId", ""),
"system.userName": lambda: context.get("userName", ""),
"system.userEmail": lambda: context.get("userEmail", ""),
"system.workflowId": lambda: context.get("workflowId", ""),
"system.runId": lambda: context.get("_runId", ""),
"system.instanceId": lambda: context.get("instanceId", ""),
"system.mandateId": lambda: context.get("mandateId", ""),
"system.loopIndex": lambda: (context.get("_loopState") or {}).get("currentIndex", -1),
"system.loopCount": lambda: len((context.get("_loopState") or {}).get("items", [])),
"system.uuid": lambda: str(uuid.uuid4()),
}
resolver = mapping.get(variable)
if resolver:
return resolver()
logger.warning("Unknown system variable: %s", variable)
return None
# ---------------------------------------------------------------------------
# Output normalizers
# ---------------------------------------------------------------------------
def _normalizeToSchema(raw: Any, schemaName: str) -> Dict[str, Any]:
"""
Normalize raw executor output to match the declared port schema.
Ensures _success/_error meta-fields are always present.
"""
if not isinstance(raw, dict):
raw = {"value": raw} if raw is not None else {}
result = dict(raw)
result.setdefault("_success", not bool(raw.get("error")))
result.setdefault("_error", raw.get("error"))
schema = PORT_TYPE_CATALOG.get(schemaName)
if not schema or schemaName == "Transit":
return result
for field in schema.fields:
if field.name not in result:
result[field.name] = _defaultForType(field.type)
return result
def _defaultForType(typeStr: str) -> Any:
"""Return a sensible default for a type string."""
if typeStr.startswith("List"):
return []
if typeStr.startswith("Dict"):
return {}
if typeStr == "bool":
return False
if typeStr == "int":
return 0
if typeStr == "str":
return ""
return None
def _normalizeError(error: Exception, schemaName: str) -> Dict[str, Any]:
"""Build an error envelope matching the schema with _success=False."""
result = {"_success": False, "_error": str(error)}
schema = PORT_TYPE_CATALOG.get(schemaName)
if schema:
for field in schema.fields:
result.setdefault(field.name, _defaultForType(field.type))
return result
# ---------------------------------------------------------------------------
# Input extractors (one per input port type)
# ---------------------------------------------------------------------------
def _extractEmailDraft(upstream: Dict[str, Any]) -> Dict[str, Any]:
"""Extract EmailDraft fields from upstream output."""
result = {}
if upstream.get("responseData") and isinstance(upstream["responseData"], dict):
rd = upstream["responseData"]
for key in ("subject", "body", "to", "cc"):
if key in rd:
result[key] = rd[key]
if not result:
for key in ("subject", "body", "to", "cc"):
if key in upstream:
result[key] = upstream[key]
return result
def _extractDocuments(upstream: Dict[str, Any]) -> Dict[str, Any]:
"""Extract documents from upstream output."""
docs = upstream.get("documents") or upstream.get("documentList") or []
if not docs and isinstance(upstream.get("data"), dict):
docs = upstream["data"].get("documents") or upstream["data"].get("documentList") or []
# input.upload format
if not docs:
files = upstream.get("files") or []
fileObj = upstream.get("file")
fileIds = upstream.get("fileIds") or []
if fileObj:
docs = [fileObj]
elif files:
docs = files
elif fileIds:
docs = [{"validationMetadata": {"fileId": fid}} for fid in fileIds]
return {"documents": docs if isinstance(docs, list) else [docs]} if docs else {}
def _extractText(upstream: Dict[str, Any]) -> Dict[str, Any]:
"""Extract text from upstream output."""
text = upstream.get("text") or upstream.get("response") or upstream.get("context") or ""
if not text and upstream.get("payload"):
import json
payload = upstream["payload"]
text = json.dumps(payload, ensure_ascii=False) if isinstance(payload, dict) else str(payload)
return {"text": str(text)} if text else {}
def _extractEmailList(upstream: Dict[str, Any]) -> Dict[str, Any]:
"""Extract email list from upstream output."""
emails = upstream.get("emails") or []
if not emails:
docs = upstream.get("documents") or upstream.get("documentList") or []
if docs:
import json
for doc in docs:
raw = doc.get("documentData") if isinstance(doc, dict) else None
if raw:
try:
data = json.loads(raw) if isinstance(raw, str) else raw
if isinstance(data, dict):
found = (data.get("emails", {}).get("emails", [])
or data.get("searchResults", {}).get("results", []))
if found:
emails = found
break
except (json.JSONDecodeError, TypeError):
pass
return {"emails": emails} if emails else {}
def _extractTaskList(upstream: Dict[str, Any]) -> Dict[str, Any]:
"""Extract task list from upstream output."""
tasks = upstream.get("tasks") or []
if not tasks:
docs = upstream.get("documents") or upstream.get("documentList") or []
if docs:
import json
for doc in docs:
raw = doc.get("documentData") if isinstance(doc, dict) else None
if raw:
try:
data = json.loads(raw) if isinstance(raw, str) else raw
if isinstance(data, dict) and "tasks" in data:
tasks = data["tasks"]
break
except (json.JSONDecodeError, TypeError):
pass
return {"tasks": tasks} if tasks else {}
def _extractFileList(upstream: Dict[str, Any]) -> Dict[str, Any]:
"""Extract file list from upstream output."""
files = upstream.get("files") or []
return {"files": files} if files else {}
def _extractFormPayload(upstream: Dict[str, Any]) -> Dict[str, Any]:
"""Extract form payload from upstream output."""
payload = upstream.get("payload")
if payload and isinstance(payload, dict):
return {"payload": payload}
return {}
def _extractAiResult(upstream: Dict[str, Any]) -> Dict[str, Any]:
"""Extract AI result fields from upstream output."""
result = {}
for key in ("prompt", "response", "responseData", "context", "documents"):
if key in upstream:
result[key] = upstream[key]
return result
def _extractBoolResult(upstream: Dict[str, Any]) -> Dict[str, Any]:
"""Extract bool result from upstream output."""
result = upstream.get("result")
if isinstance(result, bool):
return {"result": result, "reason": upstream.get("reason", "")}
approved = upstream.get("approved")
if isinstance(approved, bool):
return {"result": approved, "reason": upstream.get("reason", "")}
return {}
def _extractTaskResult(upstream: Dict[str, Any]) -> Dict[str, Any]:
"""Extract task result from upstream output."""
result = {}
if "taskId" in upstream:
result["taskId"] = upstream["taskId"]
if "task" in upstream:
result["task"] = upstream["task"]
elif "clickupTask" in upstream:
result["task"] = upstream["clickupTask"]
if "success" in upstream:
result["success"] = upstream["success"]
return result
def _extractAggregateResult(upstream: Dict[str, Any]) -> Dict[str, Any]:
"""Extract aggregate result from upstream output."""
items = upstream.get("items") or []
return {"items": items, "count": len(items)}
def _extractMergeResult(upstream: Dict[str, Any]) -> Dict[str, Any]:
"""Extract merge result from upstream output."""
return {
"inputs": upstream.get("inputs", {}),
"first": upstream.get("first"),
"merged": upstream.get("merged", {}),
}
INPUT_EXTRACTORS: Dict[str, Callable] = {
"EmailDraft": _extractEmailDraft,
"DocumentList": _extractDocuments,
"TextResult": _extractText,
"EmailList": _extractEmailList,
"TaskList": _extractTaskList,
"FileList": _extractFileList,
"FormPayload": _extractFormPayload,
"AiResult": _extractAiResult,
"BoolResult": _extractBoolResult,
"TaskResult": _extractTaskResult,
"AggregateResult": _extractAggregateResult,
"MergeResult": _extractMergeResult,
}
# ---------------------------------------------------------------------------
# Transit helpers
# ---------------------------------------------------------------------------
def _wrapTransit(data: Any, meta: Dict[str, Any]) -> Dict[str, Any]:
"""Wrap data in a Transit envelope."""
return {"_transit": True, "_meta": meta, "data": data}
def _unwrapTransit(output: Any) -> Any:
"""Unwrap a Transit envelope, returning the inner data."""
if isinstance(output, dict) and output.get("_transit"):
return output.get("data")
return output
def _resolveTransitChain(
nodeId: str,
nodeOutputs: Dict[str, Any],
connectionMap: Dict[str, list],
) -> Any:
"""
Follow _transit chain backwards until a real (non-transit) producer is found.
Returns the unwrapped output of the real producer.
"""
visited = set()
current = nodeId
while current and current not in visited:
visited.add(current)
out = nodeOutputs.get(current)
if not isinstance(out, dict) or not out.get("_transit"):
return out
sources = connectionMap.get(current, [])
if not sources:
return _unwrapTransit(out)
srcId = sources[0][0] if sources else None
if not srcId:
return _unwrapTransit(out)
current = srcId
return nodeOutputs.get(nodeId)
# ---------------------------------------------------------------------------
# Schema derivation for dynamic outputs
# ---------------------------------------------------------------------------
def _deriveFormPayloadSchema(node: Dict[str, Any]) -> Optional[PortSchema]:
"""Derive output schema from form field definitions."""
fields_param = (node.get("parameters") or {}).get("fields")
if not fields_param or not isinstance(fields_param, list):
return None
portFields = []
for f in fields_param:
if isinstance(f, dict) and f.get("name"):
_lab = f.get("label")
_desc = resolveText(_lab) if _lab is not None else f["name"]
if not _desc.strip():
_desc = f["name"]
portFields.append(PortField(
name=f["name"],
type=f.get("type", "str"),
description=_desc,
required=f.get("required", False),
))
return PortSchema(name="FormPayload_dynamic", fields=portFields) if portFields else None
def _deriveTransformSchema(node: Dict[str, Any]) -> Optional[PortSchema]:
"""Derive output schema from transform mappings."""
mappings = (node.get("parameters") or {}).get("mappings")
if not mappings or not isinstance(mappings, list):
return None
portFields = []
for m in mappings:
if isinstance(m, dict) and m.get("outputField"):
portFields.append(PortField(
name=m["outputField"],
type=m.get("type", "str"),
description=str(m.get("label", m["outputField"])),
))
return PortSchema(name="Transform_dynamic", fields=portFields) if portFields else None

File diff suppressed because it is too large Load diff

View file

@ -7,7 +7,7 @@ from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.i18nRegistry import i18nModel
class DataScope(str, Enum):
@ -17,83 +17,128 @@ class DataScope(str, Enum):
GLOBAL = "global"
@i18nModel("Daten-Neutralisierung Konfiguration")
class DataNeutraliserConfig(PowerOnModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the configuration", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
mandateId: str = Field(description="ID of the mandate this configuration belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
featureInstanceId: str = Field(description="ID of the feature instance this configuration belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
userId: str = Field(description="ID of the user who created this configuration", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
enabled: bool = Field(default=True, description="Whether data neutralization is enabled", json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False})
scope: str = Field(default="personal", description="Data visibility scope: personal, featureInstance, mandate, global", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "personal", "label": {"en": "Personal", "de": "Persönlich"}},
{"value": "featureInstance", "label": {"en": "Feature Instance", "de": "Feature-Instanz"}},
{"value": "mandate", "label": {"en": "Mandate", "de": "Mandant"}},
{"value": "global", "label": {"en": "Global", "de": "Global"}},
]})
neutralizationStatus: str = Field(default="not_required", description="Status of neutralization: pending, completed, failed, not_required", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
namesToParse: str = Field(default="", description="Multiline list of names to parse for neutralization", json_schema_extra={"frontend_type": "textarea", "frontend_readonly": False, "frontend_required": False})
sharepointSourcePath: str = Field(default="", description="SharePoint path to read files for neutralization", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
sharepointTargetPath: str = Field(default="", description="SharePoint path to store neutralized files", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
registerModelLabels(
"DataNeutraliserConfig",
{"en": "Data Neutralization Config", "fr": "Configuration de neutralisation des données"},
{
"id": {"en": "ID", "fr": "ID"},
"mandateId": {"en": "Mandate ID", "fr": "ID de mandat"},
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
"userId": {"en": "User ID", "fr": "ID utilisateur"},
"enabled": {"en": "Enabled", "fr": "Activé"},
"scope": {"en": "Scope", "fr": "Portée"},
"neutralizationStatus": {"en": "Neutralization Status", "fr": "Statut de neutralisation"},
"namesToParse": {"en": "Names to Parse", "fr": "Noms à analyser"},
"sharepointSourcePath": {"en": "Source Path", "fr": "Chemin source"},
"sharepointTargetPath": {"en": "Target Path", "fr": "Chemin cible"},
},
)
"""Konfiguration fuer die Daten-Neutralisierung."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the configuration",
json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
)
mandateId: str = Field(
description="ID of the mandate this configuration belongs to",
json_schema_extra={"label": "Mandanten-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
)
featureInstanceId: str = Field(
description="ID of the feature instance this configuration belongs to",
json_schema_extra={"label": "Feature-Instanz-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
)
userId: str = Field(
description="ID of the user who created this configuration",
json_schema_extra={"label": "Benutzer-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
)
enabled: bool = Field(
default=True,
description="Whether data neutralization is enabled",
json_schema_extra={"label": "Aktiviert", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False},
)
scope: str = Field(
default="personal",
description="Data visibility scope: personal, featureInstance, mandate, global",
json_schema_extra={"label": "Sichtbarkeit", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "personal", "label": "Persönlich"},
{"value": "featureInstance", "label": "Feature-Instanz"},
{"value": "mandate", "label": "Mandant"},
{"value": "global", "label": "Global"},
]},
)
neutralizationStatus: str = Field(
default="not_required",
description="Status of neutralization: pending, completed, failed, not_required",
json_schema_extra={"label": "Neutralisierungsstatus", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
)
namesToParse: str = Field(
default="",
description="Multiline list of names to parse for neutralization",
json_schema_extra={"label": "Zu parsende Namen", "frontend_type": "textarea", "frontend_readonly": False, "frontend_required": False},
)
sharepointSourcePath: str = Field(
default="",
description="SharePoint path to read files for neutralization",
json_schema_extra={"label": "SharePoint Quellpfad", "frontend_type": "text", "frontend_readonly": False, "frontend_required": False},
)
sharepointTargetPath: str = Field(
default="",
description="SharePoint path to store neutralized files",
json_schema_extra={"label": "SharePoint Zielpfad", "frontend_type": "text", "frontend_readonly": False, "frontend_required": False},
)
@i18nModel("Neutralisiertes Datenattribut")
class DataNeutralizerAttributes(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the attribute mapping (used as UID in neutralized files)", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
mandateId: str = Field(description="ID of the mandate this attribute belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
featureInstanceId: str = Field(description="ID of the feature instance this attribute belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
userId: str = Field(description="ID of the user who created this attribute", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
originalText: str = Field(description="Original text that was neutralized", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
fileId: Optional[str] = Field(default=None, description="ID of the file this attribute belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
patternType: str = Field(description="Type of pattern that matched (email, phone, name, etc.)", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
"""Zuordnung Originaltext zu Platzhalter fuer neutralisierte Daten."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the attribute mapping (used as UID in neutralized files)",
json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
)
mandateId: str = Field(
description="ID of the mandate this attribute belongs to",
json_schema_extra={"label": "Mandanten-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
)
featureInstanceId: str = Field(
description="ID of the feature instance this attribute belongs to",
json_schema_extra={"label": "Feature-Instanz-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
)
userId: str = Field(
description="ID of the user who created this attribute",
json_schema_extra={"label": "Benutzer-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
)
originalText: str = Field(
description="Original text that was neutralized",
json_schema_extra={"label": "Originaltext", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
)
fileId: Optional[str] = Field(
default=None,
description="ID of the file this attribute belongs to",
json_schema_extra={"label": "Datei-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
)
patternType: str = Field(
description="Type of pattern that matched (email, phone, name, etc.)",
json_schema_extra={"label": "Mustertyp", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
)
@i18nModel("Neutralisierungs-Snapshot")
class DataNeutralizationSnapshot(BaseModel):
"""Stores the full neutralized text (with embedded placeholders) per source."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
mandateId: str = Field(description="Mandate scope")
featureInstanceId: str = Field(default="", description="Feature instance scope")
userId: str = Field(description="User who triggered neutralization")
sourceLabel: str = Field(description="Human label, e.g. 'Prompt', 'Kontext', 'Nachricht 3'")
neutralizedText: str = Field(description="Full text with [type.uuid] placeholders embedded")
placeholderCount: int = Field(default=0, description="Number of placeholders in the text")
registerModelLabels(
"DataNeutralizerAttributes",
{"en": "Neutralized Data Attribute", "fr": "Attribut de données neutralisées"},
{
"id": {"en": "ID", "fr": "ID"},
"mandateId": {"en": "Mandate ID", "fr": "ID de mandat"},
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
"userId": {"en": "User ID", "fr": "ID utilisateur"},
"originalText": {"en": "Original Text", "fr": "Texte original"},
"fileId": {"en": "File ID", "fr": "ID de fichier"},
"patternType": {"en": "Pattern Type", "fr": "Type de modèle"},
},
)
registerModelLabels(
"DataNeutralizationSnapshot",
{"en": "Neutralization Snapshot", "de": "Neutralisierungs-Snapshot"},
{
"id": {"en": "ID"},
"mandateId": {"en": "Mandate ID"},
"featureInstanceId": {"en": "Feature Instance ID"},
"userId": {"en": "User ID"},
"sourceLabel": {"en": "Source", "de": "Quelle"},
"neutralizedText": {"en": "Neutralized Text", "de": "Neutralisierter Text"},
"placeholderCount": {"en": "Placeholders", "de": "Platzhalter"},
},
)
"""Speichert den vollstaendigen neutralisierten Text (mit Platzhaltern) pro Quelle."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
json_schema_extra={"label": "ID"},
)
mandateId: str = Field(
description="Mandate scope",
json_schema_extra={"label": "Mandanten-ID"},
)
featureInstanceId: str = Field(
default="",
description="Feature instance scope",
json_schema_extra={"label": "Feature-Instanz-ID"},
)
userId: str = Field(
description="User who triggered neutralization",
json_schema_extra={"label": "Benutzer-ID"},
)
sourceLabel: str = Field(
description="Human label, e.g. 'Prompt', 'Kontext', 'Nachricht 3'",
json_schema_extra={"label": "Quelle"},
)
neutralizedText: str = Field(
description="Full text with [type.uuid] placeholders embedded",
json_schema_extra={"label": "Neutralisierter Text"},
)
placeholderCount: int = Field(
default=0,
description="Number of placeholders in the text",
json_schema_extra={"label": "Platzhalter"},
)

View file

@ -12,14 +12,14 @@ logger = logging.getLogger(__name__)
# Feature metadata
FEATURE_CODE = "neutralization"
FEATURE_LABEL = {"en": "Neutralization", "de": "Neutralisierung", "fr": "Neutralisation"}
FEATURE_LABEL = "Neutralisierung"
FEATURE_ICON = "mdi-shield-check"
# UI Objects for RBAC catalog
UI_OBJECTS = [
{
"objectKey": "ui.feature.neutralization.playground",
"label": {"en": "Playground", "de": "Spielwiese", "fr": "Bac à sable"},
"label": "Spielwiese",
"meta": {"area": "playground"}
}
]
@ -28,17 +28,17 @@ UI_OBJECTS = [
RESOURCE_OBJECTS = [
{
"objectKey": "resource.feature.neutralization.process.text",
"label": {"en": "Process Text", "de": "Text verarbeiten", "fr": "Traiter texte"},
"label": "Text verarbeiten",
"meta": {"endpoint": "/api/neutralization/process/text", "method": "POST"}
},
{
"objectKey": "resource.feature.neutralization.process.files",
"label": {"en": "Process Files", "de": "Dateien verarbeiten", "fr": "Traiter fichiers"},
"label": "Dateien verarbeiten",
"meta": {"endpoint": "/api/neutralization/process/files", "method": "POST"}
},
{
"objectKey": "resource.feature.neutralization.config.update",
"label": {"en": "Update Config", "de": "Konfiguration aktualisieren", "fr": "Mettre à jour config"},
"label": "Konfiguration aktualisieren",
"meta": {"endpoint": "/api/neutralization/config", "method": "PUT"}
},
]
@ -47,11 +47,7 @@ RESOURCE_OBJECTS = [
TEMPLATE_ROLES = [
{
"roleLabel": "neutralization-viewer",
"description": {
"en": "Neutralization Viewer - View neutralization data (read-only)",
"de": "Neutralisierungs-Betrachter - Neutralisierungsdaten einsehen (nur lesen)",
"fr": "Visualiseur neutralisation - Consulter les données de neutralisation (lecture seule)",
},
"description": "Neutralisierungs-Betrachter - Neutralisierungsdaten einsehen (nur lesen)",
"accessRules": [
{"context": "UI", "item": "ui.feature.neutralization.playground", "view": True},
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
@ -59,11 +55,7 @@ TEMPLATE_ROLES = [
},
{
"roleLabel": "neutralization-user",
"description": {
"en": "Neutralization User - Use neutralization tools and manage own data",
"de": "Neutralisierungs-Benutzer - Neutralisierungstools nutzen und eigene Daten verwalten",
"fr": "Utilisateur neutralisation - Utiliser les outils et gérer ses propres données",
},
"description": "Neutralisierungs-Benutzer - Neutralisierungstools nutzen und eigene Daten verwalten",
"accessRules": [
{"context": "UI", "item": "ui.feature.neutralization.playground", "view": True},
{"context": "UI", "item": "ui.feature.neutralization.attributes", "view": True},
@ -72,11 +64,7 @@ TEMPLATE_ROLES = [
},
{
"roleLabel": "neutralization-admin",
"description": {
"en": "Neutralization Administrator - Full access to neutralization settings and data",
"de": "Neutralisierungs-Administrator - Vollzugriff auf Neutralisierungs-Einstellungen und Daten",
"fr": "Administrateur neutralisation - Accès complet aux paramètres et données",
},
"description": "Neutralisierungs-Administrator - Vollzugriff auf Neutralisierungs-Einstellungen und Daten",
"accessRules": [
{"context": "UI", "item": None, "view": True},
{"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"},
@ -84,11 +72,7 @@ TEMPLATE_ROLES = [
},
{
"roleLabel": "neutralization-analyst",
"description": {
"en": "Neutralization Analyst - Analyze and process neutralization data",
"de": "Neutralisierungs-Analyst - Neutralisierungsdaten analysieren und verarbeiten",
"fr": "Analyste neutralisation - Analyser et traiter les données de neutralisation",
},
"description": "Neutralisierungs-Analyst - Neutralisierungsdaten analysieren und verarbeiten",
"accessRules": [
{"context": "UI", "item": "ui.feature.neutralization.playground", "view": True},
{"context": "UI", "item": "ui.feature.neutralization.attributes", "view": True},
@ -163,6 +147,7 @@ def _syncTemplateRolesToDb() -> int:
try:
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext
from modules.datamodels.datamodelUtils import coerce_text_multilingual
rootInterface = getRootInterface()
@ -180,7 +165,7 @@ def _syncTemplateRolesToDb() -> int:
else:
newRole = Role(
roleLabel=roleLabel,
description=roleTemplate.get("description", {}),
description=coerce_text_multilingual(roleTemplate.get("description", {})),
featureCode=FEATURE_CODE,
mandateId=None,
featureInstanceId=None,

Some files were not shown because too many files have changed in this diff Show more