Merge branch 'int' of git.poweron.swiss:PowerOn/platform-core into int
This commit is contained in:
commit
7f7b21a384
43 changed files with 2186 additions and 2735 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -1,741 +0,0 @@
|
|||
---
|
||||
name: Swift iOS App Nachbau
|
||||
overview: Vollständiger Implementierungsplan für den Nachbau des React-Web-Frontends (frontend_nyla) als native Swift/SwiftUI iOS/iPadOS-App. Die App kommuniziert mit dem bestehenden FastAPI-Gateway-Backend und bildet alle UI-Screens, Navigation und API-Schnittstellen nach.
|
||||
todos:
|
||||
- id: phase-0
|
||||
content: "Phase 0: Xcode-Projekt erstellen, Ordnerstruktur, SPM-Dependencies, Build-Configs (Dev/Int/Prod)"
|
||||
status: pending
|
||||
- id: phase-1
|
||||
content: "Phase 1: Core Networking Layer -- APIClient, SSEClient, WebSocketClient, CSRFManager (analog api.ts + sseClient.ts)"
|
||||
status: pending
|
||||
- id: phase-2
|
||||
content: "Phase 2: Authentication -- LocalAuth, MSAL, Google, Biometrie, Keychain (analog authApi.ts + AuthProvider.tsx)"
|
||||
status: pending
|
||||
- id: phase-3
|
||||
content: "Phase 3: Domain Models + FeatureStore (analog mandate.ts + featureStore.tsx)"
|
||||
status: pending
|
||||
- id: phase-4
|
||||
content: "Phase 4: App Shell -- NavigationSplitView (iPad) / TabView (iPhone), Dashboard, Settings, backend-driven Sidebar"
|
||||
status: pending
|
||||
- id: phase-5
|
||||
content: "Phase 5: i18n String Catalogs (de/en/fr) + Theme System (Light/Dark)"
|
||||
status: pending
|
||||
- id: phase-6
|
||||
content: "Phase 6: Core Pages -- Store, GDPR, Basedata (Prompts/Files/Connections), Billing Transactions"
|
||||
status: pending
|
||||
- id: phase-7
|
||||
content: "Phase 7: Shared UI Components -- FormGenerator, ContentPreview, ChatMessage, AccessRules, NotificationBell"
|
||||
status: pending
|
||||
- id: phase-8
|
||||
content: "Phase 8: Push Notifications (APNs Registration, Deep-Link Handling)"
|
||||
status: pending
|
||||
- id: phase-9
|
||||
content: "Phase 9: Admin Module -- alle 16 Admin-Seiten (Mandates, Users, RBAC, Invitations, Wizards, etc.)"
|
||||
status: pending
|
||||
- id: phase-10
|
||||
content: "Phase 10: Feature Trustee -- Dashboard, Documents, Positions, Roles, Expense-Import, Scan, Accounting"
|
||||
status: pending
|
||||
- id: phase-11
|
||||
content: "Phase 11: Feature Workspace -- Chat-Streaming (SSE), Files, Datasources, Voice"
|
||||
status: pending
|
||||
- id: phase-12
|
||||
content: "Phase 12: Feature Chatbot -- SSE-Streaming Chat, Threads, Conversations"
|
||||
status: pending
|
||||
- id: phase-13
|
||||
content: "Phase 13: Feature Teamsbot -- Sessions, WebSocket Bot-Kommunikation, Voice, MFA"
|
||||
status: pending
|
||||
- id: phase-14
|
||||
content: "Phase 14: Feature CommCoach -- Coaching Sessions, Audio-Streaming, Personas, Dossier"
|
||||
status: pending
|
||||
- id: phase-15
|
||||
content: "Phase 15: Feature ChatPlayground -- Workflows, Playground mit SSE-Stream"
|
||||
status: pending
|
||||
- id: phase-16
|
||||
content: "Phase 16: Feature Automation -- Definitions, Templates, Logs, Execute"
|
||||
status: pending
|
||||
- id: phase-17
|
||||
content: "Phase 17: Feature CodeEditor -- Editor mit SSE-Stream, Code-Anzeige, Apply"
|
||||
status: pending
|
||||
- id: phase-18
|
||||
content: "Phase 18: Feature RealEstate/PEK -- MapKit-Integration, Parcels, Address-Search, BZO"
|
||||
status: pending
|
||||
- id: phase-19
|
||||
content: "Phase 19: Feature Neutralization -- Config, Neutralize Text/File"
|
||||
status: pending
|
||||
- id: phase-20
|
||||
content: "Phase 20: Billing-Erweiterung -- Admin-Views, Stripe Checkout"
|
||||
status: pending
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# Nyla iOS/iPadOS App -- Vollständiger Implementierungsplan
|
||||
|
||||
## Ausgangslage
|
||||
|
||||
Das bestehende Web-Frontend (`frontend_nyla`) ist eine **React 19 + Vite + TypeScript** Anwendung mit:
|
||||
|
||||
- **12+ Feature-Module** (Trustee, Workspace, Chatbot, Teamsbot, CommCoach, CodeEditor, Automation, RealEstate, Neutralization, ChatPlayground, Billing, Admin)
|
||||
- **21 API-Module** unter `src/api/*.ts` mit insgesamt **200+ API-Endpunkten**
|
||||
- **120+ UI-Komponenten** inkl. dynamischem FormGenerator, ContentPreview, Chat-Streaming, Maps, Charts
|
||||
- **Multi-Tenant-Architektur**: Mandate > Features > Instanzen > Views/Permissions
|
||||
- **3 Auth-Provider**: Local, Microsoft MSAL, Google OAuth
|
||||
- **Echtzeit**: SSE-Streaming (Chat, Workspace, CodeEditor) + WebSockets (Voice)
|
||||
- **Backend**: FastAPI (Python) auf PostgreSQL, erreichbar unter konfigurierbarer `VITE_API_BASE_URL`
|
||||
|
||||
---
|
||||
|
||||
## Technische Entscheidungen
|
||||
|
||||
|
||||
| Aspekt | Entscheidung |
|
||||
| -------------------- | --------------------------------------------------- |
|
||||
| Plattform | iOS 18+ / iPadOS 18+ |
|
||||
| UI-Framework | SwiftUI |
|
||||
| Architektur | **MVVM + Repository Pattern** (s. unten) |
|
||||
| Networking | URLSession + async/await |
|
||||
| SSE | Custom SSE-Client auf URLSession-Basis |
|
||||
| WebSocket | URLSessionWebSocketTask |
|
||||
| Auth | MSAL SDK, Google Sign-In SDK, Keychain + Local Auth |
|
||||
| Biometrie | LocalAuthentication (Face ID / Touch ID) |
|
||||
| State | `@Observable` (Observation Framework, iOS 17+) |
|
||||
| Navigation | `NavigationStack` + `NavigationSplitView` (iPad) |
|
||||
| Dependency Injection | Environment-basiert (SwiftUI `@Environment`) |
|
||||
| Package Manager | Swift Package Manager (SPM) |
|
||||
| Karten | MapKit (SwiftUI) |
|
||||
| Charts | Swift Charts |
|
||||
| i18n | String Catalogs (`.xcstrings`) fuer de/en/fr |
|
||||
| Push | APNs + UserNotifications Framework |
|
||||
| PDF-Anzeige | PDFKit |
|
||||
| Markdown | Native AttributedString (iOS 15+) |
|
||||
| Persistenz | Keychain (Secrets), UserDefaults (Preferences) |
|
||||
| Distribution | TestFlight |
|
||||
|
||||
|
||||
### Architektur: MVVM + Repository Pattern
|
||||
|
||||
```
|
||||
Presentation Layer (SwiftUI Views)
|
||||
|
|
||||
v
|
||||
ViewModels (@Observable)
|
||||
|
|
||||
v
|
||||
Repositories (Protokolle)
|
||||
|
|
||||
v
|
||||
API Services (URLSession)
|
||||
|
|
||||
v
|
||||
Gateway Backend (FastAPI)
|
||||
```
|
||||
|
||||
Begründung: SwiftUI ist nativ MVVM-orientiert. Das Repository Pattern kapselt die Datenzugriffe und macht den Code testbar. `@Observable` (iOS 17+) ist leichter als `ObservableObject` und performanter.
|
||||
|
||||
### Projektstruktur
|
||||
|
||||
```
|
||||
NylaApp/
|
||||
NylaApp.swift // App Entry Point
|
||||
Config/
|
||||
AppConfig.swift // API URLs, Build Configs
|
||||
Environment.swift // Dev/Int/Prod Environments
|
||||
Core/
|
||||
Networking/
|
||||
APIClient.swift // Zentraler HTTP-Client (= api.ts)
|
||||
APIError.swift // Error Types
|
||||
APIEndpoints.swift // Endpoint Definitionen
|
||||
SSEClient.swift // Server-Sent Events Client
|
||||
WebSocketClient.swift // WebSocket Client
|
||||
CSRFManager.swift // CSRF Token Handling
|
||||
RequestInterceptor.swift // Auth/Mandate Headers
|
||||
Auth/
|
||||
AuthManager.swift // Zentrale Auth-Logik
|
||||
LocalAuthService.swift // Username/Password
|
||||
MSALAuthService.swift // Microsoft MSAL
|
||||
GoogleAuthService.swift // Google Sign-In
|
||||
BiometricAuthService.swift // Face ID / Touch ID
|
||||
KeychainService.swift // Secure Storage
|
||||
Navigation/
|
||||
AppRouter.swift // Root Navigation
|
||||
NavigationStore.swift // Backend-driven Nav State
|
||||
DeepLinkHandler.swift // URL Scheme Handling
|
||||
Localization/
|
||||
Localizable.xcstrings // String Catalog
|
||||
LanguageManager.swift // Sprachauswahl
|
||||
Theme/
|
||||
ThemeManager.swift // Light/Dark Mode
|
||||
DesignTokens.swift // Farben, Spacing, Fonts
|
||||
Permissions/
|
||||
PermissionChecker.swift // RBAC Client-Checks
|
||||
Domain/
|
||||
Models/ // Shared Domain Models
|
||||
Mandate.swift // Mandate, Feature, Instance
|
||||
User.swift // User Model
|
||||
Permissions.swift // AccessLevel, TablePermission
|
||||
Pagination.swift // PaginatedResponse<T>
|
||||
I18nLabel.swift // Mehrsprachige Labels
|
||||
Repositories/ // Repository Protokolle
|
||||
AuthRepository.swift
|
||||
MandateRepository.swift
|
||||
FeatureRepository.swift
|
||||
...
|
||||
Data/
|
||||
API/ // API-Implementierungen (= src/api/*.ts)
|
||||
AuthAPI.swift
|
||||
UserAPI.swift
|
||||
MandateAPI.swift
|
||||
FeaturesAPI.swift
|
||||
BillingAPI.swift
|
||||
TrusteeAPI.swift
|
||||
... (21 Module)
|
||||
Repositories/ // Repository Implementierungen
|
||||
DefaultAuthRepository.swift
|
||||
DefaultMandateRepository.swift
|
||||
...
|
||||
Features/ // Feature-Module (je Ordner)
|
||||
Dashboard/
|
||||
Store/
|
||||
Settings/
|
||||
GDPR/
|
||||
Basedata/
|
||||
Prompts/
|
||||
Files/
|
||||
Connections/
|
||||
Billing/
|
||||
Admin/
|
||||
Mandates/
|
||||
Users/
|
||||
Access/
|
||||
Invitations/
|
||||
...
|
||||
Trustee/
|
||||
Workspace/
|
||||
Chatbot/
|
||||
Teamsbot/
|
||||
CommCoach/
|
||||
CodeEditor/
|
||||
ChatPlayground/
|
||||
Automation/
|
||||
RealEstate/
|
||||
Neutralization/
|
||||
Shared/
|
||||
Components/ // Wiederverwendbare UI (= src/components/)
|
||||
FormGenerator/ // Dynamische Formulare
|
||||
ContentPreview/ // PDF, Bild, JSON Vorschau
|
||||
ChatMessage/ // Chat-Nachrichten-Rendering
|
||||
AccessRules/ // Zugriffsregeln-Editor
|
||||
NotificationBell/ // Notification Badge + Overlay
|
||||
SearchBar/
|
||||
LoadingView/
|
||||
ErrorView/
|
||||
EmptyStateView/
|
||||
Extensions/
|
||||
Utilities/
|
||||
Resources/
|
||||
Assets.xcassets
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phasen-Plan
|
||||
|
||||
### Phase 0: Projekt-Setup (1-2 Tage)
|
||||
|
||||
- Xcode-Projekt erstellen (iOS 18+, SwiftUI App Lifecycle)
|
||||
- Ordnerstruktur nach obigem Schema anlegen
|
||||
- SPM Dependencies einrichten:
|
||||
- `MSAL` (Microsoft Authentication Library for iOS)
|
||||
- `GoogleSignIn` (Google Sign-In SDK)
|
||||
- Keine weiteren externen Deps noetig (MapKit, Charts, PDFKit sind System-Frameworks)
|
||||
- Build-Konfigurationen: **Dev** / **Int** / **Prod** mit je eigenem `API_BASE_URL`
|
||||
- Analog zu den `.env.dev` / `.env.int` / `.env.prod` Dateien im Web-Frontend
|
||||
- Werte: `http://localhost:8000` (Dev), INT-URL, PROD-URL
|
||||
- TestFlight-Vorbereitung: App ID, Provisioning Profile, Signing
|
||||
|
||||
### Phase 1: Core Networking Layer (3-5 Tage)
|
||||
|
||||
**Ziel**: Equivalent zu `[src/api.ts](frontend_nyla/src/api.ts)` + `[src/hooks/useApi.ts](frontend_nyla/src/hooks/useApi.ts)`
|
||||
|
||||
**APIClient.swift** -- Zentraler HTTP-Client:
|
||||
|
||||
- `URLSession.shared` mit Custom-Configuration
|
||||
- Cookie-basierte Auth (`httpCookieStorage`)
|
||||
- Request-Interceptor fuer:
|
||||
- `Authorization: Bearer` Header (aus Keychain)
|
||||
- `X-Mandate-Id` / `X-Instance-Id` Header (aus aktuellem Navigation-Context)
|
||||
- CSRF-Token fuer POST/PUT/PATCH/DELETE
|
||||
- Response-Handler:
|
||||
- 401 -> Redirect zu Login (analog Web `api.ts` Zeile 127-151)
|
||||
- 429 -> Rate-Limit Warning
|
||||
- Generische Fehlerextraktion (FastAPI `detail` Array/String)
|
||||
- Generische Request-Methoden: `get<T>()`, `post<T>()`, `put<T>()`, `delete<T>()`, `upload()`
|
||||
- `Codable`-basierte JSON Serialisierung
|
||||
|
||||
**SSEClient.swift** -- Server-Sent Events:
|
||||
|
||||
- Analog zu `[src/utils/sseClient.ts](frontend_nyla/src/utils/sseClient.ts)`
|
||||
- URLSession mit `bytes(for:)` async stream
|
||||
- Parsing von `data:` Lines
|
||||
- Callbacks: `onMessage`, `onError`, `onComplete`
|
||||
- Wird benoetigt fuer: Workspace, Chatbot, CodeEditor, CommCoach Streaming
|
||||
|
||||
**WebSocketClient.swift** -- WebSockets:
|
||||
|
||||
- `URLSessionWebSocketTask`
|
||||
- Fuer Voice-Features (Teamsbot: `/api/teamsbot/{instanceId}/bot/ws/{sessionId}`)
|
||||
- Ping/Pong, Reconnect-Logik
|
||||
|
||||
**CSRFManager.swift**:
|
||||
|
||||
- Token-Generierung und -Speicherung
|
||||
- Analog zu `[src/utils/csrfUtils.ts](frontend_nyla/src/utils/csrfUtils.ts)`
|
||||
|
||||
### Phase 2: Authentication (3-5 Tage)
|
||||
|
||||
**Ziel**: Alle 3 Auth-Provider + Biometrie
|
||||
|
||||
**Mapping Web -> Swift:**
|
||||
|
||||
|
||||
| Web (authApi.ts) | Swift |
|
||||
| ---------------------------------------- | -------------------------------------------- |
|
||||
| `POST /api/local/login` (form-data) | `LocalAuthService.login(username:password:)` |
|
||||
| `POST /api/local/register` | `LocalAuthService.register(...)` |
|
||||
| `POST /api/local/password-reset-request` | `LocalAuthService.requestPasswordReset(...)` |
|
||||
| `POST /api/local/password-reset` | `LocalAuthService.resetPassword(...)` |
|
||||
| `GET /api/local/available?username=` | `LocalAuthService.checkAvailability(...)` |
|
||||
| `GET /api/local/me` | `AuthManager.fetchCurrentUser()` |
|
||||
| `POST /api/local/logout` | `AuthManager.logout()` |
|
||||
| MSAL Login/Callback | `MSALAuthService` via MSAL SDK |
|
||||
| `GET /api/msft/me` | `MSALAuthService.fetchUser()` |
|
||||
| Google Login/Callback | `GoogleAuthService` via Google Sign-In SDK |
|
||||
| `GET /api/google/me` | `GoogleAuthService.fetchUser()` |
|
||||
|
||||
|
||||
**AuthManager.swift** (zentral):
|
||||
|
||||
- Verwaltet aktiven Auth-Provider (`local` / `msft` / `google`)
|
||||
- Speichert Auth-State in Keychain (nicht UserDefaults!)
|
||||
- Published `isAuthenticated`, `currentUser`, `authAuthority`
|
||||
- Analog zu `[src/providers/auth/AuthProvider.tsx](frontend_nyla/src/providers/auth/AuthProvider.tsx)`
|
||||
|
||||
**BiometricAuthService.swift**:
|
||||
|
||||
- `LAContext.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics)`
|
||||
- Nach erstem erfolgreichen Login: Credentials in Keychain speichern
|
||||
- Bei App-Start: Face ID/Touch ID -> Keychain Credentials -> Auto-Login
|
||||
|
||||
**Login Screen (SwiftUI)**:
|
||||
|
||||
- Username/Password Felder
|
||||
- "Anmelden mit Microsoft" Button (MSAL)
|
||||
- "Anmelden mit Google" Button (Google Sign-In)
|
||||
- "Face ID / Touch ID" Option (wenn verfuegbar)
|
||||
- Registrierung / Passwort vergessen Links
|
||||
- Analog zu `[src/pages/Login.tsx](frontend_nyla/src/pages/Login.tsx)`
|
||||
|
||||
### Phase 3: Domain Models + Feature Store (2-3 Tage)
|
||||
|
||||
**Ziel**: Alle geteilten Datenmodelle + Feature-State
|
||||
|
||||
Zentrale Models (analog zu `[src/types/mandate.ts](frontend_nyla/src/types/mandate.ts)`):
|
||||
|
||||
```swift
|
||||
// Mandate.swift
|
||||
struct I18nLabel: Codable { var de: String; var en: String; var fr: String? }
|
||||
enum AccessLevel: String, Codable { case none = "n", my = "m", group = "g", all = "a" }
|
||||
struct TablePermission: Codable { var view: Bool; var read, create, update, delete: AccessLevel }
|
||||
struct FieldPermission: Codable { var read: Bool; var write: Bool }
|
||||
struct InstancePermissions: Codable { var tables: [String: TablePermission]; var fields: [String: [String: FieldPermission]]?; var views: [String: Bool]; var isAdmin: Bool? }
|
||||
struct FeatureInstance: Codable, Identifiable { var id: String; var featureCode, mandateId, mandateName, instanceLabel: String; var userRoles: [String]; var permissions: InstancePermissions }
|
||||
struct MandateFeature: Codable { var code: String; var label: I18nLabel; var icon: String; var instances: [FeatureInstance] }
|
||||
struct Mandate: Codable, Identifiable { var id, name: String; var label, code: String?; var features: [MandateFeature] }
|
||||
struct FeaturesMyResponse: Codable { var mandates: [Mandate] }
|
||||
```
|
||||
|
||||
**FeatureStore.swift** (analog zu `[src/stores/featureStore.tsx](frontend_nyla/src/stores/featureStore.tsx)`):
|
||||
|
||||
- `@Observable class FeatureStore`
|
||||
- `loadFeatures()` -> `GET /api/features/my`
|
||||
- Cache: `[String: FeatureInstance]` fuer schnellen Zugriff
|
||||
- Methoden: `getMandateById()`, `getInstanceById()`, `getAllInstances()`, etc.
|
||||
- Injected via SwiftUI `@Environment`
|
||||
|
||||
### Phase 4: App Shell + Navigation (4-6 Tage)
|
||||
|
||||
**Ziel**: MainLayout + FeatureLayout + backend-driven Navigation
|
||||
|
||||
**Adaptive Layout:**
|
||||
|
||||
- **iPad**: `NavigationSplitView` (Sidebar + Detail) -- analog Web-Sidebar
|
||||
- **iPhone**: `TabView` mit Hauptbereichen + Navigation Stack pro Tab
|
||||
|
||||
**Sidebar / Navigation:**
|
||||
|
||||
- Backend-driven: `GET /api/navigation?language={lang}` liefert Navigationsbaum
|
||||
- Analog zu `[src/components/Navigation/MandateNavigation.tsx](frontend_nyla/src/components/Navigation/MandateNavigation.tsx)`
|
||||
- Hierarchie: Mandate > Feature > Instance > Views
|
||||
- Icon-Mapping: SF Symbols statt React Icons (Mapping-Tabelle erstellen)
|
||||
|
||||
**Screen-Routing:**
|
||||
|
||||
- `NavigationStack` mit `NavigationPath` fuer programmatische Navigation
|
||||
- Deep-Link-Schema: `nyla://mandates/{mandateId}/{featureCode}/{instanceId}/{view}`
|
||||
- Feature-View-Dispatcher: analog zu `[src/pages/FeatureView.tsx](frontend_nyla/src/pages/FeatureView.tsx)` `VIEW_COMPONENTS`
|
||||
|
||||
**Screens in Phase 4:**
|
||||
|
||||
- Dashboard (`/`) -- Mandate/Instance-Karten, analog `[src/pages/Dashboard.tsx](frontend_nyla/src/pages/Dashboard.tsx)`
|
||||
- Settings (`/settings`) -- Theme-Toggle, Sprache (de/en/fr), Profil
|
||||
- UserSection im Sidebar-Footer
|
||||
|
||||
### Phase 5: i18n + Theme (2-3 Tage)
|
||||
|
||||
**Internationalisierung:**
|
||||
|
||||
- Xcode String Catalog (`.xcstrings`) fuer de/en/fr
|
||||
- Alle statischen Strings aus den Web-Locales uebernehmen: `[src/locales/de.ts](frontend_nyla/src/locales/de.ts)`, `en.ts`, `fr.ts`
|
||||
- Dynamische Labels (I18nLabel vom Backend): Helper `label.localized(lang:)` analog `getLabel()` im Web
|
||||
- `LanguageManager` speichert Praeferenz in UserDefaults
|
||||
|
||||
**Theme:**
|
||||
|
||||
- SwiftUI `.preferredColorScheme()` fuer System-Integration
|
||||
- Custom `DesignTokens` fuer konsistente Farben/Spacing
|
||||
- Analog zu `[src/styles/themes/light.css](frontend_nyla/src/styles/themes/light.css)` + `.dark-theme`
|
||||
|
||||
### Phase 6: Core Pages (5-7 Tage)
|
||||
|
||||
**Store** (Feature Marketplace):
|
||||
|
||||
- `GET /api/store/features` -> Feature-Liste
|
||||
- `POST /api/store/activate` / `POST /api/store/deactivate`
|
||||
- Analog `[src/pages/Store.tsx](frontend_nyla/src/pages/Store.tsx)`
|
||||
|
||||
**GDPR**:
|
||||
|
||||
- `GET /api/user/me/data-export` + `/data-portability`
|
||||
- `DELETE /api/user/me/`
|
||||
- Analog `[src/pages/GDPR.tsx](frontend_nyla/src/pages/GDPR.tsx)`
|
||||
|
||||
**Basedata - Prompts** (`/basedata/prompts`):
|
||||
|
||||
- CRUD auf `/api/prompts` mit FormGenerator
|
||||
- Analog `[src/pages/PromptsPage.tsx](frontend_nyla/src/pages/PromptsPage.tsx)`
|
||||
|
||||
**Basedata - Files** (`/basedata/files`):
|
||||
|
||||
- `GET /api/files/list`, Upload, Download, Preview
|
||||
- Analog `[src/pages/FilesPage.tsx](frontend_nyla/src/pages/FilesPage.tsx)`
|
||||
- Nutzung von `UIDocumentPickerViewController` (via UIKit-Bridge) fuer File-Upload
|
||||
- `QuickLook` fuer Dateivorschau
|
||||
|
||||
**Basedata - Connections** (`/basedata/connections`):
|
||||
|
||||
- CRUD auf `/api/connections/`
|
||||
- Connect/Disconnect Aktionen
|
||||
- Analog `[src/pages/ConnectionsPage.tsx](frontend_nyla/src/pages/ConnectionsPage.tsx)`
|
||||
|
||||
**Billing** (`/billing/transactions`):
|
||||
|
||||
- `GET /api/billing/balance`, `/transactions`, `/statistics/{period}`
|
||||
- Swift Charts fuer Statistik-Visualisierung
|
||||
- Analog `[src/pages/billing/BillingDataView.tsx](frontend_nyla/src/pages/billing/BillingDataView.tsx)`
|
||||
|
||||
### Phase 7: Shared UI Components (5-8 Tage)
|
||||
|
||||
**FormGenerator** (zentral, wird von fast allen Features genutzt):
|
||||
|
||||
- Analog zu `[src/components/FormGenerator/](frontend_nyla/src/components/FormGenerator/)`
|
||||
- Dynamische Formulare basierend auf `AttributeDefinition[]` vom Backend (`GET /api/attributes/{entityType}`)
|
||||
- Feldtypen: String, Email, Select, Multiselect, Textarea, Checkbox, File, Number, DateTime, Multilingual
|
||||
- Tabellen-Ansicht (`FormGeneratorTable`) + Listen-Ansicht (`FormGeneratorList`)
|
||||
- Action Buttons (Edit, Delete, Download, Custom)
|
||||
- Pagination-Support
|
||||
|
||||
**ContentPreview**:
|
||||
|
||||
- PDF: `PDFKitView` (UIKit PDFView in UIViewRepresentable)
|
||||
- Bilder: AsyncImage
|
||||
- JSON: Syntax-Highlighting
|
||||
- HTML: WKWebView
|
||||
- Analog `[src/components/ContentPreview/](frontend_nyla/src/components/ContentPreview/)`
|
||||
|
||||
**NotificationBell**:
|
||||
|
||||
- `GET /api/notifications/unread-count` (Polling)
|
||||
- Push Notifications via APNs
|
||||
- In-App Notification Sheet
|
||||
- Analog `[src/components/NotificationBell/](frontend_nyla/src/components/NotificationBell/)`
|
||||
|
||||
**Chat Message Components**:
|
||||
|
||||
- Message-Bubbles mit Markdown-Rendering
|
||||
- File-Attachments
|
||||
- Streaming-Indicator (typing animation)
|
||||
- Auto-Scroll
|
||||
- Analog `[src/components/UiComponents/Messages/](frontend_nyla/src/components/UiComponents/Messages/)`
|
||||
|
||||
**AccessRules Components**:
|
||||
|
||||
- Tabelle + Editor fuer RBAC-Regeln
|
||||
- Analog `[src/components/AccessRules/](frontend_nyla/src/components/AccessRules/)`
|
||||
|
||||
### Phase 8: Push Notifications (2-3 Tage)
|
||||
|
||||
- APNs-Registrierung in `AppDelegate`
|
||||
- Device Token an Backend senden (neuer Endpoint oder bestehender `/api/messaging/subscriptions`)
|
||||
- `UNUserNotificationCenter` fuer lokale + remote Notifications
|
||||
- Deep-Link Handling aus Notification-Tap
|
||||
|
||||
### Phase 9: Admin Module (5-7 Tage)
|
||||
|
||||
Alle Admin-Seiten analog zu `[src/pages/admin/](frontend_nyla/src/pages/admin/)`:
|
||||
|
||||
|
||||
| Admin-Seite | API-Endpunkte |
|
||||
| -------------------- | ------------------------------------------ |
|
||||
| Mandates | CRUD `/api/mandates/` |
|
||||
| Users | CRUD `/api/users/` |
|
||||
| User-Mandates | `/api/mandates/{id}/users` |
|
||||
| Access Hub | `/api/rbac/permissions`, `/api/rbac/rules` |
|
||||
| Feature Instances | `/api/features/instances` |
|
||||
| Feature Roles | `/api/features/templates/roles` |
|
||||
| Feature Users | `/api/features/instances/{id}/users` |
|
||||
| Invitations | CRUD `/api/invitations/` |
|
||||
| Mandate Roles | `/api/rbac/roles` |
|
||||
| Role Permissions | `/api/rbac/rules/by-role/{roleId}` |
|
||||
| User Access Overview | `/api/admin/user-access-overview/`* |
|
||||
| Billing Admin | `/api/billing/admin/`* |
|
||||
| Automation Events | `/api/admin/automation-events` |
|
||||
| Logs | `/api/admin/logs` |
|
||||
| Mandate Wizard | Kombination mehrerer Endpoints |
|
||||
| Invitation Wizard | Kombination mehrerer Endpoints |
|
||||
|
||||
|
||||
### Phase 10-20: Feature-Module (je 3-7 Tage pro Feature)
|
||||
|
||||
Jedes Feature folgt demselben Pattern:
|
||||
|
||||
1. **API-Modul** erstellen (alle Endpunkte des Features)
|
||||
2. **ViewModels** fuer jede View
|
||||
3. **SwiftUI Views** fuer jede registrierte View
|
||||
4. **Feature-spezifische Komponenten** wo noetig
|
||||
|
||||
---
|
||||
|
||||
#### Phase 10: Trustee (5-7 Tage)
|
||||
|
||||
Views: Dashboard, Documents, Positions, Instance-Roles, Expense-Import, Scan-Upload, Accounting Settings
|
||||
|
||||
API-Basis: `/api/trustee/{instanceId}/`
|
||||
|
||||
- Organisations, Roles, Access, Contracts, Documents, Positions CRUD
|
||||
- Accounting: Connectors, Config, Sync
|
||||
- Document Upload mit base64-Konvertierung
|
||||
- Options-Endpoints fuer Dropdowns
|
||||
|
||||
Besonderheiten:
|
||||
|
||||
- Viele verschachtelte CRUD-Entitaeten (Organisation > Contract > Document > Position)
|
||||
- Scan-Upload: iOS-Kamera-Integration + VisionKit (OCR)
|
||||
|
||||
#### Phase 11: Workspace (5-7 Tage)
|
||||
|
||||
Views: Dashboard (Chat-Stream), Settings
|
||||
|
||||
API-Basis: `/api/workspace/{instanceId}/`
|
||||
|
||||
- SSE-Streaming fuer Chat (`POST .../start/stream`)
|
||||
- Workflows, Messages, Files, Datasources CRUD
|
||||
- Voice: Transcribe, Synthesize, Settings
|
||||
- File Browser mit Ordnerstruktur
|
||||
|
||||
Besonderheiten:
|
||||
|
||||
- **Zentrales SSE-Streaming** -- das Keep-Alive-Pattern aus dem Web (`WorkspaceKeepAlive`) muss in Swift via Task/Actor geloest werden
|
||||
- Voice: AVFoundation fuer Audio-Aufnahme, URLSession fuer Upload
|
||||
|
||||
#### Phase 12: Chatbot (3-5 Tage)
|
||||
|
||||
Views: Conversations, Settings
|
||||
|
||||
API-Basis: `/api/chatbot/{instanceId}/`
|
||||
|
||||
- `POST .../start/stream` -- SSE-Streaming via fetch (nicht Axios!)
|
||||
- Threads: List, Get, Delete
|
||||
- Stop Workflow
|
||||
|
||||
Besonderheiten:
|
||||
|
||||
- Streaming-Chat mit File-Attachments
|
||||
- Analog zu `chatbotApi.startChatbotStreamApi` -- Custom SSE via POST
|
||||
|
||||
#### Phase 13: Teamsbot (4-6 Tage)
|
||||
|
||||
Views: Dashboard, Sessions, Settings
|
||||
|
||||
API-Basis: `/api/teamsbot/{instanceId}/`
|
||||
|
||||
- Sessions CRUD + Stream (EventSource/SSE)
|
||||
- Config, System Bots, User Account
|
||||
- Voice Test
|
||||
- MFA fuer Sessions
|
||||
- WebSocket fuer Bot-Kommunikation (`/bot/ws/{sessionId}`)
|
||||
|
||||
Besonderheiten:
|
||||
|
||||
- **WebSocket** fuer Live-Bot-Interaction
|
||||
- SSE via EventSource fuer Session-Stream
|
||||
- Screenshot-Anzeige
|
||||
|
||||
#### Phase 14: CommCoach (4-6 Tage)
|
||||
|
||||
Views: Dashboard, Coaching, Dossier, Settings
|
||||
|
||||
API-Basis: `/api/commcoach/{instanceId}/`
|
||||
|
||||
- Contexts CRUD + Archive/Activate
|
||||
- Sessions: Start, Message-Stream, Audio-Stream, Complete, Cancel
|
||||
- Tasks CRUD + Status
|
||||
- Personas CRUD, Documents, Badges, Score History
|
||||
- Voice: Languages, Voices, TTS
|
||||
- Export (Dossier, Session)
|
||||
|
||||
Besonderheiten:
|
||||
|
||||
- **Audio-Streaming**: Mikrofon-Aufnahme -> POST Audio-Stream
|
||||
- SSE fuer Session-Nachrichten
|
||||
- Score/Badge-Visualisierung
|
||||
|
||||
#### Phase 15: ChatPlayground (3-5 Tage)
|
||||
|
||||
Views: Playground, Workflows
|
||||
|
||||
API-Basis: `/api/chatplayground/{instanceId}/`
|
||||
|
||||
- Start/Stop Workflow (mit SSE-Stream)
|
||||
- Workflows CRUD + Status/Logs/Messages
|
||||
- Attributes, Actions
|
||||
|
||||
#### Phase 16: Automation (3-5 Tage)
|
||||
|
||||
Views: Definitions, Templates, Logs
|
||||
|
||||
API-Basis: `/api/automations/`
|
||||
|
||||
- Automations CRUD + Execute + Duplicate
|
||||
- Templates CRUD
|
||||
- Workflow-Management (gleiche API wie ChatPlayground, anderer Base-Path)
|
||||
|
||||
#### Phase 17: CodeEditor (3-5 Tage)
|
||||
|
||||
Views: Editor, Workflows
|
||||
|
||||
API-Basis: `/api/codeeditor/{instanceId}/`
|
||||
|
||||
- Start/Stop/Apply (mit SSE-Stream)
|
||||
- ChatData, Workflows, Files, File Content
|
||||
|
||||
Besonderheiten:
|
||||
|
||||
- Code-Darstellung: Syntax-Highlighting (z.B. via `Highlightr` SPM Package oder custom)
|
||||
- Diff-Ansicht fuer Code-Apply
|
||||
|
||||
#### Phase 18: RealEstate / PEK (5-7 Tage)
|
||||
|
||||
Views: Dashboard (Map), Instance-Roles
|
||||
|
||||
API-Basis: `/api/realestate/{instanceId}/`
|
||||
|
||||
- Projects + Parcels CRUD
|
||||
- Parcel Search, WFS, Selection Summary, Adjacent Parcels
|
||||
- Address Autocomplete
|
||||
- BZO Information, Parcel Documents
|
||||
- Gemeinden
|
||||
|
||||
Besonderheiten:
|
||||
|
||||
- **MapKit** Integration: Parcel-Visualisierung auf Karte
|
||||
- Address-Autocomplete: MKLocalSearchCompleter oder Backend-API
|
||||
- Komplexe Karteninteraktion (Parcel-Selektion, Adjacent Parcels)
|
||||
|
||||
#### Phase 19: Neutralization (2-3 Tage)
|
||||
|
||||
Views: Dashboard/Playground (gleiche View)
|
||||
|
||||
API-Basis: `/api/neutralization/`
|
||||
|
||||
- Config GET/POST
|
||||
- Neutralize File/Text, Resolve Text
|
||||
- Process SharePoint, Batch Process
|
||||
- Stats, Attributes
|
||||
|
||||
#### Phase 20: Billing View-Erweiterung (1-2 Tage)
|
||||
|
||||
Admin-Billing-Views falls in Phase 9 nicht vollstaendig abgedeckt:
|
||||
|
||||
- Checkout (Stripe -- SFSafariViewController fuer Redirect)
|
||||
- Mandate/User Balances und Transaktionen
|
||||
|
||||
---
|
||||
|
||||
## API-Header-Konvention (fuer alle Requests)
|
||||
|
||||
Jeder API-Request muss folgende Header senden (analog `[src/api.ts](frontend_nyla/src/api.ts)`):
|
||||
|
||||
|
||||
| Header | Quelle | Wann |
|
||||
| -------------------------------- | ------------------ | --------------------- |
|
||||
| `Authorization: Bearer {token}` | Keychain | Wenn JWT vorhanden |
|
||||
| `X-Mandate-Id: {mandateId}` | Navigation Context | Bei Feature-Seiten |
|
||||
| `X-Instance-Id: {instanceId}` | Navigation Context | Bei Feature-Seiten |
|
||||
| `X-CSRF-Token: {token}` | CSRFManager | POST/PUT/PATCH/DELETE |
|
||||
| `Content-Type: application/json` | Standard | JSON Bodies |
|
||||
| Cookie (httpOnly) | URLSession | Automatisch |
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Gesamtaufwand-Schaetzung
|
||||
|
||||
|
||||
| Phase | Tage (geschaetzt) |
|
||||
| ------------------------------- | ----------------- |
|
||||
| Phase 0: Setup | 1-2 |
|
||||
| Phase 1: Networking | 3-5 |
|
||||
| Phase 2: Authentication | 3-5 |
|
||||
| Phase 3: Domain Models + Store | 2-3 |
|
||||
| Phase 4: App Shell + Navigation | 4-6 |
|
||||
| Phase 5: i18n + Theme | 2-3 |
|
||||
| Phase 6: Core Pages | 5-7 |
|
||||
| Phase 7: Shared UI Components | 5-8 |
|
||||
| Phase 8: Push Notifications | 2-3 |
|
||||
| Phase 9: Admin | 5-7 |
|
||||
| Phase 10: Trustee | 5-7 |
|
||||
| Phase 11: Workspace | 5-7 |
|
||||
| Phase 12: Chatbot | 3-5 |
|
||||
| Phase 13: Teamsbot | 4-6 |
|
||||
| Phase 14: CommCoach | 4-6 |
|
||||
| Phase 15: ChatPlayground | 3-5 |
|
||||
| Phase 16: Automation | 3-5 |
|
||||
| Phase 17: CodeEditor | 3-5 |
|
||||
| Phase 18: RealEstate | 5-7 |
|
||||
| Phase 19: Neutralization | 2-3 |
|
||||
| Phase 20: Billing Erweit. | 1-2 |
|
||||
| **Gesamt** | **~70-105 Tage** |
|
||||
|
||||
|
||||
Hinweis: Dies ist eine Einzelperson-Schaetzung. Mit Team (z.B. 2-3 Devs) kann parallelisiert werden, besonders ab Phase 10+ (Features sind unabhaengig voneinander).
|
||||
|
||||
---
|
||||
|
||||
## Offene Punkte / Risiken
|
||||
|
||||
1. **Backend-Anpassungen**: Das Backend setzt teilweise httpOnly Cookies nach Browser-Redirect (MSAL, Google). Fuer eine native App muss das Backend ggf. alternative Token-Flows unterstuetzen (z.B. Device Code Flow oder Token-Exchange).
|
||||
2. **Push Notifications**: Das Backend hat aktuell kein APNs-Token-Management. Ein neuer Endpoint `/api/notifications/register-device` muss im Gateway implementiert werden.
|
||||
3. **SSE ueber POST**: Die Web-App nutzt `fetch` POST + ReadableStream fuer SSE (nicht standard EventSource GET). In Swift muss dies mit `URLSession.bytes(for:)` nachgebaut werden.
|
||||
4. **Stripe Checkout**: Im Web oeffnet sich ein Stripe-Redirect. In iOS: SFSafariViewController oder Stripe iOS SDK.
|
||||
5. **SharePoint Integration**: Einige Features nutzen SharePoint-Folder-Picker. In iOS muss eine alternative UI gebaut werden (Liste statt Filepicker).
|
||||
6. **WebSocket Auth**: Der Web-Client nutzt Cookies fuer WebSocket-Auth. iOS `URLSessionWebSocketTask` unterstuetzt Cookies via URLSession Configuration.
|
||||
|
||||
58
.forgejo/workflows/int_porta-int-platform-core.yml
Normal file
58
.forgejo/workflows/int_porta-int-platform-core.yml
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
name: Deploy Plattform-Core (Int)
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- int
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Tests auf Infomaniak VM
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
echo "StrictHostKeyChecking=no" >> ~/.ssh/config
|
||||
echo "UserKnownHostsFile=/dev/null" >> ~/.ssh/config
|
||||
ssh -i ~/.ssh/deploy_key ubuntu@api-int.poweron.swiss "
|
||||
set -e
|
||||
cd /srv/gateway/current
|
||||
git remote set-url origin ssh://git@git.poweron.swiss:2222/PowerOn/platform-core.git
|
||||
git fetch origin int
|
||||
git reset --hard origin/int
|
||||
test -f env-int.env
|
||||
cp env-int.env .env
|
||||
rm -f env-*.env
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt --no-cache-dir
|
||||
python -m pytest tests/ --ignore=tests/demo
|
||||
"
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
steps:
|
||||
- name: Deploy to Infomaniak VM
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
echo "StrictHostKeyChecking=no" >> ~/.ssh/config
|
||||
echo "UserKnownHostsFile=/dev/null" >> ~/.ssh/config
|
||||
ssh -i ~/.ssh/deploy_key ubuntu@api-int.poweron.swiss "
|
||||
set -e
|
||||
cd /srv/gateway/current
|
||||
git remote set-url origin ssh://git@git.poweron.swiss:2222/PowerOn/platform-core.git
|
||||
git fetch origin int
|
||||
git reset --hard origin/int
|
||||
test -f env-int.env
|
||||
cp env-int.env .env
|
||||
rm -f env-*.env
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt --no-cache-dir
|
||||
sudo systemctl restart gateway
|
||||
"
|
||||
|
|
@ -12,7 +12,7 @@ jobs:
|
|||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
|
||||
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
echo "StrictHostKeyChecking=no" >> ~/.ssh/config
|
||||
echo "UserKnownHostsFile=/dev/null" >> ~/.ssh/config
|
||||
|
|
@ -22,9 +22,9 @@ jobs:
|
|||
git remote set-url origin ssh://git@git.poweron.swiss:2222/PowerOn/platform-core.git
|
||||
git fetch origin main
|
||||
git reset --hard origin/main
|
||||
test -f env-gateway-prod-forgejo.env
|
||||
cp env-gateway-prod-forgejo.env .env
|
||||
rm -f env-gateway-dev.env env-gateway-int.env env-gateway-prod.env env-gateway-prod-forgejo.env
|
||||
test -f env-prod.env
|
||||
cp env-prod.env .env
|
||||
rm -f env-*.env
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt --no-cache-dir
|
||||
python -m pytest tests/ --ignore=tests/demo
|
||||
|
|
@ -39,7 +39,7 @@ jobs:
|
|||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
|
||||
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
echo "StrictHostKeyChecking=no" >> ~/.ssh/config
|
||||
echo "UserKnownHostsFile=/dev/null" >> ~/.ssh/config
|
||||
|
|
@ -49,9 +49,9 @@ jobs:
|
|||
git remote set-url origin ssh://git@git.poweron.swiss:2222/PowerOn/platform-core.git
|
||||
git fetch origin main
|
||||
git reset --hard origin/main
|
||||
test -f env-gateway-prod-forgejo.env
|
||||
cp env-gateway-prod-forgejo.env .env
|
||||
rm -f env-gateway-dev.env env-gateway-int.env env-gateway-prod.env env-gateway-prod-forgejo.env
|
||||
test -f env-prod.env
|
||||
cp env-prod.env .env
|
||||
rm -f env-*.env
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt --no-cache-dir
|
||||
sudo systemctl restart gateway
|
||||
74
.github/scripts/load_config_key_from_azure.py
vendored
74
.github/scripts/load_config_key_from_azure.py
vendored
|
|
@ -1,74 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
# Copyright (c) 2026 Patrick Motsch
|
||||
"""Load CONFIG_KEY from Azure App Service for CI pytest (Kudu API + publish profile)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
|
||||
def main() -> None:
|
||||
profile_xml = os.environ.get("AZURE_PUBLISH_PROFILE")
|
||||
setting_name = os.environ.get("SETTING_NAME", "CONFIG_KEY")
|
||||
if not profile_xml:
|
||||
print("::error::AZURE_PUBLISH_PROFILE is not set", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
root = ET.fromstring(profile_xml)
|
||||
pub = None
|
||||
for element in root.findall(".//publishProfile"):
|
||||
url = (element.get("publishUrl") or "").lower()
|
||||
if "scm" in url:
|
||||
pub = element
|
||||
break
|
||||
if pub is None:
|
||||
pub = root.find(".//publishProfile")
|
||||
if pub is None:
|
||||
print("::error::No publishProfile in publish profile XML", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
host = (pub.get("publishUrl") or "").split(":")[0]
|
||||
user = pub.get("userName")
|
||||
pwd = pub.get("userPWD")
|
||||
if not (host and user and pwd):
|
||||
print("::error::Could not parse SCM credentials from publish profile", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
api = f"https://{host}/api/settings"
|
||||
req = urllib.request.Request(api)
|
||||
cred = base64.b64encode(f"{user}:{pwd}".encode()).decode()
|
||||
req.add_header("Authorization", f"Basic {cred}")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
settings = json.load(resp)
|
||||
except Exception as exc:
|
||||
print(f"::error::Kudu settings request failed: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if not isinstance(settings, dict) or setting_name not in settings:
|
||||
preview = sorted(settings.keys())[:25] if isinstance(settings, dict) else []
|
||||
print(
|
||||
f"::error::{setting_name} not in Azure App Service application settings "
|
||||
f"(sample keys: {preview})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
value = settings[setting_name]
|
||||
if not value or not str(value).strip():
|
||||
print(f"::error::{setting_name} is empty in Azure App Service", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
github_env = os.environ.get("GITHUB_ENV")
|
||||
if github_env:
|
||||
with open(github_env, "a", encoding="utf-8") as handle:
|
||||
handle.write(f"{setting_name}<<EOF\n{value}\nEOF\n")
|
||||
print(f"Loaded {setting_name} from Azure App Service ({len(value)} characters)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
194
.github/workflows/deploy-gcp.yml
vendored
194
.github/workflows/deploy-gcp.yml
vendored
|
|
@ -1,194 +0,0 @@
|
|||
# GitHub Actions workflow for deploying Gateway to Google Cloud Run
|
||||
# Documentation: https://cloud.google.com/run/docs/deploying
|
||||
#
|
||||
# Required GitHub Secrets:
|
||||
# - GCP_PROJECT_ID: Your Google Cloud Project ID
|
||||
# - GCP_SA_KEY: Service Account JSON key with Cloud Run Admin and Cloud Build Editor roles
|
||||
# - GCP_SERVICE_ACCOUNT_EMAIL: Email of the service account to run Cloud Run service as
|
||||
#
|
||||
# Required Google Cloud Setup:
|
||||
# 1. Create a service account with Cloud Run Admin and Cloud Build Editor roles
|
||||
# 2. Create secret "CONFIG_KEY" in Secret Manager with your master key
|
||||
# 3. Grant the service account access to Secret Manager secrets
|
||||
# 4. Create Cloud SQL instance (if not exists)
|
||||
# 5. Create env-gateway-prod.env and env-gateway-int.env files with your configuration
|
||||
#
|
||||
# Environment Selection:
|
||||
# - Push to 'main' branch → uses env-gateway-prod.env (production)
|
||||
# - Push to 'int' branch → uses env-gateway-int.env (integration)
|
||||
# - Manual dispatch → select environment (prod/int) to use corresponding env file
|
||||
|
||||
name: Deploy Gateway to Google Cloud Run
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- int
|
||||
paths:
|
||||
- 'gateway/**'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
description: 'Environment to deploy to'
|
||||
required: true
|
||||
default: 'prod'
|
||||
type: choice
|
||||
options:
|
||||
- prod
|
||||
- int
|
||||
|
||||
# Cancel in-progress runs when a new run is triggered (saves logs/storage)
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
PROJECT_ID: ${{ secrets.GCP_PROJECT_ID }}
|
||||
REGION: europe-west6 # Zurich region
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Determine environment
|
||||
id: env
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
|
||||
ENV_TYPE="${{ github.event.inputs.environment }}"
|
||||
elif [ "${{ github.ref }}" == "refs/heads/int" ]; then
|
||||
ENV_TYPE="int"
|
||||
else
|
||||
ENV_TYPE="prod"
|
||||
fi
|
||||
echo "env_file=env-gateway-${ENV_TYPE}.env" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Set environment file
|
||||
run: |
|
||||
ENV_FILE="${{ steps.env.outputs.env_file }}"
|
||||
test -f "$ENV_FILE"
|
||||
cp "$ENV_FILE" .env
|
||||
rm -f env-gateway-dev.env env-gateway-int.env env-gateway-prod.env env-gateway-prod-forgejo.env
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
if [ -f requirements.lock ]; then
|
||||
pip install -r requirements.lock --no-cache-dir
|
||||
else
|
||||
pip install -r requirements.txt --no-cache-dir
|
||||
fi
|
||||
|
||||
- name: Run tests
|
||||
run: python -m pytest tests/ --ignore=tests/demo
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # Required for Workload Identity Federation
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Determine environment
|
||||
id: env
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
|
||||
ENV_TYPE="${{ github.event.inputs.environment }}"
|
||||
elif [ "${{ github.ref }}" == "refs/heads/int" ]; then
|
||||
ENV_TYPE="int"
|
||||
else
|
||||
ENV_TYPE="prod"
|
||||
fi
|
||||
echo "env_type=$ENV_TYPE" >> $GITHUB_OUTPUT
|
||||
echo "service_name=gateway-$ENV_TYPE" >> $GITHUB_OUTPUT
|
||||
echo "env_file=env-gateway-${ENV_TYPE}.env" >> $GITHUB_OUTPUT
|
||||
echo "Determined environment: $ENV_TYPE"
|
||||
echo "Service name: gateway-$ENV_TYPE"
|
||||
echo "Env file: env-gateway-${ENV_TYPE}.env"
|
||||
|
||||
- name: Authenticate to Google Cloud
|
||||
uses: google-github-actions/auth@v2
|
||||
with:
|
||||
credentials_json: ${{ secrets.GCP_SA_KEY }}
|
||||
# Alternative: Use Workload Identity Federation (more secure)
|
||||
# workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
|
||||
# service_account: ${{ secrets.WIF_SERVICE_ACCOUNT }}
|
||||
|
||||
- name: Set up Cloud SDK
|
||||
uses: google-github-actions/setup-gcloud@v2
|
||||
|
||||
- name: Configure Docker for GCR
|
||||
run: |
|
||||
gcloud auth configure-docker
|
||||
|
||||
- name: Set environment file
|
||||
run: |
|
||||
cd gateway
|
||||
ENV_FILE="${{ steps.env.outputs.env_file }}"
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
echo "Using $ENV_FILE"
|
||||
cp "$ENV_FILE" .env
|
||||
else
|
||||
echo "Warning: $ENV_FILE not found, using env-gateway-prod.env as fallback"
|
||||
cp env-gateway-prod.env .env
|
||||
fi
|
||||
# Clean up other env files (optional, for security)
|
||||
rm -f env-*.env
|
||||
|
||||
- name: Build and push container image
|
||||
working-directory: ./gateway
|
||||
run: |
|
||||
# Build container image using Cloud Build
|
||||
# If Dockerfile exists, it will be used; otherwise Cloud Buildpacks will be used
|
||||
SERVICE_NAME="${{ steps.env.outputs.service_name }}"
|
||||
gcloud builds submit \
|
||||
--tag gcr.io/${{ env.PROJECT_ID }}/$SERVICE_NAME:${{ github.sha }} \
|
||||
--tag gcr.io/${{ env.PROJECT_ID }}/$SERVICE_NAME:latest \
|
||||
--project ${{ env.PROJECT_ID }}
|
||||
|
||||
- name: Deploy to Cloud Run
|
||||
run: |
|
||||
SERVICE_NAME="${{ steps.env.outputs.service_name }}"
|
||||
ENV_TYPE="${{ steps.env.outputs.env_type }}"
|
||||
gcloud run deploy $SERVICE_NAME \
|
||||
--image gcr.io/${{ env.PROJECT_ID }}/$SERVICE_NAME:${{ github.sha }} \
|
||||
--region ${{ env.REGION }} \
|
||||
--platform managed \
|
||||
--allow-unauthenticated \
|
||||
--project ${{ env.PROJECT_ID }} \
|
||||
--set-env-vars "APP_ENV_TYPE=$ENV_TYPE" \
|
||||
--set-secrets "CONFIG_KEY=CONFIG_KEY:latest" \
|
||||
--memory 2Gi \
|
||||
--cpu 2 \
|
||||
--timeout 300 \
|
||||
--max-instances 10 \
|
||||
--min-instances 1 \
|
||||
--port 8000 \
|
||||
--service-account ${{ secrets.GCP_SERVICE_ACCOUNT_EMAIL }}
|
||||
|
||||
- name: Get service URL
|
||||
id: service-url
|
||||
run: |
|
||||
SERVICE_NAME="${{ steps.env.outputs.service_name }}"
|
||||
SERVICE_URL=$(gcloud run services describe $SERVICE_NAME \
|
||||
--region ${{ env.REGION }} \
|
||||
--project ${{ env.PROJECT_ID }} \
|
||||
--format 'value(status.url)')
|
||||
echo "url=$SERVICE_URL" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Output deployment URL
|
||||
run: |
|
||||
echo "🚀 Deployment successful!"
|
||||
echo "Service URL: ${{ steps.service-url.outputs.url }}"
|
||||
121
.github/workflows/int_gateway-int.yml
vendored
121
.github/workflows/int_gateway-int.yml
vendored
|
|
@ -1,121 +0,0 @@
|
|||
# Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy
|
||||
# More GitHub Actions for Azure: https://github.com/Azure/actions
|
||||
# More info on Python, GitHub Actions, and Azure App Service: https://aka.ms/python-webapps-actions
|
||||
|
||||
name: Build and deploy Python app to Azure Web App - gateway-int
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- int
|
||||
workflow_dispatch:
|
||||
|
||||
# Cancel in-progress runs when a new run is triggered (saves logs/storage)
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
environment: Production
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Python version
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Set environment file
|
||||
run: |
|
||||
test -f env-gateway-int.env
|
||||
cp env-gateway-int.env .env
|
||||
rm -f env-gateway-dev.env env-gateway-int.env env-gateway-prod.env env-gateway-prod-forgejo.env
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
if [ -f requirements.lock ]; then
|
||||
pip install -r requirements.lock --no-cache-dir
|
||||
else
|
||||
pip install -r requirements.txt --no-cache-dir
|
||||
fi
|
||||
|
||||
- name: Load CONFIG_KEY from Azure App Service
|
||||
env:
|
||||
AZURE_PUBLISH_PROFILE: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_GATEWAY_INT }}
|
||||
run: python .github/scripts/load_config_key_from_azure.py
|
||||
|
||||
- name: Run tests
|
||||
run: python -m pytest tests/ --ignore=tests/demo
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
permissions:
|
||||
contents: read #This is required for actions/checkout
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Python version
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Create and start virtual environment
|
||||
run: |
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
if [ -f requirements.lock ]; then
|
||||
pip install -r requirements.lock --no-cache-dir
|
||||
else
|
||||
pip install -r requirements.txt --no-cache-dir
|
||||
fi
|
||||
|
||||
- name: Zip artifact for deployment
|
||||
run: zip release.zip ./* -r
|
||||
|
||||
- name: Upload artifact for deployment jobs
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: python-app
|
||||
path: |
|
||||
release.zip
|
||||
!venv/
|
||||
retention-days: 5
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
environment:
|
||||
name: 'Production'
|
||||
url: ${{ steps.deploy-to-webapp.outputs.webapp-url }}
|
||||
|
||||
steps:
|
||||
- name: Download artifact from build job
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: python-app
|
||||
|
||||
- name: Unzip artifact for deployment
|
||||
run: unzip release.zip
|
||||
|
||||
- name: Set productive environment
|
||||
run: cp env-gateway-int.env .env
|
||||
|
||||
- name: Clean up environment files
|
||||
run: rm -f env-*.env
|
||||
|
||||
- name: 'Deploy to Azure Web App'
|
||||
uses: azure/webapps-deploy@v3
|
||||
id: deploy-to-webapp
|
||||
with:
|
||||
app-name: 'gateway-int'
|
||||
slot-name: 'Production'
|
||||
publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_GATEWAY_INT }}
|
||||
121
.github/workflows/main_gateway-prod.yml
vendored
121
.github/workflows/main_gateway-prod.yml
vendored
|
|
@ -1,121 +0,0 @@
|
|||
# Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy
|
||||
# More GitHub Actions for Azure: https://github.com/Azure/actions
|
||||
# More info on Python, GitHub Actions, and Azure App Service: https://aka.ms/python-webapps-actions
|
||||
|
||||
name: Build and deploy Python app to Azure Web App - gateway-prod
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
# Cancel in-progress runs when a new run is triggered (saves logs/storage)
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
environment: Production
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Python version
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Set environment file
|
||||
run: |
|
||||
test -f env-gateway-prod.env
|
||||
cp env-gateway-prod.env .env
|
||||
rm -f env-gateway-dev.env env-gateway-int.env env-gateway-prod.env env-gateway-prod-forgejo.env
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
if [ -f requirements.lock ]; then
|
||||
pip install -r requirements.lock --no-cache-dir
|
||||
else
|
||||
pip install -r requirements.txt --no-cache-dir
|
||||
fi
|
||||
|
||||
- name: Load CONFIG_KEY from Azure App Service
|
||||
env:
|
||||
AZURE_PUBLISH_PROFILE: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_GATEWAY_PROD }}
|
||||
run: python .github/scripts/load_config_key_from_azure.py
|
||||
|
||||
- name: Run tests
|
||||
run: python -m pytest tests/ --ignore=tests/demo
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
permissions:
|
||||
contents: read #This is required for actions/checkout
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Python version
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Create and start virtual environment
|
||||
run: |
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
if [ -f requirements.lock ]; then
|
||||
pip install -r requirements.lock --no-cache-dir
|
||||
else
|
||||
pip install -r requirements.txt --no-cache-dir
|
||||
fi
|
||||
|
||||
- name: Zip artifact for deployment
|
||||
run: zip release.zip ./* -r
|
||||
|
||||
- name: Upload artifact for deployment jobs
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: python-app
|
||||
path: |
|
||||
release.zip
|
||||
!venv/
|
||||
retention-days: 5
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
environment:
|
||||
name: 'Production'
|
||||
url: ${{ steps.deploy-to-webapp.outputs.webapp-url }}
|
||||
|
||||
steps:
|
||||
- name: Download artifact from build job
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: python-app
|
||||
|
||||
- name: Unzip artifact for deployment
|
||||
run: unzip release.zip
|
||||
|
||||
- name: Set productive environment
|
||||
run: cp env-gateway-prod.env .env
|
||||
|
||||
- name: Clean up environment files
|
||||
run: rm -f env-*.env
|
||||
|
||||
- name: 'Deploy to Azure Web App'
|
||||
uses: azure/webapps-deploy@v3
|
||||
id: deploy-to-webapp
|
||||
with:
|
||||
app-name: 'gateway-prod'
|
||||
slot-name: 'Production'
|
||||
publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_GATEWAY_PROD }}
|
||||
70
.github/workflows/update-requirements-lock.yml
vendored
70
.github/workflows/update-requirements-lock.yml
vendored
|
|
@ -1,70 +0,0 @@
|
|||
# Generates requirements.lock from requirements.txt using Python 3.11 (same as build).
|
||||
# Run manually (workflow_dispatch) or on changes to requirements.txt.
|
||||
# After running, commit the generated requirements.lock so builds use it for fast installs.
|
||||
|
||||
name: Update requirements.lock
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- int
|
||||
paths:
|
||||
- 'requirements.txt'
|
||||
|
||||
# Cancel in-progress runs when a new run is triggered (saves logs/storage)
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
update-lock:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # push requirements.lock
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install pip-tools
|
||||
run: python -m pip install --upgrade "pip>=24,<26" pip-tools
|
||||
|
||||
- name: Generate requirements.lock
|
||||
run: pip-compile requirements.txt -o requirements.lock
|
||||
|
||||
- name: Set environment file
|
||||
run: |
|
||||
if [ "${{ github.ref }}" == "refs/heads/int" ]; then
|
||||
ENV_FILE="env-gateway-int.env"
|
||||
else
|
||||
ENV_FILE="env-gateway-prod.env"
|
||||
fi
|
||||
test -f "$ENV_FILE"
|
||||
cp "$ENV_FILE" .env
|
||||
|
||||
- name: Install dependencies from generated lock
|
||||
run: pip install -r requirements.lock --no-cache-dir
|
||||
|
||||
- name: Run tests
|
||||
run: python -m pytest tests/ --ignore=tests/demo
|
||||
|
||||
- name: Clean up .env before commit
|
||||
run: rm -f .env
|
||||
|
||||
- name: Commit and push requirements.lock
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add requirements.lock
|
||||
if git diff --staged --quiet; then
|
||||
echo "No changes to requirements.lock"
|
||||
else
|
||||
git commit -m "chore: update requirements.lock"
|
||||
git push
|
||||
fi
|
||||
|
|
@ -46,5 +46,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
|||
CMD python -c "import requests; requests.get('http://localhost:8000/api/admin/health', timeout=5)" || exit 1
|
||||
|
||||
# Run the application
|
||||
# Cloud Run will set PORT env var, uvicorn reads it automatically
|
||||
CMD exec uvicorn app:app --host 0.0.0.0 --port ${PORT:-8000} --workers 1
|
||||
CMD exec uvicorn app:app --host 0.0.0.0 --port ${PORT:-8000} --workers 1 --timeout-graceful-shutdown 5
|
||||
|
|
|
|||
59
app.py
59
app.py
|
|
@ -426,32 +426,36 @@ async def lifespan(app: FastAPI):
|
|||
|
||||
yield
|
||||
|
||||
# --- Stop Managers ---
|
||||
eventManager.stop()
|
||||
|
||||
# --- Stop Feature Containers (Plug&Play) ---
|
||||
# --- Shutdown sequence (protected against CancelledError) ---
|
||||
try:
|
||||
mainModules = loadFeatureMainModules()
|
||||
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}")
|
||||
# 1. Stop scheduler first (removes all pending cron/interval jobs)
|
||||
eventManager.stop()
|
||||
|
||||
# --- Close all PostgreSQL connection pools ---
|
||||
# Must run LAST: feature `onStop` hooks may still issue DB calls during
|
||||
# shutdown. Once we tear down the pools, no more borrows are possible.
|
||||
try:
|
||||
from modules.connectors.connectorDbPostgre import closeAllPools
|
||||
closeAllPools()
|
||||
except Exception as e:
|
||||
logger.warning(f"Closing DB connection pools failed: {e}")
|
||||
# 2. Stop Feature Containers (Plug&Play)
|
||||
try:
|
||||
mainModules = loadFeatureMainModules()
|
||||
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}")
|
||||
|
||||
logger.info("Application has been shut down")
|
||||
# 3. Close all PostgreSQL connection pools (LAST -- features may still
|
||||
# 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
|
||||
|
|
@ -719,4 +723,11 @@ app.include_router(automationWorkspaceRouter)
|
|||
from modules.system.registry import loadFeatureRouters
|
||||
|
||||
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,9 +73,6 @@ Service_MSFT_TENANT_ID = common
|
|||
# Google Cloud Speech Services configuration
|
||||
Connector_GoogleSpeech_API_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETk5FWWM3Q0JKMzhIYTlyMkhuNjA4NlF4dk82U2NScHhTVGY3UG83NkhfX3RrcWVtWWcyLXRjU1dTT21zWEl6YWRMMUFndXpsUnJOeHh3QThsNDZKRXROTzdXRUdsT0JZajZJNVlfb0gtMXkwWm9DOERPVnpjU0pyUEZfOGJsUnprT3ltMVVhalUyUm9hMUFtZEtHUnJqOGZ4dEZjZm5SWVVTckVCWnY1UkdVSHVmUlgwbnAyc0xDQW84R3ViSko5OHVCVWZRUVNiaG1pVFB6X3EwS0FPd2dUYjhiSmRjcXh2WEZiXzI4SFZqT21tbDduUWRyVWdFZXpmcVM5ZDR0VWtzZnF5UER6cGwwS2JlLV9CSTZ0Z0IyQ1h0YW9TcmhRTXZEckp4bWhmTkt6UTNYMk4zVkpnbUJmaDIxZnoyR2dWTEYwTUFEV0w2eUdUUGpoZk9XRkt4RVF1Z1NPdUpBeTcyWV9PY1Ffd2s0ZEdVekxGekhoeEl4TmNqaXYtbUJuSVdycFducERWdWtZajZnX011Q2w4eE9VMTBqQ1ZxRmdScWhXY1E3WWhzX1JZcHhxam9FbDVPN3Q1MWtrMUZuTUg3LVFQVHp1T1hpQWNDMzEzekVJWk9ybl91YUVjSkFob1VaMi1ONEtuMnRSOEg1S3QybUMwbVZDejItajBLTjM2Zy1hNzZQMW5LLVVDVGdFWm5BZUxNeEFnUkZzU3dxV0lCUlc0LWo4b05GczVpOGZSV2ZxbFBwUml6OU5tYjdnTks3Y3hrVEZVTHlmc1NPdFh4WE5pWldEZklOQUxBbjBpMTlkX3FFQVJ6c2NSZGdzTThycE92VW82enZKamhiRGFnU25aZGlHZHhZd2lUUmhuTVptNjhoWVlJQkxIOEkzbzJNMjZCZFJyM25tdXBnQ2ZWaHV3b2p6UWJpdk9xUEhBc1dyTlNmeF9wbm5yYUhHV01UZnVXWDFlNzBkdXlWUWhvcmJpSmljbmE3LUpUZEg4VzRwZ2JVSjdYUm1sODViQXVxUzdGTmZFbVpiN2V1YW5XV3U4b2VRWmxldGVGVHZsSldoekhVLU9wZ2V0cGZIYkNqM2pXVGctQVAyUm4xTHhpd1VVLXFhcnVEV21Rby1hbTlqTl84TjVveHdYTExUVkhHQ0ltaTB2WXJnY1NQVE5PbWg3ejgySElYc1JSTlQ3NDlFUWR6STZVUjVqaXFRN200NF9LY1ljQ0R2UldlWUtKY1NQVnJ4QXRyYTBGSWVuenhyM0Z0cWtndTd1eG8xRzY5a2dNZ1hkQm5MV3BHVzA2N1QwUkd6WlRGYTZQOUhnVWQ2S0Y5U0s1dXFNVXh5Q2pLWVUxSUQ2MlR1ak52NmRIZ2hlYTk1SGZGWS1RV3hWVU9rR3d1Rk9MLS11REZXbzhqMHpsSm1HYW1jMUNLT29YOHZsRWNaLTVvOFpmT3l3MHVwaERTT0dNLWFjcGRYZ25qT2szTkVFUnRFR3JWYS1aNXFIRnMyalozTlQzNFF2NXJLVHVPVF9zdTF6ZjlkbzJ4RFc2ZENmNFFxZDZzTzhfMUl0bW96V0lPZkh1dXFYZlEteFBlSG84Si1FNS1TTi1OMkFnX2pOYW8xY3MxMVJnVC02MDUyaXZfMEVHWDQtVlRpcENmV0h3V0dCWEFRS2prQXdNRlQ5dnRFVHU0Q1dNTmh0SlBCaU55bFMydWM1TTFFLW96ODBnV3dNZHFZTWZhRURYSHlrdzF3RlRuWDBoQUhSOUJWemtRM3pxcDJFbGJoaTJ3ZktRTlJxbXltaHBoZXVJVDlxS3cxNWo2c0ZBV0NzaUstRWdsMW1xLXFkanZGYUFiU0tSLXFQa0tkcDFoMV9kak41ZjQ0R214UmtOR1ZBanRuemY3Mmw1SkZ5aDZodGIzT3N2aV85MW9kcld6c0g0ZDgtTWo3b3Y3VjJCRnR2U2tMVm9rUXNVRnVHbzZXVTZ6RmI2RkNmajBfMWVnODVFbnpkT0oyci15czJHU0p1cUowTGZJMzVnd3hIRjQyTVhKOGRkcFRKdVpyQ3Yzd01Jb1lSajFmV0paeEV0cjk1SmpmdWpDVFJMUmMtUFctOGhaTmlKQXNRVlVUNlhJemxudHZCR056SVlBb3NOTEYxRTRLaFlVd2d3TWtxVlB6ZEtQLTkxOGMyY3N0a2pYRFUweDBNaGhja2xSSklPOUZla1dKTWRNbG8tUGdSNEV5cW90OWlOZFlIUExBd3U2b2hyS1owbXVMM3p0Qm41cUtzWUxYNzB1N3JpUTNBSGdsT0NuamNTb1lIbXR4MG1sakNPVkxBUXRLVE1xX0YxWDhOcERIY1lTQVFqS01CaXZKNllFaXlIR0JsM1pKMmV1OUo3TGI1WkRaVnYxUTl1LTM0SU1qN1V1b0RCT0x0VHNLTmNLZnk1S0MxYnBBcm03WnVua0xqaEhGUzhOU253ZkppRzdudXBSVlMxeFVOSWxtZ1o2RVBSQUhEUEFuQ1hxSVZMME4yWUtaU3VyRGo3RkUyRUNjT0pNcE1BdE1ZRzdXVl8ydUtXZjdMdHdEVW4teHUtTi1HSGliLUxud21TX0NtcGVkRFBHNkZ1WTlNczR4OUJfUVluc1BoV09oWS1scUdsNnB5d1U5M1huX3k4QzAyNldtb2hybktYN2xKZ1NTNWFsaWwzV3pCRVhkaGR5eTNlV1d6ZzFfaFZTT0E4UjRpQ3pKdEZxUlJ6UFZXM3laUndyWEk2NlBXLUpoajVhZzVwQXpWVzUtVjVNZFBwdWdQa3AxZC1KdGdqNnhibjN4dmFYb2cxcEVwc1g5R09zRUdINUZtOE5QRjVUU0dpZy1QVl9odnFtVDNuWFZLSURtMXlSMlhRNTBWSVFJbEdOOWpfVWV0SmdRWDdlUXZZWE8xRUxDN1I0aEN6MHYwNzM1cmpJS0ZpMnBYWkxfb3FsbEV1VnlqWGxqdVJ6SHlwSjAzRlMycTBaQ295NXNnZERpUnJQcjhrUUd3bkI4bDVzRmxQblhkaFJPTTdISnVUQmhET3BOMTM4bjVvUEc2VmZhb2lrR1FyTUl2RWNEeGg0U0dsNnV6eU5zOUxiNDY5SXBxR0hBS00wOTgyWTFnWkQyaEtLVUloT3ZxZGh0RWVGRmJzenFsaUtfZENQM0JzdkVVeTdXR3hUSmJST1NBMUI1NkVFWncwNW5JZVVLX1p1RXdqVnFfQWpvQ08yQjZhN1NkTkpTSnUxOVRXZXE0WFEtZWxhZW1NNXYtQ2sya0VGLURmS01lMkctNVY3c2ZhN0ZGRFgwWHlabTFkeS1hcUZ1dDZ3cnpPQ3hha2IzVE11M0pqbklmU0diczBqTFBNZC1QZGp6VzNTSnJVSjJoWkJUQjVORG4tYUJmMEJtSUNUdVpEaGt6OTM3TjFOdVhXUHItZjRtZ25nU3NhZC1sVTVXNTRDTmxZbnlfeHNsdkpuMXhUYnE1MnpVQ0ZOclRWM1M4eHdXTzRXbFRZZVQtTS1iRVdXVWZMSGotcWg3MUxUYTFnSEEtanBCRHlZRUNIdGdpUFhsYjdYUndCZnRITzhMZVJ1dHFoVlVNb0duVjlxd0U4OGRuQVV3MG90R0hiYW5MWkxWVklzbWFRNzBfSUNrdzc5bVdtTXg0dExEYnRCaDI3c1I4TWFwLXZKR0wxSjRZYjZIV3ZqZjNqTWhFT0RGSDVMc1A1UzY2bDBiMGFSUy1fNVRQRzRJWDVydUpqb1ZfSHNVbldVeUN2YlAxSW5WVDdxVzJ1WHpLeUdmb0xWMDNHN05oQzY3YnhvUUdhS2xaOHNidkVvbTZtSHFlblhOYmwyR3NQdVJDRUdxREhWdF9ZcXhwUWxHc2hyLW5vUGhIUVhJNUNhY0hFU0ptVnI0TFVhZDE1TFBBUEstSkRoZWJ5MHJhUmZrR1ZrRlFtRGpxS1pOMmFMQjBsdjluY3FiYUU4eGJVVXlZVEpuNWdHVVhJMGtwaTdZR2NDbXd2eHpOQ09SeTV6N1BaVUpsR1pQVDBZcElJUUt6VnVpQmxSYnE4Y1BCWV9IRWdVV0p3enBGVHItdnBGN3NyNWFBWmkySnByWThsbDliSlExQmp3LVlBaDIyZXp6UnR6cU9rTzJmTDBlSVpON0tiWllMdm1oME1zTFl2S2ZYYllhQlY2VHNZRGtHUDY4U1lIVExLZTU4VzZxSTZrZHl1ZTBDc0g4SjI4WGYyZHV1bm9wQ3R2Z09ld1ZmUkN5alJGeHZKSHl1bWhQVXpNMzdjblpLcUhfSm02Qlh5S1FVN3lIcHl0NnlRPT0=
|
||||
|
||||
# Feature SyncDelta JIRA configuration
|
||||
Feature_SyncDelta_JIRA_DELTA_TOKEN_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEbm0yRUJ6VUJKbUwyRW5kMnRaNW4wM2YxMkJUTXVXZUdmdVRCaUZIVHU2TTV2RWZLRmUtZkcwZE4yRUNlNDQ0aUJWYjNfdVg5YjV5c2JwMHhoUUYxZWdkeS11bXR0eGxRLWRVaVU3cUVQZWJlNDRtY1lWUDdqeDVFSlpXS0VFX21WajlRS3lHQjc0bS11akkybWV3QUFlR2hNWUNYLUdiRjZuN2dQODdDSExXWG1Dd2ZGclI2aUhlSWhETVZuY3hYdnhkb2c2LU1JTFBvWFpTNmZtMkNVOTZTejJwbDI2eGE0OS1xUlIwQnlCSmFxRFNCeVJNVzlOMDhTR1VUamx4RDRyV3p6Tk9qVHBrWWdySUM3TVRaYjd3N0JHMFhpdzFhZTNDLTFkRVQ2RVE4U19COXRhRWtNc0NVOHRqUS1CRDFpZ19xQmtFLU9YSDU3TXBZQXpVcld3PT0=
|
||||
|
||||
# Teamsbot Browser Bot Service
|
||||
# For local testing: run the bot locally with `npm run dev` in service-teams-browser-bot
|
||||
# The bot will connect back to localhost:8000 via WebSocket
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
# Production Environment Configuration
|
||||
|
||||
# System Configuration
|
||||
APP_ENV_TYPE = prod
|
||||
APP_ENV_LABEL = Production Instance
|
||||
APP_KEY_SYSVAR = CONFIG_KEY
|
||||
APP_INIT_PASS_ADMIN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3UnJRV0sySFlDblpXUlREclREaW1WbUt6bGtQYkdrNkZDOXNOLXFua1hqeFF2RHJnRXJ5VlVGV3hOZm41QjZOMlNTb0duYXNxZi05dXVTc2xDVkx0SVBFLUhncVo5T0VUZHE0UTZLWWw3ck09
|
||||
APP_INIT_PASS_EVENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3QVpIY19DQVZSSzJmc2F0VEZvQlU1cHBhTEgxdHdnR3g4eW01aTEzYTUxc1gxTDR1RVVpSHRXYjV6N1BLZUdCUGlfOW1qdy0xSHFVRkNBcGZvaGlSSkZycXRuUllaWnpyVGRoeFg1dGEyNUk9
|
||||
APP_API_URL = https://gateway-prod.poweron.swiss
|
||||
APP_COOKIE_SECURE = true
|
||||
|
||||
# PostgreSQL DB Host
|
||||
DB_HOST=gateway-prod-server.postgres.database.azure.com
|
||||
DB_USER=gzxxmcrdhn
|
||||
DB_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3Y1JScGxjZG9TdUkwaHRzSHZhRHpNcDV3N1U2TnIwZ21PRG5TWFFfR1k0N3BiRk5WelVadjlnXzVSTDZ6NXFQNFpqbnJ1R3dNVkJocm1zVEgtSk0xaDRiR19zNDBEbVIzSk51ekNlQ0Z3b0U9
|
||||
DB_PORT=5432
|
||||
|
||||
# Security Configuration
|
||||
APP_JWT_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3elhfV0Rnd2pQRjlMdkVwX1FnSmRhSzNZUlV5SVpaWXBNX1hpa2xPZGdMSWpnN2ZINHQxeGZnNHJweU5pZjlyYlY5Qm9zOUZEbl9wUEgtZHZXd1NhR19JSG9kbFU4MnFGQnllbFhRQVphRGQyNHlFVWR5VHQyUUpqN0stUmRuY2QyTi1oalczRHpLTEJqWURjZWs4YjZvT2U5YnFqcXEwdEpxV05fX05QMmtrPQ==
|
||||
APP_TOKEN_EXPIRY=300
|
||||
|
||||
# CORS Configuration
|
||||
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss,https://nyla.poweron-center.net,https://nyla-int.poweron-center.net
|
||||
|
||||
# Logging configuration
|
||||
APP_LOGGING_LOG_LEVEL = DEBUG
|
||||
APP_LOGGING_LOG_DIR = /home/site/wwwroot/
|
||||
APP_LOGGING_FORMAT = %(asctime)s - %(levelname)s - %(name)s - %(message)s
|
||||
APP_LOGGING_DATE_FORMAT = %Y-%m-%d %H:%M:%S
|
||||
APP_LOGGING_CONSOLE_ENABLED = True
|
||||
APP_LOGGING_FILE_ENABLED = True
|
||||
APP_LOGGING_ROTATION_SIZE = 10485760
|
||||
APP_LOGGING_BACKUP_COUNT = 5
|
||||
|
||||
# OAuth: Auth app (login/JWT) vs Data app (Graph / Google APIs)
|
||||
Service_MSFT_AUTH_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
|
||||
Service_MSFT_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnFBa1kySFR2NjBKM084QTNpeUlyUmM4R0N0SU1BZ2x4MmVTZTVHQkVzRE9GdmFkV041MzhudFhobjU0RWNnd3lqeXpKUXA5aGtNZkhtYU12QjBtX0NjemVmdEZBdC1TbXVBSXJTcF9vMlJXd0ZNRTRKRFBMUXNjTF85eTBxakR4RVNfYmU=
|
||||
Service_MSFT_AUTH_REDIRECT_URI = https://gateway-prod.poweron.swiss/api/msft/auth/login/callback
|
||||
Service_MSFT_DATA_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
|
||||
Service_MSFT_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnFBa1kyNVU4cVRIZFdjS3l2S1RJVTVlc1ozQ1liZXZDX1VwdFZQUzFtS0N6UWYyeGxkNGNmY1hoaWxEUDBXVU5QR2t3Vi1ZV1A2QkxqbnpobzJwOXdzYTBZaFZYdnNkeDE1VVl0bm4weHFiLXdON2gtZzAwMTkxNWRoZldFM2djSkNHVS0=
|
||||
Service_MSFT_DATA_REDIRECT_URI = https://gateway-prod.poweron.swiss/api/msft/auth/connect/callback
|
||||
|
||||
Service_GOOGLE_AUTH_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
|
||||
Service_GOOGLE_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnFBa1kyUmJleVpTOF9OaFV3NGVfcWVBX2oxSjUwMWRGOFZRWFRIN1FZRzZ6U3VQMlg5a21RY1drTHh3U254LW4zM1A1cXQ1TTFWYlNoek9hSHJIeE4tbm1wU1lKRXlKNU5HVWI4VGZwTVE0VnJGaV8wZmNvdkVrMjJGeXdmZ3UyNmVXN1E=
|
||||
Service_GOOGLE_AUTH_REDIRECT_URI = https://gateway-prod.poweron.swiss/api/google/auth/login/callback
|
||||
Service_GOOGLE_DATA_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
|
||||
Service_GOOGLE_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnFBa1kyY2pxMDh0U0RqWERianBMTTNtSUZPSzhKUzh4S0RTenR2MmxnRDlvQzJjbDVTczRWLUJtVnhxWTE2MmUxQjJia2xJcVUzVlFlUnpma040NFdHRzVNRUt0OXR0c2JkTkRmQ1RIYllXbXFFaExIQWNycFVHbUxHbmtYOVhOVUV2MFY=
|
||||
Service_GOOGLE_DATA_REDIRECT_URI = https://gateway-prod.poweron.swiss/api/google/auth/connect/callback
|
||||
|
||||
# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly.
|
||||
Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
|
||||
Service_CLICKUP_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6VGw5WDdhdDRsVENSalhSSUV0OFFxbEx0V1l6aktNV0E5Y18xU3JHLUlqMWVJdmxyajAydVZRaDJkZzJOVXhxRV9ROFRZbWxlRjh4c3NtQnRFMmRtZWpzTWVsdngtWldlNXRKTURHQjJCOEt6alMwQlkwOFYyVVJWNURJUGJIZDIxYVlfNnBrMU54M0Q3TVdVbFZqRkJKTUtqa05wUkV4eGZvbXNsVi1nNVdBPQ==
|
||||
Service_CLICKUP_OAUTH_REDIRECT_URI = https://gateway-prod.poweron.swiss/api/clickup/auth/connect/callback
|
||||
|
||||
# Infomaniak: no OAuth client. Users paste a Personal Access Token (kdrive + mail) per UI.
|
||||
|
||||
# Stripe Billing (both end with _SECRET for encryption script)
|
||||
STRIPE_SECRET_KEY_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6aVA3R3VRS3VHMUgzUEVjYkR4eUZKWFhPUzFTTVlHNnBvT3FienNQaUlBWVpPLXJyVGpGMWk4LXktMXphX0J6ZTVESkJxdjNNa3ZJbF9wX2ppYzdjYlF0cmdVamlEWWJDSmJYYkJseHctTlh4dnNoQWs4SG5haVl2TTNDdXpuaFpqeDBtNkFCbUxMa0RaWG14dmxyOEdILTNrZ2licmNpbXVkN2lFSWoxZW1BODNpV0ZTQ0VaeXRmR1d4RjExMlVFS3MtQU9zZXZlZE1mTmY3OWctUXJHdz09
|
||||
STRIPE_WEBHOOK_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGNUpTWldsakYydFhFelBrR1lSaWxYT3kyMENOMUljZTJUZHBWcEhhdWVCMzYxZXQ5b3VlTFVRalFiTVdsbGxrdUx0RDFwSEpsOC1sTDJRTEJNQlA3S3ZaQzBtV1h6bWp5VnlMZUgwUlF3cXYxcnljZVE5SWdzLVg3V0syOWRYS08=
|
||||
STRIPE_API_VERSION = 2026-01-28.clover
|
||||
STRIPE_AUTOMATIC_TAX_ENABLED = false
|
||||
STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQZG8WqlVsabrfFEu49pah
|
||||
|
||||
|
||||
# AI configuration
|
||||
Connector_AiOpenai_API_SECRET = PROD_ENC:Z0FBQUFBQnFCdlFmcDVyOGNwbVkwWFJCWmFkZS12RkhLaFhLSF9kWWpEZ0d0NDBqV2FnWlpnYmpSckdLSGpjbmh6aHJXVUZxMElwY1MzcVg1MzBOdURUZXhnZ3pqNEZyQ1JWMVA0YmxhNWJlenNpa1A3TjZkYVZSclFONjU4MF9jMTJaS2d0ZDNnXzJKSmhSRVhyckJpTUlDa0RRWHN5cWVkOUJMTUp5aFRHcDV5Z1A1aWhSUnFNOHBJTDFPdzAzcVJ3bmhueTBmVkJDZTdJakhMOEFRdHBvWFduUzdRV2dNQVdpaXdFSVlHMDJ4NnZRUTBZZ3pOakxPLUdjNlNNQnJQMXpfSWR3NmFodDdDbkEtVmRjdVBhMjRWT1NOV1BYbU15VHRSWFR0UVBBMWtKRTRkS25KMFk9
|
||||
Connector_AiAnthropic_API_SECRET = PROD_ENC:Z0FBQUFBQnFCdlFmMGhla2xoZWowNjJzc1EzMWJYRXRTcGdWWWctU3hhcXNUbVVaOTJiRFJuSGM5S3ZGZ0M4RFotTGxOQ3loa3l4aVZ2T3FsRVVMck83RTlURFNOdWxHb0JfNVEtRGJ4X193dV9Bd0EtNlVGV0h4SWk2bldfWThxNVVnOGctSkNFR3FXa2pmY2ROcV9EVE1oMndFY1d4MjdLeWtUd0VEeW5CTlFwX2FOcW9DaWVXYWVfMy1ZUnFFUEZnanFOUGZILUpUZU8yUHNSODE3OXBSWVJFNlpBdTJtUT09
|
||||
Connector_AiPerplexity_API_SECRET = PROD_ENC:Z0FBQUFBQnFCdlFmRm9saTZuR1VSZV9pQllKRGFURmN4cDNNanpsVFM3TVItdDNtNWdoWC1zVllrLUVPeGZDRXF1S3Rxd0tVUGV6bl9Ob0JMa3U5ZUNlRjRVQ1dRWXZDTXlsRU13b2o2R1paalU4RXB6SWxYVEJPa2NmaDRFdzExRXU1X2VnNDlhQzQ3cTE1RlJrSlB5elRMZ2w3NmxlV2l3PT0=
|
||||
Connector_AiTavily_API_SECRET = PROD_ENC:Z0FBQUFBQnFCdlFmZGdyWkJibS03akJtSjF0U2doYXZVVDM1em1kY2ZpRGJISmVCUURfVkw3c2Z3OEFQd1h1SzE0cTExSUtVejRPY3VmWF9XT1ZyS3RxRmVRYktJeDR6OWhYaEM0bkNLVEI1cl9VZ1VFOG9IRTFWc2FUemh0UmNHTGprQ0FweThlSGpSSDAyZmw2YmR0OFREQWxpNERHWm1nPT0=
|
||||
Connector_AiPrivateLlm_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGanZ6U3pzZWkwXzVPWGtIQ040XzFrTXc5QWRnazdEeEktaUJ0akJmNnEzbWUzNHczLTJfc2dIdzBDY0FTaXZYcDhxNFdNbTNtbEJTb2VRZ0ZYd05hdlNLR1h6SUFzVml2Z1FLY1BjTl90UWozUGxtak1URnhhZmNDRWFTb0dKVUo=
|
||||
Connector_AiMistral_API_SECRET = PROD_ENC:Z0FBQUFBQnFCdlFmcEVpVmFuWkk4eTJTc3VtRFg4cE9QU3R5NVg0eVFIR29RSVhmXy1rR0pPTm4wbFhIVFFpckx5UmhvSGxqSWV4S0xoTzdESE55R2k5eHowZEprdGhrbEU3eG5JWGpaNWJIdDRqT05zZGNCQVpXd2xTek1teHRBS3NRU2FuUTlSQ2Q=
|
||||
|
||||
Service_MSFT_TENANT_ID = common
|
||||
|
||||
# Google Cloud Speech Services configuration
|
||||
Connector_GoogleSpeech_API_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z4NFQxaF9uN3h1cVB6dnZid1c1R1VfNDlSQ1NHMEVDZWtKanpMQ29CLXc1MXBqRm1hQ0YtWVhaejBMY1ZTOEFEVlpWQ3hrYkFza1E2RDNsYkdMMndNR0VGNTMwVDRGdURJY3hyaVFxVjEtSEYwNHJzeWM3WmlpZW9jU2E3NTgycEV2allqQ3dJRTNyRFAzaDJ6dklKeXpNRkJhYjFzUkptN2dpbkNpMklrcGxuZl9vTkt3T0JvNm1YTXd5UlkwZWptUXdWVFpnV2J4X3J2WUhIUlFkSElFVnlqMnlJRnNHTnlpMWs2R1dZc2ROWjNYZG85cndmd1E5cUZnVmZRYnVjTG43dXFmSWd2bGFfVWFWSmtpWkpndWNlSUNwcnFNU2NqZXFaV0xsY3l3SElLRkVHcHZGZERKV1ltcGhTS0dhTko1VTJLYzNoZjRkSGVEX3dTMWVVTmdDczV5cE1JQUdSbUJGUm11eFhTVjJHbkt0SzB4UG1Dc2xmbnp1Y041Y2RTeWRuWGdmQy1sTGx0MGtnM2VJQ3EyLXViRlNhTU9ybzZkR1N1bXE5SXhlZENWRFpWSGlYOWx4SUQ3UlR0ZEVxQkxNakRUVFRiUmFnbklOalphLUZkRFVVaXBRUk5NZW5PaUZydTFmQkNPSTdTVTNZd0plWXllNVFJdmN4MVcyTGlwMGFtVjBzOGRxR1FjbzhfYW5zdTB0ZEZBTTJhakltazh1dktNMUZsOUItdFdTb1pIaUxySllXNkdlY20zUS0wTnpFNTB2SU5acG1VcXhyaHBmME8takw3RDh5T043T2VGOV92TzNya2pWSlpYVjZDdXlZcjM3a0hPTlhkaW9oQmxqQlpGRFYyTTY4WmZmT3k4Tk1tdXRuSGdTUVpNT2NKenhXb05PdXBfSEdhMTNxNjdpNXlKUUI2YUgydFFPX1VvXzVJb0UxWTU2YVNiNDQ0QndZanhMMHR1cGdHWGhvcEg1QXEtSXZJdTdZUE12ZEVVWkF4QmtsQS1GYnY3SFIxSHlsOGVfcEpGS1A4QUVEQWNEOFZYYlljQ3ByTU03YU16Y0UzUnJQZEprSWNjT1ZXVEtDWi03Y3ZzRVdYUTlabXJISEo5THRHVXVuM0xqbzA4bGVlZVpOMk1QMmptb21tV0pTMlVoOXdWVU95UW1iQmttc2w1RG9mMWwxXzg1T2IxYUVmTUJEZkpUdTFDTzZ3RlBFeUFiX01iRTZNWkNaSG45TkFOM2pzbUJRZ2N0VFpoejJUTG1RODY3TzZpSzVkYUQzaEpfY2pSTkRzU0VpanlkdXVQQmJ2WU5peno4QWNLTDVxZTlhSHI3NnNiM0k0Y3JkQ0xaOU05bGtsQl8zQklvaktWSDZ4aVp2MHlYelJuUDJyTU9CZC1OZjJxNFc1dDcwSUlxaVh1LTMyWWFwU0IwUU9kOUFpMWpnOERtLTh1VmJiNGVwcXBMbU5fMjVZc0hFbmxQT2puSFd1ZGpyTkphLU5sVlBZWWxrWEZrWGJQWmVkN19tZFZfZ1l1V3pSWlA0V0ZxM2lrWnl2NU9WeTdCbDROSmhfeENKTFhMVXk1d195S2JMUFJoRXZjcVo4V2g0MTNKRnZhUE1wRkNPM3FZOGdVazJPeW5PSGpuZnFGTTdJMkRnam5rUlV6NFlqODlIelRYaEN5VjdJNnVwbllNODNCTFRHMWlXbmM1VlRxbXB3Wm9LRjVrQUpjYzRNMThUMWwwSVhBMUlyamtPZnE4R0o4bEdHay1zMjR5RDJkZ1lYRHZaNHVHU2otR3ZpN25LZlEySEU0UmdTNzJGVHNWQXMyb0dVMV9WUE13ODhZWUFaakxGOWZieGNXZkNYRnV5djEyWTZLcmdrajRBLU1rS1Z0VVRkOWlDMU9fMGVmYXFhZXJGMUhpNkdmb2hkbzZ1OWV6VlNmVzNISjVYTFh6SjJNdWR5MWZidE8yVEo2dnRrZXhMRXBPczUwTG13OGhNUVpIQm0zQmRKRnJ0Nl8wNW1Ob0dHRDVpU0NWREV3TkY2SjktdVBkMFU1ZXBmSFpHQ3FHNTRZdTJvaExpZVEtLTU4YTVyeFBpNDdEajZtWUc4c1dBeUJqQ3NIY1NLS0FIMUxGZzZxNFNkOG9ORGNHWWJCVnZuNnJVTEtoQi1mRTZyUl81ZWJJMi1KOGdERzBhNVRZeHRYUUlqY2JvMFlaNHhWMU9pWFFiZjdaLUhkaG15TTBPZVlkS2R5UVdENTI4QVFiY1RJV0ZNZnlpVWxfZmlnN1BXbGdrbjFGUkhzYl9qeHBxVVJacUE4bjZETENHVFpSamh0NVpOM2hMYTZjYzBuS3J0a3hhZGxSM1V5UHd2OTU3ZHY0Yy1xWDBkWUk0Ymp0MWVrS3YzSktKODhQZnY3QTZ1Wm1VZkZJbS1jamdreks1ZlhpQjFOUDFiOHJ2Nm9NcmdTdU5LQXV2RkZWZEFNZnVKUjVwcVY3dDdhQnpmRVJ6SmlvVXpDM0ZiYXh5bGE2X04tTE9qZ3BiTnN3TF9ZaFRxSUpjNjB1dXZBcy1TZHRHTjFjSUR3WUl4cE9VNzB5Rkk4U3Z1SVZYTl9sYXlZVk83UnFrMlVmcnBpam9lRUlCY19DdVJwOXl2TVVDV1pMRFZTZk9MY3Z1eXA0MnhGazc5YllQaWtOeTc4NjlOa2lGY05RRzY1cG9nbGpYelc4c3FicWxWRkg0YzRSamFlQ19zOU14YWJreU9pNDREZVJ3a0REMUxGTzF1XzI1bEF3VXVZRjlBeWFiLXJsOXgza3VZem1WckhWSnVNbDBNcldadU8xQ3RwOTl5NGgtVlR0QklCLWl5WkE4V1FlQTBCOVU1RE9sQlRrYUNZOGdfUmEwbEZvUTFGUEFWVmQ4V1FhOU9VNjZqemRpZm1sUDhZQTJ0YVBRbWZldkF5THV4QXpfdUtNZ0tlcGdSRFM3c0lDOTNQbnBxdmxYYWNpTmI3MW9BMlZIdTQ5RldudHpNQWQ5NDNPLVVTLXVVNzdHZXh4UXpZa3dVa2J4dTFDV1RkYjRnWXU2M3lJekRYWGNMcWU5OVh6U2xZWDh6MmpqcnpiOHlnMjA5S3RFQm1NZjNSM21adkVnTUpSYVhkTzNkNnJCTmljY0x1cl9kMkx3UHhySjZEdHREanZERzNEUTFlTkR0NWlBczAtdmFGTjdZNVpTMlkxV2czYW5RN2lqemg4eUViZDV6RjdKNXdFcUlvcVhoNkJ6eVJkR1pua1hnNzQwOEs2TXJYSlpGcW9qRDU2QjBOWFFtdXBJRkRKbmdZUF9ZSmRPVEtvUjVhLTV1NjdXQjRhS0duaEtJb2FrQnNjUTRvdFMxdkdTNk1NYlFHUFhhYTJ1eUN3WHN4UlJ4UjdrZjY0SzFGYWVFN1k0cGJnc1RjNmFUenR4NHljbVhablZSWHZmUVN3cXRHNjhsX1BSZWEzdTJUZFA0S2pTaU9YMnZIQ1ZPcGhWMFJqZkVEMWRMR1h3SnU0Z2FzZ3VGM3puNzdhVjhaQXNIWHFsbjB0TDVYSFdSNV9rdWhUUUhSZHBGYkJIVDB5SDdlMC13QTVnS0g5Qkg5RGNxSGJlelVndUhPcEQ0QkRKMTJTZUM1OXJhVm0zYjU0OVY2dk9MQVBheklIQXpVNW9Yc0ROVjEzaFZTWmVxYlBWMlNlSzladzJ6TmNuMG5FVVZkN1VZN1pfS2ZHa0lQcE80S24wSnQtVlJVV09OVWJ3M09YMkZpV2ktVF9ENHhKU2dfYUQ2aUVyamk0VHJHQmVfVHU4clpUTFoteW5aSWRPV1M0RDRMTms4NGRoYmJfVE82aUl2X3VieVJOdDhBQmRwdzdnRTVBNzZwaW93dUlZb3ZRYUtOeG9ULWxvNVp5a0haSjdkcUhRb3d6UGIxRUpCVkVYX2d6TkRqQVozUWxkNGFoc1FXYVd2YWNkME9Qclo0bjYxMFRWTy1nbnI5NTBJNzRMMDluUXRKYTFqQUN4d0d5aHVlamN3Tkk3NWJXeXR0TW9BeUg5Vnp4Q2RnZUY3b3AtMDlrNmlrSGR0eGRtbUdUd2lFRWg4MklEeWJHN2wwZEpVSXMxNDNOWjRFS0tPdWxhMmFCckhfRENIY184aEFDZXNrRDl2dHQtQW12UnRuQXJjaDJoTUpiYkNWQUtfRG9GMUZoNWM4UnBYZ29RWWs2NHcyUm5kdTF3Vk1GeFpiRUJLaVZ2UGFjbi1jV3lMV0N2ZDl4VERPN295X01NNG56ZjZkRzZoYUtmY1E5NlVXemx2SnVfb19iSXg0R2M3Mjd1a2JRPT0=
|
||||
|
||||
# Feature SyncDelta JIRA configuration
|
||||
Feature_SyncDelta_JIRA_DELTA_TOKEN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z4d3Z4d2x6N1FhUktMU0RKbkxfY2pTQkRzXzJ6UXVEbDNCaFM3UHMtQVFGYzNmYWs4N0lMM1R2SFJuZTVFVmx6MGVEbXc5U3NOTnY1TWN0ZDNaamlHQWloalM3VldmREJNSHQ1TlVkSVFJMTVhQWVGSVRMTGw4UTBqNGlQZFVuaHp4WUlKemR5UnBXZlh0REJFLXJ4ejR3PT0=
|
||||
|
||||
# Teamsbot Browser Bot Service
|
||||
TEAMSBOT_BROWSER_BOT_URL = https://cae-poweron-shared.redwater-53d21339.switzerlandnorth.azurecontainerapps.io
|
||||
|
||||
# Debug Configuration
|
||||
APP_DEBUG_CHAT_WORKFLOW_ENABLED = FALSE
|
||||
APP_DEBUG_CHAT_WORKFLOW_DIR = ./test-chat
|
||||
APP_DEBUG_ACCOUNTING_SYNC_ENABLED = FALSE
|
||||
APP_DEBUG_ACCOUNTING_SYNC_DIR = ./debug/sync
|
||||
|
||||
# Azure Communication Services Email Configuration
|
||||
MESSAGING_ACS_CONNECTION_STRING = endpoint=https://mailing-poweron-prod.switzerland.communication.azure.com/;accesskey=4UizRfBKBgMhDgQ92IYINM6dJsO1HIeL6W1DvIX9S0GtaS1PjIXqJQQJ99CAACULyCpHwxUcAAAAAZCSuSCt
|
||||
MESSAGING_ACS_SENDER_EMAIL = DoNotReply@poweron.swiss
|
||||
|
|
@ -3,17 +3,17 @@
|
|||
# System Configuration
|
||||
APP_ENV_TYPE = int
|
||||
APP_ENV_LABEL = Integration Instance
|
||||
APP_API_URL = https://gateway-int.poweron.swiss
|
||||
APP_API_URL = https://api-int.poweron.swiss
|
||||
# Force SameSite=None+Secure for auth cookies (cross-site UI on poweron-center.net). Optional if APP_API_URL is https://
|
||||
APP_COOKIE_SECURE = true
|
||||
APP_KEY_SYSVAR = CONFIG_KEY
|
||||
APP_KEY_SYSVAR = /srv/gateway/shared/secrets/master_key.txt
|
||||
APP_INIT_PASS_ADMIN_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjWm41MWZ4TUZGaVlrX3pWZWNwakJsY3Facm0wLVZDd1VKeTFoZEVZQnItcEdUUnVJS1NXeDBpM2xKbGRsYmxOSmRhc29PZjJSU2txQjdLbUVrTTE1NEJjUXBHbV9NOVJWZUR3QlJkQnJvTEU9
|
||||
APP_INIT_PASS_EVENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjdmtrakgxa0djekZVNGtTZV8wM2I5UUpCZllveVBMWXROYk5yS3BiV3JEelJSM09VYTRONHpnY3VtMGxDRk5JTEZSRFhtcDZ0RVRmZ1RicTFhb3c5dVZRQ1o4SmlkLVpPTW5MMTU2eTQ0Vkk9
|
||||
|
||||
# PostgreSQL DB Host
|
||||
DB_HOST=gateway-int-server.postgres.database.azure.com
|
||||
DB_USER=heeshkdlby
|
||||
DB_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjczYzOUtTa21MMGJVTUQ5UmFfdWc3YlhCbWZOeXFaNEE1QzdJV3BLVjhnalBkLVVCMm5BZzdxdlFXQXc2RHYzLWtPSFZkZE1iWG9rQ1NkVWlpRnF5TURVbnl1cm9iYXlSMGYxd1BGYVc0VDA9
|
||||
# PostgreSQL DB Host (porta-int-db on Infomaniak Public Cloud)
|
||||
DB_HOST=db-int.poweron.swiss
|
||||
DB_USER=poweron_dev
|
||||
DB_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQnFGTFJneVFGQ09JYVgwVWRGXzRSQjJ2RnlGYS05WllIMURpUUNBS0poQS1yLUJDaFFQS2IyLTNTSTUtRTBfekF1R1U5dUhiOXdYdi1WSVF4bUltczVQUVJQN2Q0Mng3cHFWVndZVDJxc2ZicXRXVnc9
|
||||
DB_PORT=5432
|
||||
|
||||
# Security Configuration
|
||||
|
|
@ -21,11 +21,11 @@ APP_JWT_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNUctb2RwU25iR3ZnanBOdHZhWUtIajZ1RnZ
|
|||
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
|
||||
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://porta.poweron.swiss,https://porta-int.poweron.swiss,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss,https://nyla.poweron-center.net,https://nyla-int.poweron-center.net
|
||||
|
||||
# Logging configuration
|
||||
APP_LOGGING_LOG_LEVEL = DEBUG
|
||||
APP_LOGGING_LOG_DIR = /home/site/wwwroot/
|
||||
APP_LOGGING_LOG_DIR = srv/gateway/shared/logs
|
||||
APP_LOGGING_FORMAT = %(asctime)s - %(levelname)s - %(name)s - %(message)s
|
||||
APP_LOGGING_DATE_FORMAT = %Y-%m-%d %H:%M:%S
|
||||
APP_LOGGING_CONSOLE_ENABLED = True
|
||||
|
|
@ -36,23 +36,22 @@ APP_LOGGING_BACKUP_COUNT = 5
|
|||
# OAuth: Auth app (login/JWT) vs Data app (Graph / Google APIs)
|
||||
Service_MSFT_AUTH_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
|
||||
Service_MSFT_AUTH_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnFBa1kydlVubld1d1h6SUNSWW1aZ3p4X3Zod1NDTjhZVnVYS2lqOERGTFp2OXJ4TGRiNlRLVFpzLUVDTUhkZGhGUWdxa1djdEV5UWkyblN1UHZoaFBjaExNTEpGMG1PRGJEbDdHVll0Ungwcl9JemZ4ZXFzZUNFQmFlZi1DZFlCekU1S3E=
|
||||
Service_MSFT_AUTH_REDIRECT_URI = https://gateway-int.poweron.swiss/api/msft/auth/login/callback
|
||||
Service_MSFT_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://gateway-int.poweron.swiss/api/msft/auth/connect/callback
|
||||
Service_MSFT_DATA_REDIRECT_URI = https://api-int.poweron.swiss/api/msft/auth/connect/callback
|
||||
|
||||
Service_GOOGLE_AUTH_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
|
||||
Service_GOOGLE_AUTH_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnFBa1kyUTUwNXNGaHRNaGxxbF9sdWJ3Q0xLYU5yOHB4Yk8zMDZvQ29yaEhWOE5JMENXRk5jb2ZBdzRKQ2ZTTld6ZlIxemhOYzN1VE10TjBDRWZEMXlLVWRNYjZ0VG5RZ3I3NWt0SEJzMzdsUmRzcVNmbktRNHZqTUF6a2EyUkVUSFJnZFE=
|
||||
Service_GOOGLE_AUTH_REDIRECT_URI = https://gateway-int.poweron.swiss/api/google/auth/login/callback
|
||||
Service_GOOGLE_AUTH_REDIRECT_URI = https://api-int.poweron.swiss/api/google/auth/login/callback
|
||||
Service_GOOGLE_DATA_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
|
||||
Service_GOOGLE_DATA_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnFBa1kyV1FRVjF0c0d3d0dyWU1TdW9HdXVkdHdsVWZKYTJjbGZPRDhMRjA2M0FkaUZIVmhIUmFKNjg2ekFodHd6NG80VTI3TC1icW1LZ01jWVZuQ1pKRm5nMW5UREJEaGp2Wl9oRDRCSmZVT0JpTnkwXzgwY0pkV29yczQ5akF2d1ZGcVY=
|
||||
Service_GOOGLE_DATA_REDIRECT_URI = https://gateway-int.poweron.swiss/api/google/auth/connect/callback
|
||||
Service_GOOGLE_DATA_REDIRECT_URI = https://api-int.poweron.swiss/api/google/auth/connect/callback
|
||||
|
||||
# ClickUp OAuth — 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).
|
||||
# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly.
|
||||
Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
|
||||
Service_CLICKUP_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnB5dkd5SE1uVURMNVE3NkM4cHBKa2R2TjBnLWdpSXI5dHpKWGExZVFiUF95TFNnZ1NwLWFLdmh6eWFZTHVHYTBzU2FGRUpLYkVyM1NvZjZkWDZHN21qUER5ZVNOaGpCc3NrUGd3VnFTclF3OW1nUlVuWXQ1UVhDLVpyb1BwRExOeFpDeVhtbEhDVnd4TVdpbzNBNk5QQWFPdjdza0xBWGxFY1E3WFpCSUlNa1l4RDlBPQ==
|
||||
Service_CLICKUP_OAUTH_REDIRECT_URI = https://gateway-int.poweron.swiss
|
||||
Service_CLICKUP_OAUTH_REDIRECT_URI = https://api-int.poweron.swiss/api/clickup/auth/connect/callback
|
||||
|
||||
# Infomaniak: no OAuth client. Users paste a Personal Access Token (kdrive + mail) per UI.
|
||||
|
||||
|
|
@ -76,11 +75,8 @@ 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
|
||||
# Teamsbot Browser Bot Service (service-main-teams-browser-bot on Infomaniak)
|
||||
TEAMSBOT_BROWSER_BOT_URL = http://teamsbot.poweron.swiss:4100
|
||||
|
||||
# Debug Configuration
|
||||
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_API_URL = https://api.poweron.swiss
|
||||
|
||||
# PostgreSQL DB Host
|
||||
DB_HOST=10.20.0.21
|
||||
# PostgreSQL DB Host (porta-main-db on Infomaniak Public Cloud)
|
||||
DB_HOST=db.poweron.swiss
|
||||
DB_USER=poweron_dev
|
||||
DB_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnA4UXZiMnRoUzVlbVRLX3JTRl94cVpMaURtMndZVmFBYXdvdnIxLV81dWwxWmhmcUlCMUFZbDhRT2NsQmNqSl9ZMmRWRVN1Y2JqNlVwOXRJY1VBTm1oSjNiaFE9PQ==
|
||||
DB_PORT=5432
|
||||
|
|
@ -19,7 +19,7 @@ APP_JWT_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3elhfV0Rnd2pQRjlMdkVwX1FnSmRhSzNZUl
|
|||
APP_TOKEN_EXPIRY=300
|
||||
|
||||
# CORS Configuration
|
||||
APP_ALLOWED_ORIGINS=https://porta.poweron.swiss
|
||||
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://porta.poweron.swiss,https://porta-int.poweron.swiss,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss,https://nyla.poweron-center.net,https://nyla-int.poweron-center.net
|
||||
|
||||
# Logging configuration
|
||||
APP_LOGGING_LOG_LEVEL = DEBUG
|
||||
|
|
@ -74,11 +74,8 @@ 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
|
||||
TEAMSBOT_BROWSER_BOT_URL = http://teamsbot.poweron.swiss:4100
|
||||
|
||||
# Debug Configuration
|
||||
APP_DEBUG_CHAT_WORKFLOW_ENABLED = FALSE
|
||||
|
|
@ -29,25 +29,6 @@ _msg = apiRouteContext("oauthConnectTicket")
|
|||
|
||||
_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:
|
||||
"""Issue a short-lived JWT for starting a data-connection OAuth popup."""
|
||||
|
|
|
|||
|
|
@ -9,14 +9,15 @@ for compliance, audit, and data-protection reporting.
|
|||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import Field
|
||||
|
||||
from modules.datamodels.datamodelBase import PowerOnModel
|
||||
from modules.shared.i18nRegistry import i18nModel
|
||||
from modules.shared.timeUtils import getUtcTimestamp
|
||||
|
||||
|
||||
@i18nModel("AI-Audit-Eintrag")
|
||||
class AiAuditLogEntry(BaseModel):
|
||||
class AiAuditLogEntry(PowerOnModel):
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
description="Primary key",
|
||||
|
|
@ -34,7 +35,7 @@ class AiAuditLogEntry(BaseModel):
|
|||
|
||||
userId: str = Field(
|
||||
description="ID of the user who triggered the AI call",
|
||||
json_schema_extra={"label": "Benutzer-ID", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
|
||||
json_schema_extra={"label": "Benutzer-ID", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username", "softFk": True}},
|
||||
)
|
||||
username: Optional[str] = Field(
|
||||
default=None,
|
||||
|
|
@ -43,17 +44,17 @@ class AiAuditLogEntry(BaseModel):
|
|||
)
|
||||
mandateId: str = Field(
|
||||
description="Mandate context of the call",
|
||||
json_schema_extra={"label": "Mandanten-ID", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}},
|
||||
json_schema_extra={"label": "Mandanten-ID", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label", "softFk": True}},
|
||||
)
|
||||
featureInstanceId: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Feature instance context",
|
||||
json_schema_extra={"label": "Feature-Instanz-ID", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
|
||||
json_schema_extra={"label": "Feature-Instanz-ID", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label", "softFk": True}},
|
||||
)
|
||||
featureCode: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Feature code (e.g. workspace, trustee)",
|
||||
json_schema_extra={"label": "Feature", "fk_target": {"db": "poweron_app", "table": "Feature", "column": "code", "labelField": "code"}},
|
||||
json_schema_extra={"label": "Feature", "fk_target": {"db": "poweron_app", "table": "Feature", "column": "code", "labelField": "code", "softFk": True}},
|
||||
)
|
||||
instanceLabel: Optional[str] = Field(
|
||||
default=None,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from pydantic import BaseModel, Field
|
|||
from enum import Enum
|
||||
import uuid
|
||||
|
||||
from modules.datamodels.datamodelBase import PowerOnModel
|
||||
from modules.shared.timeUtils import getUtcTimestamp
|
||||
from modules.shared.i18nRegistry import i18nModel
|
||||
|
||||
|
|
@ -83,7 +84,7 @@ class AuditAction(str, Enum):
|
|||
|
||||
|
||||
@i18nModel("Audit-Log-Eintrag")
|
||||
class AuditLogEntry(BaseModel):
|
||||
class AuditLogEntry(PowerOnModel):
|
||||
"""
|
||||
Audit log entry for database storage.
|
||||
|
||||
|
|
@ -111,7 +112,7 @@ class AuditLogEntry(BaseModel):
|
|||
"frontend_type": "text",
|
||||
"frontend_readonly": True,
|
||||
"frontend_required": True,
|
||||
"fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"},
|
||||
"fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username", "softFk": True},
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -130,7 +131,7 @@ class AuditLogEntry(BaseModel):
|
|||
"frontend_type": "text",
|
||||
"frontend_readonly": True,
|
||||
"frontend_required": False,
|
||||
"fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"},
|
||||
"fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label", "softFk": True},
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -142,7 +143,7 @@ class AuditLogEntry(BaseModel):
|
|||
"frontend_type": "text",
|
||||
"frontend_readonly": True,
|
||||
"frontend_required": False,
|
||||
"fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"},
|
||||
"fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label", "softFk": True},
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ class BillingTransaction(PowerOnModel):
|
|||
|
||||
|
||||
@i18nModel("Abrechnungseinstellungen")
|
||||
class BillingSettings(BaseModel):
|
||||
class BillingSettings(PowerOnModel):
|
||||
"""Billing settings per mandate. Only PREPAY_MANDATE model."""
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
|
|
@ -186,7 +186,7 @@ class BillingSettings(BaseModel):
|
|||
)
|
||||
|
||||
|
||||
class StripeWebhookEvent(BaseModel):
|
||||
class StripeWebhookEvent(PowerOnModel):
|
||||
"""Stores processed Stripe webhook event IDs for idempotency."""
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
|
|
@ -201,7 +201,7 @@ class StripeWebhookEvent(BaseModel):
|
|||
|
||||
|
||||
@i18nModel("Nutzungsstatistik")
|
||||
class UsageStatistics(BaseModel):
|
||||
class UsageStatistics(PowerOnModel):
|
||||
"""Aggregated usage statistics for quick retrieval."""
|
||||
id: str = Field(
|
||||
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"})
|
||||
|
||||
@i18nModel("Aufgaben-Aktion")
|
||||
class ActionItem(BaseModel):
|
||||
class ActionItem(PowerOnModel):
|
||||
id: str = Field(..., description="Action ID", json_schema_extra={"label": "Aktions-ID"})
|
||||
execMethod: str = Field(..., description="Method to execute", json_schema_extra={"label": "Methode"})
|
||||
execAction: str = Field(..., description="Action to perform", json_schema_extra={"label": "Aktion"})
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ class FileContentIndex(PowerOnModel):
|
|||
connectionId: Optional[str] = Field(
|
||||
default=None,
|
||||
description="UserConnection ID if this index entry originates from an external connector",
|
||||
json_schema_extra={"label": "Connection-ID"},
|
||||
json_schema_extra={"label": "Connection-ID", "fk_target": {"db": "poweron_app", "table": "UserConnection", "labelField": "authority"}},
|
||||
)
|
||||
neutralizationStatus: Optional[str] = Field(
|
||||
default=None,
|
||||
|
|
@ -122,7 +122,7 @@ class ContentChunk(PowerOnModel):
|
|||
)
|
||||
contentObjectId: str = Field(
|
||||
description="Reference to the content object within FileContentIndex",
|
||||
json_schema_extra={"label": "Inhaltsobjekt-ID"},
|
||||
json_schema_extra={"label": "Inhaltsobjekt-ID", "fk_target": {"db": "poweron_knowledge", "table": "FileContentIndex", "labelField": "fileName"}},
|
||||
)
|
||||
fileId: str = Field(
|
||||
description="FK to the source file",
|
||||
|
|
@ -177,7 +177,7 @@ class RoundMemory(PowerOnModel):
|
|||
)
|
||||
workflowId: str = Field(
|
||||
description="FK to the workflow",
|
||||
json_schema_extra={"label": "Workflow-ID"},
|
||||
json_schema_extra={"label": "Workflow-ID", "fk_target": {"db": "poweron_chat", "table": "ChatWorkflow", "labelField": "name"}},
|
||||
)
|
||||
roundNumber: int = Field(
|
||||
default=0,
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ class MessagingSubscription(PowerOnModel):
|
|||
|
||||
|
||||
@i18nModel("Messaging-Registrierung")
|
||||
class MessagingSubscriptionRegistration(BaseModel):
|
||||
class MessagingSubscriptionRegistration(PowerOnModel):
|
||||
"""Data model for user registrations to messaging subscriptions"""
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
|
|
@ -203,7 +203,7 @@ class MessagingSubscriptionRegistration(BaseModel):
|
|||
|
||||
|
||||
@i18nModel("Messaging-Zustellung")
|
||||
class MessagingDelivery(BaseModel):
|
||||
class MessagingDelivery(PowerOnModel):
|
||||
"""Data model for individual message deliveries"""
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@ class SubscriptionPlan(BaseModel):
|
|||
# ============================================================================
|
||||
|
||||
@i18nModel("Stripe-Planpreise")
|
||||
class StripePlanPrice(BaseModel):
|
||||
class StripePlanPrice(PowerOnModel):
|
||||
"""Persistierte Zuordnung planKey zu Stripe Product/Price IDs."""
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
|
|
|
|||
|
|
@ -475,7 +475,7 @@ class UserConnection(PowerOnModel):
|
|||
description="OAuth scopes granted for this connection",
|
||||
json_schema_extra={"frontend_type": "list", "frontend_readonly": True, "frontend_required": False, "label": "Gewährte Berechtigungen"},
|
||||
)
|
||||
knowledgeIngestionEnabled: bool = Field(
|
||||
knowledgeIngestionEnabled: Optional[bool] = Field(
|
||||
default=False,
|
||||
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"},
|
||||
|
|
@ -747,4 +747,3 @@ class UserVoicePreferences(PowerOnModel):
|
|||
def _validateTtsVoiceMap(cls, value: Any) -> Optional[Dict[str, str]]:
|
||||
return normalizeTtsVoiceMap(value)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -74,9 +74,18 @@ class CoachingScoreTrend(str, Enum):
|
|||
class TrainingModule(PowerOnModel):
|
||||
"""A training module representing a topic the user is working on."""
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
userId: str = Field(description="Owner user ID (strict ownership)")
|
||||
mandateId: str = Field(description="Mandate ID")
|
||||
instanceId: str = Field(description="Feature instance ID")
|
||||
userId: str = Field(
|
||||
description="Owner user ID (strict ownership)",
|
||||
json_schema_extra={"label": "Benutzer", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
|
||||
)
|
||||
mandateId: str = Field(
|
||||
description="Mandate ID",
|
||||
json_schema_extra={"label": "Mandant", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}},
|
||||
)
|
||||
instanceId: str = Field(
|
||||
description="Feature instance ID",
|
||||
json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
|
||||
)
|
||||
title: str = Field(description="Module title, e.g. 'Conflict with team lead'")
|
||||
description: Optional[str] = Field(default=None, description="Short description")
|
||||
moduleType: TrainingModuleType = Field(default=TrainingModuleType.COACHING)
|
||||
|
|
@ -84,7 +93,10 @@ class TrainingModule(PowerOnModel):
|
|||
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}]")
|
||||
metadata: Optional[str] = Field(default=None, description="JSON object with flexible metadata")
|
||||
personaId: Optional[str] = Field(default=None, description="Default persona for sessions")
|
||||
personaId: Optional[str] = Field(
|
||||
default=None, description="Default persona for sessions",
|
||||
json_schema_extra={"label": "Persona", "fk_target": {"db": "poweron_commcoach", "table": "CoachingPersona", "labelField": "label"}},
|
||||
)
|
||||
kpiTargets: Optional[str] = Field(default=None, description="JSON object with structured KPI targets")
|
||||
sessionCount: int = Field(default=0)
|
||||
taskCount: int = Field(default=0)
|
||||
|
|
@ -96,12 +108,27 @@ class TrainingModule(PowerOnModel):
|
|||
class CoachingSession(PowerOnModel):
|
||||
"""A single coaching conversation session within a module."""
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
moduleId: str = Field(description="FK to TrainingModule")
|
||||
userId: str = Field(description="Owner user ID")
|
||||
mandateId: str = Field(description="Mandate ID")
|
||||
instanceId: str = Field(description="Feature instance 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"}},
|
||||
)
|
||||
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)
|
||||
personaId: Optional[str] = Field(default=None, description="FK to CoachingPersona (Iteration 2)")
|
||||
personaId: Optional[str] = Field(
|
||||
default=None, description="FK to CoachingPersona",
|
||||
json_schema_extra={"label": "Persona", "fk_target": {"db": "poweron_commcoach", "table": "CoachingPersona", "labelField": "label"}},
|
||||
)
|
||||
summary: Optional[str] = Field(default=None, description="AI-generated session summary")
|
||||
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")
|
||||
|
|
@ -118,9 +145,18 @@ class CoachingSession(PowerOnModel):
|
|||
class CoachingMessage(PowerOnModel):
|
||||
"""A single message in a coaching session."""
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
sessionId: str = Field(description="FK to CoachingSession")
|
||||
moduleId: str = Field(description="FK to TrainingModule")
|
||||
userId: str = Field(description="Owner user ID")
|
||||
sessionId: str = Field(
|
||||
description="FK to CoachingSession",
|
||||
json_schema_extra={"label": "Session", "fk_target": {"db": "poweron_commcoach", "table": "CoachingSession", "labelField": None}},
|
||||
)
|
||||
moduleId: str = Field(
|
||||
description="FK to TrainingModule",
|
||||
json_schema_extra={"label": "Trainingsmodul", "fk_target": {"db": "poweron_commcoach", "table": "TrainingModule", "labelField": "title"}},
|
||||
)
|
||||
userId: str = Field(
|
||||
description="Owner user ID",
|
||||
json_schema_extra={"label": "Benutzer", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
|
||||
)
|
||||
role: CoachingMessageRole = Field(description="Message author role")
|
||||
content: str = Field(description="Message content (Markdown)")
|
||||
contentType: CoachingMessageContentType = Field(default=CoachingMessageContentType.TEXT)
|
||||
|
|
@ -131,10 +167,22 @@ class CoachingMessage(PowerOnModel):
|
|||
class CoachingTask(PowerOnModel):
|
||||
"""A task/checklist item assigned within a training module."""
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
moduleId: str = Field(description="FK to TrainingModule")
|
||||
sessionId: Optional[str] = Field(default=None, description="FK to originating session")
|
||||
userId: str = Field(description="Owner user ID")
|
||||
mandateId: str = Field(description="Mandate ID")
|
||||
moduleId: str = Field(
|
||||
description="FK to TrainingModule",
|
||||
json_schema_extra={"label": "Trainingsmodul", "fk_target": {"db": "poweron_commcoach", "table": "TrainingModule", "labelField": "title"}},
|
||||
)
|
||||
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")
|
||||
description: Optional[str] = Field(default=None)
|
||||
status: CoachingTaskStatus = Field(default=CoachingTaskStatus.OPEN)
|
||||
|
|
@ -146,10 +194,22 @@ class CoachingTask(PowerOnModel):
|
|||
class CoachingScore(PowerOnModel):
|
||||
"""A competence score for a dimension, recorded after a session."""
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
moduleId: str = Field(description="FK to TrainingModule")
|
||||
sessionId: str = Field(description="FK to CoachingSession")
|
||||
userId: str = Field(description="Owner user ID")
|
||||
mandateId: str = Field(description="Mandate ID")
|
||||
moduleId: str = Field(
|
||||
description="FK to TrainingModule",
|
||||
json_schema_extra={"label": "Trainingsmodul", "fk_target": {"db": "poweron_commcoach", "table": "TrainingModule", "labelField": "title"}},
|
||||
)
|
||||
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")
|
||||
score: float = Field(ge=0.0, le=100.0)
|
||||
trend: CoachingScoreTrend = Field(default=CoachingScoreTrend.STABLE)
|
||||
|
|
@ -159,9 +219,18 @@ class CoachingScore(PowerOnModel):
|
|||
class CoachingUserProfile(PowerOnModel):
|
||||
"""Per-user coaching profile and preferences."""
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
userId: str = Field(description="Owner user ID")
|
||||
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"}},
|
||||
)
|
||||
dailyReminderTime: Optional[str] = Field(default=None, description="HH:MM format")
|
||||
dailyReminderEnabled: bool = Field(default=False)
|
||||
emailSummaryEnabled: bool = Field(default=True)
|
||||
|
|
@ -179,9 +248,18 @@ class CoachingUserProfile(PowerOnModel):
|
|||
class CoachingPersona(PowerOnModel):
|
||||
"""A roleplay persona for coaching sessions."""
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
userId: str = Field(description="Owner user ID ('system' for builtins)")
|
||||
mandateId: Optional[str] = Field(default=None)
|
||||
instanceId: Optional[str] = Field(default=None)
|
||||
userId: str = Field(
|
||||
description="Owner user ID ('system' for builtins)",
|
||||
json_schema_extra={"label": "Benutzer", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username", "softFk": True}},
|
||||
)
|
||||
mandateId: Optional[str] = Field(
|
||||
default=None,
|
||||
json_schema_extra={"label": "Mandant", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}},
|
||||
)
|
||||
instanceId: Optional[str] = Field(
|
||||
default=None,
|
||||
json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
|
||||
)
|
||||
key: str = Field(description="Unique key, e.g. 'critical_cfo_f'")
|
||||
label: str = Field(description="Display label, e.g. 'Kritische CFO'")
|
||||
description: str = Field(description="Detailed role description for the AI")
|
||||
|
|
@ -198,9 +276,18 @@ class CoachingPersona(PowerOnModel):
|
|||
class ModulePersonaMapping(PowerOnModel):
|
||||
"""Maps which personas are available for a specific training module."""
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
moduleId: str = Field(description="FK to TrainingModule")
|
||||
personaId: str = Field(description="FK to CoachingPersona")
|
||||
instanceId: str = Field(description="Feature instance ID")
|
||||
moduleId: str = Field(
|
||||
description="FK to TrainingModule",
|
||||
json_schema_extra={"label": "Trainingsmodul", "fk_target": {"db": "poweron_commcoach", "table": "TrainingModule", "labelField": "title"}},
|
||||
)
|
||||
personaId: str = Field(
|
||||
description="FK to CoachingPersona",
|
||||
json_schema_extra={"label": "Persona", "fk_target": {"db": "poweron_commcoach", "table": "CoachingPersona", "labelField": "label"}},
|
||||
)
|
||||
instanceId: str = Field(
|
||||
description="Feature instance ID",
|
||||
json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
|
||||
)
|
||||
|
||||
|
||||
class SetModulePersonasRequest(BaseModel):
|
||||
|
|
@ -214,9 +301,18 @@ class SetModulePersonasRequest(BaseModel):
|
|||
class CoachingBadge(PowerOnModel):
|
||||
"""An achievement badge awarded to a user."""
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
userId: str = Field(description="Owner user ID")
|
||||
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"}},
|
||||
)
|
||||
badgeKey: str = Field(description="Badge identifier, e.g. 'streak_7'")
|
||||
awardedAt: Optional[float] = Field(default=None, json_schema_extra={"frontend_type": "timestamp"})
|
||||
|
||||
|
|
|
|||
|
|
@ -40,9 +40,15 @@ CONTEXT_NODES = [
|
|||
),
|
||||
"injectRunContext": True,
|
||||
"parameters": [
|
||||
<<<<<<< HEAD
|
||||
{"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder",
|
||||
"description": CONTEXT_BUILDER_PARAM_DESCRIPTION, "default": "",
|
||||
"graphInherit": {"port": 0, "kind": "primaryTextRef"}},
|
||||
=======
|
||||
{"name": "documentList", "type": "str", "required": True, "frontendType": "hidden",
|
||||
"description": t("Dokumentenliste (via Wire oder DataRef)"), "default": "",
|
||||
"graphInherit": {"port": 0, "kind": "documentListWire"}},
|
||||
>>>>>>> 513ded84d529502d07a04d199df3f873f263cff0
|
||||
{
|
||||
"name": "contentFilter",
|
||||
"type": "str",
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ class DataNeutraliserConfig(PowerOnModel):
|
|||
|
||||
|
||||
@i18nModel("Neutralisiertes Datenattribut")
|
||||
class DataNeutralizerAttributes(BaseModel):
|
||||
class DataNeutralizerAttributes(PowerOnModel):
|
||||
"""Zuordnung Originaltext zu Platzhalter fuer neutralisierte Daten."""
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
|
|
@ -152,7 +152,7 @@ class DataNeutralizerAttributes(BaseModel):
|
|||
|
||||
|
||||
@i18nModel("Neutralisierungs-Snapshot")
|
||||
class DataNeutralizationSnapshot(BaseModel):
|
||||
class DataNeutralizationSnapshot(PowerOnModel):
|
||||
"""Speichert den vollstaendigen neutralisierten Text (mit Platzhaltern) pro Quelle."""
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ class GeoPolylinie(BaseModel):
|
|||
|
||||
|
||||
@i18nModel("Dokument")
|
||||
class Dokument(BaseModel):
|
||||
class Dokument(PowerOnModel):
|
||||
"""Supporting data object for file and URL management with versioning."""
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
|
|
@ -204,7 +204,7 @@ class Kontext(PowerOnModel):
|
|||
)
|
||||
|
||||
|
||||
class Land(BaseModel):
|
||||
class Land(PowerOnModel):
|
||||
"""National level administrative entity."""
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
|
|
@ -265,15 +265,19 @@ class Kanton(PowerOnModel):
|
|||
)
|
||||
mandateId: str = Field(
|
||||
description="ID of the mandate",
|
||||
frontend_type="text",
|
||||
frontend_readonly=True,
|
||||
frontend_required=False,
|
||||
json_schema_extra={
|
||||
"frontend_type": "text", "frontend_readonly": True, "frontend_required": False,
|
||||
"label": "Mandant",
|
||||
"fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"},
|
||||
},
|
||||
)
|
||||
featureInstanceId: str = Field(
|
||||
description="ID of the feature instance",
|
||||
frontend_type="text",
|
||||
frontend_readonly=True,
|
||||
frontend_required=False,
|
||||
json_schema_extra={
|
||||
"frontend_type": "text", "frontend_readonly": True, "frontend_required": False,
|
||||
"label": "Feature-Instanz",
|
||||
"fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"},
|
||||
},
|
||||
)
|
||||
label: str = Field(
|
||||
description="Canton name (e.g. 'Zürich')",
|
||||
|
|
@ -314,7 +318,7 @@ class Kanton(PowerOnModel):
|
|||
)
|
||||
|
||||
|
||||
class Gemeinde(BaseModel):
|
||||
class Gemeinde(PowerOnModel):
|
||||
"""Municipal level administrative entity."""
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
|
|
|
|||
|
|
@ -102,12 +102,24 @@ class TeamsbotModuleStatus(str, Enum):
|
|||
class TeamsbotMeetingModule(PowerOnModel):
|
||||
"""A meeting module groups related sessions (e.g. 'Weekly Standup')."""
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Module ID")
|
||||
instanceId: str = Field(description="Feature instance ID (FK)")
|
||||
mandateId: str = Field(description="Mandate ID (FK)")
|
||||
ownerUserId: str = Field(description="Owner user ID")
|
||||
instanceId: str = Field(
|
||||
description="Feature instance ID",
|
||||
json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
|
||||
)
|
||||
mandateId: str = Field(
|
||||
description="Mandate ID",
|
||||
json_schema_extra={"label": "Mandant", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}},
|
||||
)
|
||||
ownerUserId: str = Field(
|
||||
description="Owner user ID",
|
||||
json_schema_extra={"label": "Besitzer", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
|
||||
)
|
||||
title: str = Field(description="Module title, e.g. 'Weekly Standup'")
|
||||
seriesType: TeamsbotSeriesType = Field(default=TeamsbotSeriesType.ADHOC)
|
||||
defaultBotId: Optional[str] = Field(default=None, description="FK to TeamsbotSystemBot")
|
||||
defaultBotId: Optional[str] = Field(
|
||||
default=None, description="FK to TeamsbotSystemBot",
|
||||
json_schema_extra={"label": "Standard-Bot", "fk_target": {"db": "poweron_teamsbot", "table": "TeamsbotSystemBot", "labelField": "name"}},
|
||||
)
|
||||
defaultDirectorPrompts: Optional[str] = Field(default=None, description="JSON list of default director prompts")
|
||||
goals: Optional[str] = Field(default=None, description="Free-text goals")
|
||||
kpiTargets: Optional[str] = Field(default=None, description="JSON object with structured KPI targets")
|
||||
|
|
@ -120,8 +132,8 @@ class TeamsbotMeetingModule(PowerOnModel):
|
|||
description="Default display name for the bot when starting a session from this module",
|
||||
)
|
||||
defaultAvatarFileId: Optional[str] = Field(
|
||||
default=None,
|
||||
description="FileItem ID for the default avatar image/video shown in the meeting",
|
||||
default=None, description="FileItem ID for the default avatar image/video shown in the meeting",
|
||||
json_schema_extra={"label": "Avatar-Datei", "fk_target": {"db": "poweron_management", "table": "FileItem", "labelField": "fileName"}},
|
||||
)
|
||||
status: TeamsbotModuleStatus = Field(default=TeamsbotModuleStatus.ACTIVE)
|
||||
|
||||
|
|
@ -129,15 +141,27 @@ class TeamsbotMeetingModule(PowerOnModel):
|
|||
class TeamsbotSession(PowerOnModel):
|
||||
"""A Teams Bot meeting session."""
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Session ID")
|
||||
instanceId: str = Field(description="Feature instance ID (FK)")
|
||||
mandateId: str = Field(description="Mandate ID (FK)")
|
||||
moduleId: Optional[str] = Field(default=None, description="FK to TeamsbotMeetingModule (nullable during transition)")
|
||||
instanceId: str = Field(
|
||||
description="Feature instance ID",
|
||||
json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
|
||||
)
|
||||
mandateId: str = Field(
|
||||
description="Mandate ID",
|
||||
json_schema_extra={"label": "Mandant", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}},
|
||||
)
|
||||
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")
|
||||
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")
|
||||
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"})
|
||||
startedByUserId: str = Field(description="User ID who started the session")
|
||||
startedByUserId: str = Field(
|
||||
description="User ID who started the session",
|
||||
json_schema_extra={"label": "Gestartet von", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
|
||||
)
|
||||
bridgeSessionId: Optional[str] = Field(default=None, description="Session ID on the .NET Media Bridge")
|
||||
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")
|
||||
|
|
@ -150,7 +174,10 @@ class TeamsbotSession(PowerOnModel):
|
|||
class TeamsbotTranscript(PowerOnModel):
|
||||
"""A single transcript segment from the meeting."""
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Transcript segment ID")
|
||||
sessionId: str = Field(description="Session ID (FK)")
|
||||
sessionId: str = Field(
|
||||
description="FK to TeamsbotSession",
|
||||
json_schema_extra={"label": "Session", "fk_target": {"db": "poweron_teamsbot", "table": "TeamsbotSession", "labelField": "botName"}},
|
||||
)
|
||||
speaker: Optional[str] = Field(default=None, description="Speaker name or identifier")
|
||||
text: str = Field(description="Transcribed text")
|
||||
timestamp: float = Field(description="UTC unix timestamp of the speech segment", json_schema_extra={"frontend_type": "timestamp"})
|
||||
|
|
@ -163,12 +190,18 @@ class TeamsbotTranscript(PowerOnModel):
|
|||
class TeamsbotBotResponse(PowerOnModel):
|
||||
"""A bot response generated during a meeting session."""
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Response ID")
|
||||
sessionId: str = Field(description="Session ID (FK)")
|
||||
sessionId: str = Field(
|
||||
description="FK to TeamsbotSession",
|
||||
json_schema_extra={"label": "Session", "fk_target": {"db": "poweron_teamsbot", "table": "TeamsbotSession", "labelField": "botName"}},
|
||||
)
|
||||
responseText: str = Field(description="The bot's response text")
|
||||
responseType: TeamsbotResponseType = Field(default=TeamsbotResponseType.BOTH, description="How the response was delivered")
|
||||
detectedIntent: TeamsbotDetectedIntent = Field(default=TeamsbotDetectedIntent.NONE, description="What triggered the response")
|
||||
reasoning: Optional[str] = Field(default=None, description="AI reasoning for why it responded")
|
||||
triggeredByTranscriptId: Optional[str] = Field(default=None, description="Transcript segment that triggered this response")
|
||||
triggeredByTranscriptId: Optional[str] = Field(
|
||||
default=None, description="Transcript segment that triggered this response",
|
||||
json_schema_extra={"label": "Ausgelöst durch", "fk_target": {"db": "poweron_teamsbot", "table": "TeamsbotTranscript", "labelField": None}},
|
||||
)
|
||||
modelName: Optional[str] = Field(default=None, description="AI model used for this response")
|
||||
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")
|
||||
|
|
@ -184,7 +217,10 @@ class TeamsbotSystemBot(PowerOnModel):
|
|||
Credentials are stored encrypted in the database, NOT in the UI-visible config.
|
||||
Only mandate admins can manage system bots."""
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="System bot ID")
|
||||
mandateId: str = Field(description="Mandate ID (FK) - bots are scoped to mandates")
|
||||
mandateId: str = Field(
|
||||
description="Mandate ID - bots are scoped to mandates",
|
||||
json_schema_extra={"label": "Mandant", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}},
|
||||
)
|
||||
name: str = Field(description="Display name (e.g. 'Nyla Larsson')")
|
||||
email: str = Field(description="Microsoft account email")
|
||||
encryptedPassword: str = Field(description="Encrypted Microsoft account password")
|
||||
|
|
@ -200,8 +236,14 @@ class TeamsbotUserAccount(PowerOnModel):
|
|||
Each user can store their own MS credentials per mandate.
|
||||
Password is encrypted; on login only MFA confirmation is needed."""
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Record ID")
|
||||
userId: str = Field(description="Poweron user ID (FK)")
|
||||
mandateId: str = Field(description="Mandate ID (FK)")
|
||||
userId: str = Field(
|
||||
description="Poweron user ID",
|
||||
json_schema_extra={"label": "Benutzer", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
|
||||
)
|
||||
mandateId: str = Field(
|
||||
description="Mandate ID",
|
||||
json_schema_extra={"label": "Mandant", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}},
|
||||
)
|
||||
email: str = Field(description="Microsoft account email")
|
||||
encryptedPassword: str = Field(description="Encrypted Microsoft account password")
|
||||
displayName: Optional[str] = Field(default=None, description="Display name derived from MS account")
|
||||
|
|
@ -216,8 +258,14 @@ class TeamsbotUserSettings(PowerOnModel):
|
|||
Each user has their own settings per feature instance.
|
||||
These override the instance-level defaults (TeamsbotConfig)."""
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Settings ID")
|
||||
userId: str = Field(description="User ID (FK)")
|
||||
instanceId: str = Field(description="Feature instance ID (FK)")
|
||||
userId: str = Field(
|
||||
description="User ID",
|
||||
json_schema_extra={"label": "Benutzer", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
|
||||
)
|
||||
instanceId: str = Field(
|
||||
description="Feature instance ID",
|
||||
json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
|
||||
)
|
||||
botName: Optional[str] = Field(default=None, description="Bot display name override")
|
||||
aiSystemPrompt: Optional[str] = Field(default=None, description="AI system prompt override")
|
||||
responseMode: Optional[str] = Field(default=None, description="Response mode override: auto, manual, transcribeOnly")
|
||||
|
|
@ -229,7 +277,10 @@ class TeamsbotUserSettings(PowerOnModel):
|
|||
triggerCooldownSeconds: Optional[int] = Field(default=None, description="Trigger cooldown override")
|
||||
contextWindowSegments: Optional[int] = Field(default=None, description="Context window override")
|
||||
debugMode: Optional[bool] = Field(default=None, description="Debug mode override")
|
||||
avatarFileId: Optional[str] = Field(default=None, description="FileItem ID for bot avatar image/video override")
|
||||
avatarFileId: Optional[str] = Field(
|
||||
default=None, description="FileItem ID for bot avatar image/video override",
|
||||
json_schema_extra={"label": "Avatar-Datei", "fk_target": {"db": "poweron_management", "table": "FileItem", "labelField": "fileName"}},
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
|
@ -382,9 +433,18 @@ class TeamsbotDirectorPrompt(PowerOnModel):
|
|||
meeting participants.
|
||||
"""
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Director prompt ID")
|
||||
sessionId: str = Field(description="Teams Bot session ID (FK)")
|
||||
instanceId: str = Field(description="Feature instance ID (FK)")
|
||||
operatorUserId: str = Field(description="User ID of the operator who issued the prompt")
|
||||
sessionId: str = Field(
|
||||
description="FK to TeamsbotSession",
|
||||
json_schema_extra={"label": "Session", "fk_target": {"db": "poweron_teamsbot", "table": "TeamsbotSession", "labelField": "botName"}},
|
||||
)
|
||||
instanceId: str = Field(
|
||||
description="Feature instance ID",
|
||||
json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
|
||||
)
|
||||
operatorUserId: str = Field(
|
||||
description="User ID of the operator who issued the prompt",
|
||||
json_schema_extra={"label": "Operator", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
|
||||
)
|
||||
text: str = Field(description="The director instruction text", max_length=DIRECTOR_PROMPT_TEXT_LIMIT)
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -796,7 +796,7 @@ class TeamsbotService:
|
|||
import base64
|
||||
from modules.interfaces import interfaceDbManagement
|
||||
try:
|
||||
mgmt = interfaceDbManagement.getInterface(self.currentUser, self.mandateId)
|
||||
mgmt = interfaceDbManagement.getInterface(self.currentUser, self.mandateId, featureInstanceId=self.instanceId)
|
||||
fileRecord = mgmt.getFile(fileId)
|
||||
if not fileRecord:
|
||||
logger.warning(f"Avatar file {fileId} not found")
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
SysAdmin API for database table statistics and FK orphan detection/cleanup.
|
||||
SysAdmin API for database table statistics, FK orphan detection/cleanup,
|
||||
and database migration (backup / restore).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from modules.auth import limiter
|
||||
|
|
@ -17,11 +20,23 @@ from modules.system.databaseHealth import (
|
|||
OrphanCleanupRefused,
|
||||
_cleanAllOrphans,
|
||||
_cleanOrphans,
|
||||
_discoverLegacyTables,
|
||||
_dropLegacyTable,
|
||||
_getTableStats,
|
||||
_isUserIdFk,
|
||||
_listOrphans,
|
||||
_scanOrphans,
|
||||
)
|
||||
from modules.system.databaseMigration import (
|
||||
_exportDatabases,
|
||||
_exportSingleDb,
|
||||
_getAvailableDatabases,
|
||||
_getInstanceLabel,
|
||||
_importDatabases,
|
||||
_importSingleDb,
|
||||
_prepareImport,
|
||||
_validateImportPayload,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -194,3 +209,531 @@ def postDatabaseOrphansCleanAll(
|
|||
excludeUserFks,
|
||||
)
|
||||
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}
|
||||
|
|
|
|||
|
|
@ -496,7 +496,7 @@ def _getDataSourceCostEstimate(
|
|||
|
||||
Uses the current effective ragLimits (DataSource.settings.ragLimits with
|
||||
fallback to centralized defaults) as the basis. Returns the same
|
||||
`{estimatedTokens, estimatedUsd, basis}` shape regardless of source kind.
|
||||
`{estimatedTokens, estimatedChf, basis}` shape regardless of source kind.
|
||||
"""
|
||||
try:
|
||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||
|
|
|
|||
|
|
@ -3,15 +3,17 @@
|
|||
"""Indicative cost estimation for a RAG bootstrap run.
|
||||
|
||||
This is **not** a billing-grade forecast: it gives the user a back-of-the-envelope
|
||||
USD figure for the worst-case full sync, so they can sanity-check before raising
|
||||
CHF figure for the worst-case full sync, so they can sanity-check before raising
|
||||
`maxBytes`/`maxItems`. The output always carries the underlying assumptions
|
||||
(`basis`) so the user can judge plausibility.
|
||||
|
||||
Heuristic:
|
||||
estimatedTokens = ceil(maxBytes / CHARS_PER_TOKEN_BYTES_FACTOR)
|
||||
estimatedUsd = estimatedTokens / 1_000_000 * EMBEDDING_USD_PER_MTOKEN
|
||||
estimatedChf = estimatedTokens / 1_000_000 * EMBEDDING_CHF_PER_MTOKEN
|
||||
|
||||
Defaults match OpenAI `text-embedding-3-small` pricing (2026-Q2).
|
||||
Defaults match OpenAI `text-embedding-3-small` published pricing (2026-Q2);
|
||||
the project convention treats provider list prices as CHF directly (see
|
||||
`calculatepriceCHF` in `aicorePluginOpenai.py`), so no FX conversion applies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -21,7 +23,7 @@ from typing import Any, Dict
|
|||
|
||||
|
||||
CHARS_PER_TOKEN = 4
|
||||
EMBEDDING_USD_PER_MTOKEN = 0.02
|
||||
EMBEDDING_CHF_PER_MTOKEN = 0.02
|
||||
DEFAULT_TOKENS_PER_ITEM = 1500
|
||||
BYTES_PER_TOKEN_TEXT_FACTOR = 4
|
||||
EXTRACTABLE_FRACTION = 0.4
|
||||
|
|
@ -34,12 +36,12 @@ def estimateBootstrapCost(limits: Dict[str, int], kind: str = "files") -> Dict[s
|
|||
|
||||
{
|
||||
"estimatedTokens": int,
|
||||
"estimatedUsd": float, # rounded to 4 decimals
|
||||
"estimatedChf": float, # rounded to 4 decimals
|
||||
"basis": {
|
||||
"kind": "files"|"clickup",
|
||||
"limits": {...},
|
||||
"assumptions": {
|
||||
"embeddingUsdPerMToken": 0.02,
|
||||
"embeddingChfPerMToken": 0.02,
|
||||
"charsPerToken": 4,
|
||||
"extractableFraction": 0.4,
|
||||
"tokensPerItem": 1500 # only for clickup-like item counts
|
||||
|
|
@ -49,7 +51,7 @@ def estimateBootstrapCost(limits: Dict[str, int], kind: str = "files") -> Dict[s
|
|||
}
|
||||
"""
|
||||
assumptions: Dict[str, Any] = {
|
||||
"embeddingUsdPerMToken": EMBEDDING_USD_PER_MTOKEN,
|
||||
"embeddingChfPerMToken": EMBEDDING_CHF_PER_MTOKEN,
|
||||
"charsPerToken": CHARS_PER_TOKEN,
|
||||
}
|
||||
|
||||
|
|
@ -69,11 +71,11 @@ def estimateBootstrapCost(limits: Dict[str, int], kind: str = "files") -> Dict[s
|
|||
estimatedTokens = 0
|
||||
assumptions["formula"] = "unknown kind, returning zero"
|
||||
|
||||
estimatedUsd = round(estimatedTokens / 1_000_000 * EMBEDDING_USD_PER_MTOKEN, 4)
|
||||
estimatedChf = round(estimatedTokens / 1_000_000 * EMBEDDING_CHF_PER_MTOKEN, 4)
|
||||
|
||||
return {
|
||||
"estimatedTokens": estimatedTokens,
|
||||
"estimatedUsd": estimatedUsd,
|
||||
"estimatedChf": estimatedChf,
|
||||
"basis": {
|
||||
"kind": kind,
|
||||
"limits": dict(limits),
|
||||
|
|
|
|||
|
|
@ -216,9 +216,9 @@ def _archiveOtherRecurringPrices(
|
|||
stripe.Price.modify(p.id, active=False)
|
||||
logger.info("Archived stale Stripe Price %s on product %s", p.id, productId)
|
||||
except Exception as ex:
|
||||
logger.warning("Could not archive price %s: %s", p.id, ex)
|
||||
logger.debug("Could not archive price %s: %s", p.id, ex)
|
||||
except Exception as e:
|
||||
logger.warning("Stale price archive pass failed for product %s: %s", productId, e)
|
||||
logger.debug("Stale price archive pass skipped for product %s: %s", productId, e)
|
||||
|
||||
|
||||
def _validateStripeIdsExist(stripe, mapping: StripePlanPrice) -> bool:
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ class EventManagement:
|
|||
def stop(self) -> None:
|
||||
if self._scheduler and self._scheduler.running:
|
||||
try:
|
||||
self._scheduler.remove_all_jobs()
|
||||
self._scheduler.shutdown(wait=False)
|
||||
logger.info("EventManagement scheduler stopped")
|
||||
except Exception as exc:
|
||||
|
|
|
|||
|
|
@ -790,3 +790,98 @@ def _jsonSafe(v):
|
|||
except Exception:
|
||||
return repr(v)
|
||||
return str(v)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Legacy table discovery + drop
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _discoverLegacyTables(dbFilter: Optional[str] = None) -> List[dict]:
|
||||
"""Find tables that exist in the DB but have no entry in MODEL_REGISTRY.
|
||||
|
||||
A table is legacy if its name does NOT match any PowerOnModel class.
|
||||
Tables that exist in multiple DBs (shared-table pattern) are NOT flagged
|
||||
as legacy -- the connector creates them wherever code writes that model.
|
||||
|
||||
Returns a list of dicts: {db, table, rowCount, sizeBytes}.
|
||||
"""
|
||||
from modules.datamodels.datamodelBase import MODEL_REGISTRY
|
||||
from modules.shared.fkRegistry import _ensureModelsLoaded
|
||||
|
||||
_ensureModelsLoaded()
|
||||
registeredDbs = getRegisteredDatabases()
|
||||
results: List[dict] = []
|
||||
|
||||
for dbName in sorted(registeredDbs.keys()):
|
||||
if dbFilter and dbName != dbFilter:
|
||||
continue
|
||||
try:
|
||||
conn = _getConnection(dbName)
|
||||
except Exception as e:
|
||||
logger.warning("Legacy scan: cannot connect to %s: %s", dbName, e)
|
||||
continue
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT c.relname AS table_name,
|
||||
c.reltuples::bigint AS row_estimate,
|
||||
pg_total_relation_size(c.oid) AS size_bytes
|
||||
FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = 'public'
|
||||
AND c.relkind = 'r'
|
||||
AND c.relname NOT LIKE '\\_%'
|
||||
ORDER BY c.relname
|
||||
""")
|
||||
for row in cur.fetchall():
|
||||
tblName = row["table_name"]
|
||||
if tblName not in MODEL_REGISTRY:
|
||||
results.append({
|
||||
"db": dbName,
|
||||
"table": tblName,
|
||||
"rowCount": max(0, int(row["row_estimate"])),
|
||||
"sizeBytes": int(row["size_bytes"]),
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _dropLegacyTable(dbName: str, tableName: str) -> dict:
|
||||
"""Drop a single legacy table after verifying it is NOT in MODEL_REGISTRY.
|
||||
|
||||
Returns {db, table, dropped, rowCount}.
|
||||
Raises ValueError if the table is model-backed (safety guard).
|
||||
"""
|
||||
from modules.datamodels.datamodelBase import MODEL_REGISTRY
|
||||
from modules.shared.fkRegistry import _ensureModelsLoaded
|
||||
|
||||
_ensureModelsLoaded()
|
||||
if tableName in MODEL_REGISTRY:
|
||||
raise ValueError(
|
||||
f"Table '{dbName}.{tableName}' is backed by a Pydantic model and cannot be dropped via legacy cleanup."
|
||||
)
|
||||
|
||||
conn = _getConnection(dbName)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT reltuples::bigint AS row_estimate
|
||||
FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = 'public' AND c.relname = %s
|
||||
""", (tableName,))
|
||||
row = cur.fetchone()
|
||||
rowCount = max(0, int(row["row_estimate"])) if row else 0
|
||||
|
||||
cur.execute(f'DROP TABLE IF EXISTS "{tableName}" CASCADE')
|
||||
conn.commit()
|
||||
logger.info("Dropped legacy table %s.%s (%d rows)", dbName, tableName, rowCount)
|
||||
return {"db": dbName, "table": tableName, "dropped": True, "rowCount": rowCount}
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.error("Failed to drop legacy table %s.%s: %s", dbName, tableName, e)
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
|
|
|||
816
modules/system/databaseMigration.py
Normal file
816
modules/system/databaseMigration.py
Normal file
|
|
@ -0,0 +1,816 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Database migration utilities — backup (export) and restore (import) for all
|
||||
registered PowerOn databases.
|
||||
|
||||
System objects (root mandate, admin user, event user) are protected: they are
|
||||
never deleted or overwritten during import. Their IDs in the backup payload
|
||||
are remapped to the IDs of the corresponding live objects so that all FK
|
||||
references stay consistent.
|
||||
|
||||
All functions are intended for SysAdmin use only (access control in the route layer).
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
|
||||
from modules.shared.configuration import APP_CONFIG
|
||||
from modules.shared.dbRegistry import getRegisteredDatabases
|
||||
from modules.shared.fkRegistry import getFkRelationships
|
||||
from modules.datamodels.datamodelBase import MODEL_REGISTRY
|
||||
from modules.system.databaseHealth import _getConnection, _jsonSafe
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_EXPORT_FORMAT_VERSION = "1.0"
|
||||
_SYSTEM_TABLE = "_system"
|
||||
|
||||
_EXCLUDED_TABLES: Dict[str, Set[str]] = {
|
||||
"poweron_app": {"Token", "AuthEvent"},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Instance label
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _getInstanceLabel() -> str:
|
||||
"""Return the instance type from APP_ENV_TYPE (e.g. 'dev', 'int', 'prod')."""
|
||||
return APP_CONFIG.get("APP_ENV_TYPE", "unknown")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Database list
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _getAvailableDatabases() -> List[dict]:
|
||||
"""Return registered databases with table/row counts for the UI."""
|
||||
registeredDbs = getRegisteredDatabases()
|
||||
results: List[dict] = []
|
||||
for dbName in sorted(registeredDbs):
|
||||
if dbName == "poweron_test":
|
||||
continue
|
||||
entry: dict = {"name": dbName, "tableCount": 0, "recordCount": 0}
|
||||
try:
|
||||
conn = _getConnection(dbName)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT relname, n_live_tup
|
||||
FROM pg_stat_user_tables
|
||||
WHERE schemaname = 'public'
|
||||
AND relname NOT LIKE '\\_%%'
|
||||
""")
|
||||
for row in cur.fetchall():
|
||||
entry["tableCount"] += 1
|
||||
entry["recordCount"] += int(row["n_live_tup"])
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.warning("Could not stat database %s: %s", dbName, e)
|
||||
results.append(entry)
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Export
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _exportDatabases(databases: List[str]) -> dict:
|
||||
"""Export selected databases as a JSON-serialisable dict.
|
||||
|
||||
Returns ``{meta: {...}, databases: {dbName: {tables: {tbl: [rows]}, summary: {...}}}}``
|
||||
"""
|
||||
registeredDbs = getRegisteredDatabases()
|
||||
|
||||
if not databases:
|
||||
raise ValueError("No databases selected for export.")
|
||||
|
||||
exportData: dict = {
|
||||
"meta": {
|
||||
"exportedAt": datetime.now(timezone.utc).isoformat(),
|
||||
"version": _EXPORT_FORMAT_VERSION,
|
||||
"databaseCount": 0,
|
||||
"totalTables": 0,
|
||||
"totalRecords": 0,
|
||||
},
|
||||
"databases": {},
|
||||
}
|
||||
|
||||
for dbName in databases:
|
||||
if dbName not in registeredDbs:
|
||||
logger.warning("Export: skipping unregistered database %s", dbName)
|
||||
continue
|
||||
try:
|
||||
dbPayload = _exportSingleDb(dbName)
|
||||
exportData["databases"][dbName] = dbPayload
|
||||
exportData["meta"]["databaseCount"] += 1
|
||||
exportData["meta"]["totalTables"] += dbPayload["tableCount"]
|
||||
exportData["meta"]["totalRecords"] += dbPayload["totalRecords"]
|
||||
except Exception as e:
|
||||
logger.error("Export failed for database %s: %s", dbName, e)
|
||||
|
||||
return exportData
|
||||
|
||||
|
||||
def _getModelTablesForDb(dbName: str, physicalTables: List[str]) -> List[str]:
|
||||
"""Return only those physical tables that have a matching Pydantic model
|
||||
registered in MODEL_REGISTRY.
|
||||
|
||||
Tables without a Pydantic class (legacy / orphan tables) are excluded
|
||||
from export so the backup contains only model-backed data.
|
||||
|
||||
Note: the same model can exist in multiple databases (shared-table
|
||||
pattern), so we only check membership in MODEL_REGISTRY, not the
|
||||
DB mapping.
|
||||
"""
|
||||
return sorted(
|
||||
t for t in physicalTables
|
||||
if t in MODEL_REGISTRY
|
||||
)
|
||||
|
||||
|
||||
def _exportSingleDb(dbName: str) -> dict:
|
||||
conn = _getConnection(dbName)
|
||||
excluded = _EXCLUDED_TABLES.get(dbName, set())
|
||||
try:
|
||||
allTables = _listTables(conn)
|
||||
modelTables = _getModelTablesForDb(dbName, allTables)
|
||||
skippedLegacy = set(allTables) - set(modelTables) - excluded - {_SYSTEM_TABLE}
|
||||
if skippedLegacy:
|
||||
logger.info("Export %s: skipping %d legacy tables without model: %s",
|
||||
dbName, len(skippedLegacy), sorted(skippedLegacy))
|
||||
|
||||
dbPayload: dict = {"tables": {}, "summary": {}, "tableCount": 0, "totalRecords": 0}
|
||||
for tbl in modelTables:
|
||||
if tbl in excluded:
|
||||
logger.info("Export: skipping excluded table %s.%s", dbName, tbl)
|
||||
continue
|
||||
rows = _readTableRows(conn, tbl)
|
||||
dbPayload["tables"][tbl] = rows
|
||||
dbPayload["summary"][tbl] = {"recordCount": len(rows)}
|
||||
dbPayload["tableCount"] += 1
|
||||
dbPayload["totalRecords"] += len(rows)
|
||||
return dbPayload
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _listTables(conn) -> List[str]:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_type = 'BASE TABLE'
|
||||
AND table_name != %s
|
||||
ORDER BY table_name
|
||||
""", (_SYSTEM_TABLE,))
|
||||
return [row["table_name"] for row in cur.fetchall()]
|
||||
|
||||
|
||||
def _readTableRows(conn, tableName: str) -> List[dict]:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f'SELECT * FROM "{tableName}"')
|
||||
return [{k: _jsonSafe(v) for k, v in dict(row).items()} for row in cur.fetchall()]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _validateImportPayload(payload: dict) -> dict:
|
||||
"""Validate an import payload without writing anything.
|
||||
|
||||
Returns ``{valid, summary, warnings, systemObjectsFound}``.
|
||||
"""
|
||||
warnings: List[str] = []
|
||||
summary: List[dict] = []
|
||||
|
||||
meta = payload.get("meta")
|
||||
if not meta or not isinstance(meta, dict):
|
||||
return {"valid": False, "summary": [], "warnings": ["Fehlende oder ungueltige 'meta'-Sektion"], "systemObjectsFound": []}
|
||||
|
||||
version = meta.get("version", "")
|
||||
if version != _EXPORT_FORMAT_VERSION:
|
||||
warnings.append(f"Unbekannte Format-Version: {version} (erwartet: {_EXPORT_FORMAT_VERSION})")
|
||||
|
||||
databases = payload.get("databases")
|
||||
if not databases or not isinstance(databases, dict):
|
||||
return {"valid": False, "summary": [], "warnings": ["Fehlende oder ungueltige 'databases'-Sektion"], "systemObjectsFound": []}
|
||||
|
||||
registeredDbs = getRegisteredDatabases()
|
||||
|
||||
for dbName, dbData in databases.items():
|
||||
tables = dbData.get("tables", {})
|
||||
tableCount = len(tables)
|
||||
recordCount = sum(len(rows) for rows in tables.values() if isinstance(rows, list))
|
||||
registered = dbName in registeredDbs
|
||||
if not registered:
|
||||
warnings.append(f"Datenbank '{dbName}' ist nicht registriert und wird uebersprungen")
|
||||
summary.append({
|
||||
"database": dbName,
|
||||
"tableCount": tableCount,
|
||||
"recordCount": recordCount,
|
||||
"registered": registered,
|
||||
})
|
||||
|
||||
systemObjectsFound = _detectSystemObjectsInPayload(payload)
|
||||
|
||||
valid = any(s["registered"] for s in summary)
|
||||
return {
|
||||
"valid": valid,
|
||||
"summary": summary,
|
||||
"warnings": warnings,
|
||||
"systemObjectsFound": systemObjectsFound,
|
||||
}
|
||||
|
||||
|
||||
def _detectSystemObjectsInPayload(payload: dict) -> List[dict]:
|
||||
"""Find system objects (root mandate, admin user, event user) in a payload."""
|
||||
found: List[dict] = []
|
||||
appData = payload.get("databases", {}).get("poweron_app", {}).get("tables", {})
|
||||
|
||||
for row in appData.get("Mandate", []):
|
||||
if row.get("name") == "root" and row.get("isSystem") is True:
|
||||
found.append({"type": "mandate", "label": "Root Mandate", "payloadId": row.get("id")})
|
||||
|
||||
for row in appData.get("UserInDB", []):
|
||||
if row.get("username") == "admin":
|
||||
found.append({"type": "user", "label": "Admin User", "payloadId": row.get("id")})
|
||||
elif row.get("username") == "event":
|
||||
found.append({"type": "user", "label": "Event User", "payloadId": row.get("id")})
|
||||
|
||||
return found
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# System-object ID remapping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _loadLiveSystemObjectIds() -> Dict[str, str]:
|
||||
"""Load the IDs of the 3 protected system objects from the live DB.
|
||||
|
||||
Returns a dict like ``{"rootMandate": "<uuid>", "adminUser": "<uuid>", "eventUser": "<uuid>"}``.
|
||||
"""
|
||||
registeredDbs = getRegisteredDatabases()
|
||||
if "poweron_app" not in registeredDbs:
|
||||
return {}
|
||||
|
||||
result: Dict[str, str] = {}
|
||||
conn = _getConnection("poweron_app")
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""SELECT id FROM "Mandate" WHERE "name" = 'root' AND "isSystem" = true LIMIT 1""")
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
result["rootMandate"] = str(row["id"])
|
||||
|
||||
cur.execute("""SELECT id FROM "UserInDB" WHERE "username" = 'admin' LIMIT 1""")
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
result["adminUser"] = str(row["id"])
|
||||
|
||||
cur.execute("""SELECT id FROM "UserInDB" WHERE "username" = 'event' LIMIT 1""")
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
result["eventUser"] = str(row["id"])
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _buildIdRemapFromPayload(payload: dict, liveIds: Dict[str, str]) -> Dict[str, str]:
|
||||
"""Build an ``{oldId: newId}`` mapping for system objects.
|
||||
|
||||
Compares IDs found in the payload with the live system-object IDs.
|
||||
Only entries where the IDs actually differ are included.
|
||||
"""
|
||||
remap: Dict[str, str] = {}
|
||||
appTables = payload.get("databases", {}).get("poweron_app", {}).get("tables", {})
|
||||
|
||||
for row in appTables.get("Mandate", []):
|
||||
if row.get("name") == "root" and row.get("isSystem") is True:
|
||||
oldId = str(row.get("id", ""))
|
||||
newId = liveIds.get("rootMandate", "")
|
||||
if oldId and newId and oldId != newId:
|
||||
remap[oldId] = newId
|
||||
|
||||
for row in appTables.get("UserInDB", []):
|
||||
username = row.get("username")
|
||||
oldId = str(row.get("id", ""))
|
||||
if username == "admin":
|
||||
newId = liveIds.get("adminUser", "")
|
||||
elif username == "event":
|
||||
newId = liveIds.get("eventUser", "")
|
||||
else:
|
||||
continue
|
||||
if oldId and newId and oldId != newId:
|
||||
remap[oldId] = newId
|
||||
|
||||
return remap
|
||||
|
||||
|
||||
def _remapSystemObjectIds(payload: dict, remap: Dict[str, str]) -> dict:
|
||||
"""Walk the entire payload and replace every value that matches an old system-object ID."""
|
||||
if not remap:
|
||||
return payload
|
||||
|
||||
remapSet = set(remap.keys())
|
||||
|
||||
databases = payload.get("databases", {})
|
||||
for dbName, dbData in databases.items():
|
||||
tables = dbData.get("tables", {})
|
||||
for tableName, rows in tables.items():
|
||||
if not isinstance(rows, list):
|
||||
continue
|
||||
for row in rows:
|
||||
_remapRowValues(row, remap, remapSet)
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def _remapDbTables(tables: dict, remap: Dict[str, str]) -> None:
|
||||
"""In-place remap system-object IDs in a single DB's tables dict."""
|
||||
if not remap:
|
||||
return
|
||||
remapSet = set(remap.keys())
|
||||
for tableName, rows in tables.items():
|
||||
if not isinstance(rows, list):
|
||||
continue
|
||||
for row in rows:
|
||||
_remapRowValues(row, remap, remapSet)
|
||||
|
||||
|
||||
def _remapRowValues(row: dict, remap: Dict[str, str], remapSet: Set[str]) -> None:
|
||||
"""In-place replace string values in a row dict that match a remap key."""
|
||||
for key, val in row.items():
|
||||
if isinstance(val, str) and val in remapSet:
|
||||
row[key] = remap[val]
|
||||
elif isinstance(val, dict):
|
||||
_remapRowValues(val, remap, remapSet)
|
||||
elif isinstance(val, list):
|
||||
for i, item in enumerate(val):
|
||||
if isinstance(item, str) and item in remapSet:
|
||||
val[i] = remap[item]
|
||||
elif isinstance(item, dict):
|
||||
_remapRowValues(item, remap, remapSet)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Import
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PROTECTED_ROWS: Dict[str, List[dict]] = {
|
||||
"Mandate": [{"name": "root", "isSystem": True}],
|
||||
"UserInDB": [{"username": "admin"}, {"username": "event"}],
|
||||
}
|
||||
|
||||
|
||||
def _isProtectedRow(tableName: str, row: dict) -> bool:
|
||||
"""Return True if a row represents a protected system object."""
|
||||
patterns = _PROTECTED_ROWS.get(tableName, [])
|
||||
for pattern in patterns:
|
||||
if all(row.get(k) == v for k, v in pattern.items()):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _importDatabases(payload: dict, mode: str) -> dict:
|
||||
"""Import databases from a validated payload.
|
||||
|
||||
``mode`` is ``"replace"`` (clear + insert) or ``"merge"`` (insert missing only).
|
||||
"""
|
||||
if mode not in ("replace", "merge"):
|
||||
raise ValueError(f"Invalid import mode: {mode}")
|
||||
|
||||
registeredDbs = getRegisteredDatabases()
|
||||
|
||||
liveIds = _loadLiveSystemObjectIds()
|
||||
remap = _buildIdRemapFromPayload(payload, liveIds)
|
||||
if remap:
|
||||
logger.info("System-object ID remap: %s", remap)
|
||||
_remapSystemObjectIds(payload, remap)
|
||||
|
||||
protectedIdSet = set(liveIds.values())
|
||||
|
||||
imported: Dict[str, dict] = {}
|
||||
warnings: List[str] = []
|
||||
databases = payload.get("databases", {})
|
||||
|
||||
for dbName, dbData in databases.items():
|
||||
if dbName not in registeredDbs:
|
||||
warnings.append(f"Datenbank '{dbName}' uebersprungen (nicht registriert)")
|
||||
continue
|
||||
|
||||
tables = dbData.get("tables", {})
|
||||
dbResult: Dict[str, int] = {}
|
||||
|
||||
conn = _getConnection(dbName)
|
||||
try:
|
||||
conn.autocommit = False
|
||||
existingTables = set(_listTables(conn))
|
||||
|
||||
for tableName, rows in tables.items():
|
||||
if not isinstance(rows, list):
|
||||
continue
|
||||
if tableName not in existingTables:
|
||||
warnings.append(f"Tabelle '{dbName}.{tableName}' existiert nicht, uebersprungen")
|
||||
continue
|
||||
|
||||
physicalCols = _getPhysicalColumns(conn, tableName)
|
||||
if not physicalCols:
|
||||
continue
|
||||
|
||||
filteredRows = []
|
||||
for row in rows:
|
||||
if _isProtectedRow(tableName, row):
|
||||
continue
|
||||
if row.get("id") and str(row["id"]) in protectedIdSet:
|
||||
continue
|
||||
filteredRows.append(row)
|
||||
|
||||
if mode == "replace":
|
||||
_deleteNonProtected(conn, tableName, protectedIdSet)
|
||||
|
||||
insertedCount = _insertRows(conn, tableName, filteredRows, physicalCols, mode)
|
||||
dbResult[tableName] = insertedCount
|
||||
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.error("Import failed for database %s: %s", dbName, e)
|
||||
warnings.append(f"Import fuer '{dbName}' fehlgeschlagen: {e}")
|
||||
continue
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
imported[dbName] = dbResult
|
||||
|
||||
totalRecords = sum(sum(v.values()) for v in imported.values())
|
||||
return {
|
||||
"success": True,
|
||||
"imported": imported,
|
||||
"totalRecords": totalRecords,
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
|
||||
def _getPhysicalColumns(conn, tableName: str) -> List[str]:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = %s
|
||||
ORDER BY ordinal_position
|
||||
""", (tableName,))
|
||||
return [row["column_name"] for row in cur.fetchall()]
|
||||
|
||||
|
||||
def _deleteNonProtected(conn, tableName: str, protectedIds: Set[str]) -> int:
|
||||
"""Delete all rows except protected system objects."""
|
||||
if not protectedIds:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f'DELETE FROM "{tableName}"')
|
||||
return cur.rowcount
|
||||
|
||||
idList = list(protectedIds)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f'DELETE FROM "{tableName}" WHERE "id"::text != ALL(%(ids)s)',
|
||||
{"ids": idList},
|
||||
)
|
||||
return cur.rowcount
|
||||
|
||||
|
||||
def _insertRows(
|
||||
conn,
|
||||
tableName: str,
|
||||
rows: List[dict],
|
||||
physicalCols: List[str],
|
||||
mode: str,
|
||||
) -> int:
|
||||
"""Insert rows into a table. In merge mode, skip rows whose id already exists."""
|
||||
if not rows:
|
||||
return 0
|
||||
|
||||
physicalColSet = set(physicalCols)
|
||||
inserted = 0
|
||||
|
||||
for row in rows:
|
||||
cols = [c for c in row.keys() if c in physicalColSet]
|
||||
if not cols:
|
||||
continue
|
||||
|
||||
values = [_pgSafe(row[c]) for c in cols]
|
||||
colNames = ", ".join(f'"{c}"' for c in cols)
|
||||
placeholders = ", ".join(["%s"] * len(cols))
|
||||
|
||||
if mode == "merge":
|
||||
sql = f'INSERT INTO "{tableName}" ({colNames}) VALUES ({placeholders}) ON CONFLICT ("id") DO NOTHING'
|
||||
else:
|
||||
sql = f'INSERT INTO "{tableName}" ({colNames}) VALUES ({placeholders})'
|
||||
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SAVEPOINT row_sp")
|
||||
cur.execute(sql, values)
|
||||
inserted += cur.rowcount
|
||||
cur.execute("RELEASE SAVEPOINT row_sp")
|
||||
except Exception as e:
|
||||
logger.warning("Insert failed for %s row: %s", tableName, e)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("ROLLBACK TO SAVEPOINT row_sp")
|
||||
|
||||
return inserted
|
||||
|
||||
|
||||
def _pgSafe(v: Any) -> Any:
|
||||
"""Convert Python values to psycopg2-compatible types."""
|
||||
import json as _json
|
||||
|
||||
if v is None or isinstance(v, (str, int, float, bool)):
|
||||
return v
|
||||
if isinstance(v, (dict, list)):
|
||||
return _json.dumps(v)
|
||||
return str(v)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prepare import (validate + remap, return context for per-DB import)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _prepareImport(payload: dict) -> dict:
|
||||
"""Validate, remap system-object IDs, and return the prepared payload
|
||||
together with metadata the frontend needs to drive per-DB import.
|
||||
|
||||
Returns ``{valid, warnings, systemObjectsFound, databases, protectedIds, remappedPayload}``.
|
||||
"""
|
||||
validation = _validateImportPayload(payload)
|
||||
if not validation.get("valid"):
|
||||
return {
|
||||
"valid": False,
|
||||
"warnings": validation.get("warnings", []),
|
||||
"systemObjectsFound": validation.get("systemObjectsFound", []),
|
||||
"databases": [],
|
||||
"protectedIds": [],
|
||||
}
|
||||
|
||||
liveIds = _loadLiveSystemObjectIds()
|
||||
remap = _buildIdRemapFromPayload(payload, liveIds)
|
||||
if remap:
|
||||
logger.info("System-object ID remap: %s", remap)
|
||||
_remapSystemObjectIds(payload, remap)
|
||||
|
||||
protectedIdSet = set(liveIds.values())
|
||||
|
||||
registeredDbs = getRegisteredDatabases()
|
||||
dbList = []
|
||||
for dbName, dbData in payload.get("databases", {}).items():
|
||||
if dbName not in registeredDbs:
|
||||
continue
|
||||
tables = dbData.get("tables", {})
|
||||
recordCount = sum(len(rows) for rows in tables.values() if isinstance(rows, list))
|
||||
dbList.append({
|
||||
"database": dbName,
|
||||
"tableCount": len(tables),
|
||||
"recordCount": recordCount,
|
||||
})
|
||||
|
||||
return {
|
||||
"valid": True,
|
||||
"warnings": validation.get("warnings", []),
|
||||
"systemObjectsFound": validation.get("systemObjectsFound", []),
|
||||
"databases": dbList,
|
||||
"protectedIds": list(protectedIdSet),
|
||||
}
|
||||
|
||||
|
||||
def _ensureDatabaseExists(dbName: str) -> bool:
|
||||
"""Create the PostgreSQL database if it does not yet exist.
|
||||
|
||||
Connects to the ``postgres`` admin database using the same credentials
|
||||
as the target DB. Returns True if the database was created, False if
|
||||
it already existed.
|
||||
"""
|
||||
registeredDbs = getRegisteredDatabases()
|
||||
configPrefix = registeredDbs.get(dbName)
|
||||
if configPrefix is None:
|
||||
return False
|
||||
|
||||
hostKey = f"{configPrefix}_HOST" if configPrefix != "DB" else "DB_HOST"
|
||||
portKey = f"{configPrefix}_PORT" if configPrefix != "DB" else "DB_PORT"
|
||||
userKey = f"{configPrefix}_USER" if configPrefix != "DB" else "DB_USER"
|
||||
passwordKey = f"{configPrefix}_PASSWORD_SECRET" if configPrefix != "DB" else "DB_PASSWORD_SECRET"
|
||||
|
||||
adminConn = psycopg2.connect(
|
||||
host=APP_CONFIG.get(hostKey, "localhost"),
|
||||
port=int(APP_CONFIG.get(portKey, 5432)),
|
||||
database="postgres",
|
||||
user=APP_CONFIG.get(userKey),
|
||||
password=APP_CONFIG.get(passwordKey),
|
||||
client_encoding="utf8",
|
||||
)
|
||||
try:
|
||||
adminConn.autocommit = True
|
||||
with adminConn.cursor() as cur:
|
||||
cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (dbName,))
|
||||
if cur.fetchone():
|
||||
return False
|
||||
cur.execute(f'CREATE DATABASE "{dbName}"')
|
||||
logger.info("Created missing database: %s", dbName)
|
||||
return True
|
||||
finally:
|
||||
adminConn.close()
|
||||
|
||||
|
||||
def _createTableFromExport(conn, tableName: str, rows: List[dict]) -> None:
|
||||
"""Create a table based on the column structure found in the export data.
|
||||
|
||||
Uses TEXT for all columns since we don't have the original DDL.
|
||||
The ``id`` column gets a PRIMARY KEY constraint.
|
||||
"""
|
||||
allKeys: List[str] = []
|
||||
seen: set = set()
|
||||
for row in rows:
|
||||
for k in row.keys():
|
||||
if k not in seen:
|
||||
allKeys.append(k)
|
||||
seen.add(k)
|
||||
|
||||
if not allKeys:
|
||||
return
|
||||
|
||||
colDefs = []
|
||||
for col in allKeys:
|
||||
if col == "id":
|
||||
colDefs.append(f'"{col}" TEXT PRIMARY KEY')
|
||||
else:
|
||||
colDefs.append(f'"{col}" TEXT')
|
||||
|
||||
ddl = f'CREATE TABLE IF NOT EXISTS "{tableName}" ({", ".join(colDefs)})'
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(ddl)
|
||||
logger.info("Created table %s with %d columns", tableName, len(allKeys))
|
||||
|
||||
|
||||
def _getTableImportOrder(conn, tableNames: List[str], dbName: str = "") -> List[str]:
|
||||
"""Sort tables by FK dependencies (parents first) using topological sort.
|
||||
|
||||
Uses Pydantic ``fk_target`` metadata from ``fkRegistry`` as the single
|
||||
source of truth (works for ALL databases, not just those with SQL FKs).
|
||||
Only *intra-DB* dependencies are considered; cross-DB FKs (e.g. to
|
||||
``poweron_app.Mandate``) are handled by importing databases in order.
|
||||
"""
|
||||
tableSet = set(tableNames)
|
||||
allRels = getFkRelationships()
|
||||
|
||||
deps: Dict[str, Set[str]] = {t: set() for t in tableNames}
|
||||
for rel in allRels:
|
||||
if rel.sourceDb != dbName or rel.targetDb != dbName:
|
||||
continue
|
||||
child = rel.sourceTable
|
||||
parent = rel.targetTable
|
||||
if child in tableSet and parent in tableSet and child != parent:
|
||||
deps[child].add(parent)
|
||||
|
||||
inDegree = {t: len(deps[t]) for t in tableNames}
|
||||
queue = sorted(t for t in tableNames if inDegree[t] == 0)
|
||||
ordered: List[str] = []
|
||||
|
||||
while queue:
|
||||
node = queue.pop(0)
|
||||
ordered.append(node)
|
||||
for t in tableNames:
|
||||
if node in deps[t]:
|
||||
deps[t].discard(node)
|
||||
inDegree[t] -= 1
|
||||
if inDegree[t] == 0:
|
||||
queue.append(t)
|
||||
queue.sort()
|
||||
|
||||
remaining = [t for t in tableNames if t not in set(ordered)]
|
||||
if remaining:
|
||||
logger.warning("FK cycle detected, appending without order guarantee: %s", remaining)
|
||||
ordered.extend(sorted(remaining))
|
||||
|
||||
return ordered
|
||||
|
||||
|
||||
def _importSingleDb(payload: dict, dbName: str, mode: str, protectedIds: List[str]) -> dict:
|
||||
"""Import a single database from the (already remapped) payload.
|
||||
|
||||
Tables are sorted by FK dependencies: parent tables are inserted first,
|
||||
child tables are deleted first (reverse order) in replace mode.
|
||||
|
||||
Returns ``{database, tables: {tableName: insertedCount}, recordCount, warnings}``.
|
||||
"""
|
||||
if mode not in ("replace", "merge"):
|
||||
raise ValueError(f"Invalid import mode: {mode}")
|
||||
|
||||
registeredDbs = getRegisteredDatabases()
|
||||
if dbName not in registeredDbs:
|
||||
return {"database": dbName, "tables": {}, "recordCount": 0,
|
||||
"warnings": [f"Datenbank '{dbName}' nicht registriert"]}
|
||||
|
||||
dbData = payload.get("databases", {}).get(dbName)
|
||||
if not dbData:
|
||||
return {"database": dbName, "tables": {}, "recordCount": 0,
|
||||
"warnings": [f"Keine Daten fuer '{dbName}' im Payload"]}
|
||||
|
||||
try:
|
||||
dbCreated = _ensureDatabaseExists(dbName)
|
||||
except Exception as e:
|
||||
logger.error("Failed to ensure database %s exists: %s", dbName, e)
|
||||
return {"database": dbName, "tables": {}, "recordCount": 0,
|
||||
"warnings": [f"Datenbank '{dbName}' konnte nicht erstellt werden: {e}"]}
|
||||
|
||||
protectedIdSet = set(protectedIds)
|
||||
tables = dbData.get("tables", {})
|
||||
warnings: List[str] = []
|
||||
dbResult: Dict[str, int] = {}
|
||||
excluded = _EXCLUDED_TABLES.get(dbName, set())
|
||||
|
||||
if dbCreated:
|
||||
warnings.append(f"Datenbank '{dbName}' wurde neu erstellt")
|
||||
|
||||
conn = _getConnection(dbName)
|
||||
try:
|
||||
existingTables = set(_listTables(conn))
|
||||
conn.rollback()
|
||||
|
||||
# Ensure all import tables exist (create missing ones from export schema)
|
||||
conn.autocommit = True
|
||||
for tableName, rows in tables.items():
|
||||
if tableName in excluded or not isinstance(rows, list) or not rows:
|
||||
continue
|
||||
if tableName not in existingTables:
|
||||
_createTableFromExport(conn, tableName, rows)
|
||||
existingTables.add(tableName)
|
||||
logger.info("Pre-created missing table %s.%s", dbName, tableName)
|
||||
|
||||
# Build importable table list and sort by FK dependencies
|
||||
importable = [t for t in tables
|
||||
if t not in excluded
|
||||
and isinstance(tables.get(t), list)
|
||||
and t in existingTables]
|
||||
importOrder = _getTableImportOrder(conn, importable, dbName)
|
||||
|
||||
logger.info("Import order for %s: %s", dbName, importOrder)
|
||||
|
||||
for tableName in tables:
|
||||
if tableName in excluded and isinstance(tables.get(tableName), list):
|
||||
warnings.append(f"Table '{dbName}.{tableName}' excluded (security/transient)")
|
||||
|
||||
# Phase 1 (replace only): DELETE children first (reverse topological order)
|
||||
if mode == "replace":
|
||||
conn.autocommit = False
|
||||
for tableName in reversed(importOrder):
|
||||
try:
|
||||
_deleteNonProtected(conn, tableName, protectedIdSet)
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
warnings.append(f"DELETE from {dbName}.{tableName} failed: {e}")
|
||||
logger.warning("DELETE from %s.%s failed: %s", dbName, tableName, e)
|
||||
|
||||
# Phase 2: INSERT parents first (topological order)
|
||||
conn.autocommit = False
|
||||
for tableName in importOrder:
|
||||
try:
|
||||
rows = tables[tableName]
|
||||
physicalCols = _getPhysicalColumns(conn, tableName)
|
||||
if not physicalCols:
|
||||
conn.rollback()
|
||||
continue
|
||||
|
||||
filteredRows = []
|
||||
for row in rows:
|
||||
if _isProtectedRow(tableName, row):
|
||||
continue
|
||||
if row.get("id") and str(row["id"]) in protectedIdSet:
|
||||
continue
|
||||
filteredRows.append(row)
|
||||
|
||||
insertedCount = _insertRows(conn, tableName, filteredRows, physicalCols, mode)
|
||||
conn.commit()
|
||||
dbResult[tableName] = insertedCount
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
warnings.append(f"INSERT into {dbName}.{tableName} failed: {e}")
|
||||
logger.warning("INSERT into %s.%s failed: %s", dbName, tableName, e)
|
||||
except Exception as e:
|
||||
logger.error("Import failed for database %s: %s", dbName, e)
|
||||
return {"database": dbName, "tables": {}, "recordCount": 0,
|
||||
"warnings": [f"Import fuer '{dbName}' fehlgeschlagen: {e}"]}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
recordCount = sum(dbResult.values())
|
||||
return {"database": dbName, "tables": dbResult, "recordCount": recordCount, "warnings": warnings}
|
||||
|
|
@ -1751,6 +1751,7 @@ def presentation_envelopes_to_document_json(
|
|||
}
|
||||
|
||||
|
||||
<<<<<<< HEAD
|
||||
def _document_list_from_context(raw: Any, *, _depth: int = 0) -> DocumentReferenceList:
|
||||
"""Best-effort extraction of document/file references from ``context`` payloads.
|
||||
|
||||
|
|
@ -1807,6 +1808,8 @@ def _document_list_from_context(raw: Any, *, _depth: int = 0) -> DocumentReferen
|
|||
return DocumentReferenceList(references=deduped)
|
||||
|
||||
|
||||
=======
|
||||
>>>>>>> 513ded84d529502d07a04d199df3f873f263cff0
|
||||
async def extractContent(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||
operation_id = None
|
||||
try:
|
||||
|
|
@ -1814,6 +1817,7 @@ async def extractContent(self, parameters: Dict[str, Any]) -> ActionResult:
|
|||
operation_id = f"context_extract_{wf}_{int(time.time())}"
|
||||
|
||||
document_list_param = parameters.get("documentList")
|
||||
<<<<<<< HEAD
|
||||
if document_list_param:
|
||||
dl = coerceDocumentReferenceList(document_list_param)
|
||||
source = "documentList"
|
||||
|
|
@ -1832,6 +1836,20 @@ async def extractContent(self, parameters: Dict[str, Any]) -> ActionResult:
|
|||
),
|
||||
)
|
||||
logger.info("extractContent resolved %d document reference(s) from %s", len(dl.references), source)
|
||||
=======
|
||||
if not document_list_param:
|
||||
return ActionResult.isFailure(error="documentList is required")
|
||||
|
||||
dl = coerceDocumentReferenceList(document_list_param)
|
||||
if not dl.references:
|
||||
return ActionResult.isFailure(
|
||||
error=(
|
||||
f"documentList could not be parsed (type={type(document_list_param).__name__}); "
|
||||
"expected DocumentReferenceList, list of strings/dicts, or "
|
||||
"a wrapper dict like {'documents': [...]}"
|
||||
),
|
||||
)
|
||||
>>>>>>> 513ded84d529502d07a04d199df3f873f263cff0
|
||||
|
||||
parent_operation_id = parameters.get("parentOperationId")
|
||||
self.services.chat.progressLogStart(
|
||||
|
|
|
|||
|
|
@ -31,11 +31,13 @@ openpyxl>=3.1.2 # Für Excel-Dateien
|
|||
python-pptx>=0.6.21 # Für PowerPoint-Dateien
|
||||
|
||||
## Data Processing & Analysis
|
||||
numpy==1.26.3 # Version die mit pandas und matplotlib kompatibel ist
|
||||
pandas==2.2.3 # Aktuelle Version beibehalten
|
||||
numpy==1.26.3; python_version < "3.13"
|
||||
numpy>=2.1.0; python_version >= "3.13"
|
||||
pandas==2.2.3
|
||||
|
||||
## Data Visualization
|
||||
matplotlib==3.8.0 # Aktuelle Version beibehalten
|
||||
matplotlib==3.8.0; python_version < "3.13"
|
||||
matplotlib>=3.9.0; python_version >= "3.13"
|
||||
seaborn==0.13.0
|
||||
markdown
|
||||
|
||||
|
|
|
|||
308
scripts/exportDbSchemaFromModels.py
Normal file
308
scripts/exportDbSchemaFromModels.py
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
"""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,7 +171,8 @@ def liveConnector():
|
|||
try:
|
||||
with adminConn.cursor() as cur:
|
||||
cur.execute(
|
||||
'SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = %s',
|
||||
'SELECT pg_terminate_backend(pid) FROM pg_stat_activity '
|
||||
'WHERE datname = %s AND pid != pg_backend_pid() AND usename = current_user',
|
||||
(dbName,),
|
||||
)
|
||||
cur.execute(f'DROP DATABASE IF EXISTS "{dbName}"')
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ class TestCostEstimate(unittest.TestCase):
|
|||
{"maxBytes": 200 * 1024 * 1024}, kind="files",
|
||||
)
|
||||
self.assertIn("estimatedTokens", result)
|
||||
self.assertIn("estimatedUsd", result)
|
||||
self.assertIn("estimatedChf", result)
|
||||
self.assertIn("basis", result)
|
||||
self.assertIn("assumptions", result["basis"])
|
||||
self.assertIn("formula", result["basis"]["assumptions"])
|
||||
|
|
@ -39,12 +39,12 @@ class TestCostEstimate(unittest.TestCase):
|
|||
def test_unknown_kind_returns_zero(self):
|
||||
result = _costEstimate.estimateBootstrapCost({}, kind="totally-unknown")
|
||||
self.assertEqual(result["estimatedTokens"], 0)
|
||||
self.assertEqual(result["estimatedUsd"], 0.0)
|
||||
self.assertEqual(result["estimatedChf"], 0.0)
|
||||
|
||||
def test_usd_is_rounded_4_decimals(self):
|
||||
def test_chf_is_rounded_4_decimals(self):
|
||||
result = _costEstimate.estimateBootstrapCost({"maxBytes": 1024 * 1024}, kind="files")
|
||||
rounded = round(result["estimatedUsd"], 4)
|
||||
self.assertEqual(result["estimatedUsd"], rounded)
|
||||
rounded = round(result["estimatedChf"], 4)
|
||||
self.assertEqual(result["estimatedChf"], rounded)
|
||||
|
||||
def test_basis_includes_input_limits(self):
|
||||
result = _costEstimate.estimateBootstrapCost({"maxBytes": 42}, kind="files")
|
||||
|
|
|
|||
Loading…
Reference in a new issue