Compare commits
29 commits
513ded84d5
...
4ed9b605fc
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ed9b605fc | |||
| ab48aaea3c | |||
| 88bc18fd92 | |||
| 082c95d0f0 | |||
| 4d1a579dbd | |||
| c097b28b6c | |||
| 0f8e128eaa | |||
| e79f87fd17 | |||
| ab34f687dd | |||
| ee37d36a42 | |||
|
|
13e8d0a808 | ||
|
|
5277ced44c | ||
|
|
b264297193 | ||
| 53e4573332 | |||
| 996cb4a775 | |||
| 4016ec31fa | |||
| 25bf4ad5d7 | |||
| 422598ff2a | |||
| 41b2113bd5 | |||
| 9a498bf833 | |||
| 5295484fa4 | |||
| 1c973d5dfe | |||
| 41a6b9759c | |||
| 76043a6c02 | |||
| af3e69332c | |||
| ca84dda1e9 | |||
| 64dda97473 | |||
| 592f51aa21 | |||
| 67f64634ff |
48 changed files with 2904 additions and 2163 deletions
1036
.cursor/plans/swift_ios_app_nachbau_3dc75f35.plan.md
Normal file
1036
.cursor/plans/swift_ios_app_nachbau_3dc75f35.plan.md
Normal file
File diff suppressed because it is too large
Load diff
741
.cursor/plans/swift_ios_app_nachbau_80bb1212.plan.md
Normal file
741
.cursor/plans/swift_ios_app_nachbau_80bb1212.plan.md
Normal file
|
|
@ -0,0 +1,741 @@
|
||||||
|
---
|
||||||
|
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.
|
||||||
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
name: Deploy Plattform-Core (Int)
|
name: Deploy Plattform-Core INT
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- int
|
- int
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
@ -12,7 +14,7 @@ jobs:
|
||||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
|
echo "$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
|
||||||
|
|
@ -22,9 +24,9 @@ jobs:
|
||||||
git remote set-url origin ssh://git@git.poweron.swiss:2222/PowerOn/platform-core.git
|
git remote set-url origin ssh://git@git.poweron.swiss:2222/PowerOn/platform-core.git
|
||||||
git fetch origin int
|
git fetch origin int
|
||||||
git reset --hard origin/int
|
git reset --hard origin/int
|
||||||
test -f env-int.env
|
test -f env-gateway-int-forgejo.env
|
||||||
cp env-int.env .env
|
cp env-gateway-int-forgejo.env .env
|
||||||
rm -f env-*.env
|
rm -f env-gateway-dev.env env-gateway-int.env env-gateway-prod.env env-gateway-prod-forgejo.env env-gateway-int-forgejo.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,7 +41,7 @@ jobs:
|
||||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
|
echo "$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
|
||||||
|
|
@ -49,10 +51,10 @@ jobs:
|
||||||
git remote set-url origin ssh://git@git.poweron.swiss:2222/PowerOn/platform-core.git
|
git remote set-url origin ssh://git@git.poweron.swiss:2222/PowerOn/platform-core.git
|
||||||
git fetch origin int
|
git fetch origin int
|
||||||
git reset --hard origin/int
|
git reset --hard origin/int
|
||||||
test -f env-int.env
|
test -f env-gateway-int-forgejo.env
|
||||||
cp env-int.env .env
|
cp env-gateway-int-forgejo.env .env
|
||||||
rm -f env-*.env
|
rm -f env-gateway-dev.env env-gateway-int.env env-gateway-prod.env env-gateway-prod-forgejo.env env-gateway-int-forgejo.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
|
||||||
"
|
"
|
||||||
|
|
@ -12,7 +12,7 @@ jobs:
|
||||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
|
echo "$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
|
||||||
|
|
@ -22,9 +22,9 @@ jobs:
|
||||||
git remote set-url origin ssh://git@git.poweron.swiss:2222/PowerOn/platform-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-prod.env
|
test -f env-gateway-prod-forgejo.env
|
||||||
cp env-prod.env .env
|
cp env-gateway-prod-forgejo.env .env
|
||||||
rm -f env-*.env
|
rm -f env-gateway-dev.env env-gateway-int.env env-gateway-prod.env env-gateway-prod-forgejo.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,7 +39,7 @@ jobs:
|
||||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
|
echo "$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
|
||||||
|
|
@ -49,9 +49,9 @@ jobs:
|
||||||
git remote set-url origin ssh://git@git.poweron.swiss:2222/PowerOn/platform-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-prod.env
|
test -f env-gateway-prod-forgejo.env
|
||||||
cp env-prod.env .env
|
cp env-gateway-prod-forgejo.env .env
|
||||||
rm -f env-*.env
|
rm -f env-gateway-dev.env env-gateway-int.env env-gateway-prod.env env-gateway-prod-forgejo.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
Normal file
74
.github/scripts/load_config_key_from_azure.py
vendored
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
#!/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
Normal file
194
.github/workflows/deploy-gcp.yml
vendored
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
# 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
Normal file
121
.github/workflows/int_gateway-int.yml
vendored
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
# 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
Normal file
121
.github/workflows/main_gateway-prod.yml
vendored
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
# 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
Normal file
70
.github/workflows/update-requirements-lock.yml
vendored
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
# 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,4 +46,5 @@ 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
|
||||||
CMD exec uvicorn app:app --host 0.0.0.0 --port ${PORT:-8000} --workers 1 --timeout-graceful-shutdown 5
|
# 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
|
||||||
|
|
|
||||||
59
app.py
59
app.py
|
|
@ -426,36 +426,32 @@ async def lifespan(app: FastAPI):
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# --- Shutdown sequence (protected against CancelledError) ---
|
# --- Stop Managers ---
|
||||||
|
eventManager.stop()
|
||||||
|
|
||||||
|
# --- Stop Feature Containers (Plug&Play) ---
|
||||||
try:
|
try:
|
||||||
# 1. Stop scheduler first (removes all pending cron/interval jobs)
|
mainModules = loadFeatureMainModules()
|
||||||
eventManager.stop()
|
for featureName, module in mainModules.items():
|
||||||
|
if hasattr(module, "onStop"):
|
||||||
|
try:
|
||||||
|
await module.onStop(eventUser)
|
||||||
|
logger.info(f"Feature '{featureName}' stopped")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Feature '{featureName}' failed to stop: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not shutdown feature containers: {e}")
|
||||||
|
|
||||||
# 2. Stop Feature Containers (Plug&Play)
|
# --- Close all PostgreSQL connection pools ---
|
||||||
try:
|
# Must run LAST: feature `onStop` hooks may still issue DB calls during
|
||||||
mainModules = loadFeatureMainModules()
|
# shutdown. Once we tear down the pools, no more borrows are possible.
|
||||||
for featureName, module in mainModules.items():
|
try:
|
||||||
if hasattr(module, "onStop"):
|
from modules.connectors.connectorDbPostgre import closeAllPools
|
||||||
try:
|
closeAllPools()
|
||||||
await module.onStop(eventUser)
|
except Exception as e:
|
||||||
logger.info(f"Feature '{featureName}' stopped")
|
logger.warning(f"Closing DB connection pools failed: {e}")
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Feature '{featureName}' failed to stop: {e}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Could not shutdown feature containers: {e}")
|
|
||||||
|
|
||||||
# 3. Close all PostgreSQL connection pools (LAST -- features may still
|
logger.info("Application has been shut down")
|
||||||
# issue DB calls during their onStop hooks)
|
|
||||||
try:
|
|
||||||
from modules.connectors.connectorDbPostgre import closeAllPools
|
|
||||||
closeAllPools()
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Closing DB connection pools failed: {e}")
|
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -723,11 +719,4 @@ app.include_router(automationWorkspaceRouter)
|
||||||
from modules.system.registry import loadFeatureRouters
|
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)
|
|
||||||
|
|
@ -73,6 +73,9 @@ 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
|
||||||
92
env-gateway-int-forgejo.env
Normal file
92
env-gateway-int-forgejo.env
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
# Integration Environment Configuration
|
||||||
|
|
||||||
|
# System Configuration
|
||||||
|
APP_ENV_TYPE = int
|
||||||
|
APP_ENV_LABEL = Integration Instance
|
||||||
|
APP_KEY_SYSVAR = /srv/gateway/shared/secrets/master_key.txt
|
||||||
|
APP_API_URL = https://api-int.poweron.swiss
|
||||||
|
APP_COOKIE_SECURE = true
|
||||||
|
APP_INIT_PASS_ADMIN_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjWm41MWZ4TUZGaVlrX3pWZWNwakJsY3Facm0wLVZDd1VKeTFoZEVZQnItcEdUUnVJS1NXeDBpM2xKbGRsYmxOSmRhc29PZjJSU2txQjdLbUVrTTE1NEJjUXBHbV9NOVJWZUR3QlJkQnJvTEU9
|
||||||
|
APP_INIT_PASS_EVENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjdmtrakgxa0djekZVNGtTZV8wM2I5UUpCZllveVBMWXROYk5yS3BiV3JEelJSM09VYTRONHpnY3VtMGxDRk5JTEZSRFhtcDZ0RVRmZ1RicTFhb3c5dVZRQ1o4SmlkLVpPTW5MMTU2eTQ0Vkk9
|
||||||
|
|
||||||
|
# PostgreSQL DB Host
|
||||||
|
DB_HOST=10.20.0.175
|
||||||
|
DB_USER=poweron_dev
|
||||||
|
DB_PASSWORD_SECRET = your_new_password_here
|
||||||
|
DB_PORT=5432
|
||||||
|
|
||||||
|
# Security Configuration
|
||||||
|
APP_JWT_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNUctb2RwU25iR3ZnanBOdHZhWUtIajZ1RnZzTEp4aDR0MktWRjNoeVBrY1Npd1R0VE9YVHp3M2w1cXRzbUxNaU82QUJvaDNFeVQyN05KblRWblBvbWtoT0VXbkNBbDQ5OHhwSUFnaDZGRG10Vmgtdm1YUkRsYUhFMzRVZURmSFlDTFIzVWg4MXNueDZyMGc5aVpFdWRxY3dkTExGM093ZTVUZVl5LUhGWnlRPQ==
|
||||||
|
APP_TOKEN_EXPIRY=300
|
||||||
|
|
||||||
|
# CORS Configuration
|
||||||
|
APP_ALLOWED_ORIGINS=https://porta-int.poweron.swiss
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
|
APP_LOGGING_LOG_LEVEL = DEBUG
|
||||||
|
APP_LOGGING_LOG_DIR = srv/gateway/shared/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 (Graph / Google APIs)
|
||||||
|
Service_MSFT_AUTH_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
|
||||||
|
Service_MSFT_AUTH_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnFBa1kyS1hWZXEzUzZTTE5MUlJncVowMU95Y0hmV1hveDBZOWdLU1RIUWt3SGlXNGxVTXVKc2QyQmtmWTlJRU43ZnRDdnlDTGxQY0hTU25CWWFFdDhUem9HU0VYcTFJTVFEbVk0dUhmVzJNVlEzNTNWdjdmaW9WeUVDVW5PRmNFZEQzNTY=
|
||||||
|
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_SECRET = INT_ENC:Z0FBQUFBQnFBa1kyS1hWZXEzUzZTTE5MUlJncVowMU95Y0hmV1hveDBZOWdLU1RIUWt3SGlXNGxVTXVKc2QyQmtmWTlJRU43ZnRDdnlDTGxQY0hTU25CWWFFdDhUem9HU0VYcTFJTVFEbVk0dUhmVzJNVlEzNTNWdjdmaW9WeUVDVW5PRmNFZEQzNTY=
|
||||||
|
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_SECRET = INT_ENC:Z0FBQUFBQnFBa1kyUTUwNXNGaHRNaGxxbF9sdWJ3Q0xLYU5yOHB4Yk8zMDZvQ29yaEhWOE5JMENXRk5jb2ZBdzRKQ2ZTTld6ZlIxemhOYzN1VE10TjBDRWZEMXlLVWRNYjZ0VG5RZ3I3NWt0SEJzMzdsUmRzcVNmbktRNHZqTUF6a2EyUkVUSFJnZFE=
|
||||||
|
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_SECRET = INT_ENC:Z0FBQUFBQnFBa1kyUTUwNXNGaHRNaGxxbF9sdWJ3Q0xLYU5yOHB4Yk8zMDZvQ29yaEhWOE5JMENXRk5jb2ZBdzRKQ2ZTTld6ZlIxemhOYzN1VE10TjBDRWZEMXlLVWRNYjZ0VG5RZ3I3NWt0SEJzMzdsUmRzcVNmbktRNHZqTUF6a2EyUkVUSFJnZFE=
|
||||||
|
Service_GOOGLE_DATA_REDIRECT_URI = https://api-int.poweron.swiss/api/google/auth/connect/callback
|
||||||
|
|
||||||
|
# ClickUp OAuth — same app as gateway-int; add https://api-int.poweron.swiss as second redirect in ClickUp (root URL, no path).
|
||||||
|
Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
|
||||||
|
Service_CLICKUP_CLIENT_SECRET = CZECD706WLSX6UV13YI4ACNW50ADZHHXDAJALHE0YE030QFSI6Y9HP4Y61JT7CF0
|
||||||
|
Service_CLICKUP_OAUTH_REDIRECT_URI = https://api-int.poweron.swiss
|
||||||
|
|
||||||
|
# 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 = INT_ENC:Z0FBQUFBQnFCdlFlYUZpRDFqLWhQajZxSElqMEMzdGZIRm5TeDBSSFlqenpZYVJEa1BtRXM1M21pd3hjTGZvSDJPcGJoY2gyQlNncWNwNkNIR0NFQnpjXzA5U2t6Zm1DWWNNVEZrTE5DVzRQVGdlZzRldGoyRWhaeTJfYjBHd0ludWpGcWdqd3hKTHJ5T0piVE15Tk1YZUZnSnE4OXdKOUhXd292dHpWMkxlR3dNclc1N2t0ckFoMmd5WTlBci11MXRGNV9UTlFCSmdOOE83bGJyODFUQ3E2NXJpRHJWZUM0cHFHekNJa0FlN3hjd2VFQ1Nqa1JFQ2NFdjlMWW1TbEV4TVZBeDFEZVVnUWlBVUV1Z0NUNHV0RE1fTEJaLTQxQksyVE1LSE1sSG0ycG9fTS1hNzh4dTQ9
|
||||||
|
Connector_AiAnthropic_API_SECRET = INT_ENC:Z0FBQUFBQnFCdlFlRHFpNThJb3g3UU05cUw4SVJpOXBTblU5QzU1WFItZ2JkNXVILVN4VHp0Umh2RjJyZXJMNVp5OWFxLWhjRjhub3cxajkxMVRQMnZQdVBGT21obWN0Q0NlOU80MVhMMXRWb1l3cWNpR2Ytc1d0WnVlRUN1TTZ4NjFQcDd0Wll4cFN6dzk1OU5SZGNJck54WmNoeElITzEzejJrczVSQnp6ZTBINGtENHFiT3NnWjdUME9xXzJ5Y0N3dHk5QnpBRkpyVTgxOE0xTVllR2JMUC0yTkwyWWxHQT09
|
||||||
|
Connector_AiPerplexity_API_SECRET = INT_ENC:Z0FBQUFBQnFCdlFla1h1R1M3QlQ5XzJhS0x4eXFpTkZ3WHpLMWVZZldRMGpMX2psMFZ2RmpETTZMZ3ZXblo2MnhyemxYWXRsMHN1LXdZU3k5ampEMjMtdzcyb1J4Ri1rTmxPOWhJMF9MMEtzZ3d5dFZxSFY3TjNac3ZpTVJxUFFmUVpXeHEtbVBTUmtiR0lhQjhVcjM3U1NNX1ZHY1NxUFJ3PT0=
|
||||||
|
Connector_AiTavily_API_SECRET = INT_ENC:Z0FBQUFBQnFCdlFlbmRSZVRjTzVKRklFbFgwdVZJaE5jNVoyX3dVTVlRUFVUenc4X1JOX2laOHRoTU9mN1lTUVRzb2xNZjJXVjhEYnVIaXdkSWN4NEpJbTFJZFN2cmkwUkJ0ZXNKT2NidktjdDFJX1BkZ3QwU3dQRzg0aG9aNmtxc1FZZ1ZBRjQyM3lOSS1EYkpqWmxoV0xWWE1Fc01uN3RnPT0=
|
||||||
|
Connector_AiPrivateLlm_API_SECRET = INT_ENC:Z0FBQUFBQnBudkpGSjZ1NWh0aWc1R3Z4MHNaeS1HamtUbndhcUZFZDlqUDhjSmg5eHFfdlVkU0RsVkJ2UVRaMWs3aWhraG5jSlc0YkxNWHVmR2JoSW5ENFFCdkJBM0VienlKSnhzNnBKbTJOUTFKczRfWlQ3bWpmUkRTT1I1OGNUSTlQdExacGRpeXg=
|
||||||
|
Connector_AiMistral_API_SECRET = INT_ENC:Z0FBQUFBQnFCdlFlU2tMLTFnQWhET2Nia2pTcVpBakRaSVFDdUpHRzZ1bkhGVVhMeEVlSnFZU3F3UFRBUkNMMU4tQU92OUdTeDlpM2VZbXJzLURQZ1lPLVB3azgxSDZabkhkSHJ5Y005aWhtcDJzajk3a2JDQUxCZlNKRGw5elJuSzJMUUpTZ2hiSlU=
|
||||||
|
|
||||||
|
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
|
||||||
|
|
@ -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://api-int.poweron.swiss
|
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://
|
# 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 = /srv/gateway/shared/secrets/master_key.txt
|
APP_KEY_SYSVAR = CONFIG_KEY
|
||||||
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 (porta-int-db on Infomaniak Public Cloud)
|
# PostgreSQL DB Host
|
||||||
DB_HOST=db-int.poweron.swiss
|
DB_HOST=gateway-int-server.postgres.database.azure.com
|
||||||
DB_USER=poweron_dev
|
DB_USER=heeshkdlby
|
||||||
DB_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQnFGTFJneVFGQ09JYVgwVWRGXzRSQjJ2RnlGYS05WllIMURpUUNBS0poQS1yLUJDaFFQS2IyLTNTSTUtRTBfekF1R1U5dUhiOXdYdi1WSVF4bUltczVQUVJQN2Q0Mng3cHFWVndZVDJxc2ZicXRXVnc9
|
DB_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjczYzOUtTa21MMGJVTUQ5UmFfdWc3YlhCbWZOeXFaNEE1QzdJV3BLVjhnalBkLVVCMm5BZzdxdlFXQXc2RHYzLWtPSFZkZE1iWG9rQ1NkVWlpRnF5TURVbnl1cm9iYXlSMGYxd1BGYVc0VDA9
|
||||||
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://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
|
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
|
# Logging configuration
|
||||||
APP_LOGGING_LOG_LEVEL = DEBUG
|
APP_LOGGING_LOG_LEVEL = DEBUG
|
||||||
APP_LOGGING_LOG_DIR = srv/gateway/shared/logs
|
APP_LOGGING_LOG_DIR = /home/site/wwwroot/
|
||||||
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,23 @@ 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://api-int.poweron.swiss/api/msft/auth/login/callback
|
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_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://api-int.poweron.swiss/api/msft/auth/connect/callback
|
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_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://api-int.poweron.swiss/api/google/auth/login/callback
|
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_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://api-int.poweron.swiss/api/google/auth/connect/callback
|
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.
|
# ClickUp OAuth — redirect URL must match ClickUp app exactly (often API root only).
|
||||||
|
# OAuth lands on /?code=&state=; gateway forwards to /api/clickup/auth/connect/callback (routeAdmin root).
|
||||||
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://api-int.poweron.swiss/api/clickup/auth/connect/callback
|
Service_CLICKUP_OAUTH_REDIRECT_URI = https://gateway-int.poweron.swiss
|
||||||
|
|
||||||
# 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,8 +76,11 @@ 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=
|
||||||
|
|
||||||
# Teamsbot Browser Bot Service (service-main-teams-browser-bot on Infomaniak)
|
# Feature SyncDelta JIRA configuration
|
||||||
TEAMSBOT_BROWSER_BOT_URL = http://teamsbot.poweron.swiss:4100
|
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
|
# 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 (porta-main-db on Infomaniak Public Cloud)
|
# PostgreSQL DB Host
|
||||||
DB_HOST=db.poweron.swiss
|
DB_HOST=10.20.0.21
|
||||||
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=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
|
APP_ALLOWED_ORIGINS=https://porta.poweron.swiss
|
||||||
|
|
||||||
# Logging configuration
|
# Logging configuration
|
||||||
APP_LOGGING_LOG_LEVEL = DEBUG
|
APP_LOGGING_LOG_LEVEL = DEBUG
|
||||||
|
|
@ -74,8 +74,11 @@ 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 = http://teamsbot.poweron.swiss:4100
|
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
|
||||||
92
env-gateway-prod.env
Normal file
92
env-gateway-prod.env
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
# 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
|
||||||
|
|
@ -29,6 +29,25 @@ _msg = apiRouteContext("oauthConnectTicket")
|
||||||
|
|
||||||
_CONNECT_TICKET_TTL_SEC = 600
|
_CONNECT_TICKET_TTL_SEC = 600
|
||||||
|
|
||||||
|
# OAuth providers sometimes redirect to the API root if the app redirect URL omits the path.
|
||||||
|
OAUTH_FLOW_CALLBACK_PATHS: Dict[str, str] = {
|
||||||
|
"clickup_connect": "/api/clickup/auth/connect/callback",
|
||||||
|
"msft_connect": "/api/msft/auth/connect/callback",
|
||||||
|
"google_connect": "/api/google/auth/connect/callback",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def oauth_callback_redirect_path(state: str) -> str | None:
|
||||||
|
"""Map connect-ticket JWT (ClickUp ``state`` param) to the correct callback route."""
|
||||||
|
try:
|
||||||
|
data = jose_jwt.decode(state, SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
|
except JWTError:
|
||||||
|
return None
|
||||||
|
flow = data.get("flow")
|
||||||
|
if not isinstance(flow, str):
|
||||||
|
return None
|
||||||
|
return OAUTH_FLOW_CALLBACK_PATHS.get(flow)
|
||||||
|
|
||||||
|
|
||||||
def issue_connect_ticket(flow: str, connection_id: str, user_id: str) -> str:
|
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."""
|
"""Issue a short-lived JWT for starting a data-connection OAuth popup."""
|
||||||
|
|
|
||||||
|
|
@ -9,15 +9,14 @@ for compliance, audit, and data-protection reporting.
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import BaseModel, 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(PowerOnModel):
|
class AiAuditLogEntry(BaseModel):
|
||||||
id: str = Field(
|
id: str = Field(
|
||||||
default_factory=lambda: str(uuid.uuid4()),
|
default_factory=lambda: str(uuid.uuid4()),
|
||||||
description="Primary key",
|
description="Primary key",
|
||||||
|
|
@ -35,7 +34,7 @@ class AiAuditLogEntry(PowerOnModel):
|
||||||
|
|
||||||
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", "softFk": True}},
|
json_schema_extra={"label": "Benutzer-ID", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
|
||||||
)
|
)
|
||||||
username: Optional[str] = Field(
|
username: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
|
|
@ -44,17 +43,17 @@ class AiAuditLogEntry(PowerOnModel):
|
||||||
)
|
)
|
||||||
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", "softFk": True}},
|
json_schema_extra={"label": "Mandanten-ID", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}},
|
||||||
)
|
)
|
||||||
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", "softFk": True}},
|
json_schema_extra={"label": "Feature-Instanz-ID", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
|
||||||
)
|
)
|
||||||
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", "softFk": True}},
|
json_schema_extra={"label": "Feature", "fk_target": {"db": "poweron_app", "table": "Feature", "column": "code", "labelField": "code"}},
|
||||||
)
|
)
|
||||||
instanceLabel: Optional[str] = Field(
|
instanceLabel: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ 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
|
||||||
|
|
||||||
|
|
@ -84,7 +83,7 @@ class AuditAction(str, Enum):
|
||||||
|
|
||||||
|
|
||||||
@i18nModel("Audit-Log-Eintrag")
|
@i18nModel("Audit-Log-Eintrag")
|
||||||
class AuditLogEntry(PowerOnModel):
|
class AuditLogEntry(BaseModel):
|
||||||
"""
|
"""
|
||||||
Audit log entry for database storage.
|
Audit log entry for database storage.
|
||||||
|
|
||||||
|
|
@ -112,7 +111,7 @@ class AuditLogEntry(PowerOnModel):
|
||||||
"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", "softFk": True},
|
"fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -131,7 +130,7 @@ class AuditLogEntry(PowerOnModel):
|
||||||
"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", "softFk": True},
|
"fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -143,7 +142,7 @@ class AuditLogEntry(PowerOnModel):
|
||||||
"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", "softFk": True},
|
"fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ class BillingTransaction(PowerOnModel):
|
||||||
|
|
||||||
|
|
||||||
@i18nModel("Abrechnungseinstellungen")
|
@i18nModel("Abrechnungseinstellungen")
|
||||||
class BillingSettings(PowerOnModel):
|
class BillingSettings(BaseModel):
|
||||||
"""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(PowerOnModel):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class StripeWebhookEvent(PowerOnModel):
|
class StripeWebhookEvent(BaseModel):
|
||||||
"""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(PowerOnModel):
|
||||||
|
|
||||||
|
|
||||||
@i18nModel("Nutzungsstatistik")
|
@i18nModel("Nutzungsstatistik")
|
||||||
class UsageStatistics(PowerOnModel):
|
class UsageStatistics(BaseModel):
|
||||||
"""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(PowerOnModel):
|
class ActionItem(BaseModel):
|
||||||
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", "fk_target": {"db": "poweron_app", "table": "UserConnection", "labelField": "authority"}},
|
json_schema_extra={"label": "Connection-ID"},
|
||||||
)
|
)
|
||||||
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", "fk_target": {"db": "poweron_knowledge", "table": "FileContentIndex", "labelField": "fileName"}},
|
json_schema_extra={"label": "Inhaltsobjekt-ID"},
|
||||||
)
|
)
|
||||||
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", "fk_target": {"db": "poweron_chat", "table": "ChatWorkflow", "labelField": "name"}},
|
json_schema_extra={"label": "Workflow-ID"},
|
||||||
)
|
)
|
||||||
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(PowerOnModel):
|
class MessagingSubscriptionRegistration(BaseModel):
|
||||||
"""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(PowerOnModel):
|
||||||
|
|
||||||
|
|
||||||
@i18nModel("Messaging-Zustellung")
|
@i18nModel("Messaging-Zustellung")
|
||||||
class MessagingDelivery(PowerOnModel):
|
class MessagingDelivery(BaseModel):
|
||||||
"""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(PowerOnModel):
|
class StripePlanPrice(BaseModel):
|
||||||
"""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: Optional[bool] = Field(
|
knowledgeIngestionEnabled: 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,3 +747,4 @@ 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,18 +74,9 @@ 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(
|
userId: str = Field(description="Owner user ID (strict ownership)")
|
||||||
description="Owner user ID (strict ownership)",
|
mandateId: str = Field(description="Mandate ID")
|
||||||
json_schema_extra={"label": "Benutzer", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
|
instanceId: str = Field(description="Feature instance ID")
|
||||||
)
|
|
||||||
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)
|
||||||
|
|
@ -93,10 +84,7 @@ 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(
|
personaId: Optional[str] = Field(default=None, description="Default persona for sessions")
|
||||||
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)
|
||||||
|
|
@ -108,27 +96,12 @@ 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(
|
moduleId: str = Field(description="FK to TrainingModule")
|
||||||
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")
|
||||||
)
|
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(
|
personaId: Optional[str] = Field(default=None, description="FK to CoachingPersona (Iteration 2)")
|
||||||
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")
|
||||||
|
|
@ -145,18 +118,9 @@ 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(
|
sessionId: str = Field(description="FK to CoachingSession")
|
||||||
description="FK to CoachingSession",
|
moduleId: str = Field(description="FK to TrainingModule")
|
||||||
json_schema_extra={"label": "Session", "fk_target": {"db": "poweron_commcoach", "table": "CoachingSession", "labelField": None}},
|
userId: str = Field(description="Owner user ID")
|
||||||
)
|
|
||||||
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)
|
||||||
|
|
@ -167,22 +131,10 @@ 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(
|
moduleId: str = Field(description="FK to TrainingModule")
|
||||||
description="FK to TrainingModule",
|
sessionId: Optional[str] = Field(default=None, description="FK to originating session")
|
||||||
json_schema_extra={"label": "Trainingsmodul", "fk_target": {"db": "poweron_commcoach", "table": "TrainingModule", "labelField": "title"}},
|
userId: str = Field(description="Owner user ID")
|
||||||
)
|
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)
|
||||||
|
|
@ -194,22 +146,10 @@ 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(
|
moduleId: str = Field(description="FK to TrainingModule")
|
||||||
description="FK to TrainingModule",
|
sessionId: str = Field(description="FK to CoachingSession")
|
||||||
json_schema_extra={"label": "Trainingsmodul", "fk_target": {"db": "poweron_commcoach", "table": "TrainingModule", "labelField": "title"}},
|
userId: str = Field(description="Owner user ID")
|
||||||
)
|
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)
|
||||||
|
|
@ -219,18 +159,9 @@ 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(
|
userId: str = Field(description="Owner user ID")
|
||||||
description="Owner user ID",
|
mandateId: str = Field(description="Mandate ID")
|
||||||
json_schema_extra={"label": "Benutzer", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
|
instanceId: str = Field(description="Feature instance ID")
|
||||||
)
|
|
||||||
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)
|
||||||
|
|
@ -248,18 +179,9 @@ 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(
|
userId: str = Field(description="Owner user ID ('system' for builtins)")
|
||||||
description="Owner user ID ('system' for builtins)",
|
mandateId: Optional[str] = Field(default=None)
|
||||||
json_schema_extra={"label": "Benutzer", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username", "softFk": True}},
|
instanceId: Optional[str] = Field(default=None)
|
||||||
)
|
|
||||||
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")
|
||||||
|
|
@ -276,18 +198,9 @@ 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(
|
moduleId: str = Field(description="FK to TrainingModule")
|
||||||
description="FK to TrainingModule",
|
personaId: str = Field(description="FK to CoachingPersona")
|
||||||
json_schema_extra={"label": "Trainingsmodul", "fk_target": {"db": "poweron_commcoach", "table": "TrainingModule", "labelField": "title"}},
|
instanceId: str = Field(description="Feature instance ID")
|
||||||
)
|
|
||||||
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):
|
||||||
|
|
@ -301,18 +214,9 @@ 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(
|
userId: str = Field(description="Owner user ID")
|
||||||
description="Owner user ID",
|
mandateId: str = Field(description="Mandate ID")
|
||||||
json_schema_extra={"label": "Benutzer", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
|
instanceId: str = Field(description="Feature instance ID")
|
||||||
)
|
|
||||||
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"})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@
|
||||||
|
|
||||||
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 (
|
from modules.features.graphicalEditor.nodeDefinitions.flow import (
|
||||||
CONTEXT_ENVELOPE_DATA_PICK_OPTIONS,
|
CONTEXT_ENVELOPE_DATA_PICK_OPTIONS,
|
||||||
CONTEXT_MERGE_ACTION_RESULT_DATA_PICK_OPTIONS,
|
CONTEXT_MERGE_ACTION_RESULT_DATA_PICK_OPTIONS,
|
||||||
|
|
@ -37,9 +40,9 @@ CONTEXT_NODES = [
|
||||||
),
|
),
|
||||||
"injectRunContext": True,
|
"injectRunContext": True,
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "documentList", "type": "str", "required": True, "frontendType": "hidden",
|
{"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder",
|
||||||
"description": t("Dokumentenliste (via Wire oder DataRef)"), "default": "",
|
"description": CONTEXT_BUILDER_PARAM_DESCRIPTION, "default": "",
|
||||||
"graphInherit": {"port": 0, "kind": "documentListWire"}},
|
"graphInherit": {"port": 0, "kind": "primaryTextRef"}},
|
||||||
{
|
{
|
||||||
"name": "contentFilter",
|
"name": "contentFilter",
|
||||||
"type": "str",
|
"type": "str",
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@ class DataNeutraliserConfig(PowerOnModel):
|
||||||
|
|
||||||
|
|
||||||
@i18nModel("Neutralisiertes Datenattribut")
|
@i18nModel("Neutralisiertes Datenattribut")
|
||||||
class DataNeutralizerAttributes(PowerOnModel):
|
class DataNeutralizerAttributes(BaseModel):
|
||||||
"""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(PowerOnModel):
|
||||||
|
|
||||||
|
|
||||||
@i18nModel("Neutralisierungs-Snapshot")
|
@i18nModel("Neutralisierungs-Snapshot")
|
||||||
class DataNeutralizationSnapshot(PowerOnModel):
|
class DataNeutralizationSnapshot(BaseModel):
|
||||||
"""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(PowerOnModel):
|
class Dokument(BaseModel):
|
||||||
"""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(PowerOnModel):
|
class Land(BaseModel):
|
||||||
"""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,19 +265,15 @@ class Kanton(PowerOnModel):
|
||||||
)
|
)
|
||||||
mandateId: str = Field(
|
mandateId: str = Field(
|
||||||
description="ID of the mandate",
|
description="ID of the mandate",
|
||||||
json_schema_extra={
|
frontend_type="text",
|
||||||
"frontend_type": "text", "frontend_readonly": True, "frontend_required": False,
|
frontend_readonly=True,
|
||||||
"label": "Mandant",
|
frontend_required=False,
|
||||||
"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",
|
||||||
json_schema_extra={
|
frontend_type="text",
|
||||||
"frontend_type": "text", "frontend_readonly": True, "frontend_required": False,
|
frontend_readonly=True,
|
||||||
"label": "Feature-Instanz",
|
frontend_required=False,
|
||||||
"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')",
|
||||||
|
|
@ -318,7 +314,7 @@ class Kanton(PowerOnModel):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class Gemeinde(PowerOnModel):
|
class Gemeinde(BaseModel):
|
||||||
"""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,24 +102,12 @@ 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(
|
instanceId: str = Field(description="Feature instance ID (FK)")
|
||||||
description="Feature instance ID",
|
mandateId: str = Field(description="Mandate ID (FK)")
|
||||||
json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
|
ownerUserId: str = Field(description="Owner user ID")
|
||||||
)
|
|
||||||
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(
|
defaultBotId: Optional[str] = Field(default=None, description="FK to TeamsbotSystemBot")
|
||||||
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")
|
||||||
|
|
@ -132,8 +120,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, description="FileItem ID for the default avatar image/video shown in the meeting",
|
default=None,
|
||||||
json_schema_extra={"label": "Avatar-Datei", "fk_target": {"db": "poweron_management", "table": "FileItem", "labelField": "fileName"}},
|
description="FileItem ID for the default avatar image/video shown in the meeting",
|
||||||
)
|
)
|
||||||
status: TeamsbotModuleStatus = Field(default=TeamsbotModuleStatus.ACTIVE)
|
status: TeamsbotModuleStatus = Field(default=TeamsbotModuleStatus.ACTIVE)
|
||||||
|
|
||||||
|
|
@ -141,27 +129,15 @@ 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(
|
instanceId: str = Field(description="Feature instance ID (FK)")
|
||||||
description="Feature instance ID",
|
mandateId: str = Field(description="Mandate ID (FK)")
|
||||||
json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
|
moduleId: Optional[str] = Field(default=None, description="FK to TeamsbotMeetingModule (nullable during transition)")
|
||||||
)
|
|
||||||
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(
|
startedByUserId: str = Field(description="User ID who started the session")
|
||||||
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")
|
||||||
|
|
@ -174,10 +150,7 @@ 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(
|
sessionId: str = Field(description="Session ID (FK)")
|
||||||
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"})
|
||||||
|
|
@ -190,18 +163,12 @@ 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(
|
sessionId: str = Field(description="Session ID (FK)")
|
||||||
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(
|
triggeredByTranscriptId: Optional[str] = Field(default=None, description="Transcript segment that triggered this response")
|
||||||
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")
|
||||||
|
|
@ -217,10 +184,7 @@ 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(
|
mandateId: str = Field(description="Mandate ID (FK) - bots are scoped to mandates")
|
||||||
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")
|
||||||
|
|
@ -236,14 +200,8 @@ 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(
|
userId: str = Field(description="Poweron user ID (FK)")
|
||||||
description="Poweron user ID",
|
mandateId: str = Field(description="Mandate ID (FK)")
|
||||||
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")
|
||||||
|
|
@ -258,14 +216,8 @@ 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(
|
userId: str = Field(description="User ID (FK)")
|
||||||
description="User ID",
|
instanceId: str = Field(description="Feature instance ID (FK)")
|
||||||
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")
|
||||||
|
|
@ -277,10 +229,7 @@ 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(
|
avatarFileId: Optional[str] = Field(default=None, description="FileItem ID for bot avatar image/video override")
|
||||||
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"}},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
@ -433,18 +382,9 @@ 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(
|
sessionId: str = Field(description="Teams Bot session ID (FK)")
|
||||||
description="FK to TeamsbotSession",
|
instanceId: str = Field(description="Feature instance ID (FK)")
|
||||||
json_schema_extra={"label": "Session", "fk_target": {"db": "poweron_teamsbot", "table": "TeamsbotSession", "labelField": "botName"}},
|
operatorUserId: str = Field(description="User ID of the operator who issued the prompt")
|
||||||
)
|
|
||||||
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, featureInstanceId=self.instanceId)
|
mgmt = interfaceDbManagement.getInterface(self.currentUser, self.mandateId)
|
||||||
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")
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
from fastapi import APIRouter, Response, Depends, Request, Body
|
from fastapi import APIRouter, Response, Depends, Request, Body
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse, RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
|
|
@ -11,6 +11,7 @@ from fastapi import HTTPException, status
|
||||||
|
|
||||||
from modules.shared.configuration import APP_CONFIG
|
from modules.shared.configuration import APP_CONFIG
|
||||||
from modules.auth import limiter, getCurrentUser
|
from modules.auth import limiter, getCurrentUser
|
||||||
|
from modules.auth.oauthConnectTicket import oauth_callback_redirect_path
|
||||||
from modules.datamodels.datamodelUam import User
|
from modules.datamodels.datamodelUam import User
|
||||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
from modules.shared.i18nRegistry import apiRouteContext
|
from modules.shared.i18nRegistry import apiRouteContext
|
||||||
|
|
@ -35,8 +36,15 @@ router.mount(
|
||||||
|
|
||||||
@router.get("/")
|
@router.get("/")
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("30/minute")
|
||||||
def root(request: Request) -> Dict[str, str]:
|
def root(request: Request):
|
||||||
"""API status endpoint"""
|
"""API status endpoint; forwards OAuth callbacks that land on ``/`` by mistake."""
|
||||||
|
code = request.query_params.get("code")
|
||||||
|
state = request.query_params.get("state")
|
||||||
|
if code and state:
|
||||||
|
callback_path = oauth_callback_redirect_path(state)
|
||||||
|
if callback_path:
|
||||||
|
return RedirectResponse(url=f"{callback_path}?{request.url.query}", status_code=302)
|
||||||
|
|
||||||
# Validate required configuration values
|
# Validate required configuration values
|
||||||
allowedOrigins = APP_CONFIG.get("APP_ALLOWED_ORIGINS")
|
allowedOrigins = APP_CONFIG.get("APP_ALLOWED_ORIGINS")
|
||||||
if not allowedOrigins:
|
if not allowedOrigins:
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,13 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""
|
"""
|
||||||
SysAdmin API for database table statistics, FK orphan detection/cleanup,
|
SysAdmin API for database table statistics and 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, File, HTTPException, Request, UploadFile, status
|
from fastapi import APIRouter, Depends, HTTPException, Request, 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
|
||||||
|
|
@ -20,23 +17,11 @@ 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__)
|
||||||
|
|
||||||
|
|
@ -209,531 +194,3 @@ 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}
|
|
||||||
|
|
|
||||||
|
|
@ -668,6 +668,7 @@ def get_files(
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
||||||
mode: Optional[str] = Query(None, description="'filterValues' for distinct column values, 'ids' for all filtered IDs"),
|
mode: Optional[str] = Query(None, description="'filterValues' for distinct column values, 'ids' for all filtered IDs"),
|
||||||
column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"),
|
column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"),
|
||||||
|
owner: str = Query("me", description="'all' | 'me' | 'shared'"),
|
||||||
currentUser: User = Depends(getCurrentUser),
|
currentUser: User = Depends(getCurrentUser),
|
||||||
context: RequestContext = Depends(getRequestContext)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
):
|
):
|
||||||
|
|
@ -699,8 +700,9 @@ def get_files(
|
||||||
|
|
||||||
from modules.routes.routeHelpers import (
|
from modules.routes.routeHelpers import (
|
||||||
handleIdsMode,
|
handleIdsMode,
|
||||||
|
handleIdsInMemory,
|
||||||
handleFilterValuesInMemory,
|
handleFilterValuesInMemory,
|
||||||
resolveView, applyViewToParams, buildGroupLayout, effective_group_by_levels,
|
resolveView, applyViewToParams, buildGroupLayout, effective_group_by_levels, paginateInMemory,
|
||||||
)
|
)
|
||||||
import modules.interfaces.interfaceDbApp as _appIface
|
import modules.interfaces.interfaceDbApp as _appIface
|
||||||
from modules.datamodels.datamodelPagination import AppliedViewMeta
|
from modules.datamodels.datamodelPagination import AppliedViewMeta
|
||||||
|
|
@ -711,6 +713,10 @@ def get_files(
|
||||||
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
|
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
|
||||||
)
|
)
|
||||||
appInterface = _appIface.getInterface(currentUser)
|
appInterface = _appIface.getInterface(currentUser)
|
||||||
|
owner_mode = (owner or "me").strip().lower()
|
||||||
|
if owner_mode not in ("all", "me", "shared"):
|
||||||
|
raise HTTPException(status_code=400, detail="owner must be 'all', 'me', or 'shared'")
|
||||||
|
current_user_id = str(getattr(currentUser, "id", "") or "")
|
||||||
|
|
||||||
# Resolve view and merge config into params
|
# Resolve view and merge config into params
|
||||||
viewKey = paginationParams.viewKey if paginationParams else None
|
viewKey = paginationParams.viewKey if paginationParams else None
|
||||||
|
|
@ -722,6 +728,17 @@ def get_files(
|
||||||
def _filesToDicts(fileItems):
|
def _filesToDicts(fileItems):
|
||||||
return [f.model_dump() if hasattr(f, "model_dump") else (dict(f) if not isinstance(f, dict) else f) for f in fileItems]
|
return [f.model_dump() if hasattr(f, "model_dump") else (dict(f) if not isinstance(f, dict) else f) for f in fileItems]
|
||||||
|
|
||||||
|
def _apply_owner_filter(item_dicts):
|
||||||
|
if owner_mode == "all":
|
||||||
|
return item_dicts
|
||||||
|
if owner_mode == "me":
|
||||||
|
return [item for item in item_dicts if str(item.get("sysCreatedBy") or "") == current_user_id]
|
||||||
|
return [item for item in item_dicts if str(item.get("sysCreatedBy") or "") != current_user_id]
|
||||||
|
|
||||||
|
recordFilter = None
|
||||||
|
if owner_mode == "me":
|
||||||
|
recordFilter = {"sysCreatedBy": managementInterface.userId}
|
||||||
|
|
||||||
if mode == "groupSummary":
|
if mode == "groupSummary":
|
||||||
if not pagination:
|
if not pagination:
|
||||||
raise HTTPException(status_code=400, detail="pagination required for groupSummary")
|
raise HTTPException(status_code=400, detail="pagination required for groupSummary")
|
||||||
|
|
@ -736,11 +753,12 @@ def get_files(
|
||||||
)
|
)
|
||||||
field = groupByLevels[0]["field"]
|
field = groupByLevels[0]["field"]
|
||||||
null_label = str(groupByLevels[0].get("nullLabel") or "—")
|
null_label = str(groupByLevels[0].get("nullLabel") or "—")
|
||||||
allFiles = managementInterface.getAllFiles()
|
allFiles = managementInterface.getAllFiles(recordFilter=recordFilter)
|
||||||
allItems = enrichRowsWithFkLabels(
|
allItems = enrichRowsWithFkLabels(
|
||||||
_filesToDicts(allFiles if isinstance(allFiles, list) else (allFiles.items if hasattr(allFiles, "items") else [])),
|
_filesToDicts(allFiles if isinstance(allFiles, list) else (allFiles.items if hasattr(allFiles, "items") else [])),
|
||||||
FileItem,
|
FileItem,
|
||||||
)
|
)
|
||||||
|
allItems = _apply_owner_filter(allItems)
|
||||||
filtered = apply_strategy_b_filters_and_sort(allItems, paginationParams, currentUser)
|
filtered = apply_strategy_b_filters_and_sort(allItems, paginationParams, currentUser)
|
||||||
groups_out = build_group_summary_groups(filtered, field, null_label, groupByLevels=groupByLevels)
|
groups_out = build_group_summary_groups(filtered, field, null_label, groupByLevels=groupByLevels)
|
||||||
return JSONResponse(content={"groups": groups_out})
|
return JSONResponse(content={"groups": groups_out})
|
||||||
|
|
@ -748,48 +766,35 @@ def get_files(
|
||||||
if mode == "filterValues":
|
if mode == "filterValues":
|
||||||
if not column:
|
if not column:
|
||||||
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
|
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
|
||||||
allFiles = managementInterface.getAllFiles()
|
allFiles = managementInterface.getAllFiles(recordFilter=recordFilter)
|
||||||
items = allFiles if isinstance(allFiles, list) else (allFiles.items if hasattr(allFiles, "items") else [])
|
items = allFiles if isinstance(allFiles, list) else (allFiles.items if hasattr(allFiles, "items") else [])
|
||||||
itemDicts = _filesToDicts(items)
|
itemDicts = _filesToDicts(items)
|
||||||
|
itemDicts = _apply_owner_filter(itemDicts)
|
||||||
enrichRowsWithFkLabels(itemDicts, FileItem)
|
enrichRowsWithFkLabels(itemDicts, FileItem)
|
||||||
return handleFilterValuesInMemory(itemDicts, column, pagination)
|
return handleFilterValuesInMemory(itemDicts, column, pagination)
|
||||||
|
|
||||||
if mode == "ids":
|
if mode == "ids":
|
||||||
recordFilter = {"sysCreatedBy": managementInterface.userId}
|
if owner_mode == "me":
|
||||||
return handleIdsMode(managementInterface.db, FileItem, pagination, recordFilter)
|
return handleIdsMode(managementInterface.db, FileItem, pagination, recordFilter)
|
||||||
|
allFiles = managementInterface.getAllFiles(recordFilter=recordFilter)
|
||||||
|
items = allFiles if isinstance(allFiles, list) else (allFiles.items if hasattr(allFiles, "items") else [])
|
||||||
|
itemDicts = _apply_owner_filter(_filesToDicts(items))
|
||||||
|
enrichRowsWithFkLabels(itemDicts, FileItem)
|
||||||
|
return handleIdsInMemory(itemDicts, pagination)
|
||||||
|
|
||||||
if not groupByLevels:
|
# Strategy B: load visible list first, then filter/sort/paginate in memory.
|
||||||
# No grouping: let DB handle pagination directly (fastest path)
|
# This is required for files because internal workflow artefacts are
|
||||||
result = managementInterface.getAllFiles(pagination=paginationParams)
|
# suppressed after record loading; SQL-level COUNT/LIMIT would otherwise
|
||||||
if paginationParams and hasattr(result, 'items'):
|
# count hidden rows and produce pages with only a handful of visible items.
|
||||||
enriched = enrichRowsWithFkLabels(_filesToDicts(result.items), FileItem)
|
allFiles = managementInterface.getAllFiles(recordFilter=recordFilter)
|
||||||
resp: dict = {
|
|
||||||
"items": enriched,
|
|
||||||
"pagination": PaginationMetadata(
|
|
||||||
currentPage=paginationParams.page,
|
|
||||||
pageSize=paginationParams.pageSize,
|
|
||||||
totalItems=result.totalItems,
|
|
||||||
totalPages=result.totalPages,
|
|
||||||
sort=paginationParams.sort,
|
|
||||||
filters=paginationParams.filters
|
|
||||||
).model_dump(),
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
items = result if isinstance(result, list) else (result.items if hasattr(result, "items") else [result])
|
|
||||||
resp = {"items": enrichRowsWithFkLabels(_filesToDicts(items), FileItem), "pagination": None}
|
|
||||||
if viewMeta:
|
|
||||||
resp["appliedView"] = viewMeta.model_dump()
|
|
||||||
return resp
|
|
||||||
|
|
||||||
# Strategy B grouping: load full list, group, then slice
|
|
||||||
allFiles = managementInterface.getAllFiles()
|
|
||||||
allItems = enrichRowsWithFkLabels(
|
allItems = enrichRowsWithFkLabels(
|
||||||
_filesToDicts(allFiles if isinstance(allFiles, list) else (allFiles.items if hasattr(allFiles, "items") else [])),
|
_filesToDicts(allFiles if isinstance(allFiles, list) else (allFiles.items if hasattr(allFiles, "items") else [])),
|
||||||
FileItem,
|
FileItem,
|
||||||
)
|
)
|
||||||
|
allItems = _apply_owner_filter(allItems)
|
||||||
|
|
||||||
from modules.routes.routeHelpers import apply_strategy_b_filters_and_sort
|
from modules.routes.routeHelpers import apply_strategy_b_filters_and_sort
|
||||||
if paginationParams.filters or paginationParams.sort:
|
if paginationParams and (paginationParams.filters or paginationParams.sort):
|
||||||
allItems = apply_strategy_b_filters_and_sort(allItems, paginationParams, currentUser)
|
allItems = apply_strategy_b_filters_and_sort(allItems, paginationParams, currentUser)
|
||||||
|
|
||||||
if not paginationParams:
|
if not paginationParams:
|
||||||
|
|
@ -798,6 +803,24 @@ def get_files(
|
||||||
resp["appliedView"] = viewMeta.model_dump()
|
resp["appliedView"] = viewMeta.model_dump()
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
if not groupByLevels:
|
||||||
|
page_items, totalItems = paginateInMemory(allItems, paginationParams)
|
||||||
|
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
|
||||||
|
resp = {
|
||||||
|
"items": page_items,
|
||||||
|
"pagination": PaginationMetadata(
|
||||||
|
currentPage=paginationParams.page,
|
||||||
|
pageSize=paginationParams.pageSize,
|
||||||
|
totalItems=totalItems,
|
||||||
|
totalPages=totalPages,
|
||||||
|
sort=paginationParams.sort,
|
||||||
|
filters=paginationParams.filters
|
||||||
|
).model_dump(),
|
||||||
|
}
|
||||||
|
if viewMeta:
|
||||||
|
resp["appliedView"] = viewMeta.model_dump()
|
||||||
|
return resp
|
||||||
|
|
||||||
totalItems = len(allItems)
|
totalItems = len(allItems)
|
||||||
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
|
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
|
||||||
page_items, groupLayout = buildGroupLayout(allItems, groupByLevels, paginationParams.page, paginationParams.pageSize)
|
page_items, groupLayout = buildGroupLayout(allItems, groupByLevels, paginationParams.page, paginationParams.pageSize)
|
||||||
|
|
|
||||||
|
|
@ -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, estimatedChf, basis}` shape regardless of source kind.
|
`{estimatedTokens, estimatedUsd, basis}` shape regardless of source kind.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
|
|
|
||||||
|
|
@ -257,6 +257,8 @@ async def auth_connect_callback(
|
||||||
except Exception as _cbErr:
|
except Exception as _cbErr:
|
||||||
logger.warning("connection.established callback failed for %s: %s", connection.id, _cbErr)
|
logger.warning("connection.established callback failed for %s: %s", connection.id, _cbErr)
|
||||||
|
|
||||||
|
allowed = (APP_CONFIG.get("APP_ALLOWED_ORIGINS") or "").split(",")[0].strip()
|
||||||
|
post_target = allowed if allowed else "*"
|
||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
content=f"""
|
content=f"""
|
||||||
<html>
|
<html>
|
||||||
|
|
@ -273,7 +275,7 @@ async def auth_connect_callback(
|
||||||
lastChecked: {getUtcTimestamp()},
|
lastChecked: {getUtcTimestamp()},
|
||||||
expiresAt: {expires_at}
|
expiresAt: {expires_at}
|
||||||
}}
|
}}
|
||||||
}}, '*');
|
}}, {json.dumps(post_target)});
|
||||||
setTimeout(() => window.close(), 1000);
|
setTimeout(() => window.close(), 1000);
|
||||||
}} else {{
|
}} else {{
|
||||||
window.close();
|
window.close();
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,15 @@
|
||||||
"""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
|
||||||
CHF figure for the worst-case full sync, so they can sanity-check before raising
|
USD 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)
|
||||||
estimatedChf = estimatedTokens / 1_000_000 * EMBEDDING_CHF_PER_MTOKEN
|
estimatedUsd = estimatedTokens / 1_000_000 * EMBEDDING_USD_PER_MTOKEN
|
||||||
|
|
||||||
Defaults match OpenAI `text-embedding-3-small` published pricing (2026-Q2);
|
Defaults match OpenAI `text-embedding-3-small` 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
|
||||||
|
|
@ -23,7 +21,7 @@ from typing import Any, Dict
|
||||||
|
|
||||||
|
|
||||||
CHARS_PER_TOKEN = 4
|
CHARS_PER_TOKEN = 4
|
||||||
EMBEDDING_CHF_PER_MTOKEN = 0.02
|
EMBEDDING_USD_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
|
||||||
|
|
@ -36,12 +34,12 @@ def estimateBootstrapCost(limits: Dict[str, int], kind: str = "files") -> Dict[s
|
||||||
|
|
||||||
{
|
{
|
||||||
"estimatedTokens": int,
|
"estimatedTokens": int,
|
||||||
"estimatedChf": float, # rounded to 4 decimals
|
"estimatedUsd": float, # rounded to 4 decimals
|
||||||
"basis": {
|
"basis": {
|
||||||
"kind": "files"|"clickup",
|
"kind": "files"|"clickup",
|
||||||
"limits": {...},
|
"limits": {...},
|
||||||
"assumptions": {
|
"assumptions": {
|
||||||
"embeddingChfPerMToken": 0.02,
|
"embeddingUsdPerMToken": 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
|
||||||
|
|
@ -51,7 +49,7 @@ def estimateBootstrapCost(limits: Dict[str, int], kind: str = "files") -> Dict[s
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
assumptions: Dict[str, Any] = {
|
assumptions: Dict[str, Any] = {
|
||||||
"embeddingChfPerMToken": EMBEDDING_CHF_PER_MTOKEN,
|
"embeddingUsdPerMToken": EMBEDDING_USD_PER_MTOKEN,
|
||||||
"charsPerToken": CHARS_PER_TOKEN,
|
"charsPerToken": CHARS_PER_TOKEN,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -71,11 +69,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"
|
||||||
|
|
||||||
estimatedChf = round(estimatedTokens / 1_000_000 * EMBEDDING_CHF_PER_MTOKEN, 4)
|
estimatedUsd = round(estimatedTokens / 1_000_000 * EMBEDDING_USD_PER_MTOKEN, 4)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"estimatedTokens": estimatedTokens,
|
"estimatedTokens": estimatedTokens,
|
||||||
"estimatedChf": estimatedChf,
|
"estimatedUsd": estimatedUsd,
|
||||||
"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.debug("Could not archive price %s: %s", p.id, ex)
|
logger.warning("Could not archive price %s: %s", p.id, ex)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Stale price archive pass skipped for product %s: %s", productId, e)
|
logger.warning("Stale price archive pass failed for product %s: %s", productId, e)
|
||||||
|
|
||||||
|
|
||||||
def _validateStripeIdsExist(stripe, mapping: StripePlanPrice) -> bool:
|
def _validateStripeIdsExist(stripe, mapping: StripePlanPrice) -> bool:
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,6 @@ 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:
|
||||||
|
|
|
||||||
|
|
@ -790,98 +790,3 @@ 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()
|
|
||||||
|
|
|
||||||
|
|
@ -1,816 +0,0 @@
|
||||||
# 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}
|
|
||||||
|
|
@ -24,7 +24,7 @@ import time
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
from modules.datamodels.datamodelChat import ActionResult
|
from modules.datamodels.datamodelChat import ActionResult
|
||||||
from modules.datamodels.datamodelDocref import coerceDocumentReferenceList
|
from modules.datamodels.datamodelDocref import DocumentReferenceList, coerceDocumentReferenceList
|
||||||
from modules.datamodels.datamodelExtraction import ContentExtracted, ExtractionOptions
|
from modules.datamodels.datamodelExtraction import ContentExtracted, ExtractionOptions
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -1751,6 +1751,62 @@ def presentation_envelopes_to_document_json(
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _document_list_from_context(raw: Any, *, _depth: int = 0) -> DocumentReferenceList:
|
||||||
|
"""Best-effort extraction of document/file references from ``context`` payloads.
|
||||||
|
|
||||||
|
Supports direct DocumentList-like values plus nested shapes commonly produced
|
||||||
|
by DataPicker selections, ActionResult wrappers, and file/files containers.
|
||||||
|
"""
|
||||||
|
if _depth > 6 or raw is None or raw == "":
|
||||||
|
return DocumentReferenceList(references=[])
|
||||||
|
|
||||||
|
if isinstance(raw, dict) and "fileId" in raw and "id" not in raw and "documentId" not in raw:
|
||||||
|
direct = coerceDocumentReferenceList({
|
||||||
|
"id": raw.get("fileId"),
|
||||||
|
"name": raw.get("fileName") or raw.get("name"),
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
direct = coerceDocumentReferenceList(raw)
|
||||||
|
if direct.references:
|
||||||
|
return direct
|
||||||
|
|
||||||
|
collected = []
|
||||||
|
|
||||||
|
def _extend_from(value: Any) -> None:
|
||||||
|
nested = _document_list_from_context(value, _depth=_depth + 1)
|
||||||
|
if nested.references:
|
||||||
|
collected.extend(nested.references)
|
||||||
|
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
nested_files = raw.get("files")
|
||||||
|
if isinstance(nested_files, dict):
|
||||||
|
_extend_from(list(nested_files.values()))
|
||||||
|
for key in ("documents", "references", "items", "file", "document", "value", "data", "merged", "result", "context"):
|
||||||
|
nested = raw.get(key)
|
||||||
|
if nested is None or nested is raw:
|
||||||
|
continue
|
||||||
|
_extend_from(nested)
|
||||||
|
elif isinstance(raw, list):
|
||||||
|
for item in raw:
|
||||||
|
_extend_from(item)
|
||||||
|
|
||||||
|
if not collected:
|
||||||
|
return DocumentReferenceList(references=[])
|
||||||
|
|
||||||
|
deduped = []
|
||||||
|
seen = set()
|
||||||
|
for ref in collected:
|
||||||
|
try:
|
||||||
|
key = ref.to_string()
|
||||||
|
except Exception:
|
||||||
|
key = repr(ref)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
deduped.append(ref)
|
||||||
|
return DocumentReferenceList(references=deduped)
|
||||||
|
|
||||||
|
|
||||||
async def extractContent(self, parameters: Dict[str, Any]) -> ActionResult:
|
async def extractContent(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||||
operation_id = None
|
operation_id = None
|
||||||
try:
|
try:
|
||||||
|
|
@ -1758,18 +1814,24 @@ async def extractContent(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||||
operation_id = f"context_extract_{wf}_{int(time.time())}"
|
operation_id = f"context_extract_{wf}_{int(time.time())}"
|
||||||
|
|
||||||
document_list_param = parameters.get("documentList")
|
document_list_param = parameters.get("documentList")
|
||||||
if not document_list_param:
|
if document_list_param:
|
||||||
return ActionResult.isFailure(error="documentList is required")
|
dl = coerceDocumentReferenceList(document_list_param)
|
||||||
|
source = "documentList"
|
||||||
dl = coerceDocumentReferenceList(document_list_param)
|
else:
|
||||||
|
context_param = parameters.get("context")
|
||||||
|
dl = _document_list_from_context(context_param)
|
||||||
|
source = "context"
|
||||||
if not dl.references:
|
if not dl.references:
|
||||||
return ActionResult.isFailure(
|
return ActionResult.isFailure(
|
||||||
error=(
|
error=(
|
||||||
f"documentList could not be parsed (type={type(document_list_param).__name__}); "
|
f"{source} could not be parsed into document references "
|
||||||
"expected DocumentReferenceList, list of strings/dicts, or "
|
f"(type={type((document_list_param if document_list_param else parameters.get('context'))).__name__}); "
|
||||||
"a wrapper dict like {'documents': [...]}"
|
"expected DocumentReferenceList, list of string/dict refs, "
|
||||||
|
"or a context payload containing file/document refs under keys like "
|
||||||
|
"{documents, files, file, data, value}."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
logger.info("extractContent resolved %d document reference(s) from %s", len(dl.references), source)
|
||||||
|
|
||||||
parent_operation_id = parameters.get("parentOperationId")
|
parent_operation_id = parameters.get("parentOperationId")
|
||||||
self.services.chat.progressLogStart(
|
self.services.chat.progressLogStart(
|
||||||
|
|
|
||||||
|
|
@ -64,12 +64,22 @@ class MethodContext(MethodBase):
|
||||||
dynamicMode=True,
|
dynamicMode=True,
|
||||||
outputType="UdmDocument",
|
outputType="UdmDocument",
|
||||||
parameters={
|
parameters={
|
||||||
|
"context": WorkflowActionParameter(
|
||||||
|
name="context",
|
||||||
|
type="Any",
|
||||||
|
frontendType=FrontendType.CONTEXT_BUILDER,
|
||||||
|
required=False,
|
||||||
|
description=(
|
||||||
|
"Optional context payload that may contain file/document references. "
|
||||||
|
"Preferred input for extractContent; documentList remains supported for compatibility."
|
||||||
|
),
|
||||||
|
),
|
||||||
"documentList": WorkflowActionParameter(
|
"documentList": WorkflowActionParameter(
|
||||||
name="documentList",
|
name="documentList",
|
||||||
type="DocumentList",
|
type="DocumentList",
|
||||||
frontendType=FrontendType.DOCUMENT_REFERENCE,
|
frontendType=FrontendType.DOCUMENT_REFERENCE,
|
||||||
required=True,
|
required=False,
|
||||||
description="Document reference(s) to extract content from",
|
description="Optional document reference(s) to extract content from. When omitted, extractContent also accepts refs via context.",
|
||||||
),
|
),
|
||||||
"contentFilter": WorkflowActionParameter(
|
"contentFilter": WorkflowActionParameter(
|
||||||
name="contentFilter",
|
name="contentFilter",
|
||||||
|
|
|
||||||
|
|
@ -31,13 +31,11 @@ openpyxl>=3.1.2 # Für Excel-Dateien
|
||||||
python-pptx>=0.6.21 # Für PowerPoint-Dateien
|
python-pptx>=0.6.21 # Für PowerPoint-Dateien
|
||||||
|
|
||||||
## Data Processing & Analysis
|
## Data Processing & Analysis
|
||||||
numpy==1.26.3; python_version < "3.13"
|
numpy==1.26.3 # Version die mit pandas und matplotlib kompatibel ist
|
||||||
numpy>=2.1.0; python_version >= "3.13"
|
pandas==2.2.3 # Aktuelle Version beibehalten
|
||||||
pandas==2.2.3
|
|
||||||
|
|
||||||
## Data Visualization
|
## Data Visualization
|
||||||
matplotlib==3.8.0; python_version < "3.13"
|
matplotlib==3.8.0 # Aktuelle Version beibehalten
|
||||||
matplotlib>=3.9.0; python_version >= "3.13"
|
|
||||||
seaborn==0.13.0
|
seaborn==0.13.0
|
||||||
markdown
|
markdown
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,308 +0,0 @@
|
||||||
"""Export the database schema from Pydantic MODEL_REGISTRY + fk_target metadata.
|
|
||||||
|
|
||||||
Usage (run from gateway/):
|
|
||||||
python scripts/exportDbSchemaFromModels.py
|
|
||||||
python scripts/exportDbSchemaFromModels.py --validate
|
|
||||||
python scripts/exportDbSchemaFromModels.py --output ../wiki/b-reference/database-schema.md
|
|
||||||
|
|
||||||
The Pydantic classes are the single source of truth. The optional --validate
|
|
||||||
flag cross-checks against the live database and reports mismatches.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import importlib
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from collections import defaultdict
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Dict, List, Optional, Tuple
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
||||||
|
|
||||||
|
|
||||||
def _getArgs():
|
|
||||||
p = argparse.ArgumentParser(description="Export DB schema from Pydantic models")
|
|
||||||
p.add_argument("--output", default="../wiki/b-reference/database-schema.md")
|
|
||||||
p.add_argument("--validate", action="store_true",
|
|
||||||
help="Cross-check against live DB and report mismatches")
|
|
||||||
return p.parse_args()
|
|
||||||
|
|
||||||
|
|
||||||
def _loadAllModels():
|
|
||||||
"""Import all datamodel and interface modules to populate MODEL_REGISTRY + dbRegistry."""
|
|
||||||
for root, _dirs, files in os.walk("modules"):
|
|
||||||
for f in files:
|
|
||||||
if not f.endswith(".py") or f.startswith("__"):
|
|
||||||
continue
|
|
||||||
isDatamodel = f.startswith("datamodel")
|
|
||||||
isInterface = f.startswith("interface") and ("Db" in f or "Feature" in f)
|
|
||||||
if not isDatamodel and not isInterface:
|
|
||||||
continue
|
|
||||||
modPath = os.path.join(root, f).replace(os.sep, ".").replace(".py", "")
|
|
||||||
try:
|
|
||||||
importlib.import_module(modPath)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _buildCompleteTableToDbMap() -> Dict[str, str]:
|
|
||||||
"""Build tableName -> dbName by querying every registered DB's catalog.
|
|
||||||
|
|
||||||
More reliable than fkRegistry._buildTableToDbMap() for the schema script
|
|
||||||
because it catches ALL tables, not just FK targets.
|
|
||||||
"""
|
|
||||||
from modules.shared.dbRegistry import getRegisteredDatabases
|
|
||||||
from modules.system.databaseHealth import _getConnection
|
|
||||||
|
|
||||||
mapping: Dict[str, str] = {}
|
|
||||||
for dbName in getRegisteredDatabases():
|
|
||||||
try:
|
|
||||||
conn = _getConnection(dbName)
|
|
||||||
try:
|
|
||||||
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 NOT LIKE '\\_%'
|
|
||||||
""")
|
|
||||||
for row in cur.fetchall():
|
|
||||||
tbl = row["table_name"] if isinstance(row, dict) else row[0]
|
|
||||||
if tbl not in mapping:
|
|
||||||
mapping[tbl] = dbName
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
except Exception as e:
|
|
||||||
print(f" Warning: could not query {dbName}: {e}")
|
|
||||||
return mapping
|
|
||||||
|
|
||||||
|
|
||||||
def _buildSchema() -> Tuple[Dict[str, List[dict]], Dict[str, str]]:
|
|
||||||
"""Build {dbName: [tableInfo, ...]} from MODEL_REGISTRY + fk_target.
|
|
||||||
|
|
||||||
Returns (schema, tableToDb).
|
|
||||||
"""
|
|
||||||
from modules.datamodels.datamodelBase import MODEL_REGISTRY
|
|
||||||
|
|
||||||
tableToDb = _buildCompleteTableToDbMap()
|
|
||||||
schema: Dict[str, List[dict]] = defaultdict(list)
|
|
||||||
|
|
||||||
for tableName, modelCls in sorted(MODEL_REGISTRY.items()):
|
|
||||||
dbName = tableToDb.get(tableName)
|
|
||||||
if not dbName:
|
|
||||||
continue
|
|
||||||
|
|
||||||
fields = []
|
|
||||||
fkRefs = []
|
|
||||||
pkField = None
|
|
||||||
|
|
||||||
for fieldName, fieldInfo in modelCls.model_fields.items():
|
|
||||||
annotation = modelCls.__annotations__.get(fieldName)
|
|
||||||
typeName = _resolveTypeName(annotation)
|
|
||||||
isOptional = typeName.startswith("Optional[")
|
|
||||||
extra = fieldInfo.json_schema_extra or {}
|
|
||||||
fkTarget = extra.get("fk_target")
|
|
||||||
|
|
||||||
if fieldName == "id":
|
|
||||||
pkField = {"name": fieldName, "type": typeName}
|
|
||||||
continue
|
|
||||||
|
|
||||||
if fkTarget:
|
|
||||||
fkRefs.append({
|
|
||||||
"column": fieldName,
|
|
||||||
"targetDb": fkTarget.get("db", ""),
|
|
||||||
"targetTable": fkTarget.get("table", ""),
|
|
||||||
"targetColumn": fkTarget.get("column", "id"),
|
|
||||||
"labelField": fkTarget.get("labelField"),
|
|
||||||
"softFk": fkTarget.get("softFk", False),
|
|
||||||
})
|
|
||||||
|
|
||||||
fields.append({
|
|
||||||
"name": fieldName,
|
|
||||||
"type": typeName,
|
|
||||||
"optional": isOptional,
|
|
||||||
"description": fieldInfo.description or "",
|
|
||||||
})
|
|
||||||
|
|
||||||
schema[dbName].append({
|
|
||||||
"tableName": tableName,
|
|
||||||
"pk": pkField,
|
|
||||||
"fields": fields,
|
|
||||||
"fks": fkRefs,
|
|
||||||
"modelClass": f"{modelCls.__module__}.{modelCls.__name__}",
|
|
||||||
})
|
|
||||||
|
|
||||||
return dict(schema), tableToDb
|
|
||||||
|
|
||||||
|
|
||||||
def _resolveTypeName(annotation) -> str:
|
|
||||||
"""Best-effort stringification of a type annotation."""
|
|
||||||
if annotation is None:
|
|
||||||
return "Any"
|
|
||||||
origin = getattr(annotation, "__origin__", None)
|
|
||||||
if origin is not None:
|
|
||||||
args = getattr(annotation, "__args__", ())
|
|
||||||
if str(origin) == "typing.Union" or getattr(origin, "__name__", "") == "Union":
|
|
||||||
nonNone = [a for a in args if a is not type(None)]
|
|
||||||
if len(nonNone) == 1:
|
|
||||||
return f"Optional[{_resolveTypeName(nonNone[0])}]"
|
|
||||||
return f"Union[{', '.join(_resolveTypeName(a) for a in args)}]"
|
|
||||||
argStr = ", ".join(_resolveTypeName(a) for a in args)
|
|
||||||
name = getattr(origin, "__name__", str(origin))
|
|
||||||
return f"{name}[{argStr}]" if argStr else name
|
|
||||||
return getattr(annotation, "__name__", str(annotation))
|
|
||||||
|
|
||||||
|
|
||||||
def _renderMarkdown(schema: Dict[str, List[dict]]) -> str:
|
|
||||||
"""Render the schema as markdown."""
|
|
||||||
from modules.shared.dbRegistry import getRegisteredDatabases
|
|
||||||
|
|
||||||
registeredDbs = getRegisteredDatabases()
|
|
||||||
now = datetime.now().strftime("%Y-%m-%d %H:%M")
|
|
||||||
|
|
||||||
totalTables = sum(len(tables) for tables in schema.values())
|
|
||||||
totalFks = sum(len(t["fks"]) for tables in schema.values() for t in tables)
|
|
||||||
|
|
||||||
lines = [
|
|
||||||
"# PowerOn Database Schema\n",
|
|
||||||
f"> **Generated from**: Pydantic MODEL_REGISTRY + fk_target",
|
|
||||||
f"> **Date**: {now}",
|
|
||||||
f"> **Registered databases**: {len(registeredDbs)}",
|
|
||||||
f"> **Tables**: {totalTables}",
|
|
||||||
f"> **FK relationships**: {totalFks}\n",
|
|
||||||
"---\n",
|
|
||||||
]
|
|
||||||
|
|
||||||
for dbName in sorted(schema.keys()):
|
|
||||||
tables = schema[dbName]
|
|
||||||
lines.append(f"## {dbName}\n")
|
|
||||||
|
|
||||||
for tbl in sorted(tables, key=lambda t: t["tableName"]):
|
|
||||||
lines.append(f"### {tbl['tableName']}\n")
|
|
||||||
|
|
||||||
if tbl["pk"]:
|
|
||||||
lines.append(f"- **PK**: `{tbl['pk']['name']}` ({tbl['pk']['type']})")
|
|
||||||
|
|
||||||
for fk in tbl["fks"]:
|
|
||||||
crossDb = ""
|
|
||||||
if fk["targetDb"] != dbName:
|
|
||||||
crossDb = f" [cross-db: {fk['targetDb']}]"
|
|
||||||
soft = " **(soft)**" if fk["softFk"] else ""
|
|
||||||
lines.append(
|
|
||||||
f"- **FK**: `{fk['column']}` -> `{fk['targetTable']}.{fk['targetColumn']}`{crossDb}{soft}"
|
|
||||||
)
|
|
||||||
|
|
||||||
nonFkFields = []
|
|
||||||
fkCols = {fk["column"] for fk in tbl["fks"]}
|
|
||||||
for f in tbl["fields"]:
|
|
||||||
if f["name"] in fkCols or f["name"].startswith("sys"):
|
|
||||||
continue
|
|
||||||
opt = " (optional)" if f["optional"] else ""
|
|
||||||
nonFkFields.append(f"`{f['name']}` {f['type']}{opt}")
|
|
||||||
|
|
||||||
if nonFkFields:
|
|
||||||
lines.append(f"- **Fields**: {', '.join(nonFkFields)}")
|
|
||||||
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def _validateAgainstLiveDb(schema: Dict[str, List[dict]], tableToDb: Dict[str, str]) -> List[str]:
|
|
||||||
"""Compare Pydantic schema against live PostgreSQL and return mismatch warnings."""
|
|
||||||
from modules.shared.configuration import APP_CONFIG
|
|
||||||
import psycopg2
|
|
||||||
import psycopg2.extras
|
|
||||||
|
|
||||||
host = APP_CONFIG.get("DB_HOST", "localhost")
|
|
||||||
port = int(APP_CONFIG.get("DB_PORT", 5432))
|
|
||||||
user = APP_CONFIG.get("DB_USER", "poweron_dev")
|
|
||||||
password = APP_CONFIG.get("DB_PASSWORD_SECRET")
|
|
||||||
if not password:
|
|
||||||
return ["ERROR: DB_PASSWORD_SECRET not available for validation"]
|
|
||||||
|
|
||||||
warnings = []
|
|
||||||
|
|
||||||
for dbName, tables in sorted(schema.items()):
|
|
||||||
try:
|
|
||||||
conn = psycopg2.connect(
|
|
||||||
host=host, port=port, user=user, password=password,
|
|
||||||
database=dbName, client_encoding="utf8",
|
|
||||||
cursor_factory=psycopg2.extras.RealDictCursor,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
warnings.append(f" {dbName}: connection failed ({e})")
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
cur.execute("""
|
|
||||||
SELECT table_name FROM information_schema.tables
|
|
||||||
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
|
|
||||||
""")
|
|
||||||
liveTables = {row["table_name"] for row in cur.fetchall()}
|
|
||||||
|
|
||||||
for tbl in tables:
|
|
||||||
name = tbl["tableName"]
|
|
||||||
if name not in liveTables:
|
|
||||||
warnings.append(f" {dbName}.{name}: model exists but NO table in DB")
|
|
||||||
continue
|
|
||||||
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
cur.execute("""
|
|
||||||
SELECT column_name FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public' AND table_name = %s
|
|
||||||
""", (name,))
|
|
||||||
liveCols = {row["column_name"] for row in cur.fetchall()}
|
|
||||||
|
|
||||||
modelCols = {"id"} | {f["name"] for f in tbl["fields"]}
|
|
||||||
missingInDb = modelCols - liveCols
|
|
||||||
legacyAuditCols = {
|
|
||||||
"_createdAt", "_createdBy", "_modifiedAt", "_modifiedBy",
|
|
||||||
"sysCreatedAt", "sysCreatedBy", "sysModifiedAt", "sysModifiedBy",
|
|
||||||
"createdAt", "updatedAt", "creationDate", "lastModified",
|
|
||||||
}
|
|
||||||
extraInDb = liveCols - modelCols - legacyAuditCols
|
|
||||||
if missingInDb:
|
|
||||||
warnings.append(f" {dbName}.{name}: columns in model but not in DB: {sorted(missingInDb)}")
|
|
||||||
if extraInDb:
|
|
||||||
warnings.append(f" {dbName}.{name}: columns in DB but not in model: {sorted(extraInDb)}")
|
|
||||||
|
|
||||||
modelTableNames = {t["tableName"] for t in tables}
|
|
||||||
for lt in sorted(liveTables):
|
|
||||||
if lt not in modelTableNames and not lt.startswith("_"):
|
|
||||||
warnings.append(f" {dbName}.{lt}: table in DB but no Pydantic model (legacy?)")
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
return warnings
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
args = _getArgs()
|
|
||||||
_loadAllModels()
|
|
||||||
|
|
||||||
print("Building schema from MODEL_REGISTRY...")
|
|
||||||
schema, tableToDb = _buildSchema()
|
|
||||||
|
|
||||||
totalTables = sum(len(t) for t in schema.values())
|
|
||||||
totalFks = sum(len(t["fks"]) for tables in schema.values() for t in tables)
|
|
||||||
print(f" {len(schema)} databases, {totalTables} tables, {totalFks} FK relationships")
|
|
||||||
|
|
||||||
md = _renderMarkdown(schema)
|
|
||||||
with open(args.output, "w", encoding="utf-8") as f:
|
|
||||||
f.write(md)
|
|
||||||
print(f"\nSchema written to {args.output}")
|
|
||||||
|
|
||||||
if args.validate:
|
|
||||||
print("\nValidating against live database...")
|
|
||||||
warnings = _validateAgainstLiveDb(schema, tableToDb)
|
|
||||||
if warnings:
|
|
||||||
print(f"\n{len(warnings)} mismatches found:")
|
|
||||||
for w in warnings:
|
|
||||||
print(w)
|
|
||||||
else:
|
|
||||||
print(" No mismatches - live DB matches Pydantic models perfectly.")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|
@ -171,8 +171,7 @@ def liveConnector():
|
||||||
try:
|
try:
|
||||||
with adminConn.cursor() as cur:
|
with adminConn.cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
'SELECT pg_terminate_backend(pid) FROM pg_stat_activity '
|
'SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = %s',
|
||||||
'WHERE datname = %s AND pid != pg_backend_pid() AND usename = current_user',
|
|
||||||
(dbName,),
|
(dbName,),
|
||||||
)
|
)
|
||||||
cur.execute(f'DROP DATABASE IF EXISTS "{dbName}"')
|
cur.execute(f'DROP DATABASE IF EXISTS "{dbName}"')
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ class TestCostEstimate(unittest.TestCase):
|
||||||
{"maxBytes": 200 * 1024 * 1024}, kind="files",
|
{"maxBytes": 200 * 1024 * 1024}, kind="files",
|
||||||
)
|
)
|
||||||
self.assertIn("estimatedTokens", result)
|
self.assertIn("estimatedTokens", result)
|
||||||
self.assertIn("estimatedChf", result)
|
self.assertIn("estimatedUsd", result)
|
||||||
self.assertIn("basis", result)
|
self.assertIn("basis", result)
|
||||||
self.assertIn("assumptions", result["basis"])
|
self.assertIn("assumptions", result["basis"])
|
||||||
self.assertIn("formula", result["basis"]["assumptions"])
|
self.assertIn("formula", result["basis"]["assumptions"])
|
||||||
|
|
@ -39,12 +39,12 @@ class TestCostEstimate(unittest.TestCase):
|
||||||
def test_unknown_kind_returns_zero(self):
|
def test_unknown_kind_returns_zero(self):
|
||||||
result = _costEstimate.estimateBootstrapCost({}, kind="totally-unknown")
|
result = _costEstimate.estimateBootstrapCost({}, kind="totally-unknown")
|
||||||
self.assertEqual(result["estimatedTokens"], 0)
|
self.assertEqual(result["estimatedTokens"], 0)
|
||||||
self.assertEqual(result["estimatedChf"], 0.0)
|
self.assertEqual(result["estimatedUsd"], 0.0)
|
||||||
|
|
||||||
def test_chf_is_rounded_4_decimals(self):
|
def test_usd_is_rounded_4_decimals(self):
|
||||||
result = _costEstimate.estimateBootstrapCost({"maxBytes": 1024 * 1024}, kind="files")
|
result = _costEstimate.estimateBootstrapCost({"maxBytes": 1024 * 1024}, kind="files")
|
||||||
rounded = round(result["estimatedChf"], 4)
|
rounded = round(result["estimatedUsd"], 4)
|
||||||
self.assertEqual(result["estimatedChf"], rounded)
|
self.assertEqual(result["estimatedUsd"], rounded)
|
||||||
|
|
||||||
def test_basis_includes_input_limits(self):
|
def test_basis_includes_input_limits(self):
|
||||||
result = _costEstimate.estimateBootstrapCost({"maxBytes": 42}, kind="files")
|
result = _costEstimate.estimateBootstrapCost({"maxBytes": 42}, kind="files")
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ def test_context_extractContent_node_shape():
|
||||||
assert "LoopItem" in node["inputPorts"][0]["accepts"]
|
assert "LoopItem" in node["inputPorts"][0]["accepts"]
|
||||||
names = [p["name"] for p in node["parameters"]]
|
names = [p["name"] for p in node["parameters"]]
|
||||||
assert names == [
|
assert names == [
|
||||||
"documentList",
|
"context",
|
||||||
"contentFilter",
|
"contentFilter",
|
||||||
"outputMode",
|
"outputMode",
|
||||||
"splitBy",
|
"splitBy",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue