Compare commits
27 commits
4ed9b605fc
...
513ded84d5
| Author | SHA1 | Date | |
|---|---|---|---|
| 513ded84d5 | |||
| da1f3f53d0 | |||
| 060ca72eb4 | |||
| 6b5e386469 | |||
| 1a1128cc8c | |||
| e2d230f2c6 | |||
| 0c7ab77728 | |||
| 1053d0c715 | |||
| ac85c8e3dc | |||
| 9719a22581 | |||
| c2443a7781 | |||
| 31955751fb | |||
| a46e12638e | |||
| afbb8177a3 | |||
| e7874d8e38 | |||
| c4a9a66c60 | |||
| 59ad6f3849 | |||
| bc6bb44d6d | |||
| 8bc1dd22f1 | |||
| c990dd0317 | |||
| 79ec552264 | |||
| 906870faa8 | |||
| a59ee53e3c | |||
| c3530fe2aa | |||
| 2cfbb41cdf | |||
| ca261c1f5f | |||
| e800bc0b71 |
123 changed files with 14349 additions and 4398 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -1,741 +0,0 @@
|
||||||
---
|
|
||||||
name: Swift iOS App Nachbau
|
|
||||||
overview: Vollständiger Implementierungsplan für den Nachbau des React-Web-Frontends (frontend_nyla) als native Swift/SwiftUI iOS/iPadOS-App. Die App kommuniziert mit dem bestehenden FastAPI-Gateway-Backend und bildet alle UI-Screens, Navigation und API-Schnittstellen nach.
|
|
||||||
todos:
|
|
||||||
- id: phase-0
|
|
||||||
content: "Phase 0: Xcode-Projekt erstellen, Ordnerstruktur, SPM-Dependencies, Build-Configs (Dev/Int/Prod)"
|
|
||||||
status: pending
|
|
||||||
- id: phase-1
|
|
||||||
content: "Phase 1: Core Networking Layer -- APIClient, SSEClient, WebSocketClient, CSRFManager (analog api.ts + sseClient.ts)"
|
|
||||||
status: pending
|
|
||||||
- id: phase-2
|
|
||||||
content: "Phase 2: Authentication -- LocalAuth, MSAL, Google, Biometrie, Keychain (analog authApi.ts + AuthProvider.tsx)"
|
|
||||||
status: pending
|
|
||||||
- id: phase-3
|
|
||||||
content: "Phase 3: Domain Models + FeatureStore (analog mandate.ts + featureStore.tsx)"
|
|
||||||
status: pending
|
|
||||||
- id: phase-4
|
|
||||||
content: "Phase 4: App Shell -- NavigationSplitView (iPad) / TabView (iPhone), Dashboard, Settings, backend-driven Sidebar"
|
|
||||||
status: pending
|
|
||||||
- id: phase-5
|
|
||||||
content: "Phase 5: i18n String Catalogs (de/en/fr) + Theme System (Light/Dark)"
|
|
||||||
status: pending
|
|
||||||
- id: phase-6
|
|
||||||
content: "Phase 6: Core Pages -- Store, GDPR, Basedata (Prompts/Files/Connections), Billing Transactions"
|
|
||||||
status: pending
|
|
||||||
- id: phase-7
|
|
||||||
content: "Phase 7: Shared UI Components -- FormGenerator, ContentPreview, ChatMessage, AccessRules, NotificationBell"
|
|
||||||
status: pending
|
|
||||||
- id: phase-8
|
|
||||||
content: "Phase 8: Push Notifications (APNs Registration, Deep-Link Handling)"
|
|
||||||
status: pending
|
|
||||||
- id: phase-9
|
|
||||||
content: "Phase 9: Admin Module -- alle 16 Admin-Seiten (Mandates, Users, RBAC, Invitations, Wizards, etc.)"
|
|
||||||
status: pending
|
|
||||||
- id: phase-10
|
|
||||||
content: "Phase 10: Feature Trustee -- Dashboard, Documents, Positions, Roles, Expense-Import, Scan, Accounting"
|
|
||||||
status: pending
|
|
||||||
- id: phase-11
|
|
||||||
content: "Phase 11: Feature Workspace -- Chat-Streaming (SSE), Files, Datasources, Voice"
|
|
||||||
status: pending
|
|
||||||
- id: phase-12
|
|
||||||
content: "Phase 12: Feature Chatbot -- SSE-Streaming Chat, Threads, Conversations"
|
|
||||||
status: pending
|
|
||||||
- id: phase-13
|
|
||||||
content: "Phase 13: Feature Teamsbot -- Sessions, WebSocket Bot-Kommunikation, Voice, MFA"
|
|
||||||
status: pending
|
|
||||||
- id: phase-14
|
|
||||||
content: "Phase 14: Feature CommCoach -- Coaching Sessions, Audio-Streaming, Personas, Dossier"
|
|
||||||
status: pending
|
|
||||||
- id: phase-15
|
|
||||||
content: "Phase 15: Feature ChatPlayground -- Workflows, Playground mit SSE-Stream"
|
|
||||||
status: pending
|
|
||||||
- id: phase-16
|
|
||||||
content: "Phase 16: Feature Automation -- Definitions, Templates, Logs, Execute"
|
|
||||||
status: pending
|
|
||||||
- id: phase-17
|
|
||||||
content: "Phase 17: Feature CodeEditor -- Editor mit SSE-Stream, Code-Anzeige, Apply"
|
|
||||||
status: pending
|
|
||||||
- id: phase-18
|
|
||||||
content: "Phase 18: Feature RealEstate/PEK -- MapKit-Integration, Parcels, Address-Search, BZO"
|
|
||||||
status: pending
|
|
||||||
- id: phase-19
|
|
||||||
content: "Phase 19: Feature Neutralization -- Config, Neutralize Text/File"
|
|
||||||
status: pending
|
|
||||||
- id: phase-20
|
|
||||||
content: "Phase 20: Billing-Erweiterung -- Admin-Views, Stripe Checkout"
|
|
||||||
status: pending
|
|
||||||
isProject: false
|
|
||||||
---
|
|
||||||
|
|
||||||
# Nyla iOS/iPadOS App -- Vollständiger Implementierungsplan
|
|
||||||
|
|
||||||
## Ausgangslage
|
|
||||||
|
|
||||||
Das bestehende Web-Frontend (`frontend_nyla`) ist eine **React 19 + Vite + TypeScript** Anwendung mit:
|
|
||||||
|
|
||||||
- **12+ Feature-Module** (Trustee, Workspace, Chatbot, Teamsbot, CommCoach, CodeEditor, Automation, RealEstate, Neutralization, ChatPlayground, Billing, Admin)
|
|
||||||
- **21 API-Module** unter `src/api/*.ts` mit insgesamt **200+ API-Endpunkten**
|
|
||||||
- **120+ UI-Komponenten** inkl. dynamischem FormGenerator, ContentPreview, Chat-Streaming, Maps, Charts
|
|
||||||
- **Multi-Tenant-Architektur**: Mandate > Features > Instanzen > Views/Permissions
|
|
||||||
- **3 Auth-Provider**: Local, Microsoft MSAL, Google OAuth
|
|
||||||
- **Echtzeit**: SSE-Streaming (Chat, Workspace, CodeEditor) + WebSockets (Voice)
|
|
||||||
- **Backend**: FastAPI (Python) auf PostgreSQL, erreichbar unter konfigurierbarer `VITE_API_BASE_URL`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Technische Entscheidungen
|
|
||||||
|
|
||||||
|
|
||||||
| Aspekt | Entscheidung |
|
|
||||||
| -------------------- | --------------------------------------------------- |
|
|
||||||
| Plattform | iOS 18+ / iPadOS 18+ |
|
|
||||||
| UI-Framework | SwiftUI |
|
|
||||||
| Architektur | **MVVM + Repository Pattern** (s. unten) |
|
|
||||||
| Networking | URLSession + async/await |
|
|
||||||
| SSE | Custom SSE-Client auf URLSession-Basis |
|
|
||||||
| WebSocket | URLSessionWebSocketTask |
|
|
||||||
| Auth | MSAL SDK, Google Sign-In SDK, Keychain + Local Auth |
|
|
||||||
| Biometrie | LocalAuthentication (Face ID / Touch ID) |
|
|
||||||
| State | `@Observable` (Observation Framework, iOS 17+) |
|
|
||||||
| Navigation | `NavigationStack` + `NavigationSplitView` (iPad) |
|
|
||||||
| Dependency Injection | Environment-basiert (SwiftUI `@Environment`) |
|
|
||||||
| Package Manager | Swift Package Manager (SPM) |
|
|
||||||
| Karten | MapKit (SwiftUI) |
|
|
||||||
| Charts | Swift Charts |
|
|
||||||
| i18n | String Catalogs (`.xcstrings`) fuer de/en/fr |
|
|
||||||
| Push | APNs + UserNotifications Framework |
|
|
||||||
| PDF-Anzeige | PDFKit |
|
|
||||||
| Markdown | Native AttributedString (iOS 15+) |
|
|
||||||
| Persistenz | Keychain (Secrets), UserDefaults (Preferences) |
|
|
||||||
| Distribution | TestFlight |
|
|
||||||
|
|
||||||
|
|
||||||
### Architektur: MVVM + Repository Pattern
|
|
||||||
|
|
||||||
```
|
|
||||||
Presentation Layer (SwiftUI Views)
|
|
||||||
|
|
|
||||||
v
|
|
||||||
ViewModels (@Observable)
|
|
||||||
|
|
|
||||||
v
|
|
||||||
Repositories (Protokolle)
|
|
||||||
|
|
|
||||||
v
|
|
||||||
API Services (URLSession)
|
|
||||||
|
|
|
||||||
v
|
|
||||||
Gateway Backend (FastAPI)
|
|
||||||
```
|
|
||||||
|
|
||||||
Begründung: SwiftUI ist nativ MVVM-orientiert. Das Repository Pattern kapselt die Datenzugriffe und macht den Code testbar. `@Observable` (iOS 17+) ist leichter als `ObservableObject` und performanter.
|
|
||||||
|
|
||||||
### Projektstruktur
|
|
||||||
|
|
||||||
```
|
|
||||||
NylaApp/
|
|
||||||
NylaApp.swift // App Entry Point
|
|
||||||
Config/
|
|
||||||
AppConfig.swift // API URLs, Build Configs
|
|
||||||
Environment.swift // Dev/Int/Prod Environments
|
|
||||||
Core/
|
|
||||||
Networking/
|
|
||||||
APIClient.swift // Zentraler HTTP-Client (= api.ts)
|
|
||||||
APIError.swift // Error Types
|
|
||||||
APIEndpoints.swift // Endpoint Definitionen
|
|
||||||
SSEClient.swift // Server-Sent Events Client
|
|
||||||
WebSocketClient.swift // WebSocket Client
|
|
||||||
CSRFManager.swift // CSRF Token Handling
|
|
||||||
RequestInterceptor.swift // Auth/Mandate Headers
|
|
||||||
Auth/
|
|
||||||
AuthManager.swift // Zentrale Auth-Logik
|
|
||||||
LocalAuthService.swift // Username/Password
|
|
||||||
MSALAuthService.swift // Microsoft MSAL
|
|
||||||
GoogleAuthService.swift // Google Sign-In
|
|
||||||
BiometricAuthService.swift // Face ID / Touch ID
|
|
||||||
KeychainService.swift // Secure Storage
|
|
||||||
Navigation/
|
|
||||||
AppRouter.swift // Root Navigation
|
|
||||||
NavigationStore.swift // Backend-driven Nav State
|
|
||||||
DeepLinkHandler.swift // URL Scheme Handling
|
|
||||||
Localization/
|
|
||||||
Localizable.xcstrings // String Catalog
|
|
||||||
LanguageManager.swift // Sprachauswahl
|
|
||||||
Theme/
|
|
||||||
ThemeManager.swift // Light/Dark Mode
|
|
||||||
DesignTokens.swift // Farben, Spacing, Fonts
|
|
||||||
Permissions/
|
|
||||||
PermissionChecker.swift // RBAC Client-Checks
|
|
||||||
Domain/
|
|
||||||
Models/ // Shared Domain Models
|
|
||||||
Mandate.swift // Mandate, Feature, Instance
|
|
||||||
User.swift // User Model
|
|
||||||
Permissions.swift // AccessLevel, TablePermission
|
|
||||||
Pagination.swift // PaginatedResponse<T>
|
|
||||||
I18nLabel.swift // Mehrsprachige Labels
|
|
||||||
Repositories/ // Repository Protokolle
|
|
||||||
AuthRepository.swift
|
|
||||||
MandateRepository.swift
|
|
||||||
FeatureRepository.swift
|
|
||||||
...
|
|
||||||
Data/
|
|
||||||
API/ // API-Implementierungen (= src/api/*.ts)
|
|
||||||
AuthAPI.swift
|
|
||||||
UserAPI.swift
|
|
||||||
MandateAPI.swift
|
|
||||||
FeaturesAPI.swift
|
|
||||||
BillingAPI.swift
|
|
||||||
TrusteeAPI.swift
|
|
||||||
... (21 Module)
|
|
||||||
Repositories/ // Repository Implementierungen
|
|
||||||
DefaultAuthRepository.swift
|
|
||||||
DefaultMandateRepository.swift
|
|
||||||
...
|
|
||||||
Features/ // Feature-Module (je Ordner)
|
|
||||||
Dashboard/
|
|
||||||
Store/
|
|
||||||
Settings/
|
|
||||||
GDPR/
|
|
||||||
Basedata/
|
|
||||||
Prompts/
|
|
||||||
Files/
|
|
||||||
Connections/
|
|
||||||
Billing/
|
|
||||||
Admin/
|
|
||||||
Mandates/
|
|
||||||
Users/
|
|
||||||
Access/
|
|
||||||
Invitations/
|
|
||||||
...
|
|
||||||
Trustee/
|
|
||||||
Workspace/
|
|
||||||
Chatbot/
|
|
||||||
Teamsbot/
|
|
||||||
CommCoach/
|
|
||||||
CodeEditor/
|
|
||||||
ChatPlayground/
|
|
||||||
Automation/
|
|
||||||
RealEstate/
|
|
||||||
Neutralization/
|
|
||||||
Shared/
|
|
||||||
Components/ // Wiederverwendbare UI (= src/components/)
|
|
||||||
FormGenerator/ // Dynamische Formulare
|
|
||||||
ContentPreview/ // PDF, Bild, JSON Vorschau
|
|
||||||
ChatMessage/ // Chat-Nachrichten-Rendering
|
|
||||||
AccessRules/ // Zugriffsregeln-Editor
|
|
||||||
NotificationBell/ // Notification Badge + Overlay
|
|
||||||
SearchBar/
|
|
||||||
LoadingView/
|
|
||||||
ErrorView/
|
|
||||||
EmptyStateView/
|
|
||||||
Extensions/
|
|
||||||
Utilities/
|
|
||||||
Resources/
|
|
||||||
Assets.xcassets
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phasen-Plan
|
|
||||||
|
|
||||||
### Phase 0: Projekt-Setup (1-2 Tage)
|
|
||||||
|
|
||||||
- Xcode-Projekt erstellen (iOS 18+, SwiftUI App Lifecycle)
|
|
||||||
- Ordnerstruktur nach obigem Schema anlegen
|
|
||||||
- SPM Dependencies einrichten:
|
|
||||||
- `MSAL` (Microsoft Authentication Library for iOS)
|
|
||||||
- `GoogleSignIn` (Google Sign-In SDK)
|
|
||||||
- Keine weiteren externen Deps noetig (MapKit, Charts, PDFKit sind System-Frameworks)
|
|
||||||
- Build-Konfigurationen: **Dev** / **Int** / **Prod** mit je eigenem `API_BASE_URL`
|
|
||||||
- Analog zu den `.env.dev` / `.env.int` / `.env.prod` Dateien im Web-Frontend
|
|
||||||
- Werte: `http://localhost:8000` (Dev), INT-URL, PROD-URL
|
|
||||||
- TestFlight-Vorbereitung: App ID, Provisioning Profile, Signing
|
|
||||||
|
|
||||||
### Phase 1: Core Networking Layer (3-5 Tage)
|
|
||||||
|
|
||||||
**Ziel**: Equivalent zu `[src/api.ts](frontend_nyla/src/api.ts)` + `[src/hooks/useApi.ts](frontend_nyla/src/hooks/useApi.ts)`
|
|
||||||
|
|
||||||
**APIClient.swift** -- Zentraler HTTP-Client:
|
|
||||||
|
|
||||||
- `URLSession.shared` mit Custom-Configuration
|
|
||||||
- Cookie-basierte Auth (`httpCookieStorage`)
|
|
||||||
- Request-Interceptor fuer:
|
|
||||||
- `Authorization: Bearer` Header (aus Keychain)
|
|
||||||
- `X-Mandate-Id` / `X-Instance-Id` Header (aus aktuellem Navigation-Context)
|
|
||||||
- CSRF-Token fuer POST/PUT/PATCH/DELETE
|
|
||||||
- Response-Handler:
|
|
||||||
- 401 -> Redirect zu Login (analog Web `api.ts` Zeile 127-151)
|
|
||||||
- 429 -> Rate-Limit Warning
|
|
||||||
- Generische Fehlerextraktion (FastAPI `detail` Array/String)
|
|
||||||
- Generische Request-Methoden: `get<T>()`, `post<T>()`, `put<T>()`, `delete<T>()`, `upload()`
|
|
||||||
- `Codable`-basierte JSON Serialisierung
|
|
||||||
|
|
||||||
**SSEClient.swift** -- Server-Sent Events:
|
|
||||||
|
|
||||||
- Analog zu `[src/utils/sseClient.ts](frontend_nyla/src/utils/sseClient.ts)`
|
|
||||||
- URLSession mit `bytes(for:)` async stream
|
|
||||||
- Parsing von `data:` Lines
|
|
||||||
- Callbacks: `onMessage`, `onError`, `onComplete`
|
|
||||||
- Wird benoetigt fuer: Workspace, Chatbot, CodeEditor, CommCoach Streaming
|
|
||||||
|
|
||||||
**WebSocketClient.swift** -- WebSockets:
|
|
||||||
|
|
||||||
- `URLSessionWebSocketTask`
|
|
||||||
- Fuer Voice-Features (Teamsbot: `/api/teamsbot/{instanceId}/bot/ws/{sessionId}`)
|
|
||||||
- Ping/Pong, Reconnect-Logik
|
|
||||||
|
|
||||||
**CSRFManager.swift**:
|
|
||||||
|
|
||||||
- Token-Generierung und -Speicherung
|
|
||||||
- Analog zu `[src/utils/csrfUtils.ts](frontend_nyla/src/utils/csrfUtils.ts)`
|
|
||||||
|
|
||||||
### Phase 2: Authentication (3-5 Tage)
|
|
||||||
|
|
||||||
**Ziel**: Alle 3 Auth-Provider + Biometrie
|
|
||||||
|
|
||||||
**Mapping Web -> Swift:**
|
|
||||||
|
|
||||||
|
|
||||||
| Web (authApi.ts) | Swift |
|
|
||||||
| ---------------------------------------- | -------------------------------------------- |
|
|
||||||
| `POST /api/local/login` (form-data) | `LocalAuthService.login(username:password:)` |
|
|
||||||
| `POST /api/local/register` | `LocalAuthService.register(...)` |
|
|
||||||
| `POST /api/local/password-reset-request` | `LocalAuthService.requestPasswordReset(...)` |
|
|
||||||
| `POST /api/local/password-reset` | `LocalAuthService.resetPassword(...)` |
|
|
||||||
| `GET /api/local/available?username=` | `LocalAuthService.checkAvailability(...)` |
|
|
||||||
| `GET /api/local/me` | `AuthManager.fetchCurrentUser()` |
|
|
||||||
| `POST /api/local/logout` | `AuthManager.logout()` |
|
|
||||||
| MSAL Login/Callback | `MSALAuthService` via MSAL SDK |
|
|
||||||
| `GET /api/msft/me` | `MSALAuthService.fetchUser()` |
|
|
||||||
| Google Login/Callback | `GoogleAuthService` via Google Sign-In SDK |
|
|
||||||
| `GET /api/google/me` | `GoogleAuthService.fetchUser()` |
|
|
||||||
|
|
||||||
|
|
||||||
**AuthManager.swift** (zentral):
|
|
||||||
|
|
||||||
- Verwaltet aktiven Auth-Provider (`local` / `msft` / `google`)
|
|
||||||
- Speichert Auth-State in Keychain (nicht UserDefaults!)
|
|
||||||
- Published `isAuthenticated`, `currentUser`, `authAuthority`
|
|
||||||
- Analog zu `[src/providers/auth/AuthProvider.tsx](frontend_nyla/src/providers/auth/AuthProvider.tsx)`
|
|
||||||
|
|
||||||
**BiometricAuthService.swift**:
|
|
||||||
|
|
||||||
- `LAContext.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics)`
|
|
||||||
- Nach erstem erfolgreichen Login: Credentials in Keychain speichern
|
|
||||||
- Bei App-Start: Face ID/Touch ID -> Keychain Credentials -> Auto-Login
|
|
||||||
|
|
||||||
**Login Screen (SwiftUI)**:
|
|
||||||
|
|
||||||
- Username/Password Felder
|
|
||||||
- "Anmelden mit Microsoft" Button (MSAL)
|
|
||||||
- "Anmelden mit Google" Button (Google Sign-In)
|
|
||||||
- "Face ID / Touch ID" Option (wenn verfuegbar)
|
|
||||||
- Registrierung / Passwort vergessen Links
|
|
||||||
- Analog zu `[src/pages/Login.tsx](frontend_nyla/src/pages/Login.tsx)`
|
|
||||||
|
|
||||||
### Phase 3: Domain Models + Feature Store (2-3 Tage)
|
|
||||||
|
|
||||||
**Ziel**: Alle geteilten Datenmodelle + Feature-State
|
|
||||||
|
|
||||||
Zentrale Models (analog zu `[src/types/mandate.ts](frontend_nyla/src/types/mandate.ts)`):
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// Mandate.swift
|
|
||||||
struct I18nLabel: Codable { var de: String; var en: String; var fr: String? }
|
|
||||||
enum AccessLevel: String, Codable { case none = "n", my = "m", group = "g", all = "a" }
|
|
||||||
struct TablePermission: Codable { var view: Bool; var read, create, update, delete: AccessLevel }
|
|
||||||
struct FieldPermission: Codable { var read: Bool; var write: Bool }
|
|
||||||
struct InstancePermissions: Codable { var tables: [String: TablePermission]; var fields: [String: [String: FieldPermission]]?; var views: [String: Bool]; var isAdmin: Bool? }
|
|
||||||
struct FeatureInstance: Codable, Identifiable { var id: String; var featureCode, mandateId, mandateName, instanceLabel: String; var userRoles: [String]; var permissions: InstancePermissions }
|
|
||||||
struct MandateFeature: Codable { var code: String; var label: I18nLabel; var icon: String; var instances: [FeatureInstance] }
|
|
||||||
struct Mandate: Codable, Identifiable { var id, name: String; var label, code: String?; var features: [MandateFeature] }
|
|
||||||
struct FeaturesMyResponse: Codable { var mandates: [Mandate] }
|
|
||||||
```
|
|
||||||
|
|
||||||
**FeatureStore.swift** (analog zu `[src/stores/featureStore.tsx](frontend_nyla/src/stores/featureStore.tsx)`):
|
|
||||||
|
|
||||||
- `@Observable class FeatureStore`
|
|
||||||
- `loadFeatures()` -> `GET /api/features/my`
|
|
||||||
- Cache: `[String: FeatureInstance]` fuer schnellen Zugriff
|
|
||||||
- Methoden: `getMandateById()`, `getInstanceById()`, `getAllInstances()`, etc.
|
|
||||||
- Injected via SwiftUI `@Environment`
|
|
||||||
|
|
||||||
### Phase 4: App Shell + Navigation (4-6 Tage)
|
|
||||||
|
|
||||||
**Ziel**: MainLayout + FeatureLayout + backend-driven Navigation
|
|
||||||
|
|
||||||
**Adaptive Layout:**
|
|
||||||
|
|
||||||
- **iPad**: `NavigationSplitView` (Sidebar + Detail) -- analog Web-Sidebar
|
|
||||||
- **iPhone**: `TabView` mit Hauptbereichen + Navigation Stack pro Tab
|
|
||||||
|
|
||||||
**Sidebar / Navigation:**
|
|
||||||
|
|
||||||
- Backend-driven: `GET /api/navigation?language={lang}` liefert Navigationsbaum
|
|
||||||
- Analog zu `[src/components/Navigation/MandateNavigation.tsx](frontend_nyla/src/components/Navigation/MandateNavigation.tsx)`
|
|
||||||
- Hierarchie: Mandate > Feature > Instance > Views
|
|
||||||
- Icon-Mapping: SF Symbols statt React Icons (Mapping-Tabelle erstellen)
|
|
||||||
|
|
||||||
**Screen-Routing:**
|
|
||||||
|
|
||||||
- `NavigationStack` mit `NavigationPath` fuer programmatische Navigation
|
|
||||||
- Deep-Link-Schema: `nyla://mandates/{mandateId}/{featureCode}/{instanceId}/{view}`
|
|
||||||
- Feature-View-Dispatcher: analog zu `[src/pages/FeatureView.tsx](frontend_nyla/src/pages/FeatureView.tsx)` `VIEW_COMPONENTS`
|
|
||||||
|
|
||||||
**Screens in Phase 4:**
|
|
||||||
|
|
||||||
- Dashboard (`/`) -- Mandate/Instance-Karten, analog `[src/pages/Dashboard.tsx](frontend_nyla/src/pages/Dashboard.tsx)`
|
|
||||||
- Settings (`/settings`) -- Theme-Toggle, Sprache (de/en/fr), Profil
|
|
||||||
- UserSection im Sidebar-Footer
|
|
||||||
|
|
||||||
### Phase 5: i18n + Theme (2-3 Tage)
|
|
||||||
|
|
||||||
**Internationalisierung:**
|
|
||||||
|
|
||||||
- Xcode String Catalog (`.xcstrings`) fuer de/en/fr
|
|
||||||
- Alle statischen Strings aus den Web-Locales uebernehmen: `[src/locales/de.ts](frontend_nyla/src/locales/de.ts)`, `en.ts`, `fr.ts`
|
|
||||||
- Dynamische Labels (I18nLabel vom Backend): Helper `label.localized(lang:)` analog `getLabel()` im Web
|
|
||||||
- `LanguageManager` speichert Praeferenz in UserDefaults
|
|
||||||
|
|
||||||
**Theme:**
|
|
||||||
|
|
||||||
- SwiftUI `.preferredColorScheme()` fuer System-Integration
|
|
||||||
- Custom `DesignTokens` fuer konsistente Farben/Spacing
|
|
||||||
- Analog zu `[src/styles/themes/light.css](frontend_nyla/src/styles/themes/light.css)` + `.dark-theme`
|
|
||||||
|
|
||||||
### Phase 6: Core Pages (5-7 Tage)
|
|
||||||
|
|
||||||
**Store** (Feature Marketplace):
|
|
||||||
|
|
||||||
- `GET /api/store/features` -> Feature-Liste
|
|
||||||
- `POST /api/store/activate` / `POST /api/store/deactivate`
|
|
||||||
- Analog `[src/pages/Store.tsx](frontend_nyla/src/pages/Store.tsx)`
|
|
||||||
|
|
||||||
**GDPR**:
|
|
||||||
|
|
||||||
- `GET /api/user/me/data-export` + `/data-portability`
|
|
||||||
- `DELETE /api/user/me/`
|
|
||||||
- Analog `[src/pages/GDPR.tsx](frontend_nyla/src/pages/GDPR.tsx)`
|
|
||||||
|
|
||||||
**Basedata - Prompts** (`/basedata/prompts`):
|
|
||||||
|
|
||||||
- CRUD auf `/api/prompts` mit FormGenerator
|
|
||||||
- Analog `[src/pages/PromptsPage.tsx](frontend_nyla/src/pages/PromptsPage.tsx)`
|
|
||||||
|
|
||||||
**Basedata - Files** (`/basedata/files`):
|
|
||||||
|
|
||||||
- `GET /api/files/list`, Upload, Download, Preview
|
|
||||||
- Analog `[src/pages/FilesPage.tsx](frontend_nyla/src/pages/FilesPage.tsx)`
|
|
||||||
- Nutzung von `UIDocumentPickerViewController` (via UIKit-Bridge) fuer File-Upload
|
|
||||||
- `QuickLook` fuer Dateivorschau
|
|
||||||
|
|
||||||
**Basedata - Connections** (`/basedata/connections`):
|
|
||||||
|
|
||||||
- CRUD auf `/api/connections/`
|
|
||||||
- Connect/Disconnect Aktionen
|
|
||||||
- Analog `[src/pages/ConnectionsPage.tsx](frontend_nyla/src/pages/ConnectionsPage.tsx)`
|
|
||||||
|
|
||||||
**Billing** (`/billing/transactions`):
|
|
||||||
|
|
||||||
- `GET /api/billing/balance`, `/transactions`, `/statistics/{period}`
|
|
||||||
- Swift Charts fuer Statistik-Visualisierung
|
|
||||||
- Analog `[src/pages/billing/BillingDataView.tsx](frontend_nyla/src/pages/billing/BillingDataView.tsx)`
|
|
||||||
|
|
||||||
### Phase 7: Shared UI Components (5-8 Tage)
|
|
||||||
|
|
||||||
**FormGenerator** (zentral, wird von fast allen Features genutzt):
|
|
||||||
|
|
||||||
- Analog zu `[src/components/FormGenerator/](frontend_nyla/src/components/FormGenerator/)`
|
|
||||||
- Dynamische Formulare basierend auf `AttributeDefinition[]` vom Backend (`GET /api/attributes/{entityType}`)
|
|
||||||
- Feldtypen: String, Email, Select, Multiselect, Textarea, Checkbox, File, Number, DateTime, Multilingual
|
|
||||||
- Tabellen-Ansicht (`FormGeneratorTable`) + Listen-Ansicht (`FormGeneratorList`)
|
|
||||||
- Action Buttons (Edit, Delete, Download, Custom)
|
|
||||||
- Pagination-Support
|
|
||||||
|
|
||||||
**ContentPreview**:
|
|
||||||
|
|
||||||
- PDF: `PDFKitView` (UIKit PDFView in UIViewRepresentable)
|
|
||||||
- Bilder: AsyncImage
|
|
||||||
- JSON: Syntax-Highlighting
|
|
||||||
- HTML: WKWebView
|
|
||||||
- Analog `[src/components/ContentPreview/](frontend_nyla/src/components/ContentPreview/)`
|
|
||||||
|
|
||||||
**NotificationBell**:
|
|
||||||
|
|
||||||
- `GET /api/notifications/unread-count` (Polling)
|
|
||||||
- Push Notifications via APNs
|
|
||||||
- In-App Notification Sheet
|
|
||||||
- Analog `[src/components/NotificationBell/](frontend_nyla/src/components/NotificationBell/)`
|
|
||||||
|
|
||||||
**Chat Message Components**:
|
|
||||||
|
|
||||||
- Message-Bubbles mit Markdown-Rendering
|
|
||||||
- File-Attachments
|
|
||||||
- Streaming-Indicator (typing animation)
|
|
||||||
- Auto-Scroll
|
|
||||||
- Analog `[src/components/UiComponents/Messages/](frontend_nyla/src/components/UiComponents/Messages/)`
|
|
||||||
|
|
||||||
**AccessRules Components**:
|
|
||||||
|
|
||||||
- Tabelle + Editor fuer RBAC-Regeln
|
|
||||||
- Analog `[src/components/AccessRules/](frontend_nyla/src/components/AccessRules/)`
|
|
||||||
|
|
||||||
### Phase 8: Push Notifications (2-3 Tage)
|
|
||||||
|
|
||||||
- APNs-Registrierung in `AppDelegate`
|
|
||||||
- Device Token an Backend senden (neuer Endpoint oder bestehender `/api/messaging/subscriptions`)
|
|
||||||
- `UNUserNotificationCenter` fuer lokale + remote Notifications
|
|
||||||
- Deep-Link Handling aus Notification-Tap
|
|
||||||
|
|
||||||
### Phase 9: Admin Module (5-7 Tage)
|
|
||||||
|
|
||||||
Alle Admin-Seiten analog zu `[src/pages/admin/](frontend_nyla/src/pages/admin/)`:
|
|
||||||
|
|
||||||
|
|
||||||
| Admin-Seite | API-Endpunkte |
|
|
||||||
| -------------------- | ------------------------------------------ |
|
|
||||||
| Mandates | CRUD `/api/mandates/` |
|
|
||||||
| Users | CRUD `/api/users/` |
|
|
||||||
| User-Mandates | `/api/mandates/{id}/users` |
|
|
||||||
| Access Hub | `/api/rbac/permissions`, `/api/rbac/rules` |
|
|
||||||
| Feature Instances | `/api/features/instances` |
|
|
||||||
| Feature Roles | `/api/features/templates/roles` |
|
|
||||||
| Feature Users | `/api/features/instances/{id}/users` |
|
|
||||||
| Invitations | CRUD `/api/invitations/` |
|
|
||||||
| Mandate Roles | `/api/rbac/roles` |
|
|
||||||
| Role Permissions | `/api/rbac/rules/by-role/{roleId}` |
|
|
||||||
| User Access Overview | `/api/admin/user-access-overview/`* |
|
|
||||||
| Billing Admin | `/api/billing/admin/`* |
|
|
||||||
| Automation Events | `/api/admin/automation-events` |
|
|
||||||
| Logs | `/api/admin/logs` |
|
|
||||||
| Mandate Wizard | Kombination mehrerer Endpoints |
|
|
||||||
| Invitation Wizard | Kombination mehrerer Endpoints |
|
|
||||||
|
|
||||||
|
|
||||||
### Phase 10-20: Feature-Module (je 3-7 Tage pro Feature)
|
|
||||||
|
|
||||||
Jedes Feature folgt demselben Pattern:
|
|
||||||
|
|
||||||
1. **API-Modul** erstellen (alle Endpunkte des Features)
|
|
||||||
2. **ViewModels** fuer jede View
|
|
||||||
3. **SwiftUI Views** fuer jede registrierte View
|
|
||||||
4. **Feature-spezifische Komponenten** wo noetig
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Phase 10: Trustee (5-7 Tage)
|
|
||||||
|
|
||||||
Views: Dashboard, Documents, Positions, Instance-Roles, Expense-Import, Scan-Upload, Accounting Settings
|
|
||||||
|
|
||||||
API-Basis: `/api/trustee/{instanceId}/`
|
|
||||||
|
|
||||||
- Organisations, Roles, Access, Contracts, Documents, Positions CRUD
|
|
||||||
- Accounting: Connectors, Config, Sync
|
|
||||||
- Document Upload mit base64-Konvertierung
|
|
||||||
- Options-Endpoints fuer Dropdowns
|
|
||||||
|
|
||||||
Besonderheiten:
|
|
||||||
|
|
||||||
- Viele verschachtelte CRUD-Entitaeten (Organisation > Contract > Document > Position)
|
|
||||||
- Scan-Upload: iOS-Kamera-Integration + VisionKit (OCR)
|
|
||||||
|
|
||||||
#### Phase 11: Workspace (5-7 Tage)
|
|
||||||
|
|
||||||
Views: Dashboard (Chat-Stream), Settings
|
|
||||||
|
|
||||||
API-Basis: `/api/workspace/{instanceId}/`
|
|
||||||
|
|
||||||
- SSE-Streaming fuer Chat (`POST .../start/stream`)
|
|
||||||
- Workflows, Messages, Files, Datasources CRUD
|
|
||||||
- Voice: Transcribe, Synthesize, Settings
|
|
||||||
- File Browser mit Ordnerstruktur
|
|
||||||
|
|
||||||
Besonderheiten:
|
|
||||||
|
|
||||||
- **Zentrales SSE-Streaming** -- das Keep-Alive-Pattern aus dem Web (`WorkspaceKeepAlive`) muss in Swift via Task/Actor geloest werden
|
|
||||||
- Voice: AVFoundation fuer Audio-Aufnahme, URLSession fuer Upload
|
|
||||||
|
|
||||||
#### Phase 12: Chatbot (3-5 Tage)
|
|
||||||
|
|
||||||
Views: Conversations, Settings
|
|
||||||
|
|
||||||
API-Basis: `/api/chatbot/{instanceId}/`
|
|
||||||
|
|
||||||
- `POST .../start/stream` -- SSE-Streaming via fetch (nicht Axios!)
|
|
||||||
- Threads: List, Get, Delete
|
|
||||||
- Stop Workflow
|
|
||||||
|
|
||||||
Besonderheiten:
|
|
||||||
|
|
||||||
- Streaming-Chat mit File-Attachments
|
|
||||||
- Analog zu `chatbotApi.startChatbotStreamApi` -- Custom SSE via POST
|
|
||||||
|
|
||||||
#### Phase 13: Teamsbot (4-6 Tage)
|
|
||||||
|
|
||||||
Views: Dashboard, Sessions, Settings
|
|
||||||
|
|
||||||
API-Basis: `/api/teamsbot/{instanceId}/`
|
|
||||||
|
|
||||||
- Sessions CRUD + Stream (EventSource/SSE)
|
|
||||||
- Config, System Bots, User Account
|
|
||||||
- Voice Test
|
|
||||||
- MFA fuer Sessions
|
|
||||||
- WebSocket fuer Bot-Kommunikation (`/bot/ws/{sessionId}`)
|
|
||||||
|
|
||||||
Besonderheiten:
|
|
||||||
|
|
||||||
- **WebSocket** fuer Live-Bot-Interaction
|
|
||||||
- SSE via EventSource fuer Session-Stream
|
|
||||||
- Screenshot-Anzeige
|
|
||||||
|
|
||||||
#### Phase 14: CommCoach (4-6 Tage)
|
|
||||||
|
|
||||||
Views: Dashboard, Coaching, Dossier, Settings
|
|
||||||
|
|
||||||
API-Basis: `/api/commcoach/{instanceId}/`
|
|
||||||
|
|
||||||
- Contexts CRUD + Archive/Activate
|
|
||||||
- Sessions: Start, Message-Stream, Audio-Stream, Complete, Cancel
|
|
||||||
- Tasks CRUD + Status
|
|
||||||
- Personas CRUD, Documents, Badges, Score History
|
|
||||||
- Voice: Languages, Voices, TTS
|
|
||||||
- Export (Dossier, Session)
|
|
||||||
|
|
||||||
Besonderheiten:
|
|
||||||
|
|
||||||
- **Audio-Streaming**: Mikrofon-Aufnahme -> POST Audio-Stream
|
|
||||||
- SSE fuer Session-Nachrichten
|
|
||||||
- Score/Badge-Visualisierung
|
|
||||||
|
|
||||||
#### Phase 15: ChatPlayground (3-5 Tage)
|
|
||||||
|
|
||||||
Views: Playground, Workflows
|
|
||||||
|
|
||||||
API-Basis: `/api/chatplayground/{instanceId}/`
|
|
||||||
|
|
||||||
- Start/Stop Workflow (mit SSE-Stream)
|
|
||||||
- Workflows CRUD + Status/Logs/Messages
|
|
||||||
- Attributes, Actions
|
|
||||||
|
|
||||||
#### Phase 16: Automation (3-5 Tage)
|
|
||||||
|
|
||||||
Views: Definitions, Templates, Logs
|
|
||||||
|
|
||||||
API-Basis: `/api/automations/`
|
|
||||||
|
|
||||||
- Automations CRUD + Execute + Duplicate
|
|
||||||
- Templates CRUD
|
|
||||||
- Workflow-Management (gleiche API wie ChatPlayground, anderer Base-Path)
|
|
||||||
|
|
||||||
#### Phase 17: CodeEditor (3-5 Tage)
|
|
||||||
|
|
||||||
Views: Editor, Workflows
|
|
||||||
|
|
||||||
API-Basis: `/api/codeeditor/{instanceId}/`
|
|
||||||
|
|
||||||
- Start/Stop/Apply (mit SSE-Stream)
|
|
||||||
- ChatData, Workflows, Files, File Content
|
|
||||||
|
|
||||||
Besonderheiten:
|
|
||||||
|
|
||||||
- Code-Darstellung: Syntax-Highlighting (z.B. via `Highlightr` SPM Package oder custom)
|
|
||||||
- Diff-Ansicht fuer Code-Apply
|
|
||||||
|
|
||||||
#### Phase 18: RealEstate / PEK (5-7 Tage)
|
|
||||||
|
|
||||||
Views: Dashboard (Map), Instance-Roles
|
|
||||||
|
|
||||||
API-Basis: `/api/realestate/{instanceId}/`
|
|
||||||
|
|
||||||
- Projects + Parcels CRUD
|
|
||||||
- Parcel Search, WFS, Selection Summary, Adjacent Parcels
|
|
||||||
- Address Autocomplete
|
|
||||||
- BZO Information, Parcel Documents
|
|
||||||
- Gemeinden
|
|
||||||
|
|
||||||
Besonderheiten:
|
|
||||||
|
|
||||||
- **MapKit** Integration: Parcel-Visualisierung auf Karte
|
|
||||||
- Address-Autocomplete: MKLocalSearchCompleter oder Backend-API
|
|
||||||
- Komplexe Karteninteraktion (Parcel-Selektion, Adjacent Parcels)
|
|
||||||
|
|
||||||
#### Phase 19: Neutralization (2-3 Tage)
|
|
||||||
|
|
||||||
Views: Dashboard/Playground (gleiche View)
|
|
||||||
|
|
||||||
API-Basis: `/api/neutralization/`
|
|
||||||
|
|
||||||
- Config GET/POST
|
|
||||||
- Neutralize File/Text, Resolve Text
|
|
||||||
- Process SharePoint, Batch Process
|
|
||||||
- Stats, Attributes
|
|
||||||
|
|
||||||
#### Phase 20: Billing View-Erweiterung (1-2 Tage)
|
|
||||||
|
|
||||||
Admin-Billing-Views falls in Phase 9 nicht vollstaendig abgedeckt:
|
|
||||||
|
|
||||||
- Checkout (Stripe -- SFSafariViewController fuer Redirect)
|
|
||||||
- Mandate/User Balances und Transaktionen
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API-Header-Konvention (fuer alle Requests)
|
|
||||||
|
|
||||||
Jeder API-Request muss folgende Header senden (analog `[src/api.ts](frontend_nyla/src/api.ts)`):
|
|
||||||
|
|
||||||
|
|
||||||
| Header | Quelle | Wann |
|
|
||||||
| -------------------------------- | ------------------ | --------------------- |
|
|
||||||
| `Authorization: Bearer {token}` | Keychain | Wenn JWT vorhanden |
|
|
||||||
| `X-Mandate-Id: {mandateId}` | Navigation Context | Bei Feature-Seiten |
|
|
||||||
| `X-Instance-Id: {instanceId}` | Navigation Context | Bei Feature-Seiten |
|
|
||||||
| `X-CSRF-Token: {token}` | CSRFManager | POST/PUT/PATCH/DELETE |
|
|
||||||
| `Content-Type: application/json` | Standard | JSON Bodies |
|
|
||||||
| Cookie (httpOnly) | URLSession | Automatisch |
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Gesamtaufwand-Schaetzung
|
|
||||||
|
|
||||||
|
|
||||||
| Phase | Tage (geschaetzt) |
|
|
||||||
| ------------------------------- | ----------------- |
|
|
||||||
| Phase 0: Setup | 1-2 |
|
|
||||||
| Phase 1: Networking | 3-5 |
|
|
||||||
| Phase 2: Authentication | 3-5 |
|
|
||||||
| Phase 3: Domain Models + Store | 2-3 |
|
|
||||||
| Phase 4: App Shell + Navigation | 4-6 |
|
|
||||||
| Phase 5: i18n + Theme | 2-3 |
|
|
||||||
| Phase 6: Core Pages | 5-7 |
|
|
||||||
| Phase 7: Shared UI Components | 5-8 |
|
|
||||||
| Phase 8: Push Notifications | 2-3 |
|
|
||||||
| Phase 9: Admin | 5-7 |
|
|
||||||
| Phase 10: Trustee | 5-7 |
|
|
||||||
| Phase 11: Workspace | 5-7 |
|
|
||||||
| Phase 12: Chatbot | 3-5 |
|
|
||||||
| Phase 13: Teamsbot | 4-6 |
|
|
||||||
| Phase 14: CommCoach | 4-6 |
|
|
||||||
| Phase 15: ChatPlayground | 3-5 |
|
|
||||||
| Phase 16: Automation | 3-5 |
|
|
||||||
| Phase 17: CodeEditor | 3-5 |
|
|
||||||
| Phase 18: RealEstate | 5-7 |
|
|
||||||
| Phase 19: Neutralization | 2-3 |
|
|
||||||
| Phase 20: Billing Erweit. | 1-2 |
|
|
||||||
| **Gesamt** | **~70-105 Tage** |
|
|
||||||
|
|
||||||
|
|
||||||
Hinweis: Dies ist eine Einzelperson-Schaetzung. Mit Team (z.B. 2-3 Devs) kann parallelisiert werden, besonders ab Phase 10+ (Features sind unabhaengig voneinander).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Offene Punkte / Risiken
|
|
||||||
|
|
||||||
1. **Backend-Anpassungen**: Das Backend setzt teilweise httpOnly Cookies nach Browser-Redirect (MSAL, Google). Fuer eine native App muss das Backend ggf. alternative Token-Flows unterstuetzen (z.B. Device Code Flow oder Token-Exchange).
|
|
||||||
2. **Push Notifications**: Das Backend hat aktuell kein APNs-Token-Management. Ein neuer Endpoint `/api/notifications/register-device` muss im Gateway implementiert werden.
|
|
||||||
3. **SSE ueber POST**: Die Web-App nutzt `fetch` POST + ReadableStream fuer SSE (nicht standard EventSource GET). In Swift muss dies mit `URLSession.bytes(for:)` nachgebaut werden.
|
|
||||||
4. **Stripe Checkout**: Im Web oeffnet sich ein Stripe-Redirect. In iOS: SFSafariViewController oder Stripe iOS SDK.
|
|
||||||
5. **SharePoint Integration**: Einige Features nutzen SharePoint-Folder-Picker. In iOS muss eine alternative UI gebaut werden (Liste statt Filepicker).
|
|
||||||
6. **WebSocket Auth**: Der Web-Client nutzt Cookies fuer WebSocket-Auth. iOS `URLSessionWebSocketTask` unterstuetzt Cookies via URLSession Configuration.
|
|
||||||
|
|
||||||
58
.forgejo/workflows/int_porta-int-platform-core.yml
Normal file
58
.forgejo/workflows/int_porta-int-platform-core.yml
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
name: Deploy Plattform-Core (Int)
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- int
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Tests auf Infomaniak VM
|
||||||
|
env:
|
||||||
|
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
|
||||||
|
chmod 600 ~/.ssh/deploy_key
|
||||||
|
echo "StrictHostKeyChecking=no" >> ~/.ssh/config
|
||||||
|
echo "UserKnownHostsFile=/dev/null" >> ~/.ssh/config
|
||||||
|
ssh -i ~/.ssh/deploy_key ubuntu@api-int.poweron.swiss "
|
||||||
|
set -e
|
||||||
|
cd /srv/gateway/current
|
||||||
|
git remote set-url origin ssh://git@git.poweron.swiss:2222/PowerOn/platform-core.git
|
||||||
|
git fetch origin int
|
||||||
|
git reset --hard origin/int
|
||||||
|
test -f env-int.env
|
||||||
|
cp env-int.env .env
|
||||||
|
rm -f env-*.env
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt --no-cache-dir
|
||||||
|
python -m pytest tests/ --ignore=tests/demo
|
||||||
|
"
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: test
|
||||||
|
steps:
|
||||||
|
- name: Deploy to Infomaniak VM
|
||||||
|
env:
|
||||||
|
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
|
||||||
|
chmod 600 ~/.ssh/deploy_key
|
||||||
|
echo "StrictHostKeyChecking=no" >> ~/.ssh/config
|
||||||
|
echo "UserKnownHostsFile=/dev/null" >> ~/.ssh/config
|
||||||
|
ssh -i ~/.ssh/deploy_key ubuntu@api-int.poweron.swiss "
|
||||||
|
set -e
|
||||||
|
cd /srv/gateway/current
|
||||||
|
git remote set-url origin ssh://git@git.poweron.swiss:2222/PowerOn/platform-core.git
|
||||||
|
git fetch origin int
|
||||||
|
git reset --hard origin/int
|
||||||
|
test -f env-int.env
|
||||||
|
cp env-int.env .env
|
||||||
|
rm -f env-*.env
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt --no-cache-dir
|
||||||
|
sudo systemctl restart gateway
|
||||||
|
"
|
||||||
|
|
@ -12,19 +12,19 @@ jobs:
|
||||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
echo "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
|
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
|
||||||
chmod 600 ~/.ssh/deploy_key
|
chmod 600 ~/.ssh/deploy_key
|
||||||
echo "StrictHostKeyChecking=no" >> ~/.ssh/config
|
echo "StrictHostKeyChecking=no" >> ~/.ssh/config
|
||||||
echo "UserKnownHostsFile=/dev/null" >> ~/.ssh/config
|
echo "UserKnownHostsFile=/dev/null" >> ~/.ssh/config
|
||||||
ssh -i ~/.ssh/deploy_key ubuntu@api.poweron.swiss "
|
ssh -i ~/.ssh/deploy_key ubuntu@api.poweron.swiss "
|
||||||
set -e
|
set -e
|
||||||
cd /srv/gateway/current
|
cd /srv/gateway/current
|
||||||
git remote set-url origin ssh://git@git.poweron.swiss:2222/PowerOn/plattform-core.git
|
git remote set-url origin ssh://git@git.poweron.swiss:2222/PowerOn/platform-core.git
|
||||||
git fetch origin main
|
git fetch origin main
|
||||||
git reset --hard origin/main
|
git reset --hard origin/main
|
||||||
test -f env-gateway-prod-forgejo.env
|
test -f env-prod.env
|
||||||
cp env-gateway-prod-forgejo.env .env
|
cp env-prod.env .env
|
||||||
rm -f env-gateway-dev.env env-gateway-int.env env-gateway-prod.env env-gateway-prod-forgejo.env
|
rm -f env-*.env
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
pip install -r requirements.txt --no-cache-dir
|
pip install -r requirements.txt --no-cache-dir
|
||||||
python -m pytest tests/ --ignore=tests/demo
|
python -m pytest tests/ --ignore=tests/demo
|
||||||
|
|
@ -39,19 +39,19 @@ jobs:
|
||||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
echo "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
|
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
|
||||||
chmod 600 ~/.ssh/deploy_key
|
chmod 600 ~/.ssh/deploy_key
|
||||||
echo "StrictHostKeyChecking=no" >> ~/.ssh/config
|
echo "StrictHostKeyChecking=no" >> ~/.ssh/config
|
||||||
echo "UserKnownHostsFile=/dev/null" >> ~/.ssh/config
|
echo "UserKnownHostsFile=/dev/null" >> ~/.ssh/config
|
||||||
ssh -i ~/.ssh/deploy_key ubuntu@api.poweron.swiss "
|
ssh -i ~/.ssh/deploy_key ubuntu@api.poweron.swiss "
|
||||||
set -e
|
set -e
|
||||||
cd /srv/gateway/current
|
cd /srv/gateway/current
|
||||||
git remote set-url origin ssh://git@git.poweron.swiss:2222/PowerOn/plattform-core.git
|
git remote set-url origin ssh://git@git.poweron.swiss:2222/PowerOn/platform-core.git
|
||||||
git fetch origin main
|
git fetch origin main
|
||||||
git reset --hard origin/main
|
git reset --hard origin/main
|
||||||
test -f env-gateway-prod-forgejo.env
|
test -f env-prod.env
|
||||||
cp env-gateway-prod-forgejo.env .env
|
cp env-prod.env .env
|
||||||
rm -f env-gateway-dev.env env-gateway-int.env env-gateway-prod.env env-gateway-prod-forgejo.env
|
rm -f env-*.env
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
pip install -r requirements.txt --no-cache-dir
|
pip install -r requirements.txt --no-cache-dir
|
||||||
sudo systemctl restart gateway
|
sudo systemctl restart gateway
|
||||||
74
.github/scripts/load_config_key_from_azure.py
vendored
74
.github/scripts/load_config_key_from_azure.py
vendored
|
|
@ -1,74 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
# Copyright (c) 2026 Patrick Motsch
|
|
||||||
"""Load CONFIG_KEY from Azure App Service for CI pytest (Kudu API + publish profile)."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import urllib.request
|
|
||||||
import xml.etree.ElementTree as ET
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
profile_xml = os.environ.get("AZURE_PUBLISH_PROFILE")
|
|
||||||
setting_name = os.environ.get("SETTING_NAME", "CONFIG_KEY")
|
|
||||||
if not profile_xml:
|
|
||||||
print("::error::AZURE_PUBLISH_PROFILE is not set", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
root = ET.fromstring(profile_xml)
|
|
||||||
pub = None
|
|
||||||
for element in root.findall(".//publishProfile"):
|
|
||||||
url = (element.get("publishUrl") or "").lower()
|
|
||||||
if "scm" in url:
|
|
||||||
pub = element
|
|
||||||
break
|
|
||||||
if pub is None:
|
|
||||||
pub = root.find(".//publishProfile")
|
|
||||||
if pub is None:
|
|
||||||
print("::error::No publishProfile in publish profile XML", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
host = (pub.get("publishUrl") or "").split(":")[0]
|
|
||||||
user = pub.get("userName")
|
|
||||||
pwd = pub.get("userPWD")
|
|
||||||
if not (host and user and pwd):
|
|
||||||
print("::error::Could not parse SCM credentials from publish profile", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
api = f"https://{host}/api/settings"
|
|
||||||
req = urllib.request.Request(api)
|
|
||||||
cred = base64.b64encode(f"{user}:{pwd}".encode()).decode()
|
|
||||||
req.add_header("Authorization", f"Basic {cred}")
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
||||||
settings = json.load(resp)
|
|
||||||
except Exception as exc:
|
|
||||||
print(f"::error::Kudu settings request failed: {exc}", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if not isinstance(settings, dict) or setting_name not in settings:
|
|
||||||
preview = sorted(settings.keys())[:25] if isinstance(settings, dict) else []
|
|
||||||
print(
|
|
||||||
f"::error::{setting_name} not in Azure App Service application settings "
|
|
||||||
f"(sample keys: {preview})",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
value = settings[setting_name]
|
|
||||||
if not value or not str(value).strip():
|
|
||||||
print(f"::error::{setting_name} is empty in Azure App Service", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
github_env = os.environ.get("GITHUB_ENV")
|
|
||||||
if github_env:
|
|
||||||
with open(github_env, "a", encoding="utf-8") as handle:
|
|
||||||
handle.write(f"{setting_name}<<EOF\n{value}\nEOF\n")
|
|
||||||
print(f"Loaded {setting_name} from Azure App Service ({len(value)} characters)")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
194
.github/workflows/deploy-gcp.yml
vendored
194
.github/workflows/deploy-gcp.yml
vendored
|
|
@ -1,194 +0,0 @@
|
||||||
# GitHub Actions workflow for deploying Gateway to Google Cloud Run
|
|
||||||
# Documentation: https://cloud.google.com/run/docs/deploying
|
|
||||||
#
|
|
||||||
# Required GitHub Secrets:
|
|
||||||
# - GCP_PROJECT_ID: Your Google Cloud Project ID
|
|
||||||
# - GCP_SA_KEY: Service Account JSON key with Cloud Run Admin and Cloud Build Editor roles
|
|
||||||
# - GCP_SERVICE_ACCOUNT_EMAIL: Email of the service account to run Cloud Run service as
|
|
||||||
#
|
|
||||||
# Required Google Cloud Setup:
|
|
||||||
# 1. Create a service account with Cloud Run Admin and Cloud Build Editor roles
|
|
||||||
# 2. Create secret "CONFIG_KEY" in Secret Manager with your master key
|
|
||||||
# 3. Grant the service account access to Secret Manager secrets
|
|
||||||
# 4. Create Cloud SQL instance (if not exists)
|
|
||||||
# 5. Create env-gateway-prod.env and env-gateway-int.env files with your configuration
|
|
||||||
#
|
|
||||||
# Environment Selection:
|
|
||||||
# - Push to 'main' branch → uses env-gateway-prod.env (production)
|
|
||||||
# - Push to 'int' branch → uses env-gateway-int.env (integration)
|
|
||||||
# - Manual dispatch → select environment (prod/int) to use corresponding env file
|
|
||||||
|
|
||||||
name: Deploy Gateway to Google Cloud Run
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- int
|
|
||||||
paths:
|
|
||||||
- 'gateway/**'
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
environment:
|
|
||||||
description: 'Environment to deploy to'
|
|
||||||
required: true
|
|
||||||
default: 'prod'
|
|
||||||
type: choice
|
|
||||||
options:
|
|
||||||
- prod
|
|
||||||
- int
|
|
||||||
|
|
||||||
# Cancel in-progress runs when a new run is triggered (saves logs/storage)
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
env:
|
|
||||||
PROJECT_ID: ${{ secrets.GCP_PROJECT_ID }}
|
|
||||||
REGION: europe-west6 # Zurich region
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v5
|
|
||||||
|
|
||||||
- name: Determine environment
|
|
||||||
id: env
|
|
||||||
run: |
|
|
||||||
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
|
|
||||||
ENV_TYPE="${{ github.event.inputs.environment }}"
|
|
||||||
elif [ "${{ github.ref }}" == "refs/heads/int" ]; then
|
|
||||||
ENV_TYPE="int"
|
|
||||||
else
|
|
||||||
ENV_TYPE="prod"
|
|
||||||
fi
|
|
||||||
echo "env_file=env-gateway-${ENV_TYPE}.env" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v6
|
|
||||||
with:
|
|
||||||
python-version: '3.11'
|
|
||||||
|
|
||||||
- name: Set environment file
|
|
||||||
run: |
|
|
||||||
ENV_FILE="${{ steps.env.outputs.env_file }}"
|
|
||||||
test -f "$ENV_FILE"
|
|
||||||
cp "$ENV_FILE" .env
|
|
||||||
rm -f env-gateway-dev.env env-gateway-int.env env-gateway-prod.env env-gateway-prod-forgejo.env
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
if [ -f requirements.lock ]; then
|
|
||||||
pip install -r requirements.lock --no-cache-dir
|
|
||||||
else
|
|
||||||
pip install -r requirements.txt --no-cache-dir
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
run: python -m pytest tests/ --ignore=tests/demo
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: test
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
id-token: write # Required for Workload Identity Federation
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v5
|
|
||||||
|
|
||||||
- name: Determine environment
|
|
||||||
id: env
|
|
||||||
run: |
|
|
||||||
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
|
|
||||||
ENV_TYPE="${{ github.event.inputs.environment }}"
|
|
||||||
elif [ "${{ github.ref }}" == "refs/heads/int" ]; then
|
|
||||||
ENV_TYPE="int"
|
|
||||||
else
|
|
||||||
ENV_TYPE="prod"
|
|
||||||
fi
|
|
||||||
echo "env_type=$ENV_TYPE" >> $GITHUB_OUTPUT
|
|
||||||
echo "service_name=gateway-$ENV_TYPE" >> $GITHUB_OUTPUT
|
|
||||||
echo "env_file=env-gateway-${ENV_TYPE}.env" >> $GITHUB_OUTPUT
|
|
||||||
echo "Determined environment: $ENV_TYPE"
|
|
||||||
echo "Service name: gateway-$ENV_TYPE"
|
|
||||||
echo "Env file: env-gateway-${ENV_TYPE}.env"
|
|
||||||
|
|
||||||
- name: Authenticate to Google Cloud
|
|
||||||
uses: google-github-actions/auth@v2
|
|
||||||
with:
|
|
||||||
credentials_json: ${{ secrets.GCP_SA_KEY }}
|
|
||||||
# Alternative: Use Workload Identity Federation (more secure)
|
|
||||||
# workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
|
|
||||||
# service_account: ${{ secrets.WIF_SERVICE_ACCOUNT }}
|
|
||||||
|
|
||||||
- name: Set up Cloud SDK
|
|
||||||
uses: google-github-actions/setup-gcloud@v2
|
|
||||||
|
|
||||||
- name: Configure Docker for GCR
|
|
||||||
run: |
|
|
||||||
gcloud auth configure-docker
|
|
||||||
|
|
||||||
- name: Set environment file
|
|
||||||
run: |
|
|
||||||
cd gateway
|
|
||||||
ENV_FILE="${{ steps.env.outputs.env_file }}"
|
|
||||||
if [ -f "$ENV_FILE" ]; then
|
|
||||||
echo "Using $ENV_FILE"
|
|
||||||
cp "$ENV_FILE" .env
|
|
||||||
else
|
|
||||||
echo "Warning: $ENV_FILE not found, using env-gateway-prod.env as fallback"
|
|
||||||
cp env-gateway-prod.env .env
|
|
||||||
fi
|
|
||||||
# Clean up other env files (optional, for security)
|
|
||||||
rm -f env-*.env
|
|
||||||
|
|
||||||
- name: Build and push container image
|
|
||||||
working-directory: ./gateway
|
|
||||||
run: |
|
|
||||||
# Build container image using Cloud Build
|
|
||||||
# If Dockerfile exists, it will be used; otherwise Cloud Buildpacks will be used
|
|
||||||
SERVICE_NAME="${{ steps.env.outputs.service_name }}"
|
|
||||||
gcloud builds submit \
|
|
||||||
--tag gcr.io/${{ env.PROJECT_ID }}/$SERVICE_NAME:${{ github.sha }} \
|
|
||||||
--tag gcr.io/${{ env.PROJECT_ID }}/$SERVICE_NAME:latest \
|
|
||||||
--project ${{ env.PROJECT_ID }}
|
|
||||||
|
|
||||||
- name: Deploy to Cloud Run
|
|
||||||
run: |
|
|
||||||
SERVICE_NAME="${{ steps.env.outputs.service_name }}"
|
|
||||||
ENV_TYPE="${{ steps.env.outputs.env_type }}"
|
|
||||||
gcloud run deploy $SERVICE_NAME \
|
|
||||||
--image gcr.io/${{ env.PROJECT_ID }}/$SERVICE_NAME:${{ github.sha }} \
|
|
||||||
--region ${{ env.REGION }} \
|
|
||||||
--platform managed \
|
|
||||||
--allow-unauthenticated \
|
|
||||||
--project ${{ env.PROJECT_ID }} \
|
|
||||||
--set-env-vars "APP_ENV_TYPE=$ENV_TYPE" \
|
|
||||||
--set-secrets "CONFIG_KEY=CONFIG_KEY:latest" \
|
|
||||||
--memory 2Gi \
|
|
||||||
--cpu 2 \
|
|
||||||
--timeout 300 \
|
|
||||||
--max-instances 10 \
|
|
||||||
--min-instances 1 \
|
|
||||||
--port 8000 \
|
|
||||||
--service-account ${{ secrets.GCP_SERVICE_ACCOUNT_EMAIL }}
|
|
||||||
|
|
||||||
- name: Get service URL
|
|
||||||
id: service-url
|
|
||||||
run: |
|
|
||||||
SERVICE_NAME="${{ steps.env.outputs.service_name }}"
|
|
||||||
SERVICE_URL=$(gcloud run services describe $SERVICE_NAME \
|
|
||||||
--region ${{ env.REGION }} \
|
|
||||||
--project ${{ env.PROJECT_ID }} \
|
|
||||||
--format 'value(status.url)')
|
|
||||||
echo "url=$SERVICE_URL" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Output deployment URL
|
|
||||||
run: |
|
|
||||||
echo "🚀 Deployment successful!"
|
|
||||||
echo "Service URL: ${{ steps.service-url.outputs.url }}"
|
|
||||||
121
.github/workflows/int_gateway-int.yml
vendored
121
.github/workflows/int_gateway-int.yml
vendored
|
|
@ -1,121 +0,0 @@
|
||||||
# Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy
|
|
||||||
# More GitHub Actions for Azure: https://github.com/Azure/actions
|
|
||||||
# More info on Python, GitHub Actions, and Azure App Service: https://aka.ms/python-webapps-actions
|
|
||||||
|
|
||||||
name: Build and deploy Python app to Azure Web App - gateway-int
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- int
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
# Cancel in-progress runs when a new run is triggered (saves logs/storage)
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
environment: Production
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
|
|
||||||
- name: Set up Python version
|
|
||||||
uses: actions/setup-python@v6
|
|
||||||
with:
|
|
||||||
python-version: '3.11'
|
|
||||||
|
|
||||||
- name: Set environment file
|
|
||||||
run: |
|
|
||||||
test -f env-gateway-int.env
|
|
||||||
cp env-gateway-int.env .env
|
|
||||||
rm -f env-gateway-dev.env env-gateway-int.env env-gateway-prod.env env-gateway-prod-forgejo.env
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
if [ -f requirements.lock ]; then
|
|
||||||
pip install -r requirements.lock --no-cache-dir
|
|
||||||
else
|
|
||||||
pip install -r requirements.txt --no-cache-dir
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Load CONFIG_KEY from Azure App Service
|
|
||||||
env:
|
|
||||||
AZURE_PUBLISH_PROFILE: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_GATEWAY_INT }}
|
|
||||||
run: python .github/scripts/load_config_key_from_azure.py
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
run: python -m pytest tests/ --ignore=tests/demo
|
|
||||||
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: test
|
|
||||||
permissions:
|
|
||||||
contents: read #This is required for actions/checkout
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
|
|
||||||
- name: Set up Python version
|
|
||||||
uses: actions/setup-python@v6
|
|
||||||
with:
|
|
||||||
python-version: '3.11'
|
|
||||||
|
|
||||||
- name: Create and start virtual environment
|
|
||||||
run: |
|
|
||||||
python -m venv venv
|
|
||||||
source venv/bin/activate
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
if [ -f requirements.lock ]; then
|
|
||||||
pip install -r requirements.lock --no-cache-dir
|
|
||||||
else
|
|
||||||
pip install -r requirements.txt --no-cache-dir
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Zip artifact for deployment
|
|
||||||
run: zip release.zip ./* -r
|
|
||||||
|
|
||||||
- name: Upload artifact for deployment jobs
|
|
||||||
uses: actions/upload-artifact@v6
|
|
||||||
with:
|
|
||||||
name: python-app
|
|
||||||
path: |
|
|
||||||
release.zip
|
|
||||||
!venv/
|
|
||||||
retention-days: 5
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: build
|
|
||||||
environment:
|
|
||||||
name: 'Production'
|
|
||||||
url: ${{ steps.deploy-to-webapp.outputs.webapp-url }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Download artifact from build job
|
|
||||||
uses: actions/download-artifact@v7
|
|
||||||
with:
|
|
||||||
name: python-app
|
|
||||||
|
|
||||||
- name: Unzip artifact for deployment
|
|
||||||
run: unzip release.zip
|
|
||||||
|
|
||||||
- name: Set productive environment
|
|
||||||
run: cp env-gateway-int.env .env
|
|
||||||
|
|
||||||
- name: Clean up environment files
|
|
||||||
run: rm -f env-*.env
|
|
||||||
|
|
||||||
- name: 'Deploy to Azure Web App'
|
|
||||||
uses: azure/webapps-deploy@v3
|
|
||||||
id: deploy-to-webapp
|
|
||||||
with:
|
|
||||||
app-name: 'gateway-int'
|
|
||||||
slot-name: 'Production'
|
|
||||||
publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_GATEWAY_INT }}
|
|
||||||
121
.github/workflows/main_gateway-prod.yml
vendored
121
.github/workflows/main_gateway-prod.yml
vendored
|
|
@ -1,121 +0,0 @@
|
||||||
# Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy
|
|
||||||
# More GitHub Actions for Azure: https://github.com/Azure/actions
|
|
||||||
# More info on Python, GitHub Actions, and Azure App Service: https://aka.ms/python-webapps-actions
|
|
||||||
|
|
||||||
name: Build and deploy Python app to Azure Web App - gateway-prod
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
# Cancel in-progress runs when a new run is triggered (saves logs/storage)
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
environment: Production
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
|
|
||||||
- name: Set up Python version
|
|
||||||
uses: actions/setup-python@v6
|
|
||||||
with:
|
|
||||||
python-version: '3.11'
|
|
||||||
|
|
||||||
- name: Set environment file
|
|
||||||
run: |
|
|
||||||
test -f env-gateway-prod.env
|
|
||||||
cp env-gateway-prod.env .env
|
|
||||||
rm -f env-gateway-dev.env env-gateway-int.env env-gateway-prod.env env-gateway-prod-forgejo.env
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
if [ -f requirements.lock ]; then
|
|
||||||
pip install -r requirements.lock --no-cache-dir
|
|
||||||
else
|
|
||||||
pip install -r requirements.txt --no-cache-dir
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Load CONFIG_KEY from Azure App Service
|
|
||||||
env:
|
|
||||||
AZURE_PUBLISH_PROFILE: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_GATEWAY_PROD }}
|
|
||||||
run: python .github/scripts/load_config_key_from_azure.py
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
run: python -m pytest tests/ --ignore=tests/demo
|
|
||||||
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: test
|
|
||||||
permissions:
|
|
||||||
contents: read #This is required for actions/checkout
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
|
|
||||||
- name: Set up Python version
|
|
||||||
uses: actions/setup-python@v6
|
|
||||||
with:
|
|
||||||
python-version: '3.11'
|
|
||||||
|
|
||||||
- name: Create and start virtual environment
|
|
||||||
run: |
|
|
||||||
python -m venv venv
|
|
||||||
source venv/bin/activate
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
if [ -f requirements.lock ]; then
|
|
||||||
pip install -r requirements.lock --no-cache-dir
|
|
||||||
else
|
|
||||||
pip install -r requirements.txt --no-cache-dir
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Zip artifact for deployment
|
|
||||||
run: zip release.zip ./* -r
|
|
||||||
|
|
||||||
- name: Upload artifact for deployment jobs
|
|
||||||
uses: actions/upload-artifact@v6
|
|
||||||
with:
|
|
||||||
name: python-app
|
|
||||||
path: |
|
|
||||||
release.zip
|
|
||||||
!venv/
|
|
||||||
retention-days: 5
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: build
|
|
||||||
environment:
|
|
||||||
name: 'Production'
|
|
||||||
url: ${{ steps.deploy-to-webapp.outputs.webapp-url }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Download artifact from build job
|
|
||||||
uses: actions/download-artifact@v7
|
|
||||||
with:
|
|
||||||
name: python-app
|
|
||||||
|
|
||||||
- name: Unzip artifact for deployment
|
|
||||||
run: unzip release.zip
|
|
||||||
|
|
||||||
- name: Set productive environment
|
|
||||||
run: cp env-gateway-prod.env .env
|
|
||||||
|
|
||||||
- name: Clean up environment files
|
|
||||||
run: rm -f env-*.env
|
|
||||||
|
|
||||||
- name: 'Deploy to Azure Web App'
|
|
||||||
uses: azure/webapps-deploy@v3
|
|
||||||
id: deploy-to-webapp
|
|
||||||
with:
|
|
||||||
app-name: 'gateway-prod'
|
|
||||||
slot-name: 'Production'
|
|
||||||
publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_GATEWAY_PROD }}
|
|
||||||
70
.github/workflows/update-requirements-lock.yml
vendored
70
.github/workflows/update-requirements-lock.yml
vendored
|
|
@ -1,70 +0,0 @@
|
||||||
# Generates requirements.lock from requirements.txt using Python 3.11 (same as build).
|
|
||||||
# Run manually (workflow_dispatch) or on changes to requirements.txt.
|
|
||||||
# After running, commit the generated requirements.lock so builds use it for fast installs.
|
|
||||||
|
|
||||||
name: Update requirements.lock
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- int
|
|
||||||
paths:
|
|
||||||
- 'requirements.txt'
|
|
||||||
|
|
||||||
# Cancel in-progress runs when a new run is triggered (saves logs/storage)
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
update-lock:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write # push requirements.lock
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v6
|
|
||||||
with:
|
|
||||||
python-version: '3.11'
|
|
||||||
|
|
||||||
- name: Install pip-tools
|
|
||||||
run: python -m pip install --upgrade "pip>=24,<26" pip-tools
|
|
||||||
|
|
||||||
- name: Generate requirements.lock
|
|
||||||
run: pip-compile requirements.txt -o requirements.lock
|
|
||||||
|
|
||||||
- name: Set environment file
|
|
||||||
run: |
|
|
||||||
if [ "${{ github.ref }}" == "refs/heads/int" ]; then
|
|
||||||
ENV_FILE="env-gateway-int.env"
|
|
||||||
else
|
|
||||||
ENV_FILE="env-gateway-prod.env"
|
|
||||||
fi
|
|
||||||
test -f "$ENV_FILE"
|
|
||||||
cp "$ENV_FILE" .env
|
|
||||||
|
|
||||||
- name: Install dependencies from generated lock
|
|
||||||
run: pip install -r requirements.lock --no-cache-dir
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
run: python -m pytest tests/ --ignore=tests/demo
|
|
||||||
|
|
||||||
- name: Clean up .env before commit
|
|
||||||
run: rm -f .env
|
|
||||||
|
|
||||||
- name: Commit and push requirements.lock
|
|
||||||
run: |
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
||||||
git add requirements.lock
|
|
||||||
if git diff --staged --quiet; then
|
|
||||||
echo "No changes to requirements.lock"
|
|
||||||
else
|
|
||||||
git commit -m "chore: update requirements.lock"
|
|
||||||
git push
|
|
||||||
fi
|
|
||||||
|
|
@ -46,5 +46,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||||
CMD python -c "import requests; requests.get('http://localhost:8000/api/admin/health', timeout=5)" || exit 1
|
CMD python -c "import requests; requests.get('http://localhost:8000/api/admin/health', timeout=5)" || exit 1
|
||||||
|
|
||||||
# Run the application
|
# Run the application
|
||||||
# Cloud Run will set PORT env var, uvicorn reads it automatically
|
CMD exec uvicorn app:app --host 0.0.0.0 --port ${PORT:-8000} --workers 1 --timeout-graceful-shutdown 5
|
||||||
CMD exec uvicorn app:app --host 0.0.0.0 --port ${PORT:-8000} --workers 1
|
|
||||||
|
|
|
||||||
21
app.py
21
app.py
|
|
@ -426,10 +426,12 @@ async def lifespan(app: FastAPI):
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# --- Stop Managers ---
|
# --- Shutdown sequence (protected against CancelledError) ---
|
||||||
|
try:
|
||||||
|
# 1. Stop scheduler first (removes all pending cron/interval jobs)
|
||||||
eventManager.stop()
|
eventManager.stop()
|
||||||
|
|
||||||
# --- Stop Feature Containers (Plug&Play) ---
|
# 2. Stop Feature Containers (Plug&Play)
|
||||||
try:
|
try:
|
||||||
mainModules = loadFeatureMainModules()
|
mainModules = loadFeatureMainModules()
|
||||||
for featureName, module in mainModules.items():
|
for featureName, module in mainModules.items():
|
||||||
|
|
@ -442,9 +444,8 @@ async def lifespan(app: FastAPI):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not shutdown feature containers: {e}")
|
logger.warning(f"Could not shutdown feature containers: {e}")
|
||||||
|
|
||||||
# --- Close all PostgreSQL connection pools ---
|
# 3. Close all PostgreSQL connection pools (LAST -- features may still
|
||||||
# Must run LAST: feature `onStop` hooks may still issue DB calls during
|
# issue DB calls during their onStop hooks)
|
||||||
# shutdown. Once we tear down the pools, no more borrows are possible.
|
|
||||||
try:
|
try:
|
||||||
from modules.connectors.connectorDbPostgre import closeAllPools
|
from modules.connectors.connectorDbPostgre import closeAllPools
|
||||||
closeAllPools()
|
closeAllPools()
|
||||||
|
|
@ -453,6 +454,9 @@ async def lifespan(app: FastAPI):
|
||||||
|
|
||||||
logger.info("Application has been shut down")
|
logger.info("Application has been shut down")
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info("Shutdown interrupted (CancelledError) -- resources released")
|
||||||
|
|
||||||
|
|
||||||
# Custom function to generate readable operation IDs for Swagger UI
|
# Custom function to generate readable operation IDs for Swagger UI
|
||||||
# Uses snake_case function names directly instead of auto-generated IDs
|
# Uses snake_case function names directly instead of auto-generated IDs
|
||||||
|
|
@ -720,3 +724,10 @@ from modules.system.registry import loadFeatureRouters
|
||||||
|
|
||||||
featureLoadResults = loadFeatureRouters(app)
|
featureLoadResults = loadFeatureRouters(app)
|
||||||
logger.info(f"Feature router load results: {featureLoadResults}")
|
logger.info(f"Feature router load results: {featureLoadResults}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
port = int(os.environ.get("PORT", 8000))
|
||||||
|
uvicorn.run("app:app", host="0.0.0.0", port=port, workers=1, timeout_graceful_shutdown=5)
|
||||||
|
|
@ -38,7 +38,6 @@
|
||||||
"title": "Pro Scan-Dokument",
|
"title": "Pro Scan-Dokument",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"items": {"type": "ref", "nodeId": "n2", "path": ["files"]},
|
"items": {"type": "ref", "nodeId": "n2", "path": ["files"]},
|
||||||
"level": "auto",
|
|
||||||
"concurrency": 1
|
"concurrency": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -73,9 +73,6 @@ Service_MSFT_TENANT_ID = common
|
||||||
# Google Cloud Speech Services configuration
|
# Google Cloud Speech Services configuration
|
||||||
Connector_GoogleSpeech_API_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETk5FWWM3Q0JKMzhIYTlyMkhuNjA4NlF4dk82U2NScHhTVGY3UG83NkhfX3RrcWVtWWcyLXRjU1dTT21zWEl6YWRMMUFndXpsUnJOeHh3QThsNDZKRXROTzdXRUdsT0JZajZJNVlfb0gtMXkwWm9DOERPVnpjU0pyUEZfOGJsUnprT3ltMVVhalUyUm9hMUFtZEtHUnJqOGZ4dEZjZm5SWVVTckVCWnY1UkdVSHVmUlgwbnAyc0xDQW84R3ViSko5OHVCVWZRUVNiaG1pVFB6X3EwS0FPd2dUYjhiSmRjcXh2WEZiXzI4SFZqT21tbDduUWRyVWdFZXpmcVM5ZDR0VWtzZnF5UER6cGwwS2JlLV9CSTZ0Z0IyQ1h0YW9TcmhRTXZEckp4bWhmTkt6UTNYMk4zVkpnbUJmaDIxZnoyR2dWTEYwTUFEV0w2eUdUUGpoZk9XRkt4RVF1Z1NPdUpBeTcyWV9PY1Ffd2s0ZEdVekxGekhoeEl4TmNqaXYtbUJuSVdycFducERWdWtZajZnX011Q2w4eE9VMTBqQ1ZxRmdScWhXY1E3WWhzX1JZcHhxam9FbDVPN3Q1MWtrMUZuTUg3LVFQVHp1T1hpQWNDMzEzekVJWk9ybl91YUVjSkFob1VaMi1ONEtuMnRSOEg1S3QybUMwbVZDejItajBLTjM2Zy1hNzZQMW5LLVVDVGdFWm5BZUxNeEFnUkZzU3dxV0lCUlc0LWo4b05GczVpOGZSV2ZxbFBwUml6OU5tYjdnTks3Y3hrVEZVTHlmc1NPdFh4WE5pWldEZklOQUxBbjBpMTlkX3FFQVJ6c2NSZGdzTThycE92VW82enZKamhiRGFnU25aZGlHZHhZd2lUUmhuTVptNjhoWVlJQkxIOEkzbzJNMjZCZFJyM25tdXBnQ2ZWaHV3b2p6UWJpdk9xUEhBc1dyTlNmeF9wbm5yYUhHV01UZnVXWDFlNzBkdXlWUWhvcmJpSmljbmE3LUpUZEg4VzRwZ2JVSjdYUm1sODViQXVxUzdGTmZFbVpiN2V1YW5XV3U4b2VRWmxldGVGVHZsSldoekhVLU9wZ2V0cGZIYkNqM2pXVGctQVAyUm4xTHhpd1VVLXFhcnVEV21Rby1hbTlqTl84TjVveHdYTExUVkhHQ0ltaTB2WXJnY1NQVE5PbWg3ejgySElYc1JSTlQ3NDlFUWR6STZVUjVqaXFRN200NF9LY1ljQ0R2UldlWUtKY1NQVnJ4QXRyYTBGSWVuenhyM0Z0cWtndTd1eG8xRzY5a2dNZ1hkQm5MV3BHVzA2N1QwUkd6WlRGYTZQOUhnVWQ2S0Y5U0s1dXFNVXh5Q2pLWVUxSUQ2MlR1ak52NmRIZ2hlYTk1SGZGWS1RV3hWVU9rR3d1Rk9MLS11REZXbzhqMHpsSm1HYW1jMUNLT29YOHZsRWNaLTVvOFpmT3l3MHVwaERTT0dNLWFjcGRYZ25qT2szTkVFUnRFR3JWYS1aNXFIRnMyalozTlQzNFF2NXJLVHVPVF9zdTF6ZjlkbzJ4RFc2ZENmNFFxZDZzTzhfMUl0bW96V0lPZkh1dXFYZlEteFBlSG84Si1FNS1TTi1OMkFnX2pOYW8xY3MxMVJnVC02MDUyaXZfMEVHWDQtVlRpcENmV0h3V0dCWEFRS2prQXdNRlQ5dnRFVHU0Q1dNTmh0SlBCaU55bFMydWM1TTFFLW96ODBnV3dNZHFZTWZhRURYSHlrdzF3RlRuWDBoQUhSOUJWemtRM3pxcDJFbGJoaTJ3ZktRTlJxbXltaHBoZXVJVDlxS3cxNWo2c0ZBV0NzaUstRWdsMW1xLXFkanZGYUFiU0tSLXFQa0tkcDFoMV9kak41ZjQ0R214UmtOR1ZBanRuemY3Mmw1SkZ5aDZodGIzT3N2aV85MW9kcld6c0g0ZDgtTWo3b3Y3VjJCRnR2U2tMVm9rUXNVRnVHbzZXVTZ6RmI2RkNmajBfMWVnODVFbnpkT0oyci15czJHU0p1cUowTGZJMzVnd3hIRjQyTVhKOGRkcFRKdVpyQ3Yzd01Jb1lSajFmV0paeEV0cjk1SmpmdWpDVFJMUmMtUFctOGhaTmlKQXNRVlVUNlhJemxudHZCR056SVlBb3NOTEYxRTRLaFlVd2d3TWtxVlB6ZEtQLTkxOGMyY3N0a2pYRFUweDBNaGhja2xSSklPOUZla1dKTWRNbG8tUGdSNEV5cW90OWlOZFlIUExBd3U2b2hyS1owbXVMM3p0Qm41cUtzWUxYNzB1N3JpUTNBSGdsT0NuamNTb1lIbXR4MG1sakNPVkxBUXRLVE1xX0YxWDhOcERIY1lTQVFqS01CaXZKNllFaXlIR0JsM1pKMmV1OUo3TGI1WkRaVnYxUTl1LTM0SU1qN1V1b0RCT0x0VHNLTmNLZnk1S0MxYnBBcm03WnVua0xqaEhGUzhOU253ZkppRzdudXBSVlMxeFVOSWxtZ1o2RVBSQUhEUEFuQ1hxSVZMME4yWUtaU3VyRGo3RkUyRUNjT0pNcE1BdE1ZRzdXVl8ydUtXZjdMdHdEVW4teHUtTi1HSGliLUxud21TX0NtcGVkRFBHNkZ1WTlNczR4OUJfUVluc1BoV09oWS1scUdsNnB5d1U5M1huX3k4QzAyNldtb2hybktYN2xKZ1NTNWFsaWwzV3pCRVhkaGR5eTNlV1d6ZzFfaFZTT0E4UjRpQ3pKdEZxUlJ6UFZXM3laUndyWEk2NlBXLUpoajVhZzVwQXpWVzUtVjVNZFBwdWdQa3AxZC1KdGdqNnhibjN4dmFYb2cxcEVwc1g5R09zRUdINUZtOE5QRjVUU0dpZy1QVl9odnFtVDNuWFZLSURtMXlSMlhRNTBWSVFJbEdOOWpfVWV0SmdRWDdlUXZZWE8xRUxDN1I0aEN6MHYwNzM1cmpJS0ZpMnBYWkxfb3FsbEV1VnlqWGxqdVJ6SHlwSjAzRlMycTBaQ295NXNnZERpUnJQcjhrUUd3bkI4bDVzRmxQblhkaFJPTTdISnVUQmhET3BOMTM4bjVvUEc2VmZhb2lrR1FyTUl2RWNEeGg0U0dsNnV6eU5zOUxiNDY5SXBxR0hBS00wOTgyWTFnWkQyaEtLVUloT3ZxZGh0RWVGRmJzenFsaUtfZENQM0JzdkVVeTdXR3hUSmJST1NBMUI1NkVFWncwNW5JZVVLX1p1RXdqVnFfQWpvQ08yQjZhN1NkTkpTSnUxOVRXZXE0WFEtZWxhZW1NNXYtQ2sya0VGLURmS01lMkctNVY3c2ZhN0ZGRFgwWHlabTFkeS1hcUZ1dDZ3cnpPQ3hha2IzVE11M0pqbklmU0diczBqTFBNZC1QZGp6VzNTSnJVSjJoWkJUQjVORG4tYUJmMEJtSUNUdVpEaGt6OTM3TjFOdVhXUHItZjRtZ25nU3NhZC1sVTVXNTRDTmxZbnlfeHNsdkpuMXhUYnE1MnpVQ0ZOclRWM1M4eHdXTzRXbFRZZVQtTS1iRVdXVWZMSGotcWg3MUxUYTFnSEEtanBCRHlZRUNIdGdpUFhsYjdYUndCZnRITzhMZVJ1dHFoVlVNb0duVjlxd0U4OGRuQVV3MG90R0hiYW5MWkxWVklzbWFRNzBfSUNrdzc5bVdtTXg0dExEYnRCaDI3c1I4TWFwLXZKR0wxSjRZYjZIV3ZqZjNqTWhFT0RGSDVMc1A1UzY2bDBiMGFSUy1fNVRQRzRJWDVydUpqb1ZfSHNVbldVeUN2YlAxSW5WVDdxVzJ1WHpLeUdmb0xWMDNHN05oQzY3YnhvUUdhS2xaOHNidkVvbTZtSHFlblhOYmwyR3NQdVJDRUdxREhWdF9ZcXhwUWxHc2hyLW5vUGhIUVhJNUNhY0hFU0ptVnI0TFVhZDE1TFBBUEstSkRoZWJ5MHJhUmZrR1ZrRlFtRGpxS1pOMmFMQjBsdjluY3FiYUU4eGJVVXlZVEpuNWdHVVhJMGtwaTdZR2NDbXd2eHpOQ09SeTV6N1BaVUpsR1pQVDBZcElJUUt6VnVpQmxSYnE4Y1BCWV9IRWdVV0p3enBGVHItdnBGN3NyNWFBWmkySnByWThsbDliSlExQmp3LVlBaDIyZXp6UnR6cU9rTzJmTDBlSVpON0tiWllMdm1oME1zTFl2S2ZYYllhQlY2VHNZRGtHUDY4U1lIVExLZTU4VzZxSTZrZHl1ZTBDc0g4SjI4WGYyZHV1bm9wQ3R2Z09ld1ZmUkN5alJGeHZKSHl1bWhQVXpNMzdjblpLcUhfSm02Qlh5S1FVN3lIcHl0NnlRPT0=
|
Connector_GoogleSpeech_API_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETk5FWWM3Q0JKMzhIYTlyMkhuNjA4NlF4dk82U2NScHhTVGY3UG83NkhfX3RrcWVtWWcyLXRjU1dTT21zWEl6YWRMMUFndXpsUnJOeHh3QThsNDZKRXROTzdXRUdsT0JZajZJNVlfb0gtMXkwWm9DOERPVnpjU0pyUEZfOGJsUnprT3ltMVVhalUyUm9hMUFtZEtHUnJqOGZ4dEZjZm5SWVVTckVCWnY1UkdVSHVmUlgwbnAyc0xDQW84R3ViSko5OHVCVWZRUVNiaG1pVFB6X3EwS0FPd2dUYjhiSmRjcXh2WEZiXzI4SFZqT21tbDduUWRyVWdFZXpmcVM5ZDR0VWtzZnF5UER6cGwwS2JlLV9CSTZ0Z0IyQ1h0YW9TcmhRTXZEckp4bWhmTkt6UTNYMk4zVkpnbUJmaDIxZnoyR2dWTEYwTUFEV0w2eUdUUGpoZk9XRkt4RVF1Z1NPdUpBeTcyWV9PY1Ffd2s0ZEdVekxGekhoeEl4TmNqaXYtbUJuSVdycFducERWdWtZajZnX011Q2w4eE9VMTBqQ1ZxRmdScWhXY1E3WWhzX1JZcHhxam9FbDVPN3Q1MWtrMUZuTUg3LVFQVHp1T1hpQWNDMzEzekVJWk9ybl91YUVjSkFob1VaMi1ONEtuMnRSOEg1S3QybUMwbVZDejItajBLTjM2Zy1hNzZQMW5LLVVDVGdFWm5BZUxNeEFnUkZzU3dxV0lCUlc0LWo4b05GczVpOGZSV2ZxbFBwUml6OU5tYjdnTks3Y3hrVEZVTHlmc1NPdFh4WE5pWldEZklOQUxBbjBpMTlkX3FFQVJ6c2NSZGdzTThycE92VW82enZKamhiRGFnU25aZGlHZHhZd2lUUmhuTVptNjhoWVlJQkxIOEkzbzJNMjZCZFJyM25tdXBnQ2ZWaHV3b2p6UWJpdk9xUEhBc1dyTlNmeF9wbm5yYUhHV01UZnVXWDFlNzBkdXlWUWhvcmJpSmljbmE3LUpUZEg4VzRwZ2JVSjdYUm1sODViQXVxUzdGTmZFbVpiN2V1YW5XV3U4b2VRWmxldGVGVHZsSldoekhVLU9wZ2V0cGZIYkNqM2pXVGctQVAyUm4xTHhpd1VVLXFhcnVEV21Rby1hbTlqTl84TjVveHdYTExUVkhHQ0ltaTB2WXJnY1NQVE5PbWg3ejgySElYc1JSTlQ3NDlFUWR6STZVUjVqaXFRN200NF9LY1ljQ0R2UldlWUtKY1NQVnJ4QXRyYTBGSWVuenhyM0Z0cWtndTd1eG8xRzY5a2dNZ1hkQm5MV3BHVzA2N1QwUkd6WlRGYTZQOUhnVWQ2S0Y5U0s1dXFNVXh5Q2pLWVUxSUQ2MlR1ak52NmRIZ2hlYTk1SGZGWS1RV3hWVU9rR3d1Rk9MLS11REZXbzhqMHpsSm1HYW1jMUNLT29YOHZsRWNaLTVvOFpmT3l3MHVwaERTT0dNLWFjcGRYZ25qT2szTkVFUnRFR3JWYS1aNXFIRnMyalozTlQzNFF2NXJLVHVPVF9zdTF6ZjlkbzJ4RFc2ZENmNFFxZDZzTzhfMUl0bW96V0lPZkh1dXFYZlEteFBlSG84Si1FNS1TTi1OMkFnX2pOYW8xY3MxMVJnVC02MDUyaXZfMEVHWDQtVlRpcENmV0h3V0dCWEFRS2prQXdNRlQ5dnRFVHU0Q1dNTmh0SlBCaU55bFMydWM1TTFFLW96ODBnV3dNZHFZTWZhRURYSHlrdzF3RlRuWDBoQUhSOUJWemtRM3pxcDJFbGJoaTJ3ZktRTlJxbXltaHBoZXVJVDlxS3cxNWo2c0ZBV0NzaUstRWdsMW1xLXFkanZGYUFiU0tSLXFQa0tkcDFoMV9kak41ZjQ0R214UmtOR1ZBanRuemY3Mmw1SkZ5aDZodGIzT3N2aV85MW9kcld6c0g0ZDgtTWo3b3Y3VjJCRnR2U2tMVm9rUXNVRnVHbzZXVTZ6RmI2RkNmajBfMWVnODVFbnpkT0oyci15czJHU0p1cUowTGZJMzVnd3hIRjQyTVhKOGRkcFRKdVpyQ3Yzd01Jb1lSajFmV0paeEV0cjk1SmpmdWpDVFJMUmMtUFctOGhaTmlKQXNRVlVUNlhJemxudHZCR056SVlBb3NOTEYxRTRLaFlVd2d3TWtxVlB6ZEtQLTkxOGMyY3N0a2pYRFUweDBNaGhja2xSSklPOUZla1dKTWRNbG8tUGdSNEV5cW90OWlOZFlIUExBd3U2b2hyS1owbXVMM3p0Qm41cUtzWUxYNzB1N3JpUTNBSGdsT0NuamNTb1lIbXR4MG1sakNPVkxBUXRLVE1xX0YxWDhOcERIY1lTQVFqS01CaXZKNllFaXlIR0JsM1pKMmV1OUo3TGI1WkRaVnYxUTl1LTM0SU1qN1V1b0RCT0x0VHNLTmNLZnk1S0MxYnBBcm03WnVua0xqaEhGUzhOU253ZkppRzdudXBSVlMxeFVOSWxtZ1o2RVBSQUhEUEFuQ1hxSVZMME4yWUtaU3VyRGo3RkUyRUNjT0pNcE1BdE1ZRzdXVl8ydUtXZjdMdHdEVW4teHUtTi1HSGliLUxud21TX0NtcGVkRFBHNkZ1WTlNczR4OUJfUVluc1BoV09oWS1scUdsNnB5d1U5M1huX3k4QzAyNldtb2hybktYN2xKZ1NTNWFsaWwzV3pCRVhkaGR5eTNlV1d6ZzFfaFZTT0E4UjRpQ3pKdEZxUlJ6UFZXM3laUndyWEk2NlBXLUpoajVhZzVwQXpWVzUtVjVNZFBwdWdQa3AxZC1KdGdqNnhibjN4dmFYb2cxcEVwc1g5R09zRUdINUZtOE5QRjVUU0dpZy1QVl9odnFtVDNuWFZLSURtMXlSMlhRNTBWSVFJbEdOOWpfVWV0SmdRWDdlUXZZWE8xRUxDN1I0aEN6MHYwNzM1cmpJS0ZpMnBYWkxfb3FsbEV1VnlqWGxqdVJ6SHlwSjAzRlMycTBaQ295NXNnZERpUnJQcjhrUUd3bkI4bDVzRmxQblhkaFJPTTdISnVUQmhET3BOMTM4bjVvUEc2VmZhb2lrR1FyTUl2RWNEeGg0U0dsNnV6eU5zOUxiNDY5SXBxR0hBS00wOTgyWTFnWkQyaEtLVUloT3ZxZGh0RWVGRmJzenFsaUtfZENQM0JzdkVVeTdXR3hUSmJST1NBMUI1NkVFWncwNW5JZVVLX1p1RXdqVnFfQWpvQ08yQjZhN1NkTkpTSnUxOVRXZXE0WFEtZWxhZW1NNXYtQ2sya0VGLURmS01lMkctNVY3c2ZhN0ZGRFgwWHlabTFkeS1hcUZ1dDZ3cnpPQ3hha2IzVE11M0pqbklmU0diczBqTFBNZC1QZGp6VzNTSnJVSjJoWkJUQjVORG4tYUJmMEJtSUNUdVpEaGt6OTM3TjFOdVhXUHItZjRtZ25nU3NhZC1sVTVXNTRDTmxZbnlfeHNsdkpuMXhUYnE1MnpVQ0ZOclRWM1M4eHdXTzRXbFRZZVQtTS1iRVdXVWZMSGotcWg3MUxUYTFnSEEtanBCRHlZRUNIdGdpUFhsYjdYUndCZnRITzhMZVJ1dHFoVlVNb0duVjlxd0U4OGRuQVV3MG90R0hiYW5MWkxWVklzbWFRNzBfSUNrdzc5bVdtTXg0dExEYnRCaDI3c1I4TWFwLXZKR0wxSjRZYjZIV3ZqZjNqTWhFT0RGSDVMc1A1UzY2bDBiMGFSUy1fNVRQRzRJWDVydUpqb1ZfSHNVbldVeUN2YlAxSW5WVDdxVzJ1WHpLeUdmb0xWMDNHN05oQzY3YnhvUUdhS2xaOHNidkVvbTZtSHFlblhOYmwyR3NQdVJDRUdxREhWdF9ZcXhwUWxHc2hyLW5vUGhIUVhJNUNhY0hFU0ptVnI0TFVhZDE1TFBBUEstSkRoZWJ5MHJhUmZrR1ZrRlFtRGpxS1pOMmFMQjBsdjluY3FiYUU4eGJVVXlZVEpuNWdHVVhJMGtwaTdZR2NDbXd2eHpOQ09SeTV6N1BaVUpsR1pQVDBZcElJUUt6VnVpQmxSYnE4Y1BCWV9IRWdVV0p3enBGVHItdnBGN3NyNWFBWmkySnByWThsbDliSlExQmp3LVlBaDIyZXp6UnR6cU9rTzJmTDBlSVpON0tiWllMdm1oME1zTFl2S2ZYYllhQlY2VHNZRGtHUDY4U1lIVExLZTU4VzZxSTZrZHl1ZTBDc0g4SjI4WGYyZHV1bm9wQ3R2Z09ld1ZmUkN5alJGeHZKSHl1bWhQVXpNMzdjblpLcUhfSm02Qlh5S1FVN3lIcHl0NnlRPT0=
|
||||||
|
|
||||||
# Feature SyncDelta JIRA configuration
|
|
||||||
Feature_SyncDelta_JIRA_DELTA_TOKEN_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEbm0yRUJ6VUJKbUwyRW5kMnRaNW4wM2YxMkJUTXVXZUdmdVRCaUZIVHU2TTV2RWZLRmUtZkcwZE4yRUNlNDQ0aUJWYjNfdVg5YjV5c2JwMHhoUUYxZWdkeS11bXR0eGxRLWRVaVU3cUVQZWJlNDRtY1lWUDdqeDVFSlpXS0VFX21WajlRS3lHQjc0bS11akkybWV3QUFlR2hNWUNYLUdiRjZuN2dQODdDSExXWG1Dd2ZGclI2aUhlSWhETVZuY3hYdnhkb2c2LU1JTFBvWFpTNmZtMkNVOTZTejJwbDI2eGE0OS1xUlIwQnlCSmFxRFNCeVJNVzlOMDhTR1VUamx4RDRyV3p6Tk9qVHBrWWdySUM3TVRaYjd3N0JHMFhpdzFhZTNDLTFkRVQ2RVE4U19COXRhRWtNc0NVOHRqUS1CRDFpZ19xQmtFLU9YSDU3TXBZQXpVcld3PT0=
|
|
||||||
|
|
||||||
# Teamsbot Browser Bot Service
|
# Teamsbot Browser Bot Service
|
||||||
# For local testing: run the bot locally with `npm run dev` in service-teams-browser-bot
|
# For local testing: run the bot locally with `npm run dev` in service-teams-browser-bot
|
||||||
# The bot will connect back to localhost:8000 via WebSocket
|
# The bot will connect back to localhost:8000 via WebSocket
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
# Development Environment Configuration
|
|
||||||
|
|
||||||
# System Configuration
|
|
||||||
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/notes/key.txt
|
|
||||||
APP_INIT_PASS_ADMIN_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEeFFtRGtQeVUtcjlrU3dab1ZxUm9WSks0MlJVYUtERFlqUElHemZrOGNENk1tcmJNX3Vxc01UMDhlNU40VzZZRVBpUGNmT3podzZrOGhOeEJIUEt4eVlSWG5UYXA3d09DVXlLT21Kb1JYSUU9
|
|
||||||
APP_INIT_PASS_EVENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERzZjNm56WGVBdjJTeG5Udjd6OGQwUVotYXUzQjJ1YVNyVXVBa3NZVml3ODU0MVNkZjhWWmJwNUFkc19BcHlHMTU1Q3BRcHU0cDBoZkFlR2l6UEZQU3d2U3MtMDh5UDZteGFoQ0EyMUE1ckE9
|
|
||||||
|
|
||||||
# PostgreSQL DB Host
|
|
||||||
DB_HOST=localhost
|
|
||||||
DB_USER=poweron_dev
|
|
||||||
DB_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEcUIxNEFfQ2xnS0RrSC1KNnUxTlVvTGZoMHgzaEI4Z3NlVzVROTVLak5Ubi1vaEZubFZaMTFKMGd6MXAxekN2d2NvMy1hRjg2UVhybktlcFA5anZ1WjFlQmZhcXdwaGhWdzRDc3ExeUhzWTg9
|
|
||||||
DB_PORT=5432
|
|
||||||
|
|
||||||
# Security Configuration
|
|
||||||
APP_JWT_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERjlrSktmZHVuQnJ1VVJDdndLaUcxZGJsT2ZlUFRlcFdOZ001RnlzM2FhLWhRV2tjWWFhaWQwQ3hkcUFvbThMcndxSjFpYTdfRV9OZGhTcksxbXFTZWg5MDZvOHpCVXBHcDJYaHlJM0tyNWRZckZsVHpQcmxTZHJoZUs1M3lfU2ljRnJaTmNSQ0w0X085OXI0QW80M2xfQnJqZmZ6VEh3TUltX0xzeE42SGtZPQ==
|
|
||||||
APP_TOKEN_EXPIRY=300
|
|
||||||
|
|
||||||
# CORS Configuration
|
|
||||||
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss,https://nyla.poweron-center.net,https://nyla-int.poweron-center.net
|
|
||||||
|
|
||||||
# Logging configuration
|
|
||||||
APP_LOGGING_LOG_LEVEL = DEBUG
|
|
||||||
APP_LOGGING_LOG_DIR = D:/Athi/Local/Web/poweron/local/logs
|
|
||||||
APP_LOGGING_FORMAT = %(asctime)s - %(levelname)s - %(name)s - %(message)s
|
|
||||||
APP_LOGGING_DATE_FORMAT = %Y-%m-%d %H:%M:%S
|
|
||||||
APP_LOGGING_CONSOLE_ENABLED = True
|
|
||||||
APP_LOGGING_FILE_ENABLED = True
|
|
||||||
APP_LOGGING_ROTATION_SIZE = 10485760
|
|
||||||
APP_LOGGING_BACKUP_COUNT = 5
|
|
||||||
|
|
||||||
# OAuth: Auth app (login/JWT) vs Data app (Microsoft Graph / Google APIs). Same IDs until you split apps in Azure / GCP.
|
|
||||||
Service_MSFT_AUTH_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
|
|
||||||
Service_MSFT_AUTH_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQnFBa1kxaG9WY1FJaWdCbVFVaTllUlJfU3Y3MmJkRmkzMDVDWUNtZEhlNVhISzJPcy00ZUVZcklYLXFMV0dIODV3NXNSSFBKQ0ZsZllES3diTEgySDF0T1ZCbFZHREZtcXFGSWNZN1NJbzJzczRRQWxoeVNsNzlsa0VzMHJPWHUydjBBclo=
|
|
||||||
Service_MSFT_AUTH_REDIRECT_URI = http://localhost:8000/api/msft/auth/login/callback
|
|
||||||
Service_MSFT_DATA_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
|
|
||||||
Service_MSFT_DATA_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQnFBa1kyUW96aXFVOVJlLUdyRlVvT1hVU09ILWtMZnV2M19mVUxGMnFPV3FzNTdQa3dTbHVGTDBHTk01ZThLcjh6QUR5VldVZUpfcDlZNTh5YldtLWtjTll6VzJNQ3JCQ3ZubHdmd2JvaExDOXdvQ1pjWDVQTUtFWVAtUHhwS1lFQnJXWk4=
|
|
||||||
Service_MSFT_DATA_REDIRECT_URI = http://localhost:8000/api/msft/auth/connect/callback
|
|
||||||
|
|
||||||
Service_GOOGLE_AUTH_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
|
|
||||||
Service_GOOGLE_AUTH_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQnFBa1kyd1hPd09vcVFtbVg0Sm5Nd1VYVEEtWjZMZkFndmFVS0ZlcTU0dzJnYVYzRkZWbjh0QldyZkhseDV2cUgxYkNHTzF6MXhqQlZ2N0UtbmhPeWRKUHBVdzV0Q1ROaWNuN2xjMmVzMjNZQ2ZYZ3dOTHgxaU5sTGRjVHpfakhYeWF0ZGU=
|
|
||||||
Service_GOOGLE_AUTH_REDIRECT_URI = http://localhost:8000/api/google/auth/login/callback
|
|
||||||
Service_GOOGLE_DATA_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
|
|
||||||
Service_GOOGLE_DATA_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQnFBa1kySXoyd1BmTnhOd1owTUJOWm53WlZMMjFHNGJhSUwyd2NDUW9BanlRWVJPLU5jYzRlcm5QeW96d0JYUkVWVWd2dGNBVEpJbElZY2lWb0o5S0gyNnhoV1pnNXhpSFEyaklZZjcwX2lVU0ktMEJGN01DMDhXQ3k4R1BXc1Q3ejFjOEg=
|
|
||||||
Service_GOOGLE_DATA_REDIRECT_URI = http://localhost:8000/api/google/auth/connect/callback
|
|
||||||
|
|
||||||
# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly.
|
|
||||||
Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
|
|
||||||
Service_CLICKUP_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd4ZWVBeHVtRnpIT0VBN0tSZDhLRmFmN05DOVBOelJtLWhkVnJDRVBqUkh3bDFTZFRWaWQ1cWowdGNLUk5IQzlGN1J6RFVCaW8zRnBwLVBnclJfdWgxV3pVRzFEV2lwcW5Rc19Xa1ROWXNJcUF0ajZaYUxOUXk0WHRsRmJLM25FaHV5T2IxdV92ZW1nRjhzaGpwU0l2Wm9FTkRnY2lJVjhuNHUwT29salAxYV8wPQ==
|
|
||||||
Service_CLICKUP_OAUTH_REDIRECT_URI = http://localhost:8000/api/clickup/auth/connect/callback
|
|
||||||
|
|
||||||
# Infomaniak: no OAuth client. Users paste a Personal Access Token (kdrive + mail) per UI.
|
|
||||||
|
|
||||||
# Stripe Billing (both end with _SECRET for encryption script)
|
|
||||||
STRIPE_SECRET_KEY_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5aHNGejgzQmpTdmprdzQxR19KZkh3MlhYUTNseFN3WnlaWjh2SDZyalN6aU9xSktkbUQwUnZrVnlvbGVRQm4yZFdiRU5aSEk5WVJuUnR4VUwtTm9OVk1WWmJQeU5QaDdib0hfVWV5U1BfYTFXRmdoOWdnOWxkb3JFQmF3bm45UjFUVUxmWGtGRkFKUGd6bmhpQlFnaVI3Q2lLdDlsY1VESk1vOEM0ZFBJNW1qcVZ0N2tPYmRLNmVKajZ2M3o3S05lWnRRVG5LdkRseW4wQ3VjNHNQZTZUdz09
|
|
||||||
STRIPE_WEBHOOK_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5dDJMSHBrVk8wTzJhU2xzTTZCZWdvWmU2NGI2WklfRXRJZVUzaVYyOU9GLUZsalUwa2lPdEgtUHo0dVVvRDU1cy1saHJyU0Rxa2xQZjBuakExQzk3bmxBcU9WbEIxUEtpR1JoUFMxZG9ISGRZUXFhdFpSMGxvQUV3a0VLQllfUUtCOHZwTGdteV9rYTFOazBfSlN3ekNWblFpakJlZVlCTmNkWWQ4Sm01a1RCWTlnTlFHWVA0MkZYMlprUExrWFN2V0NVU1BTd1NKczFJbVo3VHpLdlc4UT09
|
|
||||||
STRIPE_API_VERSION = 2026-01-28.clover
|
|
||||||
STRIPE_AUTOMATIC_TAX_ENABLED = false
|
|
||||||
STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQd14OUoIL0Osj7A0ZQlr0
|
|
||||||
|
|
||||||
# AI configuration
|
|
||||||
Connector_AiOpenai_API_SECRET = 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
|
|
||||||
Connector_AiAnthropic_API_SECRET = D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
|
|
||||||
Connector_AiPerplexity_API_SECRET = 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
|
|
||||||
Connector_AiTavily_API_SECRET = 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
|
|
||||||
Connector_AiPrivateLlm_API_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGRHM5eFdUVmVZU1R1cHBwN1RlMUx4T0NlLTJLUFFVX3J2OElDWFpuZmJHVmp4Z3BNNWMwZUVVZUd2TFhRSjVmVkVlcFlVRWtybXh0ZHloZ01ZcnVvX195YjdlWVdEcjZSWFFTTlNBWUlaTlNoLWhqVFBIb0thVlBiaWhjYjFQOFY=
|
|
||||||
Connector_AiMistral_API_SECRET = ogaEVD2fFmiIWHDhKn8oGM0FShFxnAtT
|
|
||||||
|
|
||||||
Service_MSFT_TENANT_ID = common
|
|
||||||
|
|
||||||
# Google Cloud Speech Services configuration
|
|
||||||
Connector_GoogleSpeech_API_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETk5FWWM3Q0JKMzhIYTlyMkhuNjA4NlF4dk82U2NScHhTVGY3UG83NkhfX3RrcWVtWWcyLXRjU1dTT21zWEl6YWRMMUFndXpsUnJOeHh3QThsNDZKRXROTzdXRUdsT0JZajZJNVlfb0gtMXkwWm9DOERPVnpjU0pyUEZfOGJsUnprT3ltMVVhalUyUm9hMUFtZEtHUnJqOGZ4dEZjZm5SWVVTckVCWnY1UkdVSHVmUlgwbnAyc0xDQW84R3ViSko5OHVCVWZRUVNiaG1pVFB6X3EwS0FPd2dUYjhiSmRjcXh2WEZiXzI4SFZqT21tbDduUWRyVWdFZXpmcVM5ZDR0VWtzZnF5UER6cGwwS2JlLV9CSTZ0Z0IyQ1h0YW9TcmhRTXZEckp4bWhmTkt6UTNYMk4zVkpnbUJmaDIxZnoyR2dWTEYwTUFEV0w2eUdUUGpoZk9XRkt4RVF1Z1NPdUpBeTcyWV9PY1Ffd2s0ZEdVekxGekhoeEl4TmNqaXYtbUJuSVdycFducERWdWtZajZnX011Q2w4eE9VMTBqQ1ZxRmdScWhXY1E3WWhzX1JZcHhxam9FbDVPN3Q1MWtrMUZuTUg3LVFQVHp1T1hpQWNDMzEzekVJWk9ybl91YUVjSkFob1VaMi1ONEtuMnRSOEg1S3QybUMwbVZDejItajBLTjM2Zy1hNzZQMW5LLVVDVGdFWm5BZUxNeEFnUkZzU3dxV0lCUlc0LWo4b05GczVpOGZSV2ZxbFBwUml6OU5tYjdnTks3Y3hrVEZVTHlmc1NPdFh4WE5pWldEZklOQUxBbjBpMTlkX3FFQVJ6c2NSZGdzTThycE92VW82enZKamhiRGFnU25aZGlHZHhZd2lUUmhuTVptNjhoWVlJQkxIOEkzbzJNMjZCZFJyM25tdXBnQ2ZWaHV3b2p6UWJpdk9xUEhBc1dyTlNmeF9wbm5yYUhHV01UZnVXWDFlNzBkdXlWUWhvcmJpSmljbmE3LUpUZEg4VzRwZ2JVSjdYUm1sODViQXVxUzdGTmZFbVpiN2V1YW5XV3U4b2VRWmxldGVGVHZsSldoekhVLU9wZ2V0cGZIYkNqM2pXVGctQVAyUm4xTHhpd1VVLXFhcnVEV21Rby1hbTlqTl84TjVveHdYTExUVkhHQ0ltaTB2WXJnY1NQVE5PbWg3ejgySElYc1JSTlQ3NDlFUWR6STZVUjVqaXFRN200NF9LY1ljQ0R2UldlWUtKY1NQVnJ4QXRyYTBGSWVuenhyM0Z0cWtndTd1eG8xRzY5a2dNZ1hkQm5MV3BHVzA2N1QwUkd6WlRGYTZQOUhnVWQ2S0Y5U0s1dXFNVXh5Q2pLWVUxSUQ2MlR1ak52NmRIZ2hlYTk1SGZGWS1RV3hWVU9rR3d1Rk9MLS11REZXbzhqMHpsSm1HYW1jMUNLT29YOHZsRWNaLTVvOFpmT3l3MHVwaERTT0dNLWFjcGRYZ25qT2szTkVFUnRFR3JWYS1aNXFIRnMyalozTlQzNFF2NXJLVHVPVF9zdTF6ZjlkbzJ4RFc2ZENmNFFxZDZzTzhfMUl0bW96V0lPZkh1dXFYZlEteFBlSG84Si1FNS1TTi1OMkFnX2pOYW8xY3MxMVJnVC02MDUyaXZfMEVHWDQtVlRpcENmV0h3V0dCWEFRS2prQXdNRlQ5dnRFVHU0Q1dNTmh0SlBCaU55bFMydWM1TTFFLW96ODBnV3dNZHFZTWZhRURYSHlrdzF3RlRuWDBoQUhSOUJWemtRM3pxcDJFbGJoaTJ3ZktRTlJxbXltaHBoZXVJVDlxS3cxNWo2c0ZBV0NzaUstRWdsMW1xLXFkanZGYUFiU0tSLXFQa0tkcDFoMV9kak41ZjQ0R214UmtOR1ZBanRuemY3Mmw1SkZ5aDZodGIzT3N2aV85MW9kcld6c0g0ZDgtTWo3b3Y3VjJCRnR2U2tMVm9rUXNVRnVHbzZXVTZ6RmI2RkNmajBfMWVnODVFbnpkT0oyci15czJHU0p1cUowTGZJMzVnd3hIRjQyTVhKOGRkcFRKdVpyQ3Yzd01Jb1lSajFmV0paeEV0cjk1SmpmdWpDVFJMUmMtUFctOGhaTmlKQXNRVlVUNlhJemxudHZCR056SVlBb3NOTEYxRTRLaFlVd2d3TWtxVlB6ZEtQLTkxOGMyY3N0a2pYRFUweDBNaGhja2xSSklPOUZla1dKTWRNbG8tUGdSNEV5cW90OWlOZFlIUExBd3U2b2hyS1owbXVMM3p0Qm41cUtzWUxYNzB1N3JpUTNBSGdsT0NuamNTb1lIbXR4MG1sakNPVkxBUXRLVE1xX0YxWDhOcERIY1lTQVFqS01CaXZKNllFaXlIR0JsM1pKMmV1OUo3TGI1WkRaVnYxUTl1LTM0SU1qN1V1b0RCT0x0VHNLTmNLZnk1S0MxYnBBcm03WnVua0xqaEhGUzhOU253ZkppRzdudXBSVlMxeFVOSWxtZ1o2RVBSQUhEUEFuQ1hxSVZMME4yWUtaU3VyRGo3RkUyRUNjT0pNcE1BdE1ZRzdXVl8ydUtXZjdMdHdEVW4teHUtTi1HSGliLUxud21TX0NtcGVkRFBHNkZ1WTlNczR4OUJfUVluc1BoV09oWS1scUdsNnB5d1U5M1huX3k4QzAyNldtb2hybktYN2xKZ1NTNWFsaWwzV3pCRVhkaGR5eTNlV1d6ZzFfaFZTT0E4UjRpQ3pKdEZxUlJ6UFZXM3laUndyWEk2NlBXLUpoajVhZzVwQXpWVzUtVjVNZFBwdWdQa3AxZC1KdGdqNnhibjN4dmFYb2cxcEVwc1g5R09zRUdINUZtOE5QRjVUU0dpZy1QVl9odnFtVDNuWFZLSURtMXlSMlhRNTBWSVFJbEdOOWpfVWV0SmdRWDdlUXZZWE8xRUxDN1I0aEN6MHYwNzM1cmpJS0ZpMnBYWkxfb3FsbEV1VnlqWGxqdVJ6SHlwSjAzRlMycTBaQ295NXNnZERpUnJQcjhrUUd3bkI4bDVzRmxQblhkaFJPTTdISnVUQmhET3BOMTM4bjVvUEc2VmZhb2lrR1FyTUl2RWNEeGg0U0dsNnV6eU5zOUxiNDY5SXBxR0hBS00wOTgyWTFnWkQyaEtLVUloT3ZxZGh0RWVGRmJzenFsaUtfZENQM0JzdkVVeTdXR3hUSmJST1NBMUI1NkVFWncwNW5JZVVLX1p1RXdqVnFfQWpvQ08yQjZhN1NkTkpTSnUxOVRXZXE0WFEtZWxhZW1NNXYtQ2sya0VGLURmS01lMkctNVY3c2ZhN0ZGRFgwWHlabTFkeS1hcUZ1dDZ3cnpPQ3hha2IzVE11M0pqbklmU0diczBqTFBNZC1QZGp6VzNTSnJVSjJoWkJUQjVORG4tYUJmMEJtSUNUdVpEaGt6OTM3TjFOdVhXUHItZjRtZ25nU3NhZC1sVTVXNTRDTmxZbnlfeHNsdkpuMXhUYnE1MnpVQ0ZOclRWM1M4eHdXTzRXbFRZZVQtTS1iRVdXVWZMSGotcWg3MUxUYTFnSEEtanBCRHlZRUNIdGdpUFhsYjdYUndCZnRITzhMZVJ1dHFoVlVNb0duVjlxd0U4OGRuQVV3MG90R0hiYW5MWkxWVklzbWFRNzBfSUNrdzc5bVdtTXg0dExEYnRCaDI3c1I4TWFwLXZKR0wxSjRZYjZIV3ZqZjNqTWhFT0RGSDVMc1A1UzY2bDBiMGFSUy1fNVRQRzRJWDVydUpqb1ZfSHNVbldVeUN2YlAxSW5WVDdxVzJ1WHpLeUdmb0xWMDNHN05oQzY3YnhvUUdhS2xaOHNidkVvbTZtSHFlblhOYmwyR3NQdVJDRUdxREhWdF9ZcXhwUWxHc2hyLW5vUGhIUVhJNUNhY0hFU0ptVnI0TFVhZDE1TFBBUEstSkRoZWJ5MHJhUmZrR1ZrRlFtRGpxS1pOMmFMQjBsdjluY3FiYUU4eGJVVXlZVEpuNWdHVVhJMGtwaTdZR2NDbXd2eHpOQ09SeTV6N1BaVUpsR1pQVDBZcElJUUt6VnVpQmxSYnE4Y1BCWV9IRWdVV0p3enBGVHItdnBGN3NyNWFBWmkySnByWThsbDliSlExQmp3LVlBaDIyZXp6UnR6cU9rTzJmTDBlSVpON0tiWllMdm1oME1zTFl2S2ZYYllhQlY2VHNZRGtHUDY4U1lIVExLZTU4VzZxSTZrZHl1ZTBDc0g4SjI4WGYyZHV1bm9wQ3R2Z09ld1ZmUkN5alJGeHZKSHl1bWhQVXpNMzdjblpLcUhfSm02Qlh5S1FVN3lIcHl0NnlRPT0=
|
|
||||||
|
|
||||||
# Feature SyncDelta JIRA configuration
|
|
||||||
Feature_SyncDelta_JIRA_DELTA_TOKEN_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEbm0yRUJ6VUJKbUwyRW5kMnRaNW4wM2YxMkJUTXVXZUdmdVRCaUZIVHU2TTV2RWZLRmUtZkcwZE4yRUNlNDQ0aUJWYjNfdVg5YjV5c2JwMHhoUUYxZWdkeS11bXR0eGxRLWRVaVU3cUVQZWJlNDRtY1lWUDdqeDVFSlpXS0VFX21WajlRS3lHQjc0bS11akkybWV3QUFlR2hNWUNYLUdiRjZuN2dQODdDSExXWG1Dd2ZGclI2aUhlSWhETVZuY3hYdnhkb2c2LU1JTFBvWFpTNmZtMkNVOTZTejJwbDI2eGE0OS1xUlIwQnlCSmFxRFNCeVJNVzlOMDhTR1VUamx4RDRyV3p6Tk9qVHBrWWdySUM3TVRaYjd3N0JHMFhpdzFhZTNDLTFkRVQ2RVE4U19COXRhRWtNc0NVOHRqUS1CRDFpZ19xQmtFLU9YSDU3TXBZQXpVcld3PT0=
|
|
||||||
|
|
||||||
# Teamsbot Browser Bot Service
|
|
||||||
# For local testing: run the bot locally with `npm run dev` in service-teams-browser-bot
|
|
||||||
# The bot will connect back to localhost:8000 via WebSocket
|
|
||||||
TEAMSBOT_BROWSER_BOT_URL = http://localhost:4100
|
|
||||||
|
|
||||||
# Debug Configuration
|
|
||||||
APP_DEBUG_CHAT_WORKFLOW_ENABLED = True
|
|
||||||
APP_DEBUG_CHAT_WORKFLOW_DIR = D:/Athi/Local/Web/poweron/local/debug
|
|
||||||
APP_DEBUG_ACCOUNTING_SYNC_ENABLED = True
|
|
||||||
APP_DEBUG_ACCOUNTING_SYNC_DIR = D:/Athi/Local/Web/poweron/local/debug/sync
|
|
||||||
|
|
||||||
# Azure Communication Services Email Configuration
|
|
||||||
MESSAGING_ACS_CONNECTION_STRING = endpoint=https://mailing-poweron-prod.switzerland.communication.azure.com/;accesskey=4UizRfBKBgMhDgQ92IYINM6dJsO1HIeL6W1DvIX9S0GtaS1PjIXqJQQJ99CAACULyCpHwxUcAAAAAZCSuSCt
|
|
||||||
MESSAGING_ACS_SENDER_EMAIL = DoNotReply@poweron.swiss
|
|
||||||
|
|
||||||
# Zurich WFS Parcels (dynamic map layer). Default: Stadt Zürich OGD. Override for full canton if wfs.zh.ch resolves.
|
|
||||||
# Connector_ZhWfsParcels_WFS_URL = https://wfs.zh.ch/av
|
|
||||||
# Connector_ZhWfsParcels_TYPENAMES = av_li_liegenschaften_a
|
|
||||||
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
# Integration Environment Configuration
|
|
||||||
|
|
||||||
# System Configuration
|
|
||||||
APP_ENV_TYPE = int
|
|
||||||
APP_ENV_LABEL = Integration Instance
|
|
||||||
APP_API_URL = https://gateway-int.poweron.swiss
|
|
||||||
# Force SameSite=None+Secure for auth cookies (cross-site UI on poweron-center.net). Optional if APP_API_URL is https://
|
|
||||||
APP_COOKIE_SECURE = true
|
|
||||||
APP_KEY_SYSVAR = CONFIG_KEY
|
|
||||||
APP_INIT_PASS_ADMIN_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjWm41MWZ4TUZGaVlrX3pWZWNwakJsY3Facm0wLVZDd1VKeTFoZEVZQnItcEdUUnVJS1NXeDBpM2xKbGRsYmxOSmRhc29PZjJSU2txQjdLbUVrTTE1NEJjUXBHbV9NOVJWZUR3QlJkQnJvTEU9
|
|
||||||
APP_INIT_PASS_EVENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjdmtrakgxa0djekZVNGtTZV8wM2I5UUpCZllveVBMWXROYk5yS3BiV3JEelJSM09VYTRONHpnY3VtMGxDRk5JTEZSRFhtcDZ0RVRmZ1RicTFhb3c5dVZRQ1o4SmlkLVpPTW5MMTU2eTQ0Vkk9
|
|
||||||
|
|
||||||
# PostgreSQL DB Host
|
|
||||||
DB_HOST=gateway-int-server.postgres.database.azure.com
|
|
||||||
DB_USER=heeshkdlby
|
|
||||||
DB_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjczYzOUtTa21MMGJVTUQ5UmFfdWc3YlhCbWZOeXFaNEE1QzdJV3BLVjhnalBkLVVCMm5BZzdxdlFXQXc2RHYzLWtPSFZkZE1iWG9rQ1NkVWlpRnF5TURVbnl1cm9iYXlSMGYxd1BGYVc0VDA9
|
|
||||||
DB_PORT=5432
|
|
||||||
|
|
||||||
# Security Configuration
|
|
||||||
APP_JWT_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNUctb2RwU25iR3ZnanBOdHZhWUtIajZ1RnZzTEp4aDR0MktWRjNoeVBrY1Npd1R0VE9YVHp3M2w1cXRzbUxNaU82QUJvaDNFeVQyN05KblRWblBvbWtoT0VXbkNBbDQ5OHhwSUFnaDZGRG10Vmgtdm1YUkRsYUhFMzRVZURmSFlDTFIzVWg4MXNueDZyMGc5aVpFdWRxY3dkTExGM093ZTVUZVl5LUhGWnlRPQ==
|
|
||||||
APP_TOKEN_EXPIRY=300
|
|
||||||
|
|
||||||
# CORS Configuration
|
|
||||||
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss,https://nyla.poweron-center.net,https://nyla-int.poweron-center.net
|
|
||||||
|
|
||||||
# Logging configuration
|
|
||||||
APP_LOGGING_LOG_LEVEL = DEBUG
|
|
||||||
APP_LOGGING_LOG_DIR = /home/site/wwwroot/
|
|
||||||
APP_LOGGING_FORMAT = %(asctime)s - %(levelname)s - %(name)s - %(message)s
|
|
||||||
APP_LOGGING_DATE_FORMAT = %Y-%m-%d %H:%M:%S
|
|
||||||
APP_LOGGING_CONSOLE_ENABLED = True
|
|
||||||
APP_LOGGING_FILE_ENABLED = True
|
|
||||||
APP_LOGGING_ROTATION_SIZE = 10485760
|
|
||||||
APP_LOGGING_BACKUP_COUNT = 5
|
|
||||||
|
|
||||||
# OAuth: Auth app (login/JWT) vs Data app (Graph / Google APIs)
|
|
||||||
Service_MSFT_AUTH_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
|
|
||||||
Service_MSFT_AUTH_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnFBa1kydlVubld1d1h6SUNSWW1aZ3p4X3Zod1NDTjhZVnVYS2lqOERGTFp2OXJ4TGRiNlRLVFpzLUVDTUhkZGhGUWdxa1djdEV5UWkyblN1UHZoaFBjaExNTEpGMG1PRGJEbDdHVll0Ungwcl9JemZ4ZXFzZUNFQmFlZi1DZFlCekU1S3E=
|
|
||||||
Service_MSFT_AUTH_REDIRECT_URI = https://gateway-int.poweron.swiss/api/msft/auth/login/callback
|
|
||||||
Service_MSFT_DATA_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
|
|
||||||
Service_MSFT_DATA_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnFBa1kyS1hWZXEzUzZTTE5MUlJncVowMU95Y0hmV1hveDBZOWdLU1RIUWt3SGlXNGxVTXVKc2QyQmtmWTlJRU43ZnRDdnlDTGxQY0hTU25CWWFFdDhUem9HU0VYcTFJTVFEbVk0dUhmVzJNVlEzNTNWdjdmaW9WeUVDVW5PRmNFZEQzNTY=
|
|
||||||
Service_MSFT_DATA_REDIRECT_URI = https://gateway-int.poweron.swiss/api/msft/auth/connect/callback
|
|
||||||
|
|
||||||
Service_GOOGLE_AUTH_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
|
|
||||||
Service_GOOGLE_AUTH_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnFBa1kyUTUwNXNGaHRNaGxxbF9sdWJ3Q0xLYU5yOHB4Yk8zMDZvQ29yaEhWOE5JMENXRk5jb2ZBdzRKQ2ZTTld6ZlIxemhOYzN1VE10TjBDRWZEMXlLVWRNYjZ0VG5RZ3I3NWt0SEJzMzdsUmRzcVNmbktRNHZqTUF6a2EyUkVUSFJnZFE=
|
|
||||||
Service_GOOGLE_AUTH_REDIRECT_URI = https://gateway-int.poweron.swiss/api/google/auth/login/callback
|
|
||||||
Service_GOOGLE_DATA_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
|
|
||||||
Service_GOOGLE_DATA_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnFBa1kyV1FRVjF0c0d3d0dyWU1TdW9HdXVkdHdsVWZKYTJjbGZPRDhMRjA2M0FkaUZIVmhIUmFKNjg2ekFodHd6NG80VTI3TC1icW1LZ01jWVZuQ1pKRm5nMW5UREJEaGp2Wl9oRDRCSmZVT0JpTnkwXzgwY0pkV29yczQ5akF2d1ZGcVY=
|
|
||||||
Service_GOOGLE_DATA_REDIRECT_URI = https://gateway-int.poweron.swiss/api/google/auth/connect/callback
|
|
||||||
|
|
||||||
# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly.
|
|
||||||
Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
|
|
||||||
Service_CLICKUP_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnB5dkd5SE1uVURMNVE3NkM4cHBKa2R2TjBnLWdpSXI5dHpKWGExZVFiUF95TFNnZ1NwLWFLdmh6eWFZTHVHYTBzU2FGRUpLYkVyM1NvZjZkWDZHN21qUER5ZVNOaGpCc3NrUGd3VnFTclF3OW1nUlVuWXQ1UVhDLVpyb1BwRExOeFpDeVhtbEhDVnd4TVdpbzNBNk5QQWFPdjdza0xBWGxFY1E3WFpCSUlNa1l4RDlBPQ==
|
|
||||||
Service_CLICKUP_OAUTH_REDIRECT_URI = https://gateway-int.poweron.swiss/api/clickup/auth/connect/callback
|
|
||||||
|
|
||||||
# Infomaniak: no OAuth client. Users paste a Personal Access Token (kdrive + mail) per UI.
|
|
||||||
|
|
||||||
# Stripe Billing (both end with _SECRET for encryption script)
|
|
||||||
STRIPE_SECRET_KEY_SECRET = INT_ENC:Z0FBQUFBQnB5dkd5ekdBaGNGVUlOQUpncTlzLWlTV0V5OWZzQkpDczhCUGw4U1JpTHZ0d3pfYlFNWElLRlNiNlNsaDRYTGZUTkg2OUFrTW1GZXpOUjBVbmRQWjN6ekhHd2ZSQ195OHlaeWh1TmxrUm10V2R3YmdncmFLbFMzVjdqcWJMSUJPR2xuSEozclNoZG1rZVBTaWg3OFQ1Qzdxb0wyQ2RKazc2dG1aZXBUTXlvbDZqLS1KOVI5M3BGc3NQZkZRbnFpRjIwWmh2ZHlVNlpxZVo2dWNmMjQ5eW02QmtzUT09
|
|
||||||
STRIPE_WEBHOOK_SECRET = INT_ENC:Z0FBQUFBQnA4UXZiUUVqTl9lREVRWTh1aHFDcFpwcXRkOUx4MS1ham9Ddkl6T0xzMnJuM1hhUHdGNG5CenY1MUg4RlJBOGFQTWl5cVd5MjJ2REItcHYyRmdLX3ZlT2p5Z3BRVkMtQnRoTVkteXlfaU92MVBtOEI0Ni1kbGlfa0NiRmFRRXNHLVE2NHI=
|
|
||||||
STRIPE_API_VERSION = 2026-01-28.clover
|
|
||||||
STRIPE_AUTOMATIC_TAX_ENABLED = false
|
|
||||||
STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQd14OUoIL0Osj7A0ZQlr0
|
|
||||||
|
|
||||||
# AI configuration
|
|
||||||
Connector_AiOpenai_API_SECRET = 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
|
|
||||||
Connector_AiAnthropic_API_SECRET = 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
|
|
||||||
Connector_AiPerplexity_API_SECRET = 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
|
|
||||||
Connector_AiTavily_API_SECRET = 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
|
|
||||||
Connector_AiPrivateLlm_API_SECRET = INT_ENC:Z0FBQUFBQnBudkpGSjZ1NWh0aWc1R3Z4MHNaeS1HamtUbndhcUZFZDlqUDhjSmg5eHFfdlVkU0RsVkJ2UVRaMWs3aWhraG5jSlc0YkxNWHVmR2JoSW5ENFFCdkJBM0VienlKSnhzNnBKbTJOUTFKczRfWlQ3bWpmUkRTT1I1OGNUSTlQdExacGRpeXg=
|
|
||||||
Connector_AiMistral_API_SECRET = ogaEVD2fFmiIWHDhKn8oGM0FShFxnAtT
|
|
||||||
|
|
||||||
Service_MSFT_TENANT_ID = common
|
|
||||||
|
|
||||||
# Google Cloud Speech Services configuration
|
|
||||||
Connector_GoogleSpeech_API_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRkNmVXZ1pWcHcydTF2MXF0ZGJoWHBydF85bTczTktiaEJ3Wk1vMW1mZVhDSG1yd0ZxR2ZuSGJTX0N3MWptWXFJTkNTWjh1SUVVTXI4UDVzcGdLMkU5SHJ2TUpkRlRoRWdnSldtYjNTQkh4UDJHY2xmdTdZQ1ZiMTZZcGZxS3RzaHdjV3dtVkZUcEpJcWx0b2xuQVR6ZmpoVFZPY1hNMTV2SnhDaC1IZEh4UUpLTy1ILXA4RG1zamJTbUJ4X0t2M2NkdzJPbEJxSmFpRzV3WC0wZThoVzlxcmpHZ3ZkLVlVY3REZk1vV19WQ05BOWN6cnJ4MWNYYnNiQ0FQSUVnUlpfM3BhMnlsVlZUOG5wM3pzM1lSN1UzWlZKUXRLczlHbjI1LTFvSUJ4SlVXMy1BNk43bE5Hb0RfTTVlWk9oZnFIaVg0SW5pbm9EcXRTTzU1RFlYY3dTcnpKWWNyNjN5T1BGZ0FmX253cEFncmhvZVRuM05KYzhkOEhFMFJsc2NBSEwzZVZ1R0JMOGxsekVwUE55alZaRXFrdzNWWVNGWXNmbnhKeWhQSFo2VXBTUlRPeHdvdVdncEFuOWgydEtsSUFneUN6cGVaTnBSdjNCdVJseGJFdmlMc203UFhLVlYyTENkaGg2dVN6Z2xwT1ZmTmN5bVZGUkM3ZWcyVkt2ckFUVVd3WFFwYnJjNVRobEh2SkVJbXRwUUpEOFJKQ1NUc0Q4NHNqUFhPSDh5cTV6MEcwSDEwRUJCQ2JiTTJlOE5nd3pMMkJaQ1dVYjMwZVVWWnlETmp2dkZ3aXEtQ29WNkxZTFkzYUkxdTlQUU1OTnhWWU12YU9MVnJQa1d2ZjRtUlhneTNubEMxTmp1eUNPOThSMlB3Y1F0T2tCdFNsNFlKalZPV25yR2QycVBUb096RmZ1V0FTaGsxLV9FWDBmenBIOXpMdGpLcUc0TWRoY2hlMFhYTzlET1ZRekw0ZHNwUVBQdVJBX2h6Q2ZzWVZJWTNybTJiekp3WmhmWF9SUFBXQzlqUjctcVlHWWVMZWVQallzR0JGTVF0WmtnWlg1aTM1bFprNVExZXY5dnNvWF93UjhwbkJ3RzNXaVJ2d2RRU3JJVlBvaVh4eTlBRUtqWkJia3dJQVVBV2Nqdm9FUTRUVW1TaHp2ZUwxT0N2ZndxQ2Nka1RYWXF0LWxIWFE0dTFQcVhncFFPM0hFdUUtYlFnemx3WkF4bjA1aDFULUdrZlVZbEJtRGRCdjJyVkdJSXozd0I0dF9zbWhOeHFqRDA4T1NVaWR5cjBwSVgwbllPU294NjZGTnM1bFhIdGpNQUxFOENWd3FCbGpSRFRmRXotQnU0N2lCVEU5RGF6Qi10S2U2NGdadDlrRjZtVE5oZkw5ZWFjXzhCTmxXQzNFTFgxRXVYY3J3YkxnbnlBSm9PY3h4MlM1NVFQbVNDRW5Ld1dvNWMxSmdoTXJuaE1pT2VFeXYwWXBHZ29MZDVlN2lwUUNIeGNCVVdQVi1rRXdJMWFncUlPTXR0MmZVQ1l0d09mZTdzWGFBWUJMUFd3b0RSOU8zeER2UWpNdzAxS0ZJWnB5S3FJdU9wUDJnTTNwMWw3VFVqVXQ3ZGZnU1RkUktkc0NhUHJ0SGFxZ0lVWDEzYjNtU2JfMGNWM1Y0dHlCTzNESEdENC1jUWF5MVppRzR1QlBNSUJySjFfRi1ENHEwcmJ4S3hQUFpXVHA0TG9DZWdoUlo5WnNSM1lCZm1KbEs2ak1yUUU4Wk9JcVJGUkJwc0NvUkMyTjhoTWxtZmVQeDREZVRKZkhYN2duLVNTeGZzdFdBVnhEandJSXB5QjM0azF0ckI3Tk1wSzFhNGVOUVRrNjU0cG9JQ29pN09xOFkwR1lMTlktaGp4TktxdTVtTnNEcldsV2pEZm5nQWpJc2hxY0hjQnVSWUR5VVdaUXBHWUloTzFZUC1oNzJ4UjZ1dnpLcDJxWEZtQlNIMWkzZ0hXWXdKeC1iLXdZWVJhcU04VFlpMU5pd2ZIdTdCdkVWVFVBdmJuRk16bEFFQTh4alBrcTV2RzliT2hGdTVPOXlRMjFuZktiRTZIamQ1VFVqS0hRTXhxcU1mdkgyQ1NjQmZfcjl4c3NJd0RIeDVMZUFBbHJqdEJxWWl3aWdGUEQxR3ZnMkNGdVB4RUxkZi1xOVlFQXh1NjRfbkFEaEJ5TVZlUGFrWVhSTVRPeGxqNlJDTHNsRWRrei1pYjhnUmZrb3BvWkQ2QXBzYjFHNXZoWU1LSExhLWtlYlJTZlJmYUM5Y1Rhb1pkMVYyWTByM3NTS0VXMG1ybm1BTVN2QXRYaXZqX2dKSkZrajZSS2cyVlNOQnd5Y29zMlVyaWlNbTJEb3FuUFFtbWNTNVpZTktUenFZSl91cVFXZjRkQUZyYmtPczU2S1RKQ19ONGFOTHlwX2hOOEE1UHZEVjhnT0xxRjMxTEE4SHhRbmlmTkZwVXJBdlJDbU5oZS05SzI4QVhEWDZaN2ZiSlFwUGRXSnB5TE9MZV9ia3pYcmZVa1dicG5FMHRXUFZXMWJQVDAwOEdDQzJmZEl0ZDhUOEFpZXZWWXl5Q2xwSmFienNCMldlb2NKb2ZRYV9KbUdHRzNUcjU1VUFhMzk1a2J6dDVuNTl6NTdpM0hGa3k0UWVtbF9pdDVsQVp2cndDLUU5dnNYOF9CLS0ySXhBSFdCSnpqV010bllBb3U0cEZZYVF5R2tSNFM5NlRhdS1fb1NqbDBKMkw0V2N0VEZhNExtQlR3ckZ3cVlCeHVXdXJ6X0s4cEtsaG5rVUxCN2RRbHQxTmcyVFBqYUxyOHJzeFBXVUJaRHpXbUoxdHZzMFBzQk1UTUFvX1pGNFNMNDFvZWdTdEUtMUNKMXNIeVlvQk1CeEdpZVdmN0tsSDVZZHJXSGt5c2o2MHdwSTZIMVBhRzM1eU43Q2FtcVNidExxczNJeUx5U2RuUG5EeHpCTlg2SV9WNk1ET3BRNXFuc0pNWlVvZUYtY21oRGtJSmwxQ09QbHBUV3BuS3B5NE9RVkhfellqZjJUQ0diSV94QlhQWmdaaC1TRWxsMUVWSXB0aE1McFZDZDNwQUVKZ2t5cXRTXzlRZVJwN0pZSnJSV21XMlh0TzFRVEl0c2I4QjBxOGRCYkNxek04a011X1lrb2poQ3h2LUhKTGJiUlhneHp5QWFBcE5nMElkNTVzM3JGOWtUQ19wNVBTaVVHUHFDNFJnNXJaWDNBSkMwbi1WbTdtSnFySkhNQl9ZQjZrR2xDcXhTRExhMmNHcGlyWjR3ZU9SSjRZd1l4ZjVPeHNiYk53SW5SYnZPTzNkd1lnZmFseV9tQ3BxM3lNYVBHT0J0elJnMTByZ3VHemxta0tVQzZZRllmQ2VLZ1ZCNDhUUTc3LWNCZXBMekFwWW1fQkQ1NktzNGFMYUdYTU0xbXprY1FONUNlUHNMY3h2NFJMMmhNa3VNdzF4TVFWQk9odnJUMjFJMVd3Z2N6Sms5aEM2SWlWZFViZ0JWTEpUWWM5NmIzOS1oQmRqdkt1NUUycFlVcUxERUZGbnZqTUxIYnJmMDBHZDEzbnJsWEEzSUo3UmNPUDg1dnRUU1FzcWtjTWZwUG9zM0JTY3RqMDdST2UxcXFTM0d0bGkwdFhnMk5LaUlxNWx3V1pLaVlLUFJXZzBzVl9Ia1V1OHdYUEFWOU50UndycGtCdzM0Q0NQamp2VTNqbFBLaGhsbUk5dUI5MjU5OHVySk1oY0drUWtXUloyVVRvOWJmbUVYRzFVeWNQczh2NXJCeVppRlZiWDNJaDhOSmRmX2lURTNVS3NXQXFZT1QtUmdvMWJoVWYxU3lqUUJhbzEyX3I3TXhwbm9wc1FoQ1ZUTlNBRjMyQTBTY2tzbHZ3RFUtTjVxQ0o1QXRTVks2WENwMGZCRGstNU1jN3FhUFJCQThyaFhhMVRsbnlSRXNGRmt3Yk01X21ldmV3bTItWm1JaGpZQWZROEFtT1d1UUtPQlhYVVFqT2NxLUxQenJHX3JfMEdscDRiMXcyZ1ZmU3NFMzVoelZJaDlvT0ZoRGQ2bmtlM0M5ZHlCd2ZMbnRZRkZUWHVBUEx4czNfTmtMckh5eXZrZFBzOEItOGRYOEhsMzBhZ0xlOWFjZzgteVBsdnpPT1pYdUxnbFNXYnhKaVB6QUxVdUJCOFpvU2x2c1FHZV94MDBOVWJhYkxISkswc0U5UmdPWFJLXzZNYklHTjN1QzRKaldKdEVHb0pOU284N3c2LXZGMGVleEZ5NGZ6OGV1dm1tM0J0aTQ3VFlNOEJrdEh3PT0=
|
|
||||||
|
|
||||||
# Feature SyncDelta JIRA configuration
|
|
||||||
Feature_SyncDelta_JIRA_DELTA_TOKEN_SECRET = INT_ENC:Z0FBQUFBQm8xSVRkTUNsWm4wX0p6eXFDZmJ4dFdHNEs1MV9MUzdrb3RzeC1jVWVYZ0REWHRyZkFiaGZLcUQtTXFBZzZkNzRmQ0gxbEhGbUNlVVFfR1JEQTc0aldkZkgyWnBOcjdlUlZxR0tDTEdKRExULXAyUEtsVmNTMkRKU1BJNnFiM0hlMXo4YndMcHlRMExtZDQ3Zm9vNFhMcEZCcHpBPT0=
|
|
||||||
|
|
||||||
# Teamsbot Browser Bot Service
|
|
||||||
TEAMSBOT_BROWSER_BOT_URL = https://cae-poweron-shared.redwater-53d21339.switzerlandnorth.azurecontainerapps.io
|
|
||||||
|
|
||||||
# Debug Configuration
|
|
||||||
APP_DEBUG_CHAT_WORKFLOW_ENABLED = FALSE
|
|
||||||
APP_DEBUG_CHAT_WORKFLOW_DIR = ./test-chat
|
|
||||||
APP_DEBUG_ACCOUNTING_SYNC_ENABLED = FALSE
|
|
||||||
APP_DEBUG_ACCOUNTING_SYNC_DIR = ./debug/sync
|
|
||||||
|
|
||||||
# Azure Communication Services Email Configuration
|
|
||||||
MESSAGING_ACS_CONNECTION_STRING = endpoint=https://mailing-poweron-prod.switzerland.communication.azure.com/;accesskey=4UizRfBKBgMhDgQ92IYINM6dJsO1HIeL6W1DvIX9S0GtaS1PjIXqJQQJ99CAACULyCpHwxUcAAAAAZCSuSCt
|
|
||||||
MESSAGING_ACS_SENDER_EMAIL = DoNotReply@poweron.swiss
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
# Production Environment Configuration
|
|
||||||
|
|
||||||
# System Configuration
|
|
||||||
APP_ENV_TYPE = prod
|
|
||||||
APP_ENV_LABEL = Production Instance
|
|
||||||
APP_KEY_SYSVAR = CONFIG_KEY
|
|
||||||
APP_INIT_PASS_ADMIN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3UnJRV0sySFlDblpXUlREclREaW1WbUt6bGtQYkdrNkZDOXNOLXFua1hqeFF2RHJnRXJ5VlVGV3hOZm41QjZOMlNTb0duYXNxZi05dXVTc2xDVkx0SVBFLUhncVo5T0VUZHE0UTZLWWw3ck09
|
|
||||||
APP_INIT_PASS_EVENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3QVpIY19DQVZSSzJmc2F0VEZvQlU1cHBhTEgxdHdnR3g4eW01aTEzYTUxc1gxTDR1RVVpSHRXYjV6N1BLZUdCUGlfOW1qdy0xSHFVRkNBcGZvaGlSSkZycXRuUllaWnpyVGRoeFg1dGEyNUk9
|
|
||||||
APP_API_URL = https://gateway-prod.poweron.swiss
|
|
||||||
APP_COOKIE_SECURE = true
|
|
||||||
|
|
||||||
# PostgreSQL DB Host
|
|
||||||
DB_HOST=gateway-prod-server.postgres.database.azure.com
|
|
||||||
DB_USER=gzxxmcrdhn
|
|
||||||
DB_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3Y1JScGxjZG9TdUkwaHRzSHZhRHpNcDV3N1U2TnIwZ21PRG5TWFFfR1k0N3BiRk5WelVadjlnXzVSTDZ6NXFQNFpqbnJ1R3dNVkJocm1zVEgtSk0xaDRiR19zNDBEbVIzSk51ekNlQ0Z3b0U9
|
|
||||||
DB_PORT=5432
|
|
||||||
|
|
||||||
# Security Configuration
|
|
||||||
APP_JWT_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3elhfV0Rnd2pQRjlMdkVwX1FnSmRhSzNZUlV5SVpaWXBNX1hpa2xPZGdMSWpnN2ZINHQxeGZnNHJweU5pZjlyYlY5Qm9zOUZEbl9wUEgtZHZXd1NhR19JSG9kbFU4MnFGQnllbFhRQVphRGQyNHlFVWR5VHQyUUpqN0stUmRuY2QyTi1oalczRHpLTEJqWURjZWs4YjZvT2U5YnFqcXEwdEpxV05fX05QMmtrPQ==
|
|
||||||
APP_TOKEN_EXPIRY=300
|
|
||||||
|
|
||||||
# CORS Configuration
|
|
||||||
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss,https://nyla.poweron-center.net,https://nyla-int.poweron-center.net
|
|
||||||
|
|
||||||
# Logging configuration
|
|
||||||
APP_LOGGING_LOG_LEVEL = DEBUG
|
|
||||||
APP_LOGGING_LOG_DIR = /home/site/wwwroot/
|
|
||||||
APP_LOGGING_FORMAT = %(asctime)s - %(levelname)s - %(name)s - %(message)s
|
|
||||||
APP_LOGGING_DATE_FORMAT = %Y-%m-%d %H:%M:%S
|
|
||||||
APP_LOGGING_CONSOLE_ENABLED = True
|
|
||||||
APP_LOGGING_FILE_ENABLED = True
|
|
||||||
APP_LOGGING_ROTATION_SIZE = 10485760
|
|
||||||
APP_LOGGING_BACKUP_COUNT = 5
|
|
||||||
|
|
||||||
# OAuth: Auth app (login/JWT) vs Data app (Graph / Google APIs)
|
|
||||||
Service_MSFT_AUTH_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
|
|
||||||
Service_MSFT_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnFBa1kySFR2NjBKM084QTNpeUlyUmM4R0N0SU1BZ2x4MmVTZTVHQkVzRE9GdmFkV041MzhudFhobjU0RWNnd3lqeXpKUXA5aGtNZkhtYU12QjBtX0NjemVmdEZBdC1TbXVBSXJTcF9vMlJXd0ZNRTRKRFBMUXNjTF85eTBxakR4RVNfYmU=
|
|
||||||
Service_MSFT_AUTH_REDIRECT_URI = https://gateway-prod.poweron.swiss/api/msft/auth/login/callback
|
|
||||||
Service_MSFT_DATA_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
|
|
||||||
Service_MSFT_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnFBa1kyNVU4cVRIZFdjS3l2S1RJVTVlc1ozQ1liZXZDX1VwdFZQUzFtS0N6UWYyeGxkNGNmY1hoaWxEUDBXVU5QR2t3Vi1ZV1A2QkxqbnpobzJwOXdzYTBZaFZYdnNkeDE1VVl0bm4weHFiLXdON2gtZzAwMTkxNWRoZldFM2djSkNHVS0=
|
|
||||||
Service_MSFT_DATA_REDIRECT_URI = https://gateway-prod.poweron.swiss/api/msft/auth/connect/callback
|
|
||||||
|
|
||||||
Service_GOOGLE_AUTH_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
|
|
||||||
Service_GOOGLE_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnFBa1kyUmJleVpTOF9OaFV3NGVfcWVBX2oxSjUwMWRGOFZRWFRIN1FZRzZ6U3VQMlg5a21RY1drTHh3U254LW4zM1A1cXQ1TTFWYlNoek9hSHJIeE4tbm1wU1lKRXlKNU5HVWI4VGZwTVE0VnJGaV8wZmNvdkVrMjJGeXdmZ3UyNmVXN1E=
|
|
||||||
Service_GOOGLE_AUTH_REDIRECT_URI = https://gateway-prod.poweron.swiss/api/google/auth/login/callback
|
|
||||||
Service_GOOGLE_DATA_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
|
|
||||||
Service_GOOGLE_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnFBa1kyY2pxMDh0U0RqWERianBMTTNtSUZPSzhKUzh4S0RTenR2MmxnRDlvQzJjbDVTczRWLUJtVnhxWTE2MmUxQjJia2xJcVUzVlFlUnpma040NFdHRzVNRUt0OXR0c2JkTkRmQ1RIYllXbXFFaExIQWNycFVHbUxHbmtYOVhOVUV2MFY=
|
|
||||||
Service_GOOGLE_DATA_REDIRECT_URI = https://gateway-prod.poweron.swiss/api/google/auth/connect/callback
|
|
||||||
|
|
||||||
# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly.
|
|
||||||
Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
|
|
||||||
Service_CLICKUP_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6VGw5WDdhdDRsVENSalhSSUV0OFFxbEx0V1l6aktNV0E5Y18xU3JHLUlqMWVJdmxyajAydVZRaDJkZzJOVXhxRV9ROFRZbWxlRjh4c3NtQnRFMmRtZWpzTWVsdngtWldlNXRKTURHQjJCOEt6alMwQlkwOFYyVVJWNURJUGJIZDIxYVlfNnBrMU54M0Q3TVdVbFZqRkJKTUtqa05wUkV4eGZvbXNsVi1nNVdBPQ==
|
|
||||||
Service_CLICKUP_OAUTH_REDIRECT_URI = https://gateway-prod.poweron.swiss/api/clickup/auth/connect/callback
|
|
||||||
|
|
||||||
# Infomaniak: no OAuth client. Users paste a Personal Access Token (kdrive + mail) per UI.
|
|
||||||
|
|
||||||
# Stripe Billing (both end with _SECRET for encryption script)
|
|
||||||
STRIPE_SECRET_KEY_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6aVA3R3VRS3VHMUgzUEVjYkR4eUZKWFhPUzFTTVlHNnBvT3FienNQaUlBWVpPLXJyVGpGMWk4LXktMXphX0J6ZTVESkJxdjNNa3ZJbF9wX2ppYzdjYlF0cmdVamlEWWJDSmJYYkJseHctTlh4dnNoQWs4SG5haVl2TTNDdXpuaFpqeDBtNkFCbUxMa0RaWG14dmxyOEdILTNrZ2licmNpbXVkN2lFSWoxZW1BODNpV0ZTQ0VaeXRmR1d4RjExMlVFS3MtQU9zZXZlZE1mTmY3OWctUXJHdz09
|
|
||||||
STRIPE_WEBHOOK_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGNUpTWldsakYydFhFelBrR1lSaWxYT3kyMENOMUljZTJUZHBWcEhhdWVCMzYxZXQ5b3VlTFVRalFiTVdsbGxrdUx0RDFwSEpsOC1sTDJRTEJNQlA3S3ZaQzBtV1h6bWp5VnlMZUgwUlF3cXYxcnljZVE5SWdzLVg3V0syOWRYS08=
|
|
||||||
STRIPE_API_VERSION = 2026-01-28.clover
|
|
||||||
STRIPE_AUTOMATIC_TAX_ENABLED = false
|
|
||||||
STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQZG8WqlVsabrfFEu49pah
|
|
||||||
|
|
||||||
|
|
||||||
# AI configuration
|
|
||||||
Connector_AiOpenai_API_SECRET = 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
|
|
||||||
Connector_AiAnthropic_API_SECRET = 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
|
|
||||||
Connector_AiPerplexity_API_SECRET = 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
|
|
||||||
Connector_AiTavily_API_SECRET = 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
|
|
||||||
Connector_AiPrivateLlm_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGanZ6U3pzZWkwXzVPWGtIQ040XzFrTXc5QWRnazdEeEktaUJ0akJmNnEzbWUzNHczLTJfc2dIdzBDY0FTaXZYcDhxNFdNbTNtbEJTb2VRZ0ZYd05hdlNLR1h6SUFzVml2Z1FLY1BjTl90UWozUGxtak1URnhhZmNDRWFTb0dKVUo=
|
|
||||||
Connector_AiMistral_API_SECRET = H55rGkR3ojIhcp4YMMlgUStgvz7Wym5c
|
|
||||||
|
|
||||||
Service_MSFT_TENANT_ID = common
|
|
||||||
|
|
||||||
# Google Cloud Speech Services configuration
|
|
||||||
Connector_GoogleSpeech_API_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z4NFQxaF9uN3h1cVB6dnZid1c1R1VfNDlSQ1NHMEVDZWtKanpMQ29CLXc1MXBqRm1hQ0YtWVhaejBMY1ZTOEFEVlpWQ3hrYkFza1E2RDNsYkdMMndNR0VGNTMwVDRGdURJY3hyaVFxVjEtSEYwNHJzeWM3WmlpZW9jU2E3NTgycEV2allqQ3dJRTNyRFAzaDJ6dklKeXpNRkJhYjFzUkptN2dpbkNpMklrcGxuZl9vTkt3T0JvNm1YTXd5UlkwZWptUXdWVFpnV2J4X3J2WUhIUlFkSElFVnlqMnlJRnNHTnlpMWs2R1dZc2ROWjNYZG85cndmd1E5cUZnVmZRYnVjTG43dXFmSWd2bGFfVWFWSmtpWkpndWNlSUNwcnFNU2NqZXFaV0xsY3l3SElLRkVHcHZGZERKV1ltcGhTS0dhTko1VTJLYzNoZjRkSGVEX3dTMWVVTmdDczV5cE1JQUdSbUJGUm11eFhTVjJHbkt0SzB4UG1Dc2xmbnp1Y041Y2RTeWRuWGdmQy1sTGx0MGtnM2VJQ3EyLXViRlNhTU9ybzZkR1N1bXE5SXhlZENWRFpWSGlYOWx4SUQ3UlR0ZEVxQkxNakRUVFRiUmFnbklOalphLUZkRFVVaXBRUk5NZW5PaUZydTFmQkNPSTdTVTNZd0plWXllNVFJdmN4MVcyTGlwMGFtVjBzOGRxR1FjbzhfYW5zdTB0ZEZBTTJhakltazh1dktNMUZsOUItdFdTb1pIaUxySllXNkdlY20zUS0wTnpFNTB2SU5acG1VcXhyaHBmME8takw3RDh5T043T2VGOV92TzNya2pWSlpYVjZDdXlZcjM3a0hPTlhkaW9oQmxqQlpGRFYyTTY4WmZmT3k4Tk1tdXRuSGdTUVpNT2NKenhXb05PdXBfSEdhMTNxNjdpNXlKUUI2YUgydFFPX1VvXzVJb0UxWTU2YVNiNDQ0QndZanhMMHR1cGdHWGhvcEg1QXEtSXZJdTdZUE12ZEVVWkF4QmtsQS1GYnY3SFIxSHlsOGVfcEpGS1A4QUVEQWNEOFZYYlljQ3ByTU03YU16Y0UzUnJQZEprSWNjT1ZXVEtDWi03Y3ZzRVdYUTlabXJISEo5THRHVXVuM0xqbzA4bGVlZVpOMk1QMmptb21tV0pTMlVoOXdWVU95UW1iQmttc2w1RG9mMWwxXzg1T2IxYUVmTUJEZkpUdTFDTzZ3RlBFeUFiX01iRTZNWkNaSG45TkFOM2pzbUJRZ2N0VFpoejJUTG1RODY3TzZpSzVkYUQzaEpfY2pSTkRzU0VpanlkdXVQQmJ2WU5peno4QWNLTDVxZTlhSHI3NnNiM0k0Y3JkQ0xaOU05bGtsQl8zQklvaktWSDZ4aVp2MHlYelJuUDJyTU9CZC1OZjJxNFc1dDcwSUlxaVh1LTMyWWFwU0IwUU9kOUFpMWpnOERtLTh1VmJiNGVwcXBMbU5fMjVZc0hFbmxQT2puSFd1ZGpyTkphLU5sVlBZWWxrWEZrWGJQWmVkN19tZFZfZ1l1V3pSWlA0V0ZxM2lrWnl2NU9WeTdCbDROSmhfeENKTFhMVXk1d195S2JMUFJoRXZjcVo4V2g0MTNKRnZhUE1wRkNPM3FZOGdVazJPeW5PSGpuZnFGTTdJMkRnam5rUlV6NFlqODlIelRYaEN5VjdJNnVwbllNODNCTFRHMWlXbmM1VlRxbXB3Wm9LRjVrQUpjYzRNMThUMWwwSVhBMUlyamtPZnE4R0o4bEdHay1zMjR5RDJkZ1lYRHZaNHVHU2otR3ZpN25LZlEySEU0UmdTNzJGVHNWQXMyb0dVMV9WUE13ODhZWUFaakxGOWZieGNXZkNYRnV5djEyWTZLcmdrajRBLU1rS1Z0VVRkOWlDMU9fMGVmYXFhZXJGMUhpNkdmb2hkbzZ1OWV6VlNmVzNISjVYTFh6SjJNdWR5MWZidE8yVEo2dnRrZXhMRXBPczUwTG13OGhNUVpIQm0zQmRKRnJ0Nl8wNW1Ob0dHRDVpU0NWREV3TkY2SjktdVBkMFU1ZXBmSFpHQ3FHNTRZdTJvaExpZVEtLTU4YTVyeFBpNDdEajZtWUc4c1dBeUJqQ3NIY1NLS0FIMUxGZzZxNFNkOG9ORGNHWWJCVnZuNnJVTEtoQi1mRTZyUl81ZWJJMi1KOGdERzBhNVRZeHRYUUlqY2JvMFlaNHhWMU9pWFFiZjdaLUhkaG15TTBPZVlkS2R5UVdENTI4QVFiY1RJV0ZNZnlpVWxfZmlnN1BXbGdrbjFGUkhzYl9qeHBxVVJacUE4bjZETENHVFpSamh0NVpOM2hMYTZjYzBuS3J0a3hhZGxSM1V5UHd2OTU3ZHY0Yy1xWDBkWUk0Ymp0MWVrS3YzSktKODhQZnY3QTZ1Wm1VZkZJbS1jamdreks1ZlhpQjFOUDFiOHJ2Nm9NcmdTdU5LQXV2RkZWZEFNZnVKUjVwcVY3dDdhQnpmRVJ6SmlvVXpDM0ZiYXh5bGE2X04tTE9qZ3BiTnN3TF9ZaFRxSUpjNjB1dXZBcy1TZHRHTjFjSUR3WUl4cE9VNzB5Rkk4U3Z1SVZYTl9sYXlZVk83UnFrMlVmcnBpam9lRUlCY19DdVJwOXl2TVVDV1pMRFZTZk9MY3Z1eXA0MnhGazc5YllQaWtOeTc4NjlOa2lGY05RRzY1cG9nbGpYelc4c3FicWxWRkg0YzRSamFlQ19zOU14YWJreU9pNDREZVJ3a0REMUxGTzF1XzI1bEF3VXVZRjlBeWFiLXJsOXgza3VZem1WckhWSnVNbDBNcldadU8xQ3RwOTl5NGgtVlR0QklCLWl5WkE4V1FlQTBCOVU1RE9sQlRrYUNZOGdfUmEwbEZvUTFGUEFWVmQ4V1FhOU9VNjZqemRpZm1sUDhZQTJ0YVBRbWZldkF5THV4QXpfdUtNZ0tlcGdSRFM3c0lDOTNQbnBxdmxYYWNpTmI3MW9BMlZIdTQ5RldudHpNQWQ5NDNPLVVTLXVVNzdHZXh4UXpZa3dVa2J4dTFDV1RkYjRnWXU2M3lJekRYWGNMcWU5OVh6U2xZWDh6MmpqcnpiOHlnMjA5S3RFQm1NZjNSM21adkVnTUpSYVhkTzNkNnJCTmljY0x1cl9kMkx3UHhySjZEdHREanZERzNEUTFlTkR0NWlBczAtdmFGTjdZNVpTMlkxV2czYW5RN2lqemg4eUViZDV6RjdKNXdFcUlvcVhoNkJ6eVJkR1pua1hnNzQwOEs2TXJYSlpGcW9qRDU2QjBOWFFtdXBJRkRKbmdZUF9ZSmRPVEtvUjVhLTV1NjdXQjRhS0duaEtJb2FrQnNjUTRvdFMxdkdTNk1NYlFHUFhhYTJ1eUN3WHN4UlJ4UjdrZjY0SzFGYWVFN1k0cGJnc1RjNmFUenR4NHljbVhablZSWHZmUVN3cXRHNjhsX1BSZWEzdTJUZFA0S2pTaU9YMnZIQ1ZPcGhWMFJqZkVEMWRMR1h3SnU0Z2FzZ3VGM3puNzdhVjhaQXNIWHFsbjB0TDVYSFdSNV9rdWhUUUhSZHBGYkJIVDB5SDdlMC13QTVnS0g5Qkg5RGNxSGJlelVndUhPcEQ0QkRKMTJTZUM1OXJhVm0zYjU0OVY2dk9MQVBheklIQXpVNW9Yc0ROVjEzaFZTWmVxYlBWMlNlSzladzJ6TmNuMG5FVVZkN1VZN1pfS2ZHa0lQcE80S24wSnQtVlJVV09OVWJ3M09YMkZpV2ktVF9ENHhKU2dfYUQ2aUVyamk0VHJHQmVfVHU4clpUTFoteW5aSWRPV1M0RDRMTms4NGRoYmJfVE82aUl2X3VieVJOdDhBQmRwdzdnRTVBNzZwaW93dUlZb3ZRYUtOeG9ULWxvNVp5a0haSjdkcUhRb3d6UGIxRUpCVkVYX2d6TkRqQVozUWxkNGFoc1FXYVd2YWNkME9Qclo0bjYxMFRWTy1nbnI5NTBJNzRMMDluUXRKYTFqQUN4d0d5aHVlamN3Tkk3NWJXeXR0TW9BeUg5Vnp4Q2RnZUY3b3AtMDlrNmlrSGR0eGRtbUdUd2lFRWg4MklEeWJHN2wwZEpVSXMxNDNOWjRFS0tPdWxhMmFCckhfRENIY184aEFDZXNrRDl2dHQtQW12UnRuQXJjaDJoTUpiYkNWQUtfRG9GMUZoNWM4UnBYZ29RWWs2NHcyUm5kdTF3Vk1GeFpiRUJLaVZ2UGFjbi1jV3lMV0N2ZDl4VERPN295X01NNG56ZjZkRzZoYUtmY1E5NlVXemx2SnVfb19iSXg0R2M3Mjd1a2JRPT0=
|
|
||||||
|
|
||||||
# Feature SyncDelta JIRA configuration
|
|
||||||
Feature_SyncDelta_JIRA_DELTA_TOKEN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z4d3Z4d2x6N1FhUktMU0RKbkxfY2pTQkRzXzJ6UXVEbDNCaFM3UHMtQVFGYzNmYWs4N0lMM1R2SFJuZTVFVmx6MGVEbXc5U3NOTnY1TWN0ZDNaamlHQWloalM3VldmREJNSHQ1TlVkSVFJMTVhQWVGSVRMTGw4UTBqNGlQZFVuaHp4WUlKemR5UnBXZlh0REJFLXJ4ejR3PT0=
|
|
||||||
|
|
||||||
# Teamsbot Browser Bot Service
|
|
||||||
TEAMSBOT_BROWSER_BOT_URL = https://cae-poweron-shared.redwater-53d21339.switzerlandnorth.azurecontainerapps.io
|
|
||||||
|
|
||||||
# Debug Configuration
|
|
||||||
APP_DEBUG_CHAT_WORKFLOW_ENABLED = FALSE
|
|
||||||
APP_DEBUG_CHAT_WORKFLOW_DIR = ./test-chat
|
|
||||||
APP_DEBUG_ACCOUNTING_SYNC_ENABLED = FALSE
|
|
||||||
APP_DEBUG_ACCOUNTING_SYNC_DIR = ./debug/sync
|
|
||||||
|
|
||||||
# Azure Communication Services Email Configuration
|
|
||||||
MESSAGING_ACS_CONNECTION_STRING = endpoint=https://mailing-poweron-prod.switzerland.communication.azure.com/;accesskey=4UizRfBKBgMhDgQ92IYINM6dJsO1HIeL6W1DvIX9S0GtaS1PjIXqJQQJ99CAACULyCpHwxUcAAAAAZCSuSCt
|
|
||||||
MESSAGING_ACS_SENDER_EMAIL = DoNotReply@poweron.swiss
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
# Production Environment Configuration
|
|
||||||
|
|
||||||
# System Configuration
|
|
||||||
APP_ENV_TYPE = prod
|
|
||||||
APP_ENV_LABEL = Production Instance
|
|
||||||
APP_KEY_SYSVAR = CONFIG_KEY
|
|
||||||
APP_INIT_PASS_ADMIN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3UnJRV0sySFlDblpXUlREclREaW1WbUt6bGtQYkdrNkZDOXNOLXFua1hqeFF2RHJnRXJ5VlVGV3hOZm41QjZOMlNTb0duYXNxZi05dXVTc2xDVkx0SVBFLUhncVo5T0VUZHE0UTZLWWw3ck09
|
|
||||||
APP_INIT_PASS_EVENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3QVpIY19DQVZSSzJmc2F0VEZvQlU1cHBhTEgxdHdnR3g4eW01aTEzYTUxc1gxTDR1RVVpSHRXYjV6N1BLZUdCUGlfOW1qdy0xSHFVRkNBcGZvaGlSSkZycXRuUllaWnpyVGRoeFg1dGEyNUk9
|
|
||||||
APP_API_URL = https://gateway-prod.poweron.swiss
|
|
||||||
APP_COOKIE_SECURE = true
|
|
||||||
|
|
||||||
# PostgreSQL DB Host
|
|
||||||
DB_HOST=gateway-prod-server.postgres.database.azure.com
|
|
||||||
DB_USER=gzxxmcrdhn
|
|
||||||
DB_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3Y1JScGxjZG9TdUkwaHRzSHZhRHpNcDV3N1U2TnIwZ21PRG5TWFFfR1k0N3BiRk5WelVadjlnXzVSTDZ6NXFQNFpqbnJ1R3dNVkJocm1zVEgtSk0xaDRiR19zNDBEbVIzSk51ekNlQ0Z3b0U9
|
|
||||||
DB_PORT=5432
|
|
||||||
|
|
||||||
# Security Configuration
|
|
||||||
APP_JWT_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3elhfV0Rnd2pQRjlMdkVwX1FnSmRhSzNZUlV5SVpaWXBNX1hpa2xPZGdMSWpnN2ZINHQxeGZnNHJweU5pZjlyYlY5Qm9zOUZEbl9wUEgtZHZXd1NhR19JSG9kbFU4MnFGQnllbFhRQVphRGQyNHlFVWR5VHQyUUpqN0stUmRuY2QyTi1oalczRHpLTEJqWURjZWs4YjZvT2U5YnFqcXEwdEpxV05fX05QMmtrPQ==
|
|
||||||
APP_TOKEN_EXPIRY=300
|
|
||||||
|
|
||||||
# CORS Configuration
|
|
||||||
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss,https://nyla.poweron-center.net,https://nyla-int.poweron-center.net
|
|
||||||
|
|
||||||
# Logging configuration
|
|
||||||
APP_LOGGING_LOG_LEVEL = DEBUG
|
|
||||||
APP_LOGGING_LOG_DIR = /home/site/wwwroot/
|
|
||||||
APP_LOGGING_FORMAT = %(asctime)s - %(levelname)s - %(name)s - %(message)s
|
|
||||||
APP_LOGGING_DATE_FORMAT = %Y-%m-%d %H:%M:%S
|
|
||||||
APP_LOGGING_CONSOLE_ENABLED = True
|
|
||||||
APP_LOGGING_FILE_ENABLED = True
|
|
||||||
APP_LOGGING_ROTATION_SIZE = 10485760
|
|
||||||
APP_LOGGING_BACKUP_COUNT = 5
|
|
||||||
|
|
||||||
# OAuth: Auth app (login/JWT) vs Data app (Graph / Google APIs)
|
|
||||||
Service_MSFT_AUTH_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
|
|
||||||
Service_MSFT_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnFBa1kySFR2NjBKM084QTNpeUlyUmM4R0N0SU1BZ2x4MmVTZTVHQkVzRE9GdmFkV041MzhudFhobjU0RWNnd3lqeXpKUXA5aGtNZkhtYU12QjBtX0NjemVmdEZBdC1TbXVBSXJTcF9vMlJXd0ZNRTRKRFBMUXNjTF85eTBxakR4RVNfYmU=
|
|
||||||
Service_MSFT_AUTH_REDIRECT_URI = https://gateway-prod.poweron.swiss/api/msft/auth/login/callback
|
|
||||||
Service_MSFT_DATA_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
|
|
||||||
Service_MSFT_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnFBa1kyNVU4cVRIZFdjS3l2S1RJVTVlc1ozQ1liZXZDX1VwdFZQUzFtS0N6UWYyeGxkNGNmY1hoaWxEUDBXVU5QR2t3Vi1ZV1A2QkxqbnpobzJwOXdzYTBZaFZYdnNkeDE1VVl0bm4weHFiLXdON2gtZzAwMTkxNWRoZldFM2djSkNHVS0=
|
|
||||||
Service_MSFT_DATA_REDIRECT_URI = https://gateway-prod.poweron.swiss/api/msft/auth/connect/callback
|
|
||||||
|
|
||||||
Service_GOOGLE_AUTH_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
|
|
||||||
Service_GOOGLE_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnFBa1kyUmJleVpTOF9OaFV3NGVfcWVBX2oxSjUwMWRGOFZRWFRIN1FZRzZ6U3VQMlg5a21RY1drTHh3U254LW4zM1A1cXQ1TTFWYlNoek9hSHJIeE4tbm1wU1lKRXlKNU5HVWI4VGZwTVE0VnJGaV8wZmNvdkVrMjJGeXdmZ3UyNmVXN1E=
|
|
||||||
Service_GOOGLE_AUTH_REDIRECT_URI = https://gateway-prod.poweron.swiss/api/google/auth/login/callback
|
|
||||||
Service_GOOGLE_DATA_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
|
|
||||||
Service_GOOGLE_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnFBa1kyY2pxMDh0U0RqWERianBMTTNtSUZPSzhKUzh4S0RTenR2MmxnRDlvQzJjbDVTczRWLUJtVnhxWTE2MmUxQjJia2xJcVUzVlFlUnpma040NFdHRzVNRUt0OXR0c2JkTkRmQ1RIYllXbXFFaExIQWNycFVHbUxHbmtYOVhOVUV2MFY=
|
|
||||||
Service_GOOGLE_DATA_REDIRECT_URI = https://gateway-prod.poweron.swiss/api/google/auth/connect/callback
|
|
||||||
|
|
||||||
# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly.
|
|
||||||
Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
|
|
||||||
Service_CLICKUP_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6VGw5WDdhdDRsVENSalhSSUV0OFFxbEx0V1l6aktNV0E5Y18xU3JHLUlqMWVJdmxyajAydVZRaDJkZzJOVXhxRV9ROFRZbWxlRjh4c3NtQnRFMmRtZWpzTWVsdngtWldlNXRKTURHQjJCOEt6alMwQlkwOFYyVVJWNURJUGJIZDIxYVlfNnBrMU54M0Q3TVdVbFZqRkJKTUtqa05wUkV4eGZvbXNsVi1nNVdBPQ==
|
|
||||||
Service_CLICKUP_OAUTH_REDIRECT_URI = https://gateway-prod.poweron.swiss/api/clickup/auth/connect/callback
|
|
||||||
|
|
||||||
# Infomaniak: no OAuth client. Users paste a Personal Access Token (kdrive + mail) per UI.
|
|
||||||
|
|
||||||
# Stripe Billing (both end with _SECRET for encryption script)
|
|
||||||
STRIPE_SECRET_KEY_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6aVA3R3VRS3VHMUgzUEVjYkR4eUZKWFhPUzFTTVlHNnBvT3FienNQaUlBWVpPLXJyVGpGMWk4LXktMXphX0J6ZTVESkJxdjNNa3ZJbF9wX2ppYzdjYlF0cmdVamlEWWJDSmJYYkJseHctTlh4dnNoQWs4SG5haVl2TTNDdXpuaFpqeDBtNkFCbUxMa0RaWG14dmxyOEdILTNrZ2licmNpbXVkN2lFSWoxZW1BODNpV0ZTQ0VaeXRmR1d4RjExMlVFS3MtQU9zZXZlZE1mTmY3OWctUXJHdz09
|
|
||||||
STRIPE_WEBHOOK_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGNUpTWldsakYydFhFelBrR1lSaWxYT3kyMENOMUljZTJUZHBWcEhhdWVCMzYxZXQ5b3VlTFVRalFiTVdsbGxrdUx0RDFwSEpsOC1sTDJRTEJNQlA3S3ZaQzBtV1h6bWp5VnlMZUgwUlF3cXYxcnljZVE5SWdzLVg3V0syOWRYS08=
|
|
||||||
STRIPE_API_VERSION = 2026-01-28.clover
|
|
||||||
STRIPE_AUTOMATIC_TAX_ENABLED = false
|
|
||||||
STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQZG8WqlVsabrfFEu49pah
|
|
||||||
|
|
||||||
|
|
||||||
# AI configuration
|
|
||||||
Connector_AiOpenai_API_SECRET = PROD_ENC:Z0FBQUFBQnFCdlFmcDVyOGNwbVkwWFJCWmFkZS12RkhLaFhLSF9kWWpEZ0d0NDBqV2FnWlpnYmpSckdLSGpjbmh6aHJXVUZxMElwY1MzcVg1MzBOdURUZXhnZ3pqNEZyQ1JWMVA0YmxhNWJlenNpa1A3TjZkYVZSclFONjU4MF9jMTJaS2d0ZDNnXzJKSmhSRVhyckJpTUlDa0RRWHN5cWVkOUJMTUp5aFRHcDV5Z1A1aWhSUnFNOHBJTDFPdzAzcVJ3bmhueTBmVkJDZTdJakhMOEFRdHBvWFduUzdRV2dNQVdpaXdFSVlHMDJ4NnZRUTBZZ3pOakxPLUdjNlNNQnJQMXpfSWR3NmFodDdDbkEtVmRjdVBhMjRWT1NOV1BYbU15VHRSWFR0UVBBMWtKRTRkS25KMFk9
|
|
||||||
Connector_AiAnthropic_API_SECRET = PROD_ENC:Z0FBQUFBQnFCdlFmMGhla2xoZWowNjJzc1EzMWJYRXRTcGdWWWctU3hhcXNUbVVaOTJiRFJuSGM5S3ZGZ0M4RFotTGxOQ3loa3l4aVZ2T3FsRVVMck83RTlURFNOdWxHb0JfNVEtRGJ4X193dV9Bd0EtNlVGV0h4SWk2bldfWThxNVVnOGctSkNFR3FXa2pmY2ROcV9EVE1oMndFY1d4MjdLeWtUd0VEeW5CTlFwX2FOcW9DaWVXYWVfMy1ZUnFFUEZnanFOUGZILUpUZU8yUHNSODE3OXBSWVJFNlpBdTJtUT09
|
|
||||||
Connector_AiPerplexity_API_SECRET = PROD_ENC:Z0FBQUFBQnFCdlFmRm9saTZuR1VSZV9pQllKRGFURmN4cDNNanpsVFM3TVItdDNtNWdoWC1zVllrLUVPeGZDRXF1S3Rxd0tVUGV6bl9Ob0JMa3U5ZUNlRjRVQ1dRWXZDTXlsRU13b2o2R1paalU4RXB6SWxYVEJPa2NmaDRFdzExRXU1X2VnNDlhQzQ3cTE1RlJrSlB5elRMZ2w3NmxlV2l3PT0=
|
|
||||||
Connector_AiTavily_API_SECRET = PROD_ENC:Z0FBQUFBQnFCdlFmZGdyWkJibS03akJtSjF0U2doYXZVVDM1em1kY2ZpRGJISmVCUURfVkw3c2Z3OEFQd1h1SzE0cTExSUtVejRPY3VmWF9XT1ZyS3RxRmVRYktJeDR6OWhYaEM0bkNLVEI1cl9VZ1VFOG9IRTFWc2FUemh0UmNHTGprQ0FweThlSGpSSDAyZmw2YmR0OFREQWxpNERHWm1nPT0=
|
|
||||||
Connector_AiPrivateLlm_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGanZ6U3pzZWkwXzVPWGtIQ040XzFrTXc5QWRnazdEeEktaUJ0akJmNnEzbWUzNHczLTJfc2dIdzBDY0FTaXZYcDhxNFdNbTNtbEJTb2VRZ0ZYd05hdlNLR1h6SUFzVml2Z1FLY1BjTl90UWozUGxtak1URnhhZmNDRWFTb0dKVUo=
|
|
||||||
Connector_AiMistral_API_SECRET = PROD_ENC:Z0FBQUFBQnFCdlFmcEVpVmFuWkk4eTJTc3VtRFg4cE9QU3R5NVg0eVFIR29RSVhmXy1rR0pPTm4wbFhIVFFpckx5UmhvSGxqSWV4S0xoTzdESE55R2k5eHowZEprdGhrbEU3eG5JWGpaNWJIdDRqT05zZGNCQVpXd2xTek1teHRBS3NRU2FuUTlSQ2Q=
|
|
||||||
|
|
||||||
Service_MSFT_TENANT_ID = common
|
|
||||||
|
|
||||||
# Google Cloud Speech Services configuration
|
|
||||||
Connector_GoogleSpeech_API_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z4NFQxaF9uN3h1cVB6dnZid1c1R1VfNDlSQ1NHMEVDZWtKanpMQ29CLXc1MXBqRm1hQ0YtWVhaejBMY1ZTOEFEVlpWQ3hrYkFza1E2RDNsYkdMMndNR0VGNTMwVDRGdURJY3hyaVFxVjEtSEYwNHJzeWM3WmlpZW9jU2E3NTgycEV2allqQ3dJRTNyRFAzaDJ6dklKeXpNRkJhYjFzUkptN2dpbkNpMklrcGxuZl9vTkt3T0JvNm1YTXd5UlkwZWptUXdWVFpnV2J4X3J2WUhIUlFkSElFVnlqMnlJRnNHTnlpMWs2R1dZc2ROWjNYZG85cndmd1E5cUZnVmZRYnVjTG43dXFmSWd2bGFfVWFWSmtpWkpndWNlSUNwcnFNU2NqZXFaV0xsY3l3SElLRkVHcHZGZERKV1ltcGhTS0dhTko1VTJLYzNoZjRkSGVEX3dTMWVVTmdDczV5cE1JQUdSbUJGUm11eFhTVjJHbkt0SzB4UG1Dc2xmbnp1Y041Y2RTeWRuWGdmQy1sTGx0MGtnM2VJQ3EyLXViRlNhTU9ybzZkR1N1bXE5SXhlZENWRFpWSGlYOWx4SUQ3UlR0ZEVxQkxNakRUVFRiUmFnbklOalphLUZkRFVVaXBRUk5NZW5PaUZydTFmQkNPSTdTVTNZd0plWXllNVFJdmN4MVcyTGlwMGFtVjBzOGRxR1FjbzhfYW5zdTB0ZEZBTTJhakltazh1dktNMUZsOUItdFdTb1pIaUxySllXNkdlY20zUS0wTnpFNTB2SU5acG1VcXhyaHBmME8takw3RDh5T043T2VGOV92TzNya2pWSlpYVjZDdXlZcjM3a0hPTlhkaW9oQmxqQlpGRFYyTTY4WmZmT3k4Tk1tdXRuSGdTUVpNT2NKenhXb05PdXBfSEdhMTNxNjdpNXlKUUI2YUgydFFPX1VvXzVJb0UxWTU2YVNiNDQ0QndZanhMMHR1cGdHWGhvcEg1QXEtSXZJdTdZUE12ZEVVWkF4QmtsQS1GYnY3SFIxSHlsOGVfcEpGS1A4QUVEQWNEOFZYYlljQ3ByTU03YU16Y0UzUnJQZEprSWNjT1ZXVEtDWi03Y3ZzRVdYUTlabXJISEo5THRHVXVuM0xqbzA4bGVlZVpOMk1QMmptb21tV0pTMlVoOXdWVU95UW1iQmttc2w1RG9mMWwxXzg1T2IxYUVmTUJEZkpUdTFDTzZ3RlBFeUFiX01iRTZNWkNaSG45TkFOM2pzbUJRZ2N0VFpoejJUTG1RODY3TzZpSzVkYUQzaEpfY2pSTkRzU0VpanlkdXVQQmJ2WU5peno4QWNLTDVxZTlhSHI3NnNiM0k0Y3JkQ0xaOU05bGtsQl8zQklvaktWSDZ4aVp2MHlYelJuUDJyTU9CZC1OZjJxNFc1dDcwSUlxaVh1LTMyWWFwU0IwUU9kOUFpMWpnOERtLTh1VmJiNGVwcXBMbU5fMjVZc0hFbmxQT2puSFd1ZGpyTkphLU5sVlBZWWxrWEZrWGJQWmVkN19tZFZfZ1l1V3pSWlA0V0ZxM2lrWnl2NU9WeTdCbDROSmhfeENKTFhMVXk1d195S2JMUFJoRXZjcVo4V2g0MTNKRnZhUE1wRkNPM3FZOGdVazJPeW5PSGpuZnFGTTdJMkRnam5rUlV6NFlqODlIelRYaEN5VjdJNnVwbllNODNCTFRHMWlXbmM1VlRxbXB3Wm9LRjVrQUpjYzRNMThUMWwwSVhBMUlyamtPZnE4R0o4bEdHay1zMjR5RDJkZ1lYRHZaNHVHU2otR3ZpN25LZlEySEU0UmdTNzJGVHNWQXMyb0dVMV9WUE13ODhZWUFaakxGOWZieGNXZkNYRnV5djEyWTZLcmdrajRBLU1rS1Z0VVRkOWlDMU9fMGVmYXFhZXJGMUhpNkdmb2hkbzZ1OWV6VlNmVzNISjVYTFh6SjJNdWR5MWZidE8yVEo2dnRrZXhMRXBPczUwTG13OGhNUVpIQm0zQmRKRnJ0Nl8wNW1Ob0dHRDVpU0NWREV3TkY2SjktdVBkMFU1ZXBmSFpHQ3FHNTRZdTJvaExpZVEtLTU4YTVyeFBpNDdEajZtWUc4c1dBeUJqQ3NIY1NLS0FIMUxGZzZxNFNkOG9ORGNHWWJCVnZuNnJVTEtoQi1mRTZyUl81ZWJJMi1KOGdERzBhNVRZeHRYUUlqY2JvMFlaNHhWMU9pWFFiZjdaLUhkaG15TTBPZVlkS2R5UVdENTI4QVFiY1RJV0ZNZnlpVWxfZmlnN1BXbGdrbjFGUkhzYl9qeHBxVVJacUE4bjZETENHVFpSamh0NVpOM2hMYTZjYzBuS3J0a3hhZGxSM1V5UHd2OTU3ZHY0Yy1xWDBkWUk0Ymp0MWVrS3YzSktKODhQZnY3QTZ1Wm1VZkZJbS1jamdreks1ZlhpQjFOUDFiOHJ2Nm9NcmdTdU5LQXV2RkZWZEFNZnVKUjVwcVY3dDdhQnpmRVJ6SmlvVXpDM0ZiYXh5bGE2X04tTE9qZ3BiTnN3TF9ZaFRxSUpjNjB1dXZBcy1TZHRHTjFjSUR3WUl4cE9VNzB5Rkk4U3Z1SVZYTl9sYXlZVk83UnFrMlVmcnBpam9lRUlCY19DdVJwOXl2TVVDV1pMRFZTZk9MY3Z1eXA0MnhGazc5YllQaWtOeTc4NjlOa2lGY05RRzY1cG9nbGpYelc4c3FicWxWRkg0YzRSamFlQ19zOU14YWJreU9pNDREZVJ3a0REMUxGTzF1XzI1bEF3VXVZRjlBeWFiLXJsOXgza3VZem1WckhWSnVNbDBNcldadU8xQ3RwOTl5NGgtVlR0QklCLWl5WkE4V1FlQTBCOVU1RE9sQlRrYUNZOGdfUmEwbEZvUTFGUEFWVmQ4V1FhOU9VNjZqemRpZm1sUDhZQTJ0YVBRbWZldkF5THV4QXpfdUtNZ0tlcGdSRFM3c0lDOTNQbnBxdmxYYWNpTmI3MW9BMlZIdTQ5RldudHpNQWQ5NDNPLVVTLXVVNzdHZXh4UXpZa3dVa2J4dTFDV1RkYjRnWXU2M3lJekRYWGNMcWU5OVh6U2xZWDh6MmpqcnpiOHlnMjA5S3RFQm1NZjNSM21adkVnTUpSYVhkTzNkNnJCTmljY0x1cl9kMkx3UHhySjZEdHREanZERzNEUTFlTkR0NWlBczAtdmFGTjdZNVpTMlkxV2czYW5RN2lqemg4eUViZDV6RjdKNXdFcUlvcVhoNkJ6eVJkR1pua1hnNzQwOEs2TXJYSlpGcW9qRDU2QjBOWFFtdXBJRkRKbmdZUF9ZSmRPVEtvUjVhLTV1NjdXQjRhS0duaEtJb2FrQnNjUTRvdFMxdkdTNk1NYlFHUFhhYTJ1eUN3WHN4UlJ4UjdrZjY0SzFGYWVFN1k0cGJnc1RjNmFUenR4NHljbVhablZSWHZmUVN3cXRHNjhsX1BSZWEzdTJUZFA0S2pTaU9YMnZIQ1ZPcGhWMFJqZkVEMWRMR1h3SnU0Z2FzZ3VGM3puNzdhVjhaQXNIWHFsbjB0TDVYSFdSNV9rdWhUUUhSZHBGYkJIVDB5SDdlMC13QTVnS0g5Qkg5RGNxSGJlelVndUhPcEQ0QkRKMTJTZUM1OXJhVm0zYjU0OVY2dk9MQVBheklIQXpVNW9Yc0ROVjEzaFZTWmVxYlBWMlNlSzladzJ6TmNuMG5FVVZkN1VZN1pfS2ZHa0lQcE80S24wSnQtVlJVV09OVWJ3M09YMkZpV2ktVF9ENHhKU2dfYUQ2aUVyamk0VHJHQmVfVHU4clpUTFoteW5aSWRPV1M0RDRMTms4NGRoYmJfVE82aUl2X3VieVJOdDhBQmRwdzdnRTVBNzZwaW93dUlZb3ZRYUtOeG9ULWxvNVp5a0haSjdkcUhRb3d6UGIxRUpCVkVYX2d6TkRqQVozUWxkNGFoc1FXYVd2YWNkME9Qclo0bjYxMFRWTy1nbnI5NTBJNzRMMDluUXRKYTFqQUN4d0d5aHVlamN3Tkk3NWJXeXR0TW9BeUg5Vnp4Q2RnZUY3b3AtMDlrNmlrSGR0eGRtbUdUd2lFRWg4MklEeWJHN2wwZEpVSXMxNDNOWjRFS0tPdWxhMmFCckhfRENIY184aEFDZXNrRDl2dHQtQW12UnRuQXJjaDJoTUpiYkNWQUtfRG9GMUZoNWM4UnBYZ29RWWs2NHcyUm5kdTF3Vk1GeFpiRUJLaVZ2UGFjbi1jV3lMV0N2ZDl4VERPN295X01NNG56ZjZkRzZoYUtmY1E5NlVXemx2SnVfb19iSXg0R2M3Mjd1a2JRPT0=
|
|
||||||
|
|
||||||
# Feature SyncDelta JIRA configuration
|
|
||||||
Feature_SyncDelta_JIRA_DELTA_TOKEN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z4d3Z4d2x6N1FhUktMU0RKbkxfY2pTQkRzXzJ6UXVEbDNCaFM3UHMtQVFGYzNmYWs4N0lMM1R2SFJuZTVFVmx6MGVEbXc5U3NOTnY1TWN0ZDNaamlHQWloalM3VldmREJNSHQ1TlVkSVFJMTVhQWVGSVRMTGw4UTBqNGlQZFVuaHp4WUlKemR5UnBXZlh0REJFLXJ4ejR3PT0=
|
|
||||||
|
|
||||||
# Teamsbot Browser Bot Service
|
|
||||||
TEAMSBOT_BROWSER_BOT_URL = https://cae-poweron-shared.redwater-53d21339.switzerlandnorth.azurecontainerapps.io
|
|
||||||
|
|
||||||
# Debug Configuration
|
|
||||||
APP_DEBUG_CHAT_WORKFLOW_ENABLED = FALSE
|
|
||||||
APP_DEBUG_CHAT_WORKFLOW_DIR = ./test-chat
|
|
||||||
APP_DEBUG_ACCOUNTING_SYNC_ENABLED = FALSE
|
|
||||||
APP_DEBUG_ACCOUNTING_SYNC_DIR = ./debug/sync
|
|
||||||
|
|
||||||
# Azure Communication Services Email Configuration
|
|
||||||
MESSAGING_ACS_CONNECTION_STRING = endpoint=https://mailing-poweron-prod.switzerland.communication.azure.com/;accesskey=4UizRfBKBgMhDgQ92IYINM6dJsO1HIeL6W1DvIX9S0GtaS1PjIXqJQQJ99CAACULyCpHwxUcAAAAAZCSuSCt
|
|
||||||
MESSAGING_ACS_SENDER_EMAIL = DoNotReply@poweron.swiss
|
|
||||||
|
|
@ -3,17 +3,17 @@
|
||||||
# System Configuration
|
# System Configuration
|
||||||
APP_ENV_TYPE = int
|
APP_ENV_TYPE = int
|
||||||
APP_ENV_LABEL = Integration Instance
|
APP_ENV_LABEL = Integration Instance
|
||||||
APP_API_URL = https://gateway-int.poweron.swiss
|
APP_API_URL = https://api-int.poweron.swiss
|
||||||
# Force SameSite=None+Secure for auth cookies (cross-site UI on poweron-center.net). Optional if APP_API_URL is https://
|
# Force SameSite=None+Secure for auth cookies (cross-site UI on poweron-center.net). Optional if APP_API_URL is https://
|
||||||
APP_COOKIE_SECURE = true
|
APP_COOKIE_SECURE = true
|
||||||
APP_KEY_SYSVAR = CONFIG_KEY
|
APP_KEY_SYSVAR = /srv/gateway/shared/secrets/master_key.txt
|
||||||
APP_INIT_PASS_ADMIN_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjWm41MWZ4TUZGaVlrX3pWZWNwakJsY3Facm0wLVZDd1VKeTFoZEVZQnItcEdUUnVJS1NXeDBpM2xKbGRsYmxOSmRhc29PZjJSU2txQjdLbUVrTTE1NEJjUXBHbV9NOVJWZUR3QlJkQnJvTEU9
|
APP_INIT_PASS_ADMIN_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjWm41MWZ4TUZGaVlrX3pWZWNwakJsY3Facm0wLVZDd1VKeTFoZEVZQnItcEdUUnVJS1NXeDBpM2xKbGRsYmxOSmRhc29PZjJSU2txQjdLbUVrTTE1NEJjUXBHbV9NOVJWZUR3QlJkQnJvTEU9
|
||||||
APP_INIT_PASS_EVENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjdmtrakgxa0djekZVNGtTZV8wM2I5UUpCZllveVBMWXROYk5yS3BiV3JEelJSM09VYTRONHpnY3VtMGxDRk5JTEZSRFhtcDZ0RVRmZ1RicTFhb3c5dVZRQ1o4SmlkLVpPTW5MMTU2eTQ0Vkk9
|
APP_INIT_PASS_EVENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjdmtrakgxa0djekZVNGtTZV8wM2I5UUpCZllveVBMWXROYk5yS3BiV3JEelJSM09VYTRONHpnY3VtMGxDRk5JTEZSRFhtcDZ0RVRmZ1RicTFhb3c5dVZRQ1o4SmlkLVpPTW5MMTU2eTQ0Vkk9
|
||||||
|
|
||||||
# PostgreSQL DB Host
|
# PostgreSQL DB Host (porta-int-db on Infomaniak Public Cloud)
|
||||||
DB_HOST=gateway-int-server.postgres.database.azure.com
|
DB_HOST=db-int.poweron.swiss
|
||||||
DB_USER=heeshkdlby
|
DB_USER=poweron_dev
|
||||||
DB_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjczYzOUtTa21MMGJVTUQ5UmFfdWc3YlhCbWZOeXFaNEE1QzdJV3BLVjhnalBkLVVCMm5BZzdxdlFXQXc2RHYzLWtPSFZkZE1iWG9rQ1NkVWlpRnF5TURVbnl1cm9iYXlSMGYxd1BGYVc0VDA9
|
DB_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQnFGTFJneVFGQ09JYVgwVWRGXzRSQjJ2RnlGYS05WllIMURpUUNBS0poQS1yLUJDaFFQS2IyLTNTSTUtRTBfekF1R1U5dUhiOXdYdi1WSVF4bUltczVQUVJQN2Q0Mng3cHFWVndZVDJxc2ZicXRXVnc9
|
||||||
DB_PORT=5432
|
DB_PORT=5432
|
||||||
|
|
||||||
# Security Configuration
|
# Security Configuration
|
||||||
|
|
@ -21,11 +21,11 @@ APP_JWT_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNUctb2RwU25iR3ZnanBOdHZhWUtIajZ1RnZ
|
||||||
APP_TOKEN_EXPIRY=300
|
APP_TOKEN_EXPIRY=300
|
||||||
|
|
||||||
# CORS Configuration
|
# CORS Configuration
|
||||||
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss,https://nyla.poweron-center.net,https://nyla-int.poweron-center.net
|
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://porta.poweron.swiss,https://porta-int.poweron.swiss,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss,https://nyla.poweron-center.net,https://nyla-int.poweron-center.net
|
||||||
|
|
||||||
# Logging configuration
|
# Logging configuration
|
||||||
APP_LOGGING_LOG_LEVEL = DEBUG
|
APP_LOGGING_LOG_LEVEL = DEBUG
|
||||||
APP_LOGGING_LOG_DIR = /home/site/wwwroot/
|
APP_LOGGING_LOG_DIR = srv/gateway/shared/logs
|
||||||
APP_LOGGING_FORMAT = %(asctime)s - %(levelname)s - %(name)s - %(message)s
|
APP_LOGGING_FORMAT = %(asctime)s - %(levelname)s - %(name)s - %(message)s
|
||||||
APP_LOGGING_DATE_FORMAT = %Y-%m-%d %H:%M:%S
|
APP_LOGGING_DATE_FORMAT = %Y-%m-%d %H:%M:%S
|
||||||
APP_LOGGING_CONSOLE_ENABLED = True
|
APP_LOGGING_CONSOLE_ENABLED = True
|
||||||
|
|
@ -36,22 +36,22 @@ APP_LOGGING_BACKUP_COUNT = 5
|
||||||
# OAuth: Auth app (login/JWT) vs Data app (Graph / Google APIs)
|
# OAuth: Auth app (login/JWT) vs Data app (Graph / Google APIs)
|
||||||
Service_MSFT_AUTH_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
|
Service_MSFT_AUTH_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
|
||||||
Service_MSFT_AUTH_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnFBa1kydlVubld1d1h6SUNSWW1aZ3p4X3Zod1NDTjhZVnVYS2lqOERGTFp2OXJ4TGRiNlRLVFpzLUVDTUhkZGhGUWdxa1djdEV5UWkyblN1UHZoaFBjaExNTEpGMG1PRGJEbDdHVll0Ungwcl9JemZ4ZXFzZUNFQmFlZi1DZFlCekU1S3E=
|
Service_MSFT_AUTH_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnFBa1kydlVubld1d1h6SUNSWW1aZ3p4X3Zod1NDTjhZVnVYS2lqOERGTFp2OXJ4TGRiNlRLVFpzLUVDTUhkZGhGUWdxa1djdEV5UWkyblN1UHZoaFBjaExNTEpGMG1PRGJEbDdHVll0Ungwcl9JemZ4ZXFzZUNFQmFlZi1DZFlCekU1S3E=
|
||||||
Service_MSFT_AUTH_REDIRECT_URI = https://gateway-int.poweron.swiss/api/msft/auth/login/callback
|
Service_MSFT_AUTH_REDIRECT_URI = https://api-int.poweron.swiss/api/msft/auth/login/callback
|
||||||
Service_MSFT_DATA_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
|
Service_MSFT_DATA_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
|
||||||
Service_MSFT_DATA_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnFBa1kyS1hWZXEzUzZTTE5MUlJncVowMU95Y0hmV1hveDBZOWdLU1RIUWt3SGlXNGxVTXVKc2QyQmtmWTlJRU43ZnRDdnlDTGxQY0hTU25CWWFFdDhUem9HU0VYcTFJTVFEbVk0dUhmVzJNVlEzNTNWdjdmaW9WeUVDVW5PRmNFZEQzNTY=
|
Service_MSFT_DATA_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnFBa1kyS1hWZXEzUzZTTE5MUlJncVowMU95Y0hmV1hveDBZOWdLU1RIUWt3SGlXNGxVTXVKc2QyQmtmWTlJRU43ZnRDdnlDTGxQY0hTU25CWWFFdDhUem9HU0VYcTFJTVFEbVk0dUhmVzJNVlEzNTNWdjdmaW9WeUVDVW5PRmNFZEQzNTY=
|
||||||
Service_MSFT_DATA_REDIRECT_URI = https://gateway-int.poweron.swiss/api/msft/auth/connect/callback
|
Service_MSFT_DATA_REDIRECT_URI = https://api-int.poweron.swiss/api/msft/auth/connect/callback
|
||||||
|
|
||||||
Service_GOOGLE_AUTH_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
|
Service_GOOGLE_AUTH_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
|
||||||
Service_GOOGLE_AUTH_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnFBa1kyUTUwNXNGaHRNaGxxbF9sdWJ3Q0xLYU5yOHB4Yk8zMDZvQ29yaEhWOE5JMENXRk5jb2ZBdzRKQ2ZTTld6ZlIxemhOYzN1VE10TjBDRWZEMXlLVWRNYjZ0VG5RZ3I3NWt0SEJzMzdsUmRzcVNmbktRNHZqTUF6a2EyUkVUSFJnZFE=
|
Service_GOOGLE_AUTH_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnFBa1kyUTUwNXNGaHRNaGxxbF9sdWJ3Q0xLYU5yOHB4Yk8zMDZvQ29yaEhWOE5JMENXRk5jb2ZBdzRKQ2ZTTld6ZlIxemhOYzN1VE10TjBDRWZEMXlLVWRNYjZ0VG5RZ3I3NWt0SEJzMzdsUmRzcVNmbktRNHZqTUF6a2EyUkVUSFJnZFE=
|
||||||
Service_GOOGLE_AUTH_REDIRECT_URI = https://gateway-int.poweron.swiss/api/google/auth/login/callback
|
Service_GOOGLE_AUTH_REDIRECT_URI = https://api-int.poweron.swiss/api/google/auth/login/callback
|
||||||
Service_GOOGLE_DATA_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
|
Service_GOOGLE_DATA_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
|
||||||
Service_GOOGLE_DATA_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnFBa1kyV1FRVjF0c0d3d0dyWU1TdW9HdXVkdHdsVWZKYTJjbGZPRDhMRjA2M0FkaUZIVmhIUmFKNjg2ekFodHd6NG80VTI3TC1icW1LZ01jWVZuQ1pKRm5nMW5UREJEaGp2Wl9oRDRCSmZVT0JpTnkwXzgwY0pkV29yczQ5akF2d1ZGcVY=
|
Service_GOOGLE_DATA_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnFBa1kyV1FRVjF0c0d3d0dyWU1TdW9HdXVkdHdsVWZKYTJjbGZPRDhMRjA2M0FkaUZIVmhIUmFKNjg2ekFodHd6NG80VTI3TC1icW1LZ01jWVZuQ1pKRm5nMW5UREJEaGp2Wl9oRDRCSmZVT0JpTnkwXzgwY0pkV29yczQ5akF2d1ZGcVY=
|
||||||
Service_GOOGLE_DATA_REDIRECT_URI = https://gateway-int.poweron.swiss/api/google/auth/connect/callback
|
Service_GOOGLE_DATA_REDIRECT_URI = https://api-int.poweron.swiss/api/google/auth/connect/callback
|
||||||
|
|
||||||
# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly.
|
# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly.
|
||||||
Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
|
Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
|
||||||
Service_CLICKUP_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnB5dkd5SE1uVURMNVE3NkM4cHBKa2R2TjBnLWdpSXI5dHpKWGExZVFiUF95TFNnZ1NwLWFLdmh6eWFZTHVHYTBzU2FGRUpLYkVyM1NvZjZkWDZHN21qUER5ZVNOaGpCc3NrUGd3VnFTclF3OW1nUlVuWXQ1UVhDLVpyb1BwRExOeFpDeVhtbEhDVnd4TVdpbzNBNk5QQWFPdjdza0xBWGxFY1E3WFpCSUlNa1l4RDlBPQ==
|
Service_CLICKUP_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnB5dkd5SE1uVURMNVE3NkM4cHBKa2R2TjBnLWdpSXI5dHpKWGExZVFiUF95TFNnZ1NwLWFLdmh6eWFZTHVHYTBzU2FGRUpLYkVyM1NvZjZkWDZHN21qUER5ZVNOaGpCc3NrUGd3VnFTclF3OW1nUlVuWXQ1UVhDLVpyb1BwRExOeFpDeVhtbEhDVnd4TVdpbzNBNk5QQWFPdjdza0xBWGxFY1E3WFpCSUlNa1l4RDlBPQ==
|
||||||
Service_CLICKUP_OAUTH_REDIRECT_URI = https://gateway-int.poweron.swiss/api/clickup/auth/connect/callback
|
Service_CLICKUP_OAUTH_REDIRECT_URI = https://api-int.poweron.swiss/api/clickup/auth/connect/callback
|
||||||
|
|
||||||
# Infomaniak: no OAuth client. Users paste a Personal Access Token (kdrive + mail) per UI.
|
# Infomaniak: no OAuth client. Users paste a Personal Access Token (kdrive + mail) per UI.
|
||||||
|
|
||||||
|
|
@ -75,11 +75,8 @@ Service_MSFT_TENANT_ID = common
|
||||||
# Google Cloud Speech Services configuration
|
# Google Cloud Speech Services configuration
|
||||||
Connector_GoogleSpeech_API_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRkNmVXZ1pWcHcydTF2MXF0ZGJoWHBydF85bTczTktiaEJ3Wk1vMW1mZVhDSG1yd0ZxR2ZuSGJTX0N3MWptWXFJTkNTWjh1SUVVTXI4UDVzcGdLMkU5SHJ2TUpkRlRoRWdnSldtYjNTQkh4UDJHY2xmdTdZQ1ZiMTZZcGZxS3RzaHdjV3dtVkZUcEpJcWx0b2xuQVR6ZmpoVFZPY1hNMTV2SnhDaC1IZEh4UUpLTy1ILXA4RG1zamJTbUJ4X0t2M2NkdzJPbEJxSmFpRzV3WC0wZThoVzlxcmpHZ3ZkLVlVY3REZk1vV19WQ05BOWN6cnJ4MWNYYnNiQ0FQSUVnUlpfM3BhMnlsVlZUOG5wM3pzM1lSN1UzWlZKUXRLczlHbjI1LTFvSUJ4SlVXMy1BNk43bE5Hb0RfTTVlWk9oZnFIaVg0SW5pbm9EcXRTTzU1RFlYY3dTcnpKWWNyNjN5T1BGZ0FmX253cEFncmhvZVRuM05KYzhkOEhFMFJsc2NBSEwzZVZ1R0JMOGxsekVwUE55alZaRXFrdzNWWVNGWXNmbnhKeWhQSFo2VXBTUlRPeHdvdVdncEFuOWgydEtsSUFneUN6cGVaTnBSdjNCdVJseGJFdmlMc203UFhLVlYyTENkaGg2dVN6Z2xwT1ZmTmN5bVZGUkM3ZWcyVkt2ckFUVVd3WFFwYnJjNVRobEh2SkVJbXRwUUpEOFJKQ1NUc0Q4NHNqUFhPSDh5cTV6MEcwSDEwRUJCQ2JiTTJlOE5nd3pMMkJaQ1dVYjMwZVVWWnlETmp2dkZ3aXEtQ29WNkxZTFkzYUkxdTlQUU1OTnhWWU12YU9MVnJQa1d2ZjRtUlhneTNubEMxTmp1eUNPOThSMlB3Y1F0T2tCdFNsNFlKalZPV25yR2QycVBUb096RmZ1V0FTaGsxLV9FWDBmenBIOXpMdGpLcUc0TWRoY2hlMFhYTzlET1ZRekw0ZHNwUVBQdVJBX2h6Q2ZzWVZJWTNybTJiekp3WmhmWF9SUFBXQzlqUjctcVlHWWVMZWVQallzR0JGTVF0WmtnWlg1aTM1bFprNVExZXY5dnNvWF93UjhwbkJ3RzNXaVJ2d2RRU3JJVlBvaVh4eTlBRUtqWkJia3dJQVVBV2Nqdm9FUTRUVW1TaHp2ZUwxT0N2ZndxQ2Nka1RYWXF0LWxIWFE0dTFQcVhncFFPM0hFdUUtYlFnemx3WkF4bjA1aDFULUdrZlVZbEJtRGRCdjJyVkdJSXozd0I0dF9zbWhOeHFqRDA4T1NVaWR5cjBwSVgwbllPU294NjZGTnM1bFhIdGpNQUxFOENWd3FCbGpSRFRmRXotQnU0N2lCVEU5RGF6Qi10S2U2NGdadDlrRjZtVE5oZkw5ZWFjXzhCTmxXQzNFTFgxRXVYY3J3YkxnbnlBSm9PY3h4MlM1NVFQbVNDRW5Ld1dvNWMxSmdoTXJuaE1pT2VFeXYwWXBHZ29MZDVlN2lwUUNIeGNCVVdQVi1rRXdJMWFncUlPTXR0MmZVQ1l0d09mZTdzWGFBWUJMUFd3b0RSOU8zeER2UWpNdzAxS0ZJWnB5S3FJdU9wUDJnTTNwMWw3VFVqVXQ3ZGZnU1RkUktkc0NhUHJ0SGFxZ0lVWDEzYjNtU2JfMGNWM1Y0dHlCTzNESEdENC1jUWF5MVppRzR1QlBNSUJySjFfRi1ENHEwcmJ4S3hQUFpXVHA0TG9DZWdoUlo5WnNSM1lCZm1KbEs2ak1yUUU4Wk9JcVJGUkJwc0NvUkMyTjhoTWxtZmVQeDREZVRKZkhYN2duLVNTeGZzdFdBVnhEandJSXB5QjM0azF0ckI3Tk1wSzFhNGVOUVRrNjU0cG9JQ29pN09xOFkwR1lMTlktaGp4TktxdTVtTnNEcldsV2pEZm5nQWpJc2hxY0hjQnVSWUR5VVdaUXBHWUloTzFZUC1oNzJ4UjZ1dnpLcDJxWEZtQlNIMWkzZ0hXWXdKeC1iLXdZWVJhcU04VFlpMU5pd2ZIdTdCdkVWVFVBdmJuRk16bEFFQTh4alBrcTV2RzliT2hGdTVPOXlRMjFuZktiRTZIamQ1VFVqS0hRTXhxcU1mdkgyQ1NjQmZfcjl4c3NJd0RIeDVMZUFBbHJqdEJxWWl3aWdGUEQxR3ZnMkNGdVB4RUxkZi1xOVlFQXh1NjRfbkFEaEJ5TVZlUGFrWVhSTVRPeGxqNlJDTHNsRWRrei1pYjhnUmZrb3BvWkQ2QXBzYjFHNXZoWU1LSExhLWtlYlJTZlJmYUM5Y1Rhb1pkMVYyWTByM3NTS0VXMG1ybm1BTVN2QXRYaXZqX2dKSkZrajZSS2cyVlNOQnd5Y29zMlVyaWlNbTJEb3FuUFFtbWNTNVpZTktUenFZSl91cVFXZjRkQUZyYmtPczU2S1RKQ19ONGFOTHlwX2hOOEE1UHZEVjhnT0xxRjMxTEE4SHhRbmlmTkZwVXJBdlJDbU5oZS05SzI4QVhEWDZaN2ZiSlFwUGRXSnB5TE9MZV9ia3pYcmZVa1dicG5FMHRXUFZXMWJQVDAwOEdDQzJmZEl0ZDhUOEFpZXZWWXl5Q2xwSmFienNCMldlb2NKb2ZRYV9KbUdHRzNUcjU1VUFhMzk1a2J6dDVuNTl6NTdpM0hGa3k0UWVtbF9pdDVsQVp2cndDLUU5dnNYOF9CLS0ySXhBSFdCSnpqV010bllBb3U0cEZZYVF5R2tSNFM5NlRhdS1fb1NqbDBKMkw0V2N0VEZhNExtQlR3ckZ3cVlCeHVXdXJ6X0s4cEtsaG5rVUxCN2RRbHQxTmcyVFBqYUxyOHJzeFBXVUJaRHpXbUoxdHZzMFBzQk1UTUFvX1pGNFNMNDFvZWdTdEUtMUNKMXNIeVlvQk1CeEdpZVdmN0tsSDVZZHJXSGt5c2o2MHdwSTZIMVBhRzM1eU43Q2FtcVNidExxczNJeUx5U2RuUG5EeHpCTlg2SV9WNk1ET3BRNXFuc0pNWlVvZUYtY21oRGtJSmwxQ09QbHBUV3BuS3B5NE9RVkhfellqZjJUQ0diSV94QlhQWmdaaC1TRWxsMUVWSXB0aE1McFZDZDNwQUVKZ2t5cXRTXzlRZVJwN0pZSnJSV21XMlh0TzFRVEl0c2I4QjBxOGRCYkNxek04a011X1lrb2poQ3h2LUhKTGJiUlhneHp5QWFBcE5nMElkNTVzM3JGOWtUQ19wNVBTaVVHUHFDNFJnNXJaWDNBSkMwbi1WbTdtSnFySkhNQl9ZQjZrR2xDcXhTRExhMmNHcGlyWjR3ZU9SSjRZd1l4ZjVPeHNiYk53SW5SYnZPTzNkd1lnZmFseV9tQ3BxM3lNYVBHT0J0elJnMTByZ3VHemxta0tVQzZZRllmQ2VLZ1ZCNDhUUTc3LWNCZXBMekFwWW1fQkQ1NktzNGFMYUdYTU0xbXprY1FONUNlUHNMY3h2NFJMMmhNa3VNdzF4TVFWQk9odnJUMjFJMVd3Z2N6Sms5aEM2SWlWZFViZ0JWTEpUWWM5NmIzOS1oQmRqdkt1NUUycFlVcUxERUZGbnZqTUxIYnJmMDBHZDEzbnJsWEEzSUo3UmNPUDg1dnRUU1FzcWtjTWZwUG9zM0JTY3RqMDdST2UxcXFTM0d0bGkwdFhnMk5LaUlxNWx3V1pLaVlLUFJXZzBzVl9Ia1V1OHdYUEFWOU50UndycGtCdzM0Q0NQamp2VTNqbFBLaGhsbUk5dUI5MjU5OHVySk1oY0drUWtXUloyVVRvOWJmbUVYRzFVeWNQczh2NXJCeVppRlZiWDNJaDhOSmRmX2lURTNVS3NXQXFZT1QtUmdvMWJoVWYxU3lqUUJhbzEyX3I3TXhwbm9wc1FoQ1ZUTlNBRjMyQTBTY2tzbHZ3RFUtTjVxQ0o1QXRTVks2WENwMGZCRGstNU1jN3FhUFJCQThyaFhhMVRsbnlSRXNGRmt3Yk01X21ldmV3bTItWm1JaGpZQWZROEFtT1d1UUtPQlhYVVFqT2NxLUxQenJHX3JfMEdscDRiMXcyZ1ZmU3NFMzVoelZJaDlvT0ZoRGQ2bmtlM0M5ZHlCd2ZMbnRZRkZUWHVBUEx4czNfTmtMckh5eXZrZFBzOEItOGRYOEhsMzBhZ0xlOWFjZzgteVBsdnpPT1pYdUxnbFNXYnhKaVB6QUxVdUJCOFpvU2x2c1FHZV94MDBOVWJhYkxISkswc0U5UmdPWFJLXzZNYklHTjN1QzRKaldKdEVHb0pOU284N3c2LXZGMGVleEZ5NGZ6OGV1dm1tM0J0aTQ3VFlNOEJrdEh3PT0=
|
Connector_GoogleSpeech_API_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRkNmVXZ1pWcHcydTF2MXF0ZGJoWHBydF85bTczTktiaEJ3Wk1vMW1mZVhDSG1yd0ZxR2ZuSGJTX0N3MWptWXFJTkNTWjh1SUVVTXI4UDVzcGdLMkU5SHJ2TUpkRlRoRWdnSldtYjNTQkh4UDJHY2xmdTdZQ1ZiMTZZcGZxS3RzaHdjV3dtVkZUcEpJcWx0b2xuQVR6ZmpoVFZPY1hNMTV2SnhDaC1IZEh4UUpLTy1ILXA4RG1zamJTbUJ4X0t2M2NkdzJPbEJxSmFpRzV3WC0wZThoVzlxcmpHZ3ZkLVlVY3REZk1vV19WQ05BOWN6cnJ4MWNYYnNiQ0FQSUVnUlpfM3BhMnlsVlZUOG5wM3pzM1lSN1UzWlZKUXRLczlHbjI1LTFvSUJ4SlVXMy1BNk43bE5Hb0RfTTVlWk9oZnFIaVg0SW5pbm9EcXRTTzU1RFlYY3dTcnpKWWNyNjN5T1BGZ0FmX253cEFncmhvZVRuM05KYzhkOEhFMFJsc2NBSEwzZVZ1R0JMOGxsekVwUE55alZaRXFrdzNWWVNGWXNmbnhKeWhQSFo2VXBTUlRPeHdvdVdncEFuOWgydEtsSUFneUN6cGVaTnBSdjNCdVJseGJFdmlMc203UFhLVlYyTENkaGg2dVN6Z2xwT1ZmTmN5bVZGUkM3ZWcyVkt2ckFUVVd3WFFwYnJjNVRobEh2SkVJbXRwUUpEOFJKQ1NUc0Q4NHNqUFhPSDh5cTV6MEcwSDEwRUJCQ2JiTTJlOE5nd3pMMkJaQ1dVYjMwZVVWWnlETmp2dkZ3aXEtQ29WNkxZTFkzYUkxdTlQUU1OTnhWWU12YU9MVnJQa1d2ZjRtUlhneTNubEMxTmp1eUNPOThSMlB3Y1F0T2tCdFNsNFlKalZPV25yR2QycVBUb096RmZ1V0FTaGsxLV9FWDBmenBIOXpMdGpLcUc0TWRoY2hlMFhYTzlET1ZRekw0ZHNwUVBQdVJBX2h6Q2ZzWVZJWTNybTJiekp3WmhmWF9SUFBXQzlqUjctcVlHWWVMZWVQallzR0JGTVF0WmtnWlg1aTM1bFprNVExZXY5dnNvWF93UjhwbkJ3RzNXaVJ2d2RRU3JJVlBvaVh4eTlBRUtqWkJia3dJQVVBV2Nqdm9FUTRUVW1TaHp2ZUwxT0N2ZndxQ2Nka1RYWXF0LWxIWFE0dTFQcVhncFFPM0hFdUUtYlFnemx3WkF4bjA1aDFULUdrZlVZbEJtRGRCdjJyVkdJSXozd0I0dF9zbWhOeHFqRDA4T1NVaWR5cjBwSVgwbllPU294NjZGTnM1bFhIdGpNQUxFOENWd3FCbGpSRFRmRXotQnU0N2lCVEU5RGF6Qi10S2U2NGdadDlrRjZtVE5oZkw5ZWFjXzhCTmxXQzNFTFgxRXVYY3J3YkxnbnlBSm9PY3h4MlM1NVFQbVNDRW5Ld1dvNWMxSmdoTXJuaE1pT2VFeXYwWXBHZ29MZDVlN2lwUUNIeGNCVVdQVi1rRXdJMWFncUlPTXR0MmZVQ1l0d09mZTdzWGFBWUJMUFd3b0RSOU8zeER2UWpNdzAxS0ZJWnB5S3FJdU9wUDJnTTNwMWw3VFVqVXQ3ZGZnU1RkUktkc0NhUHJ0SGFxZ0lVWDEzYjNtU2JfMGNWM1Y0dHlCTzNESEdENC1jUWF5MVppRzR1QlBNSUJySjFfRi1ENHEwcmJ4S3hQUFpXVHA0TG9DZWdoUlo5WnNSM1lCZm1KbEs2ak1yUUU4Wk9JcVJGUkJwc0NvUkMyTjhoTWxtZmVQeDREZVRKZkhYN2duLVNTeGZzdFdBVnhEandJSXB5QjM0azF0ckI3Tk1wSzFhNGVOUVRrNjU0cG9JQ29pN09xOFkwR1lMTlktaGp4TktxdTVtTnNEcldsV2pEZm5nQWpJc2hxY0hjQnVSWUR5VVdaUXBHWUloTzFZUC1oNzJ4UjZ1dnpLcDJxWEZtQlNIMWkzZ0hXWXdKeC1iLXdZWVJhcU04VFlpMU5pd2ZIdTdCdkVWVFVBdmJuRk16bEFFQTh4alBrcTV2RzliT2hGdTVPOXlRMjFuZktiRTZIamQ1VFVqS0hRTXhxcU1mdkgyQ1NjQmZfcjl4c3NJd0RIeDVMZUFBbHJqdEJxWWl3aWdGUEQxR3ZnMkNGdVB4RUxkZi1xOVlFQXh1NjRfbkFEaEJ5TVZlUGFrWVhSTVRPeGxqNlJDTHNsRWRrei1pYjhnUmZrb3BvWkQ2QXBzYjFHNXZoWU1LSExhLWtlYlJTZlJmYUM5Y1Rhb1pkMVYyWTByM3NTS0VXMG1ybm1BTVN2QXRYaXZqX2dKSkZrajZSS2cyVlNOQnd5Y29zMlVyaWlNbTJEb3FuUFFtbWNTNVpZTktUenFZSl91cVFXZjRkQUZyYmtPczU2S1RKQ19ONGFOTHlwX2hOOEE1UHZEVjhnT0xxRjMxTEE4SHhRbmlmTkZwVXJBdlJDbU5oZS05SzI4QVhEWDZaN2ZiSlFwUGRXSnB5TE9MZV9ia3pYcmZVa1dicG5FMHRXUFZXMWJQVDAwOEdDQzJmZEl0ZDhUOEFpZXZWWXl5Q2xwSmFienNCMldlb2NKb2ZRYV9KbUdHRzNUcjU1VUFhMzk1a2J6dDVuNTl6NTdpM0hGa3k0UWVtbF9pdDVsQVp2cndDLUU5dnNYOF9CLS0ySXhBSFdCSnpqV010bllBb3U0cEZZYVF5R2tSNFM5NlRhdS1fb1NqbDBKMkw0V2N0VEZhNExtQlR3ckZ3cVlCeHVXdXJ6X0s4cEtsaG5rVUxCN2RRbHQxTmcyVFBqYUxyOHJzeFBXVUJaRHpXbUoxdHZzMFBzQk1UTUFvX1pGNFNMNDFvZWdTdEUtMUNKMXNIeVlvQk1CeEdpZVdmN0tsSDVZZHJXSGt5c2o2MHdwSTZIMVBhRzM1eU43Q2FtcVNidExxczNJeUx5U2RuUG5EeHpCTlg2SV9WNk1ET3BRNXFuc0pNWlVvZUYtY21oRGtJSmwxQ09QbHBUV3BuS3B5NE9RVkhfellqZjJUQ0diSV94QlhQWmdaaC1TRWxsMUVWSXB0aE1McFZDZDNwQUVKZ2t5cXRTXzlRZVJwN0pZSnJSV21XMlh0TzFRVEl0c2I4QjBxOGRCYkNxek04a011X1lrb2poQ3h2LUhKTGJiUlhneHp5QWFBcE5nMElkNTVzM3JGOWtUQ19wNVBTaVVHUHFDNFJnNXJaWDNBSkMwbi1WbTdtSnFySkhNQl9ZQjZrR2xDcXhTRExhMmNHcGlyWjR3ZU9SSjRZd1l4ZjVPeHNiYk53SW5SYnZPTzNkd1lnZmFseV9tQ3BxM3lNYVBHT0J0elJnMTByZ3VHemxta0tVQzZZRllmQ2VLZ1ZCNDhUUTc3LWNCZXBMekFwWW1fQkQ1NktzNGFMYUdYTU0xbXprY1FONUNlUHNMY3h2NFJMMmhNa3VNdzF4TVFWQk9odnJUMjFJMVd3Z2N6Sms5aEM2SWlWZFViZ0JWTEpUWWM5NmIzOS1oQmRqdkt1NUUycFlVcUxERUZGbnZqTUxIYnJmMDBHZDEzbnJsWEEzSUo3UmNPUDg1dnRUU1FzcWtjTWZwUG9zM0JTY3RqMDdST2UxcXFTM0d0bGkwdFhnMk5LaUlxNWx3V1pLaVlLUFJXZzBzVl9Ia1V1OHdYUEFWOU50UndycGtCdzM0Q0NQamp2VTNqbFBLaGhsbUk5dUI5MjU5OHVySk1oY0drUWtXUloyVVRvOWJmbUVYRzFVeWNQczh2NXJCeVppRlZiWDNJaDhOSmRmX2lURTNVS3NXQXFZT1QtUmdvMWJoVWYxU3lqUUJhbzEyX3I3TXhwbm9wc1FoQ1ZUTlNBRjMyQTBTY2tzbHZ3RFUtTjVxQ0o1QXRTVks2WENwMGZCRGstNU1jN3FhUFJCQThyaFhhMVRsbnlSRXNGRmt3Yk01X21ldmV3bTItWm1JaGpZQWZROEFtT1d1UUtPQlhYVVFqT2NxLUxQenJHX3JfMEdscDRiMXcyZ1ZmU3NFMzVoelZJaDlvT0ZoRGQ2bmtlM0M5ZHlCd2ZMbnRZRkZUWHVBUEx4czNfTmtMckh5eXZrZFBzOEItOGRYOEhsMzBhZ0xlOWFjZzgteVBsdnpPT1pYdUxnbFNXYnhKaVB6QUxVdUJCOFpvU2x2c1FHZV94MDBOVWJhYkxISkswc0U5UmdPWFJLXzZNYklHTjN1QzRKaldKdEVHb0pOU284N3c2LXZGMGVleEZ5NGZ6OGV1dm1tM0J0aTQ3VFlNOEJrdEh3PT0=
|
||||||
|
|
||||||
# Feature SyncDelta JIRA configuration
|
# Teamsbot Browser Bot Service (service-main-teams-browser-bot on Infomaniak)
|
||||||
Feature_SyncDelta_JIRA_DELTA_TOKEN_SECRET = INT_ENC:Z0FBQUFBQm8xSVRkTUNsWm4wX0p6eXFDZmJ4dFdHNEs1MV9MUzdrb3RzeC1jVWVYZ0REWHRyZkFiaGZLcUQtTXFBZzZkNzRmQ0gxbEhGbUNlVVFfR1JEQTc0aldkZkgyWnBOcjdlUlZxR0tDTEdKRExULXAyUEtsVmNTMkRKU1BJNnFiM0hlMXo4YndMcHlRMExtZDQ3Zm9vNFhMcEZCcHpBPT0=
|
TEAMSBOT_BROWSER_BOT_URL = http://teamsbot.poweron.swiss:4100
|
||||||
|
|
||||||
# Teamsbot Browser Bot Service
|
|
||||||
TEAMSBOT_BROWSER_BOT_URL = https://cae-poweron-shared.redwater-53d21339.switzerlandnorth.azurecontainerapps.io
|
|
||||||
|
|
||||||
# Debug Configuration
|
# Debug Configuration
|
||||||
APP_DEBUG_CHAT_WORKFLOW_ENABLED = FALSE
|
APP_DEBUG_CHAT_WORKFLOW_ENABLED = FALSE
|
||||||
|
|
@ -8,8 +8,8 @@ APP_INIT_PASS_ADMIN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3UnJRV0sySFlDblpXUlREclREaW
|
||||||
APP_INIT_PASS_EVENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3QVpIY19DQVZSSzJmc2F0VEZvQlU1cHBhTEgxdHdnR3g4eW01aTEzYTUxc1gxTDR1RVVpSHRXYjV6N1BLZUdCUGlfOW1qdy0xSHFVRkNBcGZvaGlSSkZycXRuUllaWnpyVGRoeFg1dGEyNUk9
|
APP_INIT_PASS_EVENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3QVpIY19DQVZSSzJmc2F0VEZvQlU1cHBhTEgxdHdnR3g4eW01aTEzYTUxc1gxTDR1RVVpSHRXYjV6N1BLZUdCUGlfOW1qdy0xSHFVRkNBcGZvaGlSSkZycXRuUllaWnpyVGRoeFg1dGEyNUk9
|
||||||
APP_API_URL = https://api.poweron.swiss
|
APP_API_URL = https://api.poweron.swiss
|
||||||
|
|
||||||
# PostgreSQL DB Host
|
# PostgreSQL DB Host (porta-main-db on Infomaniak Public Cloud)
|
||||||
DB_HOST=10.20.0.21
|
DB_HOST=db.poweron.swiss
|
||||||
DB_USER=poweron_dev
|
DB_USER=poweron_dev
|
||||||
DB_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnA4UXZiMnRoUzVlbVRLX3JTRl94cVpMaURtMndZVmFBYXdvdnIxLV81dWwxWmhmcUlCMUFZbDhRT2NsQmNqSl9ZMmRWRVN1Y2JqNlVwOXRJY1VBTm1oSjNiaFE9PQ==
|
DB_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnA4UXZiMnRoUzVlbVRLX3JTRl94cVpMaURtMndZVmFBYXdvdnIxLV81dWwxWmhmcUlCMUFZbDhRT2NsQmNqSl9ZMmRWRVN1Y2JqNlVwOXRJY1VBTm1oSjNiaFE9PQ==
|
||||||
DB_PORT=5432
|
DB_PORT=5432
|
||||||
|
|
@ -19,7 +19,7 @@ APP_JWT_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3elhfV0Rnd2pQRjlMdkVwX1FnSmRhSzNZUl
|
||||||
APP_TOKEN_EXPIRY=300
|
APP_TOKEN_EXPIRY=300
|
||||||
|
|
||||||
# CORS Configuration
|
# CORS Configuration
|
||||||
APP_ALLOWED_ORIGINS=https://porta.poweron.swiss
|
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://porta.poweron.swiss,https://porta-int.poweron.swiss,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss,https://nyla.poweron-center.net,https://nyla-int.poweron-center.net
|
||||||
|
|
||||||
# Logging configuration
|
# Logging configuration
|
||||||
APP_LOGGING_LOG_LEVEL = DEBUG
|
APP_LOGGING_LOG_LEVEL = DEBUG
|
||||||
|
|
@ -74,11 +74,8 @@ Service_MSFT_TENANT_ID = common
|
||||||
# Google Cloud Speech Services configuration
|
# Google Cloud Speech Services configuration
|
||||||
Connector_GoogleSpeech_API_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z4NFQxaF9uN3h1cVB6dnZid1c1R1VfNDlSQ1NHMEVDZWtKanpMQ29CLXc1MXBqRm1hQ0YtWVhaejBMY1ZTOEFEVlpWQ3hrYkFza1E2RDNsYkdMMndNR0VGNTMwVDRGdURJY3hyaVFxVjEtSEYwNHJzeWM3WmlpZW9jU2E3NTgycEV2allqQ3dJRTNyRFAzaDJ6dklKeXpNRkJhYjFzUkptN2dpbkNpMklrcGxuZl9vTkt3T0JvNm1YTXd5UlkwZWptUXdWVFpnV2J4X3J2WUhIUlFkSElFVnlqMnlJRnNHTnlpMWs2R1dZc2ROWjNYZG85cndmd1E5cUZnVmZRYnVjTG43dXFmSWd2bGFfVWFWSmtpWkpndWNlSUNwcnFNU2NqZXFaV0xsY3l3SElLRkVHcHZGZERKV1ltcGhTS0dhTko1VTJLYzNoZjRkSGVEX3dTMWVVTmdDczV5cE1JQUdSbUJGUm11eFhTVjJHbkt0SzB4UG1Dc2xmbnp1Y041Y2RTeWRuWGdmQy1sTGx0MGtnM2VJQ3EyLXViRlNhTU9ybzZkR1N1bXE5SXhlZENWRFpWSGlYOWx4SUQ3UlR0ZEVxQkxNakRUVFRiUmFnbklOalphLUZkRFVVaXBRUk5NZW5PaUZydTFmQkNPSTdTVTNZd0plWXllNVFJdmN4MVcyTGlwMGFtVjBzOGRxR1FjbzhfYW5zdTB0ZEZBTTJhakltazh1dktNMUZsOUItdFdTb1pIaUxySllXNkdlY20zUS0wTnpFNTB2SU5acG1VcXhyaHBmME8takw3RDh5T043T2VGOV92TzNya2pWSlpYVjZDdXlZcjM3a0hPTlhkaW9oQmxqQlpGRFYyTTY4WmZmT3k4Tk1tdXRuSGdTUVpNT2NKenhXb05PdXBfSEdhMTNxNjdpNXlKUUI2YUgydFFPX1VvXzVJb0UxWTU2YVNiNDQ0QndZanhMMHR1cGdHWGhvcEg1QXEtSXZJdTdZUE12ZEVVWkF4QmtsQS1GYnY3SFIxSHlsOGVfcEpGS1A4QUVEQWNEOFZYYlljQ3ByTU03YU16Y0UzUnJQZEprSWNjT1ZXVEtDWi03Y3ZzRVdYUTlabXJISEo5THRHVXVuM0xqbzA4bGVlZVpOMk1QMmptb21tV0pTMlVoOXdWVU95UW1iQmttc2w1RG9mMWwxXzg1T2IxYUVmTUJEZkpUdTFDTzZ3RlBFeUFiX01iRTZNWkNaSG45TkFOM2pzbUJRZ2N0VFpoejJUTG1RODY3TzZpSzVkYUQzaEpfY2pSTkRzU0VpanlkdXVQQmJ2WU5peno4QWNLTDVxZTlhSHI3NnNiM0k0Y3JkQ0xaOU05bGtsQl8zQklvaktWSDZ4aVp2MHlYelJuUDJyTU9CZC1OZjJxNFc1dDcwSUlxaVh1LTMyWWFwU0IwUU9kOUFpMWpnOERtLTh1VmJiNGVwcXBMbU5fMjVZc0hFbmxQT2puSFd1ZGpyTkphLU5sVlBZWWxrWEZrWGJQWmVkN19tZFZfZ1l1V3pSWlA0V0ZxM2lrWnl2NU9WeTdCbDROSmhfeENKTFhMVXk1d195S2JMUFJoRXZjcVo4V2g0MTNKRnZhUE1wRkNPM3FZOGdVazJPeW5PSGpuZnFGTTdJMkRnam5rUlV6NFlqODlIelRYaEN5VjdJNnVwbllNODNCTFRHMWlXbmM1VlRxbXB3Wm9LRjVrQUpjYzRNMThUMWwwSVhBMUlyamtPZnE4R0o4bEdHay1zMjR5RDJkZ1lYRHZaNHVHU2otR3ZpN25LZlEySEU0UmdTNzJGVHNWQXMyb0dVMV9WUE13ODhZWUFaakxGOWZieGNXZkNYRnV5djEyWTZLcmdrajRBLU1rS1Z0VVRkOWlDMU9fMGVmYXFhZXJGMUhpNkdmb2hkbzZ1OWV6VlNmVzNISjVYTFh6SjJNdWR5MWZidE8yVEo2dnRrZXhMRXBPczUwTG13OGhNUVpIQm0zQmRKRnJ0Nl8wNW1Ob0dHRDVpU0NWREV3TkY2SjktdVBkMFU1ZXBmSFpHQ3FHNTRZdTJvaExpZVEtLTU4YTVyeFBpNDdEajZtWUc4c1dBeUJqQ3NIY1NLS0FIMUxGZzZxNFNkOG9ORGNHWWJCVnZuNnJVTEtoQi1mRTZyUl81ZWJJMi1KOGdERzBhNVRZeHRYUUlqY2JvMFlaNHhWMU9pWFFiZjdaLUhkaG15TTBPZVlkS2R5UVdENTI4QVFiY1RJV0ZNZnlpVWxfZmlnN1BXbGdrbjFGUkhzYl9qeHBxVVJacUE4bjZETENHVFpSamh0NVpOM2hMYTZjYzBuS3J0a3hhZGxSM1V5UHd2OTU3ZHY0Yy1xWDBkWUk0Ymp0MWVrS3YzSktKODhQZnY3QTZ1Wm1VZkZJbS1jamdreks1ZlhpQjFOUDFiOHJ2Nm9NcmdTdU5LQXV2RkZWZEFNZnVKUjVwcVY3dDdhQnpmRVJ6SmlvVXpDM0ZiYXh5bGE2X04tTE9qZ3BiTnN3TF9ZaFRxSUpjNjB1dXZBcy1TZHRHTjFjSUR3WUl4cE9VNzB5Rkk4U3Z1SVZYTl9sYXlZVk83UnFrMlVmcnBpam9lRUlCY19DdVJwOXl2TVVDV1pMRFZTZk9MY3Z1eXA0MnhGazc5YllQaWtOeTc4NjlOa2lGY05RRzY1cG9nbGpYelc4c3FicWxWRkg0YzRSamFlQ19zOU14YWJreU9pNDREZVJ3a0REMUxGTzF1XzI1bEF3VXVZRjlBeWFiLXJsOXgza3VZem1WckhWSnVNbDBNcldadU8xQ3RwOTl5NGgtVlR0QklCLWl5WkE4V1FlQTBCOVU1RE9sQlRrYUNZOGdfUmEwbEZvUTFGUEFWVmQ4V1FhOU9VNjZqemRpZm1sUDhZQTJ0YVBRbWZldkF5THV4QXpfdUtNZ0tlcGdSRFM3c0lDOTNQbnBxdmxYYWNpTmI3MW9BMlZIdTQ5RldudHpNQWQ5NDNPLVVTLXVVNzdHZXh4UXpZa3dVa2J4dTFDV1RkYjRnWXU2M3lJekRYWGNMcWU5OVh6U2xZWDh6MmpqcnpiOHlnMjA5S3RFQm1NZjNSM21adkVnTUpSYVhkTzNkNnJCTmljY0x1cl9kMkx3UHhySjZEdHREanZERzNEUTFlTkR0NWlBczAtdmFGTjdZNVpTMlkxV2czYW5RN2lqemg4eUViZDV6RjdKNXdFcUlvcVhoNkJ6eVJkR1pua1hnNzQwOEs2TXJYSlpGcW9qRDU2QjBOWFFtdXBJRkRKbmdZUF9ZSmRPVEtvUjVhLTV1NjdXQjRhS0duaEtJb2FrQnNjUTRvdFMxdkdTNk1NYlFHUFhhYTJ1eUN3WHN4UlJ4UjdrZjY0SzFGYWVFN1k0cGJnc1RjNmFUenR4NHljbVhablZSWHZmUVN3cXRHNjhsX1BSZWEzdTJUZFA0S2pTaU9YMnZIQ1ZPcGhWMFJqZkVEMWRMR1h3SnU0Z2FzZ3VGM3puNzdhVjhaQXNIWHFsbjB0TDVYSFdSNV9rdWhUUUhSZHBGYkJIVDB5SDdlMC13QTVnS0g5Qkg5RGNxSGJlelVndUhPcEQ0QkRKMTJTZUM1OXJhVm0zYjU0OVY2dk9MQVBheklIQXpVNW9Yc0ROVjEzaFZTWmVxYlBWMlNlSzladzJ6TmNuMG5FVVZkN1VZN1pfS2ZHa0lQcE80S24wSnQtVlJVV09OVWJ3M09YMkZpV2ktVF9ENHhKU2dfYUQ2aUVyamk0VHJHQmVfVHU4clpUTFoteW5aSWRPV1M0RDRMTms4NGRoYmJfVE82aUl2X3VieVJOdDhBQmRwdzdnRTVBNzZwaW93dUlZb3ZRYUtOeG9ULWxvNVp5a0haSjdkcUhRb3d6UGIxRUpCVkVYX2d6TkRqQVozUWxkNGFoc1FXYVd2YWNkME9Qclo0bjYxMFRWTy1nbnI5NTBJNzRMMDluUXRKYTFqQUN4d0d5aHVlamN3Tkk3NWJXeXR0TW9BeUg5Vnp4Q2RnZUY3b3AtMDlrNmlrSGR0eGRtbUdUd2lFRWg4MklEeWJHN2wwZEpVSXMxNDNOWjRFS0tPdWxhMmFCckhfRENIY184aEFDZXNrRDl2dHQtQW12UnRuQXJjaDJoTUpiYkNWQUtfRG9GMUZoNWM4UnBYZ29RWWs2NHcyUm5kdTF3Vk1GeFpiRUJLaVZ2UGFjbi1jV3lMV0N2ZDl4VERPN295X01NNG56ZjZkRzZoYUtmY1E5NlVXemx2SnVfb19iSXg0R2M3Mjd1a2JRPT0=
|
Connector_GoogleSpeech_API_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z4NFQxaF9uN3h1cVB6dnZid1c1R1VfNDlSQ1NHMEVDZWtKanpMQ29CLXc1MXBqRm1hQ0YtWVhaejBMY1ZTOEFEVlpWQ3hrYkFza1E2RDNsYkdMMndNR0VGNTMwVDRGdURJY3hyaVFxVjEtSEYwNHJzeWM3WmlpZW9jU2E3NTgycEV2allqQ3dJRTNyRFAzaDJ6dklKeXpNRkJhYjFzUkptN2dpbkNpMklrcGxuZl9vTkt3T0JvNm1YTXd5UlkwZWptUXdWVFpnV2J4X3J2WUhIUlFkSElFVnlqMnlJRnNHTnlpMWs2R1dZc2ROWjNYZG85cndmd1E5cUZnVmZRYnVjTG43dXFmSWd2bGFfVWFWSmtpWkpndWNlSUNwcnFNU2NqZXFaV0xsY3l3SElLRkVHcHZGZERKV1ltcGhTS0dhTko1VTJLYzNoZjRkSGVEX3dTMWVVTmdDczV5cE1JQUdSbUJGUm11eFhTVjJHbkt0SzB4UG1Dc2xmbnp1Y041Y2RTeWRuWGdmQy1sTGx0MGtnM2VJQ3EyLXViRlNhTU9ybzZkR1N1bXE5SXhlZENWRFpWSGlYOWx4SUQ3UlR0ZEVxQkxNakRUVFRiUmFnbklOalphLUZkRFVVaXBRUk5NZW5PaUZydTFmQkNPSTdTVTNZd0plWXllNVFJdmN4MVcyTGlwMGFtVjBzOGRxR1FjbzhfYW5zdTB0ZEZBTTJhakltazh1dktNMUZsOUItdFdTb1pIaUxySllXNkdlY20zUS0wTnpFNTB2SU5acG1VcXhyaHBmME8takw3RDh5T043T2VGOV92TzNya2pWSlpYVjZDdXlZcjM3a0hPTlhkaW9oQmxqQlpGRFYyTTY4WmZmT3k4Tk1tdXRuSGdTUVpNT2NKenhXb05PdXBfSEdhMTNxNjdpNXlKUUI2YUgydFFPX1VvXzVJb0UxWTU2YVNiNDQ0QndZanhMMHR1cGdHWGhvcEg1QXEtSXZJdTdZUE12ZEVVWkF4QmtsQS1GYnY3SFIxSHlsOGVfcEpGS1A4QUVEQWNEOFZYYlljQ3ByTU03YU16Y0UzUnJQZEprSWNjT1ZXVEtDWi03Y3ZzRVdYUTlabXJISEo5THRHVXVuM0xqbzA4bGVlZVpOMk1QMmptb21tV0pTMlVoOXdWVU95UW1iQmttc2w1RG9mMWwxXzg1T2IxYUVmTUJEZkpUdTFDTzZ3RlBFeUFiX01iRTZNWkNaSG45TkFOM2pzbUJRZ2N0VFpoejJUTG1RODY3TzZpSzVkYUQzaEpfY2pSTkRzU0VpanlkdXVQQmJ2WU5peno4QWNLTDVxZTlhSHI3NnNiM0k0Y3JkQ0xaOU05bGtsQl8zQklvaktWSDZ4aVp2MHlYelJuUDJyTU9CZC1OZjJxNFc1dDcwSUlxaVh1LTMyWWFwU0IwUU9kOUFpMWpnOERtLTh1VmJiNGVwcXBMbU5fMjVZc0hFbmxQT2puSFd1ZGpyTkphLU5sVlBZWWxrWEZrWGJQWmVkN19tZFZfZ1l1V3pSWlA0V0ZxM2lrWnl2NU9WeTdCbDROSmhfeENKTFhMVXk1d195S2JMUFJoRXZjcVo4V2g0MTNKRnZhUE1wRkNPM3FZOGdVazJPeW5PSGpuZnFGTTdJMkRnam5rUlV6NFlqODlIelRYaEN5VjdJNnVwbllNODNCTFRHMWlXbmM1VlRxbXB3Wm9LRjVrQUpjYzRNMThUMWwwSVhBMUlyamtPZnE4R0o4bEdHay1zMjR5RDJkZ1lYRHZaNHVHU2otR3ZpN25LZlEySEU0UmdTNzJGVHNWQXMyb0dVMV9WUE13ODhZWUFaakxGOWZieGNXZkNYRnV5djEyWTZLcmdrajRBLU1rS1Z0VVRkOWlDMU9fMGVmYXFhZXJGMUhpNkdmb2hkbzZ1OWV6VlNmVzNISjVYTFh6SjJNdWR5MWZidE8yVEo2dnRrZXhMRXBPczUwTG13OGhNUVpIQm0zQmRKRnJ0Nl8wNW1Ob0dHRDVpU0NWREV3TkY2SjktdVBkMFU1ZXBmSFpHQ3FHNTRZdTJvaExpZVEtLTU4YTVyeFBpNDdEajZtWUc4c1dBeUJqQ3NIY1NLS0FIMUxGZzZxNFNkOG9ORGNHWWJCVnZuNnJVTEtoQi1mRTZyUl81ZWJJMi1KOGdERzBhNVRZeHRYUUlqY2JvMFlaNHhWMU9pWFFiZjdaLUhkaG15TTBPZVlkS2R5UVdENTI4QVFiY1RJV0ZNZnlpVWxfZmlnN1BXbGdrbjFGUkhzYl9qeHBxVVJacUE4bjZETENHVFpSamh0NVpOM2hMYTZjYzBuS3J0a3hhZGxSM1V5UHd2OTU3ZHY0Yy1xWDBkWUk0Ymp0MWVrS3YzSktKODhQZnY3QTZ1Wm1VZkZJbS1jamdreks1ZlhpQjFOUDFiOHJ2Nm9NcmdTdU5LQXV2RkZWZEFNZnVKUjVwcVY3dDdhQnpmRVJ6SmlvVXpDM0ZiYXh5bGE2X04tTE9qZ3BiTnN3TF9ZaFRxSUpjNjB1dXZBcy1TZHRHTjFjSUR3WUl4cE9VNzB5Rkk4U3Z1SVZYTl9sYXlZVk83UnFrMlVmcnBpam9lRUlCY19DdVJwOXl2TVVDV1pMRFZTZk9MY3Z1eXA0MnhGazc5YllQaWtOeTc4NjlOa2lGY05RRzY1cG9nbGpYelc4c3FicWxWRkg0YzRSamFlQ19zOU14YWJreU9pNDREZVJ3a0REMUxGTzF1XzI1bEF3VXVZRjlBeWFiLXJsOXgza3VZem1WckhWSnVNbDBNcldadU8xQ3RwOTl5NGgtVlR0QklCLWl5WkE4V1FlQTBCOVU1RE9sQlRrYUNZOGdfUmEwbEZvUTFGUEFWVmQ4V1FhOU9VNjZqemRpZm1sUDhZQTJ0YVBRbWZldkF5THV4QXpfdUtNZ0tlcGdSRFM3c0lDOTNQbnBxdmxYYWNpTmI3MW9BMlZIdTQ5RldudHpNQWQ5NDNPLVVTLXVVNzdHZXh4UXpZa3dVa2J4dTFDV1RkYjRnWXU2M3lJekRYWGNMcWU5OVh6U2xZWDh6MmpqcnpiOHlnMjA5S3RFQm1NZjNSM21adkVnTUpSYVhkTzNkNnJCTmljY0x1cl9kMkx3UHhySjZEdHREanZERzNEUTFlTkR0NWlBczAtdmFGTjdZNVpTMlkxV2czYW5RN2lqemg4eUViZDV6RjdKNXdFcUlvcVhoNkJ6eVJkR1pua1hnNzQwOEs2TXJYSlpGcW9qRDU2QjBOWFFtdXBJRkRKbmdZUF9ZSmRPVEtvUjVhLTV1NjdXQjRhS0duaEtJb2FrQnNjUTRvdFMxdkdTNk1NYlFHUFhhYTJ1eUN3WHN4UlJ4UjdrZjY0SzFGYWVFN1k0cGJnc1RjNmFUenR4NHljbVhablZSWHZmUVN3cXRHNjhsX1BSZWEzdTJUZFA0S2pTaU9YMnZIQ1ZPcGhWMFJqZkVEMWRMR1h3SnU0Z2FzZ3VGM3puNzdhVjhaQXNIWHFsbjB0TDVYSFdSNV9rdWhUUUhSZHBGYkJIVDB5SDdlMC13QTVnS0g5Qkg5RGNxSGJlelVndUhPcEQ0QkRKMTJTZUM1OXJhVm0zYjU0OVY2dk9MQVBheklIQXpVNW9Yc0ROVjEzaFZTWmVxYlBWMlNlSzladzJ6TmNuMG5FVVZkN1VZN1pfS2ZHa0lQcE80S24wSnQtVlJVV09OVWJ3M09YMkZpV2ktVF9ENHhKU2dfYUQ2aUVyamk0VHJHQmVfVHU4clpUTFoteW5aSWRPV1M0RDRMTms4NGRoYmJfVE82aUl2X3VieVJOdDhBQmRwdzdnRTVBNzZwaW93dUlZb3ZRYUtOeG9ULWxvNVp5a0haSjdkcUhRb3d6UGIxRUpCVkVYX2d6TkRqQVozUWxkNGFoc1FXYVd2YWNkME9Qclo0bjYxMFRWTy1nbnI5NTBJNzRMMDluUXRKYTFqQUN4d0d5aHVlamN3Tkk3NWJXeXR0TW9BeUg5Vnp4Q2RnZUY3b3AtMDlrNmlrSGR0eGRtbUdUd2lFRWg4MklEeWJHN2wwZEpVSXMxNDNOWjRFS0tPdWxhMmFCckhfRENIY184aEFDZXNrRDl2dHQtQW12UnRuQXJjaDJoTUpiYkNWQUtfRG9GMUZoNWM4UnBYZ29RWWs2NHcyUm5kdTF3Vk1GeFpiRUJLaVZ2UGFjbi1jV3lMV0N2ZDl4VERPN295X01NNG56ZjZkRzZoYUtmY1E5NlVXemx2SnVfb19iSXg0R2M3Mjd1a2JRPT0=
|
||||||
|
|
||||||
# Feature SyncDelta JIRA configuration
|
|
||||||
Feature_SyncDelta_JIRA_DELTA_TOKEN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z4d3Z4d2x6N1FhUktMU0RKbkxfY2pTQkRzXzJ6UXVEbDNCaFM3UHMtQVFGYzNmYWs4N0lMM1R2SFJuZTVFVmx6MGVEbXc5U3NOTnY1TWN0ZDNaamlHQWloalM3VldmREJNSHQ1TlVkSVFJMTVhQWVGSVRMTGw4UTBqNGlQZFVuaHp4WUlKemR5UnBXZlh0REJFLXJ4ejR3PT0=
|
|
||||||
|
|
||||||
# Teamsbot Browser Bot Service
|
# Teamsbot Browser Bot Service
|
||||||
TEAMSBOT_BROWSER_BOT_URL = https://cae-poweron-shared.redwater-53d21339.switzerlandnorth.azurecontainerapps.io
|
TEAMSBOT_BROWSER_BOT_URL = http://teamsbot.poweron.swiss:4100
|
||||||
|
|
||||||
# Debug Configuration
|
# Debug Configuration
|
||||||
APP_DEBUG_CHAT_WORKFLOW_ENABLED = FALSE
|
APP_DEBUG_CHAT_WORKFLOW_ENABLED = FALSE
|
||||||
101
modules/auth/oauthConnectTicket.py
Normal file
101
modules/auth/oauthConnectTicket.py
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
|
"""
|
||||||
|
Short-lived signed tickets for OAuth data-connection popups.
|
||||||
|
|
||||||
|
The UI authenticates API calls with a Bearer token in localStorage, but
|
||||||
|
``window.open(authUrl)`` cannot send that header. Cross-origin httpOnly cookies
|
||||||
|
are unreliable in int/prod (UI on poweron-center.net, API on poweron.swiss).
|
||||||
|
Login popups work without a session because ``/auth/login`` is public; connect
|
||||||
|
popups hit ``/auth/connect``, which used to require ``getCurrentUser``.
|
||||||
|
|
||||||
|
Flow: POST ``/api/connections/{id}/connect`` (Bearer-authenticated) issues a
|
||||||
|
ticket; the popup opens ``/auth/connect?connectTicket=...`` which validates the
|
||||||
|
ticket instead of cookies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, Tuple
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from jose import JWTError, jwt as jose_jwt
|
||||||
|
|
||||||
|
from modules.auth.jwtService import ALGORITHM, SECRET_KEY
|
||||||
|
from modules.datamodels.datamodelUam import AuthAuthority, User, UserConnection
|
||||||
|
from modules.interfaces.interfaceDbApp import getInterface, getRootInterface
|
||||||
|
from modules.shared.i18nRegistry import apiRouteContext
|
||||||
|
|
||||||
|
_msg = apiRouteContext("oauthConnectTicket")
|
||||||
|
|
||||||
|
_CONNECT_TICKET_TTL_SEC = 600
|
||||||
|
|
||||||
|
|
||||||
|
def issue_connect_ticket(flow: str, connection_id: str, user_id: str) -> str:
|
||||||
|
"""Issue a short-lived JWT for starting a data-connection OAuth popup."""
|
||||||
|
body = {
|
||||||
|
"flow": flow,
|
||||||
|
"connectionId": connection_id,
|
||||||
|
"userId": str(user_id),
|
||||||
|
"exp": int(time.time()) + _CONNECT_TICKET_TTL_SEC,
|
||||||
|
}
|
||||||
|
return jose_jwt.encode(body, SECRET_KEY, algorithm=ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_connect_ticket(ticket: str, expected_flow: str) -> Dict[str, Any]:
|
||||||
|
"""Validate connect ticket signature, expiry, and flow."""
|
||||||
|
try:
|
||||||
|
data = jose_jwt.decode(ticket, SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
|
except JWTError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=_msg("Invalid or expired connect ticket"),
|
||||||
|
) from e
|
||||||
|
if data.get("flow") != expected_flow:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=_msg("Invalid connect ticket flow"),
|
||||||
|
)
|
||||||
|
connection_id = data.get("connectionId")
|
||||||
|
user_id = data.get("userId")
|
||||||
|
if not connection_id or not user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=_msg("Incomplete connect ticket"),
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_connect_context(
|
||||||
|
connect_ticket: str,
|
||||||
|
connection_id: str,
|
||||||
|
expected_flow: str,
|
||||||
|
authority: AuthAuthority,
|
||||||
|
) -> Tuple[User, UserConnection]:
|
||||||
|
"""Validate ticket and return the user + connection for OAuth redirect."""
|
||||||
|
state = parse_connect_ticket(connect_ticket, expected_flow)
|
||||||
|
if state.get("connectionId") != connection_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=_msg("Connection ID does not match connect ticket"),
|
||||||
|
)
|
||||||
|
|
||||||
|
root = getRootInterface()
|
||||||
|
user = root.getUser(state["userId"])
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=_msg("User not found"),
|
||||||
|
)
|
||||||
|
|
||||||
|
interface = getInterface(user)
|
||||||
|
connection = None
|
||||||
|
for conn in interface.getUserConnections(user.id):
|
||||||
|
if conn.id == connection_id and conn.authority == authority:
|
||||||
|
connection = conn
|
||||||
|
break
|
||||||
|
if not connection:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=_msg("Connection not found"),
|
||||||
|
)
|
||||||
|
return user, connection
|
||||||
|
|
@ -9,14 +9,15 @@ for compliance, audit, and data-protection reporting.
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import Field
|
||||||
|
|
||||||
|
from modules.datamodels.datamodelBase import PowerOnModel
|
||||||
from modules.shared.i18nRegistry import i18nModel
|
from modules.shared.i18nRegistry import i18nModel
|
||||||
from modules.shared.timeUtils import getUtcTimestamp
|
from modules.shared.timeUtils import getUtcTimestamp
|
||||||
|
|
||||||
|
|
||||||
@i18nModel("AI-Audit-Eintrag")
|
@i18nModel("AI-Audit-Eintrag")
|
||||||
class AiAuditLogEntry(BaseModel):
|
class AiAuditLogEntry(PowerOnModel):
|
||||||
id: str = Field(
|
id: str = Field(
|
||||||
default_factory=lambda: str(uuid.uuid4()),
|
default_factory=lambda: str(uuid.uuid4()),
|
||||||
description="Primary key",
|
description="Primary key",
|
||||||
|
|
@ -34,7 +35,7 @@ class AiAuditLogEntry(BaseModel):
|
||||||
|
|
||||||
userId: str = Field(
|
userId: str = Field(
|
||||||
description="ID of the user who triggered the AI call",
|
description="ID of the user who triggered the AI call",
|
||||||
json_schema_extra={"label": "Benutzer-ID", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
|
json_schema_extra={"label": "Benutzer-ID", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username", "softFk": True}},
|
||||||
)
|
)
|
||||||
username: Optional[str] = Field(
|
username: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
|
|
@ -43,17 +44,17 @@ class AiAuditLogEntry(BaseModel):
|
||||||
)
|
)
|
||||||
mandateId: str = Field(
|
mandateId: str = Field(
|
||||||
description="Mandate context of the call",
|
description="Mandate context of the call",
|
||||||
json_schema_extra={"label": "Mandanten-ID", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}},
|
json_schema_extra={"label": "Mandanten-ID", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label", "softFk": True}},
|
||||||
)
|
)
|
||||||
featureInstanceId: Optional[str] = Field(
|
featureInstanceId: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Feature instance context",
|
description="Feature instance context",
|
||||||
json_schema_extra={"label": "Feature-Instanz-ID", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
|
json_schema_extra={"label": "Feature-Instanz-ID", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label", "softFk": True}},
|
||||||
)
|
)
|
||||||
featureCode: Optional[str] = Field(
|
featureCode: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Feature code (e.g. workspace, trustee)",
|
description="Feature code (e.g. workspace, trustee)",
|
||||||
json_schema_extra={"label": "Feature", "fk_target": {"db": "poweron_app", "table": "Feature", "column": "code", "labelField": "code"}},
|
json_schema_extra={"label": "Feature", "fk_target": {"db": "poweron_app", "table": "Feature", "column": "code", "labelField": "code", "softFk": True}},
|
||||||
)
|
)
|
||||||
instanceLabel: Optional[str] = Field(
|
instanceLabel: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ from pydantic import BaseModel, Field
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
from modules.datamodels.datamodelBase import PowerOnModel
|
||||||
from modules.shared.timeUtils import getUtcTimestamp
|
from modules.shared.timeUtils import getUtcTimestamp
|
||||||
from modules.shared.i18nRegistry import i18nModel
|
from modules.shared.i18nRegistry import i18nModel
|
||||||
|
|
||||||
|
|
@ -83,7 +84,7 @@ class AuditAction(str, Enum):
|
||||||
|
|
||||||
|
|
||||||
@i18nModel("Audit-Log-Eintrag")
|
@i18nModel("Audit-Log-Eintrag")
|
||||||
class AuditLogEntry(BaseModel):
|
class AuditLogEntry(PowerOnModel):
|
||||||
"""
|
"""
|
||||||
Audit log entry for database storage.
|
Audit log entry for database storage.
|
||||||
|
|
||||||
|
|
@ -111,7 +112,7 @@ class AuditLogEntry(BaseModel):
|
||||||
"frontend_type": "text",
|
"frontend_type": "text",
|
||||||
"frontend_readonly": True,
|
"frontend_readonly": True,
|
||||||
"frontend_required": True,
|
"frontend_required": True,
|
||||||
"fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"},
|
"fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username", "softFk": True},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -130,7 +131,7 @@ class AuditLogEntry(BaseModel):
|
||||||
"frontend_type": "text",
|
"frontend_type": "text",
|
||||||
"frontend_readonly": True,
|
"frontend_readonly": True,
|
||||||
"frontend_required": False,
|
"frontend_required": False,
|
||||||
"fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"},
|
"fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label", "softFk": True},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -142,7 +143,7 @@ class AuditLogEntry(BaseModel):
|
||||||
"frontend_type": "text",
|
"frontend_type": "text",
|
||||||
"frontend_readonly": True,
|
"frontend_readonly": True,
|
||||||
"frontend_required": False,
|
"frontend_required": False,
|
||||||
"fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"},
|
"fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label", "softFk": True},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ class BillingTransaction(PowerOnModel):
|
||||||
|
|
||||||
|
|
||||||
@i18nModel("Abrechnungseinstellungen")
|
@i18nModel("Abrechnungseinstellungen")
|
||||||
class BillingSettings(BaseModel):
|
class BillingSettings(PowerOnModel):
|
||||||
"""Billing settings per mandate. Only PREPAY_MANDATE model."""
|
"""Billing settings per mandate. Only PREPAY_MANDATE model."""
|
||||||
id: str = Field(
|
id: str = Field(
|
||||||
default_factory=lambda: str(uuid.uuid4()),
|
default_factory=lambda: str(uuid.uuid4()),
|
||||||
|
|
@ -186,7 +186,7 @@ class BillingSettings(BaseModel):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class StripeWebhookEvent(BaseModel):
|
class StripeWebhookEvent(PowerOnModel):
|
||||||
"""Stores processed Stripe webhook event IDs for idempotency."""
|
"""Stores processed Stripe webhook event IDs for idempotency."""
|
||||||
id: str = Field(
|
id: str = Field(
|
||||||
default_factory=lambda: str(uuid.uuid4()),
|
default_factory=lambda: str(uuid.uuid4()),
|
||||||
|
|
@ -201,7 +201,7 @@ class StripeWebhookEvent(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
@i18nModel("Nutzungsstatistik")
|
@i18nModel("Nutzungsstatistik")
|
||||||
class UsageStatistics(BaseModel):
|
class UsageStatistics(PowerOnModel):
|
||||||
"""Aggregated usage statistics for quick retrieval."""
|
"""Aggregated usage statistics for quick retrieval."""
|
||||||
id: str = Field(
|
id: str = Field(
|
||||||
default_factory=lambda: str(uuid.uuid4()),
|
default_factory=lambda: str(uuid.uuid4()),
|
||||||
|
|
|
||||||
|
|
@ -319,7 +319,7 @@ class DocumentExchange(BaseModel):
|
||||||
documents: List[str] = Field(default_factory=list, description="List of document references", json_schema_extra={"label": "Dokumente"})
|
documents: List[str] = Field(default_factory=list, description="List of document references", json_schema_extra={"label": "Dokumente"})
|
||||||
|
|
||||||
@i18nModel("Aufgaben-Aktion")
|
@i18nModel("Aufgaben-Aktion")
|
||||||
class ActionItem(BaseModel):
|
class ActionItem(PowerOnModel):
|
||||||
id: str = Field(..., description="Action ID", json_schema_extra={"label": "Aktions-ID"})
|
id: str = Field(..., description="Action ID", json_schema_extra={"label": "Aktions-ID"})
|
||||||
execMethod: str = Field(..., description="Method to execute", json_schema_extra={"label": "Methode"})
|
execMethod: str = Field(..., description="Method to execute", json_schema_extra={"label": "Methode"})
|
||||||
execAction: str = Field(..., description="Action to perform", json_schema_extra={"label": "Aktion"})
|
execAction: str = Field(..., description="Action to perform", json_schema_extra={"label": "Aktion"})
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,7 @@ class FileContentIndex(PowerOnModel):
|
||||||
connectionId: Optional[str] = Field(
|
connectionId: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="UserConnection ID if this index entry originates from an external connector",
|
description="UserConnection ID if this index entry originates from an external connector",
|
||||||
json_schema_extra={"label": "Connection-ID"},
|
json_schema_extra={"label": "Connection-ID", "fk_target": {"db": "poweron_app", "table": "UserConnection", "labelField": "authority"}},
|
||||||
)
|
)
|
||||||
neutralizationStatus: Optional[str] = Field(
|
neutralizationStatus: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
|
|
@ -122,7 +122,7 @@ class ContentChunk(PowerOnModel):
|
||||||
)
|
)
|
||||||
contentObjectId: str = Field(
|
contentObjectId: str = Field(
|
||||||
description="Reference to the content object within FileContentIndex",
|
description="Reference to the content object within FileContentIndex",
|
||||||
json_schema_extra={"label": "Inhaltsobjekt-ID"},
|
json_schema_extra={"label": "Inhaltsobjekt-ID", "fk_target": {"db": "poweron_knowledge", "table": "FileContentIndex", "labelField": "fileName"}},
|
||||||
)
|
)
|
||||||
fileId: str = Field(
|
fileId: str = Field(
|
||||||
description="FK to the source file",
|
description="FK to the source file",
|
||||||
|
|
@ -177,7 +177,7 @@ class RoundMemory(PowerOnModel):
|
||||||
)
|
)
|
||||||
workflowId: str = Field(
|
workflowId: str = Field(
|
||||||
description="FK to the workflow",
|
description="FK to the workflow",
|
||||||
json_schema_extra={"label": "Workflow-ID"},
|
json_schema_extra={"label": "Workflow-ID", "fk_target": {"db": "poweron_chat", "table": "ChatWorkflow", "labelField": "name"}},
|
||||||
)
|
)
|
||||||
roundNumber: int = Field(
|
roundNumber: int = Field(
|
||||||
default=0,
|
default=0,
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,7 @@ class MessagingSubscription(PowerOnModel):
|
||||||
|
|
||||||
|
|
||||||
@i18nModel("Messaging-Registrierung")
|
@i18nModel("Messaging-Registrierung")
|
||||||
class MessagingSubscriptionRegistration(BaseModel):
|
class MessagingSubscriptionRegistration(PowerOnModel):
|
||||||
"""Data model for user registrations to messaging subscriptions"""
|
"""Data model for user registrations to messaging subscriptions"""
|
||||||
id: str = Field(
|
id: str = Field(
|
||||||
default_factory=lambda: str(uuid.uuid4()),
|
default_factory=lambda: str(uuid.uuid4()),
|
||||||
|
|
@ -203,7 +203,7 @@ class MessagingSubscriptionRegistration(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
@i18nModel("Messaging-Zustellung")
|
@i18nModel("Messaging-Zustellung")
|
||||||
class MessagingDelivery(BaseModel):
|
class MessagingDelivery(PowerOnModel):
|
||||||
"""Data model for individual message deliveries"""
|
"""Data model for individual message deliveries"""
|
||||||
id: str = Field(
|
id: str = Field(
|
||||||
default_factory=lambda: str(uuid.uuid4()),
|
default_factory=lambda: str(uuid.uuid4()),
|
||||||
|
|
|
||||||
|
|
@ -153,7 +153,7 @@ class SubscriptionPlan(BaseModel):
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
@i18nModel("Stripe-Planpreise")
|
@i18nModel("Stripe-Planpreise")
|
||||||
class StripePlanPrice(BaseModel):
|
class StripePlanPrice(PowerOnModel):
|
||||||
"""Persistierte Zuordnung planKey zu Stripe Product/Price IDs."""
|
"""Persistierte Zuordnung planKey zu Stripe Product/Price IDs."""
|
||||||
id: str = Field(
|
id: str = Field(
|
||||||
default_factory=lambda: str(uuid.uuid4()),
|
default_factory=lambda: str(uuid.uuid4()),
|
||||||
|
|
|
||||||
|
|
@ -475,7 +475,7 @@ class UserConnection(PowerOnModel):
|
||||||
description="OAuth scopes granted for this connection",
|
description="OAuth scopes granted for this connection",
|
||||||
json_schema_extra={"frontend_type": "list", "frontend_readonly": True, "frontend_required": False, "label": "Gewährte Berechtigungen"},
|
json_schema_extra={"frontend_type": "list", "frontend_readonly": True, "frontend_required": False, "label": "Gewährte Berechtigungen"},
|
||||||
)
|
)
|
||||||
knowledgeIngestionEnabled: bool = Field(
|
knowledgeIngestionEnabled: Optional[bool] = Field(
|
||||||
default=False,
|
default=False,
|
||||||
description="Whether the user has consented to knowledge ingestion for this connection",
|
description="Whether the user has consented to knowledge ingestion for this connection",
|
||||||
json_schema_extra={"frontend_type": "boolean", "frontend_readonly": False, "frontend_required": False, "label": "Wissensdatenbank aktiv"},
|
json_schema_extra={"frontend_type": "boolean", "frontend_readonly": False, "frontend_required": False, "label": "Wissensdatenbank aktiv"},
|
||||||
|
|
@ -747,4 +747,3 @@ class UserVoicePreferences(PowerOnModel):
|
||||||
def _validateTtsVoiceMap(cls, value: Any) -> Optional[Dict[str, str]]:
|
def _validateTtsVoiceMap(cls, value: Any) -> Optional[Dict[str, str]]:
|
||||||
return normalizeTtsVoiceMap(value)
|
return normalizeTtsVoiceMap(value)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -74,9 +74,18 @@ class CoachingScoreTrend(str, Enum):
|
||||||
class TrainingModule(PowerOnModel):
|
class TrainingModule(PowerOnModel):
|
||||||
"""A training module representing a topic the user is working on."""
|
"""A training module representing a topic the user is working on."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
userId: str = Field(description="Owner user ID (strict ownership)")
|
userId: str = Field(
|
||||||
mandateId: str = Field(description="Mandate ID")
|
description="Owner user ID (strict ownership)",
|
||||||
instanceId: str = Field(description="Feature instance ID")
|
json_schema_extra={"label": "Benutzer", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
|
||||||
|
)
|
||||||
|
mandateId: str = Field(
|
||||||
|
description="Mandate ID",
|
||||||
|
json_schema_extra={"label": "Mandant", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}},
|
||||||
|
)
|
||||||
|
instanceId: str = Field(
|
||||||
|
description="Feature instance ID",
|
||||||
|
json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
|
||||||
|
)
|
||||||
title: str = Field(description="Module title, e.g. 'Conflict with team lead'")
|
title: str = Field(description="Module title, e.g. 'Conflict with team lead'")
|
||||||
description: Optional[str] = Field(default=None, description="Short description")
|
description: Optional[str] = Field(default=None, description="Short description")
|
||||||
moduleType: TrainingModuleType = Field(default=TrainingModuleType.COACHING)
|
moduleType: TrainingModuleType = Field(default=TrainingModuleType.COACHING)
|
||||||
|
|
@ -84,7 +93,10 @@ class TrainingModule(PowerOnModel):
|
||||||
goals: Optional[str] = Field(default=None, description="Free-text goal description")
|
goals: Optional[str] = Field(default=None, description="Free-text goal description")
|
||||||
insights: Optional[str] = Field(default=None, description="JSON array of AI insights [{id, text, sessionId, createdAt}]")
|
insights: Optional[str] = Field(default=None, description="JSON array of AI insights [{id, text, sessionId, createdAt}]")
|
||||||
metadata: Optional[str] = Field(default=None, description="JSON object with flexible metadata")
|
metadata: Optional[str] = Field(default=None, description="JSON object with flexible metadata")
|
||||||
personaId: Optional[str] = Field(default=None, description="Default persona for sessions")
|
personaId: Optional[str] = Field(
|
||||||
|
default=None, description="Default persona for sessions",
|
||||||
|
json_schema_extra={"label": "Persona", "fk_target": {"db": "poweron_commcoach", "table": "CoachingPersona", "labelField": "label"}},
|
||||||
|
)
|
||||||
kpiTargets: Optional[str] = Field(default=None, description="JSON object with structured KPI targets")
|
kpiTargets: Optional[str] = Field(default=None, description="JSON object with structured KPI targets")
|
||||||
sessionCount: int = Field(default=0)
|
sessionCount: int = Field(default=0)
|
||||||
taskCount: int = Field(default=0)
|
taskCount: int = Field(default=0)
|
||||||
|
|
@ -96,12 +108,27 @@ class TrainingModule(PowerOnModel):
|
||||||
class CoachingSession(PowerOnModel):
|
class CoachingSession(PowerOnModel):
|
||||||
"""A single coaching conversation session within a module."""
|
"""A single coaching conversation session within a module."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
moduleId: str = Field(description="FK to TrainingModule")
|
moduleId: str = Field(
|
||||||
userId: str = Field(description="Owner user ID")
|
description="FK to TrainingModule",
|
||||||
mandateId: str = Field(description="Mandate ID")
|
json_schema_extra={"label": "Trainingsmodul", "fk_target": {"db": "poweron_commcoach", "table": "TrainingModule", "labelField": "title"}},
|
||||||
instanceId: str = Field(description="Feature instance ID")
|
)
|
||||||
|
userId: str = Field(
|
||||||
|
description="Owner user ID",
|
||||||
|
json_schema_extra={"label": "Benutzer", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
|
||||||
|
)
|
||||||
|
mandateId: str = Field(
|
||||||
|
description="Mandate ID",
|
||||||
|
json_schema_extra={"label": "Mandant", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}},
|
||||||
|
)
|
||||||
|
instanceId: str = Field(
|
||||||
|
description="Feature instance ID",
|
||||||
|
json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
|
||||||
|
)
|
||||||
status: CoachingSessionStatus = Field(default=CoachingSessionStatus.ACTIVE)
|
status: CoachingSessionStatus = Field(default=CoachingSessionStatus.ACTIVE)
|
||||||
personaId: Optional[str] = Field(default=None, description="FK to CoachingPersona (Iteration 2)")
|
personaId: Optional[str] = Field(
|
||||||
|
default=None, description="FK to CoachingPersona",
|
||||||
|
json_schema_extra={"label": "Persona", "fk_target": {"db": "poweron_commcoach", "table": "CoachingPersona", "labelField": "label"}},
|
||||||
|
)
|
||||||
summary: Optional[str] = Field(default=None, description="AI-generated session summary")
|
summary: Optional[str] = Field(default=None, description="AI-generated session summary")
|
||||||
coachNotes: Optional[str] = Field(default=None, description="JSON: AI internal notes for continuity")
|
coachNotes: Optional[str] = Field(default=None, description="JSON: AI internal notes for continuity")
|
||||||
compressedHistorySummary: Optional[str] = Field(default=None, description="AI summary of older messages for long sessions")
|
compressedHistorySummary: Optional[str] = Field(default=None, description="AI summary of older messages for long sessions")
|
||||||
|
|
@ -118,9 +145,18 @@ class CoachingSession(PowerOnModel):
|
||||||
class CoachingMessage(PowerOnModel):
|
class CoachingMessage(PowerOnModel):
|
||||||
"""A single message in a coaching session."""
|
"""A single message in a coaching session."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
sessionId: str = Field(description="FK to CoachingSession")
|
sessionId: str = Field(
|
||||||
moduleId: str = Field(description="FK to TrainingModule")
|
description="FK to CoachingSession",
|
||||||
userId: str = Field(description="Owner user ID")
|
json_schema_extra={"label": "Session", "fk_target": {"db": "poweron_commcoach", "table": "CoachingSession", "labelField": None}},
|
||||||
|
)
|
||||||
|
moduleId: str = Field(
|
||||||
|
description="FK to TrainingModule",
|
||||||
|
json_schema_extra={"label": "Trainingsmodul", "fk_target": {"db": "poweron_commcoach", "table": "TrainingModule", "labelField": "title"}},
|
||||||
|
)
|
||||||
|
userId: str = Field(
|
||||||
|
description="Owner user ID",
|
||||||
|
json_schema_extra={"label": "Benutzer", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
|
||||||
|
)
|
||||||
role: CoachingMessageRole = Field(description="Message author role")
|
role: CoachingMessageRole = Field(description="Message author role")
|
||||||
content: str = Field(description="Message content (Markdown)")
|
content: str = Field(description="Message content (Markdown)")
|
||||||
contentType: CoachingMessageContentType = Field(default=CoachingMessageContentType.TEXT)
|
contentType: CoachingMessageContentType = Field(default=CoachingMessageContentType.TEXT)
|
||||||
|
|
@ -131,10 +167,22 @@ class CoachingMessage(PowerOnModel):
|
||||||
class CoachingTask(PowerOnModel):
|
class CoachingTask(PowerOnModel):
|
||||||
"""A task/checklist item assigned within a training module."""
|
"""A task/checklist item assigned within a training module."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
moduleId: str = Field(description="FK to TrainingModule")
|
moduleId: str = Field(
|
||||||
sessionId: Optional[str] = Field(default=None, description="FK to originating session")
|
description="FK to TrainingModule",
|
||||||
userId: str = Field(description="Owner user ID")
|
json_schema_extra={"label": "Trainingsmodul", "fk_target": {"db": "poweron_commcoach", "table": "TrainingModule", "labelField": "title"}},
|
||||||
mandateId: str = Field(description="Mandate ID")
|
)
|
||||||
|
sessionId: Optional[str] = Field(
|
||||||
|
default=None, description="FK to originating session",
|
||||||
|
json_schema_extra={"label": "Session", "fk_target": {"db": "poweron_commcoach", "table": "CoachingSession", "labelField": None}},
|
||||||
|
)
|
||||||
|
userId: str = Field(
|
||||||
|
description="Owner user ID",
|
||||||
|
json_schema_extra={"label": "Benutzer", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
|
||||||
|
)
|
||||||
|
mandateId: str = Field(
|
||||||
|
description="Mandate ID",
|
||||||
|
json_schema_extra={"label": "Mandant", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}},
|
||||||
|
)
|
||||||
title: str = Field(description="Task title")
|
title: str = Field(description="Task title")
|
||||||
description: Optional[str] = Field(default=None)
|
description: Optional[str] = Field(default=None)
|
||||||
status: CoachingTaskStatus = Field(default=CoachingTaskStatus.OPEN)
|
status: CoachingTaskStatus = Field(default=CoachingTaskStatus.OPEN)
|
||||||
|
|
@ -146,10 +194,22 @@ class CoachingTask(PowerOnModel):
|
||||||
class CoachingScore(PowerOnModel):
|
class CoachingScore(PowerOnModel):
|
||||||
"""A competence score for a dimension, recorded after a session."""
|
"""A competence score for a dimension, recorded after a session."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
moduleId: str = Field(description="FK to TrainingModule")
|
moduleId: str = Field(
|
||||||
sessionId: str = Field(description="FK to CoachingSession")
|
description="FK to TrainingModule",
|
||||||
userId: str = Field(description="Owner user ID")
|
json_schema_extra={"label": "Trainingsmodul", "fk_target": {"db": "poweron_commcoach", "table": "TrainingModule", "labelField": "title"}},
|
||||||
mandateId: str = Field(description="Mandate ID")
|
)
|
||||||
|
sessionId: str = Field(
|
||||||
|
description="FK to CoachingSession",
|
||||||
|
json_schema_extra={"label": "Session", "fk_target": {"db": "poweron_commcoach", "table": "CoachingSession", "labelField": None}},
|
||||||
|
)
|
||||||
|
userId: str = Field(
|
||||||
|
description="Owner user ID",
|
||||||
|
json_schema_extra={"label": "Benutzer", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
|
||||||
|
)
|
||||||
|
mandateId: str = Field(
|
||||||
|
description="Mandate ID",
|
||||||
|
json_schema_extra={"label": "Mandant", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}},
|
||||||
|
)
|
||||||
dimension: str = Field(description="e.g. empathy, clarity, assertiveness, listening")
|
dimension: str = Field(description="e.g. empathy, clarity, assertiveness, listening")
|
||||||
score: float = Field(ge=0.0, le=100.0)
|
score: float = Field(ge=0.0, le=100.0)
|
||||||
trend: CoachingScoreTrend = Field(default=CoachingScoreTrend.STABLE)
|
trend: CoachingScoreTrend = Field(default=CoachingScoreTrend.STABLE)
|
||||||
|
|
@ -159,9 +219,18 @@ class CoachingScore(PowerOnModel):
|
||||||
class CoachingUserProfile(PowerOnModel):
|
class CoachingUserProfile(PowerOnModel):
|
||||||
"""Per-user coaching profile and preferences."""
|
"""Per-user coaching profile and preferences."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
userId: str = Field(description="Owner user ID")
|
userId: str = Field(
|
||||||
mandateId: str = Field(description="Mandate ID")
|
description="Owner user ID",
|
||||||
instanceId: str = Field(description="Feature instance ID")
|
json_schema_extra={"label": "Benutzer", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
|
||||||
|
)
|
||||||
|
mandateId: str = Field(
|
||||||
|
description="Mandate ID",
|
||||||
|
json_schema_extra={"label": "Mandant", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}},
|
||||||
|
)
|
||||||
|
instanceId: str = Field(
|
||||||
|
description="Feature instance ID",
|
||||||
|
json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
|
||||||
|
)
|
||||||
dailyReminderTime: Optional[str] = Field(default=None, description="HH:MM format")
|
dailyReminderTime: Optional[str] = Field(default=None, description="HH:MM format")
|
||||||
dailyReminderEnabled: bool = Field(default=False)
|
dailyReminderEnabled: bool = Field(default=False)
|
||||||
emailSummaryEnabled: bool = Field(default=True)
|
emailSummaryEnabled: bool = Field(default=True)
|
||||||
|
|
@ -179,9 +248,18 @@ class CoachingUserProfile(PowerOnModel):
|
||||||
class CoachingPersona(PowerOnModel):
|
class CoachingPersona(PowerOnModel):
|
||||||
"""A roleplay persona for coaching sessions."""
|
"""A roleplay persona for coaching sessions."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
userId: str = Field(description="Owner user ID ('system' for builtins)")
|
userId: str = Field(
|
||||||
mandateId: Optional[str] = Field(default=None)
|
description="Owner user ID ('system' for builtins)",
|
||||||
instanceId: Optional[str] = Field(default=None)
|
json_schema_extra={"label": "Benutzer", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username", "softFk": True}},
|
||||||
|
)
|
||||||
|
mandateId: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
json_schema_extra={"label": "Mandant", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}},
|
||||||
|
)
|
||||||
|
instanceId: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
|
||||||
|
)
|
||||||
key: str = Field(description="Unique key, e.g. 'critical_cfo_f'")
|
key: str = Field(description="Unique key, e.g. 'critical_cfo_f'")
|
||||||
label: str = Field(description="Display label, e.g. 'Kritische CFO'")
|
label: str = Field(description="Display label, e.g. 'Kritische CFO'")
|
||||||
description: str = Field(description="Detailed role description for the AI")
|
description: str = Field(description="Detailed role description for the AI")
|
||||||
|
|
@ -198,9 +276,18 @@ class CoachingPersona(PowerOnModel):
|
||||||
class ModulePersonaMapping(PowerOnModel):
|
class ModulePersonaMapping(PowerOnModel):
|
||||||
"""Maps which personas are available for a specific training module."""
|
"""Maps which personas are available for a specific training module."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
moduleId: str = Field(description="FK to TrainingModule")
|
moduleId: str = Field(
|
||||||
personaId: str = Field(description="FK to CoachingPersona")
|
description="FK to TrainingModule",
|
||||||
instanceId: str = Field(description="Feature instance ID")
|
json_schema_extra={"label": "Trainingsmodul", "fk_target": {"db": "poweron_commcoach", "table": "TrainingModule", "labelField": "title"}},
|
||||||
|
)
|
||||||
|
personaId: str = Field(
|
||||||
|
description="FK to CoachingPersona",
|
||||||
|
json_schema_extra={"label": "Persona", "fk_target": {"db": "poweron_commcoach", "table": "CoachingPersona", "labelField": "label"}},
|
||||||
|
)
|
||||||
|
instanceId: str = Field(
|
||||||
|
description="Feature instance ID",
|
||||||
|
json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SetModulePersonasRequest(BaseModel):
|
class SetModulePersonasRequest(BaseModel):
|
||||||
|
|
@ -214,9 +301,18 @@ class SetModulePersonasRequest(BaseModel):
|
||||||
class CoachingBadge(PowerOnModel):
|
class CoachingBadge(PowerOnModel):
|
||||||
"""An achievement badge awarded to a user."""
|
"""An achievement badge awarded to a user."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
userId: str = Field(description="Owner user ID")
|
userId: str = Field(
|
||||||
mandateId: str = Field(description="Mandate ID")
|
description="Owner user ID",
|
||||||
instanceId: str = Field(description="Feature instance ID")
|
json_schema_extra={"label": "Benutzer", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
|
||||||
|
)
|
||||||
|
mandateId: str = Field(
|
||||||
|
description="Mandate ID",
|
||||||
|
json_schema_extra={"label": "Mandant", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}},
|
||||||
|
)
|
||||||
|
instanceId: str = Field(
|
||||||
|
description="Feature instance ID",
|
||||||
|
json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
|
||||||
|
)
|
||||||
badgeKey: str = Field(description="Badge identifier, e.g. 'streak_7'")
|
badgeKey: str = Field(description="Badge identifier, e.g. 'streak_7'")
|
||||||
awardedAt: Optional[float] = Field(default=None, json_schema_extra={"frontend_type": "timestamp"})
|
awardedAt: Optional[float] = Field(default=None, json_schema_extra={"frontend_type": "timestamp"})
|
||||||
|
|
||||||
|
|
|
||||||
612
modules/features/graphicalEditor/conditionOperators.py
Normal file
612
modules/features/graphicalEditor/conditionOperators.py
Normal file
|
|
@ -0,0 +1,612 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
"""Backend-driven condition operator catalog and value-kind resolution for flow.ifElse."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES
|
||||||
|
from modules.shared.i18nRegistry import resolveText, t
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
VALUE_KINDS = (
|
||||||
|
"string",
|
||||||
|
"number",
|
||||||
|
"boolean",
|
||||||
|
"datetime",
|
||||||
|
"array",
|
||||||
|
"object",
|
||||||
|
"file",
|
||||||
|
"context",
|
||||||
|
"unknown",
|
||||||
|
)
|
||||||
|
|
||||||
|
CONTENT_TYPE_OPTIONS = ("text", "image", "table", "code", "media")
|
||||||
|
OUTPUT_MODE_OPTIONS = ("blob", "lines", "pages", "chunks", "structured")
|
||||||
|
LANGUAGE_OPTIONS = ("de", "en", "fr", "it")
|
||||||
|
MIME_EXAMPLE_OPTIONS = (
|
||||||
|
"application/pdf",
|
||||||
|
"image/png",
|
||||||
|
"image/jpeg",
|
||||||
|
"text/plain",
|
||||||
|
"text/csv",
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
)
|
||||||
|
|
||||||
|
_NODE_BY_TYPE = {n["id"]: n for n in STATIC_NODE_TYPES}
|
||||||
|
|
||||||
|
|
||||||
|
def _op(
|
||||||
|
op_id: str,
|
||||||
|
label_key: str,
|
||||||
|
*,
|
||||||
|
needs_value: bool = True,
|
||||||
|
value_input: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
out: Dict[str, Any] = {"id": op_id, "labelKey": label_key, "needsValue": needs_value}
|
||||||
|
if value_input is not None:
|
||||||
|
out["valueInput"] = value_input
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _build_catalog() -> Dict[str, List[Dict[str, Any]]]:
|
||||||
|
text_in = {"kind": "text"}
|
||||||
|
num_in = {"kind": "number"}
|
||||||
|
date_in = {"kind": "date"}
|
||||||
|
regex_in = {"kind": "regex"}
|
||||||
|
select = lambda opts, kind: {"kind": kind, "options": list(opts)}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"string": [
|
||||||
|
_op("eq", "condition.op.eq", value_input=text_in),
|
||||||
|
_op("neq", "condition.op.neq", value_input=text_in),
|
||||||
|
_op("contains", "condition.op.contains", value_input=text_in),
|
||||||
|
_op("not_contains", "condition.op.not_contains", value_input=text_in),
|
||||||
|
_op("starts_with", "condition.op.starts_with", value_input=text_in),
|
||||||
|
_op("ends_with", "condition.op.ends_with", value_input=text_in),
|
||||||
|
_op("regex", "condition.op.regex", value_input=regex_in),
|
||||||
|
_op("empty", "condition.op.empty", needs_value=False),
|
||||||
|
_op("not_empty", "condition.op.not_empty", needs_value=False),
|
||||||
|
],
|
||||||
|
"number": [
|
||||||
|
_op("eq", "condition.op.eq", value_input=num_in),
|
||||||
|
_op("neq", "condition.op.neq", value_input=num_in),
|
||||||
|
_op("lt", "condition.op.lt", value_input=num_in),
|
||||||
|
_op("lte", "condition.op.lte", value_input=num_in),
|
||||||
|
_op("gt", "condition.op.gt", value_input=num_in),
|
||||||
|
_op("gte", "condition.op.gte", value_input=num_in),
|
||||||
|
_op("empty", "condition.op.empty", needs_value=False),
|
||||||
|
_op("not_empty", "condition.op.not_empty", needs_value=False),
|
||||||
|
],
|
||||||
|
"boolean": [
|
||||||
|
_op("is_true", "condition.op.is_true", needs_value=False),
|
||||||
|
_op("is_false", "condition.op.is_false", needs_value=False),
|
||||||
|
],
|
||||||
|
"datetime": [
|
||||||
|
_op("eq", "condition.op.eq", value_input=date_in),
|
||||||
|
_op("neq", "condition.op.neq", value_input=date_in),
|
||||||
|
_op("before", "condition.op.before", value_input=date_in),
|
||||||
|
_op("after", "condition.op.after", value_input=date_in),
|
||||||
|
_op("empty", "condition.op.empty", needs_value=False),
|
||||||
|
_op("not_empty", "condition.op.not_empty", needs_value=False),
|
||||||
|
],
|
||||||
|
"array": [
|
||||||
|
_op("contains", "condition.op.contains", value_input=text_in),
|
||||||
|
_op("not_contains", "condition.op.not_contains", value_input=text_in),
|
||||||
|
_op("empty", "condition.op.empty", needs_value=False),
|
||||||
|
_op("not_empty", "condition.op.not_empty", needs_value=False),
|
||||||
|
_op("length_eq", "condition.op.length_eq", value_input=num_in),
|
||||||
|
_op("length_gt", "condition.op.length_gt", value_input=num_in),
|
||||||
|
_op("length_lt", "condition.op.length_lt", value_input=num_in),
|
||||||
|
],
|
||||||
|
"object": [
|
||||||
|
_op("empty", "condition.op.empty", needs_value=False),
|
||||||
|
_op("not_empty", "condition.op.not_empty", needs_value=False),
|
||||||
|
],
|
||||||
|
"file": [
|
||||||
|
_op("exists", "condition.op.exists", needs_value=False),
|
||||||
|
_op("not_exists", "condition.op.not_exists", needs_value=False),
|
||||||
|
_op("mime_is", "condition.op.mime_is", value_input=select(MIME_EXAMPLE_OPTIONS, "mime")),
|
||||||
|
_op("mime_contains", "condition.op.mime_contains", value_input=text_in),
|
||||||
|
_op("empty", "condition.op.empty", needs_value=False),
|
||||||
|
_op("not_empty", "condition.op.not_empty", needs_value=False),
|
||||||
|
],
|
||||||
|
"context": [
|
||||||
|
_op(
|
||||||
|
"contains_content",
|
||||||
|
"condition.op.contains_content",
|
||||||
|
value_input=select(CONTENT_TYPE_OPTIONS, "contentType"),
|
||||||
|
),
|
||||||
|
_op("language_is", "condition.op.language_is", value_input=select(LANGUAGE_OPTIONS, "language")),
|
||||||
|
_op(
|
||||||
|
"output_mode_is",
|
||||||
|
"condition.op.output_mode_is",
|
||||||
|
value_input=select(OUTPUT_MODE_OPTIONS, "outputMode"),
|
||||||
|
),
|
||||||
|
_op("file_count_eq", "condition.op.file_count_eq", value_input=num_in),
|
||||||
|
_op("file_count_gt", "condition.op.file_count_gt", value_input=num_in),
|
||||||
|
_op("file_count_lt", "condition.op.file_count_lt", value_input=num_in),
|
||||||
|
_op("slot_count_eq", "condition.op.slot_count_eq", value_input=num_in),
|
||||||
|
_op("slot_count_gt", "condition.op.slot_count_gt", value_input=num_in),
|
||||||
|
_op("slot_count_lt", "condition.op.slot_count_lt", value_input=num_in),
|
||||||
|
_op("regex_on_text", "condition.op.regex_on_text", value_input=regex_in),
|
||||||
|
_op("empty", "condition.op.empty", needs_value=False),
|
||||||
|
_op("not_empty", "condition.op.not_empty", needs_value=False),
|
||||||
|
],
|
||||||
|
"unknown": [
|
||||||
|
_op("eq", "condition.op.eq", value_input=text_in),
|
||||||
|
_op("empty", "condition.op.empty", needs_value=False),
|
||||||
|
_op("not_empty", "condition.op.not_empty", needs_value=False),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
CONDITION_OPERATOR_CATALOG: Dict[str, List[Dict[str, Any]]] = _build_catalog()
|
||||||
|
|
||||||
|
_LABEL_KEYS = {
|
||||||
|
"condition.op.eq": t("ist gleich"),
|
||||||
|
"condition.op.neq": t("ist ungleich"),
|
||||||
|
"condition.op.contains": t("enthält"),
|
||||||
|
"condition.op.not_contains": t("enthält nicht"),
|
||||||
|
"condition.op.starts_with": t("beginnt mit"),
|
||||||
|
"condition.op.ends_with": t("endet mit"),
|
||||||
|
"condition.op.regex": t("Regex-Match"),
|
||||||
|
"condition.op.empty": t("ist leer"),
|
||||||
|
"condition.op.not_empty": t("ist nicht leer"),
|
||||||
|
"condition.op.lt": t("kleiner als"),
|
||||||
|
"condition.op.lte": t("≤"),
|
||||||
|
"condition.op.gt": t("größer als"),
|
||||||
|
"condition.op.gte": t("≥"),
|
||||||
|
"condition.op.is_true": t("ist wahr"),
|
||||||
|
"condition.op.is_false": t("ist falsch"),
|
||||||
|
"condition.op.before": t("vor"),
|
||||||
|
"condition.op.after": t("nach"),
|
||||||
|
"condition.op.exists": t("vorhanden"),
|
||||||
|
"condition.op.not_exists": t("nicht vorhanden"),
|
||||||
|
"condition.op.mime_is": t("MIME-Typ ist"),
|
||||||
|
"condition.op.mime_contains": t("MIME-Typ enthält"),
|
||||||
|
"condition.op.contains_content": t("enthält Inhaltstyp"),
|
||||||
|
"condition.op.language_is": t("Sprache ist"),
|
||||||
|
"condition.op.output_mode_is": t("Ausgabemodus ist"),
|
||||||
|
"condition.op.file_count_eq": t("Dateianzahl gleich"),
|
||||||
|
"condition.op.file_count_gt": t("Dateianzahl größer als"),
|
||||||
|
"condition.op.file_count_lt": t("Dateianzahl kleiner als"),
|
||||||
|
"condition.op.slot_count_eq": t("Slot-Anzahl gleich"),
|
||||||
|
"condition.op.slot_count_gt": t("Slot-Anzahl größer als"),
|
||||||
|
"condition.op.slot_count_lt": t("Slot-Anzahl kleiner als"),
|
||||||
|
"condition.op.regex_on_text": t("Regex auf extrahiertem Text"),
|
||||||
|
"condition.op.length_eq": t("Länge gleich"),
|
||||||
|
"condition.op.length_gt": t("Länge größer als"),
|
||||||
|
"condition.op.length_lt": t("Länge kleiner als"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def localize_operator_catalog(lang: str = "de") -> Dict[str, List[Dict[str, Any]]]:
|
||||||
|
"""Serialize catalog with resolved labels for API consumers."""
|
||||||
|
out: Dict[str, List[Dict[str, Any]]] = {}
|
||||||
|
for kind, ops in CONDITION_OPERATOR_CATALOG.items():
|
||||||
|
loc_ops: List[Dict[str, Any]] = []
|
||||||
|
for op in ops:
|
||||||
|
entry = dict(op)
|
||||||
|
label_key = op.get("labelKey", "")
|
||||||
|
label_src = _LABEL_KEYS.get(str(label_key), label_key)
|
||||||
|
entry["label"] = resolveText(label_src, lang)
|
||||||
|
loc_ops.append(entry)
|
||||||
|
out[kind] = loc_ops
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def catalog_type_to_value_kind(catalog_type: str) -> str:
|
||||||
|
"""Map port-catalog / dataPickOptions type strings to condition valueKind."""
|
||||||
|
ct = (catalog_type or "").strip()
|
||||||
|
if not ct or ct == "Any":
|
||||||
|
return "unknown"
|
||||||
|
low = ct.lower()
|
||||||
|
if low in ("str", "string", "email", "url"):
|
||||||
|
return "string"
|
||||||
|
if low in ("int", "float", "number"):
|
||||||
|
return "number"
|
||||||
|
if low == "bool":
|
||||||
|
return "boolean"
|
||||||
|
if low in ("date", "datetime", "timestamp"):
|
||||||
|
return "datetime"
|
||||||
|
if low.startswith("list[") or low == "list":
|
||||||
|
return "array"
|
||||||
|
if low.startswith("dict") or low == "dict":
|
||||||
|
return "object"
|
||||||
|
if low in ("file", "actiondocument", "fileref"):
|
||||||
|
return "file"
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _paths_equal(a: List[Any], b: List[Any]) -> bool:
|
||||||
|
if len(a) != len(b):
|
||||||
|
return False
|
||||||
|
return all(str(x) == str(y) for x, y in zip(a, b))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_context_producer(node_type: str) -> bool:
|
||||||
|
return node_type in ("context.extractContent", "context.mergeContext", "context.setContext")
|
||||||
|
|
||||||
|
|
||||||
|
def _path_suggests_context(path: List[Any], producer_type: str) -> bool:
|
||||||
|
if not path:
|
||||||
|
return _is_context_producer(producer_type)
|
||||||
|
last = str(path[-1])
|
||||||
|
if last in ("data", "files", "merged", "presentation"):
|
||||||
|
return True
|
||||||
|
if "files" in [str(p) for p in path]:
|
||||||
|
return True
|
||||||
|
if _is_context_producer(producer_type) and path[0] in ("data", "response", "merged"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _path_suggests_file(path: List[Any], producer_type: str) -> bool:
|
||||||
|
path_str = [str(p) for p in path]
|
||||||
|
if producer_type == "input.upload":
|
||||||
|
return True
|
||||||
|
if "file" in path_str or "documents" in path_str or "mimeType" in path_str or "fileName" in path_str:
|
||||||
|
return True
|
||||||
|
if producer_type.startswith("sharepoint.") and "file" in path_str:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_value_kind(graph: Dict[str, Any], ref: Dict[str, Any], *, _skip_upstream: bool = False) -> str:
|
||||||
|
"""Resolve condition valueKind for a DataRef against the workflow graph."""
|
||||||
|
if not isinstance(ref, dict):
|
||||||
|
return "unknown"
|
||||||
|
producer_id = ref.get("nodeId")
|
||||||
|
path = ref.get("path") or []
|
||||||
|
if not isinstance(path, list):
|
||||||
|
path = []
|
||||||
|
if not producer_id:
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
nodes = graph.get("nodes") or []
|
||||||
|
node_by_id = {n.get("id"): n for n in nodes if n.get("id")}
|
||||||
|
producer = node_by_id.get(producer_id) or {}
|
||||||
|
producer_type = str(producer.get("type") or "")
|
||||||
|
|
||||||
|
if _path_suggests_context(path, producer_type):
|
||||||
|
return "context"
|
||||||
|
if _path_suggests_file(path, producer_type):
|
||||||
|
tail = str(path[-1]) if path else ""
|
||||||
|
if tail in ("mimeType", "fileName"):
|
||||||
|
return "string"
|
||||||
|
return "file"
|
||||||
|
|
||||||
|
if not _skip_upstream:
|
||||||
|
from modules.features.graphicalEditor.upstreamPathsService import compute_upstream_paths
|
||||||
|
|
||||||
|
target_id = graph.get("targetNodeId") or producer_id
|
||||||
|
matched_type: Optional[str] = None
|
||||||
|
for entry in compute_upstream_paths(graph, target_id):
|
||||||
|
if entry.get("producerNodeId") != producer_id:
|
||||||
|
continue
|
||||||
|
entry_path = entry.get("path") or []
|
||||||
|
if _paths_equal(list(entry_path), list(path)):
|
||||||
|
matched_type = str(entry.get("type") or "Any")
|
||||||
|
break
|
||||||
|
|
||||||
|
if matched_type is None and path:
|
||||||
|
parent_path = list(path[:-1])
|
||||||
|
for entry in compute_upstream_paths(graph, target_id):
|
||||||
|
if entry.get("producerNodeId") != producer_id:
|
||||||
|
continue
|
||||||
|
if _paths_equal(list(entry.get("path") or []), parent_path):
|
||||||
|
matched_type = str(entry.get("type") or "Any")
|
||||||
|
break
|
||||||
|
|
||||||
|
if matched_type:
|
||||||
|
vk = catalog_type_to_value_kind(matched_type)
|
||||||
|
if vk != "unknown":
|
||||||
|
return vk
|
||||||
|
|
||||||
|
if producer_type in ("trigger.form", "input.form") and path and str(path[0]) == "payload":
|
||||||
|
return "string"
|
||||||
|
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_condition_meta(
|
||||||
|
graph: Dict[str, Any],
|
||||||
|
ref: Dict[str, Any],
|
||||||
|
*,
|
||||||
|
lang: str = "de",
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Return valueKind and localized operators for a DataRef."""
|
||||||
|
value_kind = resolve_value_kind(graph, ref)
|
||||||
|
catalog = localize_operator_catalog(lang)
|
||||||
|
operators = catalog.get(value_kind) or catalog.get("unknown", [])
|
||||||
|
return {"valueKind": value_kind, "operators": operators}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_empty_value(val: Any) -> bool:
|
||||||
|
if val is None:
|
||||||
|
return True
|
||||||
|
if val == "":
|
||||||
|
return True
|
||||||
|
if isinstance(val, (list, dict, tuple)) and len(val) == 0:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_datetime(val: Any) -> Optional[datetime]:
|
||||||
|
if val is None:
|
||||||
|
return None
|
||||||
|
if hasattr(val, "timestamp"):
|
||||||
|
return val # type: ignore[return-value]
|
||||||
|
s = str(val).strip()
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
for fmt in ("%Y-%m-%d", "%d.%m.%Y", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S"):
|
||||||
|
try:
|
||||||
|
return datetime.strptime(s, fmt)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(s.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _compare_dates(left: Any, right: Any, op) -> bool:
|
||||||
|
try:
|
||||||
|
a, b = _parse_datetime(left), _parse_datetime(right)
|
||||||
|
if a is None or b is None:
|
||||||
|
return False
|
||||||
|
return op(a, b)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("_compare_dates failed: left=%s right=%s: %s", left, right, e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _file_exists(val: Any) -> bool:
|
||||||
|
if val is None:
|
||||||
|
return False
|
||||||
|
if isinstance(val, dict):
|
||||||
|
return bool(val.get("url") or val.get("name") or val.get("fileId"))
|
||||||
|
if isinstance(val, str):
|
||||||
|
return len(val.strip()) > 0
|
||||||
|
return bool(val)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_mime(val: Any) -> str:
|
||||||
|
if isinstance(val, dict):
|
||||||
|
return str(val.get("mimeType") or val.get("contentType") or "")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _presentation_envelopes_from_value(val: Any) -> List[Dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
from modules.workflows.methods.methodContext.actions.extractContent import (
|
||||||
|
normalize_presentation_envelopes,
|
||||||
|
)
|
||||||
|
|
||||||
|
return normalize_presentation_envelopes(val)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("_presentation_envelopes_from_value: %s", e)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _joined_text_from_context(val: Any) -> str:
|
||||||
|
try:
|
||||||
|
from modules.workflows.methods.methodContext.actions.extractContent import (
|
||||||
|
joined_text_from_extract_node_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
return joined_text_from_extract_node_data(val) or ""
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_presentation_parts(envelope: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||||
|
parts: List[Dict[str, Any]] = []
|
||||||
|
files = envelope.get("files") or {}
|
||||||
|
if not isinstance(files, dict):
|
||||||
|
return parts
|
||||||
|
for bucket in files.values():
|
||||||
|
if not isinstance(bucket, dict):
|
||||||
|
continue
|
||||||
|
data = bucket.get("data")
|
||||||
|
mode = str(bucket.get("outputMode") or "").strip().lower()
|
||||||
|
if mode == "blob" and isinstance(data, str):
|
||||||
|
from modules.workflows.methods.methodContext.actions.extractContent import parse_blob_data_segments
|
||||||
|
|
||||||
|
parts.extend(parse_blob_data_segments(data))
|
||||||
|
continue
|
||||||
|
if isinstance(data, list):
|
||||||
|
for slot in data:
|
||||||
|
if isinstance(slot, dict):
|
||||||
|
parts.append(slot)
|
||||||
|
elif isinstance(data, dict):
|
||||||
|
parts.append(data)
|
||||||
|
return parts
|
||||||
|
|
||||||
|
|
||||||
|
def _context_has_content_type(val: Any, content_type: str) -> bool:
|
||||||
|
target = (content_type or "").strip().lower()
|
||||||
|
if not target:
|
||||||
|
return False
|
||||||
|
for env in _presentation_envelopes_from_value(val):
|
||||||
|
for part in _iter_presentation_parts(env):
|
||||||
|
tg = (part.get("typeGroup") or part.get("contentType") or "").strip().lower()
|
||||||
|
if target == "media":
|
||||||
|
if tg in ("image", "media", "video", "audio"):
|
||||||
|
return True
|
||||||
|
elif tg == target:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _guess_language_code(text: str) -> str:
|
||||||
|
sample = (text or "").strip()[:2000]
|
||||||
|
if not sample:
|
||||||
|
return ""
|
||||||
|
de_hits = len(re.findall(r"\b(der|die|das|und|ist|nicht|mit)\b", sample, re.I))
|
||||||
|
en_hits = len(re.findall(r"\b(the|and|is|not|with|for)\b", sample, re.I))
|
||||||
|
fr_hits = len(re.findall(r"\b(le|la|les|et|est|pas|avec)\b", sample, re.I))
|
||||||
|
it_hits = len(re.findall(r"\b(il|la|lo|gli|e|non|con)\b", sample, re.I))
|
||||||
|
scores = {"de": de_hits, "en": en_hits, "fr": fr_hits, "it": it_hits}
|
||||||
|
best = max(scores, key=scores.get)
|
||||||
|
return best if scores[best] > 0 else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _context_language(val: Any) -> str:
|
||||||
|
if isinstance(val, dict):
|
||||||
|
meta = val.get("_meta")
|
||||||
|
if isinstance(meta, dict):
|
||||||
|
lang = meta.get("language") or meta.get("detectedLanguage")
|
||||||
|
if lang:
|
||||||
|
return str(lang).strip().lower()[:2]
|
||||||
|
text = _joined_text_from_context(val)
|
||||||
|
return _guess_language_code(text)
|
||||||
|
|
||||||
|
|
||||||
|
def _context_output_mode(val: Any) -> str:
|
||||||
|
for env in _presentation_envelopes_from_value(val):
|
||||||
|
om = env.get("outputMode")
|
||||||
|
if om:
|
||||||
|
return str(om)
|
||||||
|
files = env.get("files") or {}
|
||||||
|
if isinstance(files, dict):
|
||||||
|
for bucket in files.values():
|
||||||
|
if isinstance(bucket, dict) and bucket.get("outputMode"):
|
||||||
|
return str(bucket.get("outputMode"))
|
||||||
|
if isinstance(val, dict) and val.get("outputMode"):
|
||||||
|
return str(val.get("outputMode"))
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _context_file_count(val: Any) -> int:
|
||||||
|
for env in _presentation_envelopes_from_value(val):
|
||||||
|
fo = env.get("fileOrder")
|
||||||
|
if isinstance(fo, list):
|
||||||
|
return len(fo)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _context_slot_count(val: Any) -> int:
|
||||||
|
total = 0
|
||||||
|
for env in _presentation_envelopes_from_value(val):
|
||||||
|
files = env.get("files") or {}
|
||||||
|
if not isinstance(files, dict):
|
||||||
|
continue
|
||||||
|
for bucket in files.values():
|
||||||
|
if not isinstance(bucket, dict):
|
||||||
|
continue
|
||||||
|
data = bucket.get("data")
|
||||||
|
if isinstance(data, list):
|
||||||
|
total += len(data)
|
||||||
|
elif data is not None:
|
||||||
|
total += 1
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def apply_condition_operator(left: Any, operator: str, right: Any, value_kind: Optional[str] = None) -> bool:
|
||||||
|
"""Evaluate a single condition operator against a resolved left-hand value."""
|
||||||
|
op = (operator or "eq").strip()
|
||||||
|
vk = (value_kind or "unknown").strip()
|
||||||
|
|
||||||
|
if op == "eq":
|
||||||
|
if vk == "datetime":
|
||||||
|
return _compare_dates(left, right, lambda a, b: a == b)
|
||||||
|
return left == right
|
||||||
|
if op == "neq":
|
||||||
|
if vk == "datetime":
|
||||||
|
return _compare_dates(left, right, lambda a, b: a != b)
|
||||||
|
return left != right
|
||||||
|
if op in ("lt", "lte", "gt", "gte"):
|
||||||
|
try:
|
||||||
|
l = float(left) if left is not None else 0
|
||||||
|
r = float(right) if right is not None else 0
|
||||||
|
if op == "lt":
|
||||||
|
return l < r
|
||||||
|
if op == "lte":
|
||||||
|
return l <= r
|
||||||
|
if op == "gt":
|
||||||
|
return l > r
|
||||||
|
return l >= r
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
if op == "contains":
|
||||||
|
if isinstance(left, (list, tuple, set)):
|
||||||
|
return right in left or any(str(right) == str(x) for x in left)
|
||||||
|
return right is not None and str(right) in str(left or "")
|
||||||
|
if op == "not_contains":
|
||||||
|
if isinstance(left, (list, tuple, set)):
|
||||||
|
return right not in left and not any(str(right) == str(x) for x in left)
|
||||||
|
return right is None or str(right) not in str(left or "")
|
||||||
|
if op == "starts_with":
|
||||||
|
return right is not None and str(left or "").startswith(str(right))
|
||||||
|
if op == "ends_with":
|
||||||
|
return right is not None and str(left or "").endswith(str(right))
|
||||||
|
if op == "regex":
|
||||||
|
try:
|
||||||
|
return bool(re.search(str(right or ""), str(left or "")))
|
||||||
|
except re.error as e:
|
||||||
|
logger.warning("regex operator failed: %s", e)
|
||||||
|
return False
|
||||||
|
if op == "empty":
|
||||||
|
return _is_empty_value(left)
|
||||||
|
if op == "not_empty":
|
||||||
|
return not _is_empty_value(left)
|
||||||
|
if op == "is_true":
|
||||||
|
return bool(left)
|
||||||
|
if op == "is_false":
|
||||||
|
return not bool(left)
|
||||||
|
if op == "before":
|
||||||
|
return _compare_dates(left, right, lambda a, b: a < b)
|
||||||
|
if op == "after":
|
||||||
|
return _compare_dates(left, right, lambda a, b: a > b)
|
||||||
|
if op == "exists":
|
||||||
|
return _file_exists(left)
|
||||||
|
if op == "not_exists":
|
||||||
|
return not _file_exists(left)
|
||||||
|
if op == "mime_is":
|
||||||
|
return _extract_mime(left).lower() == str(right or "").lower()
|
||||||
|
if op == "mime_contains":
|
||||||
|
return str(right or "").lower() in _extract_mime(left).lower()
|
||||||
|
if op in ("length_eq", "length_gt", "length_lt"):
|
||||||
|
try:
|
||||||
|
length = len(left) if left is not None else 0
|
||||||
|
r = int(float(right))
|
||||||
|
if op == "length_eq":
|
||||||
|
return length == r
|
||||||
|
if op == "length_gt":
|
||||||
|
return length > r
|
||||||
|
return length < r
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
if op == "contains_content":
|
||||||
|
return _context_has_content_type(left, str(right or ""))
|
||||||
|
if op == "language_is":
|
||||||
|
return _context_language(left) == str(right or "").strip().lower()[:2]
|
||||||
|
if op == "output_mode_is":
|
||||||
|
return _context_output_mode(left) == str(right or "")
|
||||||
|
if op == "file_count_eq":
|
||||||
|
return _context_file_count(left) == int(float(right))
|
||||||
|
if op == "file_count_gt":
|
||||||
|
return _context_file_count(left) > int(float(right))
|
||||||
|
if op == "file_count_lt":
|
||||||
|
return _context_file_count(left) < int(float(right))
|
||||||
|
if op == "slot_count_eq":
|
||||||
|
return _context_slot_count(left) == int(float(right))
|
||||||
|
if op == "slot_count_gt":
|
||||||
|
return _context_slot_count(left) > int(float(right))
|
||||||
|
if op == "slot_count_lt":
|
||||||
|
return _context_slot_count(left) < int(float(right))
|
||||||
|
if op == "regex_on_text":
|
||||||
|
try:
|
||||||
|
text = _joined_text_from_context(left)
|
||||||
|
return bool(re.search(str(right or ""), text))
|
||||||
|
except re.error as e:
|
||||||
|
logger.warning("regex_on_text failed: %s", e)
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
@ -83,7 +83,60 @@ def normalize_invocations_list(items: Optional[List[Any]]) -> List[Dict[str, Any
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
# Schedule / cron: wire an external job runner (APScheduler, Celery, system cron) to call
|
_NODE_TYPE_TO_KIND = {
|
||||||
|
"trigger.manual": "manual",
|
||||||
|
"trigger.form": "form",
|
||||||
|
"trigger.schedule": "schedule",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def invocations_synced_with_graph(
|
||||||
|
graph: Optional[Dict[str, Any]],
|
||||||
|
stored_invocations: Optional[List[Any]],
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Derive primary invocation (index 0) from the first start node in ``graph``.
|
||||||
|
|
||||||
|
If the graph has no start node, only non-primary stored invocations are kept
|
||||||
|
(no injected default). Document order in ``nodes`` defines which start wins.
|
||||||
|
"""
|
||||||
|
from modules.workflows.automation2.graphUtils import getTriggerNodes
|
||||||
|
|
||||||
|
g = graph if isinstance(graph, dict) else {}
|
||||||
|
nodes = g.get("nodes") or []
|
||||||
|
stored = list(stored_invocations or [])
|
||||||
|
rest: List[Dict[str, Any]] = []
|
||||||
|
for raw in stored[1:]:
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
rest.append(normalize_invocation_entry(raw))
|
||||||
|
|
||||||
|
triggers = getTriggerNodes(nodes)
|
||||||
|
if not triggers:
|
||||||
|
return rest
|
||||||
|
|
||||||
|
node = triggers[0]
|
||||||
|
nt = str(node.get("type", "")).strip()
|
||||||
|
kind = _NODE_TYPE_TO_KIND.get(nt, "manual")
|
||||||
|
nid = node.get("id")
|
||||||
|
if not nid:
|
||||||
|
nid = str(uuid.uuid4())
|
||||||
|
raw_title = node.get("title") or node.get("label") or "Start"
|
||||||
|
|
||||||
|
old_primary = stored[0] if stored and isinstance(stored[0], dict) else {}
|
||||||
|
config: Dict[str, Any] = {}
|
||||||
|
if isinstance(old_primary.get("config"), dict) and old_primary.get("kind") == kind:
|
||||||
|
config = dict(old_primary["config"])
|
||||||
|
desc = old_primary.get("description") if isinstance(old_primary.get("description"), dict) else {}
|
||||||
|
|
||||||
|
primary_raw: Dict[str, Any] = {
|
||||||
|
"id": str(nid),
|
||||||
|
"kind": kind,
|
||||||
|
"enabled": True,
|
||||||
|
"title": raw_title,
|
||||||
|
"description": desc,
|
||||||
|
"config": config,
|
||||||
|
}
|
||||||
|
primary = normalize_invocation_entry(primary_raw)
|
||||||
|
return [primary] + rest
|
||||||
# POST .../execute with entryPointId set to a schedule entry — no separate in-process scheduler here yet.
|
# POST .../execute with entryPointId set to a schedule entry — no separate in-process scheduler here yet.
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import (
|
||||||
AutoRun as Automation2WorkflowRun,
|
AutoRun as Automation2WorkflowRun,
|
||||||
AutoTask as Automation2HumanTask,
|
AutoTask as Automation2HumanTask,
|
||||||
)
|
)
|
||||||
from modules.features.graphicalEditor.entryPoints import normalize_invocations_list
|
from modules.features.graphicalEditor.entryPoints import invocations_synced_with_graph
|
||||||
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||||
from modules.shared.configuration import APP_CONFIG
|
from modules.shared.configuration import APP_CONFIG
|
||||||
from modules.shared.dbRegistry import registerDatabase
|
from modules.shared.dbRegistry import registerDatabase
|
||||||
|
|
@ -109,7 +109,7 @@ def getAllWorkflowsForScheduling() -> List[Dict[str, Any]]:
|
||||||
if r.get("active") is False:
|
if r.get("active") is False:
|
||||||
continue
|
continue
|
||||||
wf = dict(r)
|
wf = dict(r)
|
||||||
wf["invocations"] = normalize_invocations_list(wf.get("invocations"))
|
wf["invocations"] = invocations_synced_with_graph(wf.get("graph") or {}, wf.get("invocations"))
|
||||||
invocations = wf.get("invocations") or []
|
invocations = wf.get("invocations") or []
|
||||||
primary = invocations[0] if invocations else {}
|
primary = invocations[0] if invocations else {}
|
||||||
if not isinstance(primary, dict):
|
if not isinstance(primary, dict):
|
||||||
|
|
@ -204,7 +204,7 @@ class GraphicalEditorObjects:
|
||||||
)
|
)
|
||||||
rows = [dict(r) for r in records] if records else []
|
rows = [dict(r) for r in records] if records else []
|
||||||
for wf in rows:
|
for wf in rows:
|
||||||
wf["invocations"] = normalize_invocations_list(wf.get("invocations"))
|
wf["invocations"] = invocations_synced_with_graph(wf.get("graph") or {}, wf.get("invocations"))
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
def getWorkflow(self, workflowId: str) -> Optional[Dict[str, Any]]:
|
def getWorkflow(self, workflowId: str) -> Optional[Dict[str, Any]]:
|
||||||
|
|
@ -221,7 +221,7 @@ class GraphicalEditorObjects:
|
||||||
if not records:
|
if not records:
|
||||||
return None
|
return None
|
||||||
wf = dict(records[0])
|
wf = dict(records[0])
|
||||||
wf["invocations"] = normalize_invocations_list(wf.get("invocations"))
|
wf["invocations"] = invocations_synced_with_graph(wf.get("graph") or {}, wf.get("invocations"))
|
||||||
return wf
|
return wf
|
||||||
|
|
||||||
def createWorkflow(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
def createWorkflow(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
|
@ -234,10 +234,10 @@ class GraphicalEditorObjects:
|
||||||
data["targetFeatureInstanceId"] = self.featureInstanceId
|
data["targetFeatureInstanceId"] = self.featureInstanceId
|
||||||
if "active" not in data or data.get("active") is None:
|
if "active" not in data or data.get("active") is None:
|
||||||
data["active"] = True
|
data["active"] = True
|
||||||
data["invocations"] = normalize_invocations_list(data.get("invocations"))
|
data["invocations"] = invocations_synced_with_graph(data.get("graph") or {}, data.get("invocations"))
|
||||||
created = self.db.recordCreate(Automation2Workflow, data)
|
created = self.db.recordCreate(Automation2Workflow, data)
|
||||||
out = dict(created)
|
out = dict(created)
|
||||||
out["invocations"] = normalize_invocations_list(out.get("invocations"))
|
out["invocations"] = invocations_synced_with_graph(out.get("graph") or {}, out.get("invocations"))
|
||||||
try:
|
try:
|
||||||
from modules.shared.callbackRegistry import callbackRegistry
|
from modules.shared.callbackRegistry import callbackRegistry
|
||||||
callbackRegistry.trigger(_CALLBACK_WORKFLOW_CHANGED)
|
callbackRegistry.trigger(_CALLBACK_WORKFLOW_CHANGED)
|
||||||
|
|
@ -252,11 +252,15 @@ class GraphicalEditorObjects:
|
||||||
return None
|
return None
|
||||||
data.pop("mandateId", None)
|
data.pop("mandateId", None)
|
||||||
data.pop("featureInstanceId", None)
|
data.pop("featureInstanceId", None)
|
||||||
if "invocations" in data:
|
if "graph" in data or "invocations" in data:
|
||||||
data["invocations"] = normalize_invocations_list(data.get("invocations"))
|
g = data["graph"] if "graph" in data else existing.get("graph")
|
||||||
|
if not isinstance(g, dict):
|
||||||
|
g = {}
|
||||||
|
inv = data["invocations"] if "invocations" in data else existing.get("invocations")
|
||||||
|
data["invocations"] = invocations_synced_with_graph(g, inv)
|
||||||
updated = self.db.recordModify(Automation2Workflow, workflowId, data)
|
updated = self.db.recordModify(Automation2Workflow, workflowId, data)
|
||||||
out = dict(updated)
|
out = dict(updated)
|
||||||
out["invocations"] = normalize_invocations_list(out.get("invocations"))
|
out["invocations"] = invocations_synced_with_graph(out.get("graph") or {}, out.get("invocations"))
|
||||||
try:
|
try:
|
||||||
from modules.shared.callbackRegistry import callbackRegistry
|
from modules.shared.callbackRegistry import callbackRegistry
|
||||||
callbackRegistry.trigger(_CALLBACK_WORKFLOW_CHANGED)
|
callbackRegistry.trigger(_CALLBACK_WORKFLOW_CHANGED)
|
||||||
|
|
|
||||||
|
|
@ -32,11 +32,6 @@ UI_OBJECTS = [
|
||||||
"label": t("Editor", context="UI"),
|
"label": t("Editor", context="UI"),
|
||||||
"meta": {"area": "editor"}
|
"meta": {"area": "editor"}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"objectKey": "ui.feature.graphicalEditor.workflows",
|
|
||||||
"label": t("Workflows", context="UI"),
|
|
||||||
"meta": {"area": "workflows"}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"objectKey": "ui.feature.graphicalEditor.templates",
|
"objectKey": "ui.feature.graphicalEditor.templates",
|
||||||
"label": t("Vorlagen", context="UI"),
|
"label": t("Vorlagen", context="UI"),
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,131 @@
|
||||||
|
|
||||||
from modules.shared.i18nRegistry import t
|
from modules.shared.i18nRegistry import t
|
||||||
|
|
||||||
|
from modules.features.graphicalEditor.nodeDefinitions.contextPickerHelp import (
|
||||||
|
CONTEXT_BUILDER_PARAM_DESCRIPTION,
|
||||||
|
)
|
||||||
|
from modules.features.graphicalEditor.nodeDefinitions.flow import (
|
||||||
|
CONTEXT_ENVELOPE_DATA_PICK_OPTIONS,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Shared authoritative DataPicker paths (same handover idea as ``context.extractContent`` outputPorts).
|
||||||
|
ACTION_RESULT_DATA_PICK_OPTIONS = [
|
||||||
|
{
|
||||||
|
"path": ["documents", 0, "documentData"],
|
||||||
|
"pickerLabel": t("Gesamter Inhalt"),
|
||||||
|
"detail": t(
|
||||||
|
"Strukturiertes Handover als JSON inklusive aller Textteile "
|
||||||
|
"und Verweisen auf ausgelagerte Bilder."
|
||||||
|
),
|
||||||
|
"recommended": True,
|
||||||
|
"type": "Any",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ["response"],
|
||||||
|
"pickerLabel": t("Nur Text"),
|
||||||
|
"detail": t("Verketteter Klartext aus allen erkannten Textteilen."),
|
||||||
|
"recommended": True,
|
||||||
|
"type": "str",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ["imageDocumentsOnly"],
|
||||||
|
"pickerLabel": t("Nur Bilder"),
|
||||||
|
"detail": t("Nur die extrahierten Bilddokumente als Liste, ohne JSON-Handover."),
|
||||||
|
"recommended": False,
|
||||||
|
"type": "List[ActionDocument]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ["documents"],
|
||||||
|
"pickerLabel": t("Alle Dateitypen"),
|
||||||
|
"detail": t("Alle Ausgabedokumente nacheinander: JSON-Handover und Bilder."),
|
||||||
|
"recommended": False,
|
||||||
|
"type": "List[ActionDocument]",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
AI_RESULT_DATA_PICK_OPTIONS = [
|
||||||
|
*CONTEXT_ENVELOPE_DATA_PICK_OPTIONS,
|
||||||
|
{
|
||||||
|
"path": ["documents", 0, "documentData"],
|
||||||
|
"pickerLabel": t("Gesamter Inhalt"),
|
||||||
|
"detail": t(
|
||||||
|
"Hauptausgabedatei oder strukturierter Inhalt von ``documents[0]`` "
|
||||||
|
"(z. B. erzeugtes Dokument, JSON-Handover)."
|
||||||
|
),
|
||||||
|
"recommended": False,
|
||||||
|
"type": "Any",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ["response"],
|
||||||
|
"pickerLabel": t("Nur Text"),
|
||||||
|
"detail": t("Modell-Antwort als reiner Fließtext (ohne eingebettete Bildbytes)."),
|
||||||
|
"recommended": False,
|
||||||
|
"type": "str",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ["imageDocumentsOnly"],
|
||||||
|
"pickerLabel": t("Nur Bilder"),
|
||||||
|
"detail": t("Nur Bild-Dokumente aus ``documents`` (ohne erstes Nicht-Bild-Artefakt, falls gesetzt)."),
|
||||||
|
"recommended": False,
|
||||||
|
"type": "List[ActionDocument]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ["documents"],
|
||||||
|
"pickerLabel": t("Alle Ausgabedateien"),
|
||||||
|
"detail": t("Alle Dokumente der KI-Antwort: erzeugte Dateien, Bilder, Anhänge."),
|
||||||
|
"recommended": False,
|
||||||
|
"type": "List[Document]",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
DOCUMENT_LIST_DATA_PICK_OPTIONS = [
|
||||||
|
{
|
||||||
|
"path": ["documents"],
|
||||||
|
"pickerLabel": t("Alle Dokumente"),
|
||||||
|
"detail": t("Die vollständige Dokumentenliste."),
|
||||||
|
"recommended": True,
|
||||||
|
"type": "List[Document]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ["documents", 0],
|
||||||
|
"pickerLabel": t("Erstes Dokument"),
|
||||||
|
"detail": t("Metadaten und Pfade des ersten Listeneintrags."),
|
||||||
|
"recommended": False,
|
||||||
|
"type": "Document",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ["count"],
|
||||||
|
"pickerLabel": t("Anzahl"),
|
||||||
|
"detail": t("Anzahl der Dokumente."),
|
||||||
|
"recommended": False,
|
||||||
|
"type": "int",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
CONSOLIDATE_RESULT_DATA_PICK_OPTIONS = [
|
||||||
|
{
|
||||||
|
"path": ["result"],
|
||||||
|
"pickerLabel": t("Konsolidiertes Ergebnis"),
|
||||||
|
"detail": t("Text oder Struktur nach Konsolidierung."),
|
||||||
|
"recommended": True,
|
||||||
|
"type": "Any",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ["mode"],
|
||||||
|
"pickerLabel": t("Modus"),
|
||||||
|
"detail": t("Verwendeter Konsolidierungsmodus."),
|
||||||
|
"recommended": False,
|
||||||
|
"type": "str",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ["count"],
|
||||||
|
"pickerLabel": t("Anzahl"),
|
||||||
|
"detail": t("Anzahl zusammengeführter Elemente."),
|
||||||
|
"recommended": False,
|
||||||
|
"type": "int",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
_AI_COMMON_PARAMS = [
|
_AI_COMMON_PARAMS = [
|
||||||
{"name": "requireNeutralization", "type": "bool", "required": False,
|
{"name": "requireNeutralization", "type": "bool", "required": False,
|
||||||
"frontendType": "checkbox", "default": False,
|
"frontendType": "checkbox", "default": False,
|
||||||
|
|
@ -25,12 +150,11 @@ AI_NODES = [
|
||||||
"frontendOptions": {"options": ["txt", "json", "md", "csv", "xml", "html", "pdf", "docx", "xlsx", "pptx", "png", "jpg"]},
|
"frontendOptions": {"options": ["txt", "json", "md", "csv", "xml", "html", "pdf", "docx", "xlsx", "pptx", "png", "jpg"]},
|
||||||
"description": t("Ausgabeformat"), "default": "txt"},
|
"description": t("Ausgabeformat"), "default": "txt"},
|
||||||
{"name": "documentList", "type": "DocumentList", "required": False, "frontendType": "hidden",
|
{"name": "documentList", "type": "DocumentList", "required": False, "frontendType": "hidden",
|
||||||
"description": t("Dokumente aus vorherigen Schritten"), "default": ""},
|
"description": t("Dokumente aus vorherigen Schritten"), "default": "",
|
||||||
|
"graphInherit": {"port": 0, "kind": "documentListWire"}},
|
||||||
{"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder",
|
{"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder",
|
||||||
"description": t("Daten aus vorherigen Schritten"), "default": ""},
|
"description": CONTEXT_BUILDER_PARAM_DESCRIPTION, "default": "",
|
||||||
{"name": "documentTheme", "type": "str", "required": False, "frontendType": "select",
|
"graphInherit": {"port": 0, "kind": "primaryTextRef"}},
|
||||||
"frontendOptions": {"options": ["general", "finance", "legal", "technical", "hr"]},
|
|
||||||
"description": t("Dokument-Thema (Style-Hinweis fuer den Renderer)"), "default": "general"},
|
|
||||||
{"name": "simpleMode", "type": "bool", "required": False, "frontendType": "checkbox",
|
{"name": "simpleMode", "type": "bool", "required": False, "frontendType": "checkbox",
|
||||||
"description": t("Einfacher Modus"), "default": True},
|
"description": t("Einfacher Modus"), "default": True},
|
||||||
] + _AI_COMMON_PARAMS,
|
] + _AI_COMMON_PARAMS,
|
||||||
|
|
@ -39,7 +163,8 @@ AI_NODES = [
|
||||||
"inputPorts": {0: {"accepts": [
|
"inputPorts": {0: {"accepts": [
|
||||||
"FormPayload", "DocumentList", "AiResult", "TextResult", "Transit", "LoopItem", "ActionResult",
|
"FormPayload", "DocumentList", "AiResult", "TextResult", "Transit", "LoopItem", "ActionResult",
|
||||||
]}},
|
]}},
|
||||||
"outputPorts": {0: {"schema": "AiResult"}},
|
"outputPorts": {0: {"schema": "AiResult", "dataPickOptions": AI_RESULT_DATA_PICK_OPTIONS}},
|
||||||
|
"paramMappers": ["aiPromptLegacyAlias"],
|
||||||
"meta": {"icon": "mdi-robot", "color": "#9C27B0", "usesAi": True},
|
"meta": {"icon": "mdi-robot", "color": "#9C27B0", "usesAi": True},
|
||||||
"_method": "ai",
|
"_method": "ai",
|
||||||
"_action": "process",
|
"_action": "process",
|
||||||
|
|
@ -53,16 +178,18 @@ AI_NODES = [
|
||||||
{"name": "prompt", "type": "str", "required": True, "frontendType": "textarea",
|
{"name": "prompt", "type": "str", "required": True, "frontendType": "textarea",
|
||||||
"description": t("Recherche-Anfrage")},
|
"description": t("Recherche-Anfrage")},
|
||||||
{"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder",
|
{"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder",
|
||||||
"description": t("Daten aus vorherigen Schritten"), "default": ""},
|
"description": CONTEXT_BUILDER_PARAM_DESCRIPTION, "default": "",
|
||||||
|
"graphInherit": {"port": 0, "kind": "primaryTextRef"}},
|
||||||
{"name": "documentList", "type": "DocumentList", "required": False, "frontendType": "hidden",
|
{"name": "documentList", "type": "DocumentList", "required": False, "frontendType": "hidden",
|
||||||
"description": t("Dokumente aus vorherigen Schritten"), "default": ""},
|
"description": t("Dokumente aus vorherigen Schritten"), "default": "",
|
||||||
|
"graphInherit": {"port": 0, "kind": "documentListWire"}},
|
||||||
] + _AI_COMMON_PARAMS,
|
] + _AI_COMMON_PARAMS,
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": [
|
"inputPorts": {0: {"accepts": [
|
||||||
"FormPayload", "Transit", "AiResult", "DocumentList", "ActionResult", "LoopItem", "TextResult",
|
"FormPayload", "Transit", "AiResult", "DocumentList", "ActionResult", "LoopItem", "TextResult",
|
||||||
]}},
|
]}},
|
||||||
"outputPorts": {0: {"schema": "AiResult"}},
|
"outputPorts": {0: {"schema": "AiResult", "dataPickOptions": AI_RESULT_DATA_PICK_OPTIONS}},
|
||||||
"meta": {"icon": "mdi-magnify", "color": "#9C27B0", "usesAi": True},
|
"meta": {"icon": "mdi-magnify", "color": "#9C27B0", "usesAi": True},
|
||||||
"_method": "ai",
|
"_method": "ai",
|
||||||
"_action": "webResearch",
|
"_action": "webResearch",
|
||||||
|
|
@ -74,15 +201,22 @@ AI_NODES = [
|
||||||
"description": t("Dokumentinhalt zusammenfassen"),
|
"description": t("Dokumentinhalt zusammenfassen"),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "documentList", "type": "DocumentList", "required": True, "frontendType": "dataRef",
|
{"name": "documentList", "type": "DocumentList", "required": True, "frontendType": "dataRef",
|
||||||
"description": t("Dokumente aus vorherigen Schritten")},
|
"description": t("Dokumente aus vorherigen Schritten"),
|
||||||
|
"graphInherit": {"port": 0, "kind": "documentListWire"}},
|
||||||
|
{"name": "resultType", "type": "str", "required": False, "frontendType": "select",
|
||||||
|
"frontendOptions": {"options": ["txt", "json", "md", "csv", "xml", "html", "pdf", "docx", "xlsx", "pptx", "png", "jpg"]},
|
||||||
|
"description": t("Ausgabeformat"), "default": "txt"},
|
||||||
{"name": "summaryLength", "type": "str", "required": False, "frontendType": "select",
|
{"name": "summaryLength", "type": "str", "required": False, "frontendType": "select",
|
||||||
"frontendOptions": {"options": ["brief", "medium", "detailed"]},
|
"frontendOptions": {"options": ["brief", "medium", "detailed"]},
|
||||||
"description": t("Kurz, mittel oder ausführlich"), "default": "medium"},
|
"description": t("Kurz, mittel oder ausführlich"), "default": "medium"},
|
||||||
|
{"name": "folderId", "type": "str", "required": False, "frontendType": "userFileFolder",
|
||||||
|
"description": t("Zielordner in Meine Dateien"),
|
||||||
|
"default": ""},
|
||||||
] + _AI_COMMON_PARAMS,
|
] + _AI_COMMON_PARAMS,
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["DocumentList", "Transit", "LoopItem"]}},
|
"inputPorts": {0: {"accepts": ["DocumentList", "Transit", "LoopItem"]}},
|
||||||
"outputPorts": {0: {"schema": "AiResult"}},
|
"outputPorts": {0: {"schema": "AiResult", "dataPickOptions": AI_RESULT_DATA_PICK_OPTIONS}},
|
||||||
"meta": {"icon": "mdi-file-document-outline", "color": "#9C27B0", "usesAi": True},
|
"meta": {"icon": "mdi-file-document-outline", "color": "#9C27B0", "usesAi": True},
|
||||||
"_method": "ai",
|
"_method": "ai",
|
||||||
"_action": "summarizeDocument",
|
"_action": "summarizeDocument",
|
||||||
|
|
@ -94,14 +228,21 @@ AI_NODES = [
|
||||||
"description": t("Dokument in Zielsprache übersetzen"),
|
"description": t("Dokument in Zielsprache übersetzen"),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "documentList", "type": "DocumentList", "required": True, "frontendType": "dataRef",
|
{"name": "documentList", "type": "DocumentList", "required": True, "frontendType": "dataRef",
|
||||||
"description": t("Dokumente aus vorherigen Schritten")},
|
"description": t("Dokumente aus vorherigen Schritten"),
|
||||||
|
"graphInherit": {"port": 0, "kind": "documentListWire"}},
|
||||||
|
{"name": "resultType", "type": "str", "required": False, "frontendType": "select",
|
||||||
|
"frontendOptions": {"options": ["txt", "json", "md", "csv", "xml", "html", "pdf", "docx", "xlsx", "pptx", "png", "jpg"]},
|
||||||
|
"description": t("Ausgabeformat"), "default": "txt"},
|
||||||
{"name": "targetLanguage", "type": "str", "required": True, "frontendType": "text",
|
{"name": "targetLanguage", "type": "str", "required": True, "frontendType": "text",
|
||||||
"description": t("Zielsprache (z.B. de, en, French)")},
|
"description": t("Zielsprache (z.B. de, en, French)")},
|
||||||
|
{"name": "folderId", "type": "str", "required": False, "frontendType": "userFileFolder",
|
||||||
|
"description": t("Zielordner in Meine Dateien"),
|
||||||
|
"default": ""},
|
||||||
] + _AI_COMMON_PARAMS,
|
] + _AI_COMMON_PARAMS,
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["DocumentList", "Transit", "LoopItem"]}},
|
"inputPorts": {0: {"accepts": ["DocumentList", "Transit", "LoopItem"]}},
|
||||||
"outputPorts": {0: {"schema": "AiResult"}},
|
"outputPorts": {0: {"schema": "AiResult", "dataPickOptions": AI_RESULT_DATA_PICK_OPTIONS}},
|
||||||
"meta": {"icon": "mdi-translate", "color": "#9C27B0", "usesAi": True},
|
"meta": {"icon": "mdi-translate", "color": "#9C27B0", "usesAi": True},
|
||||||
"_method": "ai",
|
"_method": "ai",
|
||||||
"_action": "translateDocument",
|
"_action": "translateDocument",
|
||||||
|
|
@ -113,15 +254,19 @@ AI_NODES = [
|
||||||
"description": t("Dokument in anderes Format konvertieren"),
|
"description": t("Dokument in anderes Format konvertieren"),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "documentList", "type": "DocumentList", "required": True, "frontendType": "dataRef",
|
{"name": "documentList", "type": "DocumentList", "required": True, "frontendType": "dataRef",
|
||||||
"description": t("Dokumente aus vorherigen Schritten")},
|
"description": t("Dokumente aus vorherigen Schritten"),
|
||||||
|
"graphInherit": {"port": 0, "kind": "documentListWire"}},
|
||||||
{"name": "targetFormat", "type": "str", "required": True, "frontendType": "select",
|
{"name": "targetFormat", "type": "str", "required": True, "frontendType": "select",
|
||||||
"frontendOptions": {"options": ["docx", "pdf", "xlsx", "csv", "txt", "html", "json", "md"]},
|
"frontendOptions": {"options": ["docx", "pdf", "xlsx", "csv", "txt", "html", "json", "md"]},
|
||||||
"description": t("Zielformat")},
|
"description": t("Zielformat")},
|
||||||
|
{"name": "folderId", "type": "str", "required": False, "frontendType": "userFileFolder",
|
||||||
|
"description": t("Zielordner in Meine Dateien"),
|
||||||
|
"default": ""},
|
||||||
] + _AI_COMMON_PARAMS,
|
] + _AI_COMMON_PARAMS,
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["DocumentList", "Transit", "LoopItem"]}},
|
"inputPorts": {0: {"accepts": ["DocumentList", "Transit", "LoopItem"]}},
|
||||||
"outputPorts": {0: {"schema": "DocumentList"}},
|
"outputPorts": {0: {"schema": "DocumentList", "dataPickOptions": DOCUMENT_LIST_DATA_PICK_OPTIONS}},
|
||||||
"meta": {"icon": "mdi-file-convert", "color": "#9C27B0", "usesAi": True},
|
"meta": {"icon": "mdi-file-convert", "color": "#9C27B0", "usesAi": True},
|
||||||
"_method": "ai",
|
"_method": "ai",
|
||||||
"_action": "convertDocument",
|
"_action": "convertDocument",
|
||||||
|
|
@ -142,17 +287,22 @@ AI_NODES = [
|
||||||
{"name": "documentType", "type": "str", "required": False, "frontendType": "select",
|
{"name": "documentType", "type": "str", "required": False, "frontendType": "select",
|
||||||
"frontendOptions": {"options": ["letter", "memo", "proposal", "contract", "report", "email"]},
|
"frontendOptions": {"options": ["letter", "memo", "proposal", "contract", "report", "email"]},
|
||||||
"description": t("Dokumentart (Inhaltshinweis fuer die KI)"), "default": "proposal"},
|
"description": t("Dokumentart (Inhaltshinweis fuer die KI)"), "default": "proposal"},
|
||||||
|
{"name": "folderId", "type": "str", "required": False, "frontendType": "userFileFolder",
|
||||||
|
"description": t("Zielordner in Meine Dateien"),
|
||||||
|
"default": ""},
|
||||||
{"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder",
|
{"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder",
|
||||||
"description": t("Daten aus vorherigen Schritten"), "default": ""},
|
"description": CONTEXT_BUILDER_PARAM_DESCRIPTION, "default": "",
|
||||||
|
"graphInherit": {"port": 0, "kind": "primaryTextRef"}},
|
||||||
{"name": "documentList", "type": "DocumentList", "required": False, "frontendType": "hidden",
|
{"name": "documentList", "type": "DocumentList", "required": False, "frontendType": "hidden",
|
||||||
"description": t("Dokumente aus vorherigen Schritten"), "default": ""},
|
"description": t("Dokumente aus vorherigen Schritten"), "default": "",
|
||||||
|
"graphInherit": {"port": 0, "kind": "documentListWire"}},
|
||||||
] + _AI_COMMON_PARAMS,
|
] + _AI_COMMON_PARAMS,
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": [
|
"inputPorts": {0: {"accepts": [
|
||||||
"FormPayload", "Transit", "AiResult", "DocumentList", "ActionResult", "LoopItem", "TextResult",
|
"FormPayload", "Transit", "AiResult", "DocumentList", "ActionResult", "LoopItem", "TextResult",
|
||||||
]}},
|
]}},
|
||||||
"outputPorts": {0: {"schema": "DocumentList"}},
|
"outputPorts": {0: {"schema": "DocumentList", "dataPickOptions": DOCUMENT_LIST_DATA_PICK_OPTIONS}},
|
||||||
"meta": {"icon": "mdi-file-plus", "color": "#9C27B0", "usesAi": True},
|
"meta": {"icon": "mdi-file-plus", "color": "#9C27B0", "usesAi": True},
|
||||||
"_method": "ai",
|
"_method": "ai",
|
||||||
"_action": "generateDocument",
|
"_action": "generateDocument",
|
||||||
|
|
@ -168,17 +318,22 @@ AI_NODES = [
|
||||||
{"name": "resultType", "type": "str", "required": False, "frontendType": "select",
|
{"name": "resultType", "type": "str", "required": False, "frontendType": "select",
|
||||||
"frontendOptions": {"options": ["py", "js", "ts", "html", "java", "cpp", "txt", "json", "csv", "xml"]},
|
"frontendOptions": {"options": ["py", "js", "ts", "html", "java", "cpp", "txt", "json", "csv", "xml"]},
|
||||||
"description": t("Datei-Endung der erzeugten Code-Datei"), "default": "py"},
|
"description": t("Datei-Endung der erzeugten Code-Datei"), "default": "py"},
|
||||||
|
{"name": "folderId", "type": "str", "required": False, "frontendType": "userFileFolder",
|
||||||
|
"description": t("Zielordner in Meine Dateien"),
|
||||||
|
"default": ""},
|
||||||
{"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder",
|
{"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder",
|
||||||
"description": t("Daten aus vorherigen Schritten"), "default": ""},
|
"description": CONTEXT_BUILDER_PARAM_DESCRIPTION, "default": "",
|
||||||
|
"graphInherit": {"port": 0, "kind": "primaryTextRef"}},
|
||||||
{"name": "documentList", "type": "DocumentList", "required": False, "frontendType": "hidden",
|
{"name": "documentList", "type": "DocumentList", "required": False, "frontendType": "hidden",
|
||||||
"description": t("Dokumente aus vorherigen Schritten"), "default": ""},
|
"description": t("Dokumente aus vorherigen Schritten"), "default": "",
|
||||||
|
"graphInherit": {"port": 0, "kind": "documentListWire"}},
|
||||||
] + _AI_COMMON_PARAMS,
|
] + _AI_COMMON_PARAMS,
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": [
|
"inputPorts": {0: {"accepts": [
|
||||||
"FormPayload", "Transit", "AiResult", "DocumentList", "ActionResult", "LoopItem", "TextResult",
|
"FormPayload", "Transit", "AiResult", "DocumentList", "ActionResult", "LoopItem", "TextResult",
|
||||||
]}},
|
]}},
|
||||||
"outputPorts": {0: {"schema": "AiResult"}},
|
"outputPorts": {0: {"schema": "AiResult", "dataPickOptions": AI_RESULT_DATA_PICK_OPTIONS}},
|
||||||
"meta": {"icon": "mdi-code-tags", "color": "#9C27B0", "usesAi": True},
|
"meta": {"icon": "mdi-code-tags", "color": "#9C27B0", "usesAi": True},
|
||||||
"_method": "ai",
|
"_method": "ai",
|
||||||
"_action": "generateCode",
|
"_action": "generateCode",
|
||||||
|
|
@ -198,7 +353,7 @@ AI_NODES = [
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["AggregateResult", "Transit"]}},
|
"inputPorts": {0: {"accepts": ["AggregateResult", "Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "ConsolidateResult"}},
|
"outputPorts": {0: {"schema": "ConsolidateResult", "dataPickOptions": CONSOLIDATE_RESULT_DATA_PICK_OPTIONS}},
|
||||||
"meta": {"icon": "mdi-table-merge-cells", "color": "#9C27B0", "usesAi": True},
|
"meta": {"icon": "mdi-table-merge-cells", "color": "#9C27B0", "usesAi": True},
|
||||||
"_method": "ai",
|
"_method": "ai",
|
||||||
"_action": "consolidate",
|
"_action": "consolidate",
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,63 @@
|
||||||
|
|
||||||
from modules.shared.i18nRegistry import t
|
from modules.shared.i18nRegistry import t
|
||||||
|
|
||||||
|
from modules.features.graphicalEditor.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS
|
||||||
|
|
||||||
|
TASK_LIST_DATA_PICK_OPTIONS = [
|
||||||
|
{
|
||||||
|
"path": ["tasks"],
|
||||||
|
"pickerLabel": t("Alle Aufgaben"),
|
||||||
|
"detail": t("Vollständige Aufgabenliste."),
|
||||||
|
"recommended": True,
|
||||||
|
"type": "List[TaskItem]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ["tasks", 0],
|
||||||
|
"pickerLabel": t("Erste Aufgabe"),
|
||||||
|
"detail": t("Erstes Listenelement."),
|
||||||
|
"recommended": False,
|
||||||
|
"type": "TaskItem",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ["count"],
|
||||||
|
"pickerLabel": t("Anzahl"),
|
||||||
|
"detail": t("Anzahl der Aufgaben."),
|
||||||
|
"recommended": False,
|
||||||
|
"type": "int",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ["listId"],
|
||||||
|
"pickerLabel": t("Listen-ID"),
|
||||||
|
"detail": t("ClickUp-Listen-Kontext, falls gesetzt."),
|
||||||
|
"recommended": False,
|
||||||
|
"type": "str",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
TASK_RESULT_DATA_PICK_OPTIONS = [
|
||||||
|
{
|
||||||
|
"path": ["success"],
|
||||||
|
"pickerLabel": t("Erfolg"),
|
||||||
|
"detail": t("Ob der API-Aufruf erfolgreich war."),
|
||||||
|
"recommended": True,
|
||||||
|
"type": "bool",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ["taskId"],
|
||||||
|
"pickerLabel": t("Aufgaben-ID"),
|
||||||
|
"detail": t("ID der betroffenen Aufgabe."),
|
||||||
|
"recommended": True,
|
||||||
|
"type": "str",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ["task"],
|
||||||
|
"pickerLabel": t("Aufgabendaten"),
|
||||||
|
"detail": t("Vollständiges Task-Objekt (Dict)."),
|
||||||
|
"recommended": True,
|
||||||
|
"type": "Dict",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
CLICKUP_NODES = [
|
CLICKUP_NODES = [
|
||||||
{
|
{
|
||||||
"id": "clickup.searchTasks",
|
"id": "clickup.searchTasks",
|
||||||
|
|
@ -33,7 +90,7 @@ CLICKUP_NODES = [
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "TaskList"}},
|
"outputPorts": {0: {"schema": "TaskList", "dataPickOptions": TASK_LIST_DATA_PICK_OPTIONS}},
|
||||||
"meta": {"icon": "mdi-magnify", "color": "#7B68EE", "usesAi": False},
|
"meta": {"icon": "mdi-magnify", "color": "#7B68EE", "usesAi": False},
|
||||||
"_method": "clickup",
|
"_method": "clickup",
|
||||||
"_action": "searchTasks",
|
"_action": "searchTasks",
|
||||||
|
|
@ -58,7 +115,7 @@ CLICKUP_NODES = [
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "TaskList"}},
|
"outputPorts": {0: {"schema": "TaskList", "dataPickOptions": TASK_LIST_DATA_PICK_OPTIONS}},
|
||||||
"meta": {"icon": "mdi-format-list-bulleted", "color": "#7B68EE", "usesAi": False},
|
"meta": {"icon": "mdi-format-list-bulleted", "color": "#7B68EE", "usesAi": False},
|
||||||
"_method": "clickup",
|
"_method": "clickup",
|
||||||
"_action": "listTasks",
|
"_action": "listTasks",
|
||||||
|
|
@ -80,7 +137,7 @@ CLICKUP_NODES = [
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "TaskResult"}},
|
"outputPorts": {0: {"schema": "TaskResult", "dataPickOptions": TASK_RESULT_DATA_PICK_OPTIONS}},
|
||||||
"meta": {"icon": "mdi-file-document-outline", "color": "#7B68EE", "usesAi": False},
|
"meta": {"icon": "mdi-file-document-outline", "color": "#7B68EE", "usesAi": False},
|
||||||
"_method": "clickup",
|
"_method": "clickup",
|
||||||
"_action": "getTask",
|
"_action": "getTask",
|
||||||
|
|
@ -124,7 +181,7 @@ CLICKUP_NODES = [
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "TaskResult"}},
|
"outputPorts": {0: {"schema": "TaskResult", "dataPickOptions": TASK_RESULT_DATA_PICK_OPTIONS}},
|
||||||
"meta": {"icon": "mdi-plus-circle-outline", "color": "#7B68EE", "usesAi": False},
|
"meta": {"icon": "mdi-plus-circle-outline", "color": "#7B68EE", "usesAi": False},
|
||||||
"_method": "clickup",
|
"_method": "clickup",
|
||||||
"_action": "createTask",
|
"_action": "createTask",
|
||||||
|
|
@ -148,7 +205,8 @@ CLICKUP_NODES = [
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["TaskResult", "Transit"]}},
|
"inputPorts": {0: {"accepts": ["TaskResult", "Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "TaskResult"}},
|
"outputPorts": {0: {"schema": "TaskResult", "dataPickOptions": TASK_RESULT_DATA_PICK_OPTIONS}},
|
||||||
|
"paramMappers": ["clickupTaskUpdateMerge"],
|
||||||
"meta": {"icon": "mdi-pencil-outline", "color": "#7B68EE", "usesAi": False},
|
"meta": {"icon": "mdi-pencil-outline", "color": "#7B68EE", "usesAi": False},
|
||||||
"_method": "clickup",
|
"_method": "clickup",
|
||||||
"_action": "updateTask",
|
"_action": "updateTask",
|
||||||
|
|
@ -174,7 +232,7 @@ CLICKUP_NODES = [
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["DocumentList", "Transit"]}},
|
"inputPorts": {0: {"accepts": ["DocumentList", "Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "ActionResult"}},
|
"outputPorts": {0: {"schema": "ActionResult", "dataPickOptions": ACTION_RESULT_DATA_PICK_OPTIONS}},
|
||||||
"meta": {"icon": "mdi-attachment", "color": "#7B68EE", "usesAi": False},
|
"meta": {"icon": "mdi-attachment", "color": "#7B68EE", "usesAi": False},
|
||||||
"_method": "clickup",
|
"_method": "clickup",
|
||||||
"_action": "uploadAttachment",
|
"_action": "uploadAttachment",
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,447 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
# Context node definitions — structural extraction without AI.
|
# Context node definitions — structural extraction without AI plus
|
||||||
|
# generic key/value, merge, filter and transform helpers.
|
||||||
|
|
||||||
from modules.shared.i18nRegistry import t
|
from modules.shared.i18nRegistry import t
|
||||||
|
|
||||||
|
from modules.features.graphicalEditor.nodeDefinitions.flow import (
|
||||||
|
CONTEXT_ENVELOPE_DATA_PICK_OPTIONS,
|
||||||
|
CONTEXT_MERGE_ACTION_RESULT_DATA_PICK_OPTIONS,
|
||||||
|
)
|
||||||
|
|
||||||
|
_CONTEXT_INPUT_SCHEMAS = [
|
||||||
|
"Transit",
|
||||||
|
"ActionResult",
|
||||||
|
"AiResult",
|
||||||
|
"MergeResult",
|
||||||
|
"FormPayload",
|
||||||
|
"DocumentList",
|
||||||
|
"EmailList",
|
||||||
|
"TaskList",
|
||||||
|
"FileList",
|
||||||
|
"LoopItem",
|
||||||
|
"UdmDocument",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
CONTEXT_NODES = [
|
CONTEXT_NODES = [
|
||||||
{
|
{
|
||||||
"id": "context.extractContent",
|
"id": "context.extractContent",
|
||||||
"category": "context",
|
"category": "context",
|
||||||
"label": t("Inhalt extrahieren"),
|
"label": t("Inhalt extrahieren"),
|
||||||
"description": t("Dokumentstruktur extrahieren ohne KI (Seiten, Abschnitte, Bilder, Tabellen)"),
|
"description": t(
|
||||||
|
"Extrahiert Inhalt ohne KI. ``data`` ist die gewählte **Presentation** (`fileOrder`, `files` je "
|
||||||
|
"Quelldatei, kanonisches `data` pro Bucket) plus ``_meta`` (Quellnamen, Operation, Persist). "
|
||||||
|
"``response`` für diesen Knoten bleibt leer — kein zusätzlicher Fließtext. "
|
||||||
|
"``imageDocumentsOnly`` enthält Bilder über persistierte Artefakte."
|
||||||
|
),
|
||||||
|
"injectRunContext": True,
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "documentList", "type": "str", "required": True, "frontendType": "hidden",
|
{"name": "documentList", "type": "str", "required": True, "frontendType": "hidden",
|
||||||
"description": t("Dokumentenliste (via Wire oder DataRef)"), "default": ""},
|
"description": t("Dokumentenliste (via Wire oder DataRef)"), "default": "",
|
||||||
{"name": "extractionOptions", "type": "object", "required": False, "frontendType": "json",
|
"graphInherit": {"port": 0, "kind": "documentListWire"}},
|
||||||
|
{
|
||||||
|
"name": "contentFilter",
|
||||||
|
"type": "str",
|
||||||
|
"required": False,
|
||||||
|
"frontendType": "select",
|
||||||
|
"frontendOptions": {
|
||||||
|
"options": [
|
||||||
|
{"value": "all", "label": t("Alles (Text, Tabellen, Bilder)")},
|
||||||
|
{"value": "textOnly", "label": t("Nur Text und Tabellen")},
|
||||||
|
{"value": "imagesOnly", "label": t("Nur Bilder")},
|
||||||
|
{"value": "noImages", "label": t("Alles ausser Bilder")},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"default": "all",
|
||||||
"description": t(
|
"description": t(
|
||||||
"Extraktions-Optionen (JSON), z.B. {\"includeImages\": true, \"includeTables\": true, "
|
"Welche extrahierten Parts weiterverwendet werden. "
|
||||||
"\"outputDetail\": \"full\"}"),
|
"all = alle Typgruppen inkl. Bilder; "
|
||||||
"default": {}},
|
"textOnly = ausschliesslich Text-, Tabellen- und Struktur-Parts; "
|
||||||
|
"imagesOnly = ausschliesslich Bild-Parts; "
|
||||||
|
"noImages = alle Parts ausser Bildern (weiter als textOnly: "
|
||||||
|
"auch kuenftige Nicht-Bild-Typen bleiben erhalten)."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "outputMode",
|
||||||
|
"type": "str",
|
||||||
|
"required": False,
|
||||||
|
"frontendType": "select",
|
||||||
|
"frontendOptions": {
|
||||||
|
"options": [
|
||||||
|
{"value": "blob", "label": t("Ausgabe: ein Textblock (blob)")},
|
||||||
|
{"value": "lines", "label": t("Ausgabe: Zeilen / Segmente")},
|
||||||
|
{"value": "pages", "label": t("Ausgabe: nach Seite (z. B. PDF)")},
|
||||||
|
{"value": "chunks", "label": t("Ausgabe: Chunks (fixe Groesse)")},
|
||||||
|
{"value": "structured", "label": t("Ausgabe: Parts als Liste")},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"default": "lines",
|
||||||
|
"description": t(
|
||||||
|
"Wie das Ergebnis unter ``files`` strukturiert wird (``outputMode``: blob, lines, …)."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "splitBy",
|
||||||
|
"type": "str",
|
||||||
|
"required": False,
|
||||||
|
"frontendType": "select",
|
||||||
|
"frontendOptions": {
|
||||||
|
"options": [
|
||||||
|
{"value": "newline", "label": t("Trennen: Zeilenumbruch")},
|
||||||
|
{"value": "paragraph", "label": t("Trennen: Absatz (Leerzeilen)")},
|
||||||
|
{"value": "sentence", "label": t("Trennen: Saetze (heuristisch)")},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"default": "newline",
|
||||||
|
"description": t(
|
||||||
|
"Gueltig fuer ``outputMode`` lines und chunks: welches Trennzeichen der "
|
||||||
|
"zusammenhaengende Klartext zuerst erhaelt."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "chunkSizeUnit",
|
||||||
|
"type": "str",
|
||||||
|
"required": False,
|
||||||
|
"frontendType": "select",
|
||||||
|
"frontendOptions": {
|
||||||
|
"dependsOn": "outputMode",
|
||||||
|
"showWhen": ["chunks"],
|
||||||
|
"options": [
|
||||||
|
{"value": "tokens", "label": t("Chunk-Groesse: Tokens (approx. ~4 Zeichen)")},
|
||||||
|
{"value": "characters", "label": t("Chunk-Groesse: Zeichen")},
|
||||||
|
{"value": "words", "label": t("Chunk-Groesse: Woerter")},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"default": "tokens",
|
||||||
|
"description": t("Einheit fuer ``chunkSize`` / ``chunkOverlap`` wenn outputMode chunks."),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "chunkSize",
|
||||||
|
"type": "str",
|
||||||
|
"required": False,
|
||||||
|
"frontendType": "select",
|
||||||
|
"frontendOptions": {
|
||||||
|
"dependsOn": "outputMode",
|
||||||
|
"showWhen": ["chunks"],
|
||||||
|
"options": [
|
||||||
|
{"value": "256", "label": "256"},
|
||||||
|
{"value": "500", "label": "500"},
|
||||||
|
{"value": "1000", "label": "1000"},
|
||||||
|
{"value": "2000", "label": "2000"},
|
||||||
|
{"value": "4000", "label": "4000"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"default": "500",
|
||||||
|
"description": t("Zielgroesse pro Chunk (siehe chunkSizeUnit). Nur bei outputMode chunks."),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "chunkOverlap",
|
||||||
|
"type": "str",
|
||||||
|
"required": False,
|
||||||
|
"frontendType": "select",
|
||||||
|
"frontendOptions": {
|
||||||
|
"dependsOn": "outputMode",
|
||||||
|
"showWhen": ["chunks"],
|
||||||
|
"options": [
|
||||||
|
{"value": "0", "label": "0"},
|
||||||
|
{"value": "25", "label": "25"},
|
||||||
|
{"value": "50", "label": "50"},
|
||||||
|
{"value": "100", "label": "100"},
|
||||||
|
{"value": "200", "label": "200"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"default": "0",
|
||||||
|
"description": t("Ueberlappung zwischen aufeinanderfolgenden Chunks (gleiche Einheit wie chunkSize)."),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "filterEmptyLines",
|
||||||
|
"type": "str",
|
||||||
|
"required": False,
|
||||||
|
"frontendType": "select",
|
||||||
|
"frontendOptions": {
|
||||||
|
"options": [
|
||||||
|
{"value": "true", "label": t("Ja")},
|
||||||
|
{"value": "false", "label": t("Nein")},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"default": "true",
|
||||||
|
"description": t("Leere bzw. nur-Whitespace-Segmente bei lines/chunks entfernen."),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "trimWhitespace",
|
||||||
|
"type": "str",
|
||||||
|
"required": False,
|
||||||
|
"frontendType": "select",
|
||||||
|
"frontendOptions": {
|
||||||
|
"options": [
|
||||||
|
{"value": "true", "label": t("Ja")},
|
||||||
|
{"value": "false", "label": t("Nein")},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"default": "true",
|
||||||
|
"description": t("Fuehrende und nachfolgende Leerzeichen pro Segment trimmen."),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "includeLineNumbers",
|
||||||
|
"type": "str",
|
||||||
|
"required": False,
|
||||||
|
"frontendType": "select",
|
||||||
|
"frontendOptions": {
|
||||||
|
"options": [
|
||||||
|
{"value": "true", "label": t("Ja")},
|
||||||
|
{"value": "false", "label": t("Nein")},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"default": "false",
|
||||||
|
"description": t("Bei lines: jedem Eintrag eine Zeilennummer (1-based) zuweisen."),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "includeMetadata",
|
||||||
|
"type": "str",
|
||||||
|
"required": False,
|
||||||
|
"frontendType": "select",
|
||||||
|
"frontendOptions": {
|
||||||
|
"options": [
|
||||||
|
{"value": "true", "label": t("Ja")},
|
||||||
|
{"value": "false", "label": t("Nein")},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"default": "false",
|
||||||
|
"description": t("Dateiname und einfache Offsets bei lines/chunks/pages an Eintraege haengen."),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "csvHeaderRow",
|
||||||
|
"type": "str",
|
||||||
|
"required": False,
|
||||||
|
"frontendType": "select",
|
||||||
|
"frontendOptions": {
|
||||||
|
"options": [
|
||||||
|
{"value": "true", "label": t("Ja")},
|
||||||
|
{"value": "false", "label": t("Nein")},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"default": "true",
|
||||||
|
"description": t(
|
||||||
|
"Bei CSV-Dateien: erste Zeile als Spaltenkoepfe interpretieren "
|
||||||
|
"und ``csvRows`` als Liste von Objekten in ``presentation`` schreiben."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "pdfExtractMode",
|
||||||
|
"type": "str",
|
||||||
|
"required": False,
|
||||||
|
"frontendType": "select",
|
||||||
|
"frontendOptions": {
|
||||||
|
"options": [
|
||||||
|
{"value": "text", "label": t("PDF/Parts: Text & Tabellen (keine Bild-Parts)")},
|
||||||
|
{"value": "tables", "label": t("PDF/Parts: nur Tabellen-Parts")},
|
||||||
|
{"value": "images", "label": t("PDF/Parts: nur Bild-Parts")},
|
||||||
|
{"value": "all", "label": t("PDF/Parts: alle Typgruppen")},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"default": "all",
|
||||||
|
"description": t(
|
||||||
|
"Filtert fuer die Presentation-Schicht nach typeGroup/MIME "
|
||||||
|
"(gilt fuer alle Dokumenttypen analog, nicht nur PDF). "
|
||||||
|
"Passt zum Inhaltsfilter „Alles“; „Text & Tabellen“ blendet Bild-Parts in der Presentation aus."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "markdownPreserveFormatting",
|
||||||
|
"type": "str",
|
||||||
|
"required": False,
|
||||||
|
"frontendType": "select",
|
||||||
|
"frontendOptions": {
|
||||||
|
"options": [
|
||||||
|
{"value": "true", "label": t("Markdown beibehalten")},
|
||||||
|
{"value": "false", "label": t("zu vereinfachtem Klartext reduzieren")},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"default": "false",
|
||||||
|
"description": t(
|
||||||
|
"Bei text/markdown-Parts: leichte Entfernung von Markup-Zeichen wenn false."
|
||||||
|
),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["DocumentList", "Transit"]}},
|
"inputPorts": {0: {"accepts": ["DocumentList", "Transit", "LoopItem"]}},
|
||||||
"outputPorts": {0: {"schema": "UdmDocument"}},
|
"outputPorts": {
|
||||||
|
0: {
|
||||||
|
"schema": "ActionResult",
|
||||||
|
# Override the schema-level primaryTextRef path: ``response`` is intentionally
|
||||||
|
# empty for this node; downstream nodes with ``primaryTextRef`` should resolve to
|
||||||
|
# the full presentation object under ``data``.
|
||||||
|
"primaryTextRefPath": ["data"],
|
||||||
|
# Authoritative DataPicker paths (same idea as ``parameters`` for configuration).
|
||||||
|
# Frontend uses only this list — no schema expansion merge for this port.
|
||||||
|
"dataPickOptions": [
|
||||||
|
{
|
||||||
|
"path": ["data"],
|
||||||
|
"pickerLabel": t("Vollständiges data-Objekt"),
|
||||||
|
"detail": t(
|
||||||
|
"Presentation-Envelope (``schemaVersion``, ``kind``, ``fileOrder``, ``files``) "
|
||||||
|
"plus ``_meta`` (``operationRef``, ``sourceFileNames``, Persist)."
|
||||||
|
),
|
||||||
|
"recommended": True,
|
||||||
|
"type": "Any",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ["data", "files"],
|
||||||
|
"pickerLabel": t("Alle Dateibuckets"),
|
||||||
|
"detail": t("Map Dateischlüssel → Bucket (Zeilenliste, Blob, CSV-Tabelle bei structured, …)."),
|
||||||
|
"recommended": False,
|
||||||
|
"type": "Any",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ["imageDocumentsOnly"],
|
||||||
|
"pickerLabel": t("Nur Bilder"),
|
||||||
|
"detail": t(
|
||||||
|
"Nur die Bilder aus der Extraktion (persistierte Artefakte bzw. inline), "
|
||||||
|
"als Liste fuer nachgelagerte Schritte."
|
||||||
|
),
|
||||||
|
"recommended": False,
|
||||||
|
"type": "List[ActionDocument]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ["data", "_meta"],
|
||||||
|
"pickerLabel": t("Metadaten (_meta)"),
|
||||||
|
"detail": t(
|
||||||
|
"``operationRef``, ``sourceFileNames``, Presentation-Parameter, Liste persistierter Bilder."
|
||||||
|
),
|
||||||
|
"recommended": False,
|
||||||
|
"type": "Any",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
"meta": {"icon": "mdi-file-tree-outline", "color": "#00897B", "usesAi": False},
|
"meta": {"icon": "mdi-file-tree-outline", "color": "#00897B", "usesAi": False},
|
||||||
"_method": "context",
|
"_method": "context",
|
||||||
"_action": "extractContent",
|
"_action": "extractContent",
|
||||||
|
# Executor behaviour flags — drives actionNodeExecutor without hardcoded type checks.
|
||||||
|
"skipUnifiedPresentation": True,
|
||||||
|
"clearResponse": True,
|
||||||
|
"imageDocumentsFromExtractData": True,
|
||||||
|
"popDocumentsFromOutput": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "context.mergeContext",
|
||||||
|
"category": "context",
|
||||||
|
"label": t("Kontext zusammenführen"),
|
||||||
|
"description": t(
|
||||||
|
"Führt eine Liste von Ergebnissen zu einem einzigen Kontext zusammen. "
|
||||||
|
"Ausgabe ``data``: versionierter Umschlag (``schemaVersion``, ``kind``), Felder wie "
|
||||||
|
"``merged`` / ``first`` / ``response`` sowie ``_meta``. "
|
||||||
|
"Wähle als Datenquelle die Option Alle Schleifen-Ergebnisse einer Schleife, "
|
||||||
|
"um alle Iterationsergebnisse in einem Datensatz zu vereinen."
|
||||||
|
),
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "dataSource",
|
||||||
|
"type": "Any",
|
||||||
|
"required": True,
|
||||||
|
"frontendType": "dataRef",
|
||||||
|
"description": t(
|
||||||
|
"Datenquelle: Liste von Einträgen zum Zusammenführen "
|
||||||
|
"(z. B. Schleife → Alle Schleifen-Ergebnisse)"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"inputs": 1,
|
||||||
|
"outputs": 1,
|
||||||
|
"inputPorts": {0: {"accepts": _CONTEXT_INPUT_SCHEMAS}},
|
||||||
|
"outputPorts": {
|
||||||
|
0: {"schema": "ActionResult", "dataPickOptions": CONTEXT_MERGE_ACTION_RESULT_DATA_PICK_OPTIONS}
|
||||||
|
},
|
||||||
|
"injectUpstreamPayload": True,
|
||||||
|
# Same contract as transformContext: picker paths like ``merged`` / ``first`` must match
|
||||||
|
# ``nodeOutputs`` (see actionNodeExecutor ``surfaceDataAsTopLevel``); merge payloads live in ``data``.
|
||||||
|
"surfaceDataAsTopLevel": True,
|
||||||
|
"meta": {"icon": "mdi-call-merge", "color": "#7B1FA2", "usesAi": False},
|
||||||
|
"_method": "context",
|
||||||
|
"_action": "mergeContext",
|
||||||
|
# Image documents live on ``data.merged.imageDocumentsOnly`` (accumulated across
|
||||||
|
# iterations) rather than the top-level ``documents`` list which is always empty.
|
||||||
|
"imageDocumentsFromMerged": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "context.transformContext",
|
||||||
|
"category": "context",
|
||||||
|
"label": t("Kontext transformieren"),
|
||||||
|
"description": t(
|
||||||
|
"Verändert die Struktur des eingehenden Datenstroms. "
|
||||||
|
"Ausgabe ``data``: versionierter Umschlag (``schemaVersion``, ``kind``: transform), "
|
||||||
|
"konfigurierte Ausgabe-Felder und ``_meta``. "
|
||||||
|
"Operationen pro Mapping: 'rename' (Key umbenennen), 'cast' (Typ konvertieren), "
|
||||||
|
"'nest' (mehrere Felder unter neuem Objekt zusammenfassen), "
|
||||||
|
"'flatten' (verschachteltes Objekt auf oberste Ebene heben), "
|
||||||
|
"'compute' (neues Feld aus Template-/{{...}}-Ausdruck berechnen). "
|
||||||
|
"Jedes Mapping definiert: 'sourceField' (Eingangspfad / Ausdruck), "
|
||||||
|
"'outputField' (Ausgabe-Key), 'operation' und 'type' (Zieltyp). "
|
||||||
|
"Das Ergebnis ist ein neues Objekt — der ursprüngliche Datenstrom "
|
||||||
|
"wird nicht automatisch weitergegeben (ausser 'passthroughUnmapped: true')."
|
||||||
|
),
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "mappings",
|
||||||
|
"type": "list",
|
||||||
|
"required": True,
|
||||||
|
"frontendType": "mappingTable",
|
||||||
|
"default": [],
|
||||||
|
"description": t(
|
||||||
|
"Liste von Mapping-Einträgen. Jeder Eintrag: "
|
||||||
|
"sourceField (DataRef-Pfad oder Ausdruck), "
|
||||||
|
"outputField (Ziel-Key im Output), "
|
||||||
|
"operation (rename | cast | nest | flatten | compute), "
|
||||||
|
"type (str | int | bool | float | object | list — für cast), "
|
||||||
|
"expression (für compute: Template oder Ausdruck, z.B. '{{firstName}} {{lastName}}')."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "passthroughUnmapped",
|
||||||
|
"type": "bool",
|
||||||
|
"required": False,
|
||||||
|
"frontendType": "checkbox",
|
||||||
|
"default": False,
|
||||||
|
"description": t(
|
||||||
|
"Alle nicht gemappten Felder des Eingangs zusätzlich in den Output übernehmen."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "flattenDepth",
|
||||||
|
"type": "int",
|
||||||
|
"required": False,
|
||||||
|
"frontendType": "number",
|
||||||
|
"default": 1,
|
||||||
|
"description": t("Tiefe für flatten-Operation (1 = eine Ebene, -1 = vollständig)"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"inputs": 1,
|
||||||
|
"outputs": 1,
|
||||||
|
"inputPorts": {0: {"accepts": _CONTEXT_INPUT_SCHEMAS}},
|
||||||
|
"outputPorts": {
|
||||||
|
0: {
|
||||||
|
"schema": {
|
||||||
|
"kind": "fromGraph",
|
||||||
|
"parameter": "mappings",
|
||||||
|
"nameField": "outputField",
|
||||||
|
"schemaName": "Transform_dynamic",
|
||||||
|
},
|
||||||
|
"dynamic": True,
|
||||||
|
"deriveFrom": "mappings",
|
||||||
|
"deriveNameField": "outputField",
|
||||||
|
"dataPickOptions": CONTEXT_ENVELOPE_DATA_PICK_OPTIONS,
|
||||||
|
# ActionResult is the correct normalization schema — NOT FormPayload.
|
||||||
|
# The output is a versionned ActionResult envelope built by contextEnvelope.
|
||||||
|
"fromGraphResultSchema": "ActionResult",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"injectUpstreamPayload": True,
|
||||||
|
"surfaceDataAsTopLevel": True,
|
||||||
|
"meta": {"icon": "mdi-swap-horizontal", "color": "#EF6C00", "usesAi": False},
|
||||||
|
"_method": "context",
|
||||||
|
"_action": "transformContext",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
# Shared parameter copy for ``contextBuilder`` fields (upstream data pick).
|
||||||
|
|
||||||
|
from modules.shared.i18nRegistry import t
|
||||||
|
|
||||||
|
CONTEXT_BUILDER_PARAM_DESCRIPTION = t(
|
||||||
|
"Inhalt aus vorherigen Schritten wählen (DataRef / Daten-Picker): z. B. „response“ für Klartext, "
|
||||||
|
"Handover-Pfade für strukturiertes JSON oder Medienlisten. "
|
||||||
|
"Die Auflösung erfolgt vollständig serverseitig (`resolveParameterReferences`). "
|
||||||
|
"Formular-Schritte speichern Antworten unter „payload“ — fehlt ein gewählter Pfad am Root, "
|
||||||
|
"wird derselbe Pfad automatisch unter „payload“ nachgeschlagen (Kompatibilität mit älteren "
|
||||||
|
"und neuen Picker-Pfaden). "
|
||||||
|
"In Freitext-/Template-Feldern werden weiterhin Platzhalter `{{KnotenId.feld.b.z.}}` ersetzt "
|
||||||
|
"(gleiche Semantik inkl. optionalem Nachschlagen unter „payload“)."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Kurzreferenz für Node-Beschreibungen (optional einbinden): dieselbe Auflösungslogik
|
||||||
|
# wie bei DataRefs — kein separates Variablen-Subsystem.
|
||||||
|
REF_AND_TEMPLATE_COMPATIBILITY_SUMMARY = t(
|
||||||
|
"Verweise: typisierte DataRefs im Parameter; Zeichenketten-Templates mit {{…}}; "
|
||||||
|
"Formular-Felder unter output.payload."
|
||||||
|
)
|
||||||
|
|
@ -3,6 +3,25 @@
|
||||||
|
|
||||||
from modules.shared.i18nRegistry import t
|
from modules.shared.i18nRegistry import t
|
||||||
|
|
||||||
|
from modules.features.graphicalEditor.nodeDefinitions.ai import CONSOLIDATE_RESULT_DATA_PICK_OPTIONS
|
||||||
|
|
||||||
|
AGGREGATE_RESULT_DATA_PICK_OPTIONS = [
|
||||||
|
{
|
||||||
|
"path": ["items"],
|
||||||
|
"pickerLabel": t("Gesammelte Elemente"),
|
||||||
|
"detail": t("Alle aus der Schleife gesammelten Werte."),
|
||||||
|
"recommended": True,
|
||||||
|
"type": "List[Any]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ["count"],
|
||||||
|
"pickerLabel": t("Anzahl"),
|
||||||
|
"detail": t("Anzahl gesammelter Elemente."),
|
||||||
|
"recommended": False,
|
||||||
|
"type": "int",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
DATA_NODES = [
|
DATA_NODES = [
|
||||||
{
|
{
|
||||||
"id": "data.aggregate",
|
"id": "data.aggregate",
|
||||||
|
|
@ -17,7 +36,7 @@ DATA_NODES = [
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["Transit", "AiResult", "LoopItem"]}},
|
"inputPorts": {0: {"accepts": ["Transit", "AiResult", "LoopItem"]}},
|
||||||
"outputPorts": {0: {"schema": "AggregateResult"}},
|
"outputPorts": {0: {"schema": "AggregateResult", "dataPickOptions": AGGREGATE_RESULT_DATA_PICK_OPTIONS}},
|
||||||
"executor": "data",
|
"executor": "data",
|
||||||
"meta": {"icon": "mdi-playlist-plus", "color": "#607D8B", "usesAi": False},
|
"meta": {"icon": "mdi-playlist-plus", "color": "#607D8B", "usesAi": False},
|
||||||
},
|
},
|
||||||
|
|
@ -55,7 +74,7 @@ DATA_NODES = [
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["AggregateResult", "Transit"]}},
|
"inputPorts": {0: {"accepts": ["AggregateResult", "Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "ConsolidateResult"}},
|
"outputPorts": {0: {"schema": "ConsolidateResult", "dataPickOptions": CONSOLIDATE_RESULT_DATA_PICK_OPTIONS}},
|
||||||
"executor": "data",
|
"executor": "data",
|
||||||
"meta": {"icon": "mdi-table-merge-cells", "color": "#607D8B", "usesAi": False},
|
"meta": {"icon": "mdi-table-merge-cells", "color": "#607D8B", "usesAi": False},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,35 @@
|
||||||
|
|
||||||
from modules.shared.i18nRegistry import t
|
from modules.shared.i18nRegistry import t
|
||||||
|
|
||||||
|
from modules.features.graphicalEditor.nodeDefinitions.contextPickerHelp import (
|
||||||
|
CONTEXT_BUILDER_PARAM_DESCRIPTION,
|
||||||
|
)
|
||||||
|
from modules.features.graphicalEditor.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS
|
||||||
|
|
||||||
|
EMAIL_LIST_DATA_PICK_OPTIONS = [
|
||||||
|
{
|
||||||
|
"path": ["emails"],
|
||||||
|
"pickerLabel": t("Alle E-Mails"),
|
||||||
|
"detail": t("Die vollständige E-Mail-Liste des Schritts."),
|
||||||
|
"recommended": True,
|
||||||
|
"type": "List[EmailItem]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ["emails", 0],
|
||||||
|
"pickerLabel": t("Erste E-Mail"),
|
||||||
|
"detail": t("Das erste Element der Liste."),
|
||||||
|
"recommended": False,
|
||||||
|
"type": "EmailItem",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ["count"],
|
||||||
|
"pickerLabel": t("Anzahl"),
|
||||||
|
"detail": t("Anzahl gefundener E-Mails."),
|
||||||
|
"recommended": False,
|
||||||
|
"type": "int",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
EMAIL_NODES = [
|
EMAIL_NODES = [
|
||||||
{
|
{
|
||||||
"id": "email.checkEmail",
|
"id": "email.checkEmail",
|
||||||
|
|
@ -23,7 +52,8 @@ EMAIL_NODES = [
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "EmailList"}},
|
"outputPorts": {0: {"schema": "EmailList", "dataPickOptions": EMAIL_LIST_DATA_PICK_OPTIONS}},
|
||||||
|
"paramMappers": ["emailCheckFilter"],
|
||||||
"meta": {"icon": "mdi-email-check", "color": "#1976D2", "usesAi": False},
|
"meta": {"icon": "mdi-email-check", "color": "#1976D2", "usesAi": False},
|
||||||
"_method": "outlook",
|
"_method": "outlook",
|
||||||
"_action": "readEmails",
|
"_action": "readEmails",
|
||||||
|
|
@ -47,7 +77,8 @@ EMAIL_NODES = [
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "EmailList"}},
|
"outputPorts": {0: {"schema": "EmailList", "dataPickOptions": EMAIL_LIST_DATA_PICK_OPTIONS}},
|
||||||
|
"paramMappers": ["emailSearchQuery"],
|
||||||
"meta": {"icon": "mdi-email-search", "color": "#1976D2", "usesAi": False},
|
"meta": {"icon": "mdi-email-search", "color": "#1976D2", "usesAi": False},
|
||||||
"_method": "outlook",
|
"_method": "outlook",
|
||||||
"_action": "searchEmails",
|
"_action": "searchEmails",
|
||||||
|
|
@ -63,11 +94,13 @@ EMAIL_NODES = [
|
||||||
"frontendOptions": {"authority": "msft"},
|
"frontendOptions": {"authority": "msft"},
|
||||||
"description": t("E-Mail-Konto")},
|
"description": t("E-Mail-Konto")},
|
||||||
{"name": "context", "type": "Any", "required": False, "frontendType": "templateTextarea",
|
{"name": "context", "type": "Any", "required": False, "frontendType": "templateTextarea",
|
||||||
"description": t("Daten aus vorherigen Schritten (oder direkte Beschreibung)"), "default": ""},
|
"description": CONTEXT_BUILDER_PARAM_DESCRIPTION, "default": "",
|
||||||
|
"graphInherit": {"port": 0, "kind": "primaryTextRef"}},
|
||||||
{"name": "to", "type": "str", "required": False, "frontendType": "text",
|
{"name": "to", "type": "str", "required": False, "frontendType": "text",
|
||||||
"description": t("Empfänger (komma-separiert, optional für Entwurf)"), "default": ""},
|
"description": t("Empfänger (komma-separiert, optional für Entwurf)"), "default": ""},
|
||||||
{"name": "documentList", "type": "str", "required": False, "frontendType": "hidden",
|
{"name": "documentList", "type": "str", "required": False, "frontendType": "hidden",
|
||||||
"description": t("Anhang-Dokumente (via Wire oder DataRef)"), "default": ""},
|
"description": t("Anhang-Dokumente (via Wire oder DataRef)"), "default": "",
|
||||||
|
"graphInherit": {"port": 0, "kind": "documentListWire"}},
|
||||||
{"name": "emailContent", "type": "str", "required": False, "frontendType": "hidden",
|
{"name": "emailContent", "type": "str", "required": False, "frontendType": "hidden",
|
||||||
"description": t("Direkt vorbereiteter Inhalt {subject, body, to} (via Wire — überspringt KI)"),
|
"description": t("Direkt vorbereiteter Inhalt {subject, body, to} (via Wire — überspringt KI)"),
|
||||||
"default": ""},
|
"default": ""},
|
||||||
|
|
@ -78,7 +111,8 @@ EMAIL_NODES = [
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["EmailDraft", "AiResult", "Transit", "ConsolidateResult", "DocumentList"]}},
|
"inputPorts": {0: {"accepts": ["EmailDraft", "AiResult", "Transit", "ConsolidateResult", "DocumentList"]}},
|
||||||
"outputPorts": {0: {"schema": "ActionResult"}},
|
"outputPorts": {0: {"schema": "ActionResult", "dataPickOptions": ACTION_RESULT_DATA_PICK_OPTIONS}},
|
||||||
|
"paramMappers": ["emailDraftContextFromSubjectBody"],
|
||||||
"meta": {"icon": "mdi-email-edit", "color": "#1976D2", "usesAi": False},
|
"meta": {"icon": "mdi-email-edit", "color": "#1976D2", "usesAi": False},
|
||||||
"_method": "outlook",
|
"_method": "outlook",
|
||||||
"_action": "composeAndDraftEmailWithContext",
|
"_action": "composeAndDraftEmailWithContext",
|
||||||
|
|
|
||||||
|
|
@ -3,27 +3,41 @@
|
||||||
|
|
||||||
from modules.shared.i18nRegistry import t
|
from modules.shared.i18nRegistry import t
|
||||||
|
|
||||||
|
from modules.features.graphicalEditor.nodeDefinitions.contextPickerHelp import (
|
||||||
|
CONTEXT_BUILDER_PARAM_DESCRIPTION,
|
||||||
|
)
|
||||||
|
from modules.features.graphicalEditor.nodeDefinitions.ai import DOCUMENT_LIST_DATA_PICK_OPTIONS
|
||||||
|
|
||||||
FILE_NODES = [
|
FILE_NODES = [
|
||||||
{
|
{
|
||||||
"id": "file.create",
|
"id": "file.create",
|
||||||
"category": "file",
|
"category": "file",
|
||||||
"label": t("Datei erstellen"),
|
"label": t("Datei erstellen"),
|
||||||
"description": t("Erstellt eine Datei aus Kontext (Text/Markdown von KI)."),
|
"description": t(
|
||||||
|
"Erstellt eine Datei aus der Presentation von „Inhalt extrahieren“ "
|
||||||
|
"(``data`` oder Schleifen-``bodyResults``). Ausgabe über den Generation-Service."
|
||||||
|
),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "outputFormat", "type": "str", "required": True, "frontendType": "select",
|
{"name": "outputFormat", "type": "str", "required": True, "frontendType": "select",
|
||||||
"frontendOptions": {"options": ["docx", "pdf", "txt", "html", "md"]},
|
"frontendOptions": {"options": ["docx", "pdf", "txt", "html", "md"]},
|
||||||
"description": t("Ausgabeformat"), "default": "docx"},
|
"description": t("Ausgabeformat"), "default": "docx"},
|
||||||
{"name": "title", "type": "str", "required": False, "frontendType": "text",
|
{"name": "title", "type": "str", "required": False, "frontendType": "text",
|
||||||
"description": t("Dokumenttitel")},
|
"description": t("Dokumenttitel")},
|
||||||
|
{"name": "folderId", "type": "str", "required": False, "frontendType": "userFileFolder",
|
||||||
|
"description": t("Zielordner in Meine Dateien"),
|
||||||
|
"default": ""},
|
||||||
{"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder",
|
{"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder",
|
||||||
"description": t("Daten aus vorherigen Schritten"), "default": ""},
|
"description": CONTEXT_BUILDER_PARAM_DESCRIPTION, "default": "",
|
||||||
|
"graphInherit": {"port": 0, "kind": "recommendedDataPickRef"}},
|
||||||
],
|
],
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["AiResult", "TextResult", "Transit", "FormPayload", "LoopItem", "ActionResult"]}},
|
"inputPorts": {0: {"accepts": ["AiResult", "TextResult", "Transit", "FormPayload", "LoopItem", "ActionResult"]}},
|
||||||
"outputPorts": {0: {"schema": "DocumentList"}},
|
"outputPorts": {0: {"schema": "DocumentList", "dataPickOptions": DOCUMENT_LIST_DATA_PICK_OPTIONS}},
|
||||||
"meta": {"icon": "mdi-file-plus-outline", "color": "#2196F3", "usesAi": False},
|
"meta": {"icon": "mdi-file-plus-outline", "color": "#2196F3", "usesAi": False},
|
||||||
"_method": "file",
|
"_method": "file",
|
||||||
"_action": "create",
|
"_action": "create",
|
||||||
|
# Emit a debug log tracing how the ``context`` parameter was resolved.
|
||||||
|
"logContextResolution": True,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,35 @@
|
||||||
|
|
||||||
from modules.shared.i18nRegistry import t
|
from modules.shared.i18nRegistry import t
|
||||||
|
|
||||||
|
from modules.features.graphicalEditor.nodeDefinitions.ai import DOCUMENT_LIST_DATA_PICK_OPTIONS
|
||||||
|
|
||||||
|
BOOL_RESULT_DATA_PICK_OPTIONS = [
|
||||||
|
{
|
||||||
|
"path": ["result"],
|
||||||
|
"pickerLabel": t("Ergebnis"),
|
||||||
|
"detail": t("Boolesches Ergebnis (z. B. Genehmigung ja/nein)."),
|
||||||
|
"recommended": True,
|
||||||
|
"type": "bool",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ["reason"],
|
||||||
|
"pickerLabel": t("Begründung"),
|
||||||
|
"detail": t("Optionale textuelle Begründung."),
|
||||||
|
"recommended": False,
|
||||||
|
"type": "str",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
TEXT_RESULT_DATA_PICK_OPTIONS = [
|
||||||
|
{
|
||||||
|
"path": ["text"],
|
||||||
|
"pickerLabel": t("Text"),
|
||||||
|
"detail": t("Vom Benutzer eingegebener oder gewählter Text."),
|
||||||
|
"recommended": True,
|
||||||
|
"type": "str",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
# Canonical form field types — single source of truth.
|
# Canonical form field types — single source of truth.
|
||||||
# portType maps to the PORT_TYPE_CATALOG primitive used by DataPicker / validateGraph.
|
# portType maps to the PORT_TYPE_CATALOG primitive used by DataPicker / validateGraph.
|
||||||
FORM_FIELD_TYPES = [
|
FORM_FIELD_TYPES = [
|
||||||
|
|
@ -55,7 +84,7 @@ INPUT_NODES = [
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "BoolResult"}},
|
"outputPorts": {0: {"schema": "BoolResult", "dataPickOptions": BOOL_RESULT_DATA_PICK_OPTIONS}},
|
||||||
"executor": "input",
|
"executor": "input",
|
||||||
"meta": {"icon": "mdi-check-decagram", "color": "#4CAF50", "usesAi": False},
|
"meta": {"icon": "mdi-check-decagram", "color": "#4CAF50", "usesAi": False},
|
||||||
},
|
},
|
||||||
|
|
@ -78,7 +107,7 @@ INPUT_NODES = [
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "DocumentList"}},
|
"outputPorts": {0: {"schema": "DocumentList", "dataPickOptions": DOCUMENT_LIST_DATA_PICK_OPTIONS}},
|
||||||
"executor": "input",
|
"executor": "input",
|
||||||
"meta": {"icon": "mdi-upload", "color": "#2196F3", "usesAi": False},
|
"meta": {"icon": "mdi-upload", "color": "#2196F3", "usesAi": False},
|
||||||
},
|
},
|
||||||
|
|
@ -96,7 +125,7 @@ INPUT_NODES = [
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "TextResult"}},
|
"outputPorts": {0: {"schema": "TextResult", "dataPickOptions": TEXT_RESULT_DATA_PICK_OPTIONS}},
|
||||||
"executor": "input",
|
"executor": "input",
|
||||||
"meta": {"icon": "mdi-comment-text", "color": "#FF9800", "usesAi": False},
|
"meta": {"icon": "mdi-comment-text", "color": "#FF9800", "usesAi": False},
|
||||||
},
|
},
|
||||||
|
|
@ -115,7 +144,7 @@ INPUT_NODES = [
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "BoolResult"}},
|
"outputPorts": {0: {"schema": "BoolResult", "dataPickOptions": BOOL_RESULT_DATA_PICK_OPTIONS}},
|
||||||
"executor": "input",
|
"executor": "input",
|
||||||
"meta": {"icon": "mdi-magnify-scan", "color": "#673AB7", "usesAi": False},
|
"meta": {"icon": "mdi-magnify-scan", "color": "#673AB7", "usesAi": False},
|
||||||
},
|
},
|
||||||
|
|
@ -133,7 +162,7 @@ INPUT_NODES = [
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "TextResult"}},
|
"outputPorts": {0: {"schema": "TextResult", "dataPickOptions": TEXT_RESULT_DATA_PICK_OPTIONS}},
|
||||||
"executor": "input",
|
"executor": "input",
|
||||||
"meta": {"icon": "mdi-format-list-checks", "color": "#009688", "usesAi": False},
|
"meta": {"icon": "mdi-format-list-checks", "color": "#009688", "usesAi": False},
|
||||||
},
|
},
|
||||||
|
|
@ -153,7 +182,7 @@ INPUT_NODES = [
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "BoolResult"}},
|
"outputPorts": {0: {"schema": "BoolResult", "dataPickOptions": BOOL_RESULT_DATA_PICK_OPTIONS}},
|
||||||
"executor": "input",
|
"executor": "input",
|
||||||
"meta": {"icon": "mdi-checkbox-marked-circle", "color": "#8BC34A", "usesAi": False},
|
"meta": {"icon": "mdi-checkbox-marked-circle", "color": "#8BC34A", "usesAi": False},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
|
|
||||||
from modules.shared.i18nRegistry import t
|
from modules.shared.i18nRegistry import t
|
||||||
|
|
||||||
|
from modules.features.graphicalEditor.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS
|
||||||
|
|
||||||
# Typed FeatureInstance binding (replaces legacy `string, hidden`).
|
# Typed FeatureInstance binding (replaces legacy `string, hidden`).
|
||||||
# - type FeatureInstanceRef[redmine] is filtered by the DataPicker.
|
# - type FeatureInstanceRef[redmine] is filtered by the DataPicker.
|
||||||
# - frontendType "featureInstance" is rendered by FeatureInstancePicker which
|
# - frontendType "featureInstance" is rendered by FeatureInstancePicker which
|
||||||
|
|
@ -31,7 +33,7 @@ REDMINE_NODES = [
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "ActionResult"}},
|
"outputPorts": {0: {"schema": "ActionResult", "dataPickOptions": ACTION_RESULT_DATA_PICK_OPTIONS}},
|
||||||
"meta": {"icon": "mdi-ticket-outline", "color": "#4A6FA5", "usesAi": False},
|
"meta": {"icon": "mdi-ticket-outline", "color": "#4A6FA5", "usesAi": False},
|
||||||
"_method": "redmine",
|
"_method": "redmine",
|
||||||
"_action": "readTicket",
|
"_action": "readTicket",
|
||||||
|
|
@ -59,7 +61,7 @@ REDMINE_NODES = [
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "ActionResult"}},
|
"outputPorts": {0: {"schema": "ActionResult", "dataPickOptions": ACTION_RESULT_DATA_PICK_OPTIONS}},
|
||||||
"meta": {"icon": "mdi-format-list-bulleted", "color": "#4A6FA5", "usesAi": False},
|
"meta": {"icon": "mdi-format-list-bulleted", "color": "#4A6FA5", "usesAi": False},
|
||||||
"_method": "redmine",
|
"_method": "redmine",
|
||||||
"_action": "listTickets",
|
"_action": "listTickets",
|
||||||
|
|
@ -91,7 +93,7 @@ REDMINE_NODES = [
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "ActionResult"}},
|
"outputPorts": {0: {"schema": "ActionResult", "dataPickOptions": ACTION_RESULT_DATA_PICK_OPTIONS}},
|
||||||
"meta": {"icon": "mdi-ticket-plus-outline", "color": "#4A6FA5", "usesAi": False},
|
"meta": {"icon": "mdi-ticket-plus-outline", "color": "#4A6FA5", "usesAi": False},
|
||||||
"_method": "redmine",
|
"_method": "redmine",
|
||||||
"_action": "createTicket",
|
"_action": "createTicket",
|
||||||
|
|
@ -127,7 +129,7 @@ REDMINE_NODES = [
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "ActionResult"}},
|
"outputPorts": {0: {"schema": "ActionResult", "dataPickOptions": ACTION_RESULT_DATA_PICK_OPTIONS}},
|
||||||
"meta": {"icon": "mdi-ticket-confirmation-outline", "color": "#4A6FA5", "usesAi": False},
|
"meta": {"icon": "mdi-ticket-confirmation-outline", "color": "#4A6FA5", "usesAi": False},
|
||||||
"_method": "redmine",
|
"_method": "redmine",
|
||||||
"_action": "updateTicket",
|
"_action": "updateTicket",
|
||||||
|
|
@ -151,7 +153,7 @@ REDMINE_NODES = [
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "ActionResult"}},
|
"outputPorts": {0: {"schema": "ActionResult", "dataPickOptions": ACTION_RESULT_DATA_PICK_OPTIONS}},
|
||||||
"meta": {"icon": "mdi-chart-bar", "color": "#4A6FA5", "usesAi": False},
|
"meta": {"icon": "mdi-chart-bar", "color": "#4A6FA5", "usesAi": False},
|
||||||
"_method": "redmine",
|
"_method": "redmine",
|
||||||
"_action": "getStats",
|
"_action": "getStats",
|
||||||
|
|
@ -169,7 +171,7 @@ REDMINE_NODES = [
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "ActionResult"}},
|
"outputPorts": {0: {"schema": "ActionResult", "dataPickOptions": ACTION_RESULT_DATA_PICK_OPTIONS}},
|
||||||
"meta": {"icon": "mdi-database-sync", "color": "#4A6FA5", "usesAi": False},
|
"meta": {"icon": "mdi-database-sync", "color": "#4A6FA5", "usesAi": False},
|
||||||
"_method": "redmine",
|
"_method": "redmine",
|
||||||
"_action": "runSync",
|
"_action": "runSync",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,35 @@
|
||||||
|
|
||||||
from modules.shared.i18nRegistry import t
|
from modules.shared.i18nRegistry import t
|
||||||
|
|
||||||
|
from modules.features.graphicalEditor.nodeDefinitions.ai import (
|
||||||
|
ACTION_RESULT_DATA_PICK_OPTIONS,
|
||||||
|
DOCUMENT_LIST_DATA_PICK_OPTIONS,
|
||||||
|
)
|
||||||
|
|
||||||
|
FILE_LIST_DATA_PICK_OPTIONS = [
|
||||||
|
{
|
||||||
|
"path": ["files"],
|
||||||
|
"pickerLabel": t("Alle Dateien"),
|
||||||
|
"detail": t("Die vollständige Dateiliste."),
|
||||||
|
"recommended": True,
|
||||||
|
"type": "List[FileItem]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ["files", 0],
|
||||||
|
"pickerLabel": t("Erste Datei"),
|
||||||
|
"detail": t("Das erste Listenelement."),
|
||||||
|
"recommended": False,
|
||||||
|
"type": "FileItem",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ["count"],
|
||||||
|
"pickerLabel": t("Anzahl"),
|
||||||
|
"detail": t("Anzahl der Dateien."),
|
||||||
|
"recommended": False,
|
||||||
|
"type": "int",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
SHAREPOINT_NODES = [
|
SHAREPOINT_NODES = [
|
||||||
{
|
{
|
||||||
"id": "sharepoint.findFile",
|
"id": "sharepoint.findFile",
|
||||||
|
|
@ -23,7 +52,7 @@ SHAREPOINT_NODES = [
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "FileList"}},
|
"outputPorts": {0: {"schema": "FileList", "dataPickOptions": FILE_LIST_DATA_PICK_OPTIONS}},
|
||||||
"meta": {"icon": "mdi-file-search", "color": "#0078D4", "usesAi": False},
|
"meta": {"icon": "mdi-file-search", "color": "#0078D4", "usesAi": False},
|
||||||
"_method": "sharepoint",
|
"_method": "sharepoint",
|
||||||
"_action": "findDocumentPath",
|
"_action": "findDocumentPath",
|
||||||
|
|
@ -44,7 +73,7 @@ SHAREPOINT_NODES = [
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["FileList", "Transit", "LoopItem"]}},
|
"inputPorts": {0: {"accepts": ["FileList", "Transit", "LoopItem"]}},
|
||||||
"outputPorts": {0: {"schema": "DocumentList"}},
|
"outputPorts": {0: {"schema": "DocumentList", "dataPickOptions": DOCUMENT_LIST_DATA_PICK_OPTIONS}},
|
||||||
"meta": {"icon": "mdi-file-document", "color": "#0078D4", "usesAi": False},
|
"meta": {"icon": "mdi-file-document", "color": "#0078D4", "usesAi": False},
|
||||||
"_method": "sharepoint",
|
"_method": "sharepoint",
|
||||||
"_action": "readDocuments",
|
"_action": "readDocuments",
|
||||||
|
|
@ -67,7 +96,7 @@ SHAREPOINT_NODES = [
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["DocumentList", "Transit"]}},
|
"inputPorts": {0: {"accepts": ["DocumentList", "Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "ActionResult"}},
|
"outputPorts": {0: {"schema": "ActionResult", "dataPickOptions": ACTION_RESULT_DATA_PICK_OPTIONS}},
|
||||||
"meta": {"icon": "mdi-upload", "color": "#0078D4", "usesAi": False},
|
"meta": {"icon": "mdi-upload", "color": "#0078D4", "usesAi": False},
|
||||||
"_method": "sharepoint",
|
"_method": "sharepoint",
|
||||||
"_action": "uploadFile",
|
"_action": "uploadFile",
|
||||||
|
|
@ -88,7 +117,7 @@ SHAREPOINT_NODES = [
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "FileList"}},
|
"outputPorts": {0: {"schema": "FileList", "dataPickOptions": FILE_LIST_DATA_PICK_OPTIONS}},
|
||||||
"meta": {"icon": "mdi-folder-open", "color": "#0078D4", "usesAi": False},
|
"meta": {"icon": "mdi-folder-open", "color": "#0078D4", "usesAi": False},
|
||||||
"_method": "sharepoint",
|
"_method": "sharepoint",
|
||||||
"_action": "listDocuments",
|
"_action": "listDocuments",
|
||||||
|
|
@ -109,7 +138,7 @@ SHAREPOINT_NODES = [
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["FileList", "Transit", "LoopItem"]}},
|
"inputPorts": {0: {"accepts": ["FileList", "Transit", "LoopItem"]}},
|
||||||
"outputPorts": {0: {"schema": "DocumentList"}},
|
"outputPorts": {0: {"schema": "DocumentList", "dataPickOptions": DOCUMENT_LIST_DATA_PICK_OPTIONS}},
|
||||||
"meta": {"icon": "mdi-download", "color": "#0078D4", "usesAi": False},
|
"meta": {"icon": "mdi-download", "color": "#0078D4", "usesAi": False},
|
||||||
"_method": "sharepoint",
|
"_method": "sharepoint",
|
||||||
"_action": "downloadFileByPath",
|
"_action": "downloadFileByPath",
|
||||||
|
|
@ -133,7 +162,7 @@ SHAREPOINT_NODES = [
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "ActionResult"}},
|
"outputPorts": {0: {"schema": "ActionResult", "dataPickOptions": ACTION_RESULT_DATA_PICK_OPTIONS}},
|
||||||
"meta": {"icon": "mdi-content-copy", "color": "#0078D4", "usesAi": False},
|
"meta": {"icon": "mdi-content-copy", "color": "#0078D4", "usesAi": False},
|
||||||
"_method": "sharepoint",
|
"_method": "sharepoint",
|
||||||
"_action": "copyFile",
|
"_action": "copyFile",
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,29 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
# Canvas start nodes — variant reflects workflow configuration (gear in editor).
|
# Start nodes (palette category ``start``); kinds align with workflow entry points / run envelope.
|
||||||
|
|
||||||
from modules.shared.i18nRegistry import t
|
from modules.shared.i18nRegistry import t
|
||||||
|
|
||||||
|
from modules.features.graphicalEditor.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS
|
||||||
|
|
||||||
TRIGGER_NODES = [
|
TRIGGER_NODES = [
|
||||||
{
|
{
|
||||||
"id": "trigger.manual",
|
"id": "trigger.manual",
|
||||||
"category": "trigger",
|
"category": "start",
|
||||||
"label": t("Start"),
|
"label": t("Start"),
|
||||||
"description": t("Manuell, API oder Hintergrund-Starts (Webhook, E-Mail, …)."),
|
"description": t("Manuell Trigger. Workflow startet nur, wenn auf Start-Button geklickt wird."),
|
||||||
"parameters": [],
|
"parameters": [],
|
||||||
"inputs": 0,
|
"inputs": 0,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {},
|
"inputPorts": {},
|
||||||
"outputPorts": {0: {"schema": "ActionResult"}},
|
"outputPorts": {0: {"schema": "ActionResult", "dataPickOptions": ACTION_RESULT_DATA_PICK_OPTIONS}},
|
||||||
"executor": "trigger",
|
"executor": "trigger",
|
||||||
"meta": {"icon": "mdi-play", "color": "#4CAF50", "usesAi": False},
|
"meta": {"icon": "mdi-play", "color": "#4CAF50", "usesAi": False},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "trigger.form",
|
"id": "trigger.form",
|
||||||
"category": "trigger",
|
"category": "start",
|
||||||
"label": t("Start (Formular)"),
|
"label": t("Start (Formular)"),
|
||||||
"description": t("Felder werden beim Start befüllt; konfigurieren Sie die Felder auf dieser Node."),
|
"description": t("Formular Trigger. Workflow startet nur, wenn das Formular ausgefüllt und abgeschickt wird."),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "formFields",
|
"name": "formFields",
|
||||||
|
|
@ -40,9 +42,9 @@ TRIGGER_NODES = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "trigger.schedule",
|
"id": "trigger.schedule",
|
||||||
"category": "trigger",
|
"category": "start",
|
||||||
"label": t("Start (Zeitplan)"),
|
"label": t("Start (Zeitplan)"),
|
||||||
"description": t("Cron-Ausdruck für geplante Läufe."),
|
"description": t("Workflow startet nach dem ausgewählten Zeitplan."),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "cron",
|
"name": "cron",
|
||||||
|
|
@ -51,11 +53,18 @@ TRIGGER_NODES = [
|
||||||
"frontendType": "cron",
|
"frontendType": "cron",
|
||||||
"description": t("Cron-Ausdruck"),
|
"description": t("Cron-Ausdruck"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "schedule",
|
||||||
|
"type": "json",
|
||||||
|
"required": False,
|
||||||
|
"frontendType": "hidden",
|
||||||
|
"description": t("Zeitplan (intern, für Editor-Roundtrip)"),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
"inputs": 0,
|
"inputs": 0,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {},
|
"inputPorts": {},
|
||||||
"outputPorts": {0: {"schema": "ActionResult"}},
|
"outputPorts": {0: {"schema": "ActionResult", "dataPickOptions": ACTION_RESULT_DATA_PICK_OPTIONS}},
|
||||||
"executor": "trigger",
|
"executor": "trigger",
|
||||||
"meta": {"icon": "mdi-clock", "color": "#2196F3", "usesAi": False},
|
"meta": {"icon": "mdi-clock", "color": "#2196F3", "usesAi": False},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
|
|
||||||
from modules.shared.i18nRegistry import t
|
from modules.shared.i18nRegistry import t
|
||||||
|
|
||||||
|
from modules.features.graphicalEditor.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS
|
||||||
|
|
||||||
# Typed FeatureInstance binding (replaces legacy `string, hidden`).
|
# Typed FeatureInstance binding (replaces legacy `string, hidden`).
|
||||||
# - type uses the discriminator notation `FeatureInstanceRef[<code>]` so the
|
# - type uses the discriminator notation `FeatureInstanceRef[<code>]` so the
|
||||||
# DataPicker / RequiredAttributePicker can filter compatible upstream paths.
|
# DataPicker / RequiredAttributePicker can filter compatible upstream paths.
|
||||||
|
|
@ -35,7 +37,7 @@ TRUSTEE_NODES = [
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "ActionResult"}},
|
"outputPorts": {0: {"schema": "ActionResult", "dataPickOptions": ACTION_RESULT_DATA_PICK_OPTIONS}},
|
||||||
"meta": {"icon": "mdi-database-refresh", "color": "#4CAF50", "usesAi": False},
|
"meta": {"icon": "mdi-database-refresh", "color": "#4CAF50", "usesAi": False},
|
||||||
"_method": "trustee",
|
"_method": "trustee",
|
||||||
"_action": "refreshAccountingData",
|
"_action": "refreshAccountingData",
|
||||||
|
|
@ -62,7 +64,7 @@ TRUSTEE_NODES = [
|
||||||
# Runtime returns ActionResult.isSuccess(documents=[...]) — see
|
# Runtime returns ActionResult.isSuccess(documents=[...]) — see
|
||||||
# actions/extractFromFiles.py. Declaring DocumentList here was adapter
|
# actions/extractFromFiles.py. Declaring DocumentList here was adapter
|
||||||
# drift and broke the DataPicker for downstream nodes.
|
# drift and broke the DataPicker for downstream nodes.
|
||||||
"outputPorts": {0: {"schema": "ActionResult"}},
|
"outputPorts": {0: {"schema": "ActionResult", "dataPickOptions": ACTION_RESULT_DATA_PICK_OPTIONS}},
|
||||||
"meta": {"icon": "mdi-file-document-scan", "color": "#4CAF50", "usesAi": True},
|
"meta": {"icon": "mdi-file-document-scan", "color": "#4CAF50", "usesAi": True},
|
||||||
"_method": "trustee",
|
"_method": "trustee",
|
||||||
"_action": "extractFromFiles",
|
"_action": "extractFromFiles",
|
||||||
|
|
@ -77,13 +79,14 @@ TRUSTEE_NODES = [
|
||||||
# is List[ActionDocument] (see datamodelChat.ActionResult). The
|
# is List[ActionDocument] (see datamodelChat.ActionResult). The
|
||||||
# DataPicker uses this string to filter compatible upstream paths.
|
# DataPicker uses this string to filter compatible upstream paths.
|
||||||
{"name": "documentList", "type": "List[ActionDocument]", "required": True, "frontendType": "dataRef",
|
{"name": "documentList", "type": "List[ActionDocument]", "required": True, "frontendType": "dataRef",
|
||||||
"description": t("Dokumente aus vorherigen Schritten")},
|
"description": t("Dokumente aus vorherigen Schritten"),
|
||||||
|
"graphInherit": {"port": 0, "kind": "documentListWire"}},
|
||||||
dict(_TRUSTEE_INSTANCE_PARAM),
|
dict(_TRUSTEE_INSTANCE_PARAM),
|
||||||
],
|
],
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["ActionResult", "DocumentList", "Transit"]}},
|
"inputPorts": {0: {"accepts": ["ActionResult", "DocumentList", "Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "ActionResult"}},
|
"outputPorts": {0: {"schema": "ActionResult", "dataPickOptions": ACTION_RESULT_DATA_PICK_OPTIONS}},
|
||||||
"meta": {"icon": "mdi-file-document-check", "color": "#4CAF50", "usesAi": False},
|
"meta": {"icon": "mdi-file-document-check", "color": "#4CAF50", "usesAi": False},
|
||||||
"_method": "trustee",
|
"_method": "trustee",
|
||||||
"_action": "processDocuments",
|
"_action": "processDocuments",
|
||||||
|
|
@ -95,13 +98,14 @@ TRUSTEE_NODES = [
|
||||||
"description": t("Trustee-Positionen in Buchhaltungssystem übertragen."),
|
"description": t("Trustee-Positionen in Buchhaltungssystem übertragen."),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "documentList", "type": "List[ActionDocument]", "required": True, "frontendType": "dataRef",
|
{"name": "documentList", "type": "List[ActionDocument]", "required": True, "frontendType": "dataRef",
|
||||||
"description": t("Dokumente aus vorherigen Schritten")},
|
"description": t("Dokumente aus vorherigen Schritten"),
|
||||||
|
"graphInherit": {"port": 0, "kind": "documentListWire"}},
|
||||||
dict(_TRUSTEE_INSTANCE_PARAM),
|
dict(_TRUSTEE_INSTANCE_PARAM),
|
||||||
],
|
],
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["ActionResult", "DocumentList", "Transit"]}},
|
"inputPorts": {0: {"accepts": ["ActionResult", "DocumentList", "Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "ActionResult"}},
|
"outputPorts": {0: {"schema": "ActionResult", "dataPickOptions": ACTION_RESULT_DATA_PICK_OPTIONS}},
|
||||||
"meta": {"icon": "mdi-calculator", "color": "#4CAF50", "usesAi": False},
|
"meta": {"icon": "mdi-calculator", "color": "#4CAF50", "usesAi": False},
|
||||||
"_method": "trustee",
|
"_method": "trustee",
|
||||||
"_action": "syncToAccounting",
|
"_action": "syncToAccounting",
|
||||||
|
|
@ -138,7 +142,7 @@ TRUSTEE_NODES = [
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["Transit", "AiResult", "ConsolidateResult", "UdmDocument"]}},
|
"inputPorts": {0: {"accepts": ["Transit", "AiResult", "ConsolidateResult", "UdmDocument"]}},
|
||||||
"outputPorts": {0: {"schema": "ActionResult"}},
|
"outputPorts": {0: {"schema": "ActionResult", "dataPickOptions": ACTION_RESULT_DATA_PICK_OPTIONS}},
|
||||||
"meta": {"icon": "mdi-database-search", "color": "#4CAF50", "usesAi": False},
|
"meta": {"icon": "mdi-database-search", "color": "#4CAF50", "usesAi": False},
|
||||||
"_method": "trustee",
|
"_method": "trustee",
|
||||||
"_action": "queryData",
|
"_action": "queryData",
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""
|
"""
|
||||||
Node Type Registry for graphicalEditor - static node definitions (ai, email, sharepoint, trigger, flow, data, input).
|
Node Type Registry for graphicalEditor - static node definitions (start, input, flow, data, ai, email, …).
|
||||||
Nodes are defined first; IO/method actions are used at execution time.
|
Nodes are defined first; IO/method actions are used at execution time.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, List, Any, Optional
|
from typing import Dict, List, Any, Optional
|
||||||
|
|
||||||
|
from modules.features.graphicalEditor.conditionOperators import localize_operator_catalog
|
||||||
from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES
|
from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES
|
||||||
from modules.features.graphicalEditor.nodeDefinitions.input import FORM_FIELD_TYPES
|
from modules.features.graphicalEditor.nodeDefinitions.input import FORM_FIELD_TYPES
|
||||||
from modules.features.graphicalEditor.nodeAdapter import bindsActionFromLegacy
|
from modules.features.graphicalEditor.nodeAdapter import bindsActionFromLegacy
|
||||||
|
|
@ -82,6 +83,34 @@ def _localizeNode(node: Dict[str, Any], language: str) -> Dict[str, Any]:
|
||||||
pc["description"] = resolveText(pd, lang)
|
pc["description"] = resolveText(pd, lang)
|
||||||
params.append(pc)
|
params.append(pc)
|
||||||
out["parameters"] = params
|
out["parameters"] = params
|
||||||
|
|
||||||
|
out_ports: Dict[Any, Dict[str, Any]] = {}
|
||||||
|
for idx, po in (node.get("outputPorts") or {}).items():
|
||||||
|
if not isinstance(po, dict):
|
||||||
|
continue
|
||||||
|
port_copy = dict(po)
|
||||||
|
opts = port_copy.get("dataPickOptions")
|
||||||
|
if isinstance(opts, list):
|
||||||
|
loc_opts: List[Dict[str, Any]] = []
|
||||||
|
for o in opts:
|
||||||
|
if not isinstance(o, dict):
|
||||||
|
continue
|
||||||
|
oc = dict(o)
|
||||||
|
pl = oc.get("pickerLabel")
|
||||||
|
if pl is not None:
|
||||||
|
oc["pickerLabel"] = resolveText(pl, lang)
|
||||||
|
dt = oc.get("detail")
|
||||||
|
if dt is not None:
|
||||||
|
oc["detail"] = resolveText(dt, lang)
|
||||||
|
loc_opts.append(oc)
|
||||||
|
port_copy["dataPickOptions"] = loc_opts
|
||||||
|
out_ports[idx] = port_copy
|
||||||
|
if isinstance(node.get("outputPorts"), dict):
|
||||||
|
out["outputPorts"] = out_ports
|
||||||
|
|
||||||
|
# Legacy node-level key no longer used — do not expose.
|
||||||
|
out.pop("outputPickHints", None)
|
||||||
|
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -95,7 +124,7 @@ def getNodeTypesForApi(
|
||||||
nodes = getNodeTypes(services, language)
|
nodes = getNodeTypes(services, language)
|
||||||
localized = [_localizeNode(n, language) for n in nodes]
|
localized = [_localizeNode(n, language) for n in nodes]
|
||||||
categories = [
|
categories = [
|
||||||
{"id": "trigger", "label": "Trigger"},
|
{"id": "start", "label": "Start"},
|
||||||
{"id": "input", "label": "Eingabe/Mensch"},
|
{"id": "input", "label": "Eingabe/Mensch"},
|
||||||
{"id": "flow", "label": "Ablauf"},
|
{"id": "flow", "label": "Ablauf"},
|
||||||
{"id": "data", "label": "Daten"},
|
{"id": "data", "label": "Daten"},
|
||||||
|
|
@ -112,13 +141,14 @@ def getNodeTypesForApi(
|
||||||
for name, schema in PORT_TYPE_CATALOG.items():
|
for name, schema in PORT_TYPE_CATALOG.items():
|
||||||
catalogSerialized[name] = {
|
catalogSerialized[name] = {
|
||||||
"name": schema.name,
|
"name": schema.name,
|
||||||
"fields": [f.model_dump() for f in schema.fields],
|
"fields": [f.model_dump(by_alias=True, exclude_none=True) for f in schema.fields],
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"nodeTypes": localized,
|
"nodeTypes": localized,
|
||||||
"categories": categories,
|
"categories": categories,
|
||||||
"portTypeCatalog": catalogSerialized,
|
"portTypeCatalog": catalogSerialized,
|
||||||
|
"conditionOperatorCatalog": localize_operator_catalog(language),
|
||||||
"systemVariables": SYSTEM_VARIABLES,
|
"systemVariables": SYSTEM_VARIABLES,
|
||||||
"formFieldTypes": FORM_FIELD_TYPES,
|
"formFieldTypes": FORM_FIELD_TYPES,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,9 @@ import time
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
from modules.shared.i18nRegistry import resolveText
|
from modules.shared.i18nRegistry import resolveText, t
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -25,6 +25,8 @@ logger = logging.getLogger(__name__)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class PortField(BaseModel):
|
class PortField(BaseModel):
|
||||||
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
type: str # str, int, bool, List[str], List[Document], Dict[str,Any], ConnectionRef, …
|
type: str # str, int, bool, List[str], List[Document], Dict[str,Any], ConnectionRef, …
|
||||||
description: str = ""
|
description: str = ""
|
||||||
|
|
@ -36,11 +38,19 @@ class PortField(BaseModel):
|
||||||
discriminator: bool = False
|
discriminator: bool = False
|
||||||
# Surfaces this field at the top of the DataPicker list as the most common pick.
|
# Surfaces this field at the top of the DataPicker list as the most common pick.
|
||||||
recommended: bool = False
|
recommended: bool = False
|
||||||
|
# Human DataPicker title (camelCase JSON for frontend). Omit for technical paths-only.
|
||||||
|
picker_label: Optional[str] = Field(default=None, serialization_alias="pickerLabel")
|
||||||
|
# For List[T] fields: segment between parent and inner field (iteration / one list item).
|
||||||
|
picker_item_label: Optional[str] = Field(default=None, serialization_alias="pickerItemLabel")
|
||||||
|
|
||||||
|
|
||||||
class PortSchema(BaseModel):
|
class PortSchema(BaseModel):
|
||||||
name: str # e.g. "EmailDraft", "AiResult", "Transit"
|
name: str # e.g. "EmailDraft", "AiResult", "Transit"
|
||||||
fields: List[PortField]
|
fields: List[PortField]
|
||||||
|
# Declarative flag for the engine: when True, the executor attaches
|
||||||
|
# connection provenance ({id, authority, label}) onto the output. Replaces
|
||||||
|
# hard-coded schema lists in actionNodeExecutor._attachConnectionProvenance.
|
||||||
|
carriesConnectionProvenance: bool = False
|
||||||
|
|
||||||
|
|
||||||
class InputPortDef(BaseModel):
|
class InputPortDef(BaseModel):
|
||||||
|
|
@ -153,7 +163,7 @@ PORT_TYPE_CATALOG: Dict[str, PortSchema] = {
|
||||||
PortField(name="text", type="str", required=False, description="Textinhalt"),
|
PortField(name="text", type="str", required=False, description="Textinhalt"),
|
||||||
PortField(name="children", type="List[Any]", required=False, description="Unterblöcke"),
|
PortField(name="children", type="List[Any]", required=False, description="Unterblöcke"),
|
||||||
]),
|
]),
|
||||||
"DocumentList": PortSchema(name="DocumentList", fields=[
|
"DocumentList": PortSchema(name="DocumentList", carriesConnectionProvenance=True, fields=[
|
||||||
PortField(name="documents", type="List[Document]",
|
PortField(name="documents", type="List[Document]",
|
||||||
description="Dokumente aus vorherigen Schritten", recommended=True),
|
description="Dokumente aus vorherigen Schritten", recommended=True),
|
||||||
PortField(name="connection", type="ConnectionRef", required=False,
|
PortField(name="connection", type="ConnectionRef", required=False,
|
||||||
|
|
@ -163,7 +173,7 @@ PORT_TYPE_CATALOG: Dict[str, PortSchema] = {
|
||||||
PortField(name="count", type="int", required=False,
|
PortField(name="count", type="int", required=False,
|
||||||
description="Anzahl Dokumente"),
|
description="Anzahl Dokumente"),
|
||||||
]),
|
]),
|
||||||
"FileList": PortSchema(name="FileList", fields=[
|
"FileList": PortSchema(name="FileList", carriesConnectionProvenance=True, fields=[
|
||||||
PortField(name="files", type="List[FileItem]",
|
PortField(name="files", type="List[FileItem]",
|
||||||
description="Dateiliste"),
|
description="Dateiliste"),
|
||||||
PortField(name="connection", type="ConnectionRef", required=False,
|
PortField(name="connection", type="ConnectionRef", required=False,
|
||||||
|
|
@ -173,7 +183,7 @@ PORT_TYPE_CATALOG: Dict[str, PortSchema] = {
|
||||||
PortField(name="count", type="int", required=False,
|
PortField(name="count", type="int", required=False,
|
||||||
description="Anzahl Dateien"),
|
description="Anzahl Dateien"),
|
||||||
]),
|
]),
|
||||||
"EmailDraft": PortSchema(name="EmailDraft", fields=[
|
"EmailDraft": PortSchema(name="EmailDraft", carriesConnectionProvenance=True, fields=[
|
||||||
PortField(name="subject", type="str",
|
PortField(name="subject", type="str",
|
||||||
description="Betreff"),
|
description="Betreff"),
|
||||||
PortField(name="body", type="str",
|
PortField(name="body", type="str",
|
||||||
|
|
@ -187,7 +197,7 @@ PORT_TYPE_CATALOG: Dict[str, PortSchema] = {
|
||||||
PortField(name="connection", type="ConnectionRef", required=False,
|
PortField(name="connection", type="ConnectionRef", required=False,
|
||||||
description="Outlook-/Graph-Verbindung"),
|
description="Outlook-/Graph-Verbindung"),
|
||||||
]),
|
]),
|
||||||
"EmailList": PortSchema(name="EmailList", fields=[
|
"EmailList": PortSchema(name="EmailList", carriesConnectionProvenance=True, fields=[
|
||||||
PortField(name="emails", type="List[EmailItem]",
|
PortField(name="emails", type="List[EmailItem]",
|
||||||
description="E-Mails"),
|
description="E-Mails"),
|
||||||
PortField(name="connection", type="ConnectionRef", required=False,
|
PortField(name="connection", type="ConnectionRef", required=False,
|
||||||
|
|
@ -195,7 +205,7 @@ PORT_TYPE_CATALOG: Dict[str, PortSchema] = {
|
||||||
PortField(name="count", type="int", required=False,
|
PortField(name="count", type="int", required=False,
|
||||||
description="Anzahl"),
|
description="Anzahl"),
|
||||||
]),
|
]),
|
||||||
"TaskList": PortSchema(name="TaskList", fields=[
|
"TaskList": PortSchema(name="TaskList", carriesConnectionProvenance=True, fields=[
|
||||||
PortField(name="tasks", type="List[TaskItem]",
|
PortField(name="tasks", type="List[TaskItem]",
|
||||||
description="Aufgaben"),
|
description="Aufgaben"),
|
||||||
PortField(name="connection", type="ConnectionRef", required=False,
|
PortField(name="connection", type="ConnectionRef", required=False,
|
||||||
|
|
@ -219,15 +229,39 @@ PORT_TYPE_CATALOG: Dict[str, PortSchema] = {
|
||||||
]),
|
]),
|
||||||
"AiResult": PortSchema(name="AiResult", fields=[
|
"AiResult": PortSchema(name="AiResult", fields=[
|
||||||
PortField(name="prompt", type="str",
|
PortField(name="prompt", type="str",
|
||||||
description="Prompt"),
|
description="Prompt",
|
||||||
|
picker_label=t("Eingabe (Prompt des Schritts)"),
|
||||||
|
),
|
||||||
PortField(name="response", type="str",
|
PortField(name="response", type="str",
|
||||||
description="Antworttext", recommended=True),
|
description=(
|
||||||
|
"Antworttext (Modell-Fließtext o. ä.; Bilder liegen in documents, nicht hier)."
|
||||||
|
),
|
||||||
|
recommended=True,
|
||||||
|
picker_label=t("Ausgabetext (Modell)"),
|
||||||
|
),
|
||||||
PortField(name="responseData", type="Dict", required=False,
|
PortField(name="responseData", type="Dict", required=False,
|
||||||
description="Strukturierte Antwort (nur bei JSON-Ausgabe)"),
|
description="Strukturierte Antwort (nur bei JSON-Ausgabe)",
|
||||||
|
picker_label=t("Strukturierte Antwortdaten")),
|
||||||
PortField(name="context", type="str",
|
PortField(name="context", type="str",
|
||||||
description="Kontext"),
|
description="Kontext",
|
||||||
|
picker_label=t("Eingabe-Kontext")),
|
||||||
PortField(name="documents", type="List[Document]",
|
PortField(name="documents", type="List[Document]",
|
||||||
description="Dokumente"),
|
description=(
|
||||||
|
"Erzeugte oder mitgegebene Dateien (z. B. Bilder); documentData = Nutzlast pro Eintrag."
|
||||||
|
),
|
||||||
|
picker_label=t("Alle Ausgabe-Dateien (Liste)"),
|
||||||
|
picker_item_label=t("je Datei"),
|
||||||
|
),
|
||||||
|
PortField(name="data", type="Dict", required=False,
|
||||||
|
description=(
|
||||||
|
"Internes Payload-Objekt (entspricht ``ActionResult.data``-Semantik). "
|
||||||
|
"Wird vom Executor gesetzt und enthält denselben Inhalt wie ``response`` "
|
||||||
|
"in strukturierter Form; primär für nachgelagerte Kontext-Nodes."
|
||||||
|
),
|
||||||
|
picker_label=t("Technische Detaildaten (data)")),
|
||||||
|
PortField(name="imageDocumentsOnly", type="List[Document]", required=False,
|
||||||
|
description="Nur Bild-bezogene Einträge aus documents.",
|
||||||
|
picker_label=t("Nur Bilder (Liste)")),
|
||||||
]),
|
]),
|
||||||
"BoolResult": PortSchema(name="BoolResult", fields=[
|
"BoolResult": PortSchema(name="BoolResult", fields=[
|
||||||
PortField(name="result", type="bool",
|
PortField(name="result", type="bool",
|
||||||
|
|
@ -237,7 +271,8 @@ PORT_TYPE_CATALOG: Dict[str, PortSchema] = {
|
||||||
]),
|
]),
|
||||||
"TextResult": PortSchema(name="TextResult", fields=[
|
"TextResult": PortSchema(name="TextResult", fields=[
|
||||||
PortField(name="text", type="str",
|
PortField(name="text", type="str",
|
||||||
description="Text"),
|
description="Text",
|
||||||
|
picker_label=t("Text (Schrittausgabe)")),
|
||||||
]),
|
]),
|
||||||
"LoopItem": PortSchema(name="LoopItem", fields=[
|
"LoopItem": PortSchema(name="LoopItem", fields=[
|
||||||
PortField(name="currentItem", type="Any",
|
PortField(name="currentItem", type="Any",
|
||||||
|
|
@ -263,13 +298,32 @@ PORT_TYPE_CATALOG: Dict[str, PortSchema] = {
|
||||||
PortField(name="merged", type="Dict",
|
PortField(name="merged", type="Dict",
|
||||||
description="Zusammengeführte Daten"),
|
description="Zusammengeführte Daten"),
|
||||||
]),
|
]),
|
||||||
|
"ContextBranch": PortSchema(name="ContextBranch", fields=[
|
||||||
|
PortField(name="items", type="List[Any]",
|
||||||
|
description="Schleifen-fertige Elemente aus dem (gefilterten) Kontext",
|
||||||
|
recommended=True,
|
||||||
|
picker_label=t("Gefilterte Elemente")),
|
||||||
|
PortField(name="data", type="Dict", required=False,
|
||||||
|
description="Gefilterter Presentation-Umschlag oder Eingabe-Spiegel",
|
||||||
|
picker_label=t("Kontext (data)")),
|
||||||
|
PortField(name="filterApplied", type="bool", required=False,
|
||||||
|
description="True wenn ein Kontext-Inhaltsfilter angewendet wurde"),
|
||||||
|
PortField(name="contentType", type="str", required=False,
|
||||||
|
description="Angewendeter Inhaltstyp-Filter (z. B. image)"),
|
||||||
|
PortField(name="match", type="int", required=False,
|
||||||
|
description="Aktiver Ausgangs-Index (Fall oder Sonst)"),
|
||||||
|
]),
|
||||||
"ActionDocument": PortSchema(name="ActionDocument", fields=[
|
"ActionDocument": PortSchema(name="ActionDocument", fields=[
|
||||||
PortField(name="documentName", type="str",
|
PortField(name="documentName", type="str",
|
||||||
description="Dokumentname"),
|
description="Dokumentname",
|
||||||
|
picker_label=t("Dateiname")),
|
||||||
PortField(name="documentData", type="Any",
|
PortField(name="documentData", type="Any",
|
||||||
description="Inhalt / Rohdaten (z.B. JSON-String, Bytes)"),
|
description="Inhalt / Rohdaten (z.B. JSON-String, Bytes)",
|
||||||
|
picker_label=t("Dateiinhalt (JSON, Text oder Bild)"),
|
||||||
|
recommended=True),
|
||||||
PortField(name="mimeType", type="str",
|
PortField(name="mimeType", type="str",
|
||||||
description="MIME-Typ"),
|
description="MIME-Typ",
|
||||||
|
picker_label=t("Dateityp (MIME)")),
|
||||||
PortField(name="fileId", type="str", required=False,
|
PortField(name="fileId", type="str", required=False,
|
||||||
description="Persistierte FileItem.id (vom Engine ergänzt)"),
|
description="Persistierte FileItem.id (vom Engine ergänzt)"),
|
||||||
PortField(name="fileName", type="str", required=False,
|
PortField(name="fileName", type="str", required=False,
|
||||||
|
|
@ -285,12 +339,62 @@ PORT_TYPE_CATALOG: Dict[str, PortSchema] = {
|
||||||
# Without it in the catalog the DataPicker cannot offer downstream
|
# Without it in the catalog the DataPicker cannot offer downstream
|
||||||
# bindings like `processDocuments → documents → *` for syncToAccounting.
|
# bindings like `processDocuments → documents → *` for syncToAccounting.
|
||||||
PortField(name="documents", type="List[ActionDocument]", required=False,
|
PortField(name="documents", type="List[ActionDocument]", required=False,
|
||||||
description="Erzeugte Dokumente (immer befüllt für Trustee/AI/Email/...)"),
|
description=(
|
||||||
|
"Dokumentliste für Actions mit echten Artefakt-Dokumenten. "
|
||||||
|
"Beim Knoten „Inhalt extrahieren“ fehlt dieses Feld in der Knotenausgabe."
|
||||||
|
),
|
||||||
|
picker_label=t("Alle Ausgabe-Dokumente"),
|
||||||
|
picker_item_label=t("je Dokument"),
|
||||||
|
),
|
||||||
PortField(name="data", type="Dict", required=False,
|
PortField(name="data", type="Dict", required=False,
|
||||||
description="Ergebnisdaten"),
|
description=(
|
||||||
|
"Strukturierter Inhalt. Bei **context.extractContent**: **Presentation**-Root "
|
||||||
|
"(`schemaVersion`, `kind`, `fileOrder`, `files`) plus **`_meta`** — ohne "
|
||||||
|
"zusätzliches `response`/`contentExtracted`-Duplikat."
|
||||||
|
),
|
||||||
|
picker_label=t("Technische Detaildaten (data)")),
|
||||||
|
# Mirror AiResult primary text fields so DataPicker / primaryTextRef behave the same
|
||||||
|
PortField(name="prompt", type="str", required=False,
|
||||||
|
description="Optional: auslösender Prompt / Schrittname",
|
||||||
|
picker_label=t("Auslöser / Prompt (falls vorhanden)")),
|
||||||
|
PortField(name="response", type="str", required=False,
|
||||||
|
description=(
|
||||||
|
"Fließtext wo die Action einen liefert. Bei **„Inhalt extrahieren“** absichtlich leer — "
|
||||||
|
"Inhalt liegt in ``data``.``files``."
|
||||||
|
),
|
||||||
|
recommended=True,
|
||||||
|
picker_label=t("Nur Fließtext (gesamt)")),
|
||||||
|
PortField(name="context", type="str", required=False,
|
||||||
|
description="Optional: Eingabe-Kontext",
|
||||||
|
picker_label=t("Mitgegebener Kontext")),
|
||||||
|
PortField(name="imageDocumentsOnly", type="List[ActionDocument]", required=False,
|
||||||
|
description=(
|
||||||
|
"Nur Bild-bezogene Einträge. Bei „Inhalt extrahieren“: synthetische "
|
||||||
|
"Einträge mit ``fileId`` aus persistierten Extrakt-Bildern (kein separates JSON-Dokument)."
|
||||||
|
),
|
||||||
|
picker_label=t("Nur Bilder (Liste)")),
|
||||||
|
PortField(name="responseData", type="Dict", required=False,
|
||||||
|
description="Optional: strukturierte Zusatzdaten",
|
||||||
|
picker_label=t("Strukturierte Zusatzdaten")),
|
||||||
|
PortField(name="presentation", type="Dict", required=False,
|
||||||
|
description=(
|
||||||
|
"Selten: Top-Level-Spiegel von Präsentationsdaten andere Actions. "
|
||||||
|
"Bei „Inhalt extrahieren“ liegt alles direkt unter ``data`` (kein zusätzlicher Spiegel)."
|
||||||
|
),
|
||||||
|
picker_label=t("Presentation (Top-Level-Spiegel)")),
|
||||||
|
PortField(name="presentationSummary", type="Dict", required=False,
|
||||||
|
description=(
|
||||||
|
"Kompakte Metadaten zu ``presentation`` (Debugging / traces)."
|
||||||
|
),
|
||||||
|
picker_label=t("Presentation-Zusammenfassung")),
|
||||||
|
PortField(name="presentationConfig", type="Dict", required=False,
|
||||||
|
description=(
|
||||||
|
"Optional: Debugging-Konfiguration; bei Extract liegt die Primärquelle in ``validationMetadata`` des JSON-Dokuments."
|
||||||
|
),
|
||||||
|
picker_label=t("Presentation-Konfiguration")),
|
||||||
]),
|
]),
|
||||||
"Transit": PortSchema(name="Transit", fields=[]),
|
"Transit": PortSchema(name="Transit", fields=[]),
|
||||||
"UdmDocument": PortSchema(name="UdmDocument", fields=[
|
"UdmDocument": PortSchema(name="UdmDocument", carriesConnectionProvenance=True, fields=[
|
||||||
PortField(name="id", type="str", description="Dokument-ID"),
|
PortField(name="id", type="str", description="Dokument-ID"),
|
||||||
PortField(name="sourceType", type="str", description="Quellformat (pdf, docx, …)"),
|
PortField(name="sourceType", type="str", description="Quellformat (pdf, docx, …)"),
|
||||||
PortField(name="sourcePath", type="str", description="Quellpfad"),
|
PortField(name="sourcePath", type="str", description="Quellpfad"),
|
||||||
|
|
@ -620,6 +724,24 @@ SYSTEM_VARIABLES: Dict[str, Dict[str, str]] = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Graph inheritance (executeGraph materialization + ActionNodeExecutor wiring)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# When a parameter declares ``graphInherit.kind == "primaryTextRef"``, executeGraph
|
||||||
|
# inserts an explicit DataRef before run (see pickNotPushMigration.materializePrimaryTextHandover).
|
||||||
|
# ``recommendedDataPickRef`` uses upstream ``outputPorts.dataPickOptions`` where ``recommended: true``
|
||||||
|
# (see pickNotPushMigration.materializeRecommendedDataPickRef).
|
||||||
|
# Schema names are catalog output port types (e.g. AiResult).
|
||||||
|
|
||||||
|
PRIMARY_TEXT_HANDOVER_REF_PATH: Dict[str, List[Any]] = {
|
||||||
|
"AiResult": ["response"],
|
||||||
|
"ActionResult": ["response"],
|
||||||
|
"TextResult": ["text"],
|
||||||
|
"ConsolidateResult": ["result"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def resolveSystemVariable(variable: str, context: Dict[str, Any]) -> Any:
|
def resolveSystemVariable(variable: str, context: Dict[str, Any]) -> Any:
|
||||||
"""Resolve a system variable name to its runtime value."""
|
"""Resolve a system variable name to its runtime value."""
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
@ -817,8 +939,22 @@ def _resolveTransitChain(
|
||||||
# Schema derivation for dynamic outputs
|
# Schema derivation for dynamic outputs
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def deriveFormPayloadSchemaFromParam(node: Dict[str, Any], param_key: str) -> Optional[PortSchema]:
|
def deriveFormPayloadSchemaFromParam(
|
||||||
"""Derive output schema from a field-builder JSON list (``fields``, ``formFields``, …)."""
|
node: Dict[str, Any],
|
||||||
|
param_key: str,
|
||||||
|
name_field: str = "name",
|
||||||
|
type_field: str = "type",
|
||||||
|
label_field: str = "label",
|
||||||
|
schema_name: str = "FormPayload_dynamic",
|
||||||
|
) -> Optional[PortSchema]:
|
||||||
|
"""Derive an output schema from a graph-defined parameter.
|
||||||
|
|
||||||
|
Supports three parameter shapes:
|
||||||
|
- List[Dict] with ``name_field`` (e.g. ``fields[].name``, ``entries[].key``,
|
||||||
|
``mappings[].outputField``).
|
||||||
|
- Group-fields: ``type == "group"`` recursed via ``fields``.
|
||||||
|
- List[str]: each string is taken as a leaf path key (used for ``filterContext.keys``).
|
||||||
|
"""
|
||||||
from modules.features.graphicalEditor.nodeDefinitions.input import FORM_FIELD_TYPES
|
from modules.features.graphicalEditor.nodeDefinitions.input import FORM_FIELD_TYPES
|
||||||
_FORM_TYPE_TO_PORT: Dict[str, str] = {f["id"]: f["portType"] for f in FORM_FIELD_TYPES}
|
_FORM_TYPE_TO_PORT: Dict[str, str] = {f["id"]: f["portType"] for f in FORM_FIELD_TYPES}
|
||||||
|
|
||||||
|
|
@ -841,21 +977,35 @@ def deriveFormPayloadSchemaFromParam(node: Dict[str, Any], param_key: str) -> Op
|
||||||
))
|
))
|
||||||
|
|
||||||
for f in fields_param:
|
for f in fields_param:
|
||||||
if not isinstance(f, dict) or not f.get("name"):
|
if isinstance(f, str):
|
||||||
|
if f.strip():
|
||||||
|
_append_field(f.strip(), "str", None, False)
|
||||||
continue
|
continue
|
||||||
fname = str(f["name"])
|
if not isinstance(f, dict):
|
||||||
if str(f.get("type", "")).lower() == "group" and isinstance(f.get("fields"), list):
|
continue
|
||||||
|
fname_raw = f.get(name_field)
|
||||||
|
if not fname_raw and name_field == "contextKey":
|
||||||
|
fname_raw = f.get("key")
|
||||||
|
if not fname_raw:
|
||||||
|
continue
|
||||||
|
fname = str(fname_raw)
|
||||||
|
if str(f.get(type_field, "")).lower() == "group" and isinstance(f.get("fields"), list):
|
||||||
for sub in f["fields"]:
|
for sub in f["fields"]:
|
||||||
if isinstance(sub, dict) and sub.get("name"):
|
if isinstance(sub, dict) and sub.get(name_field):
|
||||||
_append_field(
|
_append_field(
|
||||||
f"{fname}.{sub['name']}",
|
f"{fname}.{sub[name_field]}",
|
||||||
sub.get("type", "str"),
|
sub.get(type_field, "str"),
|
||||||
sub.get("label"),
|
sub.get(label_field),
|
||||||
bool(sub.get("required", False)),
|
bool(sub.get("required", False)),
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
_append_field(fname, f.get("type", "str"), f.get("label"), bool(f.get("required", False)))
|
_append_field(
|
||||||
return PortSchema(name="FormPayload_dynamic", fields=portFields) if portFields else None
|
fname,
|
||||||
|
f.get(type_field, "str"),
|
||||||
|
f.get(label_field),
|
||||||
|
bool(f.get("required", False)),
|
||||||
|
)
|
||||||
|
return PortSchema(name=schema_name, fields=portFields) if portFields else None
|
||||||
|
|
||||||
|
|
||||||
def _deriveFormPayloadSchema(node: Dict[str, Any]) -> Optional[PortSchema]:
|
def _deriveFormPayloadSchema(node: Dict[str, Any]) -> Optional[PortSchema]:
|
||||||
|
|
@ -880,9 +1030,20 @@ def parse_graph_defined_output_schema(
|
||||||
schema_spec = output_port.get("schema")
|
schema_spec = output_port.get("schema")
|
||||||
if isinstance(schema_spec, dict) and schema_spec.get("kind") == "fromGraph":
|
if isinstance(schema_spec, dict) and schema_spec.get("kind") == "fromGraph":
|
||||||
param_key = str(schema_spec.get("parameter") or "fields")
|
param_key = str(schema_spec.get("parameter") or "fields")
|
||||||
return deriveFormPayloadSchemaFromParam(node, param_key)
|
name_field = str(schema_spec.get("nameField") or "name")
|
||||||
|
type_field = str(schema_spec.get("typeField") or "type")
|
||||||
|
label_field = str(schema_spec.get("labelField") or "label")
|
||||||
|
schema_name = str(schema_spec.get("schemaName") or "FormPayload_dynamic")
|
||||||
|
return deriveFormPayloadSchemaFromParam(
|
||||||
|
node, param_key,
|
||||||
|
name_field=name_field, type_field=type_field,
|
||||||
|
label_field=label_field, schema_name=schema_name,
|
||||||
|
)
|
||||||
if output_port.get("dynamic") and output_port.get("deriveFrom"):
|
if output_port.get("dynamic") and output_port.get("deriveFrom"):
|
||||||
return deriveFormPayloadSchemaFromParam(node, str(output_port.get("deriveFrom")))
|
name_field = str(output_port.get("deriveNameField") or "name")
|
||||||
|
return deriveFormPayloadSchemaFromParam(
|
||||||
|
node, str(output_port.get("deriveFrom")), name_field=name_field,
|
||||||
|
)
|
||||||
if isinstance(schema_spec, str) and schema_spec:
|
if isinstance(schema_spec, str) and schema_spec:
|
||||||
return PORT_TYPE_CATALOG.get(schema_spec)
|
return PORT_TYPE_CATALOG.get(schema_spec)
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,8 @@ from modules.workflows.automation2.runEnvelope import (
|
||||||
normalize_run_envelope,
|
normalize_run_envelope,
|
||||||
)
|
)
|
||||||
from modules.features.graphicalEditor.entryPoints import find_invocation
|
from modules.features.graphicalEditor.entryPoints import find_invocation
|
||||||
from modules.features.graphicalEditor.upstreamPathsService import compute_upstream_paths
|
from modules.features.graphicalEditor.conditionOperators import resolve_condition_meta
|
||||||
|
from modules.features.graphicalEditor.upstreamPathsService import compute_upstream_paths, compute_graph_data_sources
|
||||||
from modules.shared.i18nRegistry import apiRouteContext, resolveText
|
from modules.shared.i18nRegistry import apiRouteContext, resolveText
|
||||||
routeApiMsg = apiRouteContext("routeFeatureGraphicalEditor")
|
routeApiMsg = apiRouteContext("routeFeatureGraphicalEditor")
|
||||||
|
|
||||||
|
|
@ -192,6 +193,56 @@ def post_upstream_paths(
|
||||||
return {"paths": paths}
|
return {"paths": paths}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{instanceId}/condition-meta")
|
||||||
|
@limiter.limit("120/minute")
|
||||||
|
def post_condition_meta(
|
||||||
|
request: Request,
|
||||||
|
instanceId: str = Path(..., description="Feature instance ID"),
|
||||||
|
body: Dict[str, Any] = Body(...),
|
||||||
|
language: str = Query("de", description="Localization (en, de, fr)"),
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
) -> dict:
|
||||||
|
"""Return valueKind and operators for a DataRef (backend-driven If/Else UI)."""
|
||||||
|
_validateInstanceAccess(instanceId, context)
|
||||||
|
graph = body.get("graph")
|
||||||
|
ref = body.get("ref")
|
||||||
|
node_id = body.get("nodeId")
|
||||||
|
if not isinstance(graph, dict) or not isinstance(ref, dict):
|
||||||
|
raise HTTPException(status_code=400, detail=routeApiMsg("graph and ref are required"))
|
||||||
|
graph_payload = dict(graph)
|
||||||
|
if node_id:
|
||||||
|
graph_payload["targetNodeId"] = str(node_id)
|
||||||
|
return resolve_condition_meta(graph_payload, ref, lang=language)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{instanceId}/graph-data-sources")
|
||||||
|
@limiter.limit("120/minute")
|
||||||
|
def post_graph_data_sources(
|
||||||
|
request: Request,
|
||||||
|
instanceId: str = Path(..., description="Feature instance ID"),
|
||||||
|
body: Dict[str, Any] = Body(...),
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
) -> dict:
|
||||||
|
"""Scope-aware data sources for the DataPicker.
|
||||||
|
|
||||||
|
Takes ``{ nodeId, graph: { nodes, connections } }`` and returns::
|
||||||
|
|
||||||
|
{
|
||||||
|
"availableSourceIds": [...], # ancestors minus loop-body nodes on Done branch
|
||||||
|
"portIndexOverrides": {nodeId: n}, # use outputPorts[n] instead of 0
|
||||||
|
"loopBodyContextIds": [...], # loops whose body the node is in
|
||||||
|
}
|
||||||
|
|
||||||
|
All loop scope logic lives here so the frontend has zero topology knowledge.
|
||||||
|
"""
|
||||||
|
_validateInstanceAccess(instanceId, context)
|
||||||
|
graph = body.get("graph")
|
||||||
|
node_id = body.get("nodeId")
|
||||||
|
if not isinstance(graph, dict) or not node_id:
|
||||||
|
raise HTTPException(status_code=400, detail=routeApiMsg("graph and nodeId are required"))
|
||||||
|
return compute_graph_data_sources(graph, str(node_id))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{instanceId}/upstream-paths/{node_id}")
|
@router.get("/{instanceId}/upstream-paths/{node_id}")
|
||||||
@limiter.limit("60/minute")
|
@limiter.limit("60/minute")
|
||||||
def get_upstream_paths_saved(
|
def get_upstream_paths_saved(
|
||||||
|
|
@ -1724,6 +1775,51 @@ async def complete_task(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{instanceId}/tasks/{taskId}/cancel")
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
def cancel_pending_task_stop_run(
|
||||||
|
request: Request,
|
||||||
|
instanceId: str = Path(..., description="Feature instance ID"),
|
||||||
|
taskId: str = Path(..., description="Human task ID"),
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
) -> dict:
|
||||||
|
"""Cancel a pending human task and stop the workflow run behind it."""
|
||||||
|
mandateId = _validateInstanceAccess(instanceId, context)
|
||||||
|
iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
|
||||||
|
task = iface.getTask(taskId)
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(status_code=404, detail=routeApiMsg("Task not found"))
|
||||||
|
|
||||||
|
wf_ids = {w.get("id") for w in iface.getWorkflows() if w.get("id")}
|
||||||
|
if task.get("workflowId") not in wf_ids:
|
||||||
|
raise HTTPException(status_code=404, detail=routeApiMsg("Task not found"))
|
||||||
|
|
||||||
|
if task.get("status") != "pending":
|
||||||
|
raise HTTPException(status_code=400, detail=routeApiMsg("Task already completed"))
|
||||||
|
|
||||||
|
run_id = task.get("runId")
|
||||||
|
|
||||||
|
from modules.workflows.automation2.executionEngine import requestRunStop
|
||||||
|
|
||||||
|
if run_id:
|
||||||
|
requestRunStop(run_id)
|
||||||
|
db_run = iface.getRun(run_id)
|
||||||
|
if db_run:
|
||||||
|
current = db_run.get("status") or ""
|
||||||
|
if current not in ("completed", "failed", "cancelled"):
|
||||||
|
iface.updateRun(run_id, status="cancelled")
|
||||||
|
|
||||||
|
pending = iface.getTasks(runId=run_id, status="pending")
|
||||||
|
for t in pending:
|
||||||
|
tid = t.get("id")
|
||||||
|
if tid:
|
||||||
|
iface.updateTask(tid, status="cancelled")
|
||||||
|
else:
|
||||||
|
iface.updateTask(taskId, status="cancelled")
|
||||||
|
|
||||||
|
return {"success": True, "runId": run_id, "taskId": taskId}
|
||||||
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Monitoring / Metrics
|
# Monitoring / Metrics
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|
|
||||||
308
modules/features/graphicalEditor/switchOutput.py
Normal file
308
modules/features/graphicalEditor/switchOutput.py
Normal file
|
|
@ -0,0 +1,308 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
"""Build flow.switch branch payloads: filtered context + loop-ready items."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import re
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from modules.features.graphicalEditor.portTypes import unwrapTransit
|
||||||
|
|
||||||
|
_CONTEXT_FILTER_OPERATORS = frozenset({"contains_content"})
|
||||||
|
_BLOB_IMAGE_CHUNK_RE = re.compile(r"^\[image(?:\:([^\]]+))?\]$")
|
||||||
|
|
||||||
|
|
||||||
|
def _artifacts_by_part_id_from_presentation(inp: Any) -> Dict[str, str]:
|
||||||
|
plain = _unwrap_input(inp)
|
||||||
|
meta = plain.get("_meta") if isinstance(plain, dict) else None
|
||||||
|
if not isinstance(meta, dict):
|
||||||
|
return {}
|
||||||
|
out: Dict[str, str] = {}
|
||||||
|
for art in meta.get("persistedImageArtifacts") or []:
|
||||||
|
if not isinstance(art, dict):
|
||||||
|
continue
|
||||||
|
sp = str(art.get("sourcePartId") or "").strip()
|
||||||
|
fid = str(art.get("fileId") or "").strip()
|
||||||
|
if sp and fid:
|
||||||
|
out[sp] = fid
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _enrich_image_slot(slot: Dict[str, Any], artifacts_by_part: Dict[str, str]) -> None:
|
||||||
|
if (slot.get("typeGroup") or "").strip().lower() != "image":
|
||||||
|
return
|
||||||
|
existing = str(slot.get("embeddedImageFileId") or "").strip()
|
||||||
|
if existing and existing in artifacts_by_part.values():
|
||||||
|
return
|
||||||
|
candidates: List[str] = []
|
||||||
|
sid = str(slot.get("id") or "").strip()
|
||||||
|
if sid:
|
||||||
|
candidates.append(sid)
|
||||||
|
data = slot.get("data")
|
||||||
|
if isinstance(data, str):
|
||||||
|
m = _BLOB_IMAGE_CHUNK_RE.fullmatch(data.strip())
|
||||||
|
if m:
|
||||||
|
tok = (m.group(1) or "").strip()
|
||||||
|
if tok:
|
||||||
|
candidates.append(tok)
|
||||||
|
for cand in candidates:
|
||||||
|
fid = artifacts_by_part.get(cand)
|
||||||
|
if fid:
|
||||||
|
slot["embeddedImageFileId"] = fid
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def _slot_matches_content_type(slot: Dict[str, Any], content_type: str) -> bool:
|
||||||
|
target = (content_type or "").strip().lower()
|
||||||
|
if not target:
|
||||||
|
return False
|
||||||
|
tg = (slot.get("typeGroup") or slot.get("contentType") or "").strip().lower()
|
||||||
|
if target == "media":
|
||||||
|
return tg in ("image", "media", "video", "audio")
|
||||||
|
if target == "text":
|
||||||
|
return tg in ("text", "table", "structure")
|
||||||
|
return tg == target
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_bucket_slots(bucket: Dict[str, Any], content_type: str) -> Dict[str, Any]:
|
||||||
|
"""Return a copy of a presentation file bucket with filtered ``data`` slots."""
|
||||||
|
mode = str(bucket.get("outputMode") or "").strip().lower()
|
||||||
|
data = bucket.get("data")
|
||||||
|
if mode == "blob" and isinstance(data, str):
|
||||||
|
from modules.workflows.methods.methodContext.actions.extractContent import (
|
||||||
|
filter_blob_bucket_by_content_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
return filter_blob_bucket_by_content_type(bucket, content_type)
|
||||||
|
out = copy.deepcopy(bucket)
|
||||||
|
if isinstance(data, list):
|
||||||
|
out["data"] = [s for s in data if isinstance(s, dict) and _slot_matches_content_type(s, content_type)]
|
||||||
|
elif isinstance(data, dict) and _slot_matches_content_type(data, content_type):
|
||||||
|
out["data"] = data
|
||||||
|
else:
|
||||||
|
out["data"] = [] if isinstance(data, list) else data
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_presentation_envelope(envelope: Dict[str, Any], content_type: str) -> Dict[str, Any]:
|
||||||
|
"""Filter all slots in a presentation envelope by content type group."""
|
||||||
|
from modules.workflows.methods.methodContext.actions.extractContent import (
|
||||||
|
PRESENTATION_KIND,
|
||||||
|
PRESENTATION_SCHEMA_VERSION,
|
||||||
|
)
|
||||||
|
|
||||||
|
out = copy.deepcopy(envelope)
|
||||||
|
files = out.get("files") or {}
|
||||||
|
if not isinstance(files, dict):
|
||||||
|
return out
|
||||||
|
filtered_files: Dict[str, Any] = {}
|
||||||
|
kept_order: List[str] = []
|
||||||
|
for fk in out.get("fileOrder") or list(files.keys()):
|
||||||
|
bucket = files.get(fk)
|
||||||
|
if not isinstance(bucket, dict):
|
||||||
|
continue
|
||||||
|
fb = _filter_bucket_slots(bucket, content_type)
|
||||||
|
data = fb.get("data")
|
||||||
|
has_data = (
|
||||||
|
(isinstance(data, list) and len(data) > 0)
|
||||||
|
or (isinstance(data, dict))
|
||||||
|
or (isinstance(data, str) and str(data).strip())
|
||||||
|
)
|
||||||
|
if has_data:
|
||||||
|
filtered_files[str(fk)] = fb
|
||||||
|
kept_order.append(str(fk))
|
||||||
|
out["schemaVersion"] = out.get("schemaVersion") or PRESENTATION_SCHEMA_VERSION
|
||||||
|
out["kind"] = out.get("kind") or PRESENTATION_KIND
|
||||||
|
out["fileOrder"] = kept_order
|
||||||
|
out["files"] = filtered_files
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _slots_from_bucket(bucket: Dict[str, Any]) -> List[Any]:
|
||||||
|
data = bucket.get("data")
|
||||||
|
mode = str(bucket.get("outputMode") or "").strip().lower()
|
||||||
|
if mode == "blob" and isinstance(data, str) and data.strip():
|
||||||
|
from modules.workflows.methods.methodContext.actions.extractContent import parse_blob_data_segments
|
||||||
|
|
||||||
|
return parse_blob_data_segments(data)
|
||||||
|
if isinstance(data, list):
|
||||||
|
return [s for s in data if isinstance(s, dict)]
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return [data]
|
||||||
|
if isinstance(data, str) and data.strip():
|
||||||
|
return [{"typeGroup": "text", "data": data}]
|
||||||
|
items = bucket.get("items")
|
||||||
|
if isinstance(items, list):
|
||||||
|
return [i for i in items if isinstance(i, dict)]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _items_from_presentation_envelope(
|
||||||
|
envelope: Dict[str, Any],
|
||||||
|
*,
|
||||||
|
artifacts_by_part: Optional[Dict[str, str]] = None,
|
||||||
|
) -> List[Any]:
|
||||||
|
items: List[Any] = []
|
||||||
|
files = envelope.get("files") or {}
|
||||||
|
if not isinstance(files, dict):
|
||||||
|
return items
|
||||||
|
for fk in envelope.get("fileOrder") or list(files.keys()):
|
||||||
|
bucket = files.get(fk)
|
||||||
|
if isinstance(bucket, dict):
|
||||||
|
for slot in _slots_from_bucket(bucket):
|
||||||
|
if artifacts_by_part:
|
||||||
|
_enrich_image_slot(slot, artifacts_by_part)
|
||||||
|
sid = str(slot.get("id") or slot.get("label") or len(items))
|
||||||
|
items.append({"name": f"{fk}:{sid}", "value": slot})
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def expand_items_from_input(raw: Any) -> List[Any]:
|
||||||
|
"""Best-effort loop items from transit/presentation/list/dict input."""
|
||||||
|
if raw is None:
|
||||||
|
return []
|
||||||
|
if isinstance(raw, dict) and isinstance(raw.get("items"), list):
|
||||||
|
return list(raw["items"])
|
||||||
|
plain = unwrapTransit(raw) if isinstance(raw, dict) and raw.get("_transit") else raw
|
||||||
|
if isinstance(plain, dict) and isinstance(plain.get("items"), list):
|
||||||
|
return list(plain["items"])
|
||||||
|
from modules.workflows.methods.methodContext.actions.extractContent import (
|
||||||
|
normalize_presentation_envelopes,
|
||||||
|
)
|
||||||
|
|
||||||
|
envelopes = normalize_presentation_envelopes(plain)
|
||||||
|
if envelopes:
|
||||||
|
out: List[Any] = []
|
||||||
|
for env in envelopes:
|
||||||
|
out.extend(_items_from_presentation_envelope(env))
|
||||||
|
return out
|
||||||
|
if isinstance(plain, list):
|
||||||
|
return list(plain)
|
||||||
|
if isinstance(plain, dict):
|
||||||
|
children = plain.get("children")
|
||||||
|
if isinstance(children, list) and children:
|
||||||
|
return list(children)
|
||||||
|
return [{"name": k, "value": v} for k, v in plain.items()]
|
||||||
|
return [plain]
|
||||||
|
|
||||||
|
|
||||||
|
def _unwrap_input(inp: Any) -> Any:
|
||||||
|
if isinstance(inp, dict) and inp.get("_transit"):
|
||||||
|
return unwrapTransit(inp)
|
||||||
|
return inp
|
||||||
|
|
||||||
|
|
||||||
|
def build_switch_branch_payload(
|
||||||
|
inp: Any,
|
||||||
|
case: Dict[str, Any],
|
||||||
|
*,
|
||||||
|
value_kind: str = "unknown",
|
||||||
|
match_index: int = 0,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Payload for a matched switch case (ContextBranch inner data)."""
|
||||||
|
operator = str(case.get("operator") or "eq")
|
||||||
|
right = case.get("value")
|
||||||
|
plain_in = _unwrap_input(inp)
|
||||||
|
|
||||||
|
if operator in _CONTEXT_FILTER_OPERATORS and value_kind == "context":
|
||||||
|
content_type = str(right or "")
|
||||||
|
from modules.workflows.methods.methodContext.actions.extractContent import (
|
||||||
|
normalize_presentation_envelopes,
|
||||||
|
)
|
||||||
|
|
||||||
|
source = plain_in
|
||||||
|
if isinstance(source, dict) and "data" in source and not source.get("kind"):
|
||||||
|
nested = source.get("data")
|
||||||
|
if isinstance(nested, dict):
|
||||||
|
source = nested
|
||||||
|
envelopes = normalize_presentation_envelopes(source)
|
||||||
|
if not envelopes and isinstance(plain_in, dict):
|
||||||
|
envelopes = normalize_presentation_envelopes(plain_in)
|
||||||
|
filtered_envs = [_filter_presentation_envelope(env, content_type) for env in envelopes]
|
||||||
|
artifacts_by_part = _artifacts_by_part_id_from_presentation(plain_in)
|
||||||
|
items: List[Any] = []
|
||||||
|
for env in filtered_envs:
|
||||||
|
items.extend(_items_from_presentation_envelope(env, artifacts_by_part=artifacts_by_part))
|
||||||
|
if len(filtered_envs) == 1:
|
||||||
|
data_out: Any = filtered_envs[0]
|
||||||
|
elif filtered_envs:
|
||||||
|
data_out = {"envelopes": filtered_envs}
|
||||||
|
else:
|
||||||
|
data_out = {}
|
||||||
|
return {
|
||||||
|
"data": data_out,
|
||||||
|
"items": items,
|
||||||
|
"filterApplied": True,
|
||||||
|
"contentType": content_type,
|
||||||
|
"match": match_index,
|
||||||
|
}
|
||||||
|
|
||||||
|
data_out = plain_in if isinstance(plain_in, dict) else {"value": plain_in}
|
||||||
|
return {
|
||||||
|
"data": data_out,
|
||||||
|
"items": expand_items_from_input(inp),
|
||||||
|
"filterApplied": False,
|
||||||
|
"match": match_index,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_switch_default_payload(inp: Any, *, match_index: int) -> Dict[str, Any]:
|
||||||
|
"""Sonst branch: unmodified input passthrough."""
|
||||||
|
plain_in = _unwrap_input(inp)
|
||||||
|
data_out = plain_in if isinstance(plain_in, dict) else {"value": plain_in}
|
||||||
|
return {
|
||||||
|
"data": data_out,
|
||||||
|
"items": expand_items_from_input(inp),
|
||||||
|
"filterApplied": False,
|
||||||
|
"match": match_index,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_switch_combined_output(
|
||||||
|
inp: Any,
|
||||||
|
cases: List[Any],
|
||||||
|
*,
|
||||||
|
matched_indices: List[int],
|
||||||
|
value_kind: str = "unknown",
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Build per-port branch payloads; primary fields mirror the first active match."""
|
||||||
|
branches: Dict[str, Dict[str, Any]] = {}
|
||||||
|
default_idx = len(cases)
|
||||||
|
for idx in matched_indices:
|
||||||
|
if idx == default_idx:
|
||||||
|
branches[str(idx)] = build_switch_default_payload(inp, match_index=default_idx)
|
||||||
|
elif 0 <= idx < len(cases):
|
||||||
|
c = cases[idx] if isinstance(cases[idx], dict) else {"operator": "eq", "value": cases[idx]}
|
||||||
|
branches[str(idx)] = build_switch_branch_payload(
|
||||||
|
inp, c, value_kind=value_kind, match_index=idx,
|
||||||
|
)
|
||||||
|
primary_idx = matched_indices[0] if matched_indices else default_idx
|
||||||
|
primary = branches.get(str(primary_idx)) or build_switch_default_payload(inp, match_index=default_idx)
|
||||||
|
return {**primary, "branches": branches}
|
||||||
|
|
||||||
|
|
||||||
|
def switch_branch_payload(transit: Any, source_output: int) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Return the ContextBranch inner dict for a specific switch output port."""
|
||||||
|
if not isinstance(transit, dict):
|
||||||
|
return None
|
||||||
|
data = transit.get("data") if transit.get("_transit") else transit
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None
|
||||||
|
branches = data.get("branches")
|
||||||
|
if isinstance(branches, dict):
|
||||||
|
branch = branches.get(str(source_output))
|
||||||
|
if isinstance(branch, dict):
|
||||||
|
return branch
|
||||||
|
if transit.get("_transit"):
|
||||||
|
return data
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def unwrap_transit_for_port(output: Any, source_output: Optional[int] = None) -> Any:
|
||||||
|
"""Unwrap transit; when ``source_output`` is set, pick that switch branch payload."""
|
||||||
|
if source_output is not None:
|
||||||
|
branch = switch_branch_payload(output, source_output)
|
||||||
|
if branch is not None:
|
||||||
|
return branch
|
||||||
|
return unwrapTransit(output)
|
||||||
|
|
@ -4,9 +4,10 @@ from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Dict, List, Set
|
from typing import Any, Dict, List, Set
|
||||||
|
|
||||||
|
from modules.features.graphicalEditor.conditionOperators import catalog_type_to_value_kind, resolve_value_kind
|
||||||
from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES
|
from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES
|
||||||
from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG, PortSchema, parse_graph_defined_output_schema
|
from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG, PortSchema, parse_graph_defined_output_schema
|
||||||
from modules.workflows.automation2.graphUtils import buildConnectionMap
|
from modules.workflows.automation2.graphUtils import buildConnectionMap, getLoopBodyNodeIds, getLoopDoneNodeIds
|
||||||
|
|
||||||
_NODE_BY_TYPE = {n["id"]: n for n in STATIC_NODE_TYPES}
|
_NODE_BY_TYPE = {n["id"]: n for n in STATIC_NODE_TYPES}
|
||||||
|
|
||||||
|
|
@ -36,6 +37,31 @@ def _paths_for_port_schema(schema: PortSchema, producer_node_id: str) -> List[Di
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _paths_for_data_pick_options(
|
||||||
|
options: List[Dict[str, Any]],
|
||||||
|
producer_node_id: str,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Explicit per-port pick list from node definition (authoritative; no catalog expansion)."""
|
||||||
|
out: List[Dict[str, Any]] = []
|
||||||
|
for o in options:
|
||||||
|
if not isinstance(o, dict):
|
||||||
|
continue
|
||||||
|
path = o.get("path")
|
||||||
|
if not isinstance(path, list):
|
||||||
|
continue
|
||||||
|
label = o.get("pickerLabel")
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"producerNodeId": producer_node_id,
|
||||||
|
"path": path,
|
||||||
|
"type": o.get("type") or "Any",
|
||||||
|
"label": label if isinstance(label, str) else ".".join(str(p) for p in path),
|
||||||
|
"scopeOrigin": "data",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _paths_for_schema(schema_name: str, producer_node_id: str) -> List[Dict[str, Any]]:
|
def _paths_for_schema(schema_name: str, producer_node_id: str) -> List[Dict[str, Any]]:
|
||||||
if not schema_name or schema_name == "Transit":
|
if not schema_name or schema_name == "Transit":
|
||||||
return []
|
return []
|
||||||
|
|
@ -83,22 +109,39 @@ def compute_upstream_paths(graph: Dict[str, Any], target_node_id: str) -> List[D
|
||||||
if not ndef:
|
if not ndef:
|
||||||
continue
|
continue
|
||||||
out0 = (ndef.get("outputPorts") or {}).get(0, {})
|
out0 = (ndef.get("outputPorts") or {}).get(0, {})
|
||||||
derived = parse_graph_defined_output_schema(anode, out0 if isinstance(out0, dict) else {})
|
out0 = out0 if isinstance(out0, dict) else {}
|
||||||
|
dpo = out0.get("dataPickOptions")
|
||||||
|
|
||||||
|
bases: List[Dict[str, Any]] = []
|
||||||
|
if isinstance(dpo, list):
|
||||||
|
bases = _paths_for_data_pick_options(dpo, aid)
|
||||||
|
derived = parse_graph_defined_output_schema(anode, out0)
|
||||||
|
derived_paths: List[Dict[str, Any]] = []
|
||||||
if derived:
|
if derived:
|
||||||
for entry in _paths_for_port_schema(derived, aid):
|
derived_paths = _paths_for_port_schema(derived, aid)
|
||||||
entry["producerLabel"] = (anode.get("title") or "").strip() or aid
|
|
||||||
|
merged_list = bases + derived_paths
|
||||||
|
if merged_list:
|
||||||
|
plab = (anode.get("title") or "").strip() or aid
|
||||||
|
for entry in merged_list:
|
||||||
|
entry["producerLabel"] = plab
|
||||||
paths.append(entry)
|
paths.append(entry)
|
||||||
else:
|
continue
|
||||||
|
|
||||||
raw_schema = out0.get("schema") if isinstance(out0, dict) else None
|
raw_schema = out0.get("schema") if isinstance(out0, dict) else None
|
||||||
schema_name = raw_schema if isinstance(raw_schema, str) and raw_schema else "ActionResult"
|
schema_name = raw_schema if isinstance(raw_schema, str) and raw_schema else "ActionResult"
|
||||||
|
plab = (anode.get("title") or "").strip() or aid
|
||||||
for entry in _paths_for_schema(schema_name, aid):
|
for entry in _paths_for_schema(schema_name, aid):
|
||||||
entry["producerLabel"] = (anode.get("title") or "").strip() or aid
|
entry["producerLabel"] = plab
|
||||||
paths.append(entry)
|
paths.append(entry)
|
||||||
|
|
||||||
# Lexical loop hints (flow.loop): any loop node in ancestors adds synthetic paths
|
# Lexical loop hints (flow.loop): only for nodes inside the loop body
|
||||||
for aid in ancestors:
|
for aid in ancestors:
|
||||||
anode = node_by_id.get(aid) or {}
|
anode = node_by_id.get(aid) or {}
|
||||||
if anode.get("type") == "flow.loop":
|
if anode.get("type") != "flow.loop":
|
||||||
|
continue
|
||||||
|
body_ids = getLoopBodyNodeIds(aid, conn_map)
|
||||||
|
if target_node_id in body_ids:
|
||||||
paths.extend(
|
paths.extend(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
|
|
@ -125,4 +168,93 @@ def compute_upstream_paths(graph: Dict[str, Any], target_node_id: str) -> List[D
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
for entry in paths:
|
||||||
|
ct = str(entry.get("type") or "Any")
|
||||||
|
vk = catalog_type_to_value_kind(ct)
|
||||||
|
if vk == "unknown":
|
||||||
|
ref = {
|
||||||
|
"nodeId": entry.get("producerNodeId"),
|
||||||
|
"path": entry.get("path") or [],
|
||||||
|
}
|
||||||
|
graph_with_target = {**graph, "targetNodeId": target_node_id}
|
||||||
|
vk = resolve_value_kind(graph_with_target, ref, _skip_upstream=True)
|
||||||
|
entry["valueKind"] = vk
|
||||||
|
|
||||||
return paths
|
return paths
|
||||||
|
|
||||||
|
|
||||||
|
def compute_graph_data_sources(graph: Dict[str, Any], target_node_id: str) -> Dict[str, Any]:
|
||||||
|
"""Return scope-aware data sources for the DataPicker.
|
||||||
|
|
||||||
|
Determines which ancestor nodes are valid sources for ``target_node_id``,
|
||||||
|
taking loop scoping into account:
|
||||||
|
|
||||||
|
- If ``target_node_id`` is on the *Done* branch of a ``flow.loop``, the
|
||||||
|
loop body nodes are excluded from ``availableSourceIds`` and the loop
|
||||||
|
node itself is mapped to its *Fertig* output port (index 1) via
|
||||||
|
``portIndexOverrides``.
|
||||||
|
- If ``target_node_id`` is *inside* the loop body, the loop node id is
|
||||||
|
included in ``loopBodyContextIds`` so the frontend can show the lexical
|
||||||
|
loop variables (currentItem, currentIndex, count).
|
||||||
|
|
||||||
|
Returns::
|
||||||
|
|
||||||
|
{
|
||||||
|
"availableSourceIds": [...], # ordered list
|
||||||
|
"portIndexOverrides": {nodeId: n}, # non-zero port indices
|
||||||
|
"loopBodyContextIds": [...], # loops whose body this node is in
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
nodes = graph.get("nodes") or []
|
||||||
|
connections = graph.get("connections") or []
|
||||||
|
node_by_id: Dict[str, Any] = {n["id"]: n for n in nodes if n.get("id")}
|
||||||
|
|
||||||
|
if target_node_id not in node_by_id:
|
||||||
|
return {"availableSourceIds": [], "portIndexOverrides": {}, "loopBodyContextIds": []}
|
||||||
|
|
||||||
|
conn_map = buildConnectionMap(connections)
|
||||||
|
|
||||||
|
# Collect all ancestors via backward BFS
|
||||||
|
preds: Dict[str, Set[str]] = {}
|
||||||
|
for tgt, pairs in conn_map.items():
|
||||||
|
for src, _, _ in pairs:
|
||||||
|
preds.setdefault(tgt, set()).add(src)
|
||||||
|
|
||||||
|
seen: Set[str] = set()
|
||||||
|
stack = [target_node_id]
|
||||||
|
ancestors: Set[str] = set()
|
||||||
|
while stack:
|
||||||
|
cur = stack.pop()
|
||||||
|
for p in preds.get(cur, ()):
|
||||||
|
if p not in seen:
|
||||||
|
seen.add(p)
|
||||||
|
ancestors.add(p)
|
||||||
|
stack.append(p)
|
||||||
|
|
||||||
|
body_nodes_to_exclude: Set[str] = set()
|
||||||
|
port_index_overrides: Dict[str, int] = {}
|
||||||
|
loop_body_context_ids: List[str] = []
|
||||||
|
|
||||||
|
for aid in ancestors:
|
||||||
|
anode = node_by_id.get(aid) or {}
|
||||||
|
if anode.get("type") != "flow.loop":
|
||||||
|
continue
|
||||||
|
body_ids = getLoopBodyNodeIds(aid, conn_map)
|
||||||
|
done_ids = getLoopDoneNodeIds(aid, conn_map)
|
||||||
|
|
||||||
|
if target_node_id in body_ids:
|
||||||
|
loop_body_context_ids.append(aid)
|
||||||
|
elif target_node_id in done_ids:
|
||||||
|
body_nodes_to_exclude.update(body_ids)
|
||||||
|
port_index_overrides[aid] = 1
|
||||||
|
|
||||||
|
available_source_ids = [
|
||||||
|
aid for aid in sorted(ancestors)
|
||||||
|
if aid not in body_nodes_to_exclude
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"availableSourceIds": available_source_ids,
|
||||||
|
"portIndexOverrides": port_index_overrides,
|
||||||
|
"loopBodyContextIds": loop_body_context_ids,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@ class DataNeutraliserConfig(PowerOnModel):
|
||||||
|
|
||||||
|
|
||||||
@i18nModel("Neutralisiertes Datenattribut")
|
@i18nModel("Neutralisiertes Datenattribut")
|
||||||
class DataNeutralizerAttributes(BaseModel):
|
class DataNeutralizerAttributes(PowerOnModel):
|
||||||
"""Zuordnung Originaltext zu Platzhalter fuer neutralisierte Daten."""
|
"""Zuordnung Originaltext zu Platzhalter fuer neutralisierte Daten."""
|
||||||
id: str = Field(
|
id: str = Field(
|
||||||
default_factory=lambda: str(uuid.uuid4()),
|
default_factory=lambda: str(uuid.uuid4()),
|
||||||
|
|
@ -152,7 +152,7 @@ class DataNeutralizerAttributes(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
@i18nModel("Neutralisierungs-Snapshot")
|
@i18nModel("Neutralisierungs-Snapshot")
|
||||||
class DataNeutralizationSnapshot(BaseModel):
|
class DataNeutralizationSnapshot(PowerOnModel):
|
||||||
"""Speichert den vollstaendigen neutralisierten Text (mit Platzhaltern) pro Quelle."""
|
"""Speichert den vollstaendigen neutralisierten Text (mit Platzhaltern) pro Quelle."""
|
||||||
id: str = Field(
|
id: str = Field(
|
||||||
default_factory=lambda: str(uuid.uuid4()),
|
default_factory=lambda: str(uuid.uuid4()),
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,7 @@ class GeoPolylinie(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
@i18nModel("Dokument")
|
@i18nModel("Dokument")
|
||||||
class Dokument(BaseModel):
|
class Dokument(PowerOnModel):
|
||||||
"""Supporting data object for file and URL management with versioning."""
|
"""Supporting data object for file and URL management with versioning."""
|
||||||
id: str = Field(
|
id: str = Field(
|
||||||
default_factory=lambda: str(uuid.uuid4()),
|
default_factory=lambda: str(uuid.uuid4()),
|
||||||
|
|
@ -204,7 +204,7 @@ class Kontext(PowerOnModel):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class Land(BaseModel):
|
class Land(PowerOnModel):
|
||||||
"""National level administrative entity."""
|
"""National level administrative entity."""
|
||||||
id: str = Field(
|
id: str = Field(
|
||||||
default_factory=lambda: str(uuid.uuid4()),
|
default_factory=lambda: str(uuid.uuid4()),
|
||||||
|
|
@ -265,15 +265,19 @@ class Kanton(PowerOnModel):
|
||||||
)
|
)
|
||||||
mandateId: str = Field(
|
mandateId: str = Field(
|
||||||
description="ID of the mandate",
|
description="ID of the mandate",
|
||||||
frontend_type="text",
|
json_schema_extra={
|
||||||
frontend_readonly=True,
|
"frontend_type": "text", "frontend_readonly": True, "frontend_required": False,
|
||||||
frontend_required=False,
|
"label": "Mandant",
|
||||||
|
"fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
featureInstanceId: str = Field(
|
featureInstanceId: str = Field(
|
||||||
description="ID of the feature instance",
|
description="ID of the feature instance",
|
||||||
frontend_type="text",
|
json_schema_extra={
|
||||||
frontend_readonly=True,
|
"frontend_type": "text", "frontend_readonly": True, "frontend_required": False,
|
||||||
frontend_required=False,
|
"label": "Feature-Instanz",
|
||||||
|
"fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
label: str = Field(
|
label: str = Field(
|
||||||
description="Canton name (e.g. 'Zürich')",
|
description="Canton name (e.g. 'Zürich')",
|
||||||
|
|
@ -314,7 +318,7 @@ class Kanton(PowerOnModel):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class Gemeinde(BaseModel):
|
class Gemeinde(PowerOnModel):
|
||||||
"""Municipal level administrative entity."""
|
"""Municipal level administrative entity."""
|
||||||
id: str = Field(
|
id: str = Field(
|
||||||
default_factory=lambda: str(uuid.uuid4()),
|
default_factory=lambda: str(uuid.uuid4()),
|
||||||
|
|
|
||||||
|
|
@ -102,12 +102,24 @@ class TeamsbotModuleStatus(str, Enum):
|
||||||
class TeamsbotMeetingModule(PowerOnModel):
|
class TeamsbotMeetingModule(PowerOnModel):
|
||||||
"""A meeting module groups related sessions (e.g. 'Weekly Standup')."""
|
"""A meeting module groups related sessions (e.g. 'Weekly Standup')."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Module ID")
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Module ID")
|
||||||
instanceId: str = Field(description="Feature instance ID (FK)")
|
instanceId: str = Field(
|
||||||
mandateId: str = Field(description="Mandate ID (FK)")
|
description="Feature instance ID",
|
||||||
ownerUserId: str = Field(description="Owner user ID")
|
json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
|
||||||
|
)
|
||||||
|
mandateId: str = Field(
|
||||||
|
description="Mandate ID",
|
||||||
|
json_schema_extra={"label": "Mandant", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}},
|
||||||
|
)
|
||||||
|
ownerUserId: str = Field(
|
||||||
|
description="Owner user ID",
|
||||||
|
json_schema_extra={"label": "Besitzer", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
|
||||||
|
)
|
||||||
title: str = Field(description="Module title, e.g. 'Weekly Standup'")
|
title: str = Field(description="Module title, e.g. 'Weekly Standup'")
|
||||||
seriesType: TeamsbotSeriesType = Field(default=TeamsbotSeriesType.ADHOC)
|
seriesType: TeamsbotSeriesType = Field(default=TeamsbotSeriesType.ADHOC)
|
||||||
defaultBotId: Optional[str] = Field(default=None, description="FK to TeamsbotSystemBot")
|
defaultBotId: Optional[str] = Field(
|
||||||
|
default=None, description="FK to TeamsbotSystemBot",
|
||||||
|
json_schema_extra={"label": "Standard-Bot", "fk_target": {"db": "poweron_teamsbot", "table": "TeamsbotSystemBot", "labelField": "name"}},
|
||||||
|
)
|
||||||
defaultDirectorPrompts: Optional[str] = Field(default=None, description="JSON list of default director prompts")
|
defaultDirectorPrompts: Optional[str] = Field(default=None, description="JSON list of default director prompts")
|
||||||
goals: Optional[str] = Field(default=None, description="Free-text goals")
|
goals: Optional[str] = Field(default=None, description="Free-text goals")
|
||||||
kpiTargets: Optional[str] = Field(default=None, description="JSON object with structured KPI targets")
|
kpiTargets: Optional[str] = Field(default=None, description="JSON object with structured KPI targets")
|
||||||
|
|
@ -120,8 +132,8 @@ class TeamsbotMeetingModule(PowerOnModel):
|
||||||
description="Default display name for the bot when starting a session from this module",
|
description="Default display name for the bot when starting a session from this module",
|
||||||
)
|
)
|
||||||
defaultAvatarFileId: Optional[str] = Field(
|
defaultAvatarFileId: Optional[str] = Field(
|
||||||
default=None,
|
default=None, description="FileItem ID for the default avatar image/video shown in the meeting",
|
||||||
description="FileItem ID for the default avatar image/video shown in the meeting",
|
json_schema_extra={"label": "Avatar-Datei", "fk_target": {"db": "poweron_management", "table": "FileItem", "labelField": "fileName"}},
|
||||||
)
|
)
|
||||||
status: TeamsbotModuleStatus = Field(default=TeamsbotModuleStatus.ACTIVE)
|
status: TeamsbotModuleStatus = Field(default=TeamsbotModuleStatus.ACTIVE)
|
||||||
|
|
||||||
|
|
@ -129,15 +141,27 @@ class TeamsbotMeetingModule(PowerOnModel):
|
||||||
class TeamsbotSession(PowerOnModel):
|
class TeamsbotSession(PowerOnModel):
|
||||||
"""A Teams Bot meeting session."""
|
"""A Teams Bot meeting session."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Session ID")
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Session ID")
|
||||||
instanceId: str = Field(description="Feature instance ID (FK)")
|
instanceId: str = Field(
|
||||||
mandateId: str = Field(description="Mandate ID (FK)")
|
description="Feature instance ID",
|
||||||
moduleId: Optional[str] = Field(default=None, description="FK to TeamsbotMeetingModule (nullable during transition)")
|
json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
|
||||||
|
)
|
||||||
|
mandateId: str = Field(
|
||||||
|
description="Mandate ID",
|
||||||
|
json_schema_extra={"label": "Mandant", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}},
|
||||||
|
)
|
||||||
|
moduleId: Optional[str] = Field(
|
||||||
|
default=None, description="FK to TeamsbotMeetingModule",
|
||||||
|
json_schema_extra={"label": "Meeting-Modul", "fk_target": {"db": "poweron_teamsbot", "table": "TeamsbotMeetingModule", "labelField": "title"}},
|
||||||
|
)
|
||||||
meetingLink: str = Field(description="Teams meeting join link")
|
meetingLink: str = Field(description="Teams meeting join link")
|
||||||
botName: str = Field(default="AI Assistant", description="Display name of the bot in the meeting")
|
botName: str = Field(default="AI Assistant", description="Display name of the bot in the meeting")
|
||||||
status: TeamsbotSessionStatus = Field(default=TeamsbotSessionStatus.PENDING, description="Current session status")
|
status: TeamsbotSessionStatus = Field(default=TeamsbotSessionStatus.PENDING, description="Current session status")
|
||||||
startedAt: Optional[float] = Field(default=None, description="UTC unix timestamp when session started", json_schema_extra={"frontend_type": "timestamp"})
|
startedAt: Optional[float] = Field(default=None, description="UTC unix timestamp when session started", json_schema_extra={"frontend_type": "timestamp"})
|
||||||
endedAt: Optional[float] = Field(default=None, description="UTC unix timestamp when session ended", json_schema_extra={"frontend_type": "timestamp"})
|
endedAt: Optional[float] = Field(default=None, description="UTC unix timestamp when session ended", json_schema_extra={"frontend_type": "timestamp"})
|
||||||
startedByUserId: str = Field(description="User ID who started the session")
|
startedByUserId: str = Field(
|
||||||
|
description="User ID who started the session",
|
||||||
|
json_schema_extra={"label": "Gestartet von", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
|
||||||
|
)
|
||||||
bridgeSessionId: Optional[str] = Field(default=None, description="Session ID on the .NET Media Bridge")
|
bridgeSessionId: Optional[str] = Field(default=None, description="Session ID on the .NET Media Bridge")
|
||||||
meetingChatId: Optional[str] = Field(default=None, description="Teams meeting chat ID for Graph API messages")
|
meetingChatId: Optional[str] = Field(default=None, description="Teams meeting chat ID for Graph API messages")
|
||||||
sessionContext: Optional[str] = Field(default=None, description="Custom context/knowledge provided by the user for this session")
|
sessionContext: Optional[str] = Field(default=None, description="Custom context/knowledge provided by the user for this session")
|
||||||
|
|
@ -150,7 +174,10 @@ class TeamsbotSession(PowerOnModel):
|
||||||
class TeamsbotTranscript(PowerOnModel):
|
class TeamsbotTranscript(PowerOnModel):
|
||||||
"""A single transcript segment from the meeting."""
|
"""A single transcript segment from the meeting."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Transcript segment ID")
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Transcript segment ID")
|
||||||
sessionId: str = Field(description="Session ID (FK)")
|
sessionId: str = Field(
|
||||||
|
description="FK to TeamsbotSession",
|
||||||
|
json_schema_extra={"label": "Session", "fk_target": {"db": "poweron_teamsbot", "table": "TeamsbotSession", "labelField": "botName"}},
|
||||||
|
)
|
||||||
speaker: Optional[str] = Field(default=None, description="Speaker name or identifier")
|
speaker: Optional[str] = Field(default=None, description="Speaker name or identifier")
|
||||||
text: str = Field(description="Transcribed text")
|
text: str = Field(description="Transcribed text")
|
||||||
timestamp: float = Field(description="UTC unix timestamp of the speech segment", json_schema_extra={"frontend_type": "timestamp"})
|
timestamp: float = Field(description="UTC unix timestamp of the speech segment", json_schema_extra={"frontend_type": "timestamp"})
|
||||||
|
|
@ -163,12 +190,18 @@ class TeamsbotTranscript(PowerOnModel):
|
||||||
class TeamsbotBotResponse(PowerOnModel):
|
class TeamsbotBotResponse(PowerOnModel):
|
||||||
"""A bot response generated during a meeting session."""
|
"""A bot response generated during a meeting session."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Response ID")
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Response ID")
|
||||||
sessionId: str = Field(description="Session ID (FK)")
|
sessionId: str = Field(
|
||||||
|
description="FK to TeamsbotSession",
|
||||||
|
json_schema_extra={"label": "Session", "fk_target": {"db": "poweron_teamsbot", "table": "TeamsbotSession", "labelField": "botName"}},
|
||||||
|
)
|
||||||
responseText: str = Field(description="The bot's response text")
|
responseText: str = Field(description="The bot's response text")
|
||||||
responseType: TeamsbotResponseType = Field(default=TeamsbotResponseType.BOTH, description="How the response was delivered")
|
responseType: TeamsbotResponseType = Field(default=TeamsbotResponseType.BOTH, description="How the response was delivered")
|
||||||
detectedIntent: TeamsbotDetectedIntent = Field(default=TeamsbotDetectedIntent.NONE, description="What triggered the response")
|
detectedIntent: TeamsbotDetectedIntent = Field(default=TeamsbotDetectedIntent.NONE, description="What triggered the response")
|
||||||
reasoning: Optional[str] = Field(default=None, description="AI reasoning for why it responded")
|
reasoning: Optional[str] = Field(default=None, description="AI reasoning for why it responded")
|
||||||
triggeredByTranscriptId: Optional[str] = Field(default=None, description="Transcript segment that triggered this response")
|
triggeredByTranscriptId: Optional[str] = Field(
|
||||||
|
default=None, description="Transcript segment that triggered this response",
|
||||||
|
json_schema_extra={"label": "Ausgelöst durch", "fk_target": {"db": "poweron_teamsbot", "table": "TeamsbotTranscript", "labelField": None}},
|
||||||
|
)
|
||||||
modelName: Optional[str] = Field(default=None, description="AI model used for this response")
|
modelName: Optional[str] = Field(default=None, description="AI model used for this response")
|
||||||
processingTime: float = Field(default=0.0, description="Processing time in seconds")
|
processingTime: float = Field(default=0.0, description="Processing time in seconds")
|
||||||
priceCHF: float = Field(default=0.0, description="Cost of this AI call in CHF")
|
priceCHF: float = Field(default=0.0, description="Cost of this AI call in CHF")
|
||||||
|
|
@ -184,7 +217,10 @@ class TeamsbotSystemBot(PowerOnModel):
|
||||||
Credentials are stored encrypted in the database, NOT in the UI-visible config.
|
Credentials are stored encrypted in the database, NOT in the UI-visible config.
|
||||||
Only mandate admins can manage system bots."""
|
Only mandate admins can manage system bots."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="System bot ID")
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="System bot ID")
|
||||||
mandateId: str = Field(description="Mandate ID (FK) - bots are scoped to mandates")
|
mandateId: str = Field(
|
||||||
|
description="Mandate ID - bots are scoped to mandates",
|
||||||
|
json_schema_extra={"label": "Mandant", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}},
|
||||||
|
)
|
||||||
name: str = Field(description="Display name (e.g. 'Nyla Larsson')")
|
name: str = Field(description="Display name (e.g. 'Nyla Larsson')")
|
||||||
email: str = Field(description="Microsoft account email")
|
email: str = Field(description="Microsoft account email")
|
||||||
encryptedPassword: str = Field(description="Encrypted Microsoft account password")
|
encryptedPassword: str = Field(description="Encrypted Microsoft account password")
|
||||||
|
|
@ -200,8 +236,14 @@ class TeamsbotUserAccount(PowerOnModel):
|
||||||
Each user can store their own MS credentials per mandate.
|
Each user can store their own MS credentials per mandate.
|
||||||
Password is encrypted; on login only MFA confirmation is needed."""
|
Password is encrypted; on login only MFA confirmation is needed."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Record ID")
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Record ID")
|
||||||
userId: str = Field(description="Poweron user ID (FK)")
|
userId: str = Field(
|
||||||
mandateId: str = Field(description="Mandate ID (FK)")
|
description="Poweron user ID",
|
||||||
|
json_schema_extra={"label": "Benutzer", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
|
||||||
|
)
|
||||||
|
mandateId: str = Field(
|
||||||
|
description="Mandate ID",
|
||||||
|
json_schema_extra={"label": "Mandant", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}},
|
||||||
|
)
|
||||||
email: str = Field(description="Microsoft account email")
|
email: str = Field(description="Microsoft account email")
|
||||||
encryptedPassword: str = Field(description="Encrypted Microsoft account password")
|
encryptedPassword: str = Field(description="Encrypted Microsoft account password")
|
||||||
displayName: Optional[str] = Field(default=None, description="Display name derived from MS account")
|
displayName: Optional[str] = Field(default=None, description="Display name derived from MS account")
|
||||||
|
|
@ -216,8 +258,14 @@ class TeamsbotUserSettings(PowerOnModel):
|
||||||
Each user has their own settings per feature instance.
|
Each user has their own settings per feature instance.
|
||||||
These override the instance-level defaults (TeamsbotConfig)."""
|
These override the instance-level defaults (TeamsbotConfig)."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Settings ID")
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Settings ID")
|
||||||
userId: str = Field(description="User ID (FK)")
|
userId: str = Field(
|
||||||
instanceId: str = Field(description="Feature instance ID (FK)")
|
description="User ID",
|
||||||
|
json_schema_extra={"label": "Benutzer", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
|
||||||
|
)
|
||||||
|
instanceId: str = Field(
|
||||||
|
description="Feature instance ID",
|
||||||
|
json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
|
||||||
|
)
|
||||||
botName: Optional[str] = Field(default=None, description="Bot display name override")
|
botName: Optional[str] = Field(default=None, description="Bot display name override")
|
||||||
aiSystemPrompt: Optional[str] = Field(default=None, description="AI system prompt override")
|
aiSystemPrompt: Optional[str] = Field(default=None, description="AI system prompt override")
|
||||||
responseMode: Optional[str] = Field(default=None, description="Response mode override: auto, manual, transcribeOnly")
|
responseMode: Optional[str] = Field(default=None, description="Response mode override: auto, manual, transcribeOnly")
|
||||||
|
|
@ -229,7 +277,10 @@ class TeamsbotUserSettings(PowerOnModel):
|
||||||
triggerCooldownSeconds: Optional[int] = Field(default=None, description="Trigger cooldown override")
|
triggerCooldownSeconds: Optional[int] = Field(default=None, description="Trigger cooldown override")
|
||||||
contextWindowSegments: Optional[int] = Field(default=None, description="Context window override")
|
contextWindowSegments: Optional[int] = Field(default=None, description="Context window override")
|
||||||
debugMode: Optional[bool] = Field(default=None, description="Debug mode override")
|
debugMode: Optional[bool] = Field(default=None, description="Debug mode override")
|
||||||
avatarFileId: Optional[str] = Field(default=None, description="FileItem ID for bot avatar image/video override")
|
avatarFileId: Optional[str] = Field(
|
||||||
|
default=None, description="FileItem ID for bot avatar image/video override",
|
||||||
|
json_schema_extra={"label": "Avatar-Datei", "fk_target": {"db": "poweron_management", "table": "FileItem", "labelField": "fileName"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
@ -382,9 +433,18 @@ class TeamsbotDirectorPrompt(PowerOnModel):
|
||||||
meeting participants.
|
meeting participants.
|
||||||
"""
|
"""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Director prompt ID")
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Director prompt ID")
|
||||||
sessionId: str = Field(description="Teams Bot session ID (FK)")
|
sessionId: str = Field(
|
||||||
instanceId: str = Field(description="Feature instance ID (FK)")
|
description="FK to TeamsbotSession",
|
||||||
operatorUserId: str = Field(description="User ID of the operator who issued the prompt")
|
json_schema_extra={"label": "Session", "fk_target": {"db": "poweron_teamsbot", "table": "TeamsbotSession", "labelField": "botName"}},
|
||||||
|
)
|
||||||
|
instanceId: str = Field(
|
||||||
|
description="Feature instance ID",
|
||||||
|
json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
|
||||||
|
)
|
||||||
|
operatorUserId: str = Field(
|
||||||
|
description="User ID of the operator who issued the prompt",
|
||||||
|
json_schema_extra={"label": "Operator", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
|
||||||
|
)
|
||||||
text: str = Field(description="The director instruction text", max_length=DIRECTOR_PROMPT_TEXT_LIMIT)
|
text: str = Field(description="The director instruction text", max_length=DIRECTOR_PROMPT_TEXT_LIMIT)
|
||||||
mode: TeamsbotDirectorPromptMode = Field(default=TeamsbotDirectorPromptMode.ONE_SHOT, description="oneShot or persistent")
|
mode: TeamsbotDirectorPromptMode = Field(default=TeamsbotDirectorPromptMode.ONE_SHOT, description="oneShot or persistent")
|
||||||
fileIds: List[str] = Field(default_factory=list, description="UDB-selected file/object IDs to attach as RAG context")
|
fileIds: List[str] = Field(default_factory=list, description="UDB-selected file/object IDs to attach as RAG context")
|
||||||
|
|
|
||||||
|
|
@ -796,7 +796,7 @@ class TeamsbotService:
|
||||||
import base64
|
import base64
|
||||||
from modules.interfaces import interfaceDbManagement
|
from modules.interfaces import interfaceDbManagement
|
||||||
try:
|
try:
|
||||||
mgmt = interfaceDbManagement.getInterface(self.currentUser, self.mandateId)
|
mgmt = interfaceDbManagement.getInterface(self.currentUser, self.mandateId, featureInstanceId=self.instanceId)
|
||||||
fileRecord = mgmt.getFile(fileId)
|
fileRecord = mgmt.getFile(fileId)
|
||||||
if not fileRecord:
|
if not fileRecord:
|
||||||
logger.warning(f"Avatar file {fileId} not found")
|
logger.warning(f"Avatar file {fileId} not found")
|
||||||
|
|
|
||||||
|
|
@ -151,15 +151,20 @@ class AccountingBridge:
|
||||||
logger.info("Accounting sync skipped (no accounts): positionId=%s", positionId)
|
logger.info("Accounting sync skipped (no accounts): positionId=%s", positionId)
|
||||||
return SyncResult(success=True, errorMessage="Position hat keine Kontierung (Soll-/Haben-Konto) – Sync übersprungen")
|
return SyncResult(success=True, errorMessage="Position hat keine Kontierung (Soll-/Haben-Konto) – Sync übersprungen")
|
||||||
|
|
||||||
# 1) First: ensure all documents are in RMA (upload or duplicate); collect Beleg-IDs for linking
|
# Collect document references
|
||||||
documentIds = []
|
documentIds = []
|
||||||
for key in ("documentId", "bankDocumentId"):
|
for key in ("documentId", "bankDocumentId"):
|
||||||
docId = position.get(key)
|
docId = position.get(key)
|
||||||
if docId:
|
if docId:
|
||||||
documentIds.append(docId)
|
documentIds.append(docId)
|
||||||
if documentIds:
|
|
||||||
|
pendingDocs = [] # [(documentId, fileName, fileContent, mimeType)] for post-booking attach
|
||||||
|
postBookingAttach = connector.requiresPostBookingDocAttach
|
||||||
|
|
||||||
|
# 1) Pre-booking document upload (RMA-style: upload first, link via belegId)
|
||||||
|
if documentIds and not postBookingAttach:
|
||||||
from modules.features.trustee.datamodelFeatureTrustee import TrusteeDocument as TrusteeDocumentModel
|
from modules.features.trustee.datamodelFeatureTrustee import TrusteeDocument as TrusteeDocumentModel
|
||||||
logger.info("Accounting sync: positionId=%s, syncing %s document(s) to RMA ...", positionId, len(documentIds))
|
logger.info("Accounting sync: positionId=%s, uploading %s document(s) pre-booking ...", positionId, len(documentIds))
|
||||||
belegIds = []
|
belegIds = []
|
||||||
belegLabels = []
|
belegLabels = []
|
||||||
for documentId in documentIds:
|
for documentId in documentIds:
|
||||||
|
|
@ -185,24 +190,40 @@ class AccountingBridge:
|
||||||
comment=booking.reference,
|
comment=booking.reference,
|
||||||
)
|
)
|
||||||
if not uploadResult.success:
|
if not uploadResult.success:
|
||||||
errMsg = f"Dokument konnte nicht nach RMA hochgeladen werden: {uploadResult.errorMessage}"
|
|
||||||
logger.error(
|
logger.error(
|
||||||
"Accounting sync failed (document upload): positionId=%s, documentId=%s, error=%s",
|
"Accounting sync failed (document upload): positionId=%s, documentId=%s, error=%s",
|
||||||
positionId, documentId, uploadResult.errorMessage,
|
positionId, documentId, uploadResult.errorMessage,
|
||||||
)
|
)
|
||||||
return SyncResult(success=False, errorMessage=errMsg)
|
return SyncResult(success=False, errorMessage=f"Dokument-Upload fehlgeschlagen: {uploadResult.errorMessage}")
|
||||||
belegId = uploadResult.externalId
|
belegId = uploadResult.externalId
|
||||||
if belegId:
|
if belegId:
|
||||||
self._trusteeInterface.db.recordModify(TrusteeDocumentModel, documentId, {"externalBelegId": belegId})
|
self._trusteeInterface.db.recordModify(TrusteeDocumentModel, documentId, {"externalBelegId": belegId})
|
||||||
logger.info("Accounting sync: document uploaded & belegId=%s stored on document %s", belegId, documentId)
|
logger.info("Accounting sync: document uploaded & belegId=%s stored on document %s", belegId, documentId)
|
||||||
else:
|
|
||||||
logger.info("Accounting sync: document uploaded but no belegId in response (409 duplicate?), fileName=%s", fileName)
|
|
||||||
belegIds.append(belegId)
|
belegIds.append(belegId)
|
||||||
belegLabels.append(fileName)
|
belegLabels.append(fileName)
|
||||||
if belegIds or belegLabels:
|
if belegIds or belegLabels:
|
||||||
booking.externalDocumentIds = belegIds
|
booking.externalDocumentIds = belegIds
|
||||||
booking.externalDocumentLabels = belegLabels
|
booking.externalDocumentLabels = belegLabels
|
||||||
logger.info("Accounting sync: positionId=%s, document sync done, pushing GL booking (POST /gl) ...", positionId)
|
logger.info("Accounting sync: positionId=%s, document upload done, pushing booking ...", positionId)
|
||||||
|
|
||||||
|
# 1b) Post-booking flow: collect raw doc data now, attach after pushBooking
|
||||||
|
if documentIds and postBookingAttach:
|
||||||
|
from modules.features.trustee.datamodelFeatureTrustee import TrusteeDocument as TrusteeDocumentModel
|
||||||
|
for documentId in documentIds:
|
||||||
|
doc = self._trusteeInterface.getDocument(documentId)
|
||||||
|
if not doc:
|
||||||
|
continue
|
||||||
|
existingBelegId = getattr(doc, "externalBelegId", None)
|
||||||
|
if existingBelegId:
|
||||||
|
continue
|
||||||
|
docData = self._trusteeInterface.getDocumentData(documentId)
|
||||||
|
if docData is None:
|
||||||
|
continue
|
||||||
|
fileName = getattr(doc, "documentName", None) or "beleg.pdf"
|
||||||
|
mimeType = getattr(doc, "documentMimeType", None) or "application/pdf"
|
||||||
|
pendingDocs.append((documentId, fileName, docData, mimeType))
|
||||||
|
if pendingDocs:
|
||||||
|
logger.info("Accounting sync: positionId=%s, %s document(s) queued for post-booking attach", positionId, len(pendingDocs))
|
||||||
|
|
||||||
# Duplicate check: if locally marked as synced, verify with Buha system
|
# Duplicate check: if locally marked as synced, verify with Buha system
|
||||||
accountingSyncId = position.get("accountingSyncId")
|
accountingSyncId = position.get("accountingSyncId")
|
||||||
|
|
@ -218,7 +239,6 @@ class AccountingBridge:
|
||||||
positionId, booking.reference,
|
positionId, booking.reference,
|
||||||
)
|
)
|
||||||
return SyncResult(success=False, errorMessage="Position already synced to this system")
|
return SyncResult(success=False, errorMessage="Position already synced to this system")
|
||||||
# Not found in Buha (e.g. deleted there): clear local records and re-push
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Accounting sync: reference %s not found in Buha (deleted?), clearing local records and re-pushing positionId=%s",
|
"Accounting sync: reference %s not found in Buha (deleted?), clearing local records and re-pushing positionId=%s",
|
||||||
booking.reference, positionId,
|
booking.reference, positionId,
|
||||||
|
|
@ -230,9 +250,9 @@ class AccountingBridge:
|
||||||
if rid:
|
if rid:
|
||||||
self._trusteeInterface.db.recordDelete(TrusteeAccountingSync, rid)
|
self._trusteeInterface.db.recordDelete(TrusteeAccountingSync, rid)
|
||||||
|
|
||||||
# 2) Then: push booking (with reference to document IDs so RMA can link)
|
# 2) Push booking
|
||||||
if not documentIds:
|
if not documentIds:
|
||||||
logger.info("Accounting sync: positionId=%s, no documents, pushing GL booking (POST /gl) ...", positionId)
|
logger.info("Accounting sync: positionId=%s, no documents, pushing booking ...", positionId)
|
||||||
result = await connector.pushBooking(plainConfig, booking)
|
result = await connector.pushBooking(plainConfig, booking)
|
||||||
if not result.success:
|
if not result.success:
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|
@ -241,6 +261,28 @@ class AccountingBridge:
|
||||||
result.errorMessage or "unknown",
|
result.errorMessage or "unknown",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 3) Post-booking document attach (Abacus-style: entry must exist before attaching docs)
|
||||||
|
if result.success and pendingDocs and result.externalId:
|
||||||
|
from modules.features.trustee.datamodelFeatureTrustee import TrusteeDocument as TrusteeDocumentModel
|
||||||
|
logger.info("Accounting sync: positionId=%s, attaching %s document(s) to entry %s ...", positionId, len(pendingDocs), result.externalId)
|
||||||
|
for documentId, fileName, docData, mimeType in pendingDocs:
|
||||||
|
attachResult = await connector.attachDocumentToEntry(
|
||||||
|
plainConfig,
|
||||||
|
entryId=result.externalId,
|
||||||
|
fileName=fileName,
|
||||||
|
fileContent=docData,
|
||||||
|
mimeType=mimeType,
|
||||||
|
)
|
||||||
|
if not attachResult.success:
|
||||||
|
logger.warning(
|
||||||
|
"Accounting sync: document attach failed (non-blocking): positionId=%s, documentId=%s, error=%s",
|
||||||
|
positionId, documentId, attachResult.errorMessage,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if attachResult.externalId:
|
||||||
|
self._trusteeInterface.db.recordModify(TrusteeDocumentModel, documentId, {"externalBelegId": attachResult.externalId})
|
||||||
|
logger.info("Accounting sync: document attached, externalId=%s stored on document %s", attachResult.externalId, documentId)
|
||||||
|
|
||||||
# Save sync record
|
# Save sync record
|
||||||
import uuid
|
import uuid
|
||||||
syncRecord = {
|
syncRecord = {
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,12 @@ class BaseAccountingConnector(ABC):
|
||||||
"""
|
"""
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def requiresPostBookingDocAttach(self) -> bool:
|
||||||
|
"""If True, documents must be attached AFTER pushBooking (e.g. Abacus GeneralLedgerEntryDocuments).
|
||||||
|
If False (default), documents are uploaded BEFORE the booking (e.g. RMA belege)."""
|
||||||
|
return False
|
||||||
|
|
||||||
async def uploadDocument(
|
async def uploadDocument(
|
||||||
self,
|
self,
|
||||||
config: Dict[str, Any],
|
config: Dict[str, Any],
|
||||||
|
|
@ -179,5 +185,16 @@ class BaseAccountingConnector(ABC):
|
||||||
mimeType: str = "application/pdf",
|
mimeType: str = "application/pdf",
|
||||||
comment: Optional[str] = None,
|
comment: Optional[str] = None,
|
||||||
) -> SyncResult:
|
) -> SyncResult:
|
||||||
"""Upload a document/receipt (e.g. beleg). comment can link to booking reference. Override in connectors that support it."""
|
"""Upload a document/receipt before booking (pre-booking flow). Override in connectors that support it."""
|
||||||
return SyncResult(success=False, errorMessage="Document upload not supported by this connector")
|
return SyncResult(success=False, errorMessage="Document upload not supported by this connector")
|
||||||
|
|
||||||
|
async def attachDocumentToEntry(
|
||||||
|
self,
|
||||||
|
config: Dict[str, Any],
|
||||||
|
entryId: str,
|
||||||
|
fileName: str,
|
||||||
|
fileContent: bytes,
|
||||||
|
mimeType: str = "application/pdf",
|
||||||
|
) -> SyncResult:
|
||||||
|
"""Attach a document to an existing booking/entry (post-booking flow). Override in connectors that need it."""
|
||||||
|
return SyncResult(success=False, errorMessage="Post-booking document attach not supported by this connector")
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ Account balances:
|
||||||
Abacus exposes an ``AccountBalances`` entity (per fiscal year), but its
|
Abacus exposes an ``AccountBalances`` entity (per fiscal year), but its
|
||||||
availability depends on the customer's Abacus license / Profile and is
|
availability depends on the customer's Abacus license / Profile and is
|
||||||
NOT guaranteed for all instances. The robust default is therefore to
|
NOT guaranteed for all instances. The robust default is therefore to
|
||||||
aggregate balances locally from ``GeneralJournalEntries`` (always
|
aggregate balances locally from ``GeneralLedgerEntries`` (always
|
||||||
present). If a future iteration confirms the entity for a specific
|
present). If a future iteration confirms the entity for a specific
|
||||||
instance, ``getAccountBalances`` can be extended to prefer that source
|
instance, ``getAccountBalances`` can be extended to prefer that source
|
||||||
via a config flag (e.g. ``useAccountBalancesEntity: true``).
|
via a config flag (e.g. ``useAccountBalancesEntity: true``).
|
||||||
|
|
@ -58,6 +58,10 @@ class AccountingConnectorAbacus(BaseAccountingConnector):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._tokenCache: Dict[str, Dict[str, Any]] = {}
|
self._tokenCache: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def requiresPostBookingDocAttach(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
def getConnectorType(self) -> str:
|
def getConnectorType(self) -> str:
|
||||||
return "abacus"
|
return "abacus"
|
||||||
|
|
||||||
|
|
@ -92,6 +96,14 @@ class AccountingConnectorAbacus(BaseAccountingConnector):
|
||||||
fieldType="password",
|
fieldType="password",
|
||||||
secret=True,
|
secret=True,
|
||||||
),
|
),
|
||||||
|
ConnectorConfigField(
|
||||||
|
key="defaultCostCentre",
|
||||||
|
label=t("Standard-Kostenstelle"),
|
||||||
|
fieldType="text",
|
||||||
|
secret=False,
|
||||||
|
required=False,
|
||||||
|
placeholder="e.g. 100",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
def _buildBaseUrl(self, config: Dict[str, Any]) -> str:
|
def _buildBaseUrl(self, config: Dict[str, Any]) -> str:
|
||||||
|
|
@ -165,7 +177,9 @@ class AccountingConnectorAbacus(BaseAccountingConnector):
|
||||||
clientName = config.get("clientName")
|
clientName = config.get("clientName")
|
||||||
if not clientName:
|
if not clientName:
|
||||||
raise ValueError("Missing required config: clientName")
|
raise ValueError("Missing required config: clientName")
|
||||||
return f"{baseUrl}/{clientName}/{entity}"
|
if "/api/entity/v1" not in baseUrl:
|
||||||
|
baseUrl = f"{baseUrl}/api/entity/v1"
|
||||||
|
return f"{baseUrl}/mandants/{clientName}/{entity}"
|
||||||
|
|
||||||
async def _buildAuthHeaders(self, config: Dict[str, Any]) -> Optional[Dict[str, str]]:
|
async def _buildAuthHeaders(self, config: Dict[str, Any]) -> Optional[Dict[str, str]]:
|
||||||
token = await self._getAccessToken(config)
|
token = await self._getAccessToken(config)
|
||||||
|
|
@ -218,53 +232,135 @@ class AccountingConnectorAbacus(BaseAccountingConnector):
|
||||||
data = await resp.json()
|
data = await resp.json()
|
||||||
|
|
||||||
for item in data.get("value", []):
|
for item in data.get("value", []):
|
||||||
|
label = ""
|
||||||
|
for d in (item.get("Designations") or []):
|
||||||
|
if d.get("Language") == "de":
|
||||||
|
label = d.get("Text", "")
|
||||||
|
break
|
||||||
|
if not label:
|
||||||
|
desigs = item.get("Designations") or []
|
||||||
|
label = desigs[0].get("Text", "") if desigs else ""
|
||||||
charts.append(AccountingChart(
|
charts.append(AccountingChart(
|
||||||
accountNumber=str(item.get("AccountNumber", item.get("Id", ""))),
|
accountNumber=str(item.get("Id", "")),
|
||||||
label=item.get("Name", item.get("Description", "")),
|
label=label,
|
||||||
accountType=item.get("AccountType", None),
|
accountType=item.get("Segment", None),
|
||||||
))
|
))
|
||||||
url = data.get("@odata.nextLink")
|
url = data.get("@odata.nextLink")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Abacus getChartOfAccounts error: {e}")
|
logger.error(f"Abacus getChartOfAccounts error: {e}")
|
||||||
return charts
|
return charts
|
||||||
|
|
||||||
|
async def _fetchJournals(self, config: Dict[str, Any], headers: Dict[str, str]) -> List[Dict[str, Any]]:
|
||||||
|
"""Fetch all journals from Abacus."""
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
url = self._buildEntityUrl(config, "Journals")
|
||||||
|
async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=15)) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
return []
|
||||||
|
data = await resp.json()
|
||||||
|
return data.get("value", [])
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def _resolveJournalId(self, config: Dict[str, Any], headers: Dict[str, str], bookingDate: str) -> Optional[str]:
|
||||||
|
"""Find the open journal that covers the booking date."""
|
||||||
|
for j in await self._fetchJournals(config, headers):
|
||||||
|
start = j.get("StartDate", "")
|
||||||
|
end = j.get("EndDate", "")
|
||||||
|
if start <= bookingDate <= end:
|
||||||
|
return j.get("Id")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _buildJournalFilter(self, config: Dict[str, Any], headers: Dict[str, str], dateFrom: Optional[str] = None, dateTo: Optional[str] = None) -> Optional[str]:
|
||||||
|
"""Build an OData $filter on JournalId for journals overlapping the date range.
|
||||||
|
Abacus only allows filtering by JournalId, not by Date.
|
||||||
|
"""
|
||||||
|
journals = await self._fetchJournals(config, headers)
|
||||||
|
if not journals:
|
||||||
|
return None
|
||||||
|
matchingIds = []
|
||||||
|
for j in journals:
|
||||||
|
jStart = j.get("StartDate", "")
|
||||||
|
jEnd = j.get("EndDate", "")
|
||||||
|
if dateTo and jStart > dateTo:
|
||||||
|
continue
|
||||||
|
if dateFrom and jEnd < dateFrom:
|
||||||
|
continue
|
||||||
|
matchingIds.append(j.get("Id"))
|
||||||
|
if not matchingIds:
|
||||||
|
return None
|
||||||
|
if len(matchingIds) == 1:
|
||||||
|
return f"JournalId eq '{matchingIds[0]}'"
|
||||||
|
parts = " or ".join(f"JournalId eq '{jid}'" for jid in matchingIds)
|
||||||
|
return f"({parts})"
|
||||||
|
|
||||||
async def pushBooking(self, config: Dict[str, Any], booking: AccountingBooking) -> SyncResult:
|
async def pushBooking(self, config: Dict[str, Any], booking: AccountingBooking) -> SyncResult:
|
||||||
headers = await self._buildAuthHeaders(config)
|
headers = await self._buildAuthHeaders(config)
|
||||||
if not headers:
|
if not headers:
|
||||||
return SyncResult(success=False, errorMessage="Failed to obtain access token")
|
return SyncResult(success=False, errorMessage="Failed to obtain access token")
|
||||||
|
|
||||||
try:
|
debitLine = None
|
||||||
lines = []
|
creditLine = None
|
||||||
for line in booking.lines:
|
for line in booking.lines:
|
||||||
entry: Dict[str, Any] = {
|
|
||||||
"AccountId": line.accountNumber,
|
|
||||||
"Text": line.description or booking.description,
|
|
||||||
}
|
|
||||||
if line.debitAmount > 0:
|
if line.debitAmount > 0:
|
||||||
entry["DebitAmount"] = line.debitAmount
|
debitLine = line
|
||||||
if line.creditAmount > 0:
|
if line.creditAmount > 0:
|
||||||
entry["CreditAmount"] = line.creditAmount
|
creditLine = line
|
||||||
if line.taxCode:
|
if not debitLine or not creditLine:
|
||||||
entry["TaxCode"] = line.taxCode
|
return SyncResult(success=False, errorMessage="Booking must have at least one debit and one credit line")
|
||||||
if line.costCenter:
|
|
||||||
entry["CostCenterId"] = line.costCenter
|
|
||||||
lines.append(entry)
|
|
||||||
|
|
||||||
payload = {
|
amount = debitLine.debitAmount
|
||||||
"JournalDate": booking.bookingDate,
|
|
||||||
"Reference": booking.reference,
|
journalId = await self._resolveJournalId(config, headers, booking.bookingDate)
|
||||||
"Text": booking.description,
|
if not journalId:
|
||||||
"Lines": lines,
|
return SyncResult(success=False, errorMessage=f"No open journal found for date {booking.bookingDate}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
debitAccountId = int(debitLine.accountNumber)
|
||||||
|
creditAccountId = int(creditLine.accountNumber)
|
||||||
|
except ValueError:
|
||||||
|
return SyncResult(success=False, errorMessage=f"Account numbers must be numeric: debit={debitLine.accountNumber}, credit={creditLine.accountNumber}")
|
||||||
|
|
||||||
|
debitSide: Dict[str, Any] = {"AccountId": debitAccountId, "EnterpriseId": 0, "CrossDivisionId": 0}
|
||||||
|
creditSide: Dict[str, Any] = {"AccountId": creditAccountId, "EnterpriseId": 0, "CrossDivisionId": 0}
|
||||||
|
defaultCC = config.get("defaultCostCentre")
|
||||||
|
for line, side in [(debitLine, debitSide), (creditLine, creditSide)]:
|
||||||
|
cc = line.costCenter or defaultCC
|
||||||
|
if cc:
|
||||||
|
try:
|
||||||
|
side["CostCentre1Id"] = int(cc)
|
||||||
|
except ValueError:
|
||||||
|
side["CostCentre1Id"] = cc
|
||||||
|
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
|
"Date": booking.bookingDate,
|
||||||
|
"JournalId": journalId,
|
||||||
|
"DivisionId": 0,
|
||||||
|
"Direction": "Debit",
|
||||||
|
"Debit": debitSide,
|
||||||
|
"Credit": creditSide,
|
||||||
|
"Amount": {"KeyAmount": amount},
|
||||||
|
"Texts": {"Text1": (booking.description or "")[:80]},
|
||||||
}
|
}
|
||||||
|
ref = (booking.reference or "")[:10]
|
||||||
|
if ref:
|
||||||
|
payload["Document"] = {"Number": ref}
|
||||||
|
if debitLine.taxCode:
|
||||||
|
payload["Tax"] = {"CodeId": debitLine.taxCode[:3]}
|
||||||
|
|
||||||
|
try:
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
url = self._buildEntityUrl(config, "GeneralJournalEntries")
|
url = self._buildEntityUrl(config, "GeneralLedgerEntries")
|
||||||
async with session.post(url, headers=headers, json=payload, timeout=aiohttp.ClientTimeout(total=30)) as resp:
|
async with session.post(url, headers=headers, json=payload, timeout=aiohttp.ClientTimeout(total=30)) as resp:
|
||||||
body = await resp.json() if resp.content_type and "json" in resp.content_type else {"raw": await resp.text()}
|
body = await resp.json() if resp.content_type and "json" in resp.content_type else {"raw": await resp.text()}
|
||||||
if resp.status in (200, 201):
|
if resp.status in (200, 201):
|
||||||
externalId = str(body.get("Id", "")) if isinstance(body, dict) else None
|
externalId = str(body.get("Id", "")) if isinstance(body, dict) else None
|
||||||
return SyncResult(success=True, externalId=externalId, rawResponse=body)
|
return SyncResult(success=True, externalId=externalId, rawResponse=body)
|
||||||
return SyncResult(success=False, errorMessage=f"HTTP {resp.status}", rawResponse=body)
|
errDetail = ""
|
||||||
|
if isinstance(body, dict) and "error" in body:
|
||||||
|
errDetail = body["error"].get("message", "")
|
||||||
|
return SyncResult(success=False, errorMessage=f"HTTP {resp.status}: {errDetail or str(body)[:200]}", rawResponse=body)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return SyncResult(success=False, errorMessage=str(e))
|
return SyncResult(success=False, errorMessage=str(e))
|
||||||
|
|
||||||
|
|
@ -274,7 +370,7 @@ class AccountingConnectorAbacus(BaseAccountingConnector):
|
||||||
return SyncResult(success=False, errorMessage="Failed to obtain access token")
|
return SyncResult(success=False, errorMessage="Failed to obtain access token")
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
url = self._buildEntityUrl(config, f"GeneralJournalEntries({externalId})")
|
url = self._buildEntityUrl(config, f"GeneralLedgerEntries({externalId})")
|
||||||
async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=15)) as resp:
|
async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=15)) as resp:
|
||||||
if resp.status == 200:
|
if resp.status == 200:
|
||||||
return SyncResult(success=True, externalId=externalId)
|
return SyncResult(success=True, externalId=externalId)
|
||||||
|
|
@ -283,22 +379,20 @@ class AccountingConnectorAbacus(BaseAccountingConnector):
|
||||||
return SyncResult(success=False, errorMessage=str(e))
|
return SyncResult(success=False, errorMessage=str(e))
|
||||||
|
|
||||||
async def getJournalEntries(self, config: Dict[str, Any], dateFrom: Optional[str] = None, dateTo: Optional[str] = None, accountNumbers: Optional[List[str]] = None) -> List[Dict[str, Any]]:
|
async def getJournalEntries(self, config: Dict[str, Any], dateFrom: Optional[str] = None, dateTo: Optional[str] = None, accountNumbers: Optional[List[str]] = None) -> List[Dict[str, Any]]:
|
||||||
"""Read GeneralJournalEntries from Abacus (OData V4, paginated)."""
|
"""Read GeneralLedgerEntries from Abacus (OData V4, paginated).
|
||||||
|
Each Abacus entry is a single-line (one debit + one credit account).
|
||||||
|
We map it to our multi-line format with two lines per entry.
|
||||||
|
Abacus only allows filtering by JournalId, so date filtering is done client-side.
|
||||||
|
"""
|
||||||
headers = await self._buildAuthHeaders(config)
|
headers = await self._buildAuthHeaders(config)
|
||||||
if not headers:
|
if not headers:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
filterParts = []
|
journalFilter = await self._buildJournalFilter(config, headers, dateFrom, dateTo)
|
||||||
if dateFrom:
|
queryParams = f"?$filter={journalFilter}" if journalFilter else ""
|
||||||
filterParts.append(f"JournalDate ge {dateFrom}")
|
|
||||||
if dateTo:
|
|
||||||
filterParts.append(f"JournalDate le {dateTo}")
|
|
||||||
queryParams = ""
|
|
||||||
if filterParts:
|
|
||||||
queryParams = "?$filter=" + " and ".join(filterParts)
|
|
||||||
|
|
||||||
entries: List[Dict[str, Any]] = []
|
entries: List[Dict[str, Any]] = []
|
||||||
url: Optional[str] = self._buildEntityUrl(config, f"GeneralJournalEntries{queryParams}")
|
url: Optional[str] = self._buildEntityUrl(config, f"GeneralLedgerEntries{queryParams}")
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
while url:
|
while url:
|
||||||
|
|
@ -308,28 +402,28 @@ class AccountingConnectorAbacus(BaseAccountingConnector):
|
||||||
data = await resp.json()
|
data = await resp.json()
|
||||||
|
|
||||||
for item in data.get("value", []):
|
for item in data.get("value", []):
|
||||||
lines = []
|
entryDate = str(item.get("Date", "")).split("T")[0]
|
||||||
totalAmt = 0.0
|
if dateFrom and entryDate < dateFrom:
|
||||||
for line in (item.get("Lines") or []):
|
continue
|
||||||
debit = float(line.get("DebitAmount", 0))
|
if dateTo and entryDate > dateTo:
|
||||||
credit = float(line.get("CreditAmount", 0))
|
continue
|
||||||
lines.append({
|
amt = float((item.get("Amount") or {}).get("KeyAmount", 0))
|
||||||
"accountNumber": str(line.get("AccountId", "")),
|
debitAcc = str((item.get("Debit") or {}).get("AccountId", ""))
|
||||||
"debitAmount": debit,
|
creditAcc = str((item.get("Credit") or {}).get("AccountId", ""))
|
||||||
"creditAmount": credit,
|
texts = item.get("Texts") or {}
|
||||||
"description": line.get("Text", ""),
|
desc = texts.get("Text1", "")
|
||||||
"taxCode": line.get("TaxCode"),
|
docInfo = item.get("Document") or {}
|
||||||
"costCenter": line.get("CostCenterId"),
|
|
||||||
})
|
|
||||||
totalAmt += max(debit, credit)
|
|
||||||
entries.append({
|
entries.append({
|
||||||
"externalId": str(item.get("Id", "")),
|
"externalId": str(item.get("Id", "")),
|
||||||
"bookingDate": str(item.get("JournalDate", "")).split("T")[0],
|
"bookingDate": entryDate,
|
||||||
"reference": item.get("Reference", ""),
|
"reference": docInfo.get("Number", ""),
|
||||||
"description": item.get("Text", ""),
|
"description": desc,
|
||||||
"currency": "CHF",
|
"currency": "CHF",
|
||||||
"totalAmount": totalAmt,
|
"totalAmount": amt,
|
||||||
"lines": lines,
|
"lines": [
|
||||||
|
{"accountNumber": debitAcc, "debitAmount": amt, "creditAmount": 0, "description": desc},
|
||||||
|
{"accountNumber": creditAcc, "debitAmount": 0, "creditAmount": amt, "description": desc},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
url = data.get("@odata.nextLink")
|
url = data.get("@odata.nextLink")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -374,23 +468,11 @@ class AccountingConnectorAbacus(BaseAccountingConnector):
|
||||||
years: List[int],
|
years: List[int],
|
||||||
accountNumbers: Optional[List[str]] = None,
|
accountNumbers: Optional[List[str]] = None,
|
||||||
) -> List[AccountingPeriodBalance]:
|
) -> List[AccountingPeriodBalance]:
|
||||||
"""Aggregate account balances from ``GeneralJournalEntries`` (OData V4).
|
"""Aggregate account balances from GeneralLedgerEntries (OData V4).
|
||||||
|
|
||||||
Strategy:
|
Each Abacus entry is a single line with Debit.AccountId, Credit.AccountId,
|
||||||
1. Page through ``GET GeneralJournalEntries?$filter=JournalDate le YYYY-12-31``
|
and Amount.KeyAmount. We expand this into two movements per entry
|
||||||
until ``@odata.nextLink`` is exhausted. Including ALL prior years
|
(debit account gets +amount, credit account gets -amount).
|
||||||
is required to compute the carry-over for balance-sheet accounts.
|
|
||||||
2. Per (account, year, month) accumulate ``DebitAmount``/``CreditAmount``
|
|
||||||
from ``Lines``.
|
|
||||||
3. Income-statement accounts (3xxx-9xxx) reset to 0 per fiscal year;
|
|
||||||
balance-sheet accounts (1xxx-2xxx) carry their cumulative balance.
|
|
||||||
|
|
||||||
Optional optimization (not yet active): if the customer's Abacus
|
|
||||||
instance ships the ``AccountBalances`` OData entity, it can return
|
|
||||||
authoritative period balances directly. Detect via a probe GET on
|
|
||||||
``AccountBalances?$top=1`` and prefer that source. This is intentionally
|
|
||||||
deferred until we hit a customer where the entity is available --
|
|
||||||
the local aggregation is always-correct fallback.
|
|
||||||
"""
|
"""
|
||||||
if not years:
|
if not years:
|
||||||
return []
|
return []
|
||||||
|
|
@ -409,7 +491,7 @@ class AccountingConnectorAbacus(BaseAccountingConnector):
|
||||||
movements: Dict[Tuple[str, int, int], Dict[str, float]] = {}
|
movements: Dict[Tuple[str, int, int], Dict[str, float]] = {}
|
||||||
seenAccounts: set = set()
|
seenAccounts: set = set()
|
||||||
for entry in rawEntries:
|
for entry in rawEntries:
|
||||||
dateRaw = str(entry.get("JournalDate") or "")[:10]
|
dateRaw = str(entry.get("Date") or "")[:10]
|
||||||
if len(dateRaw) < 7:
|
if len(dateRaw) < 7:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
|
|
@ -417,18 +499,15 @@ class AccountingConnectorAbacus(BaseAccountingConnector):
|
||||||
month = int(dateRaw[5:7])
|
month = int(dateRaw[5:7])
|
||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
for line in (entry.get("Lines") or []):
|
amt = float((entry.get("Amount") or {}).get("KeyAmount", 0))
|
||||||
accNo = str(line.get("AccountId") or "").strip()
|
if amt == 0:
|
||||||
|
continue
|
||||||
|
debitAcc = str((entry.get("Debit") or {}).get("AccountId", "")).strip()
|
||||||
|
creditAcc = str((entry.get("Credit") or {}).get("AccountId", "")).strip()
|
||||||
|
for accNo, debit, credit in [(debitAcc, amt, 0.0), (creditAcc, 0.0, amt)]:
|
||||||
if not accNo:
|
if not accNo:
|
||||||
continue
|
continue
|
||||||
seenAccounts.add(accNo)
|
seenAccounts.add(accNo)
|
||||||
try:
|
|
||||||
debit = float(line.get("DebitAmount") or 0)
|
|
||||||
credit = float(line.get("CreditAmount") or 0)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
continue
|
|
||||||
if debit == 0 and credit == 0:
|
|
||||||
continue
|
|
||||||
bucket = movements.setdefault((accNo, year, month), {"debit": 0.0, "credit": 0.0})
|
bucket = movements.setdefault((accNo, year, month), {"debit": 0.0, "credit": 0.0})
|
||||||
bucket["debit"] += debit
|
bucket["debit"] += debit
|
||||||
bucket["credit"] += credit
|
bucket["credit"] += credit
|
||||||
|
|
@ -495,14 +574,13 @@ class AccountingConnectorAbacus(BaseAccountingConnector):
|
||||||
headers: Dict[str, str],
|
headers: Dict[str, str],
|
||||||
dateTo: str,
|
dateTo: str,
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""Page through ``GeneralJournalEntries`` (OData V4) following ``@odata.nextLink``.
|
"""Page through GeneralLedgerEntries (OData V4) following @odata.nextLink.
|
||||||
|
Abacus only allows filtering by JournalId, so date filtering is done client-side.
|
||||||
We filter ``JournalDate le dateTo`` to bound the result, but include
|
|
||||||
ALL prior years (no lower bound) so cumulative balance-sheet
|
|
||||||
carry-over is correct.
|
|
||||||
"""
|
"""
|
||||||
results: List[Dict[str, Any]] = []
|
results: List[Dict[str, Any]] = []
|
||||||
baseUrl = self._buildEntityUrl(config, f"GeneralJournalEntries?$filter=JournalDate le {dateTo}")
|
journalFilter = await self._buildJournalFilter(config, headers, dateTo=dateTo)
|
||||||
|
queryParams = f"?$filter={journalFilter}" if journalFilter else ""
|
||||||
|
baseUrl = self._buildEntityUrl(config, f"GeneralLedgerEntries{queryParams}")
|
||||||
nextUrl: Optional[str] = baseUrl
|
nextUrl: Optional[str] = baseUrl
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
while nextUrl:
|
while nextUrl:
|
||||||
|
|
@ -510,11 +588,11 @@ class AccountingConnectorAbacus(BaseAccountingConnector):
|
||||||
async with session.get(nextUrl, headers=headers, timeout=aiohttp.ClientTimeout(total=60)) as resp:
|
async with session.get(nextUrl, headers=headers, timeout=aiohttp.ClientTimeout(total=60)) as resp:
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
body = await resp.text()
|
body = await resp.text()
|
||||||
logger.warning("Abacus GeneralJournalEntries HTTP %s: %s", resp.status, body[:200])
|
logger.warning("Abacus GeneralLedgerEntries HTTP %s: %s", resp.status, body[:200])
|
||||||
break
|
break
|
||||||
data = await resp.json()
|
data = await resp.json()
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning("Abacus GeneralJournalEntries request failed: %s", ex)
|
logger.warning("Abacus GeneralLedgerEntries request failed: %s", ex)
|
||||||
break
|
break
|
||||||
page = data.get("value") or []
|
page = data.get("value") or []
|
||||||
if not isinstance(page, list):
|
if not isinstance(page, list):
|
||||||
|
|
@ -522,3 +600,60 @@ class AccountingConnectorAbacus(BaseAccountingConnector):
|
||||||
results.extend(page)
|
results.extend(page)
|
||||||
nextUrl = data.get("@odata.nextLink")
|
nextUrl = data.get("@odata.nextLink")
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
async def attachDocumentToEntry(
|
||||||
|
self,
|
||||||
|
config: Dict[str, Any],
|
||||||
|
entryId: str,
|
||||||
|
fileName: str,
|
||||||
|
fileContent: bytes,
|
||||||
|
mimeType: str = "application/pdf",
|
||||||
|
) -> SyncResult:
|
||||||
|
"""Attach a document to a GeneralLedgerEntry via OData V4 two-step flow:
|
||||||
|
1) POST GeneralLedgerEntryDocuments (metadata) → get document ID
|
||||||
|
2) PUT GeneralLedgerEntryDocuments({id})/Content (binary stream)
|
||||||
|
"""
|
||||||
|
headers = await self._buildAuthHeaders(config)
|
||||||
|
if not headers:
|
||||||
|
return SyncResult(success=False, errorMessage="Failed to obtain access token")
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
# Step 1: create document metadata
|
||||||
|
docUrl = self._buildEntityUrl(config, "GeneralLedgerEntryDocuments")
|
||||||
|
payload = {
|
||||||
|
"Name": fileName,
|
||||||
|
"GeneralLedgerEntryId": entryId,
|
||||||
|
}
|
||||||
|
async with session.post(docUrl, headers=headers, json=payload, timeout=aiohttp.ClientTimeout(total=30)) as resp:
|
||||||
|
body = await resp.text()
|
||||||
|
if resp.status not in (200, 201):
|
||||||
|
logger.error("Abacus document create failed: HTTP %s: %s", resp.status, body[:500])
|
||||||
|
return SyncResult(success=False, errorMessage=f"HTTP {resp.status}: {body[:200]}")
|
||||||
|
try:
|
||||||
|
docData = await resp.json(content_type=None)
|
||||||
|
except Exception:
|
||||||
|
docData = {}
|
||||||
|
docId = docData.get("Id")
|
||||||
|
|
||||||
|
if not docId:
|
||||||
|
logger.error("Abacus document create: no Id in response: %s", body[:300])
|
||||||
|
return SyncResult(success=False, errorMessage="No document Id returned by Abacus")
|
||||||
|
|
||||||
|
# Step 2: upload binary content stream
|
||||||
|
contentUrl = self._buildEntityUrl(config, f"GeneralLedgerEntryDocuments({docId})/Content")
|
||||||
|
streamHeaders = {
|
||||||
|
"Authorization": headers["Authorization"],
|
||||||
|
"Content-Type": mimeType,
|
||||||
|
}
|
||||||
|
async with session.put(contentUrl, headers=streamHeaders, data=fileContent, timeout=aiohttp.ClientTimeout(total=60)) as resp2:
|
||||||
|
if resp2.status not in (200, 204):
|
||||||
|
body2 = await resp2.text()
|
||||||
|
logger.error("Abacus document content upload failed: HTTP %s: %s", resp2.status, body2[:500])
|
||||||
|
return SyncResult(success=False, errorMessage=f"Content upload HTTP {resp2.status}: {body2[:200]}")
|
||||||
|
|
||||||
|
logger.info("Abacus document attached: docId=%s, entryId=%s, fileName=%s", docId, entryId, fileName)
|
||||||
|
return SyncResult(success=True, externalId=str(docId))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Abacus attachDocumentToEntry error: %s", e)
|
||||||
|
return SyncResult(success=False, errorMessage=str(e))
|
||||||
|
|
|
||||||
|
|
@ -308,7 +308,6 @@ def _buildSystemTemplates():
|
||||||
"title": "Pro E-Mail",
|
"title": "Pro E-Mail",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"items": {"type": "ref", "nodeId": "n2", "path": ["emails"]},
|
"items": {"type": "ref", "nodeId": "n2", "path": ["emails"]},
|
||||||
"level": "auto",
|
|
||||||
"concurrency": 1,
|
"concurrency": 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -348,7 +347,6 @@ def _buildSystemTemplates():
|
||||||
"title": "Pro Dokument",
|
"title": "Pro Dokument",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"items": {"type": "ref", "nodeId": "n2", "path": ["files"]},
|
"items": {"type": "ref", "nodeId": "n2", "path": ["files"]},
|
||||||
"level": "auto",
|
|
||||||
"concurrency": 1,
|
"concurrency": 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -990,6 +990,10 @@ class ComponentObjects:
|
||||||
If pagination is provided: PaginatedResult with items and metadata
|
If pagination is provided: PaginatedResult with items and metadata
|
||||||
"""
|
"""
|
||||||
def _convertFileItems(files):
|
def _convertFileItems(files):
|
||||||
|
from modules.workflows.automation2.workflowArtifactVisibility import (
|
||||||
|
suppress_workflow_file_in_workspace_ui,
|
||||||
|
)
|
||||||
|
|
||||||
fileItems = []
|
fileItems = []
|
||||||
for file in files:
|
for file in files:
|
||||||
try:
|
try:
|
||||||
|
|
@ -1002,6 +1006,8 @@ class ComponentObjects:
|
||||||
fileName = file.get("fileName")
|
fileName = file.get("fileName")
|
||||||
if not fileName or fileName == "None":
|
if not fileName or fileName == "None":
|
||||||
continue
|
continue
|
||||||
|
if suppress_workflow_file_in_workspace_ui(file):
|
||||||
|
continue
|
||||||
|
|
||||||
if file.get("scope") is None:
|
if file.get("scope") is None:
|
||||||
file["scope"] = "personal"
|
file["scope"] = "personal"
|
||||||
|
|
@ -1342,16 +1348,34 @@ class ComponentObjects:
|
||||||
return newfileName
|
return newfileName
|
||||||
counter += 1
|
counter += 1
|
||||||
|
|
||||||
def createFile(self, name: str, mimeType: str, content: bytes) -> FileItem:
|
def createFile(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
mimeType: str,
|
||||||
|
content: bytes,
|
||||||
|
folderId: Optional[str] = None,
|
||||||
|
) -> FileItem:
|
||||||
"""Creates a new file entry if user has permission. Computes fileHash and fileSize from content.
|
"""Creates a new file entry if user has permission. Computes fileHash and fileSize from content.
|
||||||
|
|
||||||
Duplicate check: if a file with the same user + fileHash + fileName already exists,
|
Duplicate check: if a file with the same user + fileHash + fileName already exists,
|
||||||
the existing file is returned instead of creating a new one.
|
the existing file is returned instead of creating a new one.
|
||||||
Same hash with different name is allowed (intentional copy by user).
|
Same hash with different name is allowed (intentional copy by user).
|
||||||
|
|
||||||
|
When ``folderId`` is set, the folder must exist and the user must be allowed to modify it.
|
||||||
"""
|
"""
|
||||||
if not self.checkRbacPermission(FileItem, "create"):
|
if not self.checkRbacPermission(FileItem, "create"):
|
||||||
raise PermissionError("No permission to create files")
|
raise PermissionError("No permission to create files")
|
||||||
|
|
||||||
|
resolved_folder_id: Optional[str] = None
|
||||||
|
if folderId is not None:
|
||||||
|
raw = str(folderId).strip()
|
||||||
|
if raw:
|
||||||
|
folder = self.getFolder(raw)
|
||||||
|
if not folder:
|
||||||
|
raise FileNotFoundError(f"Folder {raw} not found")
|
||||||
|
self._requireFolderWriteAccess(folder, raw, "update")
|
||||||
|
resolved_folder_id = raw
|
||||||
|
|
||||||
# Compute file size and hash
|
# Compute file size and hash
|
||||||
fileSize = len(content)
|
fileSize = len(content)
|
||||||
fileHash = hashlib.sha256(content).hexdigest()
|
fileHash = hashlib.sha256(content).hexdigest()
|
||||||
|
|
@ -1383,6 +1407,7 @@ class ComponentObjects:
|
||||||
mimeType=mimeType,
|
mimeType=mimeType,
|
||||||
fileSize=fileSize,
|
fileSize=fileSize,
|
||||||
fileHash=fileHash,
|
fileHash=fileHash,
|
||||||
|
folderId=resolved_folder_id,
|
||||||
)
|
)
|
||||||
# Ensure audit user is always stored: workflow/singleton contexts sometimes leave
|
# Ensure audit user is always stored: workflow/singleton contexts sometimes leave
|
||||||
# the connector without _current_user_id, so _saveRecord skips sysCreatedBy →
|
# the connector without _current_user_id, so _saveRecord skips sysCreatedBy →
|
||||||
|
|
|
||||||
|
|
@ -3383,6 +3383,116 @@
|
||||||
"key": "Warnschwelle",
|
"key": "Warnschwelle",
|
||||||
"value": ""
|
"value": ""
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Ansicht an Fenster anpassen",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Ansicht zurücksetzen",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Auswahl löschen",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Canvas bearbeiten",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Klicken Sie auf einen Ausgang, dann auf einen Eingang",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Klicken Sie auf einen Eingang, um die Verbindung zu erstellen",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Kommentar (optional)",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Kommentar bearbeiten",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Knoten duplizieren",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Rückgängig",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Verbindungen zeichnen",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Vergrößern",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Verkleinern",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Wiederholen",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Zoom-Voreinstellungen",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Zoomstufe (Prozent)",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Doppelklick zum Bearbeiten",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Kommentar auf dem Canvas einfügen",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Kommentar eingeben …",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Canvas-Notiz verschieben",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Notizfarbe",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Notizgröße ändern",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"context": "ui",
|
"context": "ui",
|
||||||
"key": "✓ Mandat eingereicht",
|
"key": "✓ Mandat eingereicht",
|
||||||
|
|
@ -6776,6 +6886,116 @@
|
||||||
"key": "Warnschwelle",
|
"key": "Warnschwelle",
|
||||||
"value": "Warnschwelle"
|
"value": "Warnschwelle"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Ansicht an Fenster anpassen",
|
||||||
|
"value": "Ansicht an Fenster anpassen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Ansicht zurücksetzen",
|
||||||
|
"value": "Ansicht zurücksetzen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Auswahl löschen",
|
||||||
|
"value": "Auswahl löschen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Canvas bearbeiten",
|
||||||
|
"value": "Canvas bearbeiten"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Klicken Sie auf einen Ausgang, dann auf einen Eingang",
|
||||||
|
"value": "Klicken Sie auf einen Ausgang, dann auf einen Eingang"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Klicken Sie auf einen Eingang, um die Verbindung zu erstellen",
|
||||||
|
"value": "Klicken Sie auf einen Eingang, um die Verbindung zu erstellen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Kommentar (optional)",
|
||||||
|
"value": "Kommentar (optional)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Kommentar bearbeiten",
|
||||||
|
"value": "Kommentar bearbeiten"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Knoten duplizieren",
|
||||||
|
"value": "Knoten duplizieren"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Rückgängig",
|
||||||
|
"value": "Rückgängig"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Verbindungen zeichnen",
|
||||||
|
"value": "Verbindungen zeichnen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Vergrößern",
|
||||||
|
"value": "Vergrößern"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Verkleinern",
|
||||||
|
"value": "Verkleinern"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Wiederholen",
|
||||||
|
"value": "Wiederholen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Zoom-Voreinstellungen",
|
||||||
|
"value": "Zoom-Voreinstellungen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Zoomstufe (Prozent)",
|
||||||
|
"value": "Zoomstufe (Prozent)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Doppelklick zum Bearbeiten",
|
||||||
|
"value": "Doppelklick zum Bearbeiten"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Kommentar auf dem Canvas einfügen",
|
||||||
|
"value": "Kommentar auf dem Canvas einfügen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Kommentar eingeben …",
|
||||||
|
"value": "Kommentar eingeben …"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Canvas-Notiz verschieben",
|
||||||
|
"value": "Zum Verschieben greifen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Notizfarbe",
|
||||||
|
"value": "Notizfarbe"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Notizgröße ändern",
|
||||||
|
"value": "Notizgröße ändern"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"context": "ui",
|
"context": "ui",
|
||||||
"key": "✓ Mandat eingereicht",
|
"key": "✓ Mandat eingereicht",
|
||||||
|
|
@ -9994,6 +10214,116 @@
|
||||||
"key": "Warnschwelle",
|
"key": "Warnschwelle",
|
||||||
"value": "Warning threshold"
|
"value": "Warning threshold"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Ansicht an Fenster anpassen",
|
||||||
|
"value": "Fit to window"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Ansicht zurücksetzen",
|
||||||
|
"value": "Reset view"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Auswahl löschen",
|
||||||
|
"value": "Delete selection"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Canvas bearbeiten",
|
||||||
|
"value": "Edit canvas"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Klicken Sie auf einen Ausgang, dann auf einen Eingang",
|
||||||
|
"value": "Click an output, then an input"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Klicken Sie auf einen Eingang, um die Verbindung zu erstellen",
|
||||||
|
"value": "Click an input to create the connection"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Kommentar (optional)",
|
||||||
|
"value": "Comment (optional)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Kommentar bearbeiten",
|
||||||
|
"value": "Edit comment"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Knoten duplizieren",
|
||||||
|
"value": "Duplicate node"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Rückgängig",
|
||||||
|
"value": "Undo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Verbindungen zeichnen",
|
||||||
|
"value": "Draw connections"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Vergrößern",
|
||||||
|
"value": "Zoom in"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Verkleinern",
|
||||||
|
"value": "Zoom out"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Wiederholen",
|
||||||
|
"value": "Redo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Zoom-Voreinstellungen",
|
||||||
|
"value": "Zoom presets"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Zoomstufe (Prozent)",
|
||||||
|
"value": "Zoom level (percent)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Doppelklick zum Bearbeiten",
|
||||||
|
"value": "Double-click to edit"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Kommentar auf dem Canvas einfügen",
|
||||||
|
"value": "Add comment on canvas"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Kommentar eingeben …",
|
||||||
|
"value": "Enter a comment…"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Canvas-Notiz verschieben",
|
||||||
|
"value": "Drag to move note"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Notizfarbe",
|
||||||
|
"value": "Note color"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Notizgröße ändern",
|
||||||
|
"value": "Resize note"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"context": "ui",
|
"context": "ui",
|
||||||
"key": "✓ Mandat eingereicht",
|
"key": "✓ Mandat eingereicht",
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""
|
"""
|
||||||
SysAdmin API for database table statistics and FK orphan detection/cleanup.
|
SysAdmin API for database table statistics, FK orphan detection/cleanup,
|
||||||
|
and database migration (backup / restore).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from modules.auth import limiter
|
from modules.auth import limiter
|
||||||
|
|
@ -17,11 +20,23 @@ from modules.system.databaseHealth import (
|
||||||
OrphanCleanupRefused,
|
OrphanCleanupRefused,
|
||||||
_cleanAllOrphans,
|
_cleanAllOrphans,
|
||||||
_cleanOrphans,
|
_cleanOrphans,
|
||||||
|
_discoverLegacyTables,
|
||||||
|
_dropLegacyTable,
|
||||||
_getTableStats,
|
_getTableStats,
|
||||||
_isUserIdFk,
|
_isUserIdFk,
|
||||||
_listOrphans,
|
_listOrphans,
|
||||||
_scanOrphans,
|
_scanOrphans,
|
||||||
)
|
)
|
||||||
|
from modules.system.databaseMigration import (
|
||||||
|
_exportDatabases,
|
||||||
|
_exportSingleDb,
|
||||||
|
_getAvailableDatabases,
|
||||||
|
_getInstanceLabel,
|
||||||
|
_importDatabases,
|
||||||
|
_importSingleDb,
|
||||||
|
_prepareImport,
|
||||||
|
_validateImportPayload,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -194,3 +209,531 @@ def postDatabaseOrphansCleanAll(
|
||||||
excludeUserFks,
|
excludeUserFks,
|
||||||
)
|
)
|
||||||
return {"results": results, "skipped": skipped, "errored": errored, "deleted": deletedTotal}
|
return {"results": results, "skipped": skipped, "errored": errored, "deleted": deletedTotal}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Legacy Tables (tables without Pydantic model)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class LegacyTableDropRequest(BaseModel):
|
||||||
|
"""Body for dropping a legacy table."""
|
||||||
|
db: str = Field(..., description="Database name")
|
||||||
|
table: str = Field(..., description="Table name to drop")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/legacy-tables")
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
def getLegacyTables(
|
||||||
|
request: Request,
|
||||||
|
db: Optional[str] = None,
|
||||||
|
currentUser: User = Depends(requireSysAdmin),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""List tables that exist in the database but have no Pydantic model.
|
||||||
|
|
||||||
|
Optional ``db`` filter to scope to a single database.
|
||||||
|
"""
|
||||||
|
tables = _discoverLegacyTables(dbFilter=db)
|
||||||
|
totalRows = sum(t["rowCount"] for t in tables)
|
||||||
|
totalSize = sum(t["sizeBytes"] for t in tables)
|
||||||
|
return {
|
||||||
|
"legacyTables": tables,
|
||||||
|
"totalCount": len(tables),
|
||||||
|
"totalRows": totalRows,
|
||||||
|
"totalSizeBytes": totalSize,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/legacy-tables/drop")
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
def postLegacyTableDrop(
|
||||||
|
request: Request,
|
||||||
|
body: LegacyTableDropRequest,
|
||||||
|
currentUser: User = Depends(requireSysAdmin),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Drop a legacy table (CASCADE). Refuses if the table is model-backed."""
|
||||||
|
try:
|
||||||
|
result = _dropLegacyTable(body.db, body.table)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(e),
|
||||||
|
) from e
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Drop failed: {e}",
|
||||||
|
) from e
|
||||||
|
logger.info(
|
||||||
|
"SysAdmin legacy-table drop: user=%s db=%s table=%s rows=%s",
|
||||||
|
currentUser.username, body.db, body.table, result.get("rowCount"),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Migration (Backup / Restore)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class MigrationImportRequest(BaseModel):
|
||||||
|
"""Body for the import endpoint."""
|
||||||
|
|
||||||
|
payload: dict = Field(..., description="The full export JSON payload")
|
||||||
|
mode: str = Field(
|
||||||
|
...,
|
||||||
|
description="'replace' (clear + insert) or 'merge' (insert missing only)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/migration/databases")
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
def getMigrationDatabases(
|
||||||
|
request: Request,
|
||||||
|
currentUser: User = Depends(requireSysAdmin),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""List registered databases with table/record counts for the migration UI."""
|
||||||
|
databases = _getAvailableDatabases()
|
||||||
|
return {"databases": databases, "instanceLabel": _getInstanceLabel()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/migration/export")
|
||||||
|
@limiter.limit("2/minute")
|
||||||
|
def getMigrationExport(
|
||||||
|
request: Request,
|
||||||
|
databases: str = "all",
|
||||||
|
currentUser: User = Depends(requireSysAdmin),
|
||||||
|
) -> StreamingResponse:
|
||||||
|
"""Export selected databases as a downloadable JSON file.
|
||||||
|
|
||||||
|
``databases`` is a comma-separated list of database names, or ``"all"``.
|
||||||
|
"""
|
||||||
|
if databases == "all":
|
||||||
|
available = _getAvailableDatabases()
|
||||||
|
dbList = [db["name"] for db in available]
|
||||||
|
else:
|
||||||
|
dbList = [d.strip() for d in databases.split(",") if d.strip()]
|
||||||
|
|
||||||
|
if not dbList:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="No databases selected for export.",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"SysAdmin migration export: user=%s databases=%s",
|
||||||
|
currentUser.username,
|
||||||
|
dbList,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
exportData = _exportDatabases(dbList)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Migration export failed: %s", e)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Export failed: {e}",
|
||||||
|
) from e
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d_%H-%M")
|
||||||
|
filename = f"migration_backup_{ts}.json"
|
||||||
|
|
||||||
|
content = json.dumps(exportData, ensure_ascii=False, default=str)
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
iter([content]),
|
||||||
|
media_type="application/json",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/migration/validate")
|
||||||
|
@limiter.limit("5/minute")
|
||||||
|
async def postMigrationValidate(
|
||||||
|
request: Request,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
currentUser: User = Depends(requireSysAdmin),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Validate an uploaded migration JSON file without writing anything."""
|
||||||
|
try:
|
||||||
|
rawBytes = await file.read()
|
||||||
|
payload = json.loads(rawBytes.decode("utf-8"))
|
||||||
|
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid JSON file: {e}",
|
||||||
|
) from e
|
||||||
|
|
||||||
|
result = _validateImportPayload(payload)
|
||||||
|
logger.info(
|
||||||
|
"SysAdmin migration validate: user=%s valid=%s",
|
||||||
|
currentUser.username,
|
||||||
|
result.get("valid"),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/migration/import")
|
||||||
|
@limiter.limit("2/minute")
|
||||||
|
async def postMigrationImport(
|
||||||
|
request: Request,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
mode: str = "merge",
|
||||||
|
currentUser: User = Depends(requireSysAdmin),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Import a migration JSON file.
|
||||||
|
|
||||||
|
``mode`` is passed as a form field:
|
||||||
|
- ``replace``: clear all tables (except system objects) and insert.
|
||||||
|
- ``merge``: insert only records whose ID does not yet exist.
|
||||||
|
"""
|
||||||
|
if mode not in ("replace", "merge"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid mode: '{mode}'. Must be 'replace' or 'merge'.",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
rawBytes = await file.read()
|
||||||
|
payload = json.loads(rawBytes.decode("utf-8"))
|
||||||
|
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid JSON file: {e}",
|
||||||
|
) from e
|
||||||
|
|
||||||
|
validation = _validateImportPayload(payload)
|
||||||
|
if not validation.get("valid"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail={"message": "Payload validation failed", "warnings": validation.get("warnings", [])},
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"SysAdmin migration import: user=%s mode=%s",
|
||||||
|
currentUser.username,
|
||||||
|
mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = _importDatabases(payload, mode)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Migration import failed: %s", e)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Import failed: {e}",
|
||||||
|
) from e
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"SysAdmin migration import complete: user=%s mode=%s totalRecords=%s warnings=%s",
|
||||||
|
currentUser.username,
|
||||||
|
mode,
|
||||||
|
result.get("totalRecords"),
|
||||||
|
len(result.get("warnings", [])),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Per-DB endpoints (progress-friendly)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_pendingExports: Dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/migration/export-start")
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
def postMigrationExportStart(
|
||||||
|
request: Request,
|
||||||
|
currentUser: User = Depends(requireSysAdmin),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Start an export session. Returns a token for subsequent per-DB calls."""
|
||||||
|
import uuid
|
||||||
|
token = str(uuid.uuid4())
|
||||||
|
_pendingExports[token] = {"databases": {}}
|
||||||
|
logger.info("SysAdmin migration export-start: user=%s token=%s", currentUser.username, token)
|
||||||
|
return {"token": token}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/migration/export-single")
|
||||||
|
@limiter.limit("60/minute")
|
||||||
|
def getMigrationExportSingle(
|
||||||
|
request: Request,
|
||||||
|
token: str,
|
||||||
|
database: str,
|
||||||
|
currentUser: User = Depends(requireSysAdmin),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Export a single database and store it server-side. Returns only metadata."""
|
||||||
|
from modules.shared.dbRegistry import getRegisteredDatabases
|
||||||
|
|
||||||
|
pending = _pendingExports.get(token)
|
||||||
|
if not pending:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid export token.")
|
||||||
|
|
||||||
|
if database not in getRegisteredDatabases():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Database '{database}' is not registered.",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("SysAdmin migration export-single: user=%s db=%s", currentUser.username, database)
|
||||||
|
|
||||||
|
try:
|
||||||
|
dbPayload = _exportSingleDb(database)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Export-single failed for %s: %s", database, e)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Export failed for '{database}': {e}",
|
||||||
|
) from e
|
||||||
|
|
||||||
|
pending["databases"][database] = dbPayload
|
||||||
|
logger.info("SysAdmin migration export-single done: user=%s db=%s tables=%s records=%s",
|
||||||
|
currentUser.username, database, dbPayload.get("tableCount", 0), dbPayload.get("totalRecords", 0))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"database": database,
|
||||||
|
"tableCount": dbPayload.get("tableCount", 0),
|
||||||
|
"totalRecords": dbPayload.get("totalRecords", 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/migration/export-download")
|
||||||
|
@limiter.limit("5/minute")
|
||||||
|
def getMigrationExportDownload(
|
||||||
|
request: Request,
|
||||||
|
token: str,
|
||||||
|
filename: str = "backup.json",
|
||||||
|
currentUser: User = Depends(requireSysAdmin),
|
||||||
|
) -> StreamingResponse:
|
||||||
|
"""Assemble and stream the final export file from server-side data."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
pending = _pendingExports.pop(token, None)
|
||||||
|
if not pending or not pending.get("databases"):
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired export token.")
|
||||||
|
|
||||||
|
databases = pending["databases"]
|
||||||
|
totalTables = sum(d.get("tableCount", 0) for d in databases.values())
|
||||||
|
totalRecords = sum(d.get("totalRecords", 0) for d in databases.values())
|
||||||
|
|
||||||
|
exportData = {
|
||||||
|
"meta": {
|
||||||
|
"exportedAt": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"version": "1.0",
|
||||||
|
"databaseCount": len(databases),
|
||||||
|
"totalTables": totalTables,
|
||||||
|
"totalRecords": totalRecords,
|
||||||
|
},
|
||||||
|
"databases": databases,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("SysAdmin migration export-download: user=%s dbs=%s tables=%s records=%s",
|
||||||
|
currentUser.username, len(databases), totalTables, totalRecords)
|
||||||
|
|
||||||
|
content = json.dumps(exportData, ensure_ascii=False, default=str)
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
iter([content]),
|
||||||
|
media_type="application/json",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _processUploadedFile(filePath: str, tmpDir: str, token: str) -> dict:
|
||||||
|
"""Parse JSON, validate, remap, split into per-DB files.
|
||||||
|
|
||||||
|
Runs in a thread pool to avoid blocking the asyncio event loop
|
||||||
|
during the CPU-heavy json.load() of large (500+ MB) files.
|
||||||
|
"""
|
||||||
|
import gc
|
||||||
|
import os
|
||||||
|
|
||||||
|
with open(filePath, "r", encoding="utf-8") as f:
|
||||||
|
payload = json.load(f)
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.remove(filePath)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
result = _prepareImport(payload)
|
||||||
|
|
||||||
|
if not result.get("valid"):
|
||||||
|
del payload
|
||||||
|
gc.collect()
|
||||||
|
return {"result": result, "dbFiles": {}}
|
||||||
|
|
||||||
|
protectedIds = result.get("protectedIds", [])
|
||||||
|
|
||||||
|
dbFiles = {}
|
||||||
|
databases = payload.get("databases", {})
|
||||||
|
for dbName, dbData in databases.items():
|
||||||
|
dbPath = os.path.join(tmpDir, f"poweron_import_{token}_{dbName}.json")
|
||||||
|
with open(dbPath, "w", encoding="utf-8") as dbF:
|
||||||
|
json.dump(dbData, dbF, ensure_ascii=False, default=str)
|
||||||
|
dbFiles[dbName] = dbPath
|
||||||
|
|
||||||
|
del payload
|
||||||
|
del databases
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
return {"result": result, "dbFiles": dbFiles, "protectedIds": protectedIds}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/migration/upload-import")
|
||||||
|
@limiter.limit("5/minute")
|
||||||
|
async def postMigrationUploadImport(
|
||||||
|
request: Request,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
currentUser: User = Depends(requireSysAdmin),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Upload a backup file to disk (chunked), validate, remap IDs,
|
||||||
|
split into per-DB temp files so the full payload doesn't stay in RAM.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
token = str(uuid.uuid4())
|
||||||
|
tmpDir = tempfile.gettempdir()
|
||||||
|
filePath = os.path.join(tmpDir, f"poweron_import_{token}.json")
|
||||||
|
|
||||||
|
logger.info("SysAdmin migration upload-import: user=%s streaming to %s", currentUser.username, filePath)
|
||||||
|
|
||||||
|
totalBytes = 0
|
||||||
|
chunkSize = 1024 * 1024
|
||||||
|
try:
|
||||||
|
with open(filePath, "wb") as f:
|
||||||
|
while True:
|
||||||
|
chunk = await file.read(chunkSize)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
f.write(chunk)
|
||||||
|
totalBytes += len(chunk)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Upload-import write failed: %s", e)
|
||||||
|
if os.path.exists(filePath):
|
||||||
|
os.remove(filePath)
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Upload failed: {e}") from e
|
||||||
|
|
||||||
|
logger.info("SysAdmin migration upload-import: %s bytes on disk (%.1f MB)",
|
||||||
|
totalBytes, totalBytes / 1024 / 1024)
|
||||||
|
|
||||||
|
try:
|
||||||
|
processed = await asyncio.to_thread(_processUploadedFile, filePath, tmpDir, token)
|
||||||
|
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
||||||
|
if os.path.exists(filePath):
|
||||||
|
os.remove(filePath)
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid JSON file: {e}") from e
|
||||||
|
except Exception as e:
|
||||||
|
if os.path.exists(filePath):
|
||||||
|
os.remove(filePath)
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Processing failed: {e}") from e
|
||||||
|
|
||||||
|
result = processed["result"]
|
||||||
|
dbFiles = processed.get("dbFiles", {})
|
||||||
|
|
||||||
|
if not result.get("valid"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail={"message": "Payload validation failed", "warnings": result.get("warnings", [])},
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("SysAdmin migration upload-import: split into %d per-DB files, payload freed",
|
||||||
|
len(dbFiles))
|
||||||
|
|
||||||
|
_pendingImports[token] = {
|
||||||
|
"dbFiles": dbFiles,
|
||||||
|
"protectedIds": processed.get("protectedIds", []),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"token": token,
|
||||||
|
"valid": result.get("valid", False),
|
||||||
|
"databases": result.get("databases", []),
|
||||||
|
"warnings": result.get("warnings", []),
|
||||||
|
"systemObjectsFound": result.get("systemObjectsFound", []),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_pendingImports: Dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/migration/import-single")
|
||||||
|
@limiter.limit("60/minute")
|
||||||
|
def postMigrationImportSingle(
|
||||||
|
request: Request,
|
||||||
|
body: dict,
|
||||||
|
currentUser: User = Depends(requireSysAdmin),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Import a single database from a previously uploaded + prepared payload.
|
||||||
|
|
||||||
|
Body: ``{token, database, mode}``
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
token = body.get("token", "")
|
||||||
|
database = body.get("database", "")
|
||||||
|
mode = body.get("mode", "merge")
|
||||||
|
|
||||||
|
if mode not in ("replace", "merge"):
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid mode: '{mode}'.")
|
||||||
|
|
||||||
|
pending = _pendingImports.get(token)
|
||||||
|
if not pending:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired import token.")
|
||||||
|
|
||||||
|
dbFiles = pending.get("dbFiles", {})
|
||||||
|
dbFilePath = dbFiles.get(database)
|
||||||
|
if not dbFilePath or not os.path.exists(dbFilePath):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"No data for database '{database}'.",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("SysAdmin migration import-single: user=%s db=%s mode=%s", currentUser.username, database, mode)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(dbFilePath, "r", encoding="utf-8") as f:
|
||||||
|
dbData = json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to read import data for '{database}': {e}",
|
||||||
|
) from e
|
||||||
|
|
||||||
|
payload = {"databases": {database: dbData}}
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = _importSingleDb(payload, database, mode, pending["protectedIds"])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Import-single failed for %s: %s", database, e)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Import failed for '{database}': {e}",
|
||||||
|
) from e
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/migration/import-done")
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
def postMigrationImportDone(
|
||||||
|
request: Request,
|
||||||
|
body: dict,
|
||||||
|
currentUser: User = Depends(requireSysAdmin),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Clean up the per-DB temp files."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
token = body.get("token", "")
|
||||||
|
pending = _pendingImports.pop(token, None)
|
||||||
|
if pending:
|
||||||
|
for dbPath in pending.get("dbFiles", {}).values():
|
||||||
|
try:
|
||||||
|
os.remove(dbPath)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return {"ok": True}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import (
|
||||||
AutoWorkflow,
|
AutoWorkflow,
|
||||||
)
|
)
|
||||||
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import graphicalEditorDatabase
|
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import graphicalEditorDatabase
|
||||||
|
from modules.workflows.automation2.workflowArtifactVisibility import suppress_workflow_file_in_workspace_ui
|
||||||
from modules.shared.i18nRegistry import apiRouteContext
|
from modules.shared.i18nRegistry import apiRouteContext
|
||||||
|
|
||||||
routeApiMsg = apiRouteContext("routeAutomationWorkspace")
|
routeApiMsg = apiRouteContext("routeAutomationWorkspace")
|
||||||
|
|
@ -265,7 +266,8 @@ def getWorkspaceRunDetail(
|
||||||
logger.warning("getWorkspaceRunDetail: file lookup failed: %s", e)
|
logger.warning("getWorkspaceRunDetail: file lookup failed: %s", e)
|
||||||
|
|
||||||
def _resolveFileList(ids: set[str]) -> list[dict]:
|
def _resolveFileList(ids: set[str]) -> list[dict]:
|
||||||
return [fileMetaById[fid] for fid in ids if fid in fileMetaById]
|
rows = [dict(fileMetaById[fid]) for fid in ids if fid in fileMetaById]
|
||||||
|
return [m for m in rows if not suppress_workflow_file_in_workspace_ui(m)]
|
||||||
|
|
||||||
assignedFileIds: set[str] = set()
|
assignedFileIds: set[str] = set()
|
||||||
for step, (inputIds, outputIds) in zip(steps, perStepFileIds):
|
for step, (inputIds, outputIds) in zip(steps, perStepFileIds):
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ from fastapi.responses import JSONResponse
|
||||||
from modules.datamodels.datamodelUam import User, UserConnection, AuthAuthority, ConnectionStatus
|
from modules.datamodels.datamodelUam import User, UserConnection, AuthAuthority, ConnectionStatus
|
||||||
from modules.datamodels.datamodelSecurity import Token
|
from modules.datamodels.datamodelSecurity import Token
|
||||||
from modules.auth import getCurrentUser, limiter
|
from modules.auth import getCurrentUser, limiter
|
||||||
|
from modules.auth.oauthConnectTicket import issue_connect_ticket
|
||||||
from modules.auth.tokenRefreshService import token_refresh_service
|
from modules.auth.tokenRefreshService import token_refresh_service
|
||||||
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
|
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
|
||||||
from modules.interfaces.interfaceDbApp import getInterface
|
from modules.interfaces.interfaceDbApp import getInterface
|
||||||
|
|
@ -564,14 +565,30 @@ def connect_service(
|
||||||
reauth = bool((body or {}).get("reauth")) if isinstance(body, dict) else False
|
reauth = bool((body or {}).get("reauth")) if isinstance(body, dict) else False
|
||||||
reauthSuffix = "&reauth=1" if reauth else ""
|
reauthSuffix = "&reauth=1" if reauth else ""
|
||||||
|
|
||||||
# Data-app OAuth (JWT state issued server-side in /auth/connect)
|
# Data-app OAuth: issue connect ticket here (Bearer auth) so the popup
|
||||||
|
# does not depend on httpOnly cookies (UI uses localStorage Bearer).
|
||||||
auth_url = None
|
auth_url = None
|
||||||
if connection.authority == AuthAuthority.MSFT:
|
if connection.authority == AuthAuthority.MSFT:
|
||||||
auth_url = f"/api/msft/auth/connect?connectionId={quote(connectionId, safe='')}{reauthSuffix}"
|
ticket = issue_connect_ticket("msft_connect", connectionId, str(currentUser.id))
|
||||||
|
ticket_param = f"&connectTicket={quote(ticket, safe='')}"
|
||||||
|
auth_url = (
|
||||||
|
f"/api/msft/auth/connect?connectionId={quote(connectionId, safe='')}"
|
||||||
|
f"{ticket_param}{reauthSuffix}"
|
||||||
|
)
|
||||||
elif connection.authority == AuthAuthority.GOOGLE:
|
elif connection.authority == AuthAuthority.GOOGLE:
|
||||||
auth_url = f"/api/google/auth/connect?connectionId={quote(connectionId, safe='')}{reauthSuffix}"
|
ticket = issue_connect_ticket("google_connect", connectionId, str(currentUser.id))
|
||||||
|
ticket_param = f"&connectTicket={quote(ticket, safe='')}"
|
||||||
|
auth_url = (
|
||||||
|
f"/api/google/auth/connect?connectionId={quote(connectionId, safe='')}"
|
||||||
|
f"{ticket_param}{reauthSuffix}"
|
||||||
|
)
|
||||||
elif connection.authority == AuthAuthority.CLICKUP:
|
elif connection.authority == AuthAuthority.CLICKUP:
|
||||||
auth_url = f"/api/clickup/auth/connect?connectionId={quote(connectionId, safe='')}{reauthSuffix}"
|
ticket = issue_connect_ticket("clickup_connect", connectionId, str(currentUser.id))
|
||||||
|
ticket_param = f"&connectTicket={quote(ticket, safe='')}"
|
||||||
|
auth_url = (
|
||||||
|
f"/api/clickup/auth/connect?connectionId={quote(connectionId, safe='')}"
|
||||||
|
f"{ticket_param}{reauthSuffix}"
|
||||||
|
)
|
||||||
elif connection.authority == AuthAuthority.INFOMANIAK:
|
elif connection.authority == AuthAuthority.INFOMANIAK:
|
||||||
# Infomaniak does not use OAuth for data access; the frontend posts a
|
# Infomaniak does not use OAuth for data access; the frontend posts a
|
||||||
# Personal Access Token directly to /api/infomaniak/connections/{id}/token.
|
# Personal Access Token directly to /api/infomaniak/connections/{id}/token.
|
||||||
|
|
|
||||||
|
|
@ -496,7 +496,7 @@ def _getDataSourceCostEstimate(
|
||||||
|
|
||||||
Uses the current effective ragLimits (DataSource.settings.ragLimits with
|
Uses the current effective ragLimits (DataSource.settings.ragLimits with
|
||||||
fallback to centralized defaults) as the basis. Returns the same
|
fallback to centralized defaults) as the basis. Returns the same
|
||||||
`{estimatedTokens, estimatedUsd, basis}` shape regardless of source kind.
|
`{estimatedTokens, estimatedChf, basis}` shape regardless of source kind.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ from modules.interfaces.interfaceDbApp import getInterface, getRootInterface
|
||||||
from modules.datamodels.datamodelUam import AuthAuthority, User, ConnectionStatus, UserConnection
|
from modules.datamodels.datamodelUam import AuthAuthority, User, ConnectionStatus, UserConnection
|
||||||
from modules.datamodels.datamodelSecurity import Token, TokenPurpose
|
from modules.datamodels.datamodelSecurity import Token, TokenPurpose
|
||||||
from modules.auth import getCurrentUser, limiter, SECRET_KEY, ALGORITHM
|
from modules.auth import getCurrentUser, limiter, SECRET_KEY, ALGORITHM
|
||||||
|
from modules.auth.oauthConnectTicket import resolve_connect_context
|
||||||
from modules.shared.timeUtils import createExpirationTimestamp, getUtcTimestamp
|
from modules.shared.timeUtils import createExpirationTimestamp, getUtcTimestamp
|
||||||
from modules.shared.i18nRegistry import apiRouteContext
|
from modules.shared.i18nRegistry import apiRouteContext
|
||||||
routeApiMsg = apiRouteContext("routeSecurityClickup")
|
routeApiMsg = apiRouteContext("routeSecurityClickup")
|
||||||
|
|
@ -76,28 +77,20 @@ router = APIRouter(
|
||||||
def auth_connect(
|
def auth_connect(
|
||||||
request: Request,
|
request: Request,
|
||||||
connectionId: str = Query(..., description="UserConnection id"),
|
connectionId: str = Query(..., description="UserConnection id"),
|
||||||
currentUser: User = Depends(getCurrentUser),
|
connectTicket: str = Query(..., description="Short-lived ticket from POST /api/connections/{id}/connect"),
|
||||||
) -> RedirectResponse:
|
) -> RedirectResponse:
|
||||||
"""Start ClickUp OAuth for an existing connection (requires gateway session)."""
|
"""Start ClickUp OAuth for an existing connection.
|
||||||
|
|
||||||
|
Authenticated via ``connectTicket`` (issued by POST connect) so the popup
|
||||||
|
works when the UI uses Bearer tokens in localStorage instead of cookies.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
_require_clickup_config()
|
_require_clickup_config()
|
||||||
interface = getInterface(currentUser)
|
_user, connection = resolve_connect_context(
|
||||||
connections = interface.getUserConnections(currentUser.id)
|
connectTicket, connectionId, _FLOW_CONNECT, AuthAuthority.CLICKUP
|
||||||
connection = None
|
|
||||||
for conn in connections:
|
|
||||||
if conn.id == connectionId and conn.authority == AuthAuthority.CLICKUP:
|
|
||||||
connection = conn
|
|
||||||
break
|
|
||||||
if not connection:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=routeApiMsg("ClickUp connection not found"))
|
|
||||||
|
|
||||||
state_jwt = _issue_oauth_state(
|
|
||||||
{
|
|
||||||
"flow": _FLOW_CONNECT,
|
|
||||||
"connectionId": connectionId,
|
|
||||||
"userId": str(currentUser.id),
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
state_jwt = connectTicket
|
||||||
query = urlencode(
|
query = urlencode(
|
||||||
{
|
{
|
||||||
"client_id": CLIENT_ID,
|
"client_id": CLIENT_ID,
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ from modules.interfaces.interfaceDbApp import getInterface, getRootInterface
|
||||||
from modules.datamodels.datamodelUam import AuthAuthority, User, ConnectionStatus, UserConnection
|
from modules.datamodels.datamodelUam import AuthAuthority, User, ConnectionStatus, UserConnection
|
||||||
from modules.datamodels.datamodelSecurity import Token, TokenPurpose
|
from modules.datamodels.datamodelSecurity import Token, TokenPurpose
|
||||||
from modules.auth import getCurrentUser, limiter, SECRET_KEY, ALGORITHM
|
from modules.auth import getCurrentUser, limiter, SECRET_KEY, ALGORITHM
|
||||||
|
from modules.auth.oauthConnectTicket import resolve_connect_context
|
||||||
from modules.auth import (
|
from modules.auth import (
|
||||||
createAccessToken,
|
createAccessToken,
|
||||||
setAccessTokenCookie,
|
setAccessTokenCookie,
|
||||||
|
|
@ -281,10 +282,13 @@ async def auth_login_callback(
|
||||||
def auth_connect(
|
def auth_connect(
|
||||||
request: Request,
|
request: Request,
|
||||||
connectionId: str = Query(..., description="UserConnection id"),
|
connectionId: str = Query(..., description="UserConnection id"),
|
||||||
|
connectTicket: str = Query(..., description="Short-lived ticket from POST /api/connections/{id}/connect"),
|
||||||
reauth: Optional[int] = Query(0, description="If 1, force the consent screen so newly added scopes are granted"),
|
reauth: Optional[int] = Query(0, description="If 1, force the consent screen so newly added scopes are granted"),
|
||||||
currentUser: User = Depends(getCurrentUser),
|
|
||||||
) -> RedirectResponse:
|
) -> RedirectResponse:
|
||||||
"""Start Google Data OAuth for an existing connection (requires gateway session).
|
"""Start Google Data OAuth for an existing connection.
|
||||||
|
|
||||||
|
Authenticated via ``connectTicket`` (issued by POST connect) so the popup
|
||||||
|
works when the UI uses Bearer tokens in localStorage instead of cookies.
|
||||||
|
|
||||||
Google already defaults to ``prompt=consent`` here, but ``include_granted_scopes=true``
|
Google already defaults to ``prompt=consent`` here, but ``include_granted_scopes=true``
|
||||||
can cause newly added scopes (e.g. calendar.readonly, contacts.readonly) to be
|
can cause newly added scopes (e.g. calendar.readonly, contacts.readonly) to be
|
||||||
|
|
@ -294,23 +298,11 @@ def auth_connect(
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
_require_google_data_config()
|
_require_google_data_config()
|
||||||
interface = getInterface(currentUser)
|
_user, connection = resolve_connect_context(
|
||||||
connections = interface.getUserConnections(currentUser.id)
|
connectTicket, connectionId, _FLOW_CONNECT, AuthAuthority.GOOGLE
|
||||||
connection = None
|
|
||||||
for conn in connections:
|
|
||||||
if conn.id == connectionId and conn.authority == AuthAuthority.GOOGLE:
|
|
||||||
connection = conn
|
|
||||||
break
|
|
||||||
if not connection:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=routeApiMsg("Google connection not found"))
|
|
||||||
|
|
||||||
state_jwt = _issue_oauth_state(
|
|
||||||
{
|
|
||||||
"flow": _FLOW_CONNECT,
|
|
||||||
"connectionId": connectionId,
|
|
||||||
"userId": str(currentUser.id),
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
state_jwt = connectTicket
|
||||||
oauth = OAuth2Session(
|
oauth = OAuth2Session(
|
||||||
client_id=DATA_CLIENT_ID,
|
client_id=DATA_CLIENT_ID,
|
||||||
redirect_uri=DATA_REDIRECT_URI,
|
redirect_uri=DATA_REDIRECT_URI,
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ from modules.interfaces.interfaceDbApp import getInterface, getRootInterface
|
||||||
from modules.datamodels.datamodelUam import AuthAuthority, User, ConnectionStatus, UserConnection
|
from modules.datamodels.datamodelUam import AuthAuthority, User, ConnectionStatus, UserConnection
|
||||||
from modules.datamodels.datamodelSecurity import Token, TokenPurpose
|
from modules.datamodels.datamodelSecurity import Token, TokenPurpose
|
||||||
from modules.auth import getCurrentUser, limiter, SECRET_KEY, ALGORITHM
|
from modules.auth import getCurrentUser, limiter, SECRET_KEY, ALGORITHM
|
||||||
|
from modules.auth.oauthConnectTicket import resolve_connect_context
|
||||||
from modules.auth import (
|
from modules.auth import (
|
||||||
createAccessToken,
|
createAccessToken,
|
||||||
setAccessTokenCookie,
|
setAccessTokenCookie,
|
||||||
|
|
@ -244,27 +245,22 @@ async def auth_login_callback(
|
||||||
def auth_connect(
|
def auth_connect(
|
||||||
request: Request,
|
request: Request,
|
||||||
connectionId: str = Query(..., description="UserConnection id"),
|
connectionId: str = Query(..., description="UserConnection id"),
|
||||||
|
connectTicket: str = Query(..., description="Short-lived ticket from POST /api/connections/{id}/connect"),
|
||||||
reauth: Optional[int] = Query(0, description="If 1, force the consent screen so newly added scopes are granted"),
|
reauth: Optional[int] = Query(0, description="If 1, force the consent screen so newly added scopes are granted"),
|
||||||
currentUser: User = Depends(getCurrentUser),
|
|
||||||
) -> RedirectResponse:
|
) -> RedirectResponse:
|
||||||
"""Start Microsoft Data OAuth for an existing connection.
|
"""Start Microsoft Data OAuth for an existing connection.
|
||||||
|
|
||||||
|
Authenticated via ``connectTicket`` (issued by POST connect) so the popup
|
||||||
|
works when the UI uses Bearer tokens in localStorage instead of cookies.
|
||||||
|
|
||||||
With ``reauth=1`` the consent screen is forced (``prompt=consent``) so the
|
With ``reauth=1`` the consent screen is forced (``prompt=consent``) so the
|
||||||
user re-grants permissions and any newly added scopes (e.g. Calendars.Read,
|
user re-grants permissions and any newly added scopes (e.g. Calendars.Read,
|
||||||
Contacts.Read) actually land on the access token.
|
Contacts.Read) actually land on the access token.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
_require_msft_data_config()
|
_require_msft_data_config()
|
||||||
interface = getInterface(currentUser)
|
_user, connection = resolve_connect_context(
|
||||||
connections = interface.getUserConnections(currentUser.id)
|
connectTicket, connectionId, _FLOW_CONNECT, AuthAuthority.MSFT
|
||||||
connection = None
|
|
||||||
for conn in connections:
|
|
||||||
if conn.id == connectionId and conn.authority == AuthAuthority.MSFT:
|
|
||||||
connection = conn
|
|
||||||
break
|
|
||||||
if not connection:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail=routeApiMsg("Microsoft connection not found")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
msal_app = msal.ConfidentialClientApplication(
|
msal_app = msal.ConfidentialClientApplication(
|
||||||
|
|
@ -272,13 +268,7 @@ def auth_connect(
|
||||||
authority=AUTHORITY,
|
authority=AUTHORITY,
|
||||||
client_credential=DATA_CLIENT_SECRET,
|
client_credential=DATA_CLIENT_SECRET,
|
||||||
)
|
)
|
||||||
state_jwt = _issue_oauth_state(
|
state_jwt = connectTicket
|
||||||
{
|
|
||||||
"flow": _FLOW_CONNECT,
|
|
||||||
"connectionId": connectionId,
|
|
||||||
"userId": str(currentUser.id),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
login_kwargs: Dict[str, Any] = {"prompt": "select_account", "state": state_jwt}
|
login_kwargs: Dict[str, Any] = {"prompt": "select_account", "state": state_jwt}
|
||||||
login_hint = connection.externalEmail or connection.externalUsername
|
login_hint = connection.externalEmail or connection.externalUsername
|
||||||
if login_hint:
|
if login_hint:
|
||||||
|
|
|
||||||
|
|
@ -58,14 +58,32 @@ def _getUserMandateIds(userId: str) -> list[str]:
|
||||||
|
|
||||||
|
|
||||||
def _getAdminMandateIds(userId: str, mandateIds: list) -> list:
|
def _getAdminMandateIds(userId: str, mandateIds: list) -> list:
|
||||||
"""Batch-check which mandates the user is admin for (2 SQL queries total)."""
|
"""Batch-check which mandates the user is admin for (UserMandate → UserMandateRole → Role)."""
|
||||||
if not mandateIds:
|
if not mandateIds:
|
||||||
return []
|
return []
|
||||||
rootIface = getRootInterface()
|
rootIface = getRootInterface()
|
||||||
from modules.datamodels.datamodelMembership import UserMandateRole
|
from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
|
||||||
allRoles = rootIface.db.getRecordset(UserMandateRole, recordFilter={
|
|
||||||
"userId": userId, "mandateId": mandateIds,
|
memberships = rootIface.db.getRecordset(
|
||||||
})
|
UserMandate,
|
||||||
|
recordFilter={"userId": userId, "mandateId": mandateIds, "enabled": True},
|
||||||
|
)
|
||||||
|
if not memberships:
|
||||||
|
return []
|
||||||
|
|
||||||
|
umIdToMandateId: dict[str, str] = {}
|
||||||
|
for m in memberships:
|
||||||
|
row = m if isinstance(m, dict) else m.__dict__
|
||||||
|
um_id = row.get("id")
|
||||||
|
mid = row.get("mandateId")
|
||||||
|
if um_id and mid:
|
||||||
|
umIdToMandateId[str(um_id)] = str(mid)
|
||||||
|
|
||||||
|
userMandateIds = list(umIdToMandateId.keys())
|
||||||
|
allRoles = rootIface.db.getRecordset(
|
||||||
|
UserMandateRole,
|
||||||
|
recordFilter={"userMandateId": userMandateIds},
|
||||||
|
)
|
||||||
if not allRoles:
|
if not allRoles:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
@ -74,22 +92,25 @@ def _getAdminMandateIds(userId: str, mandateIds: list) -> list:
|
||||||
for r in allRoles:
|
for r in allRoles:
|
||||||
row = r if isinstance(r, dict) else r.__dict__
|
row = r if isinstance(r, dict) else r.__dict__
|
||||||
rid = row.get("roleId")
|
rid = row.get("roleId")
|
||||||
mid = row.get("mandateId")
|
um_id = row.get("userMandateId")
|
||||||
if rid:
|
mid = umIdToMandateId.get(str(um_id)) if um_id else None
|
||||||
|
if rid and mid:
|
||||||
roleIds.add(rid)
|
roleIds.add(rid)
|
||||||
roleToMandate.setdefault(rid, set()).add(mid)
|
roleToMandate.setdefault(rid, set()).add(mid)
|
||||||
|
|
||||||
if not roleIds:
|
if not roleIds:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
from modules.datamodels.datamodelRbac import MandateRole
|
from modules.datamodels.datamodelRbac import Role
|
||||||
roleRecords = rootIface.db.getRecordset(MandateRole, recordFilter={"id": list(roleIds)})
|
roleRecords = rootIface.db.getRecordset(Role, recordFilter={"id": list(roleIds)})
|
||||||
adminMandates: set = set()
|
adminMandates: set = set()
|
||||||
for role in (roleRecords or []):
|
for role in (roleRecords or []):
|
||||||
row = role if isinstance(role, dict) else role.__dict__
|
row = role if isinstance(role, dict) else role.__dict__
|
||||||
if row.get("isAdmin"):
|
|
||||||
rid = row.get("id")
|
rid = row.get("id")
|
||||||
if rid and rid in roleToMandate:
|
if not rid or rid not in roleToMandate:
|
||||||
|
continue
|
||||||
|
# Same rule as routeBilling._isAdminOfMandate / notifyMandateAdmins
|
||||||
|
if row.get("roleLabel") == "admin" and not row.get("featureInstanceId"):
|
||||||
adminMandates.update(roleToMandate[rid])
|
adminMandates.update(roleToMandate[rid])
|
||||||
|
|
||||||
return [mid for mid in mandateIds if mid in adminMandates]
|
return [mid for mid in mandateIds if mid in adminMandates]
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,30 @@ class PdfExtractor(Extractor):
|
||||||
))
|
))
|
||||||
return parts
|
return parts
|
||||||
|
|
||||||
# Extract text per page with PyMuPDF (same lib as in-place search - ensures extraction matches PDF text layer)
|
file_name = context.get("fileName", "document.pdf")
|
||||||
|
ordered_ok = False
|
||||||
|
try:
|
||||||
|
doc = fitz.open(stream=fileBytes, filetype="pdf")
|
||||||
|
for page_index in range(len(doc)):
|
||||||
|
page = doc[page_index]
|
||||||
|
page_parts = self._extract_page_blocks_in_reading_order(
|
||||||
|
page,
|
||||||
|
doc,
|
||||||
|
page_index=page_index,
|
||||||
|
root_id=rootId,
|
||||||
|
file_name=file_name,
|
||||||
|
)
|
||||||
|
if page_parts:
|
||||||
|
parts.extend(page_parts)
|
||||||
|
ordered_ok = True
|
||||||
|
doc.close()
|
||||||
|
except Exception:
|
||||||
|
ordered_ok = False
|
||||||
|
|
||||||
|
if ordered_ok and any(getattr(p, "typeGroup", "") in ("text", "image") for p in parts):
|
||||||
|
return parts
|
||||||
|
|
||||||
|
parts = [parts[0]] # keep container only; fall back below
|
||||||
try:
|
try:
|
||||||
doc = fitz.open(stream=fileBytes, filetype="pdf")
|
doc = fitz.open(stream=fileBytes, filetype="pdf")
|
||||||
for i in range(len(doc)):
|
for i in range(len(doc)):
|
||||||
|
|
@ -174,4 +197,196 @@ class PdfExtractor(Extractor):
|
||||||
|
|
||||||
return parts
|
return parts
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _text_from_text_block(block: Dict[str, Any]) -> str:
|
||||||
|
lines_out: List[str] = []
|
||||||
|
for line in block.get("lines") or []:
|
||||||
|
if not isinstance(line, dict):
|
||||||
|
continue
|
||||||
|
spans = line.get("spans") or []
|
||||||
|
line_text = "".join(
|
||||||
|
str(span.get("text") or "")
|
||||||
|
for span in spans
|
||||||
|
if isinstance(span, dict)
|
||||||
|
)
|
||||||
|
lines_out.append(line_text)
|
||||||
|
return "\n".join(lines_out).strip()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _bbox_center(bbox: Any) -> tuple[float, float]:
|
||||||
|
if not isinstance(bbox, (list, tuple)) or len(bbox) < 4:
|
||||||
|
return 0.0, 0.0
|
||||||
|
x0, y0, x1, y1 = float(bbox[0]), float(bbox[1]), float(bbox[2]), float(bbox[3])
|
||||||
|
return (x0 + x1) / 2.0, (y0 + y1) / 2.0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _point_inside_bbox(x: float, y: float, bbox: Any) -> bool:
|
||||||
|
if not isinstance(bbox, (list, tuple)) or len(bbox) < 4:
|
||||||
|
return False
|
||||||
|
x0, y0, x1, y1 = float(bbox[0]), float(bbox[1]), float(bbox[2]), float(bbox[3])
|
||||||
|
return x0 <= x <= x1 and y0 <= y <= y1
|
||||||
|
|
||||||
|
def _extract_page_blocks_in_reading_order(
|
||||||
|
self,
|
||||||
|
page: Any,
|
||||||
|
doc: Any,
|
||||||
|
*,
|
||||||
|
page_index: int,
|
||||||
|
root_id: str,
|
||||||
|
file_name: str,
|
||||||
|
) -> List[ContentPart]:
|
||||||
|
"""Emit text/image/table parts in on-page reading order (top-to-bottom, left-to-right)."""
|
||||||
|
entries: List[tuple[float, float, str, Dict[str, Any]]] = []
|
||||||
|
table_bboxes: List[Any] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
table_finder = page.find_tables()
|
||||||
|
for ti, tab in enumerate(getattr(table_finder, "tables", []) or []):
|
||||||
|
try:
|
||||||
|
matrix = tab.extract()
|
||||||
|
except Exception:
|
||||||
|
matrix = None
|
||||||
|
if not matrix:
|
||||||
|
continue
|
||||||
|
csv_data = self._rows_to_csv_payload(matrix)
|
||||||
|
if not csv_data.strip():
|
||||||
|
continue
|
||||||
|
bbox = getattr(tab, "bbox", None)
|
||||||
|
if bbox is not None:
|
||||||
|
table_bboxes.append(bbox)
|
||||||
|
cy, cx = self._bbox_center(bbox)
|
||||||
|
entries.append((cy, cx, "table", {
|
||||||
|
"label": f"table_{page_index + 1}_{ti}",
|
||||||
|
"data": csv_data,
|
||||||
|
"table_index": ti,
|
||||||
|
}))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
page_dict = page.get_text("dict", sort=True)
|
||||||
|
except Exception:
|
||||||
|
page_dict = None
|
||||||
|
blocks = page_dict.get("blocks") if isinstance(page_dict, dict) else None
|
||||||
|
if isinstance(blocks, list):
|
||||||
|
text_block_no = 0
|
||||||
|
image_no = 0
|
||||||
|
for block in blocks:
|
||||||
|
if not isinstance(block, dict):
|
||||||
|
continue
|
||||||
|
bbox = block.get("bbox")
|
||||||
|
cy, cx = self._bbox_center(bbox)
|
||||||
|
btype = block.get("type")
|
||||||
|
if btype == 0:
|
||||||
|
if any(self._point_inside_bbox(cx, cy, tb) for tb in table_bboxes):
|
||||||
|
continue
|
||||||
|
text = self._text_from_text_block(block)
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
label = f"page_{page_index + 1}" if text_block_no == 0 else f"page_{page_index + 1}_t{text_block_no}"
|
||||||
|
entries.append((cy, cx, "text", {
|
||||||
|
"label": label,
|
||||||
|
"data": text,
|
||||||
|
"text_block_no": text_block_no,
|
||||||
|
}))
|
||||||
|
text_block_no += 1
|
||||||
|
continue
|
||||||
|
if btype != 1:
|
||||||
|
continue
|
||||||
|
img_bytes = block.get("image")
|
||||||
|
ext = str(block.get("ext") or "png").lower()
|
||||||
|
mime = f"image/{ext}"
|
||||||
|
if not img_bytes:
|
||||||
|
xref = block.get("xref")
|
||||||
|
if xref is not None:
|
||||||
|
try:
|
||||||
|
extracted = doc.extract_image(int(xref))
|
||||||
|
img_bytes = extracted.get("image", b"")
|
||||||
|
ext = str(extracted.get("ext") or ext).lower()
|
||||||
|
mime = f"image/{ext}"
|
||||||
|
except Exception:
|
||||||
|
img_bytes = b""
|
||||||
|
if not img_bytes:
|
||||||
|
continue
|
||||||
|
entries.append((cy, cx, "image", {
|
||||||
|
"label": f"image_{page_index + 1}_{image_no}",
|
||||||
|
"mime": mime,
|
||||||
|
"bytes": img_bytes,
|
||||||
|
"image_no": image_no,
|
||||||
|
}))
|
||||||
|
image_no += 1
|
||||||
|
|
||||||
|
entries.sort(key=lambda item: (item[0], item[1]))
|
||||||
|
out: List[ContentPart] = []
|
||||||
|
for _y, _x, kind, payload in entries:
|
||||||
|
if kind == "text":
|
||||||
|
tbno = int(payload.get("text_block_no") or 0)
|
||||||
|
text = str(payload.get("data") or "")
|
||||||
|
out.append(ContentPart(
|
||||||
|
id=makeId(),
|
||||||
|
parentId=root_id,
|
||||||
|
label=str(payload.get("label") or f"page_{page_index + 1}"),
|
||||||
|
typeGroup="text",
|
||||||
|
mimeType="text/plain",
|
||||||
|
data=text,
|
||||||
|
metadata={
|
||||||
|
"pages": 1,
|
||||||
|
"pageIndex": page_index,
|
||||||
|
"size": len(text.encode("utf-8")),
|
||||||
|
"contextRef": {
|
||||||
|
"containerPath": file_name,
|
||||||
|
"location": f"page:{page_index + 1}/block:{tbno}",
|
||||||
|
"pageIndex": page_index,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
))
|
||||||
|
elif kind == "table":
|
||||||
|
ti = int(payload.get("table_index") or 0)
|
||||||
|
csv_data = str(payload.get("data") or "")
|
||||||
|
out.append(ContentPart(
|
||||||
|
id=makeId(),
|
||||||
|
parentId=root_id,
|
||||||
|
label=str(payload.get("label") or f"table_{page_index + 1}_{ti}"),
|
||||||
|
typeGroup="table",
|
||||||
|
mimeType="text/csv",
|
||||||
|
data=csv_data,
|
||||||
|
metadata={
|
||||||
|
"pageIndex": page_index,
|
||||||
|
"size": len(csv_data.encode("utf-8")),
|
||||||
|
"contextRef": {
|
||||||
|
"containerPath": file_name,
|
||||||
|
"location": f"page:{page_index + 1}/table:{ti}",
|
||||||
|
"pageIndex": page_index,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
))
|
||||||
|
elif kind == "image":
|
||||||
|
ino = int(payload.get("image_no") or 0)
|
||||||
|
img_bytes = payload.get("bytes") or b""
|
||||||
|
mime = str(payload.get("mime") or "image/png")
|
||||||
|
out.append(ContentPart(
|
||||||
|
id=makeId(),
|
||||||
|
parentId=root_id,
|
||||||
|
label=str(payload.get("label") or f"image_{page_index + 1}_{ino}"),
|
||||||
|
typeGroup="image",
|
||||||
|
mimeType=mime,
|
||||||
|
data=base64.b64encode(img_bytes).decode("utf-8"),
|
||||||
|
metadata={
|
||||||
|
"pageIndex": page_index,
|
||||||
|
"size": len(img_bytes),
|
||||||
|
"contextRef": {
|
||||||
|
"containerPath": file_name,
|
||||||
|
"location": f"page:{page_index + 1}/image:{ino}",
|
||||||
|
"pageIndex": page_index,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
))
|
||||||
|
return out
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _rows_to_csv_payload(rows: List[List[Any]]) -> str:
|
||||||
|
lines: List[str] = []
|
||||||
|
for row in rows:
|
||||||
|
cells = [str(c or "").replace('"', '""') for c in row]
|
||||||
|
lines.append(",".join(f'"{c}"' for c in cells))
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ Markdown renderer for report generation.
|
||||||
|
|
||||||
from .documentRendererBaseTemplate import BaseRenderer
|
from .documentRendererBaseTemplate import BaseRenderer
|
||||||
from modules.datamodels.datamodelDocument import RenderedDocument
|
from modules.datamodels.datamodelDocument import RenderedDocument
|
||||||
from typing import Dict, Any, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
class RendererMarkdown(BaseRenderer):
|
class RendererMarkdown(BaseRenderer):
|
||||||
"""Renders content to Markdown format with format-specific extraction."""
|
"""Renders content to Markdown format with format-specific extraction."""
|
||||||
|
|
@ -33,12 +33,72 @@ class RendererMarkdown(BaseRenderer):
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def getAcceptedSectionTypes(cls, formatName: Optional[str] = None) -> List[str]:
|
def getAcceptedSectionTypes(cls, formatName: Optional[str] = None) -> List[str]:
|
||||||
"""
|
"""Markdown accepts all section types including images.
|
||||||
Return list of section content types that Markdown renderer accepts.
|
|
||||||
Markdown renderer accepts all section types except images.
|
Images are emitted as sibling files (``extract_media_….png``) with
|
||||||
|
```` relative links in the ``.md`` — same pattern as
|
||||||
|
``RendererHtml`` (main document + sidecar assets).
|
||||||
"""
|
"""
|
||||||
from modules.datamodels.datamodelJson import supportedSectionTypes
|
from modules.datamodels.datamodelJson import supportedSectionTypes
|
||||||
return [st for st in supportedSectionTypes if st != "image"]
|
return list(supportedSectionTypes)
|
||||||
|
|
||||||
|
def _collectImageDocuments(self, jsonContent: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||||
|
"""Extract image sections into sidecar file payloads for markdown export."""
|
||||||
|
import base64 as _b64
|
||||||
|
|
||||||
|
out: List[Dict[str, Any]] = []
|
||||||
|
documents = jsonContent.get("documents")
|
||||||
|
if not isinstance(documents, list):
|
||||||
|
raise ValueError("extractedContent.documents must be a list")
|
||||||
|
|
||||||
|
for doc in documents:
|
||||||
|
if not isinstance(doc, dict):
|
||||||
|
continue
|
||||||
|
for section in doc.get("sections") or []:
|
||||||
|
if not isinstance(section, dict):
|
||||||
|
continue
|
||||||
|
if section.get("content_type") != "image":
|
||||||
|
continue
|
||||||
|
for element in section.get("elements") or []:
|
||||||
|
if not isinstance(element, dict):
|
||||||
|
raise ValueError("image section element must be a dict")
|
||||||
|
content = element.get("content")
|
||||||
|
if not isinstance(content, dict):
|
||||||
|
raise ValueError("image section element missing content dict")
|
||||||
|
|
||||||
|
b64 = content.get("base64Data")
|
||||||
|
if not isinstance(b64, str) or not b64:
|
||||||
|
raise ValueError(
|
||||||
|
"image section missing base64Data — markdown export "
|
||||||
|
"requires binary payload to write sidecar image files"
|
||||||
|
)
|
||||||
|
alt = content.get("altText")
|
||||||
|
if not isinstance(alt, str) or not alt.strip():
|
||||||
|
raise ValueError("image section missing altText")
|
||||||
|
mime = content.get("mimeType")
|
||||||
|
if not isinstance(mime, str) or not mime.strip().startswith("image/"):
|
||||||
|
raise ValueError("image section missing mimeType")
|
||||||
|
fname = content.get("fileName")
|
||||||
|
if not isinstance(fname, str) or not fname.strip():
|
||||||
|
raise ValueError("image section missing fileName")
|
||||||
|
|
||||||
|
safe_name = "".join(
|
||||||
|
c if c.isalnum() or c in "._-" else "_" for c in fname.strip()
|
||||||
|
)
|
||||||
|
if not safe_name:
|
||||||
|
raise ValueError(f"image fileName sanitized to empty: {fname!r}")
|
||||||
|
|
||||||
|
blob = _b64.b64decode(b64, validate=True)
|
||||||
|
if not blob:
|
||||||
|
raise ValueError(f"image base64Data decoded to empty bytes ({fname!r})")
|
||||||
|
|
||||||
|
out.append({
|
||||||
|
"filename": safe_name,
|
||||||
|
"altText": alt.strip(),
|
||||||
|
"mimeType": mime.strip(),
|
||||||
|
"bytes": blob,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
async def render(
|
async def render(
|
||||||
self,
|
self,
|
||||||
|
|
@ -49,311 +109,281 @@ class RendererMarkdown(BaseRenderer):
|
||||||
*,
|
*,
|
||||||
style: Dict[str, Any] = None,
|
style: Dict[str, Any] = None,
|
||||||
) -> List[RenderedDocument]:
|
) -> List[RenderedDocument]:
|
||||||
"""Render extracted JSON content to Markdown format."""
|
"""Render markdown plus sidecar image files (same folder as the ``.md``).
|
||||||
|
|
||||||
|
Returns ``[main.md, image1.png, image2.jpg, …]``. Relative ````
|
||||||
|
links in the markdown point at those sibling files — no API URLs, no
|
||||||
|
base64 inlined in the markdown text.
|
||||||
|
"""
|
||||||
_ = style
|
_ = style
|
||||||
try:
|
image_docs = self._collectImageDocuments(extractedContent)
|
||||||
# Generate markdown from JSON structure
|
|
||||||
markdownContent = self._generateMarkdownFromJson(extractedContent, title)
|
markdownContent = self._generateMarkdownFromJson(extractedContent, title)
|
||||||
|
|
||||||
# Determine filename from document or title
|
documents = extractedContent.get("documents") or []
|
||||||
documents = extractedContent.get("documents", [])
|
filename: Optional[str] = None
|
||||||
if documents and isinstance(documents[0], dict):
|
if documents and isinstance(documents[0], dict):
|
||||||
filename = documents[0].get("filename")
|
filename = documents[0].get("filename")
|
||||||
if not filename:
|
if not filename:
|
||||||
filename = self._determineFilename(title, "text/markdown")
|
filename = self._determineFilename(title, "text/markdown")
|
||||||
else:
|
|
||||||
filename = self._determineFilename(title, "text/markdown")
|
|
||||||
|
|
||||||
# Extract metadata for document type and other info
|
metadata = extractedContent.get("metadata") if isinstance(extractedContent, dict) else None
|
||||||
metadata = extractedContent.get("metadata", {}) if extractedContent else {}
|
if not isinstance(metadata, dict):
|
||||||
documentType = metadata.get("documentType") if isinstance(metadata, dict) else None
|
metadata = None
|
||||||
|
documentType = metadata.get("documentType") if metadata else None
|
||||||
|
|
||||||
return [
|
result: List[RenderedDocument] = [
|
||||||
RenderedDocument(
|
RenderedDocument(
|
||||||
documentData=markdownContent.encode('utf-8'),
|
documentData=markdownContent.encode("utf-8"),
|
||||||
mimeType="text/markdown",
|
mimeType="text/markdown",
|
||||||
filename=filename,
|
filename=filename,
|
||||||
documentType=documentType,
|
documentType=documentType,
|
||||||
metadata=metadata if isinstance(metadata, dict) else None
|
metadata=metadata,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
for img in image_docs:
|
||||||
except Exception as e:
|
result.append(
|
||||||
self.logger.error(f"Error rendering markdown: {str(e)}")
|
|
||||||
# Return minimal markdown fallback
|
|
||||||
fallbackContent = f"# {title}\n\nError rendering report: {str(e)}"
|
|
||||||
metadata = extractedContent.get("metadata", {}) if extractedContent else {}
|
|
||||||
documentType = metadata.get("documentType") if isinstance(metadata, dict) else None
|
|
||||||
return [
|
|
||||||
RenderedDocument(
|
RenderedDocument(
|
||||||
documentData=fallbackContent.encode('utf-8'),
|
documentData=img["bytes"],
|
||||||
mimeType="text/markdown",
|
mimeType=img["mimeType"],
|
||||||
filename=self._determineFilename(title, "text/markdown"),
|
filename=img["filename"],
|
||||||
documentType=documentType,
|
|
||||||
metadata=metadata if isinstance(metadata, dict) else None
|
|
||||||
)
|
)
|
||||||
]
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
def _generateMarkdownFromJson(self, jsonContent: Dict[str, Any], title: str) -> str:
|
def _generateMarkdownFromJson(self, jsonContent: Dict[str, Any], title: str) -> str:
|
||||||
"""Generate markdown content from structured JSON document."""
|
"""Generate markdown content from structured JSON document."""
|
||||||
try:
|
|
||||||
# Validate JSON structure (standardized schema: {metadata: {...}, documents: [{sections: [...]}]})
|
|
||||||
if not self._validateJsonStructure(jsonContent):
|
if not self._validateJsonStructure(jsonContent):
|
||||||
raise ValueError("JSON content must follow standardized schema: {metadata: {...}, documents: [{sections: [...]}]}")
|
raise ValueError(
|
||||||
|
"JSON content must follow standardized schema: "
|
||||||
|
"{metadata: {...}, documents: [{sections: [...]}]}"
|
||||||
|
)
|
||||||
|
|
||||||
# Extract sections and metadata from standardized schema
|
|
||||||
sections = self._extractSections(jsonContent)
|
sections = self._extractSections(jsonContent)
|
||||||
metadata = self._extractMetadata(jsonContent)
|
metadata = self._extractMetadata(jsonContent)
|
||||||
|
|
||||||
# Use provided title (which comes from documents[].title) as primary source
|
documentTitle = title or (metadata.get("title") if isinstance(metadata, dict) else None)
|
||||||
# Fallback to metadata.title only if title parameter is empty
|
if not documentTitle:
|
||||||
documentTitle = title if title else metadata.get("title", "Generated Document")
|
raise ValueError(
|
||||||
|
"markdown render: no title given and metadata.title missing — "
|
||||||
|
"callers must pass an explicit title"
|
||||||
|
)
|
||||||
|
|
||||||
# Build markdown content
|
markdownParts: List[str] = [f"# {documentTitle}", ""]
|
||||||
markdownParts = []
|
|
||||||
|
|
||||||
# Document title
|
|
||||||
markdownParts.append(f"# {documentTitle}")
|
|
||||||
markdownParts.append("")
|
|
||||||
|
|
||||||
# Process each section
|
|
||||||
for section in sections:
|
for section in sections:
|
||||||
sectionMarkdown = self._renderJsonSection(section)
|
sectionMarkdown = self._renderJsonSection(section)
|
||||||
if sectionMarkdown:
|
if sectionMarkdown:
|
||||||
markdownParts.append(sectionMarkdown)
|
markdownParts.append(sectionMarkdown)
|
||||||
markdownParts.append("") # Add spacing between sections
|
markdownParts.append("")
|
||||||
|
|
||||||
# Add generation info
|
|
||||||
markdownParts.append("---")
|
markdownParts.append("---")
|
||||||
markdownParts.append(f"*Generated: {self._formatTimestamp()}*")
|
markdownParts.append(f"*Generated: {self._formatTimestamp()}*")
|
||||||
|
|
||||||
return '\n'.join(markdownParts)
|
return "\n".join(markdownParts)
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error generating markdown from JSON: {str(e)}")
|
|
||||||
raise Exception(f"Markdown generation failed: {str(e)}")
|
|
||||||
|
|
||||||
def _renderJsonSection(self, section: Dict[str, Any]) -> str:
|
def _renderJsonSection(self, section: Dict[str, Any]) -> str:
|
||||||
"""Render a single JSON section to markdown.
|
"""Render a single JSON section to markdown.
|
||||||
Supports three content formats: reference, object (base64), extracted_text.
|
|
||||||
|
Errors propagate: unknown section types or malformed payloads must surface,
|
||||||
|
not be swallowed into a fallback paragraph or ``[Error rendering section]``
|
||||||
|
marker that hides the real problem.
|
||||||
"""
|
"""
|
||||||
try:
|
|
||||||
sectionType = self._getSectionType(section)
|
sectionType = self._getSectionType(section)
|
||||||
sectionData = self._getSectionData(section)
|
sectionData = self._getSectionData(section)
|
||||||
|
|
||||||
# Check for three content formats from Phase 5D in elements
|
|
||||||
if isinstance(sectionData, list):
|
if isinstance(sectionData, list):
|
||||||
markdownParts = []
|
markdownParts: List[str] = []
|
||||||
for element in sectionData:
|
for element in sectionData:
|
||||||
element_type = element.get("type", "") if isinstance(element, dict) else ""
|
element_type = element.get("type", "") if isinstance(element, dict) else ""
|
||||||
|
|
||||||
# Support three content formats from Phase 5D
|
|
||||||
if element_type == "reference":
|
if element_type == "reference":
|
||||||
# Document reference format
|
|
||||||
doc_ref = element.get("documentReference", "")
|
|
||||||
label = element.get("label", "Reference")
|
label = element.get("label", "Reference")
|
||||||
markdownParts.append(f"*[Reference: {label}]*")
|
markdownParts.append(f"*[Reference: {label}]*")
|
||||||
continue
|
continue
|
||||||
elif element_type == "extracted_text":
|
if element_type == "extracted_text":
|
||||||
# Extracted text format
|
|
||||||
content = element.get("content", "")
|
content = element.get("content", "")
|
||||||
source = element.get("source", "")
|
source = element.get("source", "")
|
||||||
if content:
|
if content:
|
||||||
source_text = f" *(Source: {source})*" if source else ""
|
source_text = f" *(Source: {source})*" if source else ""
|
||||||
markdownParts.append(f"{content}{source_text}")
|
markdownParts.append(f"{content}{source_text}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# If we processed reference/extracted_text elements, return them
|
|
||||||
if markdownParts:
|
if markdownParts:
|
||||||
return '\n\n'.join(markdownParts)
|
return "\n\n".join(markdownParts)
|
||||||
|
|
||||||
|
def _first_element(data: Any) -> Dict[str, Any]:
|
||||||
|
if isinstance(data, list) and data and isinstance(data[0], dict):
|
||||||
|
return data[0]
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return data
|
||||||
|
raise ValueError(
|
||||||
|
f"section type {sectionType!r} expects elements list / dict, got {type(data).__name__}"
|
||||||
|
)
|
||||||
|
|
||||||
if sectionType == "table":
|
if sectionType == "table":
|
||||||
# Work directly with elements like other renderers
|
return self._renderJsonTable(_first_element(sectionData))
|
||||||
if isinstance(sectionData, list) and sectionData:
|
if sectionType == "bullet_list":
|
||||||
element = sectionData[0] if isinstance(sectionData[0], dict) else {}
|
return self._renderJsonBulletList(_first_element(sectionData))
|
||||||
return self._renderJsonTable(element)
|
if sectionType == "heading":
|
||||||
return ""
|
return self._renderJsonHeading(_first_element(sectionData))
|
||||||
elif sectionType == "bullet_list":
|
if sectionType == "paragraph":
|
||||||
# Work directly with elements like other renderers
|
return self._renderJsonParagraph(_first_element(sectionData))
|
||||||
if isinstance(sectionData, list) and sectionData:
|
if sectionType == "code_block":
|
||||||
element = sectionData[0] if isinstance(sectionData[0], dict) else {}
|
return self._renderJsonCodeBlock(_first_element(sectionData))
|
||||||
return self._renderJsonBulletList(element)
|
if sectionType == "image":
|
||||||
return ""
|
return self._renderJsonImage(_first_element(sectionData))
|
||||||
elif sectionType == "heading":
|
|
||||||
# Work directly with elements like other renderers
|
|
||||||
if isinstance(sectionData, list) and sectionData:
|
|
||||||
element = sectionData[0] if isinstance(sectionData[0], dict) else {}
|
|
||||||
return self._renderJsonHeading(element)
|
|
||||||
return ""
|
|
||||||
elif sectionType == "paragraph":
|
|
||||||
# Work directly with elements like other renderers
|
|
||||||
if isinstance(sectionData, list) and sectionData:
|
|
||||||
element = sectionData[0] if isinstance(sectionData[0], dict) else {}
|
|
||||||
return self._renderJsonParagraph(element)
|
|
||||||
elif isinstance(sectionData, dict):
|
|
||||||
return self._renderJsonParagraph(sectionData)
|
|
||||||
return ""
|
|
||||||
elif sectionType == "code_block":
|
|
||||||
# Work directly with elements like other renderers
|
|
||||||
if isinstance(sectionData, list) and sectionData:
|
|
||||||
element = sectionData[0] if isinstance(sectionData[0], dict) else {}
|
|
||||||
return self._renderJsonCodeBlock(element)
|
|
||||||
return ""
|
|
||||||
elif sectionType == "image":
|
|
||||||
# Work directly with elements like other renderers
|
|
||||||
if isinstance(sectionData, list) and sectionData:
|
|
||||||
element = sectionData[0] if isinstance(sectionData[0], dict) else {}
|
|
||||||
return self._renderJsonImage(element)
|
|
||||||
return ""
|
|
||||||
else:
|
|
||||||
# Fallback to paragraph for unknown types
|
|
||||||
if isinstance(sectionData, list) and sectionData:
|
|
||||||
element = sectionData[0] if isinstance(sectionData[0], dict) else {}
|
|
||||||
return self._renderJsonParagraph(element)
|
|
||||||
elif isinstance(sectionData, dict):
|
|
||||||
return self._renderJsonParagraph(sectionData)
|
|
||||||
return ""
|
|
||||||
|
|
||||||
except Exception as e:
|
raise ValueError(
|
||||||
self.logger.warning(f"Error rendering section {self._getSectionId(section)}: {str(e)}")
|
f"unsupported section content_type {sectionType!r} "
|
||||||
return f"*[Error rendering section: {str(e)}]*"
|
f"(section id={self._getSectionId(section)!r})"
|
||||||
|
)
|
||||||
|
|
||||||
def _renderJsonTable(self, tableData: Dict[str, Any]) -> str:
|
def _renderJsonTable(self, tableData: Dict[str, Any]) -> str:
|
||||||
"""Render a JSON table to markdown."""
|
"""Render a JSON table to markdown."""
|
||||||
try:
|
content = tableData.get("content")
|
||||||
# Extract from nested content structure: element.content.{headers, rows}
|
|
||||||
content = tableData.get("content", {})
|
|
||||||
if not isinstance(content, dict):
|
if not isinstance(content, dict):
|
||||||
return ""
|
raise ValueError(
|
||||||
headers = content.get("headers", [])
|
f"table section has invalid content (type={type(content).__name__})"
|
||||||
rows = content.get("rows", [])
|
)
|
||||||
|
headers = content.get("headers") or []
|
||||||
|
rows = content.get("rows") or []
|
||||||
if not headers or not rows:
|
if not headers or not rows:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
markdownParts = []
|
lines = [
|
||||||
|
" | ".join(str(h) for h in headers),
|
||||||
# Create table header
|
" | ".join("---" for _ in headers),
|
||||||
headerLine = " | ".join(str(header) for header in headers)
|
]
|
||||||
markdownParts.append(headerLine)
|
|
||||||
|
|
||||||
# Add separator line
|
|
||||||
separatorLine = " | ".join("---" for _ in headers)
|
|
||||||
markdownParts.append(separatorLine)
|
|
||||||
|
|
||||||
# Add data rows
|
|
||||||
for row in rows:
|
for row in rows:
|
||||||
rowLine = " | ".join(str(cellData) for cellData in row)
|
lines.append(" | ".join(str(cell) for cell in row))
|
||||||
markdownParts.append(rowLine)
|
return "\n".join(lines)
|
||||||
|
|
||||||
return '\n'.join(markdownParts)
|
def _renderInlineRunsMarkdown(self, runs: Any) -> str:
|
||||||
|
"""Turn Phase-5 inlineRuns (from markdownToDocumentJson) into markdown text."""
|
||||||
except Exception as e:
|
if not runs:
|
||||||
self.logger.warning(f"Error rendering table: {str(e)}")
|
|
||||||
return ""
|
return ""
|
||||||
|
if not isinstance(runs, list):
|
||||||
|
return str(runs)
|
||||||
|
parts: List[str] = []
|
||||||
|
for run in runs:
|
||||||
|
if not isinstance(run, dict):
|
||||||
|
parts.append(str(run))
|
||||||
|
continue
|
||||||
|
run_type = run.get("type", "text")
|
||||||
|
value = str(run.get("value", ""))
|
||||||
|
if run_type == "text":
|
||||||
|
parts.append(value)
|
||||||
|
elif run_type == "bold":
|
||||||
|
parts.append(f"**{value}**")
|
||||||
|
elif run_type == "italic":
|
||||||
|
parts.append(f"*{value}*")
|
||||||
|
elif run_type == "code":
|
||||||
|
if not value:
|
||||||
|
parts.append("``")
|
||||||
|
elif "`" not in value:
|
||||||
|
parts.append(f"`{value}`")
|
||||||
|
else:
|
||||||
|
parts.append(f"``{value}``")
|
||||||
|
elif run_type == "link":
|
||||||
|
href = str(run.get("href", ""))
|
||||||
|
parts.append(f"[{value}]({href})")
|
||||||
|
elif run_type == "image":
|
||||||
|
parts.append(f"")
|
||||||
|
else:
|
||||||
|
parts.append(value)
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
def _renderJsonBulletList(self, listData: Dict[str, Any]) -> str:
|
def _renderJsonBulletList(self, listData: Dict[str, Any]) -> str:
|
||||||
"""Render a JSON bullet list to markdown."""
|
"""Render a JSON bullet list to markdown."""
|
||||||
try:
|
content = listData.get("content")
|
||||||
# Extract from nested content structure: element.content.{items}
|
|
||||||
content = listData.get("content", {})
|
|
||||||
if not isinstance(content, dict):
|
if not isinstance(content, dict):
|
||||||
return ""
|
raise ValueError(
|
||||||
items = content.get("items", [])
|
f"bullet_list section has invalid content (type={type(content).__name__})"
|
||||||
|
)
|
||||||
|
items = content.get("items") or []
|
||||||
if not items:
|
if not items:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
markdownParts = []
|
lines: List[str] = []
|
||||||
for item in items:
|
for item in items:
|
||||||
if isinstance(item, str):
|
if isinstance(item, str):
|
||||||
markdownParts.append(f"- {item}")
|
lines.append(f"- {item}")
|
||||||
|
elif isinstance(item, list):
|
||||||
|
lines.append(f"- {self._renderInlineRunsMarkdown(item)}")
|
||||||
elif isinstance(item, dict) and "text" in item:
|
elif isinstance(item, dict) and "text" in item:
|
||||||
markdownParts.append(f"- {item['text']}")
|
lines.append(f"- {item['text']}")
|
||||||
|
else:
|
||||||
return '\n'.join(markdownParts)
|
raise ValueError(
|
||||||
|
f"bullet_list item has unsupported shape (type={type(item).__name__})"
|
||||||
except Exception as e:
|
)
|
||||||
self.logger.warning(f"Error rendering bullet list: {str(e)}")
|
return "\n".join(lines)
|
||||||
return ""
|
|
||||||
|
|
||||||
def _renderJsonHeading(self, headingData: Dict[str, Any]) -> str:
|
def _renderJsonHeading(self, headingData: Dict[str, Any]) -> str:
|
||||||
"""Render a JSON heading to markdown."""
|
"""Render a JSON heading to markdown."""
|
||||||
try:
|
content = headingData.get("content")
|
||||||
# Extract from nested content structure: element.content.{text, level}
|
|
||||||
content = headingData.get("content", {})
|
|
||||||
if not isinstance(content, dict):
|
if not isinstance(content, dict):
|
||||||
return ""
|
raise ValueError(
|
||||||
text = content.get("text", "")
|
f"heading section has invalid content (type={type(content).__name__})"
|
||||||
|
)
|
||||||
|
text = content.get("text")
|
||||||
|
if not isinstance(text, str) or not text:
|
||||||
|
raise ValueError("heading section has empty 'text'")
|
||||||
level = content.get("level", 1)
|
level = content.get("level", 1)
|
||||||
|
if not isinstance(level, int):
|
||||||
if text:
|
raise ValueError(f"heading 'level' must be int, got {type(level).__name__}")
|
||||||
level = max(1, min(6, level))
|
level = max(1, min(6, level))
|
||||||
md_level = min(6, level + 1)
|
md_level = min(6, level + 1)
|
||||||
return f"{'#' * md_level} {text}"
|
return f"{'#' * md_level} {text}"
|
||||||
|
|
||||||
return ""
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.warning(f"Error rendering heading: {str(e)}")
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def _renderJsonParagraph(self, paragraphData: Dict[str, Any]) -> str:
|
def _renderJsonParagraph(self, paragraphData: Dict[str, Any]) -> str:
|
||||||
"""Render a JSON paragraph to markdown."""
|
"""Render a JSON paragraph to markdown."""
|
||||||
try:
|
content = paragraphData.get("content")
|
||||||
# Extract from nested content structure
|
top = paragraphData.get("text")
|
||||||
content = paragraphData.get("content", {})
|
if isinstance(top, str) and top.strip():
|
||||||
if isinstance(content, dict):
|
if not isinstance(content, dict) or (
|
||||||
text = content.get("text", "")
|
not content.get("text") and not content.get("inlineRuns")
|
||||||
elif isinstance(content, str):
|
):
|
||||||
text = content
|
return top
|
||||||
else:
|
|
||||||
text = ""
|
|
||||||
return text if text else ""
|
|
||||||
|
|
||||||
except Exception as e:
|
if isinstance(content, dict):
|
||||||
self.logger.warning(f"Error rendering paragraph: {str(e)}")
|
runs = self._inlineRunsFromContent(content)
|
||||||
return ""
|
if runs:
|
||||||
|
return self._renderInlineRunsMarkdown(runs)
|
||||||
|
text = content.get("text", "")
|
||||||
|
return text if isinstance(text, str) else ""
|
||||||
|
if isinstance(content, str):
|
||||||
|
return content
|
||||||
|
raise ValueError(
|
||||||
|
f"paragraph section has invalid content (type={type(content).__name__})"
|
||||||
|
)
|
||||||
|
|
||||||
def _renderJsonCodeBlock(self, codeData: Dict[str, Any]) -> str:
|
def _renderJsonCodeBlock(self, codeData: Dict[str, Any]) -> str:
|
||||||
"""Render a JSON code block to markdown."""
|
"""Render a JSON code block to markdown."""
|
||||||
try:
|
content = codeData.get("content")
|
||||||
# Extract from nested content structure
|
|
||||||
content = codeData.get("content", {})
|
|
||||||
if not isinstance(content, dict):
|
if not isinstance(content, dict):
|
||||||
return ""
|
raise ValueError(
|
||||||
code = content.get("code", "")
|
f"code_block section has invalid content (type={type(content).__name__})"
|
||||||
language = content.get("language", "")
|
)
|
||||||
|
code = content.get("code")
|
||||||
if code:
|
if not isinstance(code, str) or not code:
|
||||||
if language:
|
raise ValueError("code_block section has empty 'code'")
|
||||||
return f"```{language}\n{code}\n```"
|
language = content.get("language") or ""
|
||||||
else:
|
return f"```{language}\n{code}\n```" if language else f"```\n{code}\n```"
|
||||||
return f"```\n{code}\n```"
|
|
||||||
|
|
||||||
return ""
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.warning(f"Error rendering code block: {str(e)}")
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def _renderJsonImage(self, imageData: Dict[str, Any]) -> str:
|
def _renderJsonImage(self, imageData: Dict[str, Any]) -> str:
|
||||||
"""Render a JSON image to markdown."""
|
"""Render image as relative ```` link to a sidecar file."""
|
||||||
try:
|
content = imageData.get("content")
|
||||||
# Extract from nested content structure: element.content.{base64Data, altText, caption}
|
|
||||||
content = imageData.get("content", {})
|
|
||||||
if not isinstance(content, dict):
|
if not isinstance(content, dict):
|
||||||
return ""
|
raise ValueError(
|
||||||
altText = content.get("altText", "Image")
|
f"image section has invalid content (type={type(content).__name__})"
|
||||||
base64Data = content.get("base64Data", "")
|
)
|
||||||
|
altText = content.get("altText")
|
||||||
if base64Data:
|
if not isinstance(altText, str) or not altText.strip():
|
||||||
# For base64 images, we can't embed them directly in markdown
|
raise ValueError("image section is missing 'altText'")
|
||||||
# So we'll use a placeholder with the alt text
|
fileName = content.get("fileName")
|
||||||
return f""
|
if not isinstance(fileName, str) or not fileName.strip():
|
||||||
else:
|
raise ValueError("image section is missing 'fileName' for relative markdown link")
|
||||||
return f""
|
safe_name = "".join(
|
||||||
|
c if c.isalnum() or c in "._-" else "_" for c in fileName.strip()
|
||||||
except Exception as e:
|
)
|
||||||
self.logger.warning(f"Error rendering image: {str(e)}")
|
if not safe_name:
|
||||||
return f""
|
raise ValueError(f"image fileName sanitized to empty: {fileName!r}")
|
||||||
|
return f""
|
||||||
|
|
|
||||||
|
|
@ -670,7 +670,7 @@ class RendererPdf(BaseRenderer):
|
||||||
runType = run.get("type", "text")
|
runType = run.get("type", "text")
|
||||||
value = self._escapeReportlabXml(run.get("value", ""))
|
value = self._escapeReportlabXml(run.get("value", ""))
|
||||||
if runType == "text":
|
if runType == "text":
|
||||||
parts.append(value)
|
parts.append(value.replace("\n", "<br/>"))
|
||||||
elif runType == "bold":
|
elif runType == "bold":
|
||||||
parts.append(f"<b>{value}</b>")
|
parts.append(f"<b>{value}</b>")
|
||||||
elif runType == "italic":
|
elif runType == "italic":
|
||||||
|
|
@ -691,6 +691,7 @@ class RendererPdf(BaseRenderer):
|
||||||
if not text:
|
if not text:
|
||||||
return ""
|
return ""
|
||||||
s = self._escapeReportlabXml(text)
|
s = self._escapeReportlabXml(text)
|
||||||
|
s = s.replace("\n", "<br/>")
|
||||||
s = _re_pdf.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", s, flags=_re_pdf.DOTALL)
|
s = _re_pdf.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", s, flags=_re_pdf.DOTALL)
|
||||||
s = _re_pdf.sub(r"__(.+?)__", r"<b>\1</b>", s, flags=_re_pdf.DOTALL)
|
s = _re_pdf.sub(r"__(.+?)__", r"<b>\1</b>", s, flags=_re_pdf.DOTALL)
|
||||||
s = _re_pdf.sub(r"(?<!\*)\*([^*\n]+?)\*(?!\*)", r"<i>\1</i>", s)
|
s = _re_pdf.sub(r"(?<!\*)\*([^*\n]+?)\*(?!\*)", r"<i>\1</i>", s)
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,76 @@ import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_MAX_AUTO_TABLE_COLS = 64
|
||||||
|
_MAX_AUTO_TABLE_ROWS = 5000
|
||||||
|
_MAX_AUTO_CELL_CHARS = 8000
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_cell_for_pipe_table(cell: str) -> str:
|
||||||
|
"""Single-line cell safe for markdown pipe tables (no raw ``|``)."""
|
||||||
|
s = str(cell).replace("\r\n", "\n").replace("\r", "\n")
|
||||||
|
s = " ".join(line.strip() for line in s.split("\n") if line.strip()).strip()
|
||||||
|
return s.replace("|", "·")
|
||||||
|
|
||||||
|
|
||||||
|
def _try_delimited_block_as_markdown_table(block: str) -> Optional[str]:
|
||||||
|
"""If ``block`` is a uniform tab- or semicolon-separated grid, return a pipe markdown table."""
|
||||||
|
lines = [ln.strip() for ln in block.replace("\r\n", "\n").replace("\r", "\n").split("\n")]
|
||||||
|
lines = [ln for ln in lines if ln]
|
||||||
|
if len(lines) < 2:
|
||||||
|
return None
|
||||||
|
for sep in ("\t", ";"):
|
||||||
|
rows: List[List[str]] = []
|
||||||
|
bad = False
|
||||||
|
for ln in lines:
|
||||||
|
cells = [c.strip() for c in ln.split(sep)]
|
||||||
|
if len(cells) < 2:
|
||||||
|
bad = True
|
||||||
|
break
|
||||||
|
rows.append(cells)
|
||||||
|
if bad:
|
||||||
|
continue
|
||||||
|
ncols = len(rows[0])
|
||||||
|
if ncols > _MAX_AUTO_TABLE_COLS or len(rows) > _MAX_AUTO_TABLE_ROWS:
|
||||||
|
continue
|
||||||
|
if any(len(r) != ncols for r in rows):
|
||||||
|
continue
|
||||||
|
if any(len(_sanitize_cell_for_pipe_table(c)) > _MAX_AUTO_CELL_CHARS for r in rows for c in r):
|
||||||
|
continue
|
||||||
|
|
||||||
|
def _row_md(r: List[str]) -> str:
|
||||||
|
return "| " + " | ".join(_sanitize_cell_for_pipe_table(c) for c in r) + " |"
|
||||||
|
|
||||||
|
header = _row_md(rows[0])
|
||||||
|
divider = "| " + " | ".join(["---"] * ncols) + " |"
|
||||||
|
body = "\n".join(_row_md(r) for r in rows[1:])
|
||||||
|
return "\n".join([header, divider, body])
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def enhancePlainTextWithMarkdownTables(body: str) -> str:
|
||||||
|
"""Detect delimiter-separated grids in plain paragraphs and convert them to markdown pipe tables.
|
||||||
|
|
||||||
|
Extractors often emit CSV-like blocks (``;`` or TAB) without markdown markers; passing those
|
||||||
|
straight into ``markdownToDocumentJson`` produced one giant paragraph. This pass runs only
|
||||||
|
on whitespace-separated blocks so normal prose stays unchanged.
|
||||||
|
"""
|
||||||
|
if not isinstance(body, str) or not body.strip():
|
||||||
|
return body if isinstance(body, str) else ""
|
||||||
|
chunks = re.split(r"\n\s*\n", body.strip())
|
||||||
|
out_parts: List[str] = []
|
||||||
|
for ch in chunks:
|
||||||
|
ch = ch.strip()
|
||||||
|
if not ch:
|
||||||
|
continue
|
||||||
|
md_table = _try_delimited_block_as_markdown_table(ch)
|
||||||
|
out_parts.append(md_table if md_table else ch)
|
||||||
|
return "\n\n".join(out_parts)
|
||||||
|
|
||||||
|
|
||||||
def _parseInlineRuns(text: str) -> list:
|
def _parseInlineRuns(text: str) -> list:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,17 @@
|
||||||
"""Indicative cost estimation for a RAG bootstrap run.
|
"""Indicative cost estimation for a RAG bootstrap run.
|
||||||
|
|
||||||
This is **not** a billing-grade forecast: it gives the user a back-of-the-envelope
|
This is **not** a billing-grade forecast: it gives the user a back-of-the-envelope
|
||||||
USD figure for the worst-case full sync, so they can sanity-check before raising
|
CHF figure for the worst-case full sync, so they can sanity-check before raising
|
||||||
`maxBytes`/`maxItems`. The output always carries the underlying assumptions
|
`maxBytes`/`maxItems`. The output always carries the underlying assumptions
|
||||||
(`basis`) so the user can judge plausibility.
|
(`basis`) so the user can judge plausibility.
|
||||||
|
|
||||||
Heuristic:
|
Heuristic:
|
||||||
estimatedTokens = ceil(maxBytes / CHARS_PER_TOKEN_BYTES_FACTOR)
|
estimatedTokens = ceil(maxBytes / CHARS_PER_TOKEN_BYTES_FACTOR)
|
||||||
estimatedUsd = estimatedTokens / 1_000_000 * EMBEDDING_USD_PER_MTOKEN
|
estimatedChf = estimatedTokens / 1_000_000 * EMBEDDING_CHF_PER_MTOKEN
|
||||||
|
|
||||||
Defaults match OpenAI `text-embedding-3-small` pricing (2026-Q2).
|
Defaults match OpenAI `text-embedding-3-small` published pricing (2026-Q2);
|
||||||
|
the project convention treats provider list prices as CHF directly (see
|
||||||
|
`calculatepriceCHF` in `aicorePluginOpenai.py`), so no FX conversion applies.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -21,7 +23,7 @@ from typing import Any, Dict
|
||||||
|
|
||||||
|
|
||||||
CHARS_PER_TOKEN = 4
|
CHARS_PER_TOKEN = 4
|
||||||
EMBEDDING_USD_PER_MTOKEN = 0.02
|
EMBEDDING_CHF_PER_MTOKEN = 0.02
|
||||||
DEFAULT_TOKENS_PER_ITEM = 1500
|
DEFAULT_TOKENS_PER_ITEM = 1500
|
||||||
BYTES_PER_TOKEN_TEXT_FACTOR = 4
|
BYTES_PER_TOKEN_TEXT_FACTOR = 4
|
||||||
EXTRACTABLE_FRACTION = 0.4
|
EXTRACTABLE_FRACTION = 0.4
|
||||||
|
|
@ -34,12 +36,12 @@ def estimateBootstrapCost(limits: Dict[str, int], kind: str = "files") -> Dict[s
|
||||||
|
|
||||||
{
|
{
|
||||||
"estimatedTokens": int,
|
"estimatedTokens": int,
|
||||||
"estimatedUsd": float, # rounded to 4 decimals
|
"estimatedChf": float, # rounded to 4 decimals
|
||||||
"basis": {
|
"basis": {
|
||||||
"kind": "files"|"clickup",
|
"kind": "files"|"clickup",
|
||||||
"limits": {...},
|
"limits": {...},
|
||||||
"assumptions": {
|
"assumptions": {
|
||||||
"embeddingUsdPerMToken": 0.02,
|
"embeddingChfPerMToken": 0.02,
|
||||||
"charsPerToken": 4,
|
"charsPerToken": 4,
|
||||||
"extractableFraction": 0.4,
|
"extractableFraction": 0.4,
|
||||||
"tokensPerItem": 1500 # only for clickup-like item counts
|
"tokensPerItem": 1500 # only for clickup-like item counts
|
||||||
|
|
@ -49,7 +51,7 @@ def estimateBootstrapCost(limits: Dict[str, int], kind: str = "files") -> Dict[s
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
assumptions: Dict[str, Any] = {
|
assumptions: Dict[str, Any] = {
|
||||||
"embeddingUsdPerMToken": EMBEDDING_USD_PER_MTOKEN,
|
"embeddingChfPerMToken": EMBEDDING_CHF_PER_MTOKEN,
|
||||||
"charsPerToken": CHARS_PER_TOKEN,
|
"charsPerToken": CHARS_PER_TOKEN,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,11 +71,11 @@ def estimateBootstrapCost(limits: Dict[str, int], kind: str = "files") -> Dict[s
|
||||||
estimatedTokens = 0
|
estimatedTokens = 0
|
||||||
assumptions["formula"] = "unknown kind, returning zero"
|
assumptions["formula"] = "unknown kind, returning zero"
|
||||||
|
|
||||||
estimatedUsd = round(estimatedTokens / 1_000_000 * EMBEDDING_USD_PER_MTOKEN, 4)
|
estimatedChf = round(estimatedTokens / 1_000_000 * EMBEDDING_CHF_PER_MTOKEN, 4)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"estimatedTokens": estimatedTokens,
|
"estimatedTokens": estimatedTokens,
|
||||||
"estimatedUsd": estimatedUsd,
|
"estimatedChf": estimatedChf,
|
||||||
"basis": {
|
"basis": {
|
||||||
"kind": kind,
|
"kind": kind,
|
||||||
"limits": dict(limits),
|
"limits": dict(limits),
|
||||||
|
|
|
||||||
|
|
@ -216,9 +216,9 @@ def _archiveOtherRecurringPrices(
|
||||||
stripe.Price.modify(p.id, active=False)
|
stripe.Price.modify(p.id, active=False)
|
||||||
logger.info("Archived stale Stripe Price %s on product %s", p.id, productId)
|
logger.info("Archived stale Stripe Price %s on product %s", p.id, productId)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning("Could not archive price %s: %s", p.id, ex)
|
logger.debug("Could not archive price %s: %s", p.id, ex)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Stale price archive pass failed for product %s: %s", productId, e)
|
logger.debug("Stale price archive pass skipped for product %s: %s", productId, e)
|
||||||
|
|
||||||
|
|
||||||
def _validateStripeIdsExist(stripe, mapping: StripePlanPrice) -> bool:
|
def _validateStripeIdsExist(stripe, mapping: StripePlanPrice) -> bool:
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,12 @@ def _resolveLogDir() -> str:
|
||||||
logDir = os.path.join(gatewayDir, logDir)
|
logDir = os.path.join(gatewayDir, logDir)
|
||||||
return logDir
|
return logDir
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_app_log_dir() -> str:
|
||||||
|
"""Absolute filesystem path for ``APP_LOGGING_LOG_DIR``."""
|
||||||
|
return _resolveLogDir()
|
||||||
|
|
||||||
|
|
||||||
def ensureDir(path: str) -> None:
|
def ensureDir(path: str) -> None:
|
||||||
"""Create directory if it does not exist."""
|
"""Create directory if it does not exist."""
|
||||||
os.makedirs(path, exist_ok=True)
|
os.makedirs(path, exist_ok=True)
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ class EventManagement:
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
if self._scheduler and self._scheduler.running:
|
if self._scheduler and self._scheduler.running:
|
||||||
try:
|
try:
|
||||||
|
self._scheduler.remove_all_jobs()
|
||||||
self._scheduler.shutdown(wait=False)
|
self._scheduler.shutdown(wait=False)
|
||||||
logger.info("EventManagement scheduler stopped")
|
logger.info("EventManagement scheduler stopped")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,15 @@ class FrontendType(str, Enum):
|
||||||
FILTER_EXPRESSION = "filterExpression"
|
FILTER_EXPRESSION = "filterExpression"
|
||||||
"""Filter expression builder for data.filter"""
|
"""Filter expression builder for data.filter"""
|
||||||
|
|
||||||
|
CONTEXT_BUILDER = "contextBuilder"
|
||||||
|
"""Upstream handover picker (graph editor): DataRef / path selection from prior nodes."""
|
||||||
|
|
||||||
|
CONTEXT_ASSIGNMENTS = "contextAssignments"
|
||||||
|
"""Context set assignments: target key, picker | literal | human task (graph editor)."""
|
||||||
|
|
||||||
|
USER_FILE_FOLDER = "userFileFolder"
|
||||||
|
"""User file storage folder (graph editor): browse My Files tree or create folders."""
|
||||||
|
|
||||||
|
|
||||||
# Mapping of custom types to their API endpoint for dynamic options
|
# Mapping of custom types to their API endpoint for dynamic options
|
||||||
CUSTOM_TYPE_OPTIONS_API: Dict[FrontendType, str] = {
|
CUSTOM_TYPE_OPTIONS_API: Dict[FrontendType, str] = {
|
||||||
|
|
|
||||||
|
|
@ -790,3 +790,98 @@ def _jsonSafe(v):
|
||||||
except Exception:
|
except Exception:
|
||||||
return repr(v)
|
return repr(v)
|
||||||
return str(v)
|
return str(v)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Legacy table discovery + drop
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _discoverLegacyTables(dbFilter: Optional[str] = None) -> List[dict]:
|
||||||
|
"""Find tables that exist in the DB but have no entry in MODEL_REGISTRY.
|
||||||
|
|
||||||
|
A table is legacy if its name does NOT match any PowerOnModel class.
|
||||||
|
Tables that exist in multiple DBs (shared-table pattern) are NOT flagged
|
||||||
|
as legacy -- the connector creates them wherever code writes that model.
|
||||||
|
|
||||||
|
Returns a list of dicts: {db, table, rowCount, sizeBytes}.
|
||||||
|
"""
|
||||||
|
from modules.datamodels.datamodelBase import MODEL_REGISTRY
|
||||||
|
from modules.shared.fkRegistry import _ensureModelsLoaded
|
||||||
|
|
||||||
|
_ensureModelsLoaded()
|
||||||
|
registeredDbs = getRegisteredDatabases()
|
||||||
|
results: List[dict] = []
|
||||||
|
|
||||||
|
for dbName in sorted(registeredDbs.keys()):
|
||||||
|
if dbFilter and dbName != dbFilter:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
conn = _getConnection(dbName)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Legacy scan: cannot connect to %s: %s", dbName, e)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT c.relname AS table_name,
|
||||||
|
c.reltuples::bigint AS row_estimate,
|
||||||
|
pg_total_relation_size(c.oid) AS size_bytes
|
||||||
|
FROM pg_class c
|
||||||
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||||
|
WHERE n.nspname = 'public'
|
||||||
|
AND c.relkind = 'r'
|
||||||
|
AND c.relname NOT LIKE '\\_%'
|
||||||
|
ORDER BY c.relname
|
||||||
|
""")
|
||||||
|
for row in cur.fetchall():
|
||||||
|
tblName = row["table_name"]
|
||||||
|
if tblName not in MODEL_REGISTRY:
|
||||||
|
results.append({
|
||||||
|
"db": dbName,
|
||||||
|
"table": tblName,
|
||||||
|
"rowCount": max(0, int(row["row_estimate"])),
|
||||||
|
"sizeBytes": int(row["size_bytes"]),
|
||||||
|
})
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _dropLegacyTable(dbName: str, tableName: str) -> dict:
|
||||||
|
"""Drop a single legacy table after verifying it is NOT in MODEL_REGISTRY.
|
||||||
|
|
||||||
|
Returns {db, table, dropped, rowCount}.
|
||||||
|
Raises ValueError if the table is model-backed (safety guard).
|
||||||
|
"""
|
||||||
|
from modules.datamodels.datamodelBase import MODEL_REGISTRY
|
||||||
|
from modules.shared.fkRegistry import _ensureModelsLoaded
|
||||||
|
|
||||||
|
_ensureModelsLoaded()
|
||||||
|
if tableName in MODEL_REGISTRY:
|
||||||
|
raise ValueError(
|
||||||
|
f"Table '{dbName}.{tableName}' is backed by a Pydantic model and cannot be dropped via legacy cleanup."
|
||||||
|
)
|
||||||
|
|
||||||
|
conn = _getConnection(dbName)
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT reltuples::bigint AS row_estimate
|
||||||
|
FROM pg_class c
|
||||||
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||||
|
WHERE n.nspname = 'public' AND c.relname = %s
|
||||||
|
""", (tableName,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
rowCount = max(0, int(row["row_estimate"])) if row else 0
|
||||||
|
|
||||||
|
cur.execute(f'DROP TABLE IF EXISTS "{tableName}" CASCADE')
|
||||||
|
conn.commit()
|
||||||
|
logger.info("Dropped legacy table %s.%s (%d rows)", dbName, tableName, rowCount)
|
||||||
|
return {"db": dbName, "table": tableName, "dropped": True, "rowCount": rowCount}
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
logger.error("Failed to drop legacy table %s.%s: %s", dbName, tableName, e)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
|
||||||
816
modules/system/databaseMigration.py
Normal file
816
modules/system/databaseMigration.py
Normal file
|
|
@ -0,0 +1,816 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
|
"""
|
||||||
|
Database migration utilities — backup (export) and restore (import) for all
|
||||||
|
registered PowerOn databases.
|
||||||
|
|
||||||
|
System objects (root mandate, admin user, event user) are protected: they are
|
||||||
|
never deleted or overwritten during import. Their IDs in the backup payload
|
||||||
|
are remapped to the IDs of the corresponding live objects so that all FK
|
||||||
|
references stay consistent.
|
||||||
|
|
||||||
|
All functions are intended for SysAdmin use only (access control in the route layer).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
import psycopg2.extras
|
||||||
|
|
||||||
|
from modules.shared.configuration import APP_CONFIG
|
||||||
|
from modules.shared.dbRegistry import getRegisteredDatabases
|
||||||
|
from modules.shared.fkRegistry import getFkRelationships
|
||||||
|
from modules.datamodels.datamodelBase import MODEL_REGISTRY
|
||||||
|
from modules.system.databaseHealth import _getConnection, _jsonSafe
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_EXPORT_FORMAT_VERSION = "1.0"
|
||||||
|
_SYSTEM_TABLE = "_system"
|
||||||
|
|
||||||
|
_EXCLUDED_TABLES: Dict[str, Set[str]] = {
|
||||||
|
"poweron_app": {"Token", "AuthEvent"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Instance label
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _getInstanceLabel() -> str:
|
||||||
|
"""Return the instance type from APP_ENV_TYPE (e.g. 'dev', 'int', 'prod')."""
|
||||||
|
return APP_CONFIG.get("APP_ENV_TYPE", "unknown")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Database list
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _getAvailableDatabases() -> List[dict]:
|
||||||
|
"""Return registered databases with table/row counts for the UI."""
|
||||||
|
registeredDbs = getRegisteredDatabases()
|
||||||
|
results: List[dict] = []
|
||||||
|
for dbName in sorted(registeredDbs):
|
||||||
|
if dbName == "poweron_test":
|
||||||
|
continue
|
||||||
|
entry: dict = {"name": dbName, "tableCount": 0, "recordCount": 0}
|
||||||
|
try:
|
||||||
|
conn = _getConnection(dbName)
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT relname, n_live_tup
|
||||||
|
FROM pg_stat_user_tables
|
||||||
|
WHERE schemaname = 'public'
|
||||||
|
AND relname NOT LIKE '\\_%%'
|
||||||
|
""")
|
||||||
|
for row in cur.fetchall():
|
||||||
|
entry["tableCount"] += 1
|
||||||
|
entry["recordCount"] += int(row["n_live_tup"])
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Could not stat database %s: %s", dbName, e)
|
||||||
|
results.append(entry)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Export
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _exportDatabases(databases: List[str]) -> dict:
|
||||||
|
"""Export selected databases as a JSON-serialisable dict.
|
||||||
|
|
||||||
|
Returns ``{meta: {...}, databases: {dbName: {tables: {tbl: [rows]}, summary: {...}}}}``
|
||||||
|
"""
|
||||||
|
registeredDbs = getRegisteredDatabases()
|
||||||
|
|
||||||
|
if not databases:
|
||||||
|
raise ValueError("No databases selected for export.")
|
||||||
|
|
||||||
|
exportData: dict = {
|
||||||
|
"meta": {
|
||||||
|
"exportedAt": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"version": _EXPORT_FORMAT_VERSION,
|
||||||
|
"databaseCount": 0,
|
||||||
|
"totalTables": 0,
|
||||||
|
"totalRecords": 0,
|
||||||
|
},
|
||||||
|
"databases": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
for dbName in databases:
|
||||||
|
if dbName not in registeredDbs:
|
||||||
|
logger.warning("Export: skipping unregistered database %s", dbName)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
dbPayload = _exportSingleDb(dbName)
|
||||||
|
exportData["databases"][dbName] = dbPayload
|
||||||
|
exportData["meta"]["databaseCount"] += 1
|
||||||
|
exportData["meta"]["totalTables"] += dbPayload["tableCount"]
|
||||||
|
exportData["meta"]["totalRecords"] += dbPayload["totalRecords"]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Export failed for database %s: %s", dbName, e)
|
||||||
|
|
||||||
|
return exportData
|
||||||
|
|
||||||
|
|
||||||
|
def _getModelTablesForDb(dbName: str, physicalTables: List[str]) -> List[str]:
|
||||||
|
"""Return only those physical tables that have a matching Pydantic model
|
||||||
|
registered in MODEL_REGISTRY.
|
||||||
|
|
||||||
|
Tables without a Pydantic class (legacy / orphan tables) are excluded
|
||||||
|
from export so the backup contains only model-backed data.
|
||||||
|
|
||||||
|
Note: the same model can exist in multiple databases (shared-table
|
||||||
|
pattern), so we only check membership in MODEL_REGISTRY, not the
|
||||||
|
DB mapping.
|
||||||
|
"""
|
||||||
|
return sorted(
|
||||||
|
t for t in physicalTables
|
||||||
|
if t in MODEL_REGISTRY
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _exportSingleDb(dbName: str) -> dict:
|
||||||
|
conn = _getConnection(dbName)
|
||||||
|
excluded = _EXCLUDED_TABLES.get(dbName, set())
|
||||||
|
try:
|
||||||
|
allTables = _listTables(conn)
|
||||||
|
modelTables = _getModelTablesForDb(dbName, allTables)
|
||||||
|
skippedLegacy = set(allTables) - set(modelTables) - excluded - {_SYSTEM_TABLE}
|
||||||
|
if skippedLegacy:
|
||||||
|
logger.info("Export %s: skipping %d legacy tables without model: %s",
|
||||||
|
dbName, len(skippedLegacy), sorted(skippedLegacy))
|
||||||
|
|
||||||
|
dbPayload: dict = {"tables": {}, "summary": {}, "tableCount": 0, "totalRecords": 0}
|
||||||
|
for tbl in modelTables:
|
||||||
|
if tbl in excluded:
|
||||||
|
logger.info("Export: skipping excluded table %s.%s", dbName, tbl)
|
||||||
|
continue
|
||||||
|
rows = _readTableRows(conn, tbl)
|
||||||
|
dbPayload["tables"][tbl] = rows
|
||||||
|
dbPayload["summary"][tbl] = {"recordCount": len(rows)}
|
||||||
|
dbPayload["tableCount"] += 1
|
||||||
|
dbPayload["totalRecords"] += len(rows)
|
||||||
|
return dbPayload
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _listTables(conn) -> List[str]:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT table_name
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_type = 'BASE TABLE'
|
||||||
|
AND table_name != %s
|
||||||
|
ORDER BY table_name
|
||||||
|
""", (_SYSTEM_TABLE,))
|
||||||
|
return [row["table_name"] for row in cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def _readTableRows(conn, tableName: str) -> List[dict]:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(f'SELECT * FROM "{tableName}"')
|
||||||
|
return [{k: _jsonSafe(v) for k, v in dict(row).items()} for row in cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Validate
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _validateImportPayload(payload: dict) -> dict:
|
||||||
|
"""Validate an import payload without writing anything.
|
||||||
|
|
||||||
|
Returns ``{valid, summary, warnings, systemObjectsFound}``.
|
||||||
|
"""
|
||||||
|
warnings: List[str] = []
|
||||||
|
summary: List[dict] = []
|
||||||
|
|
||||||
|
meta = payload.get("meta")
|
||||||
|
if not meta or not isinstance(meta, dict):
|
||||||
|
return {"valid": False, "summary": [], "warnings": ["Fehlende oder ungueltige 'meta'-Sektion"], "systemObjectsFound": []}
|
||||||
|
|
||||||
|
version = meta.get("version", "")
|
||||||
|
if version != _EXPORT_FORMAT_VERSION:
|
||||||
|
warnings.append(f"Unbekannte Format-Version: {version} (erwartet: {_EXPORT_FORMAT_VERSION})")
|
||||||
|
|
||||||
|
databases = payload.get("databases")
|
||||||
|
if not databases or not isinstance(databases, dict):
|
||||||
|
return {"valid": False, "summary": [], "warnings": ["Fehlende oder ungueltige 'databases'-Sektion"], "systemObjectsFound": []}
|
||||||
|
|
||||||
|
registeredDbs = getRegisteredDatabases()
|
||||||
|
|
||||||
|
for dbName, dbData in databases.items():
|
||||||
|
tables = dbData.get("tables", {})
|
||||||
|
tableCount = len(tables)
|
||||||
|
recordCount = sum(len(rows) for rows in tables.values() if isinstance(rows, list))
|
||||||
|
registered = dbName in registeredDbs
|
||||||
|
if not registered:
|
||||||
|
warnings.append(f"Datenbank '{dbName}' ist nicht registriert und wird uebersprungen")
|
||||||
|
summary.append({
|
||||||
|
"database": dbName,
|
||||||
|
"tableCount": tableCount,
|
||||||
|
"recordCount": recordCount,
|
||||||
|
"registered": registered,
|
||||||
|
})
|
||||||
|
|
||||||
|
systemObjectsFound = _detectSystemObjectsInPayload(payload)
|
||||||
|
|
||||||
|
valid = any(s["registered"] for s in summary)
|
||||||
|
return {
|
||||||
|
"valid": valid,
|
||||||
|
"summary": summary,
|
||||||
|
"warnings": warnings,
|
||||||
|
"systemObjectsFound": systemObjectsFound,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _detectSystemObjectsInPayload(payload: dict) -> List[dict]:
|
||||||
|
"""Find system objects (root mandate, admin user, event user) in a payload."""
|
||||||
|
found: List[dict] = []
|
||||||
|
appData = payload.get("databases", {}).get("poweron_app", {}).get("tables", {})
|
||||||
|
|
||||||
|
for row in appData.get("Mandate", []):
|
||||||
|
if row.get("name") == "root" and row.get("isSystem") is True:
|
||||||
|
found.append({"type": "mandate", "label": "Root Mandate", "payloadId": row.get("id")})
|
||||||
|
|
||||||
|
for row in appData.get("UserInDB", []):
|
||||||
|
if row.get("username") == "admin":
|
||||||
|
found.append({"type": "user", "label": "Admin User", "payloadId": row.get("id")})
|
||||||
|
elif row.get("username") == "event":
|
||||||
|
found.append({"type": "user", "label": "Event User", "payloadId": row.get("id")})
|
||||||
|
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# System-object ID remapping
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _loadLiveSystemObjectIds() -> Dict[str, str]:
|
||||||
|
"""Load the IDs of the 3 protected system objects from the live DB.
|
||||||
|
|
||||||
|
Returns a dict like ``{"rootMandate": "<uuid>", "adminUser": "<uuid>", "eventUser": "<uuid>"}``.
|
||||||
|
"""
|
||||||
|
registeredDbs = getRegisteredDatabases()
|
||||||
|
if "poweron_app" not in registeredDbs:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
result: Dict[str, str] = {}
|
||||||
|
conn = _getConnection("poweron_app")
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""SELECT id FROM "Mandate" WHERE "name" = 'root' AND "isSystem" = true LIMIT 1""")
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row:
|
||||||
|
result["rootMandate"] = str(row["id"])
|
||||||
|
|
||||||
|
cur.execute("""SELECT id FROM "UserInDB" WHERE "username" = 'admin' LIMIT 1""")
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row:
|
||||||
|
result["adminUser"] = str(row["id"])
|
||||||
|
|
||||||
|
cur.execute("""SELECT id FROM "UserInDB" WHERE "username" = 'event' LIMIT 1""")
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row:
|
||||||
|
result["eventUser"] = str(row["id"])
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _buildIdRemapFromPayload(payload: dict, liveIds: Dict[str, str]) -> Dict[str, str]:
|
||||||
|
"""Build an ``{oldId: newId}`` mapping for system objects.
|
||||||
|
|
||||||
|
Compares IDs found in the payload with the live system-object IDs.
|
||||||
|
Only entries where the IDs actually differ are included.
|
||||||
|
"""
|
||||||
|
remap: Dict[str, str] = {}
|
||||||
|
appTables = payload.get("databases", {}).get("poweron_app", {}).get("tables", {})
|
||||||
|
|
||||||
|
for row in appTables.get("Mandate", []):
|
||||||
|
if row.get("name") == "root" and row.get("isSystem") is True:
|
||||||
|
oldId = str(row.get("id", ""))
|
||||||
|
newId = liveIds.get("rootMandate", "")
|
||||||
|
if oldId and newId and oldId != newId:
|
||||||
|
remap[oldId] = newId
|
||||||
|
|
||||||
|
for row in appTables.get("UserInDB", []):
|
||||||
|
username = row.get("username")
|
||||||
|
oldId = str(row.get("id", ""))
|
||||||
|
if username == "admin":
|
||||||
|
newId = liveIds.get("adminUser", "")
|
||||||
|
elif username == "event":
|
||||||
|
newId = liveIds.get("eventUser", "")
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
if oldId and newId and oldId != newId:
|
||||||
|
remap[oldId] = newId
|
||||||
|
|
||||||
|
return remap
|
||||||
|
|
||||||
|
|
||||||
|
def _remapSystemObjectIds(payload: dict, remap: Dict[str, str]) -> dict:
|
||||||
|
"""Walk the entire payload and replace every value that matches an old system-object ID."""
|
||||||
|
if not remap:
|
||||||
|
return payload
|
||||||
|
|
||||||
|
remapSet = set(remap.keys())
|
||||||
|
|
||||||
|
databases = payload.get("databases", {})
|
||||||
|
for dbName, dbData in databases.items():
|
||||||
|
tables = dbData.get("tables", {})
|
||||||
|
for tableName, rows in tables.items():
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
continue
|
||||||
|
for row in rows:
|
||||||
|
_remapRowValues(row, remap, remapSet)
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _remapDbTables(tables: dict, remap: Dict[str, str]) -> None:
|
||||||
|
"""In-place remap system-object IDs in a single DB's tables dict."""
|
||||||
|
if not remap:
|
||||||
|
return
|
||||||
|
remapSet = set(remap.keys())
|
||||||
|
for tableName, rows in tables.items():
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
continue
|
||||||
|
for row in rows:
|
||||||
|
_remapRowValues(row, remap, remapSet)
|
||||||
|
|
||||||
|
|
||||||
|
def _remapRowValues(row: dict, remap: Dict[str, str], remapSet: Set[str]) -> None:
|
||||||
|
"""In-place replace string values in a row dict that match a remap key."""
|
||||||
|
for key, val in row.items():
|
||||||
|
if isinstance(val, str) and val in remapSet:
|
||||||
|
row[key] = remap[val]
|
||||||
|
elif isinstance(val, dict):
|
||||||
|
_remapRowValues(val, remap, remapSet)
|
||||||
|
elif isinstance(val, list):
|
||||||
|
for i, item in enumerate(val):
|
||||||
|
if isinstance(item, str) and item in remapSet:
|
||||||
|
val[i] = remap[item]
|
||||||
|
elif isinstance(item, dict):
|
||||||
|
_remapRowValues(item, remap, remapSet)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Import
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_PROTECTED_ROWS: Dict[str, List[dict]] = {
|
||||||
|
"Mandate": [{"name": "root", "isSystem": True}],
|
||||||
|
"UserInDB": [{"username": "admin"}, {"username": "event"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _isProtectedRow(tableName: str, row: dict) -> bool:
|
||||||
|
"""Return True if a row represents a protected system object."""
|
||||||
|
patterns = _PROTECTED_ROWS.get(tableName, [])
|
||||||
|
for pattern in patterns:
|
||||||
|
if all(row.get(k) == v for k, v in pattern.items()):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _importDatabases(payload: dict, mode: str) -> dict:
|
||||||
|
"""Import databases from a validated payload.
|
||||||
|
|
||||||
|
``mode`` is ``"replace"`` (clear + insert) or ``"merge"`` (insert missing only).
|
||||||
|
"""
|
||||||
|
if mode not in ("replace", "merge"):
|
||||||
|
raise ValueError(f"Invalid import mode: {mode}")
|
||||||
|
|
||||||
|
registeredDbs = getRegisteredDatabases()
|
||||||
|
|
||||||
|
liveIds = _loadLiveSystemObjectIds()
|
||||||
|
remap = _buildIdRemapFromPayload(payload, liveIds)
|
||||||
|
if remap:
|
||||||
|
logger.info("System-object ID remap: %s", remap)
|
||||||
|
_remapSystemObjectIds(payload, remap)
|
||||||
|
|
||||||
|
protectedIdSet = set(liveIds.values())
|
||||||
|
|
||||||
|
imported: Dict[str, dict] = {}
|
||||||
|
warnings: List[str] = []
|
||||||
|
databases = payload.get("databases", {})
|
||||||
|
|
||||||
|
for dbName, dbData in databases.items():
|
||||||
|
if dbName not in registeredDbs:
|
||||||
|
warnings.append(f"Datenbank '{dbName}' uebersprungen (nicht registriert)")
|
||||||
|
continue
|
||||||
|
|
||||||
|
tables = dbData.get("tables", {})
|
||||||
|
dbResult: Dict[str, int] = {}
|
||||||
|
|
||||||
|
conn = _getConnection(dbName)
|
||||||
|
try:
|
||||||
|
conn.autocommit = False
|
||||||
|
existingTables = set(_listTables(conn))
|
||||||
|
|
||||||
|
for tableName, rows in tables.items():
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
continue
|
||||||
|
if tableName not in existingTables:
|
||||||
|
warnings.append(f"Tabelle '{dbName}.{tableName}' existiert nicht, uebersprungen")
|
||||||
|
continue
|
||||||
|
|
||||||
|
physicalCols = _getPhysicalColumns(conn, tableName)
|
||||||
|
if not physicalCols:
|
||||||
|
continue
|
||||||
|
|
||||||
|
filteredRows = []
|
||||||
|
for row in rows:
|
||||||
|
if _isProtectedRow(tableName, row):
|
||||||
|
continue
|
||||||
|
if row.get("id") and str(row["id"]) in protectedIdSet:
|
||||||
|
continue
|
||||||
|
filteredRows.append(row)
|
||||||
|
|
||||||
|
if mode == "replace":
|
||||||
|
_deleteNonProtected(conn, tableName, protectedIdSet)
|
||||||
|
|
||||||
|
insertedCount = _insertRows(conn, tableName, filteredRows, physicalCols, mode)
|
||||||
|
dbResult[tableName] = insertedCount
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
logger.error("Import failed for database %s: %s", dbName, e)
|
||||||
|
warnings.append(f"Import fuer '{dbName}' fehlgeschlagen: {e}")
|
||||||
|
continue
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
imported[dbName] = dbResult
|
||||||
|
|
||||||
|
totalRecords = sum(sum(v.values()) for v in imported.values())
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"imported": imported,
|
||||||
|
"totalRecords": totalRecords,
|
||||||
|
"warnings": warnings,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _getPhysicalColumns(conn, tableName: str) -> List[str]:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = %s
|
||||||
|
ORDER BY ordinal_position
|
||||||
|
""", (tableName,))
|
||||||
|
return [row["column_name"] for row in cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def _deleteNonProtected(conn, tableName: str, protectedIds: Set[str]) -> int:
|
||||||
|
"""Delete all rows except protected system objects."""
|
||||||
|
if not protectedIds:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(f'DELETE FROM "{tableName}"')
|
||||||
|
return cur.rowcount
|
||||||
|
|
||||||
|
idList = list(protectedIds)
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
f'DELETE FROM "{tableName}" WHERE "id"::text != ALL(%(ids)s)',
|
||||||
|
{"ids": idList},
|
||||||
|
)
|
||||||
|
return cur.rowcount
|
||||||
|
|
||||||
|
|
||||||
|
def _insertRows(
|
||||||
|
conn,
|
||||||
|
tableName: str,
|
||||||
|
rows: List[dict],
|
||||||
|
physicalCols: List[str],
|
||||||
|
mode: str,
|
||||||
|
) -> int:
|
||||||
|
"""Insert rows into a table. In merge mode, skip rows whose id already exists."""
|
||||||
|
if not rows:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
physicalColSet = set(physicalCols)
|
||||||
|
inserted = 0
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
cols = [c for c in row.keys() if c in physicalColSet]
|
||||||
|
if not cols:
|
||||||
|
continue
|
||||||
|
|
||||||
|
values = [_pgSafe(row[c]) for c in cols]
|
||||||
|
colNames = ", ".join(f'"{c}"' for c in cols)
|
||||||
|
placeholders = ", ".join(["%s"] * len(cols))
|
||||||
|
|
||||||
|
if mode == "merge":
|
||||||
|
sql = f'INSERT INTO "{tableName}" ({colNames}) VALUES ({placeholders}) ON CONFLICT ("id") DO NOTHING'
|
||||||
|
else:
|
||||||
|
sql = f'INSERT INTO "{tableName}" ({colNames}) VALUES ({placeholders})'
|
||||||
|
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SAVEPOINT row_sp")
|
||||||
|
cur.execute(sql, values)
|
||||||
|
inserted += cur.rowcount
|
||||||
|
cur.execute("RELEASE SAVEPOINT row_sp")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Insert failed for %s row: %s", tableName, e)
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("ROLLBACK TO SAVEPOINT row_sp")
|
||||||
|
|
||||||
|
return inserted
|
||||||
|
|
||||||
|
|
||||||
|
def _pgSafe(v: Any) -> Any:
|
||||||
|
"""Convert Python values to psycopg2-compatible types."""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
if v is None or isinstance(v, (str, int, float, bool)):
|
||||||
|
return v
|
||||||
|
if isinstance(v, (dict, list)):
|
||||||
|
return _json.dumps(v)
|
||||||
|
return str(v)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Prepare import (validate + remap, return context for per-DB import)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _prepareImport(payload: dict) -> dict:
|
||||||
|
"""Validate, remap system-object IDs, and return the prepared payload
|
||||||
|
together with metadata the frontend needs to drive per-DB import.
|
||||||
|
|
||||||
|
Returns ``{valid, warnings, systemObjectsFound, databases, protectedIds, remappedPayload}``.
|
||||||
|
"""
|
||||||
|
validation = _validateImportPayload(payload)
|
||||||
|
if not validation.get("valid"):
|
||||||
|
return {
|
||||||
|
"valid": False,
|
||||||
|
"warnings": validation.get("warnings", []),
|
||||||
|
"systemObjectsFound": validation.get("systemObjectsFound", []),
|
||||||
|
"databases": [],
|
||||||
|
"protectedIds": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
liveIds = _loadLiveSystemObjectIds()
|
||||||
|
remap = _buildIdRemapFromPayload(payload, liveIds)
|
||||||
|
if remap:
|
||||||
|
logger.info("System-object ID remap: %s", remap)
|
||||||
|
_remapSystemObjectIds(payload, remap)
|
||||||
|
|
||||||
|
protectedIdSet = set(liveIds.values())
|
||||||
|
|
||||||
|
registeredDbs = getRegisteredDatabases()
|
||||||
|
dbList = []
|
||||||
|
for dbName, dbData in payload.get("databases", {}).items():
|
||||||
|
if dbName not in registeredDbs:
|
||||||
|
continue
|
||||||
|
tables = dbData.get("tables", {})
|
||||||
|
recordCount = sum(len(rows) for rows in tables.values() if isinstance(rows, list))
|
||||||
|
dbList.append({
|
||||||
|
"database": dbName,
|
||||||
|
"tableCount": len(tables),
|
||||||
|
"recordCount": recordCount,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"valid": True,
|
||||||
|
"warnings": validation.get("warnings", []),
|
||||||
|
"systemObjectsFound": validation.get("systemObjectsFound", []),
|
||||||
|
"databases": dbList,
|
||||||
|
"protectedIds": list(protectedIdSet),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ensureDatabaseExists(dbName: str) -> bool:
|
||||||
|
"""Create the PostgreSQL database if it does not yet exist.
|
||||||
|
|
||||||
|
Connects to the ``postgres`` admin database using the same credentials
|
||||||
|
as the target DB. Returns True if the database was created, False if
|
||||||
|
it already existed.
|
||||||
|
"""
|
||||||
|
registeredDbs = getRegisteredDatabases()
|
||||||
|
configPrefix = registeredDbs.get(dbName)
|
||||||
|
if configPrefix is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
hostKey = f"{configPrefix}_HOST" if configPrefix != "DB" else "DB_HOST"
|
||||||
|
portKey = f"{configPrefix}_PORT" if configPrefix != "DB" else "DB_PORT"
|
||||||
|
userKey = f"{configPrefix}_USER" if configPrefix != "DB" else "DB_USER"
|
||||||
|
passwordKey = f"{configPrefix}_PASSWORD_SECRET" if configPrefix != "DB" else "DB_PASSWORD_SECRET"
|
||||||
|
|
||||||
|
adminConn = psycopg2.connect(
|
||||||
|
host=APP_CONFIG.get(hostKey, "localhost"),
|
||||||
|
port=int(APP_CONFIG.get(portKey, 5432)),
|
||||||
|
database="postgres",
|
||||||
|
user=APP_CONFIG.get(userKey),
|
||||||
|
password=APP_CONFIG.get(passwordKey),
|
||||||
|
client_encoding="utf8",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
adminConn.autocommit = True
|
||||||
|
with adminConn.cursor() as cur:
|
||||||
|
cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (dbName,))
|
||||||
|
if cur.fetchone():
|
||||||
|
return False
|
||||||
|
cur.execute(f'CREATE DATABASE "{dbName}"')
|
||||||
|
logger.info("Created missing database: %s", dbName)
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
adminConn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _createTableFromExport(conn, tableName: str, rows: List[dict]) -> None:
|
||||||
|
"""Create a table based on the column structure found in the export data.
|
||||||
|
|
||||||
|
Uses TEXT for all columns since we don't have the original DDL.
|
||||||
|
The ``id`` column gets a PRIMARY KEY constraint.
|
||||||
|
"""
|
||||||
|
allKeys: List[str] = []
|
||||||
|
seen: set = set()
|
||||||
|
for row in rows:
|
||||||
|
for k in row.keys():
|
||||||
|
if k not in seen:
|
||||||
|
allKeys.append(k)
|
||||||
|
seen.add(k)
|
||||||
|
|
||||||
|
if not allKeys:
|
||||||
|
return
|
||||||
|
|
||||||
|
colDefs = []
|
||||||
|
for col in allKeys:
|
||||||
|
if col == "id":
|
||||||
|
colDefs.append(f'"{col}" TEXT PRIMARY KEY')
|
||||||
|
else:
|
||||||
|
colDefs.append(f'"{col}" TEXT')
|
||||||
|
|
||||||
|
ddl = f'CREATE TABLE IF NOT EXISTS "{tableName}" ({", ".join(colDefs)})'
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(ddl)
|
||||||
|
logger.info("Created table %s with %d columns", tableName, len(allKeys))
|
||||||
|
|
||||||
|
|
||||||
|
def _getTableImportOrder(conn, tableNames: List[str], dbName: str = "") -> List[str]:
|
||||||
|
"""Sort tables by FK dependencies (parents first) using topological sort.
|
||||||
|
|
||||||
|
Uses Pydantic ``fk_target`` metadata from ``fkRegistry`` as the single
|
||||||
|
source of truth (works for ALL databases, not just those with SQL FKs).
|
||||||
|
Only *intra-DB* dependencies are considered; cross-DB FKs (e.g. to
|
||||||
|
``poweron_app.Mandate``) are handled by importing databases in order.
|
||||||
|
"""
|
||||||
|
tableSet = set(tableNames)
|
||||||
|
allRels = getFkRelationships()
|
||||||
|
|
||||||
|
deps: Dict[str, Set[str]] = {t: set() for t in tableNames}
|
||||||
|
for rel in allRels:
|
||||||
|
if rel.sourceDb != dbName or rel.targetDb != dbName:
|
||||||
|
continue
|
||||||
|
child = rel.sourceTable
|
||||||
|
parent = rel.targetTable
|
||||||
|
if child in tableSet and parent in tableSet and child != parent:
|
||||||
|
deps[child].add(parent)
|
||||||
|
|
||||||
|
inDegree = {t: len(deps[t]) for t in tableNames}
|
||||||
|
queue = sorted(t for t in tableNames if inDegree[t] == 0)
|
||||||
|
ordered: List[str] = []
|
||||||
|
|
||||||
|
while queue:
|
||||||
|
node = queue.pop(0)
|
||||||
|
ordered.append(node)
|
||||||
|
for t in tableNames:
|
||||||
|
if node in deps[t]:
|
||||||
|
deps[t].discard(node)
|
||||||
|
inDegree[t] -= 1
|
||||||
|
if inDegree[t] == 0:
|
||||||
|
queue.append(t)
|
||||||
|
queue.sort()
|
||||||
|
|
||||||
|
remaining = [t for t in tableNames if t not in set(ordered)]
|
||||||
|
if remaining:
|
||||||
|
logger.warning("FK cycle detected, appending without order guarantee: %s", remaining)
|
||||||
|
ordered.extend(sorted(remaining))
|
||||||
|
|
||||||
|
return ordered
|
||||||
|
|
||||||
|
|
||||||
|
def _importSingleDb(payload: dict, dbName: str, mode: str, protectedIds: List[str]) -> dict:
|
||||||
|
"""Import a single database from the (already remapped) payload.
|
||||||
|
|
||||||
|
Tables are sorted by FK dependencies: parent tables are inserted first,
|
||||||
|
child tables are deleted first (reverse order) in replace mode.
|
||||||
|
|
||||||
|
Returns ``{database, tables: {tableName: insertedCount}, recordCount, warnings}``.
|
||||||
|
"""
|
||||||
|
if mode not in ("replace", "merge"):
|
||||||
|
raise ValueError(f"Invalid import mode: {mode}")
|
||||||
|
|
||||||
|
registeredDbs = getRegisteredDatabases()
|
||||||
|
if dbName not in registeredDbs:
|
||||||
|
return {"database": dbName, "tables": {}, "recordCount": 0,
|
||||||
|
"warnings": [f"Datenbank '{dbName}' nicht registriert"]}
|
||||||
|
|
||||||
|
dbData = payload.get("databases", {}).get(dbName)
|
||||||
|
if not dbData:
|
||||||
|
return {"database": dbName, "tables": {}, "recordCount": 0,
|
||||||
|
"warnings": [f"Keine Daten fuer '{dbName}' im Payload"]}
|
||||||
|
|
||||||
|
try:
|
||||||
|
dbCreated = _ensureDatabaseExists(dbName)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to ensure database %s exists: %s", dbName, e)
|
||||||
|
return {"database": dbName, "tables": {}, "recordCount": 0,
|
||||||
|
"warnings": [f"Datenbank '{dbName}' konnte nicht erstellt werden: {e}"]}
|
||||||
|
|
||||||
|
protectedIdSet = set(protectedIds)
|
||||||
|
tables = dbData.get("tables", {})
|
||||||
|
warnings: List[str] = []
|
||||||
|
dbResult: Dict[str, int] = {}
|
||||||
|
excluded = _EXCLUDED_TABLES.get(dbName, set())
|
||||||
|
|
||||||
|
if dbCreated:
|
||||||
|
warnings.append(f"Datenbank '{dbName}' wurde neu erstellt")
|
||||||
|
|
||||||
|
conn = _getConnection(dbName)
|
||||||
|
try:
|
||||||
|
existingTables = set(_listTables(conn))
|
||||||
|
conn.rollback()
|
||||||
|
|
||||||
|
# Ensure all import tables exist (create missing ones from export schema)
|
||||||
|
conn.autocommit = True
|
||||||
|
for tableName, rows in tables.items():
|
||||||
|
if tableName in excluded or not isinstance(rows, list) or not rows:
|
||||||
|
continue
|
||||||
|
if tableName not in existingTables:
|
||||||
|
_createTableFromExport(conn, tableName, rows)
|
||||||
|
existingTables.add(tableName)
|
||||||
|
logger.info("Pre-created missing table %s.%s", dbName, tableName)
|
||||||
|
|
||||||
|
# Build importable table list and sort by FK dependencies
|
||||||
|
importable = [t for t in tables
|
||||||
|
if t not in excluded
|
||||||
|
and isinstance(tables.get(t), list)
|
||||||
|
and t in existingTables]
|
||||||
|
importOrder = _getTableImportOrder(conn, importable, dbName)
|
||||||
|
|
||||||
|
logger.info("Import order for %s: %s", dbName, importOrder)
|
||||||
|
|
||||||
|
for tableName in tables:
|
||||||
|
if tableName in excluded and isinstance(tables.get(tableName), list):
|
||||||
|
warnings.append(f"Table '{dbName}.{tableName}' excluded (security/transient)")
|
||||||
|
|
||||||
|
# Phase 1 (replace only): DELETE children first (reverse topological order)
|
||||||
|
if mode == "replace":
|
||||||
|
conn.autocommit = False
|
||||||
|
for tableName in reversed(importOrder):
|
||||||
|
try:
|
||||||
|
_deleteNonProtected(conn, tableName, protectedIdSet)
|
||||||
|
conn.commit()
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
warnings.append(f"DELETE from {dbName}.{tableName} failed: {e}")
|
||||||
|
logger.warning("DELETE from %s.%s failed: %s", dbName, tableName, e)
|
||||||
|
|
||||||
|
# Phase 2: INSERT parents first (topological order)
|
||||||
|
conn.autocommit = False
|
||||||
|
for tableName in importOrder:
|
||||||
|
try:
|
||||||
|
rows = tables[tableName]
|
||||||
|
physicalCols = _getPhysicalColumns(conn, tableName)
|
||||||
|
if not physicalCols:
|
||||||
|
conn.rollback()
|
||||||
|
continue
|
||||||
|
|
||||||
|
filteredRows = []
|
||||||
|
for row in rows:
|
||||||
|
if _isProtectedRow(tableName, row):
|
||||||
|
continue
|
||||||
|
if row.get("id") and str(row["id"]) in protectedIdSet:
|
||||||
|
continue
|
||||||
|
filteredRows.append(row)
|
||||||
|
|
||||||
|
insertedCount = _insertRows(conn, tableName, filteredRows, physicalCols, mode)
|
||||||
|
conn.commit()
|
||||||
|
dbResult[tableName] = insertedCount
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
warnings.append(f"INSERT into {dbName}.{tableName} failed: {e}")
|
||||||
|
logger.warning("INSERT into %s.%s failed: %s", dbName, tableName, e)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Import failed for database %s: %s", dbName, e)
|
||||||
|
return {"database": dbName, "tables": {}, "recordCount": 0,
|
||||||
|
"warnings": [f"Import fuer '{dbName}' fehlgeschlagen: {e}"]}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
recordCount = sum(dbResult.values())
|
||||||
|
return {"database": dbName, "tables": dbResult, "recordCount": recordCount, "warnings": warnings}
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,10 +1,14 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
# Action node executor - maps ai.*, email.*, sharepoint.*, clickup.*, file.*, trustee.* to method actions.
|
# Action node executor — maps ai.*, email.*, sharepoint.*, clickup.*, file.*, trustee.* to method actions.
|
||||||
#
|
#
|
||||||
# Typed Port System: explicit DataRefs / static parameters; optional ``documentList`` from input port 0
|
# Typed port system: parameters resolve via DataRefs / static values. Declarative port inheritance
|
||||||
# when the param is empty (same idea as IOExecutor wire fill).
|
# uses ``graphInherit`` on parameter definitions in node JSON (see STATIC_NODE_TYPES): e.g.
|
||||||
# ``materializeConnectionRefs`` (see pickNotPushMigration) may still rewrite empty connectionReference at run start.
|
# ``primaryTextRef`` is materialized to explicit refs in pickNotPushMigration.materializePrimaryTextHandover;
|
||||||
|
# ``documentListWire`` is applied at runtime in this executor via graphUtils.extract_wired_document_list.
|
||||||
|
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
|
@ -16,12 +20,125 @@ from modules.features.graphicalEditor.portTypes import (
|
||||||
)
|
)
|
||||||
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionInactiveException as _SubscriptionInactiveException
|
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionInactiveException as _SubscriptionInactiveException
|
||||||
from modules.serviceCenter.services.serviceBilling.mainServiceBilling import BillingContextError as _BillingContextError
|
from modules.serviceCenter.services.serviceBilling.mainServiceBilling import BillingContextError as _BillingContextError
|
||||||
|
from modules.workflows.automation2.executors.inputExecutor import PauseForHumanTaskError
|
||||||
|
from modules.workflows.methods.methodContext.actions.extractContent import (
|
||||||
|
PRESENTATION_KIND,
|
||||||
|
build_presentation_envelope_from_plain_text,
|
||||||
|
presentation_dict_without_meta,
|
||||||
|
presentation_response_text,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_FILE_CREATE_CTX_LOG_MAX = 500
|
||||||
|
|
||||||
|
|
||||||
|
def _attach_unified_presentation_data(out: Dict[str, Any], *, node_def: Dict[str, Any]) -> None:
|
||||||
|
"""Ensure ``out[\"data\"]`` carries ``context.extractContent.presentation.v1`` for ``file.create``."""
|
||||||
|
if node_def.get("skipUnifiedPresentation"):
|
||||||
|
return
|
||||||
|
data = out.get("data")
|
||||||
|
if isinstance(data, dict) and data.get("kind") == PRESENTATION_KIND:
|
||||||
|
return
|
||||||
|
text = str(out.get("response") or "").strip()
|
||||||
|
if not text and isinstance(data, dict):
|
||||||
|
text = str(data.get("response") or "").strip()
|
||||||
|
if not text:
|
||||||
|
return
|
||||||
|
pres = build_presentation_envelope_from_plain_text(text, source_name=node_type or "content")
|
||||||
|
if not pres:
|
||||||
|
return
|
||||||
|
meta: Dict[str, Any] = {"actionType": node_type}
|
||||||
|
if isinstance(data, dict):
|
||||||
|
prev = data.get("_meta")
|
||||||
|
if isinstance(prev, dict):
|
||||||
|
meta = {**prev, **meta}
|
||||||
|
out["data"] = {**pres, "_meta": meta}
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate_for_log(val: Any, max_len: int = _FILE_CREATE_CTX_LOG_MAX) -> str:
|
||||||
|
s = val if isinstance(val, str) else repr(val)
|
||||||
|
s = s.replace("\r", "\\r").replace("\n", "\\n")
|
||||||
|
if len(s) <= max_len:
|
||||||
|
return s
|
||||||
|
return s[:max_len] + f"...<{len(s)} chars>"
|
||||||
|
|
||||||
|
|
||||||
|
def _log_file_create_context_resolution(
|
||||||
|
node_id: str,
|
||||||
|
raw_params: Dict[str, Any],
|
||||||
|
resolved_params: Dict[str, Any],
|
||||||
|
exec_context: Dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Debug ``file.create`` when ``context`` resolves empty — trace refs and upstream output."""
|
||||||
|
raw_c = raw_params.get("context")
|
||||||
|
res_c = resolved_params.get("context")
|
||||||
|
node_outputs = exec_context.get("nodeOutputs") or {}
|
||||||
|
input_sources = (exec_context.get("inputSources") or {}).get(node_id) or {}
|
||||||
|
src_entry = input_sources.get(0)
|
||||||
|
src_id = src_entry[0] if src_entry else None
|
||||||
|
upstream = node_outputs.get(src_id) if src_id else None
|
||||||
|
|
||||||
|
up_summary = "missing"
|
||||||
|
up_resp_len = -1
|
||||||
|
up_transit = False
|
||||||
|
if isinstance(upstream, dict):
|
||||||
|
up_transit = bool(upstream.get("_transit"))
|
||||||
|
inner = upstream.get("data") if up_transit else upstream
|
||||||
|
up_keys = sorted(k for k in upstream.keys() if not str(k).startswith("_") or k in ("_transit", "_success"))
|
||||||
|
up_resp_len = len(str((inner if isinstance(inner, dict) else upstream).get("response") or ""))
|
||||||
|
up_summary = "keys=%s transit=%s response_len=%s _success=%s" % (
|
||||||
|
up_keys[:25],
|
||||||
|
up_transit,
|
||||||
|
up_resp_len,
|
||||||
|
upstream.get("_success"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _shape(name: str, v: Any) -> str:
|
||||||
|
if v is None:
|
||||||
|
return f"{name}=None"
|
||||||
|
if isinstance(v, dict) and v.get("type") == "ref":
|
||||||
|
return f"{name}=ref(nodeId={v.get('nodeId')!r}, path={v.get('path')!r})"
|
||||||
|
if isinstance(v, list):
|
||||||
|
if v and all(isinstance(x, dict) and x.get("type") == "ref" for x in v):
|
||||||
|
bits = [
|
||||||
|
f"ref({x.get('nodeId')!r},{x.get('path')!r})"
|
||||||
|
for x in v[:5]
|
||||||
|
]
|
||||||
|
return f"{name}=contextBuilder[{len(v)} refs: {', '.join(bits)}{'…' if len(v) > 5 else ''}]"
|
||||||
|
return f"{name}=list(len={len(v)}, elem0_type={type(v[0]).__name__})"
|
||||||
|
if isinstance(v, str):
|
||||||
|
return f"{name}=str(len={len(v)}, preview={_truncate_for_log(v, 240)!r})"
|
||||||
|
return f"{name}={type(v).__name__}({_truncate_for_log(v)!r})"
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"file.create context resolution node=%s port0=%r upstream_node=%s upstream: %s | %s | %s",
|
||||||
|
node_id,
|
||||||
|
src_id,
|
||||||
|
src_id,
|
||||||
|
up_summary,
|
||||||
|
_shape("raw", raw_c),
|
||||||
|
_shape("resolved", res_c),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_ascii_base64_payload(s: str) -> bool:
|
||||||
|
"""Heuristic: ActionDocument binary payloads use standard ASCII base64; markdown/text uses other chars (#, *, -, …)."""
|
||||||
|
t = "".join(s.split())
|
||||||
|
if len(t) < 8:
|
||||||
|
return False
|
||||||
|
if not t.isascii():
|
||||||
|
return False
|
||||||
|
return bool(re.fullmatch(r"[A-Za-z0-9+/]+=*", t)) and len(t) % 4 == 0
|
||||||
|
|
||||||
|
|
||||||
def _coerce_document_data_to_bytes(raw: Any) -> Optional[bytes]:
|
def _coerce_document_data_to_bytes(raw: Any) -> Optional[bytes]:
|
||||||
"""Normalize documentData (bytes/str/buffer) for DB file persistence."""
|
"""Normalize documentData for DB file persistence.
|
||||||
|
|
||||||
|
ActionDocument conventions (see methodFile.create): binary bodies are carried as ASCII
|
||||||
|
base64 strings; plain markdown/text stays as Unicode. Do not UTF-8-encode a base64
|
||||||
|
literal — that persists the ASCII of the encoding (file looks like base64 gibberish).
|
||||||
|
"""
|
||||||
if raw is None:
|
if raw is None:
|
||||||
return None
|
return None
|
||||||
if isinstance(raw, bytes):
|
if isinstance(raw, bytes):
|
||||||
|
|
@ -33,11 +150,67 @@ def _coerce_document_data_to_bytes(raw: Any) -> Optional[bytes]:
|
||||||
b = raw.tobytes()
|
b = raw.tobytes()
|
||||||
return b if len(b) > 0 else None
|
return b if len(b) > 0 else None
|
||||||
if isinstance(raw, str):
|
if isinstance(raw, str):
|
||||||
b = raw.encode("utf-8")
|
stripped = raw.strip()
|
||||||
|
if not stripped:
|
||||||
|
return None
|
||||||
|
if _looks_like_ascii_base64_payload(stripped):
|
||||||
|
try:
|
||||||
|
decoded = base64.b64decode(stripped, validate=True)
|
||||||
|
except (TypeError, binascii.Error, ValueError):
|
||||||
|
try:
|
||||||
|
decoded = base64.b64decode(stripped)
|
||||||
|
except (binascii.Error, ValueError):
|
||||||
|
decoded = b""
|
||||||
|
if decoded:
|
||||||
|
return decoded
|
||||||
|
b = stripped.encode("utf-8")
|
||||||
return b if len(b) > 0 else None
|
return b if len(b) > 0 else None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _image_documents_from_docs_list(docs_list: list) -> list:
|
||||||
|
"""All image/* ActionDocument dicts (generic — no assumptions about index 0)."""
|
||||||
|
return [
|
||||||
|
d for d in (docs_list or [])
|
||||||
|
if isinstance(d, dict) and str(d.get("mimeType") or "").strip().lower().startswith("image/")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _image_refs_from_extract_node_data(extract_data: Any) -> list:
|
||||||
|
"""Synthetic image document dicts from ``context.extractContent`` ``_meta.persistedImageArtifacts``."""
|
||||||
|
if not isinstance(extract_data, dict):
|
||||||
|
return []
|
||||||
|
meta = extract_data.get("_meta")
|
||||||
|
if not isinstance(meta, dict):
|
||||||
|
return []
|
||||||
|
arts = meta.get("persistedImageArtifacts")
|
||||||
|
if not isinstance(arts, list):
|
||||||
|
return []
|
||||||
|
out: list = []
|
||||||
|
for a in arts:
|
||||||
|
if not isinstance(a, dict):
|
||||||
|
continue
|
||||||
|
fid = a.get("fileId")
|
||||||
|
if not fid:
|
||||||
|
continue
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"documentName": a.get("fileName") or f"extract_image_{fid}",
|
||||||
|
"mimeType": str(a.get("mimeType") or "application/octet-stream"),
|
||||||
|
"documentData": None,
|
||||||
|
"fileId": str(fid),
|
||||||
|
"_hasBinaryData": True,
|
||||||
|
"validationMetadata": {
|
||||||
|
"actionType": "context.extractContent",
|
||||||
|
"handoverRole": "extractedMedia",
|
||||||
|
"suppressInWorkflowFileLists": True,
|
||||||
|
"sourcePartId": a.get("sourcePartId"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
_USER_CONNECTION_ID_RE = re.compile(
|
_USER_CONNECTION_ID_RE = re.compile(
|
||||||
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
|
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
|
|
@ -174,6 +347,13 @@ def _buildConnectionRefDict(connRef: str, chatService, services) -> Optional[Dic
|
||||||
return {"id": conn_id, "authority": authority, "label": label or f"{authority}:{user}"}
|
return {"id": conn_id, "authority": authority, "label": label or f"{authority}:{user}"}
|
||||||
|
|
||||||
|
|
||||||
|
def _schemaCarriesConnectionProvenance(outputSchema: str) -> bool:
|
||||||
|
"""True iff the port schema declares ``carriesConnectionProvenance`` in the catalog."""
|
||||||
|
from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG
|
||||||
|
schema = PORT_TYPE_CATALOG.get(outputSchema)
|
||||||
|
return bool(getattr(schema, "carriesConnectionProvenance", False))
|
||||||
|
|
||||||
|
|
||||||
def _attachConnectionProvenance(
|
def _attachConnectionProvenance(
|
||||||
out: Dict[str, Any],
|
out: Dict[str, Any],
|
||||||
resolvedParams: Dict[str, Any],
|
resolvedParams: Dict[str, Any],
|
||||||
|
|
@ -187,7 +367,7 @@ def _attachConnectionProvenance(
|
||||||
cref = resolvedParams.get("connectionReference")
|
cref = resolvedParams.get("connectionReference")
|
||||||
if not cref:
|
if not cref:
|
||||||
return
|
return
|
||||||
if outputSchema not in ("FileList", "DocumentList", "EmailList", "TaskList", "EmailDraft", "UdmDocument"):
|
if not _schemaCarriesConnectionProvenance(outputSchema):
|
||||||
return
|
return
|
||||||
payload = _buildConnectionRefDict(str(cref), chatService, services)
|
payload = _buildConnectionRefDict(str(cref), chatService, services)
|
||||||
if payload:
|
if payload:
|
||||||
|
|
@ -203,8 +383,7 @@ def _resolveConnectionParam(params: Dict, chatService, services) -> None:
|
||||||
params["connectionReference"] = resolved
|
params["connectionReference"] = resolved
|
||||||
|
|
||||||
|
|
||||||
def _applyEmailCheckFilter(params: Dict) -> None:
|
def _mapper_emailCheckFilter(params: Dict, **_) -> None:
|
||||||
"""Build filter from discrete email params for email.checkEmail."""
|
|
||||||
built = _buildEmailFilter(
|
built = _buildEmailFilter(
|
||||||
fromAddress=params.get("fromAddress"),
|
fromAddress=params.get("fromAddress"),
|
||||||
subjectContains=params.get("subjectContains"),
|
subjectContains=params.get("subjectContains"),
|
||||||
|
|
@ -216,8 +395,7 @@ def _applyEmailCheckFilter(params: Dict) -> None:
|
||||||
params.pop(k, None)
|
params.pop(k, None)
|
||||||
|
|
||||||
|
|
||||||
def _applyEmailSearchQuery(params: Dict) -> None:
|
def _mapper_emailSearchQuery(params: Dict, **_) -> None:
|
||||||
"""Build query from discrete email params for email.searchEmail."""
|
|
||||||
built = _buildSearchQuery(
|
built = _buildSearchQuery(
|
||||||
query=params.get("query"),
|
query=params.get("query"),
|
||||||
fromAddress=params.get("fromAddress"),
|
fromAddress=params.get("fromAddress"),
|
||||||
|
|
@ -232,6 +410,56 @@ def _applyEmailSearchQuery(params: Dict) -> None:
|
||||||
params.pop(k, None)
|
params.pop(k, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _mapper_aiPromptLegacyAlias(params: Dict, **_) -> None:
|
||||||
|
"""Backwards-compatible alias: legacy ``prompt`` parameter is exposed as ``aiPrompt``."""
|
||||||
|
if "aiPrompt" not in params and "prompt" in params:
|
||||||
|
params["aiPrompt"] = params.pop("prompt")
|
||||||
|
|
||||||
|
|
||||||
|
def _mapper_emailDraftContextFromSubjectBody(params: Dict, **_) -> None:
|
||||||
|
"""Build ``context`` from discrete subject + body fields and drop them."""
|
||||||
|
subject = params.get("subject", "")
|
||||||
|
body = params.get("body", "")
|
||||||
|
if not (subject or body):
|
||||||
|
return
|
||||||
|
parts = []
|
||||||
|
if subject:
|
||||||
|
parts.append(f"Subject: {subject}")
|
||||||
|
if body:
|
||||||
|
parts.append(f"Body:\n{body}")
|
||||||
|
params["context"] = "\n\n".join(parts)
|
||||||
|
params.pop("subject", None)
|
||||||
|
params.pop("body", None)
|
||||||
|
|
||||||
|
|
||||||
|
def _mapper_clickupTaskUpdateMerge(params: Dict, **_) -> None:
|
||||||
|
from modules.workflows.automation2.clickupTaskUpdateMerge import merge_clickup_task_update_entries
|
||||||
|
merge_clickup_task_update_entries(params)
|
||||||
|
|
||||||
|
|
||||||
|
_PARAM_MAPPERS: Dict[str, Any] = {
|
||||||
|
"emailCheckFilter": _mapper_emailCheckFilter,
|
||||||
|
"emailSearchQuery": _mapper_emailSearchQuery,
|
||||||
|
"aiPromptLegacyAlias": _mapper_aiPromptLegacyAlias,
|
||||||
|
"emailDraftContextFromSubjectBody": _mapper_emailDraftContextFromSubjectBody,
|
||||||
|
"clickupTaskUpdateMerge": _mapper_clickupTaskUpdateMerge,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _applyParamMappers(nodeDef: Dict[str, Any], resolvedParams: Dict[str, Any]) -> None:
|
||||||
|
"""Run declared ``paramMappers`` from the node definition (no node-id branching)."""
|
||||||
|
mappers = nodeDef.get("paramMappers") or []
|
||||||
|
for name in mappers:
|
||||||
|
fn = _PARAM_MAPPERS.get(name)
|
||||||
|
if not fn:
|
||||||
|
logger.warning("Unknown paramMapper %r — node %s; skipping", name, nodeDef.get("id"))
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
fn(resolvedParams)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("paramMapper %r failed for node %s: %s", name, nodeDef.get("id"), e)
|
||||||
|
|
||||||
|
|
||||||
def _getOutputSchemaName(nodeDef: Dict) -> str:
|
def _getOutputSchemaName(nodeDef: Dict) -> str:
|
||||||
"""Get the output schema name from the node definition."""
|
"""Get the output schema name from the node definition."""
|
||||||
outputPorts = nodeDef.get("outputPorts", {})
|
outputPorts = nodeDef.get("outputPorts", {})
|
||||||
|
|
@ -239,76 +467,55 @@ def _getOutputSchemaName(nodeDef: Dict) -> str:
|
||||||
return port0.get("schema", "ActionResult")
|
return port0.get("schema", "ActionResult")
|
||||||
|
|
||||||
|
|
||||||
def _extract_wired_document_list(inp: Any) -> Optional[Dict[str, Any]]:
|
def _resolveUpstreamPayload(nodeId: str, context: Dict[str, Any]) -> Any:
|
||||||
|
"""Return the unwrapped output of the primary inbound wire to ``nodeId``.
|
||||||
|
|
||||||
|
Prefer logical input port 0. Some persisted graphs register the only edge
|
||||||
|
under a non-zero ``targetInput`` — fall back to the sole inbound port or
|
||||||
|
the first ``connectionMap`` entry so ``injectUpstreamPayload`` (e.g.
|
||||||
|
``context.mergeContext`` after ``flow.loop``) still receives data.
|
||||||
"""
|
"""
|
||||||
Build a DocumentList-shaped dict from upstream node output (matches IOExecutor wire behavior).
|
from modules.features.graphicalEditor.switchOutput import unwrap_transit_for_port
|
||||||
Handles DocumentList, human upload shapes (file / files / fileIds), FileList, loop file items.
|
|
||||||
During flow.loop body execution the loop node's output is
|
|
||||||
{items, count, currentItem, currentIndex}; wired document actions must use currentItem.
|
|
||||||
"""
|
|
||||||
if inp is None:
|
|
||||||
return None
|
|
||||||
from modules.features.graphicalEditor.portTypes import (
|
|
||||||
unwrapTransit,
|
|
||||||
_coerce_document_list_upload_fields,
|
|
||||||
_file_record_to_document,
|
|
||||||
)
|
|
||||||
|
|
||||||
data = unwrapTransit(inp)
|
nodeOutputs = context.get("nodeOutputs") or {}
|
||||||
if isinstance(data, str):
|
connectionMap = context.get("connectionMap") or {}
|
||||||
one = _file_record_to_document(data)
|
src_map = (context.get("inputSources") or {}).get(nodeId) or {}
|
||||||
return {"documents": [one], "count": 1} if one else None
|
|
||||||
if not isinstance(data, dict):
|
entry = src_map.get(0)
|
||||||
return None
|
if not entry and src_map:
|
||||||
d = dict(data)
|
if len(src_map) == 1:
|
||||||
_coerce_document_list_upload_fields(d)
|
entry = next(iter(src_map.values()))
|
||||||
# Per-iteration payload from executionEngine (flow.loop → downstream in loop body)
|
else:
|
||||||
if "currentItem" in d:
|
mi = min(src_map.keys())
|
||||||
ci = d.get("currentItem")
|
entry = src_map.get(mi)
|
||||||
if ci is not None:
|
if not entry and connectionMap.get(nodeId):
|
||||||
nested = _extract_wired_document_list(ci)
|
inc = connectionMap[nodeId]
|
||||||
if nested:
|
if inc:
|
||||||
return nested
|
src_node_id, _so, _ti = inc[0]
|
||||||
docs = d.get("documents")
|
entry = (src_node_id, _so)
|
||||||
if isinstance(docs, list) and len(docs) > 0:
|
|
||||||
return {"documents": docs, "count": d.get("count", len(docs))}
|
if not entry:
|
||||||
raw_list = d.get("documentList")
|
|
||||||
if isinstance(raw_list, list) and len(raw_list) > 0 and isinstance(raw_list[0], dict):
|
|
||||||
return {"documents": raw_list, "count": len(raw_list)}
|
|
||||||
doc_id = d.get("documentId") or d.get("id")
|
|
||||||
if doc_id and str(doc_id).strip():
|
|
||||||
one: Dict[str, Any] = {"id": str(doc_id).strip()}
|
|
||||||
fn = d.get("fileName") or d.get("name")
|
|
||||||
if fn:
|
|
||||||
one["name"] = str(fn)
|
|
||||||
mt = d.get("mimeType")
|
|
||||||
if mt:
|
|
||||||
one["mimeType"] = str(mt)
|
|
||||||
return {"documents": [one], "count": 1}
|
|
||||||
files = d.get("files")
|
|
||||||
if isinstance(files, list) and files:
|
|
||||||
collected = []
|
|
||||||
for item in files:
|
|
||||||
conv = _file_record_to_document(item) if isinstance(item, dict) else None
|
|
||||||
if conv:
|
|
||||||
collected.append(conv)
|
|
||||||
if collected:
|
|
||||||
return {"documents": collected, "count": len(collected)}
|
|
||||||
return None
|
return None
|
||||||
|
src_node_id, src_out = entry
|
||||||
|
upstream = nodeOutputs.get(src_node_id)
|
||||||
|
return unwrap_transit_for_port(upstream, src_out)
|
||||||
|
|
||||||
|
|
||||||
def _document_list_param_is_empty(val: Any) -> bool:
|
def _resolveBranchInputs(nodeId: str, context: Dict[str, Any]) -> Dict[int, Any]:
|
||||||
if val is None or val == "":
|
"""Return ``Dict[port_index → unwrapped upstream output]`` for every wired input port."""
|
||||||
return True
|
from modules.features.graphicalEditor.switchOutput import unwrap_transit_for_port
|
||||||
if isinstance(val, list) and len(val) == 0:
|
src_map = (context.get("inputSources") or {}).get(nodeId) or {}
|
||||||
return True
|
nodeOutputs = context.get("nodeOutputs") or {}
|
||||||
if isinstance(val, dict):
|
out: Dict[int, Any] = {}
|
||||||
if val.get("documents") or val.get("references") or val.get("items"):
|
for port_ix, entry in src_map.items():
|
||||||
return False
|
if not entry:
|
||||||
if val.get("documentId") or val.get("id"):
|
continue
|
||||||
return False
|
src_node_id, src_out = entry
|
||||||
return True
|
upstream = nodeOutputs.get(src_node_id)
|
||||||
return False
|
if upstream is None:
|
||||||
|
continue
|
||||||
|
out[int(port_ix)] = unwrap_transit_for_port(upstream, src_out)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
class ActionNodeExecutor:
|
class ActionNodeExecutor:
|
||||||
|
|
@ -323,7 +530,11 @@ class ActionNodeExecutor:
|
||||||
context: Dict[str, Any],
|
context: Dict[str, Any],
|
||||||
) -> Any:
|
) -> Any:
|
||||||
from modules.features.graphicalEditor.nodeRegistry import getNodeTypeToMethodAction
|
from modules.features.graphicalEditor.nodeRegistry import getNodeTypeToMethodAction
|
||||||
from modules.workflows.automation2.graphUtils import resolveParameterReferences
|
from modules.workflows.automation2.graphUtils import (
|
||||||
|
document_list_param_is_empty,
|
||||||
|
extract_wired_document_list,
|
||||||
|
resolveParameterReferences,
|
||||||
|
)
|
||||||
from modules.workflows.processing.core.actionExecutor import ActionExecutor
|
from modules.workflows.processing.core.actionExecutor import ActionExecutor
|
||||||
|
|
||||||
nodeType = node.get("type", "")
|
nodeType = node.get("type", "")
|
||||||
|
|
@ -343,7 +554,12 @@ class ActionNodeExecutor:
|
||||||
# 1. Resolve parameters (DataRef, SystemVar, Static)
|
# 1. Resolve parameters (DataRef, SystemVar, Static)
|
||||||
params = dict(node.get("parameters") or {})
|
params = dict(node.get("parameters") or {})
|
||||||
logger.debug("ActionNodeExecutor node %s raw params keys=%s", nodeId, list(params.keys()))
|
logger.debug("ActionNodeExecutor node %s raw params keys=%s", nodeId, list(params.keys()))
|
||||||
resolvedParams = resolveParameterReferences(params, context.get("nodeOutputs", {}))
|
resolvedParams = resolveParameterReferences(
|
||||||
|
params,
|
||||||
|
context.get("nodeOutputs", {}),
|
||||||
|
consumer_node_id=nodeId,
|
||||||
|
input_sources=context.get("inputSources"),
|
||||||
|
)
|
||||||
logger.debug("ActionNodeExecutor node %s resolved params keys=%s documentList_present=%s documentList_type=%s", nodeId, list(resolvedParams.keys()), "documentList" in resolvedParams, type(resolvedParams.get("documentList")).__name__)
|
logger.debug("ActionNodeExecutor node %s resolved params keys=%s documentList_present=%s documentList_type=%s", nodeId, list(resolvedParams.keys()), "documentList" in resolvedParams, type(resolvedParams.get("documentList")).__name__)
|
||||||
|
|
||||||
# 2. Apply defaults from parameter definitions
|
# 2. Apply defaults from parameter definitions
|
||||||
|
|
@ -352,29 +568,45 @@ class ActionNodeExecutor:
|
||||||
if pName and pName not in resolvedParams and "default" in pDef:
|
if pName and pName not in resolvedParams and "default" in pDef:
|
||||||
resolvedParams[pName] = pDef["default"]
|
resolvedParams[pName] = pDef["default"]
|
||||||
|
|
||||||
_param_names = {p.get("name") for p in nodeDef.get("parameters", []) if p.get("name")}
|
for pDef in nodeDef.get("parameters") or []:
|
||||||
if "documentList" in _param_names and _document_list_param_is_empty(resolvedParams.get("documentList")):
|
gi = pDef.get("graphInherit") or {}
|
||||||
|
if gi.get("kind") != "documentListWire":
|
||||||
|
continue
|
||||||
|
pname = pDef.get("name")
|
||||||
|
if not pname or not document_list_param_is_empty(resolvedParams.get(pname)):
|
||||||
|
continue
|
||||||
|
port_ix = int(gi.get("port", 0))
|
||||||
_src_map = (context.get("inputSources") or {}).get(nodeId) or {}
|
_src_map = (context.get("inputSources") or {}).get(nodeId) or {}
|
||||||
_entry = _src_map.get(0)
|
_entry = _src_map.get(port_ix)
|
||||||
if _entry:
|
if not _entry:
|
||||||
|
continue
|
||||||
_src_node_id, _ = _entry
|
_src_node_id, _ = _entry
|
||||||
_upstream = (context.get("nodeOutputs") or {}).get(_src_node_id)
|
_upstream = (context.get("nodeOutputs") or {}).get(_src_node_id)
|
||||||
_wired = _extract_wired_document_list(_upstream)
|
_wired = extract_wired_document_list(_upstream)
|
||||||
if _wired:
|
if _wired:
|
||||||
resolvedParams["documentList"] = _wired
|
resolvedParams[pname] = _wired
|
||||||
|
|
||||||
# 3. Resolve connectionReference
|
# 3. Resolve connectionReference
|
||||||
chatService = getattr(self.services, "chat", None)
|
chatService = getattr(self.services, "chat", None)
|
||||||
_resolveConnectionParam(resolvedParams, chatService, self.services)
|
_resolveConnectionParam(resolvedParams, chatService, self.services)
|
||||||
|
|
||||||
# 4. Node-type-specific param transformations
|
# 3b. Optional graph-level injections declared on the node definition.
|
||||||
if nodeType == "email.checkEmail":
|
# - injectUpstreamPayload: True → ``_upstreamPayload`` (port 0 source output, transit-unwrapped)
|
||||||
_applyEmailCheckFilter(resolvedParams)
|
# - injectBranchInputs: True → ``_branchInputs`` (Dict[port_index, output] for all wired ports)
|
||||||
elif nodeType == "email.searchEmail":
|
# - injectRunContext: True → ``_runContext`` (the live execution context dict)
|
||||||
_applyEmailSearchQuery(resolvedParams)
|
if nodeDef.get("injectUpstreamPayload"):
|
||||||
elif nodeType == "clickup.updateTask":
|
resolvedParams["_upstreamPayload"] = _resolveUpstreamPayload(nodeId, context)
|
||||||
from modules.workflows.automation2.clickupTaskUpdateMerge import merge_clickup_task_update_entries
|
if nodeDef.get("injectBranchInputs"):
|
||||||
merge_clickup_task_update_entries(resolvedParams)
|
resolvedParams["_branchInputs"] = _resolveBranchInputs(nodeId, context)
|
||||||
|
if nodeDef.get("injectRunContext"):
|
||||||
|
resolvedParams["_runContext"] = context
|
||||||
|
resolvedParams["_workflowNodeId"] = nodeId
|
||||||
|
|
||||||
|
# 4. Apply declarative paramMappers from the node definition
|
||||||
|
_applyParamMappers(nodeDef, resolvedParams)
|
||||||
|
|
||||||
|
if nodeDef.get("logContextResolution"):
|
||||||
|
_log_file_create_context_resolution(nodeId, params, resolvedParams, context)
|
||||||
|
|
||||||
# 5. email.checkEmail pause for email wait
|
# 5. email.checkEmail pause for email wait
|
||||||
if nodeType == "email.checkEmail":
|
if nodeType == "email.checkEmail":
|
||||||
|
|
@ -391,26 +623,7 @@ class ActionNodeExecutor:
|
||||||
}
|
}
|
||||||
raise PauseForEmailWaitError(runId=runId, nodeId=nodeId, waitConfig=waitConfig)
|
raise PauseForEmailWaitError(runId=runId, nodeId=nodeId, waitConfig=waitConfig)
|
||||||
|
|
||||||
# 6. AI nodes: normalize legacy "prompt" -> "aiPrompt"
|
# 6. Create progress parent so nested actions have a hierarchy
|
||||||
if nodeType == "ai.prompt":
|
|
||||||
if "aiPrompt" not in resolvedParams and "prompt" in resolvedParams:
|
|
||||||
resolvedParams["aiPrompt"] = resolvedParams.pop("prompt")
|
|
||||||
|
|
||||||
# 7. Build context for email.draftEmail from subject + body
|
|
||||||
if nodeType == "email.draftEmail":
|
|
||||||
subject = resolvedParams.get("subject", "")
|
|
||||||
body = resolvedParams.get("body", "")
|
|
||||||
if subject or body:
|
|
||||||
contextParts = []
|
|
||||||
if subject:
|
|
||||||
contextParts.append(f"Subject: {subject}")
|
|
||||||
if body:
|
|
||||||
contextParts.append(f"Body:\n{body}")
|
|
||||||
resolvedParams["context"] = "\n\n".join(contextParts)
|
|
||||||
resolvedParams.pop("subject", None)
|
|
||||||
resolvedParams.pop("body", None)
|
|
||||||
|
|
||||||
# 8. Create progress parent so nested actions have a hierarchy
|
|
||||||
import time as _time
|
import time as _time
|
||||||
nodeOperationId = f"node_{nodeId}_{context.get('_runId', 'x')}_{int(_time.time())}"
|
nodeOperationId = f"node_{nodeId}_{context.get('_runId', 'x')}_{int(_time.time())}"
|
||||||
chatService = getattr(self.services, "chat", None)
|
chatService = getattr(self.services, "chat", None)
|
||||||
|
|
@ -440,10 +653,27 @@ class ActionNodeExecutor:
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# 9. Persist generated documents as files and build JSON-safe output
|
# 7. Persist generated documents as files and build JSON-safe output
|
||||||
|
_raw_folder_id = resolvedParams.get("folderId")
|
||||||
|
persist_folder_id: Optional[str] = None
|
||||||
|
if _raw_folder_id is not None:
|
||||||
|
_s = str(_raw_folder_id).strip()
|
||||||
|
if _s:
|
||||||
|
persist_folder_id = _s
|
||||||
|
|
||||||
docsList = []
|
docsList = []
|
||||||
for d in (result.documents or []):
|
for d in (result.documents or []):
|
||||||
dumped = d.model_dump() if hasattr(d, "model_dump") else dict(d) if isinstance(d, dict) else d
|
dumped = d.model_dump() if hasattr(d, "model_dump") else dict(d) if isinstance(d, dict) else d
|
||||||
|
if isinstance(dumped, dict):
|
||||||
|
_meta = dumped.get("validationMetadata") if isinstance(dumped.get("validationMetadata"), dict) else {}
|
||||||
|
_existing = dumped.get("fileId") or _meta.get("fileId")
|
||||||
|
# e.g. file.create already persisted inside the action — avoid a second FileItem with wrong bytes
|
||||||
|
if _existing and str(_existing).strip():
|
||||||
|
dumped["documentData"] = None
|
||||||
|
dumped.setdefault("_hasBinaryData", True)
|
||||||
|
docsList.append(dumped)
|
||||||
|
continue
|
||||||
|
|
||||||
rawData = getattr(d, "documentData", None) if hasattr(d, "documentData") else (dumped.get("documentData") if isinstance(dumped, dict) else None)
|
rawData = getattr(d, "documentData", None) if hasattr(d, "documentData") else (dumped.get("documentData") if isinstance(dumped, dict) else None)
|
||||||
rawBytes = _coerce_document_data_to_bytes(rawData)
|
rawBytes = _coerce_document_data_to_bytes(rawData)
|
||||||
if isinstance(dumped, dict) and rawBytes:
|
if isinstance(dumped, dict) and rawBytes:
|
||||||
|
|
@ -470,7 +700,7 @@ class ActionNodeExecutor:
|
||||||
_mgmt = _getMgmtInterface(_owner, mandateId=_mandateId, featureInstanceId=_instanceId)
|
_mgmt = _getMgmtInterface(_owner, mandateId=_mandateId, featureInstanceId=_instanceId)
|
||||||
_docName = dumped.get("documentName") or f"workflow-result-{nodeId}.bin"
|
_docName = dumped.get("documentName") or f"workflow-result-{nodeId}.bin"
|
||||||
_mimeType = dumped.get("mimeType") or "application/octet-stream"
|
_mimeType = dumped.get("mimeType") or "application/octet-stream"
|
||||||
_fileItem = _mgmt.createFile(_docName, _mimeType, rawBytes)
|
_fileItem = _mgmt.createFile(_docName, _mimeType, rawBytes, folderId=persist_folder_id)
|
||||||
_mgmt.createFileData(_fileItem.id, rawBytes)
|
_mgmt.createFileData(_fileItem.id, rawBytes)
|
||||||
dumped["fileId"] = _fileItem.id
|
dumped["fileId"] = _fileItem.id
|
||||||
dumped["id"] = _fileItem.id
|
dumped["id"] = _fileItem.id
|
||||||
|
|
@ -482,8 +712,8 @@ class ActionNodeExecutor:
|
||||||
logger.warning("Could not persist workflow document: %s", _fe)
|
logger.warning("Could not persist workflow document: %s", _fe)
|
||||||
docsList.append(dumped)
|
docsList.append(dumped)
|
||||||
|
|
||||||
# Clean DocumentList shape for document nodes (match file.create: documents + count, no AiResult fields)
|
# Clean DocumentList shape for document nodes (documents + count, no ActionResult/AiResult noise)
|
||||||
if outputSchema == "DocumentList" and nodeType in ("ai.generateDocument", "ai.convertDocument"):
|
if outputSchema == "DocumentList":
|
||||||
if not result.success:
|
if not result.success:
|
||||||
return _normalizeError(
|
return _normalizeError(
|
||||||
RuntimeError(str(result.error or "document action failed")),
|
RuntimeError(str(result.error or "document action failed")),
|
||||||
|
|
@ -497,17 +727,14 @@ class ActionNodeExecutor:
|
||||||
return normalizeToSchema(list_out, outputSchema)
|
return normalizeToSchema(list_out, outputSchema)
|
||||||
|
|
||||||
extractedContext = ""
|
extractedContext = ""
|
||||||
if result.documents:
|
rd_early = getattr(result, "data", None)
|
||||||
doc = result.documents[0]
|
if isinstance(rd_early, dict):
|
||||||
raw = getattr(doc, "documentData", None) if hasattr(doc, "documentData") else (doc.get("documentData") if isinstance(doc, dict) else None)
|
if rd_early.get("kind") == PRESENTATION_KIND:
|
||||||
if isinstance(raw, bytes):
|
extractedContext = presentation_response_text(presentation_dict_without_meta(rd_early)).strip()
|
||||||
try:
|
else:
|
||||||
extractedContext = raw.decode("utf-8").strip()
|
_r = rd_early.get("response")
|
||||||
except (UnicodeDecodeError, ValueError):
|
if _r is not None and str(_r).strip():
|
||||||
extractedContext = ""
|
extractedContext = str(_r).strip()
|
||||||
elif raw:
|
|
||||||
extractedContext = str(raw).strip()
|
|
||||||
|
|
||||||
promptText = str(resolvedParams.get("aiPrompt") or resolvedParams.get("prompt") or "").strip()
|
promptText = str(resolvedParams.get("aiPrompt") or resolvedParams.get("prompt") or "").strip()
|
||||||
|
|
||||||
resultData = getattr(result, "data", None)
|
resultData = getattr(result, "data", None)
|
||||||
|
|
@ -525,7 +752,7 @@ class ActionNodeExecutor:
|
||||||
"data": dataField,
|
"data": dataField,
|
||||||
}
|
}
|
||||||
|
|
||||||
if nodeType.startswith("ai."):
|
if outputSchema == "AiResult":
|
||||||
out["prompt"] = promptText
|
out["prompt"] = promptText
|
||||||
out["response"] = extractedContext
|
out["response"] = extractedContext
|
||||||
inputContext = resolvedParams.get("context")
|
inputContext = resolvedParams.get("context")
|
||||||
|
|
@ -541,8 +768,38 @@ class ActionNodeExecutor:
|
||||||
out["responseData"] = parsed
|
out["responseData"] = parsed
|
||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
if outputSchema == "AiResult" and result.success:
|
||||||
|
out["imageDocumentsOnly"] = _image_documents_from_docs_list(docsList)
|
||||||
|
|
||||||
if nodeType.startswith("clickup.") and result.success and docsList:
|
if outputSchema == "ActionResult":
|
||||||
|
# Unified handover: mirror AiResult primary paths for DataRefs / primaryTextRef
|
||||||
|
inp_ctx = resolvedParams.get("context")
|
||||||
|
ctx_str = ""
|
||||||
|
if inp_ctx is not None:
|
||||||
|
ctx_str = inp_ctx if isinstance(inp_ctx, str) else json.dumps(inp_ctx, ensure_ascii=False, default=str)
|
||||||
|
out.setdefault("prompt", "")
|
||||||
|
out.setdefault("context", ctx_str if ctx_str else "")
|
||||||
|
rsp = str(out.get("response") or "").strip()
|
||||||
|
if not rsp:
|
||||||
|
if nodeDef.get("clearResponse"):
|
||||||
|
out["response"] = ""
|
||||||
|
else:
|
||||||
|
out["response"] = extractedContext or ""
|
||||||
|
if result.success:
|
||||||
|
img_only = _image_documents_from_docs_list(docsList)
|
||||||
|
if nodeDef.get("imageDocumentsFromExtractData") and isinstance(result.data, dict):
|
||||||
|
img_only = list(img_only) + _image_refs_from_extract_node_data(result.data)
|
||||||
|
if nodeDef.get("imageDocumentsFromMerged") and isinstance(result.data, dict):
|
||||||
|
# mergeContext packs iterated image sidecars under ``data.merged.imageDocumentsOnly``
|
||||||
|
# rather than the top-level ``documents`` list which is always empty.
|
||||||
|
merged_blob = result.data.get("merged")
|
||||||
|
if isinstance(merged_blob, dict):
|
||||||
|
merged_imgs = merged_blob.get("imageDocumentsOnly")
|
||||||
|
if isinstance(merged_imgs, list) and merged_imgs:
|
||||||
|
img_only = merged_imgs
|
||||||
|
out["imageDocumentsOnly"] = img_only
|
||||||
|
|
||||||
|
if outputSchema == "TaskResult" and result.success and docsList:
|
||||||
try:
|
try:
|
||||||
d0 = docsList[0] if isinstance(docsList[0], dict) else {}
|
d0 = docsList[0] if isinstance(docsList[0], dict) else {}
|
||||||
raw = d0.get("documentData")
|
raw = d0.get("documentData")
|
||||||
|
|
@ -554,7 +811,7 @@ class ActionNodeExecutor:
|
||||||
except (json.JSONDecodeError, TypeError, ValueError):
|
except (json.JSONDecodeError, TypeError, ValueError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if outputSchema == "ConsolidateResult" and nodeType == "ai.consolidate":
|
if outputSchema == "ConsolidateResult":
|
||||||
data_dict = result.data if isinstance(getattr(result, "data", None), dict) else {}
|
data_dict = result.data if isinstance(getattr(result, "data", None), dict) else {}
|
||||||
cr_out = {
|
cr_out = {
|
||||||
"result": data_dict.get("result", ""),
|
"result": data_dict.get("result", ""),
|
||||||
|
|
@ -564,5 +821,22 @@ class ActionNodeExecutor:
|
||||||
_attachConnectionProvenance(cr_out, resolvedParams, outputSchema, chatService, self.services)
|
_attachConnectionProvenance(cr_out, resolvedParams, outputSchema, chatService, self.services)
|
||||||
return normalizeToSchema(cr_out, outputSchema)
|
return normalizeToSchema(cr_out, outputSchema)
|
||||||
|
|
||||||
|
if nodeDef.get("popDocumentsFromOutput"):
|
||||||
|
out.pop("documents", None)
|
||||||
|
|
||||||
|
if outputSchema in ("AiResult", "ActionResult") and result.success:
|
||||||
|
_attach_unified_presentation_data(out, node_def=nodeDef)
|
||||||
|
|
||||||
_attachConnectionProvenance(out, resolvedParams, outputSchema, chatService, self.services)
|
_attachConnectionProvenance(out, resolvedParams, outputSchema, chatService, self.services)
|
||||||
return normalizeToSchema(out, outputSchema)
|
|
||||||
|
# When the node declares ``surfaceDataAsTopLevel`` (typical for
|
||||||
|
# dynamic-schema context nodes whose output keys are graph-defined),
|
||||||
|
# surface ``data.<key>`` to ``out.<key>`` so DataRefs from downstream
|
||||||
|
# nodes hit the user-defined keys without needing a ``data.`` prefix.
|
||||||
|
if nodeDef.get("surfaceDataAsTopLevel") and isinstance(dataField, dict):
|
||||||
|
for k, v in dataField.items():
|
||||||
|
if k not in out and not str(k).startswith("_"):
|
||||||
|
out[k] = v
|
||||||
|
|
||||||
|
normalized_schema = outputSchema if isinstance(outputSchema, str) else "Transit"
|
||||||
|
return normalizeToSchema(out, normalized_schema)
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,9 @@
|
||||||
# Flow control node executor (ifElse, switch, loop, merge).
|
# Flow control node executor (ifElse, switch, loop, merge).
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from modules.features.graphicalEditor.conditionOperators import apply_condition_operator, resolve_value_kind
|
||||||
from modules.features.graphicalEditor.portTypes import wrapTransit, unwrapTransit
|
from modules.features.graphicalEditor.portTypes import wrapTransit, unwrapTransit
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -65,20 +66,29 @@ class FlowExecutor:
|
||||||
nodeId: str,
|
nodeId: str,
|
||||||
inputSources: Dict,
|
inputSources: Dict,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
condParam = (node.get("parameters") or {}).get("condition")
|
params = node.get("parameters") or {}
|
||||||
|
condParam = params.get("condition")
|
||||||
|
itemParam = params.get("Item")
|
||||||
inp = self._getInputData(nodeId, {nodeId: inputSources}, nodeOutputs)
|
inp = self._getInputData(nodeId, {nodeId: inputSources}, nodeOutputs)
|
||||||
ok = self._evalConditionParam(condParam, nodeOutputs)
|
ok = self._evalConditionParam(condParam, nodeOutputs, item_param=itemParam, node=node)
|
||||||
return wrapTransit(
|
return wrapTransit(
|
||||||
unwrapTransit(inp) if inp else inp,
|
unwrapTransit(inp) if inp else inp,
|
||||||
{"branch": 0 if ok else 1, "conditionResult": ok},
|
{"branch": 0 if ok else 1, "conditionResult": ok},
|
||||||
)
|
)
|
||||||
|
|
||||||
def _evalConditionParam(self, condParam: Any, nodeOutputs: Dict) -> bool:
|
def _evalConditionParam(
|
||||||
"""Evaluate condition: structured {type,ref,operator,value} or legacy string/ref."""
|
self,
|
||||||
|
condParam: Any,
|
||||||
|
nodeOutputs: Dict,
|
||||||
|
*,
|
||||||
|
item_param: Any = None,
|
||||||
|
node: Optional[Dict] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Evaluate condition: structured {operator,value} with Item dataRef, or legacy."""
|
||||||
if condParam is None:
|
if condParam is None:
|
||||||
return False
|
return False
|
||||||
if isinstance(condParam, dict) and condParam.get("type") == "condition":
|
if isinstance(condParam, dict) and condParam.get("type") == "condition":
|
||||||
return self._evalStructuredCondition(condParam, nodeOutputs)
|
return self._evalStructuredCondition(condParam, nodeOutputs, item_param=item_param, node=node)
|
||||||
from modules.workflows.automation2.graphUtils import resolveParameterReferences
|
from modules.workflows.automation2.graphUtils import resolveParameterReferences
|
||||||
resolved = resolveParameterReferences(condParam, nodeOutputs)
|
resolved = resolveParameterReferences(condParam, nodeOutputs)
|
||||||
return self._evalCondition(resolved)
|
return self._evalCondition(resolved)
|
||||||
|
|
@ -101,57 +111,45 @@ class FlowExecutor:
|
||||||
return None
|
return None
|
||||||
return current
|
return current
|
||||||
|
|
||||||
def _evalStructuredCondition(self, cond: Dict, nodeOutputs: Dict) -> bool:
|
def _evalStructuredCondition(
|
||||||
"""Evaluate structured {ref, operator, value} condition."""
|
self,
|
||||||
ref = cond.get("ref")
|
cond: Dict,
|
||||||
if not ref or ref.get("type") != "ref":
|
nodeOutputs: Dict,
|
||||||
return False
|
*,
|
||||||
node_id = ref.get("nodeId")
|
item_param: Any = None,
|
||||||
path = ref.get("path") or []
|
node: Optional[Dict] = None,
|
||||||
left = self._get_by_path(nodeOutputs.get(node_id), list(path))
|
) -> bool:
|
||||||
|
"""Evaluate structured {operator, value} with Item dataRef (legacy: condition.ref)."""
|
||||||
|
from modules.workflows.automation2.graphUtils import resolveParameterReferences
|
||||||
|
|
||||||
|
left_ref = item_param
|
||||||
|
if left_ref is None or (isinstance(left_ref, dict) and not left_ref):
|
||||||
|
left_ref = cond.get("ref")
|
||||||
|
left = resolveParameterReferences(left_ref, nodeOutputs) if left_ref is not None else None
|
||||||
operator = cond.get("operator", "eq")
|
operator = cond.get("operator", "eq")
|
||||||
right = cond.get("value")
|
right = cond.get("value")
|
||||||
|
|
||||||
if operator == "eq":
|
value_kind = "unknown"
|
||||||
return left == right
|
ref_for_kind = left_ref if isinstance(left_ref, dict) else cond.get("ref")
|
||||||
if operator == "neq":
|
if isinstance(ref_for_kind, dict) and ref_for_kind.get("nodeId") and node:
|
||||||
return left != right
|
graph_stub = self._graph_stub_for_ref(node, ref_for_kind, nodeOutputs)
|
||||||
if operator in ("lt", "lte", "gt", "gte"):
|
value_kind = resolve_value_kind(graph_stub, ref_for_kind)
|
||||||
try:
|
|
||||||
l, r = float(left) if left is not None else 0, float(right) if right is not None else 0
|
|
||||||
if operator == "lt":
|
|
||||||
return l < r
|
|
||||||
if operator == "lte":
|
|
||||||
return l <= r
|
|
||||||
if operator == "gt":
|
|
||||||
return l > r
|
|
||||||
if operator == "gte":
|
|
||||||
return l >= r
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return False
|
|
||||||
if operator == "contains":
|
|
||||||
return right is not None and str(right) in str(left or "")
|
|
||||||
if operator == "not_contains":
|
|
||||||
return right is None or str(right) not in str(left or "")
|
|
||||||
if operator == "empty":
|
|
||||||
return left is None or left == "" or (isinstance(left, (list, dict)) and len(left) == 0)
|
|
||||||
if operator == "not_empty":
|
|
||||||
return left is not None and left != "" and (not isinstance(left, (list, dict)) or len(left) > 0)
|
|
||||||
if operator == "is_true":
|
|
||||||
return bool(left)
|
|
||||||
if operator == "is_false":
|
|
||||||
return not bool(left)
|
|
||||||
if operator == "before":
|
|
||||||
return self._compare_dates(left, right, lambda a, b: a < b)
|
|
||||||
if operator == "after":
|
|
||||||
return self._compare_dates(left, right, lambda a, b: a > b)
|
|
||||||
if operator == "exists":
|
|
||||||
return self._file_exists(left)
|
|
||||||
if operator == "not_exists":
|
|
||||||
return not self._file_exists(left)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _compare_dates(self, left: Any, right: Any, op) -> bool:
|
return apply_condition_operator(left, str(operator), right, value_kind)
|
||||||
|
|
||||||
|
def _graph_stub_for_ref(self, node: Dict, ref: Dict, nodeOutputs: Dict) -> Dict[str, Any]:
|
||||||
|
"""Minimal graph for ``resolve_value_kind`` (includes value producer when known)."""
|
||||||
|
nodes: List[Dict[str, Any]] = [{"id": node.get("id"), "type": node.get("type")}]
|
||||||
|
producer_id = ref.get("nodeId")
|
||||||
|
if producer_id:
|
||||||
|
ctx = nodeOutputs.get("_context") if isinstance(nodeOutputs.get("_context"), dict) else {}
|
||||||
|
graph_nodes = ctx.get("graphNodesById") if isinstance(ctx.get("graphNodesById"), dict) else {}
|
||||||
|
pnode = graph_nodes.get(producer_id) if isinstance(graph_nodes, dict) else None
|
||||||
|
if isinstance(pnode, dict):
|
||||||
|
nodes.append({"id": producer_id, "type": pnode.get("type", "")})
|
||||||
|
else:
|
||||||
|
nodes.append({"id": producer_id, "type": ""})
|
||||||
|
return {"nodes": nodes, "targetNodeId": node.get("id")}
|
||||||
"""Compare left/right as dates; op(a,b) is the comparison."""
|
"""Compare left/right as dates; op(a,b) is the comparison."""
|
||||||
|
|
||||||
def parse(v):
|
def parse(v):
|
||||||
|
|
@ -208,23 +206,42 @@ class FlowExecutor:
|
||||||
return bool(resolved)
|
return bool(resolved)
|
||||||
|
|
||||||
async def _switch(self, node: Dict, nodeOutputs: Dict, nodeId: str, inputSources: Dict) -> Any:
|
async def _switch(self, node: Dict, nodeOutputs: Dict, nodeId: str, inputSources: Dict) -> Any:
|
||||||
valueExpr = (node.get("parameters") or {}).get("value", "")
|
params = node.get("parameters") or {}
|
||||||
|
valueExpr = params.get("value", "")
|
||||||
from modules.workflows.automation2.graphUtils import resolveParameterReferences
|
from modules.workflows.automation2.graphUtils import resolveParameterReferences
|
||||||
value = resolveParameterReferences(valueExpr, nodeOutputs)
|
from modules.features.graphicalEditor.switchOutput import (
|
||||||
cases = (node.get("parameters") or {}).get("cases", [])
|
build_switch_combined_output,
|
||||||
inp = self._getInputData(nodeId, {nodeId: inputSources}, nodeOutputs)
|
build_switch_default_payload,
|
||||||
for i, c in enumerate(cases):
|
|
||||||
if self._evalSwitchCase(value, c):
|
|
||||||
return wrapTransit(
|
|
||||||
unwrapTransit(inp) if inp else inp,
|
|
||||||
{"match": i, "value": value},
|
|
||||||
)
|
|
||||||
return wrapTransit(
|
|
||||||
unwrapTransit(inp) if inp else inp,
|
|
||||||
{"match": -1, "value": value},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _evalSwitchCase(self, left: Any, case: Any) -> bool:
|
value = resolveParameterReferences(valueExpr, nodeOutputs)
|
||||||
|
cases = params.get("cases", []) or []
|
||||||
|
value_kind = "unknown"
|
||||||
|
if isinstance(valueExpr, dict) and valueExpr.get("type") == "ref":
|
||||||
|
graph_stub = self._graph_stub_for_ref(node, valueExpr, nodeOutputs)
|
||||||
|
value_kind = resolve_value_kind(graph_stub, valueExpr)
|
||||||
|
inp = self._getInputData(nodeId, {nodeId: inputSources}, nodeOutputs)
|
||||||
|
matched: List[int] = [
|
||||||
|
i for i, c in enumerate(cases)
|
||||||
|
if self._evalSwitchCase(value, c, value_kind=value_kind)
|
||||||
|
]
|
||||||
|
default_idx = len(cases) if isinstance(cases, list) else 0
|
||||||
|
if not matched:
|
||||||
|
matched = [default_idx]
|
||||||
|
combined = build_switch_combined_output(
|
||||||
|
inp, cases, matched_indices=matched, value_kind=value_kind,
|
||||||
|
)
|
||||||
|
return wrapTransit(
|
||||||
|
combined,
|
||||||
|
{
|
||||||
|
"match": matched[0],
|
||||||
|
"matches": matched,
|
||||||
|
"value": value,
|
||||||
|
"filterApplied": bool(combined.get("filterApplied")),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _evalSwitchCase(self, left: Any, case: Any, *, value_kind: Optional[str] = None) -> bool:
|
||||||
"""
|
"""
|
||||||
Evaluate a switch case. Case can be:
|
Evaluate a switch case. Case can be:
|
||||||
- dict: {operator, value} - use operator to compare left vs value
|
- dict: {operator, value} - use operator to compare left vs value
|
||||||
|
|
@ -236,69 +253,90 @@ class FlowExecutor:
|
||||||
else:
|
else:
|
||||||
operator = "eq"
|
operator = "eq"
|
||||||
right = case
|
right = case
|
||||||
# Same logic as _evalStructuredCondition but with explicit left/right
|
return apply_condition_operator(left, str(operator), right, value_kind)
|
||||||
if operator == "eq":
|
|
||||||
return left == right
|
|
||||||
if operator == "neq":
|
|
||||||
return left != right
|
|
||||||
if operator in ("lt", "lte", "gt", "gte"):
|
|
||||||
try:
|
|
||||||
l, r = float(left) if left is not None else 0, float(right) if right is not None else 0
|
|
||||||
if operator == "lt":
|
|
||||||
return l < r
|
|
||||||
if operator == "lte":
|
|
||||||
return l <= r
|
|
||||||
if operator == "gt":
|
|
||||||
return l > r
|
|
||||||
if operator == "gte":
|
|
||||||
return l >= r
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return False
|
|
||||||
if operator == "contains":
|
|
||||||
return right is not None and str(right) in str(left or "")
|
|
||||||
if operator == "not_contains":
|
|
||||||
return right is None or str(right) not in str(left or "")
|
|
||||||
if operator == "empty":
|
|
||||||
return left is None or left == "" or (isinstance(left, (list, dict)) and len(left) == 0)
|
|
||||||
if operator == "not_empty":
|
|
||||||
return left is not None and left != "" and (not isinstance(left, (list, dict)) or len(left) > 0)
|
|
||||||
if operator == "is_true":
|
|
||||||
return bool(left)
|
|
||||||
if operator == "is_false":
|
|
||||||
return not bool(left)
|
|
||||||
if operator == "before":
|
|
||||||
return self._compare_dates(left, right, lambda a, b: a < b)
|
|
||||||
if operator == "after":
|
|
||||||
return self._compare_dates(left, right, lambda a, b: a > b)
|
|
||||||
if operator == "exists":
|
|
||||||
return self._file_exists(left)
|
|
||||||
if operator == "not_exists":
|
|
||||||
return not self._file_exists(left)
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def _loop(self, node: Dict, nodeOutputs: Dict, nodeId: str, inputSources: Dict) -> Any:
|
async def _loop(self, node: Dict, nodeOutputs: Dict, nodeId: str, inputSources: Dict) -> Any:
|
||||||
params = node.get("parameters") or {}
|
params = node.get("parameters") or {}
|
||||||
itemsPath = params.get("items", "[]")
|
itemsPath = params.get("items", "[]")
|
||||||
level = params.get("level", "auto")
|
|
||||||
from modules.workflows.automation2.graphUtils import resolveParameterReferences
|
from modules.workflows.automation2.graphUtils import resolveParameterReferences
|
||||||
items = resolveParameterReferences(itemsPath, nodeOutputs)
|
|
||||||
|
|
||||||
if level != "auto" and isinstance(items, dict):
|
raw = resolveParameterReferences(
|
||||||
items = self._resolveUdmLevel(items, level)
|
itemsPath,
|
||||||
elif isinstance(items, list):
|
nodeOutputs,
|
||||||
pass
|
consumer_node_id=nodeId,
|
||||||
elif isinstance(items, dict):
|
input_sources=inputSources,
|
||||||
children = items.get("children")
|
)
|
||||||
if isinstance(children, list) and children:
|
items = self._normalize_loop_items(raw)
|
||||||
items = children
|
mode = (params.get("iterationMode") or "all").strip().lower()
|
||||||
else:
|
stride = params.get("iterationStride", 2)
|
||||||
items = [{"name": k, "value": v} for k, v in items.items()]
|
try:
|
||||||
else:
|
stride_int = int(stride)
|
||||||
items = [items] if items is not None else []
|
except (TypeError, ValueError):
|
||||||
|
stride_int = 2
|
||||||
|
items = self._apply_iteration_mode(items, mode, stride_int)
|
||||||
return {"items": items, "count": len(items)}
|
return {"items": items, "count": len(items)}
|
||||||
|
|
||||||
|
def _normalize_loop_items(self, raw: Any) -> List[Any]:
|
||||||
|
"""Coerce resolved `items` into a list (lists, dict children, or scalars)."""
|
||||||
|
if isinstance(raw, dict) and isinstance(raw.get("items"), list):
|
||||||
|
return self._expand_presentation_lines_loop_items(raw["items"])
|
||||||
|
if isinstance(raw, list):
|
||||||
|
return self._expand_presentation_lines_loop_items(raw)
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
children = raw.get("children")
|
||||||
|
if isinstance(children, list) and len(children) > 0:
|
||||||
|
return self._expand_presentation_lines_loop_items(children)
|
||||||
|
items = [{"name": k, "value": v} for k, v in raw.items()]
|
||||||
|
return self._expand_presentation_lines_loop_items(items)
|
||||||
|
return [raw] if raw is not None else []
|
||||||
|
|
||||||
|
def _expand_presentation_lines_loop_items(self, items: List[Any]) -> List[Any]:
|
||||||
|
"""When looping ``presentation.files`` in ``lines`` mode, iterate per slot (e.g. CSV row)."""
|
||||||
|
if not items:
|
||||||
|
return items
|
||||||
|
expanded: List[Any] = []
|
||||||
|
saw_lines_bucket = False
|
||||||
|
for it in items:
|
||||||
|
if not isinstance(it, dict):
|
||||||
|
expanded.append(it)
|
||||||
|
continue
|
||||||
|
val = it.get("value")
|
||||||
|
if not isinstance(val, dict) or val.get("outputMode") != "lines":
|
||||||
|
expanded.append(it)
|
||||||
|
continue
|
||||||
|
data = val.get("data")
|
||||||
|
if not isinstance(data, list) or len(data) <= 1:
|
||||||
|
expanded.append(it)
|
||||||
|
continue
|
||||||
|
saw_lines_bucket = True
|
||||||
|
base_name = str(it.get("name") or val.get("sourceFileName") or "line")
|
||||||
|
for idx, slot in enumerate(data):
|
||||||
|
if not isinstance(slot, dict):
|
||||||
|
continue
|
||||||
|
sid = str(slot.get("id") or slot.get("label") or idx)
|
||||||
|
expanded.append({"name": f"{base_name}:{sid}", "value": slot})
|
||||||
|
return expanded if saw_lines_bucket else items
|
||||||
|
|
||||||
|
def _apply_iteration_mode(self, items: List[Any], mode: str, stride: int) -> List[Any]:
|
||||||
|
"""Select which elements to iterate over (backend-defined modes)."""
|
||||||
|
if not items:
|
||||||
|
return []
|
||||||
|
m = (mode or "all").strip().lower()
|
||||||
|
if m == "first":
|
||||||
|
return items[:1]
|
||||||
|
if m == "last":
|
||||||
|
return items[-1:]
|
||||||
|
if m == "every_second":
|
||||||
|
return items[::2]
|
||||||
|
if m == "every_third":
|
||||||
|
return items[::3]
|
||||||
|
if m == "every_nth":
|
||||||
|
step = max(2, min(100, int(stride)))
|
||||||
|
return items[::step]
|
||||||
|
return list(items)
|
||||||
|
|
||||||
def _resolveUdmLevel(self, udm: Dict, level: str) -> list:
|
def _resolveUdmLevel(self, udm: Dict, level: str) -> list:
|
||||||
"""Extract items from a UDM document/node at the requested structural level."""
|
"""Extract items from a UDM document/node at the requested structural level (test / tooling)."""
|
||||||
children = udm.get("children") or []
|
children = udm.get("children") or []
|
||||||
if level == "documents":
|
if level == "documents":
|
||||||
return [c for c in children if isinstance(c, dict) and c.get("role") in ("document", "archive")]
|
return [c for c in children if isinstance(c, dict) and c.get("role") in ("document", "archive")]
|
||||||
|
|
|
||||||
|
|
@ -65,16 +65,23 @@ class InputExecutor:
|
||||||
)
|
)
|
||||||
taskId = task.get("id")
|
taskId = task.get("id")
|
||||||
|
|
||||||
self.automation2.updateRun(
|
from modules.workflows.automation2.graphicalEditorRunFileLogger import merge_persisted_run_context
|
||||||
|
|
||||||
|
_pause_ctx = merge_persisted_run_context(
|
||||||
|
self.automation2,
|
||||||
runId,
|
runId,
|
||||||
status="paused",
|
{
|
||||||
nodeOutputs=context.get("nodeOutputs"),
|
|
||||||
currentNodeId=nodeId,
|
|
||||||
context={
|
|
||||||
"connectionMap": context.get("connectionMap"),
|
"connectionMap": context.get("connectionMap"),
|
||||||
"inputSources": context.get("inputSources"),
|
"inputSources": context.get("inputSources"),
|
||||||
"orderedNodeIds": [n.get("id") for n in context.get("_orderedNodes", []) if n.get("id")],
|
"orderedNodeIds": [n.get("id") for n in context.get("_orderedNodes", []) if n.get("id")],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
self.automation2.updateRun(
|
||||||
|
runId,
|
||||||
|
status="paused",
|
||||||
|
nodeOutputs=context.get("nodeOutputs"),
|
||||||
|
currentNodeId=nodeId,
|
||||||
|
context=_pause_ctx,
|
||||||
|
)
|
||||||
logger.info("InputExecutor node %s: created task %s, run %s paused", nodeId, taskId, runId)
|
logger.info("InputExecutor node %s: created task %s, run %s paused", nodeId, taskId, runId)
|
||||||
raise PauseForHumanTaskError(runId=runId, taskId=taskId, nodeId=nodeId)
|
raise PauseForHumanTaskError(runId=runId, taskId=taskId, nodeId=nodeId)
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ class IOExecutor:
|
||||||
nodeOutputs = context.get("nodeOutputs", {})
|
nodeOutputs = context.get("nodeOutputs", {})
|
||||||
params = dict(node.get("parameters") or {})
|
params = dict(node.get("parameters") or {})
|
||||||
|
|
||||||
from modules.workflows.automation2.graphUtils import resolveParameterReferences
|
from modules.workflows.automation2.graphUtils import extract_wired_document_list, resolveParameterReferences
|
||||||
resolvedParams = resolveParameterReferences(params, nodeOutputs)
|
resolvedParams = resolveParameterReferences(params, nodeOutputs)
|
||||||
logger.info("IOExecutor node %s resolvedParams keys=%s", nodeId, list(resolvedParams.keys()))
|
logger.info("IOExecutor node %s resolvedParams keys=%s", nodeId, list(resolvedParams.keys()))
|
||||||
|
|
||||||
|
|
@ -45,9 +45,7 @@ class IOExecutor:
|
||||||
if 0 in inputSources:
|
if 0 in inputSources:
|
||||||
srcId, _ = inputSources[0]
|
srcId, _ = inputSources[0]
|
||||||
inp = nodeOutputs.get(srcId)
|
inp = nodeOutputs.get(srcId)
|
||||||
from modules.workflows.automation2.executors.actionNodeExecutor import _extract_wired_document_list
|
wired = extract_wired_document_list(inp)
|
||||||
|
|
||||||
wired = _extract_wired_document_list(inp)
|
|
||||||
docs = (wired or {}).get("documents") if isinstance(wired, dict) else None
|
docs = (wired or {}).get("documents") if isinstance(wired, dict) else None
|
||||||
if docs:
|
if docs:
|
||||||
resolvedParams.setdefault("documentList", wired)
|
resolvedParams.setdefault("documentList", wired)
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ class TriggerExecutor:
|
||||||
context: Dict[str, Any],
|
context: Dict[str, Any],
|
||||||
) -> Any:
|
) -> Any:
|
||||||
node_id = node.get("id", "")
|
node_id = node.get("id", "")
|
||||||
|
node_type = str(node.get("type") or "")
|
||||||
base = context.get("runEnvelope")
|
base = context.get("runEnvelope")
|
||||||
if not isinstance(base, dict):
|
if not isinstance(base, dict):
|
||||||
out = normalize_run_envelope(None, user_id=context.get("userId"))
|
out = normalize_run_envelope(None, user_id=context.get("userId"))
|
||||||
|
|
@ -31,4 +32,11 @@ class TriggerExecutor:
|
||||||
node_id,
|
node_id,
|
||||||
(out.get("trigger") or {}).get("type"),
|
(out.get("trigger") or {}).get("type"),
|
||||||
)
|
)
|
||||||
|
# Form start: port schema is FormPayload — downstream refs use payload.<field>.
|
||||||
|
# Do not emit the full run envelope on this port.
|
||||||
|
if node_type == "trigger.form":
|
||||||
|
payload = out.get("payload")
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
payload = {}
|
||||||
|
return {"payload": payload, "_success": True}
|
||||||
return out
|
return out
|
||||||
|
|
|
||||||
|
|
@ -7,50 +7,6 @@ from typing import Dict, List, Any, Tuple, Set, Optional
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _ai_result_text_from_documents(d: Dict[str, Any]) -> Optional[str]:
|
|
||||||
"""Extract plain-text body from AiResult-style ``documents[0].documentData``."""
|
|
||||||
docs = d.get("documents")
|
|
||||||
if not isinstance(docs, list) or not docs:
|
|
||||||
return None
|
|
||||||
d0 = docs[0]
|
|
||||||
raw: Any = None
|
|
||||||
if isinstance(d0, dict):
|
|
||||||
raw = d0.get("documentData")
|
|
||||||
elif d0 is not None:
|
|
||||||
raw = getattr(d0, "documentData", None)
|
|
||||||
if raw is None:
|
|
||||||
return None
|
|
||||||
if isinstance(raw, bytes):
|
|
||||||
try:
|
|
||||||
t = raw.decode("utf-8").strip()
|
|
||||||
return t or None
|
|
||||||
except (UnicodeDecodeError, ValueError):
|
|
||||||
return None
|
|
||||||
if isinstance(raw, str):
|
|
||||||
s = raw.strip()
|
|
||||||
return s or None
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _ref_coalesce_empty_ai_result_text(data: Any, path: List[Any], resolved: Any) -> Any:
|
|
||||||
"""If a ref targets AiResult text fields but resolves empty/missing, fall back to documents.
|
|
||||||
|
|
||||||
Needed when: optional ``responseData`` is absent (no synthetic ``{}``), ``response`` is
|
|
||||||
still empty but ``documents`` hold the model output, or legacy graphs bind responseData only.
|
|
||||||
"""
|
|
||||||
if resolved not in (None, ""):
|
|
||||||
return resolved
|
|
||||||
if not isinstance(data, dict) or not path:
|
|
||||||
return resolved
|
|
||||||
head = path[0]
|
|
||||||
if head not in ("response", "responseData", "context"):
|
|
||||||
return resolved
|
|
||||||
if head == "context" and len(path) != 1:
|
|
||||||
return resolved
|
|
||||||
fb = _ai_result_text_from_documents(data)
|
|
||||||
return fb if fb is not None else resolved
|
|
||||||
|
|
||||||
|
|
||||||
def parseGraph(graph: Dict[str, Any]) -> Tuple[List[Dict], List[Dict], Set[str]]:
|
def parseGraph(graph: Dict[str, Any]) -> Tuple[List[Dict], List[Dict], Set[str]]:
|
||||||
"""
|
"""
|
||||||
Parse graph into nodes, connections, and node IDs.
|
Parse graph into nodes, connections, and node IDs.
|
||||||
|
|
@ -92,26 +48,93 @@ def buildConnectionMap(connections: List[Dict]) -> Dict[str, List[Tuple[str, int
|
||||||
|
|
||||||
|
|
||||||
def getLoopBodyNodeIds(loopNodeId: str, connectionMap: Dict[str, List[Tuple[str, int, int]]]) -> Set[str]:
|
def getLoopBodyNodeIds(loopNodeId: str, connectionMap: Dict[str, List[Tuple[str, int, int]]]) -> Set[str]:
|
||||||
"""Nodes reachable from loop's output (BFS forward). Body = downstream nodes that receive from loop."""
|
"""Nodes reachable from flow.loop output port 0 only (loop body), BFS forward.
|
||||||
|
|
||||||
|
Edges vom Rumpf zurück in den Loop-Knoten (gleicher Eingang wie der Hauptfluss) beenden die
|
||||||
|
Expansion am Loop-Knoten — der Loop-Knoten selbst ist nie Teil des Rumpfes.
|
||||||
|
"""
|
||||||
from collections import deque
|
from collections import deque
|
||||||
body = set()
|
|
||||||
# connectionMap: target -> [(source, sourceOutput, targetInput)]
|
body: Set[str] = set()
|
||||||
rev: Dict[str, List[str]] = {} # source -> [targets]
|
rev: Dict[str, List[Tuple[str, int, int]]] = {}
|
||||||
for tgt, pairs in connectionMap.items():
|
for tgt, pairs in connectionMap.items():
|
||||||
for src, _, _ in pairs:
|
for src, so, ti in pairs:
|
||||||
if src not in rev:
|
rev.setdefault(src, []).append((tgt, so, ti))
|
||||||
rev[src] = []
|
|
||||||
rev[src].append(tgt)
|
q: deque = deque()
|
||||||
q = deque([loopNodeId])
|
for tgt, so, ti in rev.get(loopNodeId, []):
|
||||||
|
if so != 0:
|
||||||
|
continue
|
||||||
|
if tgt == loopNodeId:
|
||||||
|
continue
|
||||||
|
q.append(tgt)
|
||||||
|
|
||||||
while q:
|
while q:
|
||||||
nid = q.popleft()
|
nid = q.popleft()
|
||||||
for tgt in rev.get(nid, []):
|
if nid == loopNodeId:
|
||||||
|
continue
|
||||||
|
if nid not in body:
|
||||||
|
body.add(nid)
|
||||||
|
for tgt, _so, _ti in rev.get(nid, []):
|
||||||
|
if tgt == loopNodeId:
|
||||||
|
continue
|
||||||
if tgt not in body:
|
if tgt not in body:
|
||||||
body.add(tgt)
|
|
||||||
q.append(tgt)
|
q.append(tgt)
|
||||||
return body
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def getLoopPrimaryInputSource(
|
||||||
|
loop_node_id: str,
|
||||||
|
connectionMap: Dict[str, List[Tuple[str, int, int]]],
|
||||||
|
body_ids: Set[str],
|
||||||
|
) -> Optional[Tuple[str, int]]:
|
||||||
|
"""Pick the inbound edge for ``flow.loop`` when several wires hit the same input (0).
|
||||||
|
|
||||||
|
The Schleifen-Rücklauf vom Rumpf und der „normale“ Vorgänger enden auf demselben Port;
|
||||||
|
für die Datenzusammenführung (Fertig-Ausgang, Logs) zählt der Vorgänger **außerhalb** des Rumpfes.
|
||||||
|
"""
|
||||||
|
incoming = connectionMap.get(loop_node_id, [])
|
||||||
|
candidates = [(src, so) for src, so, ti in incoming if ti == 0]
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
outside = [(src, so) for src, so in candidates if src not in body_ids]
|
||||||
|
if outside:
|
||||||
|
return outside[0]
|
||||||
|
return candidates[0]
|
||||||
|
|
||||||
|
|
||||||
|
def getLoopDoneNodeIds(loopNodeId: str, connectionMap: Dict[str, List[Tuple[str, int, int]]]) -> Set[str]:
|
||||||
|
"""Nodes reachable from flow.loop output port 1 (runs once after all iterations)."""
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
done: Set[str] = set()
|
||||||
|
rev: Dict[str, List[Tuple[str, int, int]]] = {}
|
||||||
|
for tgt, pairs in connectionMap.items():
|
||||||
|
for src, so, ti in pairs:
|
||||||
|
rev.setdefault(src, []).append((tgt, so, ti))
|
||||||
|
|
||||||
|
q: deque = deque()
|
||||||
|
for tgt, so, ti in rev.get(loopNodeId, []):
|
||||||
|
if so != 1:
|
||||||
|
continue
|
||||||
|
if tgt == loopNodeId:
|
||||||
|
continue
|
||||||
|
q.append(tgt)
|
||||||
|
|
||||||
|
while q:
|
||||||
|
nid = q.popleft()
|
||||||
|
if nid == loopNodeId:
|
||||||
|
continue
|
||||||
|
if nid not in done:
|
||||||
|
done.add(nid)
|
||||||
|
for tgt, _so, _ti in rev.get(nid, []):
|
||||||
|
if tgt == loopNodeId:
|
||||||
|
continue
|
||||||
|
if tgt not in done:
|
||||||
|
q.append(tgt)
|
||||||
|
return done
|
||||||
|
|
||||||
|
|
||||||
def getInputSources(nodeId: str, connectionMap: Dict[str, List[Tuple[str, int, int]]]) -> Dict[int, Tuple[str, int]]:
|
def getInputSources(nodeId: str, connectionMap: Dict[str, List[Tuple[str, int, int]]]) -> Dict[int, Tuple[str, int]]:
|
||||||
"""
|
"""
|
||||||
For a node, return targetInput -> (sourceNodeId, sourceOutput).
|
For a node, return targetInput -> (sourceNodeId, sourceOutput).
|
||||||
|
|
@ -123,8 +146,15 @@ def getInputSources(nodeId: str, connectionMap: Dict[str, List[Tuple[str, int, i
|
||||||
|
|
||||||
|
|
||||||
def getTriggerNodes(nodes: List[Dict]) -> List[Dict]:
|
def getTriggerNodes(nodes: List[Dict]) -> List[Dict]:
|
||||||
"""Return nodes with category=trigger or type starting with trigger."""
|
"""Return start/trigger nodes: type ``trigger.*``, or category ``trigger`` / ``start``."""
|
||||||
return [n for n in nodes if (n.get("type", "").startswith("trigger.") or n.get("category") == "trigger")]
|
return [
|
||||||
|
n
|
||||||
|
for n in nodes
|
||||||
|
if (
|
||||||
|
str(n.get("type", "")).startswith("trigger.")
|
||||||
|
or n.get("category") in ("trigger", "start")
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def validateGraph(graph: Dict[str, Any], nodeTypeIds: Set[str]) -> List[str]:
|
def validateGraph(graph: Dict[str, Any], nodeTypeIds: Set[str]) -> List[str]:
|
||||||
|
|
@ -163,6 +193,11 @@ def validateGraph(graph: Dict[str, Any], nodeTypeIds: Set[str]) -> List[str]:
|
||||||
logger.warning("validateGraph port mismatches: %s", port_errors)
|
logger.warning("validateGraph port mismatches: %s", port_errors)
|
||||||
errors.extend(port_errors)
|
errors.extend(port_errors)
|
||||||
|
|
||||||
|
if nodes and not getTriggerNodes(nodes):
|
||||||
|
errors.append(
|
||||||
|
"Workflow has no start node: add a node from the Start category before running."
|
||||||
|
)
|
||||||
|
|
||||||
if errors:
|
if errors:
|
||||||
logger.debug("validateGraph errors: %s", errors)
|
logger.debug("validateGraph errors: %s", errors)
|
||||||
else:
|
else:
|
||||||
|
|
@ -218,6 +253,8 @@ def _checkPortCompatibility(
|
||||||
continue
|
continue
|
||||||
srcOutputPorts = srcDef.get("outputPorts", {})
|
srcOutputPorts = srcDef.get("outputPorts", {})
|
||||||
srcPort = srcOutputPorts.get(srcOut, {}) or {}
|
srcPort = srcOutputPorts.get(srcOut, {}) or {}
|
||||||
|
if srcNode.get("type") == "flow.switch" and not srcPort.get("schema"):
|
||||||
|
srcPort = srcOutputPorts.get(0, {}) or srcPort
|
||||||
tgtPort = tgtInputPorts.get(tgtIn, {}) or {}
|
tgtPort = tgtInputPorts.get(tgtIn, {}) or {}
|
||||||
|
|
||||||
if not isinstance(srcPort, dict):
|
if not isinstance(srcPort, dict):
|
||||||
|
|
@ -229,6 +266,9 @@ def _checkPortCompatibility(
|
||||||
continue
|
continue
|
||||||
if src_schema in accepts:
|
if src_schema in accepts:
|
||||||
continue
|
continue
|
||||||
|
# ContextBranch is a typed Transit envelope (switch filtered branches).
|
||||||
|
if src_schema == "ContextBranch" and ("Transit" in accepts or "ContextBranch" in accepts):
|
||||||
|
continue
|
||||||
# Port that only declares Transit behaves as an untyped sink (legacy graphs).
|
# Port that only declares Transit behaves as an untyped sink (legacy graphs).
|
||||||
if len(accepts) == 1 and accepts[0] == "Transit":
|
if len(accepts) == 1 and accepts[0] == "Transit":
|
||||||
continue
|
continue
|
||||||
|
|
@ -374,12 +414,21 @@ def _unwrapTypedRef(value: Any) -> Any:
|
||||||
return value.get(primary, value)
|
return value.get(primary, value)
|
||||||
|
|
||||||
|
|
||||||
def resolveParameterReferences(value: Any, nodeOutputs: Dict[str, Any]) -> Any:
|
def resolveParameterReferences(
|
||||||
|
value: Any,
|
||||||
|
nodeOutputs: Dict[str, Any],
|
||||||
|
*,
|
||||||
|
consumer_node_id: Optional[str] = None,
|
||||||
|
input_sources: Optional[Dict[str, Dict[int, tuple]]] = None,
|
||||||
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
Resolve parameter references:
|
Resolve parameter references:
|
||||||
- {{nodeId.output}} or {{nodeId.output.path}} in strings (legacy)
|
- {{nodeId.output}} or {{nodeId.output.path}} in strings (legacy)
|
||||||
- { "type": "ref", "nodeId": "...", "path": ["field", "nested"] } -> resolved value
|
- { "type": "ref", "nodeId": "...", "path": ["field", "nested"] } -> resolved value
|
||||||
- { "type": "value", "value": ... } -> value (then recursively resolve)
|
- { "type": "value", "value": ... } -> value (then recursively resolve)
|
||||||
|
|
||||||
|
When ``consumer_node_id`` and ``input_sources`` are set, refs to the wired
|
||||||
|
upstream switch use that connection's output port (per-branch payload).
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
|
@ -395,11 +444,23 @@ def resolveParameterReferences(value: Any, nodeOutputs: Dict[str, Any]) -> Any:
|
||||||
path = value.get("path")
|
path = value.get("path")
|
||||||
if node_id is not None and isinstance(path, (list, tuple)):
|
if node_id is not None and isinstance(path, (list, tuple)):
|
||||||
data = nodeOutputs.get(node_id)
|
data = nodeOutputs.get(node_id)
|
||||||
# Unwrap transit envelopes to access the real data
|
wired = None
|
||||||
if isinstance(data, dict) and data.get("_transit"):
|
if consumer_node_id and input_sources:
|
||||||
|
wired = (input_sources.get(consumer_node_id) or {}).get(0)
|
||||||
|
if wired and wired[0] == node_id:
|
||||||
|
from modules.features.graphicalEditor.switchOutput import unwrap_transit_for_port
|
||||||
|
data = unwrap_transit_for_port(data, wired[1])
|
||||||
|
elif isinstance(data, dict) and data.get("_transit"):
|
||||||
data = data.get("data", data)
|
data = data.get("data", data)
|
||||||
plist = list(path)
|
plist = list(path)
|
||||||
resolved = _get_by_path(data, plist)
|
resolved = _get_by_path(data, plist)
|
||||||
|
if resolved is None:
|
||||||
|
from modules.workflows.automation2.pickNotPushMigration import (
|
||||||
|
remap_stale_presentation_ref_path,
|
||||||
|
)
|
||||||
|
alt_path = remap_stale_presentation_ref_path(plist)
|
||||||
|
if alt_path != plist:
|
||||||
|
resolved = _get_by_path(data, alt_path)
|
||||||
if resolved is None and isinstance(data, dict) and plist:
|
if resolved is None and isinstance(data, dict) and plist:
|
||||||
if plist[0] == "payload" and len(plist) > 1:
|
if plist[0] == "payload" and len(plist) > 1:
|
||||||
# Strip explicit "payload" prefix (legacy DataPicker paths)
|
# Strip explicit "payload" prefix (legacy DataPicker paths)
|
||||||
|
|
@ -408,17 +469,34 @@ def resolveParameterReferences(value: Any, nodeOutputs: Dict[str, Any]) -> Any:
|
||||||
# Form nodes store fields under {"payload": {fieldName: …}}.
|
# Form nodes store fields under {"payload": {fieldName: …}}.
|
||||||
# DataPicker emits bare field paths like ["url"]; try under payload.
|
# DataPicker emits bare field paths like ["url"]; try under payload.
|
||||||
resolved = _get_by_path(data["payload"], plist)
|
resolved = _get_by_path(data["payload"], plist)
|
||||||
resolved = _ref_coalesce_empty_ai_result_text(data, plist, resolved)
|
return resolveParameterReferences(
|
||||||
return resolveParameterReferences(resolved, nodeOutputs)
|
resolved,
|
||||||
|
nodeOutputs,
|
||||||
|
consumer_node_id=consumer_node_id,
|
||||||
|
input_sources=input_sources,
|
||||||
|
)
|
||||||
return value
|
return value
|
||||||
if value.get("type") == "value":
|
if value.get("type") == "value":
|
||||||
inner = value.get("value")
|
inner = value.get("value")
|
||||||
return resolveParameterReferences(inner, nodeOutputs)
|
return resolveParameterReferences(
|
||||||
|
inner,
|
||||||
|
nodeOutputs,
|
||||||
|
consumer_node_id=consumer_node_id,
|
||||||
|
input_sources=input_sources,
|
||||||
|
)
|
||||||
if value.get("type") == "system":
|
if value.get("type") == "system":
|
||||||
variable = value.get("variable", "")
|
variable = value.get("variable", "")
|
||||||
from modules.features.graphicalEditor.portTypes import resolveSystemVariable
|
from modules.features.graphicalEditor.portTypes import resolveSystemVariable
|
||||||
return resolveSystemVariable(variable, nodeOutputs.get("_context", {}))
|
return resolveSystemVariable(variable, nodeOutputs.get("_context", {}))
|
||||||
return {k: resolveParameterReferences(v, nodeOutputs) for k, v in value.items()}
|
return {
|
||||||
|
k: resolveParameterReferences(
|
||||||
|
v,
|
||||||
|
nodeOutputs,
|
||||||
|
consumer_node_id=consumer_node_id,
|
||||||
|
input_sources=input_sources,
|
||||||
|
)
|
||||||
|
for k, v in value.items()
|
||||||
|
}
|
||||||
|
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
def repl(m):
|
def repl(m):
|
||||||
|
|
@ -455,10 +533,97 @@ def resolveParameterReferences(value: Any, nodeOutputs: Dict[str, Any]) -> Any:
|
||||||
return re.sub(r"\{\{\s*([^}]+)\s*\}\}", repl, value)
|
return re.sub(r"\{\{\s*([^}]+)\s*\}\}", repl, value)
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
# contextBuilder: list where every item is a `{"type":"ref",...}` envelope.
|
# contextBuilder: list where every item is a `{"type":"ref",...}` envelope.
|
||||||
# Resolve each ref and join the serialised parts into a single prompt string.
|
# Resolve each part; a single ref preserves the resolved type (str, list, dict).
|
||||||
if value and all(isinstance(v, dict) and v.get("type") == "ref" for v in value):
|
if value and all(isinstance(v, dict) and v.get("type") == "ref" for v in value):
|
||||||
from modules.workflows.methods.methodAi._common import serialize_context
|
resolved_parts = [
|
||||||
parts = [serialize_context(resolveParameterReferences(v, nodeOutputs)) for v in value]
|
resolveParameterReferences(
|
||||||
return "\n\n".join(p for p in parts if p)
|
v,
|
||||||
return [resolveParameterReferences(v, nodeOutputs) for v in value]
|
nodeOutputs,
|
||||||
|
consumer_node_id=consumer_node_id,
|
||||||
|
input_sources=input_sources,
|
||||||
|
)
|
||||||
|
for v in value
|
||||||
|
]
|
||||||
|
if len(resolved_parts) == 1:
|
||||||
|
return resolved_parts[0]
|
||||||
|
return resolved_parts
|
||||||
|
return [
|
||||||
|
resolveParameterReferences(
|
||||||
|
v,
|
||||||
|
nodeOutputs,
|
||||||
|
consumer_node_id=consumer_node_id,
|
||||||
|
input_sources=input_sources,
|
||||||
|
)
|
||||||
|
for v in value
|
||||||
|
]
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def document_list_param_is_empty(val: Any) -> bool:
|
||||||
|
"""True when a documentList-style parameter has not been set (wire + DataRef may fill)."""
|
||||||
|
if val is None or val == "":
|
||||||
|
return True
|
||||||
|
if isinstance(val, list) and len(val) == 0:
|
||||||
|
return True
|
||||||
|
if isinstance(val, dict):
|
||||||
|
if val.get("documents") or val.get("references") or val.get("items"):
|
||||||
|
return False
|
||||||
|
if val.get("documentId") or val.get("id"):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def extract_wired_document_list(inp: Any) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Build a DocumentList-shaped dict from an upstream node output (port wire).
|
||||||
|
Used when a parameter declares ``graphInherit.kind == "documentListWire"``.
|
||||||
|
"""
|
||||||
|
if inp is None:
|
||||||
|
return None
|
||||||
|
from modules.features.graphicalEditor.portTypes import (
|
||||||
|
unwrapTransit,
|
||||||
|
_coerce_document_list_upload_fields,
|
||||||
|
_file_record_to_document,
|
||||||
|
)
|
||||||
|
|
||||||
|
data = unwrapTransit(inp)
|
||||||
|
if isinstance(data, str):
|
||||||
|
one = _file_record_to_document(data)
|
||||||
|
return {"documents": [one], "count": 1} if one else None
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None
|
||||||
|
d = dict(data)
|
||||||
|
_coerce_document_list_upload_fields(d)
|
||||||
|
if "currentItem" in d:
|
||||||
|
ci = d.get("currentItem")
|
||||||
|
if ci is not None:
|
||||||
|
nested = extract_wired_document_list(ci)
|
||||||
|
if nested:
|
||||||
|
return nested
|
||||||
|
docs = d.get("documents")
|
||||||
|
if isinstance(docs, list) and len(docs) > 0:
|
||||||
|
return {"documents": docs, "count": d.get("count", len(docs))}
|
||||||
|
raw_list = d.get("documentList")
|
||||||
|
if isinstance(raw_list, list) and len(raw_list) > 0 and isinstance(raw_list[0], dict):
|
||||||
|
return {"documents": raw_list, "count": len(raw_list)}
|
||||||
|
doc_id = d.get("documentId") or d.get("id")
|
||||||
|
if doc_id and str(doc_id).strip():
|
||||||
|
one: Dict[str, Any] = {"id": str(doc_id).strip()}
|
||||||
|
fn = d.get("fileName") or d.get("name")
|
||||||
|
if fn:
|
||||||
|
one["name"] = str(fn)
|
||||||
|
mt = d.get("mimeType")
|
||||||
|
if mt:
|
||||||
|
one["mimeType"] = str(mt)
|
||||||
|
return {"documents": [one], "count": 1}
|
||||||
|
files = d.get("files")
|
||||||
|
if isinstance(files, list) and files:
|
||||||
|
collected = []
|
||||||
|
for item in files:
|
||||||
|
conv = _file_record_to_document(item) if isinstance(item, dict) else None
|
||||||
|
if conv:
|
||||||
|
collected.append(conv)
|
||||||
|
if collected:
|
||||||
|
return {"documents": collected, "count": len(collected)}
|
||||||
|
return None
|
||||||
|
|
|
||||||
215
modules/workflows/automation2/graphicalEditorRunFileLogger.py
Normal file
215
modules/workflows/automation2/graphicalEditorRunFileLogger.py
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
"""Per-run NDJSON logs for persisted Automation2 / graphical-editor runs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from modules.shared.configuration import APP_CONFIG
|
||||||
|
from modules.shared.debugLogger import ensureDir, resolve_app_log_dir
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
RUN_FILE_LOG_RELATIVE_ROOT = "graphical_editor_runs"
|
||||||
|
CONTEXT_KEY = "_geRunFileLogRelativeDir"
|
||||||
|
EXECUTION_FILENAME = "node_execution.ndjson"
|
||||||
|
CONTEXT_SNAPSHOT_FILENAME = "workflow_context.ndjson"
|
||||||
|
|
||||||
|
|
||||||
|
def graphical_editor_run_file_logging_enabled() -> bool:
|
||||||
|
"""True when NDJSON files should be written for each persisted run."""
|
||||||
|
raw = APP_CONFIG.get("APP_GRAPHICAL_EDITOR_RUN_FILE_LOGGING", False)
|
||||||
|
if isinstance(raw, bool):
|
||||||
|
return raw
|
||||||
|
s = str(raw).strip().lower()
|
||||||
|
return s in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
|
|
||||||
|
def merge_run_context_with_ge_log_prefix(
|
||||||
|
base_context: Optional[Dict[str, Any]],
|
||||||
|
incoming: Dict[str, Any],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Copy ``CONTEXT_KEY`` from *base_context* onto *incoming* if present (pause paths)."""
|
||||||
|
out = dict(incoming or {})
|
||||||
|
prev = (base_context or {}).get(CONTEXT_KEY)
|
||||||
|
if prev is not None:
|
||||||
|
out[CONTEXT_KEY] = prev
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def merge_persisted_run_context(
|
||||||
|
automation2_interface: Any,
|
||||||
|
run_id: str,
|
||||||
|
replacement: Dict[str, Any],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""``{**db_context, **replacement}`` so *_geRunFileLogRelativeDir* and other keys survive pause updates."""
|
||||||
|
prev = dict((automation2_interface.getRun(run_id) or {}).get("context") or {})
|
||||||
|
return {**prev, **(replacement or {})}
|
||||||
|
|
||||||
|
|
||||||
|
class GraphicalEditorRunFileLogger:
|
||||||
|
"""Append-only NDJSON log for one run folder under ``resolve_app_log_dir()``."""
|
||||||
|
|
||||||
|
__slots__ = ("_exec_path", "_ctx_path", "_lock", "_run_id")
|
||||||
|
|
||||||
|
def __init__(self, run_id: str, absolute_run_dir: str) -> None:
|
||||||
|
self._run_id = run_id
|
||||||
|
ensureDir(absolute_run_dir)
|
||||||
|
self._exec_path = os.path.join(absolute_run_dir, EXECUTION_FILENAME)
|
||||||
|
self._ctx_path = os.path.join(absolute_run_dir, CONTEXT_SNAPSHOT_FILENAME)
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def run_id(self) -> str:
|
||||||
|
return self._run_id
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def fresh_run_subdirectory_name(run_id: str) -> str:
|
||||||
|
ts = datetime.now(timezone.utc).strftime("%Y_%m_%d_%H_%M_%S")
|
||||||
|
return f"{ts}__{run_id}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def relative_run_path(subdir_name: str) -> str:
|
||||||
|
"""Path relative to ``APP_LOGGING_LOG_DIR`` (POSIX-style segments)."""
|
||||||
|
return "/".join((RUN_FILE_LOG_RELATIVE_ROOT, subdir_name))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def bootstrap_new_run(cls, automation2_interface: Any, run_id: str, run_context: Dict[str, Any]) -> GraphicalEditorRunFileLogger | None:
|
||||||
|
"""Create filesystem folder + persist CONTEXT_KEY via ``updateRun``."""
|
||||||
|
if not graphical_editor_run_file_logging_enabled():
|
||||||
|
return None
|
||||||
|
if not automation2_interface or not run_id:
|
||||||
|
return None
|
||||||
|
subdir = cls.fresh_run_subdirectory_name(run_id)
|
||||||
|
rel = cls.relative_run_path(subdir)
|
||||||
|
base = resolve_app_log_dir()
|
||||||
|
absolute = os.path.join(base, RUN_FILE_LOG_RELATIVE_ROOT, subdir)
|
||||||
|
|
||||||
|
merged = dict(run_context or {})
|
||||||
|
merged[CONTEXT_KEY] = rel
|
||||||
|
try:
|
||||||
|
automation2_interface.updateRun(run_id, context=merged)
|
||||||
|
except Exception as ex:
|
||||||
|
logger.warning("GeRunFileLog: could not persist log dir on run=%s: %s", run_id, ex)
|
||||||
|
return None
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"GeRunFileLog: created run folder %s (run=%s)",
|
||||||
|
absolute,
|
||||||
|
run_id,
|
||||||
|
)
|
||||||
|
return cls(run_id, absolute)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def open_from_run_record(cls, automation2_interface: Any, run_id: str) -> GraphicalEditorRunFileLogger | None:
|
||||||
|
"""Open logger for an existing run using CONTEXT_KEY from DB."""
|
||||||
|
if not graphical_editor_run_file_logging_enabled():
|
||||||
|
return None
|
||||||
|
if not automation2_interface or not run_id:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
run = automation2_interface.getRun(run_id) or {}
|
||||||
|
except Exception as ex:
|
||||||
|
logger.debug("GeRunFileLog: getRun failed run=%s: %s", run_id, ex)
|
||||||
|
return None
|
||||||
|
rel = (run.get("context") or {}).get(CONTEXT_KEY)
|
||||||
|
if not rel or not isinstance(rel, str):
|
||||||
|
return None
|
||||||
|
base_norm = os.path.realpath(resolve_app_log_dir())
|
||||||
|
allowed_root = os.path.realpath(os.path.join(base_norm, RUN_FILE_LOG_RELATIVE_ROOT))
|
||||||
|
cand = os.path.realpath(os.path.join(base_norm, *rel.replace("\\", "/").split("/")))
|
||||||
|
if cand != allowed_root and not cand.startswith(allowed_root + os.sep):
|
||||||
|
logger.warning(
|
||||||
|
"GeRunFileLog: path outside log root denied for run=%s rel=%s",
|
||||||
|
run_id,
|
||||||
|
rel,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
absolute = cand
|
||||||
|
return cls(run_id, absolute)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def find_existing_absolute_dir(cls, run_id: str) -> Optional[str]:
|
||||||
|
"""If a folder named ``*{timestamp}__{run_id}`` exists under the log root, return its absolute path."""
|
||||||
|
root = os.path.realpath(os.path.join(resolve_app_log_dir(), RUN_FILE_LOG_RELATIVE_ROOT))
|
||||||
|
if not os.path.isdir(root):
|
||||||
|
return None
|
||||||
|
suffix = f"__{run_id}"
|
||||||
|
try:
|
||||||
|
names = sorted((n for n in os.listdir(root) if n.endswith(suffix)), reverse=True)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
if not names:
|
||||||
|
return None
|
||||||
|
cand = os.path.realpath(os.path.join(root, names[0]))
|
||||||
|
allowed_root = root
|
||||||
|
if cand != allowed_root and not cand.startswith(allowed_root + os.sep):
|
||||||
|
return None
|
||||||
|
return cand if os.path.isdir(cand) else None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def ensure_attached(cls, automation2_interface: Any, run_id: str) -> GraphicalEditorRunFileLogger | None:
|
||||||
|
"""Open logger from DB, or reattach an on-disk folder for *run_id*, or create a new one."""
|
||||||
|
opened = cls.open_from_run_record(automation2_interface, run_id)
|
||||||
|
if opened is not None:
|
||||||
|
return opened
|
||||||
|
if not graphical_editor_run_file_logging_enabled():
|
||||||
|
return None
|
||||||
|
if not automation2_interface or not run_id:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
run = automation2_interface.getRun(run_id) or {}
|
||||||
|
except Exception as ex:
|
||||||
|
logger.debug("GeRunFileLog: ensure getRun failed run=%s: %s", run_id, ex)
|
||||||
|
return None
|
||||||
|
prev_ctx = dict(run.get("context") or {})
|
||||||
|
|
||||||
|
existing_abs = cls.find_existing_absolute_dir(run_id)
|
||||||
|
if existing_abs:
|
||||||
|
base_norm = os.path.realpath(resolve_app_log_dir())
|
||||||
|
rel = os.path.relpath(existing_abs, base_norm).replace(os.sep, "/")
|
||||||
|
merged = {**prev_ctx, CONTEXT_KEY: rel}
|
||||||
|
try:
|
||||||
|
automation2_interface.updateRun(run_id, context=merged)
|
||||||
|
except Exception as ex:
|
||||||
|
logger.warning("GeRunFileLog: reattach persist failed run=%s: %s", run_id, ex)
|
||||||
|
return None
|
||||||
|
logger.info("GeRunFileLog: reattached existing folder for run=%s -> %s", run_id, existing_abs)
|
||||||
|
return cls(run_id, existing_abs)
|
||||||
|
|
||||||
|
subdir = cls.fresh_run_subdirectory_name(run_id)
|
||||||
|
rel = cls.relative_run_path(subdir)
|
||||||
|
base = resolve_app_log_dir()
|
||||||
|
absolute = os.path.join(base, RUN_FILE_LOG_RELATIVE_ROOT, subdir)
|
||||||
|
merged = {**prev_ctx, CONTEXT_KEY: rel}
|
||||||
|
try:
|
||||||
|
automation2_interface.updateRun(run_id, context=merged)
|
||||||
|
except Exception as ex:
|
||||||
|
logger.warning("GeRunFileLog: ensure new folder persist failed run=%s: %s", run_id, ex)
|
||||||
|
return None
|
||||||
|
logger.info("GeRunFileLog: created late attach folder %s (run=%s)", absolute, run_id)
|
||||||
|
return cls(run_id, absolute)
|
||||||
|
|
||||||
|
async def append_node_execution_line(self, record: Dict[str, Any]) -> None:
|
||||||
|
line = json.dumps(record, ensure_ascii=False, default=str)
|
||||||
|
async with self._lock:
|
||||||
|
try:
|
||||||
|
with open(self._exec_path, "a", encoding="utf-8") as f:
|
||||||
|
f.write(line + "\n")
|
||||||
|
except Exception as ex:
|
||||||
|
logger.warning("GeRunFileLog: append execution failed run=%s: %s", self._run_id, ex)
|
||||||
|
|
||||||
|
async def append_context_snapshot_line(self, record: Dict[str, Any]) -> None:
|
||||||
|
line = json.dumps(record, ensure_ascii=False, default=str)
|
||||||
|
async with self._lock:
|
||||||
|
try:
|
||||||
|
with open(self._ctx_path, "a", encoding="utf-8") as f:
|
||||||
|
f.write(line + "\n")
|
||||||
|
except Exception as ex:
|
||||||
|
logger.warning("GeRunFileLog: append context snapshot failed run=%s: %s", self._run_id, ex)
|
||||||
|
|
@ -1,18 +1,26 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
"""
|
"""
|
||||||
Graph helpers for Pick-not-Push: materialize connectionReference as explicit DataRefs.
|
Graph helpers for Pick-not-Push: materialize typed DataRefs before executeGraph runs.
|
||||||
|
|
||||||
Runtime: executeGraph deep-copies the version graph and applies materialize_connection_refs
|
- ``materializeConnectionRefs``: empty ``connectionReference`` from upstream connection provenance.
|
||||||
so downstream nodes resolve connection UUIDs from upstream output.connection.id.
|
- ``materializePrimaryTextHandover``: parameters whose static definition includes
|
||||||
|
``graphInherit.kind == "primaryTextRef"`` (canonical paths: ``PRIMARY_TEXT_HANDOVER_REF_PATH``).
|
||||||
|
- ``materializeRecommendedDataPickRef``: parameters with ``graphInherit.kind == "recommendedDataPickRef"``
|
||||||
|
use the upstream output port's ``dataPickOptions`` entry with ``recommended: true``.
|
||||||
|
|
||||||
|
Runtime: executeGraph deep-copies the version graph and applies these passes in order.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import copy
|
import copy
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES
|
from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES
|
||||||
from modules.features.graphicalEditor.portTypes import resolve_output_schema_name
|
from modules.features.graphicalEditor.portTypes import (
|
||||||
|
PRIMARY_TEXT_HANDOVER_REF_PATH,
|
||||||
|
resolve_output_schema_name,
|
||||||
|
)
|
||||||
from modules.workflows.automation2.graphUtils import buildConnectionMap, getInputSources
|
from modules.workflows.automation2.graphUtils import buildConnectionMap, getInputSources
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -81,3 +89,207 @@ def materializeConnectionRefs(graph: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
logger.debug("materializeConnectionRefs: %s.connectionReference -> ref %s.connection.id", nid, src_id)
|
logger.debug("materializeConnectionRefs: %s.connectionReference -> ref %s.connection.id", nid, src_id)
|
||||||
|
|
||||||
return g
|
return g
|
||||||
|
|
||||||
|
|
||||||
|
def _slot_empty_for_primary_text_inherit(val: Any) -> bool:
|
||||||
|
return val is None or val == "" or val == []
|
||||||
|
|
||||||
|
|
||||||
|
def materializePrimaryTextHandover(graph: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
For parameters declaring ``graphInherit.kind == "primaryTextRef"`` (optional ``port``, default 0) with an
|
||||||
|
empty value, set an explicit ``DataRef`` to the canonical text field of the producer on
|
||||||
|
that port (see ``PRIMARY_TEXT_HANDOVER_REF_PATH`` keyed by upstream output schema name).
|
||||||
|
"""
|
||||||
|
g = copy.deepcopy(graph)
|
||||||
|
nodes: List[Dict[str, Any]] = g.get("nodes") or []
|
||||||
|
connections = g.get("connections") or []
|
||||||
|
if not nodes:
|
||||||
|
return g
|
||||||
|
|
||||||
|
conn_map = buildConnectionMap(connections)
|
||||||
|
node_by_id = {n["id"]: n for n in nodes if n.get("id")}
|
||||||
|
|
||||||
|
for node in nodes:
|
||||||
|
nid = node.get("id")
|
||||||
|
ntype = node.get("type")
|
||||||
|
if not nid or not ntype:
|
||||||
|
continue
|
||||||
|
node_def = _NODE_DEF_BY_ID.get(ntype)
|
||||||
|
if not node_def:
|
||||||
|
continue
|
||||||
|
params = node.get("parameters")
|
||||||
|
if not isinstance(params, dict):
|
||||||
|
node["parameters"] = {}
|
||||||
|
params = node["parameters"]
|
||||||
|
|
||||||
|
for pdef in node_def.get("parameters") or []:
|
||||||
|
gi = pdef.get("graphInherit")
|
||||||
|
if not isinstance(gi, dict) or gi.get("kind") != "primaryTextRef":
|
||||||
|
continue
|
||||||
|
pname = pdef.get("name")
|
||||||
|
if not pname:
|
||||||
|
continue
|
||||||
|
port_ix = int(gi.get("port", 0))
|
||||||
|
if not _slot_empty_for_primary_text_inherit(params.get(pname)):
|
||||||
|
continue
|
||||||
|
input_sources = getInputSources(nid, conn_map)
|
||||||
|
if port_ix not in input_sources:
|
||||||
|
continue
|
||||||
|
src_id, _ = input_sources[port_ix]
|
||||||
|
src_node = node_by_id.get(src_id) or {}
|
||||||
|
src_def = _NODE_DEF_BY_ID.get(src_node.get("type") or "")
|
||||||
|
if not src_def:
|
||||||
|
continue
|
||||||
|
out_port = (src_def.get("outputPorts") or {}).get(0, {}) or {}
|
||||||
|
out_schema = resolve_output_schema_name(src_node, out_port if isinstance(out_port, dict) else {})
|
||||||
|
# Port-level override takes precedence over the schema-wide default path.
|
||||||
|
# Example: context.extractContent sets primaryTextRefPath=["data"] because
|
||||||
|
# its ``response`` field is intentionally empty.
|
||||||
|
ref_path = (
|
||||||
|
out_port.get("primaryTextRefPath")
|
||||||
|
if isinstance(out_port, dict) and out_port.get("primaryTextRefPath")
|
||||||
|
else PRIMARY_TEXT_HANDOVER_REF_PATH.get(out_schema)
|
||||||
|
)
|
||||||
|
if not ref_path:
|
||||||
|
continue
|
||||||
|
params[pname] = _data_ref(src_id, list(ref_path))
|
||||||
|
logger.debug(
|
||||||
|
"materializePrimaryTextHandover: %s.%s -> ref %s path=%s",
|
||||||
|
nid,
|
||||||
|
pname,
|
||||||
|
src_id,
|
||||||
|
ref_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
return g
|
||||||
|
|
||||||
|
|
||||||
|
def _recommended_data_pick_path(out_port: Dict[str, Any]) -> Optional[List[Any]]:
|
||||||
|
opts = out_port.get("dataPickOptions") if isinstance(out_port, dict) else None
|
||||||
|
if not isinstance(opts, list):
|
||||||
|
return None
|
||||||
|
for opt in opts:
|
||||||
|
if not isinstance(opt, dict):
|
||||||
|
continue
|
||||||
|
if opt.get("recommended") is True:
|
||||||
|
path = opt.get("path")
|
||||||
|
if isinstance(path, list) and path:
|
||||||
|
return list(path)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def materializeRecommendedDataPickRef(graph: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Materialize empty parameters that declare ``graphInherit.kind == \"recommendedDataPickRef\"``."""
|
||||||
|
g = copy.deepcopy(graph)
|
||||||
|
nodes: List[Dict[str, Any]] = g.get("nodes") or []
|
||||||
|
connections = g.get("connections") or []
|
||||||
|
if not nodes:
|
||||||
|
return g
|
||||||
|
|
||||||
|
conn_map = buildConnectionMap(connections)
|
||||||
|
node_by_id = {n["id"]: n for n in nodes if n.get("id")}
|
||||||
|
|
||||||
|
for node in nodes:
|
||||||
|
nid = node.get("id")
|
||||||
|
ntype = node.get("type")
|
||||||
|
if not nid or not ntype:
|
||||||
|
continue
|
||||||
|
node_def = _NODE_DEF_BY_ID.get(ntype)
|
||||||
|
if not node_def:
|
||||||
|
continue
|
||||||
|
params = node.get("parameters")
|
||||||
|
if not isinstance(params, dict):
|
||||||
|
node["parameters"] = {}
|
||||||
|
params = node["parameters"]
|
||||||
|
|
||||||
|
for pdef in node_def.get("parameters") or []:
|
||||||
|
gi = pdef.get("graphInherit")
|
||||||
|
if not isinstance(gi, dict) or gi.get("kind") != "recommendedDataPickRef":
|
||||||
|
continue
|
||||||
|
pname = pdef.get("name")
|
||||||
|
if not pname:
|
||||||
|
continue
|
||||||
|
port_ix = int(gi.get("port", 0))
|
||||||
|
if not _slot_empty_for_primary_text_inherit(params.get(pname)):
|
||||||
|
continue
|
||||||
|
input_sources = getInputSources(nid, conn_map)
|
||||||
|
if port_ix not in input_sources:
|
||||||
|
continue
|
||||||
|
src_id, _ = input_sources[port_ix]
|
||||||
|
src_node = node_by_id.get(src_id) or {}
|
||||||
|
src_def = _NODE_DEF_BY_ID.get(src_node.get("type") or "")
|
||||||
|
if not src_def:
|
||||||
|
continue
|
||||||
|
out_port = (src_def.get("outputPorts") or {}).get(port_ix, {}) or {}
|
||||||
|
if not isinstance(out_port, dict):
|
||||||
|
out_port = (src_def.get("outputPorts") or {}).get(0, {}) or {}
|
||||||
|
ref_path = _recommended_data_pick_path(out_port if isinstance(out_port, dict) else {})
|
||||||
|
if not ref_path:
|
||||||
|
continue
|
||||||
|
ref = _data_ref(src_id, ref_path)
|
||||||
|
if pdef.get("frontendType") == "contextBuilder":
|
||||||
|
params[pname] = [ref]
|
||||||
|
else:
|
||||||
|
params[pname] = ref
|
||||||
|
logger.debug(
|
||||||
|
"materializeRecommendedDataPickRef: %s.%s -> ref %s path=%s",
|
||||||
|
nid,
|
||||||
|
pname,
|
||||||
|
src_id,
|
||||||
|
ref_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
return g
|
||||||
|
|
||||||
|
|
||||||
|
_STALE_FILE_CREATE_CONTEXT_PATHS = frozenset({
|
||||||
|
("responseData",),
|
||||||
|
("response",),
|
||||||
|
("merged",),
|
||||||
|
("documents", 0, "documentData"),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def remap_stale_presentation_ref_path(path: List[Any]) -> List[Any]:
|
||||||
|
"""Map legacy text-handover paths to unified presentation ``data``."""
|
||||||
|
if tuple(path) in _STALE_FILE_CREATE_CONTEXT_PATHS:
|
||||||
|
return ["data"]
|
||||||
|
return list(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_presentation_refs_in_value(val: Any) -> Any:
|
||||||
|
"""Rewrite stale ref paths inside ``contextBuilder`` lists or bare refs."""
|
||||||
|
if isinstance(val, dict) and val.get("type") == "ref":
|
||||||
|
path = val.get("path")
|
||||||
|
if isinstance(path, list) and path:
|
||||||
|
new_path = remap_stale_presentation_ref_path(path)
|
||||||
|
if new_path != path:
|
||||||
|
return {**val, "path": new_path}
|
||||||
|
return val
|
||||||
|
if isinstance(val, list):
|
||||||
|
return [_normalize_presentation_refs_in_value(item) for item in val]
|
||||||
|
return val
|
||||||
|
|
||||||
|
|
||||||
|
def normalizeFileCreatePresentationRefs(graph: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Remap legacy ``file.create`` context refs to unified presentation ``data``."""
|
||||||
|
g = copy.deepcopy(graph)
|
||||||
|
nodes: List[Dict[str, Any]] = g.get("nodes") or []
|
||||||
|
for node in nodes:
|
||||||
|
if node.get("type") != "file.create":
|
||||||
|
continue
|
||||||
|
params = node.get("parameters")
|
||||||
|
if not isinstance(params, dict):
|
||||||
|
continue
|
||||||
|
ctx = params.get("context")
|
||||||
|
if ctx in (None, "", []):
|
||||||
|
continue
|
||||||
|
normalized = _normalize_presentation_refs_in_value(ctx)
|
||||||
|
if normalized != ctx:
|
||||||
|
params["context"] = normalized
|
||||||
|
logger.debug(
|
||||||
|
"normalizeFileCreatePresentationRefs: %s.context remapped to presentation data ref",
|
||||||
|
node.get("id"),
|
||||||
|
)
|
||||||
|
return g
|
||||||
|
|
|
||||||
32
modules/workflows/automation2/workflowArtifactVisibility.py
Normal file
32
modules/workflows/automation2/workflowArtifactVisibility.py
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
"""Heuristics for hiding internal workflow artefacts from user-facing file lists."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Mapping, Optional
|
||||||
|
|
||||||
|
|
||||||
|
_WORKFLOW_INTERNAL_FILE_TAG = "_workflowInternal"
|
||||||
|
|
||||||
|
|
||||||
|
def suppress_workflow_file_in_workspace_ui(meta: Optional[Mapping[str, Any]]) -> bool:
|
||||||
|
"""True when a file row should not appear in user-facing file lists.
|
||||||
|
|
||||||
|
Used by Automation Workspace **and** ``/api/files/list`` (Meine Dateien).
|
||||||
|
Matches persisted JSON handovers from transient runs (``extracted_content_transient*``),
|
||||||
|
internal extract image files (``extract_media_*``), the ``_workflowInternal`` tag, and
|
||||||
|
optional explicit flags.
|
||||||
|
"""
|
||||||
|
if not isinstance(meta, Mapping):
|
||||||
|
return False
|
||||||
|
tags = meta.get("tags")
|
||||||
|
if isinstance(tags, list) and _WORKFLOW_INTERNAL_FILE_TAG in tags:
|
||||||
|
return True
|
||||||
|
fn = str(meta.get("fileName") or "").lower()
|
||||||
|
if "extracted_content_transient" in fn:
|
||||||
|
return True
|
||||||
|
if "extract_media_" in fn:
|
||||||
|
return True
|
||||||
|
if meta.get("suppressInWorkflowFileLists") is True:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
@ -4,27 +4,101 @@
|
||||||
"""Shared helpers for AI workflow actions."""
|
"""Shared helpers for AI workflow actions."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from typing import Any
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
|
||||||
def serialize_context(val: Any) -> str:
|
def is_image_action_document_list(val: Any) -> bool:
|
||||||
|
"""True if ``val`` is a non-empty list of ActionDocument-shaped dicts (mimeType image/*)."""
|
||||||
|
if not isinstance(val, list) or not val:
|
||||||
|
return False
|
||||||
|
for item in val:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
return False
|
||||||
|
mime = str(item.get("mimeType") or "").strip().lower()
|
||||||
|
if not mime.startswith("image/"):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _handover_response_plain(val: Any) -> Optional[str]:
|
||||||
|
"""If ``val`` is a dict with a non-empty ``response`` string, return it (BOM-stripped)."""
|
||||||
|
if not isinstance(val, dict):
|
||||||
|
return None
|
||||||
|
r = val.get("response")
|
||||||
|
if r is None or not str(r).strip():
|
||||||
|
return None
|
||||||
|
return str(r).strip().lstrip("\ufeff")
|
||||||
|
|
||||||
|
|
||||||
|
def primary_text_for_prompt_context(val: Any) -> str:
|
||||||
|
"""Flatten ActionResult / presentation / merge payloads to readable text.
|
||||||
|
|
||||||
|
Used when merging multiple context-builder refs so extract outputs are not
|
||||||
|
turned into giant JSON via ``serialize_context`` (empty ``response``).
|
||||||
|
"""
|
||||||
|
if val is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(val, str):
|
||||||
|
s = val.strip().lstrip("\ufeff")
|
||||||
|
if not s:
|
||||||
|
return ""
|
||||||
|
if len(s) >= 2 and ((s.startswith("[") and s.endswith("]")) or (s.startswith("{") and s.endswith("}"))):
|
||||||
|
try:
|
||||||
|
return primary_text_for_prompt_context(json.loads(s))
|
||||||
|
except (json.JSONDecodeError, TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
return s
|
||||||
|
if isinstance(val, list):
|
||||||
|
chunks = [primary_text_for_prompt_context(item) for item in val]
|
||||||
|
chunks = [c for c in chunks if c]
|
||||||
|
return "\n\n".join(chunks)
|
||||||
|
if isinstance(val, dict):
|
||||||
|
got = _handover_response_plain(val)
|
||||||
|
if got is not None:
|
||||||
|
return got
|
||||||
|
inner = val.get("data")
|
||||||
|
if isinstance(inner, dict):
|
||||||
|
from modules.workflows.methods.methodContext.actions.extractContent import (
|
||||||
|
joined_text_from_extract_node_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
t = (joined_text_from_extract_node_data(inner) or "").strip()
|
||||||
|
if t:
|
||||||
|
return t
|
||||||
|
from modules.workflows.methods.methodContext.actions.extractContent import (
|
||||||
|
joined_text_from_extract_node_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
return (joined_text_from_extract_node_data(val) or "").strip()
|
||||||
|
return str(val).strip() if str(val).strip() else ""
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_context(val: Any, *, prefer_handover_primary: bool = False) -> str:
|
||||||
"""Convert any context value to a readable string for use in AI prompts.
|
"""Convert any context value to a readable string for use in AI prompts.
|
||||||
|
|
||||||
- None / empty string → ""
|
- None / empty string → ""
|
||||||
- empty dict (no keys) → "" (avoids literal "{}" in file.create / prompts)
|
- empty dict (no keys) → "" (avoids literal "{}" in file.create / prompts)
|
||||||
- str → as-is
|
- str → as-is
|
||||||
- dict / list → pretty-printed JSON
|
- dict / list → pretty-printed JSON (unless ``prefer_handover_primary`` and dict has ``response``)
|
||||||
|
- if JSON encoding fails (cycles, etc.) but dict has ``response``, return that text instead of ``str(dict)``
|
||||||
- anything else → str()
|
- anything else → str()
|
||||||
"""
|
"""
|
||||||
if val is None or val == "" or val == []:
|
if val is None or val == "" or val == []:
|
||||||
return ""
|
return ""
|
||||||
if isinstance(val, dict) and len(val) == 0:
|
if isinstance(val, dict) and len(val) == 0:
|
||||||
return ""
|
return ""
|
||||||
|
if prefer_handover_primary:
|
||||||
|
got = _handover_response_plain(val)
|
||||||
|
if got is not None:
|
||||||
|
return got
|
||||||
if isinstance(val, str):
|
if isinstance(val, str):
|
||||||
return val.strip()
|
return val.strip().lstrip("\ufeff")
|
||||||
try:
|
try:
|
||||||
return json.dumps(val, ensure_ascii=False, indent=2)
|
return json.dumps(val, ensure_ascii=False, indent=2, default=str)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
got = _handover_response_plain(val)
|
||||||
|
if got is not None:
|
||||||
|
return got
|
||||||
return str(val)
|
return str(val)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -389,34 +389,33 @@ async def process(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||||
))
|
))
|
||||||
|
|
||||||
final_documents = action_documents
|
final_documents = action_documents
|
||||||
|
handover_data = None
|
||||||
else:
|
else:
|
||||||
# Text response - create document from content
|
# Text-only response: keep handover in ActionResult.data (no ActionDocument).
|
||||||
# If no extension provided, use "txt" (required for filename)
|
# Avoids automation2 persisting a synthetic file per run; use ai.generateDocument for files.
|
||||||
extension = output_extension.lstrip('.') if output_extension else "txt"
|
body = aiResponse.content
|
||||||
meaningful_name = self._generateMeaningfulFileName(
|
if body is None:
|
||||||
base_name="ai",
|
body = ""
|
||||||
extension=extension,
|
elif not isinstance(body, str):
|
||||||
action_name="result"
|
body = str(body)
|
||||||
)
|
final_documents = []
|
||||||
validationMetadata = {
|
handover_data = {
|
||||||
"actionType": "ai.process",
|
"response": body,
|
||||||
"resultType": normalized_result_type if normalized_result_type else None,
|
"resultType": normalized_result_type,
|
||||||
"outputFormat": output_format if output_format else None,
|
"outputFormat": output_format,
|
||||||
"hasDocuments": False,
|
"contentType": "text",
|
||||||
"contentType": "text"
|
|
||||||
}
|
}
|
||||||
action_document = ActionDocument(
|
md = getattr(aiResponse, "metadata", None)
|
||||||
documentName=meaningful_name,
|
if md is not None:
|
||||||
documentData=aiResponse.content,
|
extra = getattr(md, "additionalData", None)
|
||||||
mimeType=output_mime_type,
|
if isinstance(extra, dict):
|
||||||
validationMetadata=validationMetadata
|
for k, v in extra.items():
|
||||||
)
|
handover_data.setdefault(k, v)
|
||||||
final_documents = [action_document]
|
|
||||||
|
|
||||||
# Complete progress tracking
|
# Complete progress tracking
|
||||||
self.services.chat.progressLogFinish(operationId, True)
|
self.services.chat.progressLogFinish(operationId, True)
|
||||||
|
|
||||||
return ActionResult.isSuccess(documents=final_documents)
|
return ActionResult.isSuccess(documents=final_documents, data=handover_data)
|
||||||
|
|
||||||
except (SubscriptionInactiveException, BillingContextError):
|
except (SubscriptionInactiveException, BillingContextError):
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -230,7 +230,14 @@ class MethodAi(MethodBase):
|
||||||
required=False,
|
required=False,
|
||||||
default="txt",
|
default="txt",
|
||||||
description="Output file extension"
|
description="Output file extension"
|
||||||
)
|
),
|
||||||
|
"folderId": WorkflowActionParameter(
|
||||||
|
name="folderId",
|
||||||
|
type="str",
|
||||||
|
frontendType=FrontendType.USER_FILE_FOLDER,
|
||||||
|
required=False,
|
||||||
|
description="Target folder in My Files when persisting workflow output",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
execute=summarizeDocument.__get__(self, self.__class__)
|
execute=summarizeDocument.__get__(self, self.__class__)
|
||||||
),
|
),
|
||||||
|
|
@ -275,7 +282,14 @@ class MethodAi(MethodBase):
|
||||||
frontendType=FrontendType.TEXT,
|
frontendType=FrontendType.TEXT,
|
||||||
required=False,
|
required=False,
|
||||||
description="Output file extension. If not specified, uses same format as input"
|
description="Output file extension. If not specified, uses same format as input"
|
||||||
)
|
),
|
||||||
|
"folderId": WorkflowActionParameter(
|
||||||
|
name="folderId",
|
||||||
|
type="str",
|
||||||
|
frontendType=FrontendType.USER_FILE_FOLDER,
|
||||||
|
required=False,
|
||||||
|
description="Target folder in My Files when persisting workflow output",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
execute=translateDocument.__get__(self, self.__class__)
|
execute=translateDocument.__get__(self, self.__class__)
|
||||||
),
|
),
|
||||||
|
|
@ -307,7 +321,14 @@ class MethodAi(MethodBase):
|
||||||
required=False,
|
required=False,
|
||||||
default=True,
|
default=True,
|
||||||
description="Whether to preserve document structure (headings, tables, etc.)"
|
description="Whether to preserve document structure (headings, tables, etc.)"
|
||||||
)
|
),
|
||||||
|
"folderId": WorkflowActionParameter(
|
||||||
|
name="folderId",
|
||||||
|
type="str",
|
||||||
|
frontendType=FrontendType.USER_FILE_FOLDER,
|
||||||
|
required=False,
|
||||||
|
description="Target folder in My Files when persisting workflow output",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
execute=convertDocument.__get__(self, self.__class__)
|
execute=convertDocument.__get__(self, self.__class__)
|
||||||
),
|
),
|
||||||
|
|
@ -371,6 +392,13 @@ class MethodAi(MethodBase):
|
||||||
required=False,
|
required=False,
|
||||||
description="Legacy/API output format extension (e.g. txt, docx). Ignored when outputFormat is set."
|
description="Legacy/API output format extension (e.g. txt, docx). Ignored when outputFormat is set."
|
||||||
),
|
),
|
||||||
|
"folderId": WorkflowActionParameter(
|
||||||
|
name="folderId",
|
||||||
|
type="str",
|
||||||
|
frontendType=FrontendType.USER_FILE_FOLDER,
|
||||||
|
required=False,
|
||||||
|
description="Target folder in My Files when persisting workflow output",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
execute=generateDocument.__get__(self, self.__class__)
|
execute=generateDocument.__get__(self, self.__class__)
|
||||||
),
|
),
|
||||||
|
|
@ -411,6 +439,13 @@ class MethodAi(MethodBase):
|
||||||
default="",
|
default="",
|
||||||
description="Additional context from upstream steps.",
|
description="Additional context from upstream steps.",
|
||||||
),
|
),
|
||||||
|
"folderId": WorkflowActionParameter(
|
||||||
|
name="folderId",
|
||||||
|
type="str",
|
||||||
|
frontendType=FrontendType.USER_FILE_FOLDER,
|
||||||
|
required=False,
|
||||||
|
description="Target folder in My Files when persisting workflow output",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
execute=generateCode.__get__(self, self.__class__)
|
execute=generateCode.__get__(self, self.__class__)
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -194,40 +194,41 @@ class MethodBase:
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
def _validateParameters(self, parameters: Dict[str, Any], paramDefs: Dict[str, WorkflowActionParameter]) -> Dict[str, Any]:
|
def _validateParameters(self, parameters: Dict[str, Any], paramDefs: Dict[str, WorkflowActionParameter]) -> Dict[str, Any]:
|
||||||
"""Validate parameters against definitions
|
"""Validate declared parameters; pass through unknown ones from the node definition.
|
||||||
|
|
||||||
IMPORTANT: System parameters (like parentOperationId, expectedDocumentFormats) are preserved
|
The graphical-editor node definition is the source of truth for the full UI parameter
|
||||||
even if they're not in the parameter definitions, as they're used internally by the framework.
|
list. Actions only need to declare the parameters they want validated/defaulted; any
|
||||||
|
additional parameter passed in by the executor (e.g. contentFilter, pdfExtractMode,
|
||||||
|
outputMode for context.extractContent) is preserved so the action can read it.
|
||||||
|
|
||||||
|
System parameters (parentOperationId, _runContext, _upstreamPayload, ...) are always
|
||||||
|
preserved as before.
|
||||||
"""
|
"""
|
||||||
validated = {}
|
validated: Dict[str, Any] = {}
|
||||||
|
|
||||||
# System parameters that should always be preserved, even if not in paramDefs
|
|
||||||
systemParams = ['parentOperationId', 'expectedDocumentFormats']
|
|
||||||
for sysParam in systemParams:
|
|
||||||
if sysParam in parameters:
|
|
||||||
validated[sysParam] = parameters[sysParam]
|
|
||||||
|
|
||||||
for paramName, paramDef in paramDefs.items():
|
for paramName, paramDef in paramDefs.items():
|
||||||
value = parameters.get(paramName)
|
value = parameters.get(paramName)
|
||||||
|
|
||||||
# Check required
|
|
||||||
if paramDef.required and value is None:
|
if paramDef.required and value is None:
|
||||||
raise ValueError(f"Required parameter '{paramName}' is missing")
|
raise ValueError(f"Required parameter '{paramName}' is missing")
|
||||||
|
|
||||||
# Use default if not provided
|
|
||||||
if value is None and paramDef.default is not None:
|
if value is None and paramDef.default is not None:
|
||||||
value = paramDef.default
|
value = paramDef.default
|
||||||
|
|
||||||
# Type validation
|
|
||||||
if value is not None:
|
if value is not None:
|
||||||
value = self._validateType(value, paramDef.type)
|
value = self._validateType(value, paramDef.type)
|
||||||
|
|
||||||
# Custom validation rules
|
|
||||||
if paramDef.validation and value is not None:
|
if paramDef.validation and value is not None:
|
||||||
self._applyValidationRules(value, paramDef.validation)
|
self._applyValidationRules(value, paramDef.validation)
|
||||||
|
|
||||||
validated[paramName] = value
|
validated[paramName] = value
|
||||||
|
|
||||||
|
# Preserve every additional parameter the executor passed in (node-defined params,
|
||||||
|
# system params, declarative injections). This keeps the node definition authoritative.
|
||||||
|
for k, v in parameters.items():
|
||||||
|
if k not in validated:
|
||||||
|
validated[k] = v
|
||||||
|
|
||||||
return validated
|
return validated
|
||||||
|
|
||||||
def _validateType(self, value: Any, expectedType: str) -> Any:
|
def _validateType(self, value: Any, expectedType: str) -> Any:
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
141
modules/workflows/methods/methodContext/actions/filterContext.py
Normal file
141
modules/workflows/methods/methodContext/actions/filterContext.py
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
# Copyright (c) 2026 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
|
"""Action ``context.filterContext``.
|
||||||
|
|
||||||
|
Allow- or block-lists keys/paths from the upstream payload using simple glob
|
||||||
|
patterns. Implementation uses ``fnmatch`` (no regex) and traverses dotted paths
|
||||||
|
on dicts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import fnmatch
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from modules.datamodels.datamodelChat import ActionResult
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
_META_KEYS = ("_success", "_error", "_transit", "_meta", "_warnings")
|
||||||
|
|
||||||
|
|
||||||
|
def _flatten(payload: Any, prefix: str = "") -> Dict[str, Any]:
|
||||||
|
"""Yield ``{dotted.path: value}`` for every leaf in a dict tree."""
|
||||||
|
out: Dict[str, Any] = {}
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
if prefix:
|
||||||
|
out[prefix] = payload
|
||||||
|
return out
|
||||||
|
for k, v in payload.items():
|
||||||
|
path = f"{prefix}.{k}" if prefix else str(k)
|
||||||
|
if isinstance(v, dict):
|
||||||
|
out.update(_flatten(v, path))
|
||||||
|
else:
|
||||||
|
out[path] = v
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _set_path(target: Dict[str, Any], dotted: str, value: Any) -> None:
|
||||||
|
parts = dotted.split(".")
|
||||||
|
cur = target
|
||||||
|
for seg in parts[:-1]:
|
||||||
|
nxt = cur.get(seg)
|
||||||
|
if not isinstance(nxt, dict):
|
||||||
|
nxt = {}
|
||||||
|
cur[seg] = nxt
|
||||||
|
cur = nxt
|
||||||
|
cur[parts[-1]] = value
|
||||||
|
|
||||||
|
|
||||||
|
def _del_path(target: Dict[str, Any], dotted: str) -> bool:
|
||||||
|
parts = dotted.split(".")
|
||||||
|
cur: Any = target
|
||||||
|
stack: List[Tuple[Dict[str, Any], str]] = []
|
||||||
|
for seg in parts[:-1]:
|
||||||
|
if not isinstance(cur, dict) or seg not in cur:
|
||||||
|
return False
|
||||||
|
stack.append((cur, seg))
|
||||||
|
cur = cur[seg]
|
||||||
|
if not isinstance(cur, dict) or parts[-1] not in cur:
|
||||||
|
return False
|
||||||
|
del cur[parts[-1]]
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _match_any(pattern: str, all_paths: List[str]) -> List[str]:
|
||||||
|
"""Return every flattened path matching the glob pattern."""
|
||||||
|
return [p for p in all_paths if fnmatch.fnmatchcase(p, pattern)]
|
||||||
|
|
||||||
|
|
||||||
|
async def filterContext(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||||
|
try:
|
||||||
|
mode = str(parameters.get("mode") or "allow")
|
||||||
|
if mode not in ("allow", "block"):
|
||||||
|
return ActionResult.isFailure(error=f"Invalid mode '{mode}', expected 'allow' or 'block'")
|
||||||
|
|
||||||
|
keys: List[str] = parameters.get("keys") or []
|
||||||
|
if not isinstance(keys, list) or not keys:
|
||||||
|
return ActionResult.isFailure(error="'keys' must be a non-empty list of paths or patterns")
|
||||||
|
|
||||||
|
missing_behavior = str(parameters.get("missingKeyBehavior") or "skip")
|
||||||
|
if missing_behavior not in ("skip", "nullFill", "error"):
|
||||||
|
return ActionResult.isFailure(error=f"Invalid missingKeyBehavior '{missing_behavior}'")
|
||||||
|
|
||||||
|
preserve_meta = bool(parameters.get("preserveMeta", True))
|
||||||
|
upstream = parameters.get("_upstreamPayload") or {}
|
||||||
|
if not isinstance(upstream, dict):
|
||||||
|
upstream = {"value": upstream}
|
||||||
|
|
||||||
|
flat = _flatten(upstream)
|
||||||
|
all_paths = list(flat.keys())
|
||||||
|
|
||||||
|
if mode == "allow":
|
||||||
|
result: Dict[str, Any] = {}
|
||||||
|
missing: List[str] = []
|
||||||
|
for pat in keys:
|
||||||
|
p = str(pat).strip()
|
||||||
|
if not p:
|
||||||
|
continue
|
||||||
|
matches = _match_any(p, all_paths)
|
||||||
|
if not matches:
|
||||||
|
missing.append(p)
|
||||||
|
if missing_behavior == "nullFill":
|
||||||
|
_set_path(result, p, None)
|
||||||
|
continue
|
||||||
|
for m in matches:
|
||||||
|
_set_path(result, m, flat[m])
|
||||||
|
|
||||||
|
if missing and missing_behavior == "error":
|
||||||
|
return ActionResult.isFailure(error=f"Missing keys: {missing}")
|
||||||
|
|
||||||
|
if preserve_meta:
|
||||||
|
for mk in _META_KEYS:
|
||||||
|
if mk in upstream:
|
||||||
|
result[mk] = upstream[mk]
|
||||||
|
|
||||||
|
data: Dict[str, Any] = result
|
||||||
|
if missing and missing_behavior != "error":
|
||||||
|
data["_missingKeys"] = missing
|
||||||
|
return ActionResult.isSuccess(data=data)
|
||||||
|
|
||||||
|
# mode == "block"
|
||||||
|
cloned = copy.deepcopy(upstream)
|
||||||
|
removed: List[str] = []
|
||||||
|
for pat in keys:
|
||||||
|
p = str(pat).strip()
|
||||||
|
if not p:
|
||||||
|
continue
|
||||||
|
matches = _match_any(p, all_paths)
|
||||||
|
for m in matches:
|
||||||
|
if preserve_meta and m in _META_KEYS:
|
||||||
|
continue
|
||||||
|
if _del_path(cloned, m):
|
||||||
|
removed.append(m)
|
||||||
|
cloned["_removedKeys"] = removed
|
||||||
|
return ActionResult.isSuccess(data=cloned)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("filterContext failed")
|
||||||
|
return ActionResult.isFailure(error=str(exc))
|
||||||
254
modules/workflows/methods/methodContext/actions/mergeContext.py
Normal file
254
modules/workflows/methods/methodContext/actions/mergeContext.py
Normal file
|
|
@ -0,0 +1,254 @@
|
||||||
|
# Copyright (c) 2026 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
|
"""Action ``context.mergeContext``.
|
||||||
|
|
||||||
|
Receives a list of results (e.g. from ``flow.loop`` ``bodyResults``) via the
|
||||||
|
``dataSource`` DataRef parameter and deep-merges them into a single dict.
|
||||||
|
|
||||||
|
``dataSource`` must be set explicitly (resolved DataRef). There is no implicit
|
||||||
|
fallback to ``_upstreamPayload`` or loop payloads.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from modules.datamodels.datamodelChat import ActionResult
|
||||||
|
from modules.workflows.methods.methodContext.actions.extractContent import (
|
||||||
|
joined_text_from_extract_node_data,
|
||||||
|
)
|
||||||
|
from modules.workflows.methods.methodContext.contextEnvelope import wrap_merge_context_data
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _deep_merge(target: Dict[str, Any], source: Dict[str, Any], conflicts: List[str], path: str = "") -> None:
|
||||||
|
for k, v in source.items():
|
||||||
|
full = f"{path}.{k}" if path else k
|
||||||
|
if k not in target:
|
||||||
|
target[k] = copy.deepcopy(v) if isinstance(v, (dict, list)) else v
|
||||||
|
continue
|
||||||
|
existing = target[k]
|
||||||
|
if isinstance(existing, dict) and isinstance(v, dict):
|
||||||
|
_deep_merge(existing, v, conflicts, full)
|
||||||
|
elif isinstance(existing, list) and isinstance(v, list):
|
||||||
|
target[k] = existing + v
|
||||||
|
else:
|
||||||
|
if existing != v:
|
||||||
|
conflicts.append(full)
|
||||||
|
target[k] = copy.deepcopy(v) if isinstance(v, (dict, list)) else v
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_to_list(value: Any) -> List[Any]:
|
||||||
|
"""Normalise ``value`` to a list of items to merge."""
|
||||||
|
if isinstance(value, list):
|
||||||
|
return value
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
return [value]
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_document_data(doc: Any) -> Any:
|
||||||
|
"""Keep document metadata but drop the raw blob so deep-merge stays small."""
|
||||||
|
if not isinstance(doc, dict):
|
||||||
|
return doc
|
||||||
|
out = dict(doc)
|
||||||
|
out["documentData"] = None
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_payload(item: Any) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Return the dict to deep-merge for this item, or ``None`` to skip.
|
||||||
|
|
||||||
|
``documents[n].documentData`` is nulled before merging so large blobs
|
||||||
|
(e.g. ~3–4 MB handover-JSON per extractContent iteration) don't accumulate.
|
||||||
|
``imageDocumentsOnly`` is left intact — ``_deep_merge`` list-concats it
|
||||||
|
across iterations, giving downstream nodes all images from all iterations.
|
||||||
|
"""
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
return None
|
||||||
|
# Opt-in: only merge items that explicitly report success.
|
||||||
|
# Items without a ``success`` key (e.g. DocumentList, Transit outputs) are
|
||||||
|
# still included so non-action node results are not silently dropped.
|
||||||
|
success_val = item.get("success")
|
||||||
|
if success_val is not None and success_val is not True:
|
||||||
|
return None
|
||||||
|
out = dict(item)
|
||||||
|
if isinstance(out.get("documents"), list):
|
||||||
|
out["documents"] = [_strip_document_data(d) for d in out["documents"]]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _primary_text_from_item(it: Any) -> str:
|
||||||
|
"""Same sources as ``actionNodeExecutor`` / ``context.extractContent`` for primary text."""
|
||||||
|
if not isinstance(it, dict):
|
||||||
|
return ""
|
||||||
|
r = it.get("response")
|
||||||
|
if r is not None and str(r).strip():
|
||||||
|
return str(r).strip()
|
||||||
|
inner = it.get("data")
|
||||||
|
if isinstance(inner, dict):
|
||||||
|
r = inner.get("response")
|
||||||
|
if r is not None and str(r).strip():
|
||||||
|
return str(r).strip()
|
||||||
|
ce_text = joined_text_from_extract_node_data(inner)
|
||||||
|
if ce_text.strip():
|
||||||
|
return ce_text.strip()
|
||||||
|
docs = it.get("documents")
|
||||||
|
if not isinstance(docs, list) or not docs:
|
||||||
|
return ""
|
||||||
|
doc0 = docs[0]
|
||||||
|
raw: Any = None
|
||||||
|
if isinstance(doc0, dict):
|
||||||
|
raw = doc0.get("documentData")
|
||||||
|
elif hasattr(doc0, "documentData"):
|
||||||
|
raw = getattr(doc0, "documentData", None)
|
||||||
|
if isinstance(raw, bytes):
|
||||||
|
try:
|
||||||
|
return raw.decode("utf-8").strip()
|
||||||
|
except (UnicodeDecodeError, ValueError):
|
||||||
|
return ""
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
return (joined_text_from_extract_node_data(raw) or "").strip()
|
||||||
|
if isinstance(raw, str) and raw.strip():
|
||||||
|
s = raw.strip()
|
||||||
|
if s.startswith("{") and s.endswith("}"):
|
||||||
|
try:
|
||||||
|
parsed = json.loads(s)
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
return (joined_text_from_extract_node_data(parsed) or "").strip()
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
pass
|
||||||
|
return s
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_heading_title(name: str) -> str:
|
||||||
|
t = " ".join(name.replace("\r", " ").replace("\n", " ").split()).strip()
|
||||||
|
return t[:160] if len(t) > 160 else t
|
||||||
|
|
||||||
|
|
||||||
|
def _iteration_heading_from_item(it: Any) -> Optional[str]:
|
||||||
|
if not isinstance(it, dict):
|
||||||
|
return None
|
||||||
|
inner = it.get("data")
|
||||||
|
if isinstance(inner, dict):
|
||||||
|
meta = inner.get("_meta") if isinstance(inner.get("_meta"), dict) else {}
|
||||||
|
sf = inner.get("sourceFileNames") or meta.get("sourceFileNames")
|
||||||
|
if isinstance(sf, list) and sf:
|
||||||
|
first = sf[0]
|
||||||
|
if isinstance(first, str) and first.strip():
|
||||||
|
return _sanitize_heading_title(first.strip())
|
||||||
|
docs = it.get("documents")
|
||||||
|
if not isinstance(docs, list) or not docs:
|
||||||
|
return None
|
||||||
|
d0 = docs[0]
|
||||||
|
if not isinstance(d0, dict):
|
||||||
|
return None
|
||||||
|
name = d0.get("documentName")
|
||||||
|
if isinstance(name, str) and name.strip():
|
||||||
|
return _sanitize_heading_title(name.strip())
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _synthesize_primary_response(merged: Dict[str, Any], inputs: List[Any]) -> str:
|
||||||
|
"""Flat text for ``ActionResult.response`` / file.create.
|
||||||
|
|
||||||
|
Prefer concatenating each input's primary text (loop bodyResults) so no
|
||||||
|
iteration is dropped — ``deep_merge`` overwrites scalar ``response`` with
|
||||||
|
the last item only; that merged value is a fallback when no per-item text
|
||||||
|
is found.
|
||||||
|
|
||||||
|
When several inputs are merged, prefix each chunk with a markdown ``###``
|
||||||
|
heading from ``documents[0].documentName`` so ``file.create`` renders clear
|
||||||
|
sections (CSV vs PDF vs …).
|
||||||
|
"""
|
||||||
|
chunks: List[str] = []
|
||||||
|
multi = len(inputs) > 1
|
||||||
|
for it in inputs:
|
||||||
|
t = _primary_text_from_item(it)
|
||||||
|
if not t:
|
||||||
|
continue
|
||||||
|
if multi:
|
||||||
|
h = _iteration_heading_from_item(it)
|
||||||
|
if h:
|
||||||
|
chunks.append(f"### {h}\n\n{t}")
|
||||||
|
continue
|
||||||
|
chunks.append(t)
|
||||||
|
if chunks:
|
||||||
|
return "\n\n".join(chunks)
|
||||||
|
|
||||||
|
if isinstance(merged, dict):
|
||||||
|
r = merged.get("response")
|
||||||
|
if r is not None and str(r).strip():
|
||||||
|
return str(r).strip()
|
||||||
|
|
||||||
|
if isinstance(merged, dict) and merged:
|
||||||
|
try:
|
||||||
|
return json.dumps(merged, ensure_ascii=False, indent=2, default=str)
|
||||||
|
except Exception:
|
||||||
|
return str(merged)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
async def mergeContext(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||||
|
try:
|
||||||
|
if "dataSource" not in parameters:
|
||||||
|
raise ValueError("dataSource is required (set a DataRef on the merge node)")
|
||||||
|
raw = parameters["dataSource"]
|
||||||
|
if isinstance(raw, str) and not raw.strip():
|
||||||
|
raw = None
|
||||||
|
if raw is None:
|
||||||
|
return ActionResult.isFailure(error="dataSource ist erforderlich (DataRef auf die Quelle setzen).")
|
||||||
|
if isinstance(raw, list) and len(raw) == 0:
|
||||||
|
return ActionResult.isFailure(error="Keine Datenquelle angegeben oder Datenquelle ist leer.")
|
||||||
|
|
||||||
|
items = _coerce_to_list(raw)
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
return ActionResult.isFailure(error="Keine Datenquelle angegeben oder Datenquelle ist leer.")
|
||||||
|
|
||||||
|
merged: Dict[str, Any] = {}
|
||||||
|
conflicts: List[str] = []
|
||||||
|
inputs: List[Any] = []
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
if item is None:
|
||||||
|
continue
|
||||||
|
inputs.append(item)
|
||||||
|
payload = _merge_payload(item)
|
||||||
|
if payload:
|
||||||
|
_deep_merge(merged, payload, conflicts)
|
||||||
|
|
||||||
|
if not inputs:
|
||||||
|
return ActionResult.isFailure(error="Alle Einträge in der Datenquelle sind leer.")
|
||||||
|
|
||||||
|
primary = _synthesize_primary_response(merged, inputs)
|
||||||
|
# ``response`` lives only at the top-level of the data envelope (``payload["response"]``).
|
||||||
|
# Do NOT set ``merged["response"]`` — that would duplicate it inside the deep-merged blob
|
||||||
|
# and overwrite whatever the natural merge produced for debugging.
|
||||||
|
|
||||||
|
_ps = primary if isinstance(primary, str) else repr(primary)
|
||||||
|
logger.info(
|
||||||
|
"mergeContext: inputs=%d merged_keys=%s primary_len=%d primary_preview=%r conflicts=%d",
|
||||||
|
len(inputs),
|
||||||
|
list(merged.keys())[:20],
|
||||||
|
len(_ps or ""),
|
||||||
|
(_ps[:200] + "\u2026") if len(_ps) > 200 else _ps,
|
||||||
|
len(conflicts),
|
||||||
|
)
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
|
"merged": merged,
|
||||||
|
"inputs": inputs,
|
||||||
|
"first": inputs[0] if inputs else None,
|
||||||
|
"count": len(inputs),
|
||||||
|
"conflicts": sorted(set(conflicts)) if conflicts else [],
|
||||||
|
"response": primary,
|
||||||
|
}
|
||||||
|
return ActionResult.isSuccess(data=wrap_merge_context_data(payload))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("mergeContext failed")
|
||||||
|
return ActionResult.isFailure(error=str(exc))
|
||||||
|
|
@ -1,239 +1,309 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
|
|
||||||
|
import base64 as _b64
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from typing import Dict, Any
|
from typing import Any, Dict
|
||||||
|
|
||||||
from modules.datamodels.datamodelChat import ActionResult, ActionDocument
|
from modules.datamodels.datamodelChat import ActionResult, ActionDocument
|
||||||
from modules.datamodels.datamodelDocref import (
|
from modules.datamodels.datamodelDocref import coerceDocumentReferenceList
|
||||||
DocumentReferenceList,
|
|
||||||
coerceDocumentReferenceList,
|
|
||||||
)
|
|
||||||
from modules.datamodels.datamodelExtraction import ContentExtracted, ContentPart
|
from modules.datamodels.datamodelExtraction import ContentExtracted, ContentPart
|
||||||
|
|
||||||
|
from .extractContent import _one_file_bucket
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
async def neutralizeData(self, parameters: Dict[str, Any]) -> ActionResult:
|
HANDOVER_KIND = "context.extractContent.handover.v1"
|
||||||
operationId = None
|
|
||||||
try:
|
|
||||||
workflowId = self.services.workflow.id if self.services.workflow else f"no-workflow-{int(time.time())}"
|
|
||||||
operationId = f"context_neutralize_{workflowId}_{int(time.time())}"
|
|
||||||
|
|
||||||
neutralizationEnabled = False
|
|
||||||
try:
|
|
||||||
config = self.services.neutralization.getConfig()
|
|
||||||
neutralizationEnabled = config and config.enabled
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Could not check neutralization config: {str(e)}")
|
|
||||||
|
|
||||||
if not neutralizationEnabled:
|
async def _neutralize_one_content_extracted(
|
||||||
logger.info("Neutralization is not enabled, returning documents unchanged")
|
*,
|
||||||
# Return original documents if neutralization is disabled
|
svc,
|
||||||
documentListParam = parameters.get("documentList")
|
content_extracted: ContentExtracted,
|
||||||
if not documentListParam:
|
operation_id: str,
|
||||||
return ActionResult.isFailure(error="documentList is required")
|
chat_doc_slot: int,
|
||||||
|
chat_documents_len: int,
|
||||||
documentList = coerceDocumentReferenceList(documentListParam)
|
) -> ContentExtracted:
|
||||||
if not documentList.references:
|
"""Neutralize every part inside a ContentExtracted (copied semantics from legacy inline loop)."""
|
||||||
return ActionResult.isFailure(
|
neutralized_parts = []
|
||||||
error=f"documentList could not be parsed (type={type(documentListParam).__name__})"
|
for part in content_extracted.parts:
|
||||||
)
|
|
||||||
|
|
||||||
# Get ChatDocuments from documentList
|
|
||||||
chatDocuments = self.services.chat.getChatDocumentsFromDocumentList(documentList)
|
|
||||||
if not chatDocuments:
|
|
||||||
return ActionResult.isFailure(error="No documents found in documentList")
|
|
||||||
|
|
||||||
# Return original documents as ActionDocuments
|
|
||||||
actionDocuments = []
|
|
||||||
for chatDoc in chatDocuments:
|
|
||||||
# Extract ContentExtracted from documentData if available
|
|
||||||
if hasattr(chatDoc, 'documentData') and chatDoc.documentData:
|
|
||||||
actionDoc = ActionDocument(
|
|
||||||
documentName=getattr(chatDoc, 'fileName', 'unknown'),
|
|
||||||
documentData=chatDoc.documentData,
|
|
||||||
mimeType=getattr(chatDoc, 'mimeType', 'application/json'),
|
|
||||||
validationMetadata={
|
|
||||||
"actionType": "context.neutralizeData",
|
|
||||||
"neutralized": False,
|
|
||||||
"reason": "Neutralization disabled"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
actionDocuments.append(actionDoc)
|
|
||||||
|
|
||||||
return ActionResult.isSuccess(documents=actionDocuments)
|
|
||||||
|
|
||||||
documentListParam = parameters.get("documentList")
|
|
||||||
if not documentListParam:
|
|
||||||
return ActionResult.isFailure(error="documentList is required")
|
|
||||||
|
|
||||||
documentList = coerceDocumentReferenceList(documentListParam)
|
|
||||||
if not documentList.references:
|
|
||||||
return ActionResult.isFailure(
|
|
||||||
error=f"documentList could not be parsed (type={type(documentListParam).__name__})"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Start progress tracking
|
|
||||||
parentOperationId = parameters.get('parentOperationId')
|
|
||||||
self.services.chat.progressLogStart(
|
|
||||||
operationId,
|
|
||||||
"Neutralizing data from documents",
|
|
||||||
"Data Neutralization",
|
|
||||||
f"Documents: {len(documentList.references)}",
|
|
||||||
parentOperationId=parentOperationId
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get ChatDocuments from documentList
|
|
||||||
self.services.chat.progressLogUpdate(operationId, 0.2, "Loading documents")
|
|
||||||
chatDocuments = self.services.chat.getChatDocumentsFromDocumentList(documentList)
|
|
||||||
|
|
||||||
if not chatDocuments:
|
|
||||||
self.services.chat.progressLogFinish(operationId, False)
|
|
||||||
return ActionResult.isFailure(error="No documents found in documentList")
|
|
||||||
|
|
||||||
logger.info(f"Neutralizing data from {len(chatDocuments)} documents")
|
|
||||||
|
|
||||||
# Process each document
|
|
||||||
self.services.chat.progressLogUpdate(operationId, 0.3, "Processing documents")
|
|
||||||
actionDocuments = []
|
|
||||||
|
|
||||||
for i, chatDoc in enumerate(chatDocuments):
|
|
||||||
try:
|
|
||||||
# Extract ContentExtracted from documentData
|
|
||||||
if not hasattr(chatDoc, 'documentData') or not chatDoc.documentData:
|
|
||||||
logger.warning(f"Document {i+1} has no documentData, skipping")
|
|
||||||
continue
|
|
||||||
|
|
||||||
documentData = chatDoc.documentData
|
|
||||||
|
|
||||||
# Check if it's a ContentExtracted object
|
|
||||||
if isinstance(documentData, ContentExtracted):
|
|
||||||
contentExtracted = documentData
|
|
||||||
elif isinstance(documentData, dict):
|
|
||||||
# Try to parse as ContentExtracted
|
|
||||||
try:
|
|
||||||
contentExtracted = ContentExtracted(**documentData)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Document {i+1} documentData is not ContentExtracted: {str(e)}")
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
logger.warning(f"Document {i+1} documentData is not ContentExtracted or dict")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Neutralize each ContentPart's data field
|
|
||||||
neutralizedParts = []
|
|
||||||
for part in contentExtracted.parts:
|
|
||||||
if not isinstance(part, ContentPart):
|
if not isinstance(part, ContentPart):
|
||||||
# Try to parse as ContentPart
|
|
||||||
if isinstance(part, dict):
|
if isinstance(part, dict):
|
||||||
try:
|
try:
|
||||||
part = ContentPart(**part)
|
part = ContentPart(**part)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not parse ContentPart: {str(e)}")
|
logger.warning(f"Could not parse ContentPart: {str(e)}")
|
||||||
neutralizedParts.append(part)
|
neutralized_parts.append(part)
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
neutralizedParts.append(part)
|
neutralized_parts.append(part)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Neutralize the data field based on typeGroup
|
_type_group = getattr(part, "typeGroup", "") or ""
|
||||||
_typeGroup = getattr(part, 'typeGroup', '') or ''
|
prog = 0.3 + (chat_doc_slot / max(1, chat_documents_len)) * 0.6
|
||||||
if _typeGroup == 'image' and part.data:
|
|
||||||
import base64 as _b64
|
if _type_group == "image" and part.data:
|
||||||
try:
|
try:
|
||||||
self.services.chat.progressLogUpdate(
|
svc.services.chat.progressLogUpdate(
|
||||||
operationId,
|
operation_id,
|
||||||
0.3 + (i / len(chatDocuments)) * 0.6,
|
prog,
|
||||||
f"Checking image part {len(neutralizedParts) + 1} of document {i+1}"
|
f"Checking image part {len(neutralized_parts) + 1}",
|
||||||
)
|
)
|
||||||
_imgBytes = _b64.b64decode(str(part.data))
|
_img_bytes = _b64.b64decode(str(part.data))
|
||||||
_imgResult = await self.services.neutralization.processImageAsync(_imgBytes, f"part_{part.id}")
|
_img_result = await svc.services.neutralization.processImageAsync(_img_bytes, f"part_{part.id}")
|
||||||
if _imgResult.get("status") == "ok":
|
if _img_result.get("status") == "ok":
|
||||||
neutralizedParts.append(part)
|
neutralized_parts.append(part)
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Fail-Safe: Image part {part.id} blocked (PII detected), SKIPPING")
|
logger.warning("Fail-Safe: Image part %s blocked (PII), SKIPPING", part.id)
|
||||||
except Exception as _imgErr:
|
except Exception as _img_err:
|
||||||
logger.error(f"Fail-Safe: Image check failed for part {part.id}: {_imgErr}, SKIPPING")
|
logger.error(f"Fail-Safe: Image check failed for part {part.id}: {_img_err}, SKIPPING")
|
||||||
elif part.data:
|
elif part.data:
|
||||||
try:
|
try:
|
||||||
self.services.chat.progressLogUpdate(
|
svc.services.chat.progressLogUpdate(
|
||||||
operationId,
|
operation_id,
|
||||||
0.3 + (i / len(chatDocuments)) * 0.6,
|
prog,
|
||||||
f"Neutralizing part {len(neutralizedParts) + 1} of document {i+1}"
|
f"Neutralizing part {len(neutralized_parts) + 1}",
|
||||||
)
|
)
|
||||||
|
neut_res = await svc.services.neutralization.processTextAsync(part.data)
|
||||||
neutralizationResult = await self.services.neutralization.processTextAsync(part.data)
|
if neut_res and "neutralized_text" in neut_res:
|
||||||
|
neutral_data = neut_res["neutralized_text"]
|
||||||
if neutralizationResult and 'neutralized_text' in neutralizationResult:
|
neutralized_parts.append(
|
||||||
neutralizedData = neutralizationResult['neutralized_text']
|
ContentPart(
|
||||||
|
|
||||||
neutralizedPart = ContentPart(
|
|
||||||
id=part.id,
|
id=part.id,
|
||||||
parentId=part.parentId,
|
parentId=part.parentId,
|
||||||
label=part.label,
|
label=part.label,
|
||||||
typeGroup=part.typeGroup,
|
typeGroup=part.typeGroup,
|
||||||
mimeType=part.mimeType,
|
mimeType=part.mimeType,
|
||||||
data=neutralizedData,
|
data=neutral_data,
|
||||||
metadata=part.metadata.copy() if part.metadata else {}
|
metadata=part.metadata.copy() if part.metadata else {},
|
||||||
|
)
|
||||||
)
|
)
|
||||||
neutralizedParts.append(neutralizedPart)
|
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Fail-Safe: Neutralization incomplete for part {part.id}, SKIPPING (not passing original)")
|
logger.warning(
|
||||||
|
"Fail-Safe: Neutralization incomplete for part %s — SKIPPING (not passing original)",
|
||||||
|
part.id,
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Fail-Safe: Error neutralizing part {part.id}, SKIPPING document (not passing original): {str(e)}")
|
logger.error(f"Fail-Safe: Error neutralizing part {part.id}: {str(e)}, SKIPPING")
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
neutralizedParts.append(part)
|
neutralized_parts.append(part)
|
||||||
|
|
||||||
# Create neutralized ContentExtracted object
|
return ContentExtracted(
|
||||||
neutralizedContentExtracted = ContentExtracted(
|
id=content_extracted.id,
|
||||||
id=contentExtracted.id,
|
parts=neutralized_parts,
|
||||||
parts=neutralizedParts,
|
summary=content_extracted.summary,
|
||||||
summary=contentExtracted.summary
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create ActionDocument
|
|
||||||
originalFileName = getattr(chatDoc, 'fileName', f"document_{i+1}.json")
|
|
||||||
baseName = originalFileName.rsplit('.', 1)[0] if '.' in originalFileName else originalFileName
|
|
||||||
documentName = f"{baseName}_neutralized_{contentExtracted.id}.json"
|
|
||||||
|
|
||||||
|
async def neutralizeData(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||||
|
operation_id = None
|
||||||
|
try:
|
||||||
|
workflow_id = self.services.workflow.id if self.services.workflow else f"no-workflow-{int(time.time())}"
|
||||||
|
operation_id = f"context_neutralize_{workflow_id}_{int(time.time())}"
|
||||||
|
|
||||||
|
neutralization_enabled = False
|
||||||
|
try:
|
||||||
|
config = self.services.neutralization.getConfig()
|
||||||
|
neutralization_enabled = config and config.enabled
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Could not check neutralization config: {str(e)}")
|
||||||
|
|
||||||
|
if not neutralization_enabled:
|
||||||
|
logger.info("Neutralization is not enabled, returning documents unchanged")
|
||||||
|
document_list_param = parameters.get("documentList")
|
||||||
|
if not document_list_param:
|
||||||
|
return ActionResult.isFailure(error="documentList is required")
|
||||||
|
|
||||||
|
doc_list = coerceDocumentReferenceList(document_list_param)
|
||||||
|
if not doc_list.references:
|
||||||
|
return ActionResult.isFailure(error=f"documentList invalid (empty)")
|
||||||
|
|
||||||
|
chat_docs = self.services.chat.getChatDocumentsFromDocumentList(doc_list)
|
||||||
|
if not chat_docs:
|
||||||
|
return ActionResult.isFailure(error="No documents found in documentList")
|
||||||
|
|
||||||
|
action_documents = []
|
||||||
|
for chat_doc in chat_docs:
|
||||||
|
if hasattr(chat_doc, "documentData") and chat_doc.documentData:
|
||||||
|
action_documents.append(
|
||||||
|
ActionDocument(
|
||||||
|
documentName=getattr(chat_doc, "fileName", "unknown"),
|
||||||
|
documentData=chat_doc.documentData,
|
||||||
|
mimeType=getattr(chat_doc, "mimeType", "application/json"),
|
||||||
|
validationMetadata={
|
||||||
|
"actionType": "context.neutralizeData",
|
||||||
|
"neutralized": False,
|
||||||
|
"reason": "Neutralization disabled",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return ActionResult.isSuccess(documents=action_documents)
|
||||||
|
|
||||||
|
document_list_param = parameters.get("documentList")
|
||||||
|
if not document_list_param:
|
||||||
|
return ActionResult.isFailure(error="documentList is required")
|
||||||
|
|
||||||
|
doc_list = coerceDocumentReferenceList(document_list_param)
|
||||||
|
if not doc_list.references:
|
||||||
|
return ActionResult.isFailure(error=f"documentList invalid")
|
||||||
|
|
||||||
|
parent_operation_id = parameters.get("parentOperationId")
|
||||||
|
self.services.chat.progressLogStart(
|
||||||
|
operation_id,
|
||||||
|
"Neutralizing data from documents",
|
||||||
|
"Data Neutralization",
|
||||||
|
f"Documents: {len(doc_list.references)}",
|
||||||
|
parentOperationId=parent_operation_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.services.chat.progressLogUpdate(operation_id, 0.2, "Loading documents")
|
||||||
|
chat_documents = self.services.chat.getChatDocumentsFromDocumentList(doc_list)
|
||||||
|
if not chat_documents:
|
||||||
|
self.services.chat.progressLogFinish(operation_id, False)
|
||||||
|
return ActionResult.isFailure(error="No documents found in documentList")
|
||||||
|
|
||||||
|
logger.info(f"Neutralizing data from {len(chat_documents)} document(s)")
|
||||||
|
self.services.chat.progressLogUpdate(operation_id, 0.3, "Processing documents")
|
||||||
|
action_documents = []
|
||||||
|
|
||||||
|
for i, chat_doc in enumerate(chat_documents):
|
||||||
|
try:
|
||||||
|
dd = getattr(chat_doc, "documentData", None)
|
||||||
|
if not dd:
|
||||||
|
logger.warning(f"Document {i + 1} has no documentData, skipping")
|
||||||
|
continue
|
||||||
|
|
||||||
|
fn = str(getattr(chat_doc, "fileName", "") or "")
|
||||||
|
mime_guess = str(getattr(chat_doc, "mimeType", "") or "").lower()
|
||||||
|
if (
|
||||||
|
mime_guess.startswith("image/")
|
||||||
|
and fn.startswith("extract_media_")
|
||||||
|
and not (isinstance(dd, dict) and dd.get("kind") == HANDOVER_KIND)
|
||||||
|
):
|
||||||
|
action_documents.append(
|
||||||
|
ActionDocument(
|
||||||
|
documentName=fn or f"media_{i + 1}",
|
||||||
|
documentData=dd,
|
||||||
|
mimeType=mime_guess or "application/octet-stream",
|
||||||
|
validationMetadata={
|
||||||
|
"actionType": "context.neutralizeData",
|
||||||
|
"neutralized": False,
|
||||||
|
"reason": "extractContent_media_sidecar_pass_through",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# --- Unified JSON envelope from context.extractContent (v1) ---
|
||||||
|
if isinstance(dd, dict) and dd.get("kind") == HANDOVER_KIND:
|
||||||
|
bundle = dict(dd)
|
||||||
|
files_section = dd.get("files") or {}
|
||||||
|
new_files = {}
|
||||||
|
for fk, bucket in files_section.items():
|
||||||
|
if not isinstance(bucket, dict):
|
||||||
|
continue
|
||||||
|
parts_raw = bucket.get("parts") or []
|
||||||
|
parsed_parts = []
|
||||||
|
for pd in parts_raw:
|
||||||
|
parsed_parts.append(ContentPart(**pd) if isinstance(pd, dict) else pd)
|
||||||
|
|
||||||
|
summary = bucket.get("summary") or {}
|
||||||
|
if hasattr(summary, "model_dump"):
|
||||||
|
summary = summary.model_dump(mode="json")
|
||||||
|
|
||||||
|
ce = ContentExtracted(
|
||||||
|
id=str(bucket.get("extractedId") or ""),
|
||||||
|
parts=parsed_parts,
|
||||||
|
summary=summary if isinstance(summary, dict) else {},
|
||||||
|
)
|
||||||
|
|
||||||
|
ce_out = await _neutralize_one_content_extracted(
|
||||||
|
svc=self,
|
||||||
|
content_extracted=ce,
|
||||||
|
operation_id=operation_id,
|
||||||
|
chat_doc_slot=i,
|
||||||
|
chat_documents_len=max(len(chat_documents), 1),
|
||||||
|
)
|
||||||
|
new_files[fk] = _one_file_bucket(ce_out, str(bucket.get("sourceFileName") or fk))
|
||||||
|
|
||||||
|
bundle["files"] = new_files
|
||||||
|
original_filename = getattr(chat_doc, "fileName", f"neutralized_bundle_{workflow_id}.json")
|
||||||
|
bn = original_filename.rsplit(".", 1)[0] if "." in original_filename else original_filename
|
||||||
|
action_documents.append(
|
||||||
|
ActionDocument(
|
||||||
|
documentName=f"{bn}_neutralized.json",
|
||||||
|
documentData=bundle,
|
||||||
|
mimeType="application/json",
|
||||||
|
validationMetadata={
|
||||||
|
"actionType": "context.neutralizeData",
|
||||||
|
"neutralized": True,
|
||||||
|
"handoverKind": HANDOVER_KIND,
|
||||||
|
"bundleFileCount": len(new_files),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# --- Legacy ContentExtracted per persisted document ---
|
||||||
|
if isinstance(dd, ContentExtracted):
|
||||||
|
content_extracted = dd
|
||||||
|
elif isinstance(dd, dict):
|
||||||
|
try:
|
||||||
|
content_extracted = ContentExtracted(**dd)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(f"Document {i + 1} documentData cannot be parsed as ContentExtracted dict")
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
logger.warning(f"Document {i + 1} documentData is not supported")
|
||||||
|
continue
|
||||||
|
|
||||||
|
neut_out = await _neutralize_one_content_extracted(
|
||||||
|
svc=self,
|
||||||
|
content_extracted=content_extracted,
|
||||||
|
operation_id=operation_id,
|
||||||
|
chat_doc_slot=i,
|
||||||
|
chat_documents_len=max(len(chat_documents), 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
original_file_name = getattr(chat_doc, "fileName", f"document_{i + 1}.json")
|
||||||
|
base_name = original_file_name.rsplit(".", 1)[0] if "." in original_file_name else original_file_name
|
||||||
|
document_name = f"{base_name}_neutralized_{neut_out.id}.json"
|
||||||
|
|
||||||
|
action_documents.append(
|
||||||
|
ActionDocument(
|
||||||
|
documentName=document_name,
|
||||||
|
documentData=neut_out,
|
||||||
|
mimeType="application/json",
|
||||||
validationMetadata={
|
validationMetadata={
|
||||||
"actionType": "context.neutralizeData",
|
"actionType": "context.neutralizeData",
|
||||||
"documentIndex": i,
|
"documentIndex": i,
|
||||||
"extractedId": contentExtracted.id,
|
"extractedId": neut_out.id,
|
||||||
"partCount": len(neutralizedParts),
|
"partCount": len(neut_out.parts),
|
||||||
"neutralized": True,
|
"neutralized": True,
|
||||||
"originalFileName": originalFileName
|
"originalFileName": original_file_name,
|
||||||
}
|
},
|
||||||
|
)
|
||||||
actionDoc = ActionDocument(
|
|
||||||
documentName=documentName,
|
|
||||||
documentData=neutralizedContentExtracted,
|
|
||||||
mimeType="application/json",
|
|
||||||
validationMetadata=validationMetadata
|
|
||||||
)
|
)
|
||||||
actionDocuments.append(actionDoc)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error processing document {i + 1}: {str(e)}")
|
logger.error(f"Error processing document {i + 1}: {str(e)}")
|
||||||
# Continue with other documents
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not actionDocuments:
|
if not action_documents:
|
||||||
self.services.chat.progressLogFinish(operationId, False)
|
self.services.chat.progressLogFinish(operation_id, False)
|
||||||
return ActionResult.isFailure(error="No valid ContentExtracted documents found to neutralize")
|
return ActionResult.isFailure(error="No valid documents found to neutralize")
|
||||||
|
|
||||||
self.services.chat.progressLogFinish(operationId, True)
|
self.services.chat.progressLogFinish(operation_id, True)
|
||||||
|
return ActionResult.isSuccess(documents=action_documents)
|
||||||
return ActionResult.isSuccess(documents=actionDocuments)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in data neutralization: {str(e)}")
|
logger.error(f"Error in data neutralization: {str(e)}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if operationId:
|
if operation_id:
|
||||||
self.services.chat.progressLogFinish(operationId, False)
|
self.services.chat.progressLogFinish(operation_id, False)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
|
||||||
459
modules/workflows/methods/methodContext/actions/setContext.py
Normal file
459
modules/workflows/methods/methodContext/actions/setContext.py
Normal file
|
|
@ -0,0 +1,459 @@
|
||||||
|
# Copyright (c) 2026 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
|
"""Action ``context.setContext``.
|
||||||
|
|
||||||
|
Stores values in the workflow context (``local`` | ``global`` | ``session``).
|
||||||
|
|
||||||
|
Each **assignment** row defines a target ``contextKey`` and how to obtain the value:
|
||||||
|
|
||||||
|
- ``valueSource=pickUpstream`` — use ``upstreamRef`` (DataRef resolved by the graph) or,
|
||||||
|
for experts, a dotted ``sourcePath`` on ``_upstreamPayload``.
|
||||||
|
- ``valueSource=literal`` — use ``literal`` (with ``valueType`` coercion).
|
||||||
|
- ``valueSource=humanTask`` — pause and create a task (requires ``_automation2Interface``).
|
||||||
|
|
||||||
|
Legacy graphs may still send ``entries`` / ``upstreamPick`` + ``targetKey``; those are
|
||||||
|
normalized into the same shape before processing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from modules.datamodels.datamodelChat import ActionResult
|
||||||
|
from modules.workflows.automation2.executors.inputExecutor import PauseForHumanTaskError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
_VALID_MODES = {"set", "setIfEmpty", "append", "increment"}
|
||||||
|
_VALID_SCOPES = {"local", "global", "session"}
|
||||||
|
_VALID_VALUE_SOURCES = {"pickUpstream", "literal", "humanTask"}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_by_path(data: Any, dotted: str) -> Any:
|
||||||
|
"""Traverse dict/list by dotted path (``payload.status``, ``items.0.name``)."""
|
||||||
|
if not dotted or not str(dotted).strip():
|
||||||
|
return None
|
||||||
|
cur: Any = data
|
||||||
|
for seg in str(dotted).strip().split("."):
|
||||||
|
if cur is None:
|
||||||
|
return None
|
||||||
|
if isinstance(cur, dict) and seg in cur:
|
||||||
|
cur = cur[seg]
|
||||||
|
continue
|
||||||
|
if isinstance(cur, (list, tuple)):
|
||||||
|
try:
|
||||||
|
idx = int(seg)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if 0 <= idx < len(cur):
|
||||||
|
cur = cur[idx]
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
return cur
|
||||||
|
|
||||||
|
|
||||||
|
def _is_unresolved_ref(value: Any) -> bool:
|
||||||
|
return isinstance(value, dict) and value.get("type") == "ref"
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_type(value: Any, type_str: str) -> Any:
|
||||||
|
"""Best-effort coerce ``value`` into the declared entry ``type``."""
|
||||||
|
if type_str in (None, "", "any", "Any"):
|
||||||
|
return value
|
||||||
|
try:
|
||||||
|
if type_str == "str":
|
||||||
|
return "" if value is None else str(value)
|
||||||
|
if type_str == "int":
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return int(value)
|
||||||
|
if value is None or value == "":
|
||||||
|
return 0
|
||||||
|
return int(float(value))
|
||||||
|
if type_str == "float":
|
||||||
|
if value is None or value == "":
|
||||||
|
return 0.0
|
||||||
|
return float(value)
|
||||||
|
if type_str == "bool":
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return bool(value)
|
||||||
|
return str(value).strip().lower() in ("1", "true", "yes", "on", "ja")
|
||||||
|
if type_str in ("list", "List", "array"):
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
if isinstance(value, str) and value.strip().startswith(("[", "{")):
|
||||||
|
try:
|
||||||
|
parsed = json.loads(value)
|
||||||
|
return parsed if isinstance(parsed, list) else [parsed]
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
return value if isinstance(value, list) else [value]
|
||||||
|
if type_str in ("object", "dict", "Dict"):
|
||||||
|
if isinstance(value, str) and value.strip().startswith("{"):
|
||||||
|
try:
|
||||||
|
parsed = json.loads(value)
|
||||||
|
return parsed if isinstance(value, dict) else {"value": parsed}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
return value if isinstance(value, dict) else {"value": value}
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
logger.warning("setContext._coerce_type %r → %s failed: %s", value, type_str, exc)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_store(scope: str, run_context: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
|
"""Return the dict that backs the requested scope."""
|
||||||
|
if not isinstance(run_context, dict):
|
||||||
|
return {}
|
||||||
|
if scope == "global":
|
||||||
|
return run_context.setdefault("_globalContext", {})
|
||||||
|
if scope == "session":
|
||||||
|
return run_context.setdefault("_sessionContext", {})
|
||||||
|
return run_context.setdefault("_localContext", {})
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_context_key(entry: Dict[str, Any]) -> Optional[str]:
|
||||||
|
ck = entry.get("contextKey") or entry.get("key")
|
||||||
|
if ck is None:
|
||||||
|
return None
|
||||||
|
s = str(ck).strip()
|
||||||
|
return s or None
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_value_to_store(
|
||||||
|
store: Dict[str, Any],
|
||||||
|
context_key: str,
|
||||||
|
value: Any,
|
||||||
|
mode: str,
|
||||||
|
type_str: str,
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Apply coerced ``value`` to ``store[context_key]``. Returns error string or None."""
|
||||||
|
if mode not in _VALID_MODES:
|
||||||
|
return f"unknown mode '{mode}' on key '{context_key}'"
|
||||||
|
|
||||||
|
coerced = _coerce_type(value, str(type_str or ""))
|
||||||
|
|
||||||
|
if mode == "set":
|
||||||
|
store[context_key] = coerced
|
||||||
|
return None
|
||||||
|
if mode == "setIfEmpty":
|
||||||
|
if context_key not in store or store.get(context_key) in (None, "", [], {}):
|
||||||
|
store[context_key] = coerced
|
||||||
|
return None
|
||||||
|
if mode == "append":
|
||||||
|
existing = store.get(context_key)
|
||||||
|
if existing is None:
|
||||||
|
store[context_key] = [coerced] if not isinstance(coerced, list) else list(coerced)
|
||||||
|
elif isinstance(existing, list):
|
||||||
|
if isinstance(coerced, list):
|
||||||
|
existing.extend(coerced)
|
||||||
|
else:
|
||||||
|
existing.append(coerced)
|
||||||
|
elif isinstance(existing, str):
|
||||||
|
store[context_key] = existing + ("" if coerced is None else str(coerced))
|
||||||
|
else:
|
||||||
|
store[context_key] = [existing, coerced]
|
||||||
|
return None
|
||||||
|
if mode == "increment":
|
||||||
|
existing = store.get(context_key, 0)
|
||||||
|
try:
|
||||||
|
store[context_key] = (
|
||||||
|
float(existing) + float(coerced)
|
||||||
|
if isinstance(existing, float) or isinstance(coerced, float)
|
||||||
|
else int(existing) + int(coerced)
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return f"increment requires numeric value/state for key '{context_key}'"
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _value_source(row: Dict[str, Any]) -> str:
|
||||||
|
vs = row.get("valueSource")
|
||||||
|
if isinstance(vs, str) and vs.strip() in _VALID_VALUE_SOURCES:
|
||||||
|
return vs.strip()
|
||||||
|
am = str(row.get("assignmentMode") or "direct").strip()
|
||||||
|
if am == "fromUpstream":
|
||||||
|
return "pickUpstream"
|
||||||
|
if am == "humanTask":
|
||||||
|
return "humanTask"
|
||||||
|
if am == "direct":
|
||||||
|
return "literal"
|
||||||
|
return "literal"
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_assignments(parameters: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||||
|
"""Build a single list of assignment dicts from new or legacy parameters."""
|
||||||
|
raw = parameters.get("assignments")
|
||||||
|
if isinstance(raw, list) and raw:
|
||||||
|
out: List[Dict[str, Any]] = []
|
||||||
|
for item in raw:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
out.append(dict(item))
|
||||||
|
if out:
|
||||||
|
return out
|
||||||
|
|
||||||
|
legacy_entries = parameters.get("entries")
|
||||||
|
global_pick = parameters.get("upstreamPick")
|
||||||
|
|
||||||
|
if isinstance(legacy_entries, list) and legacy_entries:
|
||||||
|
out = []
|
||||||
|
for entry in legacy_entries:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
row = dict(entry)
|
||||||
|
row["valueSource"] = _value_source(entry)
|
||||||
|
am = str(entry.get("assignmentMode") or "direct").strip()
|
||||||
|
if am == "fromUpstream" and not str(entry.get("sourcePath") or "").strip():
|
||||||
|
if global_pick is not None and not (isinstance(global_pick, str) and not global_pick.strip()):
|
||||||
|
if not (isinstance(global_pick, (list, dict)) and len(global_pick) == 0):
|
||||||
|
row["upstreamRef"] = global_pick
|
||||||
|
if am == "direct":
|
||||||
|
row["literal"] = entry.get("value")
|
||||||
|
row["valueSource"] = "literal"
|
||||||
|
out.append(row)
|
||||||
|
if out:
|
||||||
|
return out
|
||||||
|
|
||||||
|
tk = str(parameters.get("targetKey") or "").strip()
|
||||||
|
if tk and global_pick is not None:
|
||||||
|
if isinstance(global_pick, str) and not global_pick.strip():
|
||||||
|
pass
|
||||||
|
elif isinstance(global_pick, (list, dict)) and len(global_pick) == 0:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"contextKey": tk,
|
||||||
|
"valueSource": "pickUpstream",
|
||||||
|
"upstreamRef": global_pick,
|
||||||
|
"mode": "set",
|
||||||
|
"valueType": "str",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_pick_upstream(
|
||||||
|
row: Dict[str, Any],
|
||||||
|
upstream: Any,
|
||||||
|
parameters: Dict[str, Any],
|
||||||
|
) -> Tuple[Optional[Any], Optional[str]]:
|
||||||
|
path = str(row.get("sourcePath") or "").strip()
|
||||||
|
ref_val = row.get("upstreamRef")
|
||||||
|
|
||||||
|
if ref_val is not None and ref_val != "":
|
||||||
|
if _is_unresolved_ref(ref_val):
|
||||||
|
return None, "upstream DataRef konnte nicht aufgelöst werden"
|
||||||
|
base: Any = ref_val
|
||||||
|
if path:
|
||||||
|
hit = _get_by_path(base, path)
|
||||||
|
if hit is None and isinstance(upstream, dict):
|
||||||
|
hit = _get_by_path(upstream, path)
|
||||||
|
if hit is not None:
|
||||||
|
return hit, None
|
||||||
|
return None, f"path '{path}' not found under picked value or upstream payload"
|
||||||
|
return base, None
|
||||||
|
|
||||||
|
if path:
|
||||||
|
if not isinstance(upstream, dict):
|
||||||
|
return None, "sourcePath benötigt ein strukturiertes Upstream-Payload (dict)"
|
||||||
|
return _get_by_path(upstream, path), None
|
||||||
|
|
||||||
|
return None, "Picker: Datenquelle wählen oder sourcePath (z. B. payload.status) setzen"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_literal(row: Dict[str, Any]) -> Tuple[Optional[Any], Optional[str]]:
|
||||||
|
raw = row.get("literal")
|
||||||
|
if raw is None and "value" in row:
|
||||||
|
raw = row.get("value")
|
||||||
|
if raw is None:
|
||||||
|
return None, "literal value missing"
|
||||||
|
if isinstance(raw, (dict, list, bool, int, float)) or raw is None:
|
||||||
|
return raw, None
|
||||||
|
s = str(raw)
|
||||||
|
type_str = str(row.get("valueType") or row.get("type") or "str")
|
||||||
|
if type_str in ("object", "dict", "Dict", "list", "List", "array") and s.strip().startswith(("[", "{")):
|
||||||
|
try:
|
||||||
|
return json.loads(s), None
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
return None, f"invalid JSON literal: {exc}"
|
||||||
|
return s, None
|
||||||
|
|
||||||
|
|
||||||
|
def _pause_for_human_tasks(
|
||||||
|
*,
|
||||||
|
iface: Any,
|
||||||
|
run_context: Dict[str, Any],
|
||||||
|
parameters: Dict[str, Any],
|
||||||
|
pending_entries: List[Dict[str, Any]],
|
||||||
|
scope: str,
|
||||||
|
) -> None:
|
||||||
|
"""Create a single human task for all ``humanTask`` rows and pause the run."""
|
||||||
|
run_id = str(run_context.get("_runId") or "")
|
||||||
|
workflow_id = str(run_context.get("workflowId") or "")
|
||||||
|
node_id = str(parameters.get("_workflowNodeId") or "")
|
||||||
|
user_id = run_context.get("userId")
|
||||||
|
|
||||||
|
cfg = {
|
||||||
|
"kind": "contextSetAssignment",
|
||||||
|
"scope": scope,
|
||||||
|
"entries": pending_entries,
|
||||||
|
"description": (
|
||||||
|
"Set or confirm workflow context keys. After completion, resume the run;"
|
||||||
|
" submitted values should be merged into context by the task handler."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
task = iface.createTask(
|
||||||
|
runId=run_id,
|
||||||
|
workflowId=workflow_id,
|
||||||
|
nodeId=node_id,
|
||||||
|
nodeType="context.setContext",
|
||||||
|
config=cfg,
|
||||||
|
assigneeId=str(user_id) if user_id else None,
|
||||||
|
)
|
||||||
|
task_id = str((task or {}).get("id") or "")
|
||||||
|
ordered_ids = [n.get("id") for n in (run_context.get("_orderedNodes") or []) if n.get("id")]
|
||||||
|
from modules.workflows.automation2.graphicalEditorRunFileLogger import merge_persisted_run_context
|
||||||
|
|
||||||
|
_pause_ctx = merge_persisted_run_context(
|
||||||
|
iface,
|
||||||
|
run_id,
|
||||||
|
{
|
||||||
|
"connectionMap": run_context.get("connectionMap"),
|
||||||
|
"inputSources": run_context.get("inputSources"),
|
||||||
|
"orderedNodeIds": ordered_ids,
|
||||||
|
"pauseReason": "contextAssignment",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
iface.updateRun(
|
||||||
|
run_id,
|
||||||
|
status="paused",
|
||||||
|
nodeOutputs=run_context.get("nodeOutputs"),
|
||||||
|
currentNodeId=node_id,
|
||||||
|
context=_pause_ctx,
|
||||||
|
)
|
||||||
|
if not (run_id and task_id and node_id):
|
||||||
|
raise RuntimeError("humanTask requires _runId, task id, and _workflowNodeId")
|
||||||
|
raise PauseForHumanTaskError(runId=run_id, taskId=task_id, nodeId=node_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def setContext(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||||
|
try:
|
||||||
|
scope = str(parameters.get("scope") or "local")
|
||||||
|
if scope not in _VALID_SCOPES:
|
||||||
|
return ActionResult.isFailure(error=f"Invalid scope '{scope}', expected one of {sorted(_VALID_SCOPES)}")
|
||||||
|
|
||||||
|
entries: List[Dict[str, Any]] = _normalize_assignments(parameters)
|
||||||
|
if not entries:
|
||||||
|
return ActionResult.isFailure(
|
||||||
|
error="Mindestens eine Zuweisung konfigurieren (Ziel-Schlüssel, Quelle und Wert / Picker / Task).",
|
||||||
|
)
|
||||||
|
|
||||||
|
run_context = parameters.get("_runContext")
|
||||||
|
if not isinstance(run_context, dict):
|
||||||
|
return ActionResult.isFailure(error="internal: execution context missing")
|
||||||
|
|
||||||
|
store = _resolve_store(scope, run_context)
|
||||||
|
upstream = parameters.get("_upstreamPayload")
|
||||||
|
|
||||||
|
applied: Dict[str, Any] = {}
|
||||||
|
errors: List[str] = []
|
||||||
|
human_rows: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
errors.append("entry is not an object")
|
||||||
|
continue
|
||||||
|
|
||||||
|
ck = _entry_context_key(entry)
|
||||||
|
if not ck:
|
||||||
|
errors.append("assignment needs contextKey")
|
||||||
|
continue
|
||||||
|
|
||||||
|
vs = _value_source(entry)
|
||||||
|
if vs not in _VALID_VALUE_SOURCES:
|
||||||
|
errors.append(f"{ck}: unknown valueSource '{vs}'")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if vs == "humanTask":
|
||||||
|
human_rows.append(
|
||||||
|
{
|
||||||
|
"contextKey": ck,
|
||||||
|
"sourcePath": entry.get("sourcePath"),
|
||||||
|
"taskTitle": entry.get("taskTitle"),
|
||||||
|
"taskDescription": entry.get("taskDescription"),
|
||||||
|
"type": entry.get("valueType") or entry.get("type"),
|
||||||
|
"mode": entry.get("mode") or "set",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
val: Any = None
|
||||||
|
err: Optional[str] = None
|
||||||
|
|
||||||
|
if vs == "pickUpstream":
|
||||||
|
val, err = _resolve_pick_upstream(entry, upstream, parameters)
|
||||||
|
else:
|
||||||
|
val, err = _resolve_literal(entry)
|
||||||
|
|
||||||
|
if err:
|
||||||
|
errors.append(f"{ck}: {err}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
err2 = _apply_value_to_store(
|
||||||
|
store,
|
||||||
|
ck,
|
||||||
|
val,
|
||||||
|
str(entry.get("mode") or "set"),
|
||||||
|
str(entry.get("valueType") or entry.get("type") or ""),
|
||||||
|
)
|
||||||
|
if err2:
|
||||||
|
errors.append(f"{ck}: {err2}")
|
||||||
|
continue
|
||||||
|
applied[ck] = store.get(ck)
|
||||||
|
|
||||||
|
iface = run_context.get("_automation2Interface")
|
||||||
|
if human_rows:
|
||||||
|
if iface:
|
||||||
|
_pause_for_human_tasks(
|
||||||
|
iface=iface,
|
||||||
|
run_context=run_context,
|
||||||
|
parameters=parameters,
|
||||||
|
pending_entries=human_rows,
|
||||||
|
scope=scope,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
applied["_humanTaskFallback"] = (
|
||||||
|
"humanTask requires a live automation2 interface on the run; "
|
||||||
|
"configure execution via the graphical editor API or add an input.human node."
|
||||||
|
)
|
||||||
|
applied["_pendingHumanContextKeys"] = [r["contextKey"] for r in human_rows]
|
||||||
|
|
||||||
|
if errors and not applied and not human_rows:
|
||||||
|
return ActionResult.isFailure(error="; ".join(errors))
|
||||||
|
|
||||||
|
data: Dict[str, Any] = dict(applied)
|
||||||
|
data["_scope"] = scope
|
||||||
|
data["_appliedKeys"] = [k for k in applied if not str(k).startswith("_")]
|
||||||
|
if errors:
|
||||||
|
data["_warnings"] = errors
|
||||||
|
|
||||||
|
if isinstance(upstream, dict):
|
||||||
|
meta = upstream.get("_meta")
|
||||||
|
if isinstance(meta, dict):
|
||||||
|
data["_meta"] = meta
|
||||||
|
data.setdefault("_transit", True)
|
||||||
|
|
||||||
|
return ActionResult.isSuccess(data=data)
|
||||||
|
except PauseForHumanTaskError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("setContext failed")
|
||||||
|
return ActionResult.isFailure(error=str(exc))
|
||||||
|
|
@ -0,0 +1,223 @@
|
||||||
|
# Copyright (c) 2026 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
|
"""Action ``context.transformContext``.
|
||||||
|
|
||||||
|
Applies a sequence of mappings to the upstream payload. Supported operations:
|
||||||
|
|
||||||
|
- ``rename`` — copy a source path to a new output key
|
||||||
|
- ``cast`` — copy and convert to a target type (errors recorded in ``_castErrors``)
|
||||||
|
- ``nest`` — group several mappings under a dotted ``outputField`` (e.g. ``address.city``)
|
||||||
|
- ``flatten`` — copy a nested dict's leaves up to the configured ``flattenDepth``
|
||||||
|
- ``compute`` — render a ``{{...}}`` template using the upstream payload as scope
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from modules.datamodels.datamodelChat import ActionResult
|
||||||
|
from modules.workflows.methods.methodContext.contextEnvelope import wrap_transform_context_data
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
_VALID_OPERATIONS = {"rename", "cast", "nest", "flatten", "compute"}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_path(payload: Any, dotted: str) -> Any:
|
||||||
|
cur = payload
|
||||||
|
for seg in str(dotted).split("."):
|
||||||
|
if cur is None:
|
||||||
|
return None
|
||||||
|
if isinstance(cur, dict):
|
||||||
|
cur = cur.get(seg)
|
||||||
|
continue
|
||||||
|
if isinstance(cur, list):
|
||||||
|
try:
|
||||||
|
cur = cur[int(seg)]
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return None
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
return cur
|
||||||
|
|
||||||
|
|
||||||
|
def _set_path(target: Dict[str, Any], dotted: str, value: Any) -> None:
|
||||||
|
parts = str(dotted).split(".")
|
||||||
|
cur = target
|
||||||
|
for seg in parts[:-1]:
|
||||||
|
nxt = cur.get(seg)
|
||||||
|
if not isinstance(nxt, dict):
|
||||||
|
nxt = {}
|
||||||
|
cur[seg] = nxt
|
||||||
|
cur = nxt
|
||||||
|
cur[parts[-1]] = value
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_type(value: Any, type_str: str) -> Any:
|
||||||
|
if type_str in (None, "", "any", "Any"):
|
||||||
|
return value
|
||||||
|
if type_str == "str":
|
||||||
|
return "" if value is None else str(value)
|
||||||
|
if type_str == "int":
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return int(value)
|
||||||
|
if value is None or value == "":
|
||||||
|
raise ValueError("empty value")
|
||||||
|
return int(float(value))
|
||||||
|
if type_str == "float":
|
||||||
|
if value is None or value == "":
|
||||||
|
raise ValueError("empty value")
|
||||||
|
return float(value)
|
||||||
|
if type_str == "bool":
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return bool(value)
|
||||||
|
return str(value).strip().lower() in ("1", "true", "yes", "on", "ja")
|
||||||
|
if type_str in ("list", "List", "array"):
|
||||||
|
return value if isinstance(value, list) else ([value] if value is not None else [])
|
||||||
|
if type_str in ("object", "dict", "Dict"):
|
||||||
|
return value if isinstance(value, dict) else {"value": value}
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
_TEMPLATE_RE = re.compile(r"\{\{\s*([^{}\s|]+)(?:\s*\|\s*([^{}]*))?\s*\}\}")
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_filter(value: Any, filter_chain: str) -> Any:
|
||||||
|
"""Minimal filter pipeline: ``upper``, ``lower``, ``trim``, ``default:foo``."""
|
||||||
|
out = value
|
||||||
|
for token in filter_chain.split("|"):
|
||||||
|
f = token.strip()
|
||||||
|
if not f:
|
||||||
|
continue
|
||||||
|
if f == "upper":
|
||||||
|
out = "" if out is None else str(out).upper()
|
||||||
|
elif f == "lower":
|
||||||
|
out = "" if out is None else str(out).lower()
|
||||||
|
elif f == "trim":
|
||||||
|
out = "" if out is None else str(out).strip()
|
||||||
|
elif f.startswith("default:"):
|
||||||
|
if out is None or out == "":
|
||||||
|
out = f.split(":", 1)[1]
|
||||||
|
else:
|
||||||
|
logger.debug("transformContext: unknown filter '%s' ignored", f)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _render_template(template: str, scope: Dict[str, Any]) -> str:
|
||||||
|
def replace(match: re.Match) -> str:
|
||||||
|
path = match.group(1)
|
||||||
|
filters = match.group(2) or ""
|
||||||
|
value = _get_path(scope, path)
|
||||||
|
if filters:
|
||||||
|
value = _apply_filter(value, filters)
|
||||||
|
return "" if value is None else str(value)
|
||||||
|
|
||||||
|
return _TEMPLATE_RE.sub(replace, template)
|
||||||
|
|
||||||
|
|
||||||
|
def _flatten_with_depth(node: Any, depth: int, prefix: str = "") -> Dict[str, Any]:
|
||||||
|
out: Dict[str, Any] = {}
|
||||||
|
if not isinstance(node, dict) or depth == 0:
|
||||||
|
if prefix:
|
||||||
|
out[prefix] = node
|
||||||
|
return out
|
||||||
|
for k, v in node.items():
|
||||||
|
path = f"{prefix}.{k}" if prefix else str(k)
|
||||||
|
if isinstance(v, dict) and depth != 1:
|
||||||
|
out.update(_flatten_with_depth(v, depth - 1 if depth > 0 else -1, path))
|
||||||
|
elif isinstance(v, dict):
|
||||||
|
out[path] = v
|
||||||
|
else:
|
||||||
|
out[path] = v
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def transformContext(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||||
|
try:
|
||||||
|
mappings: List[Dict[str, Any]] = parameters.get("mappings") or []
|
||||||
|
if not isinstance(mappings, list) or not mappings:
|
||||||
|
return ActionResult.isFailure(error="'mappings' must be a non-empty list")
|
||||||
|
|
||||||
|
passthrough = bool(parameters.get("passthroughUnmapped", False))
|
||||||
|
flatten_depth = int(parameters.get("flattenDepth") or 1)
|
||||||
|
|
||||||
|
upstream = parameters.get("_upstreamPayload")
|
||||||
|
if not isinstance(upstream, dict):
|
||||||
|
upstream = {"value": upstream} if upstream is not None else {}
|
||||||
|
|
||||||
|
result: Dict[str, Any] = {}
|
||||||
|
consumed_paths: set = set()
|
||||||
|
cast_errors: Dict[str, str] = {}
|
||||||
|
|
||||||
|
for m in mappings:
|
||||||
|
if not isinstance(m, dict):
|
||||||
|
continue
|
||||||
|
op = str(m.get("operation") or "rename")
|
||||||
|
if op not in _VALID_OPERATIONS:
|
||||||
|
cast_errors[str(m.get("outputField") or "?")] = f"unknown operation '{op}'"
|
||||||
|
continue
|
||||||
|
output_field = str(m.get("outputField") or "").strip()
|
||||||
|
if not output_field:
|
||||||
|
continue
|
||||||
|
source_field = str(m.get("sourceField") or "").strip()
|
||||||
|
target_type = str(m.get("type") or "")
|
||||||
|
|
||||||
|
if op == "compute":
|
||||||
|
expression = str(m.get("expression") or m.get("sourceField") or "")
|
||||||
|
value = _render_template(expression, upstream)
|
||||||
|
if target_type:
|
||||||
|
try:
|
||||||
|
value = _coerce_type(value, target_type)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
cast_errors[output_field] = str(exc)
|
||||||
|
value = None
|
||||||
|
_set_path(result, output_field, value)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if op == "flatten":
|
||||||
|
base = _get_path(upstream, source_field) if source_field else upstream
|
||||||
|
flat = _flatten_with_depth(base, flatten_depth, output_field if source_field else "")
|
||||||
|
for path, val in flat.items():
|
||||||
|
_set_path(result, path or output_field, val)
|
||||||
|
if source_field:
|
||||||
|
consumed_paths.add(source_field)
|
||||||
|
continue
|
||||||
|
|
||||||
|
value = _get_path(upstream, source_field) if source_field else None
|
||||||
|
if source_field:
|
||||||
|
consumed_paths.add(source_field)
|
||||||
|
|
||||||
|
if op == "cast" and target_type:
|
||||||
|
try:
|
||||||
|
value = _coerce_type(value, target_type)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
cast_errors[output_field] = str(exc)
|
||||||
|
value = None
|
||||||
|
elif op == "rename" and target_type:
|
||||||
|
# Optional explicit type on rename is treated like cast best-effort.
|
||||||
|
try:
|
||||||
|
value = _coerce_type(value, target_type)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
cast_errors[output_field] = str(exc)
|
||||||
|
# ``nest`` is implicit: dotted ``outputField`` writes into a nested dict
|
||||||
|
_set_path(result, output_field, value)
|
||||||
|
|
||||||
|
if passthrough:
|
||||||
|
for k, v in upstream.items():
|
||||||
|
if k.startswith("_"):
|
||||||
|
continue
|
||||||
|
if k in result or k in consumed_paths:
|
||||||
|
continue
|
||||||
|
result[k] = v
|
||||||
|
|
||||||
|
if cast_errors:
|
||||||
|
result["_castErrors"] = cast_errors
|
||||||
|
return ActionResult.isSuccess(data=wrap_transform_context_data(result))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("transformContext failed")
|
||||||
|
return ActionResult.isFailure(error=str(exc))
|
||||||
42
modules/workflows/methods/methodContext/contextEnvelope.py
Normal file
42
modules/workflows/methods/methodContext/contextEnvelope.py
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
# Copyright (c) 2026 Patrick Motsch
|
||||||
|
"""Versioned ``ActionResult.data`` envelope for context.* actions (merge, transform)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
CONTEXT_MERGE_KIND = "context.mergeContext.v1"
|
||||||
|
CONTEXT_MERGE_SCHEMA_VERSION = 1
|
||||||
|
|
||||||
|
CONTEXT_TRANSFORM_KIND = "context.transformContext.v1"
|
||||||
|
CONTEXT_TRANSFORM_SCHEMA_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
|
def wrap_merge_context_data(body: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Wrap merge payload: ``schemaVersion``, ``kind``, body fields, ``_meta`` last."""
|
||||||
|
meta: Dict[str, Any] = {
|
||||||
|
"actionType": "context.mergeContext",
|
||||||
|
"mergePayloadSchemaVersion": CONTEXT_MERGE_SCHEMA_VERSION,
|
||||||
|
}
|
||||||
|
out: Dict[str, Any] = {
|
||||||
|
"schemaVersion": CONTEXT_MERGE_SCHEMA_VERSION,
|
||||||
|
"kind": CONTEXT_MERGE_KIND,
|
||||||
|
}
|
||||||
|
out.update(body)
|
||||||
|
out["_meta"] = meta
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def wrap_transform_context_data(fields: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Wrap transform output fields under a versioned envelope (``_meta`` overwrites same key in fields)."""
|
||||||
|
meta: Dict[str, Any] = {
|
||||||
|
"actionType": "context.transformContext",
|
||||||
|
"transformPayloadSchemaVersion": CONTEXT_TRANSFORM_SCHEMA_VERSION,
|
||||||
|
}
|
||||||
|
out: Dict[str, Any] = {
|
||||||
|
"schemaVersion": CONTEXT_TRANSFORM_SCHEMA_VERSION,
|
||||||
|
"kind": CONTEXT_TRANSFORM_KIND,
|
||||||
|
}
|
||||||
|
out.update(fields)
|
||||||
|
out["_meta"] = meta
|
||||||
|
return out
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue