Compare commits
15 commits
main
...
feat/grafi
| Author | SHA1 | Date | |
|---|---|---|---|
| 42ffeee5d3 | |||
| 7a1deccc2d | |||
| 716837e8fb | |||
| 64b58802a4 | |||
| 8eb094be16 | |||
| b831975077 | |||
| f115bd9aa2 | |||
| 64591fda3f | |||
| b238721563 | |||
| 42973a242e | |||
| 55e23f939c | |||
| 6e82de6f60 | |||
| 988430e4c9 | |||
| da3b9cd3b1 | |||
| 01e90e02ff |
866 changed files with 54464 additions and 39461 deletions
1036
.cursor/plans/swift_ios_app_nachbau_3dc75f35.plan.md
Normal file
1036
.cursor/plans/swift_ios_app_nachbau_3dc75f35.plan.md
Normal file
File diff suppressed because it is too large
Load diff
741
.cursor/plans/swift_ios_app_nachbau_80bb1212.plan.md
Normal file
741
.cursor/plans/swift_ios_app_nachbau_80bb1212.plan.md
Normal file
|
|
@ -0,0 +1,741 @@
|
|||
---
|
||||
name: Swift iOS App Nachbau
|
||||
overview: Vollständiger Implementierungsplan für den Nachbau des React-Web-Frontends (frontend_nyla) als native Swift/SwiftUI iOS/iPadOS-App. Die App kommuniziert mit dem bestehenden FastAPI-Gateway-Backend und bildet alle UI-Screens, Navigation und API-Schnittstellen nach.
|
||||
todos:
|
||||
- id: phase-0
|
||||
content: "Phase 0: Xcode-Projekt erstellen, Ordnerstruktur, SPM-Dependencies, Build-Configs (Dev/Int/Prod)"
|
||||
status: pending
|
||||
- id: phase-1
|
||||
content: "Phase 1: Core Networking Layer -- APIClient, SSEClient, WebSocketClient, CSRFManager (analog api.ts + sseClient.ts)"
|
||||
status: pending
|
||||
- id: phase-2
|
||||
content: "Phase 2: Authentication -- LocalAuth, MSAL, Google, Biometrie, Keychain (analog authApi.ts + AuthProvider.tsx)"
|
||||
status: pending
|
||||
- id: phase-3
|
||||
content: "Phase 3: Domain Models + FeatureStore (analog mandate.ts + featureStore.tsx)"
|
||||
status: pending
|
||||
- id: phase-4
|
||||
content: "Phase 4: App Shell -- NavigationSplitView (iPad) / TabView (iPhone), Dashboard, Settings, backend-driven Sidebar"
|
||||
status: pending
|
||||
- id: phase-5
|
||||
content: "Phase 5: i18n String Catalogs (de/en/fr) + Theme System (Light/Dark)"
|
||||
status: pending
|
||||
- id: phase-6
|
||||
content: "Phase 6: Core Pages -- Store, GDPR, Basedata (Prompts/Files/Connections), Billing Transactions"
|
||||
status: pending
|
||||
- id: phase-7
|
||||
content: "Phase 7: Shared UI Components -- FormGenerator, ContentPreview, ChatMessage, AccessRules, NotificationBell"
|
||||
status: pending
|
||||
- id: phase-8
|
||||
content: "Phase 8: Push Notifications (APNs Registration, Deep-Link Handling)"
|
||||
status: pending
|
||||
- id: phase-9
|
||||
content: "Phase 9: Admin Module -- alle 16 Admin-Seiten (Mandates, Users, RBAC, Invitations, Wizards, etc.)"
|
||||
status: pending
|
||||
- id: phase-10
|
||||
content: "Phase 10: Feature Trustee -- Dashboard, Documents, Positions, Roles, Expense-Import, Scan, Accounting"
|
||||
status: pending
|
||||
- id: phase-11
|
||||
content: "Phase 11: Feature Workspace -- Chat-Streaming (SSE), Files, Datasources, Voice"
|
||||
status: pending
|
||||
- id: phase-12
|
||||
content: "Phase 12: Feature Chatbot -- SSE-Streaming Chat, Threads, Conversations"
|
||||
status: pending
|
||||
- id: phase-13
|
||||
content: "Phase 13: Feature Teamsbot -- Sessions, WebSocket Bot-Kommunikation, Voice, MFA"
|
||||
status: pending
|
||||
- id: phase-14
|
||||
content: "Phase 14: Feature CommCoach -- Coaching Sessions, Audio-Streaming, Personas, Dossier"
|
||||
status: pending
|
||||
- id: phase-15
|
||||
content: "Phase 15: Feature ChatPlayground -- Workflows, Playground mit SSE-Stream"
|
||||
status: pending
|
||||
- id: phase-16
|
||||
content: "Phase 16: Feature Automation -- Definitions, Templates, Logs, Execute"
|
||||
status: pending
|
||||
- id: phase-17
|
||||
content: "Phase 17: Feature CodeEditor -- Editor mit SSE-Stream, Code-Anzeige, Apply"
|
||||
status: pending
|
||||
- id: phase-18
|
||||
content: "Phase 18: Feature RealEstate/PEK -- MapKit-Integration, Parcels, Address-Search, BZO"
|
||||
status: pending
|
||||
- id: phase-19
|
||||
content: "Phase 19: Feature Neutralization -- Config, Neutralize Text/File"
|
||||
status: pending
|
||||
- id: phase-20
|
||||
content: "Phase 20: Billing-Erweiterung -- Admin-Views, Stripe Checkout"
|
||||
status: pending
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# Nyla iOS/iPadOS App -- Vollständiger Implementierungsplan
|
||||
|
||||
## Ausgangslage
|
||||
|
||||
Das bestehende Web-Frontend (`frontend_nyla`) ist eine **React 19 + Vite + TypeScript** Anwendung mit:
|
||||
|
||||
- **12+ Feature-Module** (Trustee, Workspace, Chatbot, Teamsbot, CommCoach, CodeEditor, Automation, RealEstate, Neutralization, ChatPlayground, Billing, Admin)
|
||||
- **21 API-Module** unter `src/api/*.ts` mit insgesamt **200+ API-Endpunkten**
|
||||
- **120+ UI-Komponenten** inkl. dynamischem FormGenerator, ContentPreview, Chat-Streaming, Maps, Charts
|
||||
- **Multi-Tenant-Architektur**: Mandate > Features > Instanzen > Views/Permissions
|
||||
- **3 Auth-Provider**: Local, Microsoft MSAL, Google OAuth
|
||||
- **Echtzeit**: SSE-Streaming (Chat, Workspace, CodeEditor) + WebSockets (Voice)
|
||||
- **Backend**: FastAPI (Python) auf PostgreSQL, erreichbar unter konfigurierbarer `VITE_API_BASE_URL`
|
||||
|
||||
---
|
||||
|
||||
## Technische Entscheidungen
|
||||
|
||||
|
||||
| Aspekt | Entscheidung |
|
||||
| -------------------- | --------------------------------------------------- |
|
||||
| Plattform | iOS 18+ / iPadOS 18+ |
|
||||
| UI-Framework | SwiftUI |
|
||||
| Architektur | **MVVM + Repository Pattern** (s. unten) |
|
||||
| Networking | URLSession + async/await |
|
||||
| SSE | Custom SSE-Client auf URLSession-Basis |
|
||||
| WebSocket | URLSessionWebSocketTask |
|
||||
| Auth | MSAL SDK, Google Sign-In SDK, Keychain + Local Auth |
|
||||
| Biometrie | LocalAuthentication (Face ID / Touch ID) |
|
||||
| State | `@Observable` (Observation Framework, iOS 17+) |
|
||||
| Navigation | `NavigationStack` + `NavigationSplitView` (iPad) |
|
||||
| Dependency Injection | Environment-basiert (SwiftUI `@Environment`) |
|
||||
| Package Manager | Swift Package Manager (SPM) |
|
||||
| Karten | MapKit (SwiftUI) |
|
||||
| Charts | Swift Charts |
|
||||
| i18n | String Catalogs (`.xcstrings`) fuer de/en/fr |
|
||||
| Push | APNs + UserNotifications Framework |
|
||||
| PDF-Anzeige | PDFKit |
|
||||
| Markdown | Native AttributedString (iOS 15+) |
|
||||
| Persistenz | Keychain (Secrets), UserDefaults (Preferences) |
|
||||
| Distribution | TestFlight |
|
||||
|
||||
|
||||
### Architektur: MVVM + Repository Pattern
|
||||
|
||||
```
|
||||
Presentation Layer (SwiftUI Views)
|
||||
|
|
||||
v
|
||||
ViewModels (@Observable)
|
||||
|
|
||||
v
|
||||
Repositories (Protokolle)
|
||||
|
|
||||
v
|
||||
API Services (URLSession)
|
||||
|
|
||||
v
|
||||
Gateway Backend (FastAPI)
|
||||
```
|
||||
|
||||
Begründung: SwiftUI ist nativ MVVM-orientiert. Das Repository Pattern kapselt die Datenzugriffe und macht den Code testbar. `@Observable` (iOS 17+) ist leichter als `ObservableObject` und performanter.
|
||||
|
||||
### Projektstruktur
|
||||
|
||||
```
|
||||
NylaApp/
|
||||
NylaApp.swift // App Entry Point
|
||||
Config/
|
||||
AppConfig.swift // API URLs, Build Configs
|
||||
Environment.swift // Dev/Int/Prod Environments
|
||||
Core/
|
||||
Networking/
|
||||
APIClient.swift // Zentraler HTTP-Client (= api.ts)
|
||||
APIError.swift // Error Types
|
||||
APIEndpoints.swift // Endpoint Definitionen
|
||||
SSEClient.swift // Server-Sent Events Client
|
||||
WebSocketClient.swift // WebSocket Client
|
||||
CSRFManager.swift // CSRF Token Handling
|
||||
RequestInterceptor.swift // Auth/Mandate Headers
|
||||
Auth/
|
||||
AuthManager.swift // Zentrale Auth-Logik
|
||||
LocalAuthService.swift // Username/Password
|
||||
MSALAuthService.swift // Microsoft MSAL
|
||||
GoogleAuthService.swift // Google Sign-In
|
||||
BiometricAuthService.swift // Face ID / Touch ID
|
||||
KeychainService.swift // Secure Storage
|
||||
Navigation/
|
||||
AppRouter.swift // Root Navigation
|
||||
NavigationStore.swift // Backend-driven Nav State
|
||||
DeepLinkHandler.swift // URL Scheme Handling
|
||||
Localization/
|
||||
Localizable.xcstrings // String Catalog
|
||||
LanguageManager.swift // Sprachauswahl
|
||||
Theme/
|
||||
ThemeManager.swift // Light/Dark Mode
|
||||
DesignTokens.swift // Farben, Spacing, Fonts
|
||||
Permissions/
|
||||
PermissionChecker.swift // RBAC Client-Checks
|
||||
Domain/
|
||||
Models/ // Shared Domain Models
|
||||
Mandate.swift // Mandate, Feature, Instance
|
||||
User.swift // User Model
|
||||
Permissions.swift // AccessLevel, TablePermission
|
||||
Pagination.swift // PaginatedResponse<T>
|
||||
I18nLabel.swift // Mehrsprachige Labels
|
||||
Repositories/ // Repository Protokolle
|
||||
AuthRepository.swift
|
||||
MandateRepository.swift
|
||||
FeatureRepository.swift
|
||||
...
|
||||
Data/
|
||||
API/ // API-Implementierungen (= src/api/*.ts)
|
||||
AuthAPI.swift
|
||||
UserAPI.swift
|
||||
MandateAPI.swift
|
||||
FeaturesAPI.swift
|
||||
BillingAPI.swift
|
||||
TrusteeAPI.swift
|
||||
... (21 Module)
|
||||
Repositories/ // Repository Implementierungen
|
||||
DefaultAuthRepository.swift
|
||||
DefaultMandateRepository.swift
|
||||
...
|
||||
Features/ // Feature-Module (je Ordner)
|
||||
Dashboard/
|
||||
Store/
|
||||
Settings/
|
||||
GDPR/
|
||||
Basedata/
|
||||
Prompts/
|
||||
Files/
|
||||
Connections/
|
||||
Billing/
|
||||
Admin/
|
||||
Mandates/
|
||||
Users/
|
||||
Access/
|
||||
Invitations/
|
||||
...
|
||||
Trustee/
|
||||
Workspace/
|
||||
Chatbot/
|
||||
Teamsbot/
|
||||
CommCoach/
|
||||
CodeEditor/
|
||||
ChatPlayground/
|
||||
Automation/
|
||||
RealEstate/
|
||||
Neutralization/
|
||||
Shared/
|
||||
Components/ // Wiederverwendbare UI (= src/components/)
|
||||
FormGenerator/ // Dynamische Formulare
|
||||
ContentPreview/ // PDF, Bild, JSON Vorschau
|
||||
ChatMessage/ // Chat-Nachrichten-Rendering
|
||||
AccessRules/ // Zugriffsregeln-Editor
|
||||
NotificationBell/ // Notification Badge + Overlay
|
||||
SearchBar/
|
||||
LoadingView/
|
||||
ErrorView/
|
||||
EmptyStateView/
|
||||
Extensions/
|
||||
Utilities/
|
||||
Resources/
|
||||
Assets.xcassets
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phasen-Plan
|
||||
|
||||
### Phase 0: Projekt-Setup (1-2 Tage)
|
||||
|
||||
- Xcode-Projekt erstellen (iOS 18+, SwiftUI App Lifecycle)
|
||||
- Ordnerstruktur nach obigem Schema anlegen
|
||||
- SPM Dependencies einrichten:
|
||||
- `MSAL` (Microsoft Authentication Library for iOS)
|
||||
- `GoogleSignIn` (Google Sign-In SDK)
|
||||
- Keine weiteren externen Deps noetig (MapKit, Charts, PDFKit sind System-Frameworks)
|
||||
- Build-Konfigurationen: **Dev** / **Int** / **Prod** mit je eigenem `API_BASE_URL`
|
||||
- Analog zu den `.env.dev` / `.env.int` / `.env.prod` Dateien im Web-Frontend
|
||||
- Werte: `http://localhost:8000` (Dev), INT-URL, PROD-URL
|
||||
- TestFlight-Vorbereitung: App ID, Provisioning Profile, Signing
|
||||
|
||||
### Phase 1: Core Networking Layer (3-5 Tage)
|
||||
|
||||
**Ziel**: Equivalent zu `[src/api.ts](frontend_nyla/src/api.ts)` + `[src/hooks/useApi.ts](frontend_nyla/src/hooks/useApi.ts)`
|
||||
|
||||
**APIClient.swift** -- Zentraler HTTP-Client:
|
||||
|
||||
- `URLSession.shared` mit Custom-Configuration
|
||||
- Cookie-basierte Auth (`httpCookieStorage`)
|
||||
- Request-Interceptor fuer:
|
||||
- `Authorization: Bearer` Header (aus Keychain)
|
||||
- `X-Mandate-Id` / `X-Instance-Id` Header (aus aktuellem Navigation-Context)
|
||||
- CSRF-Token fuer POST/PUT/PATCH/DELETE
|
||||
- Response-Handler:
|
||||
- 401 -> Redirect zu Login (analog Web `api.ts` Zeile 127-151)
|
||||
- 429 -> Rate-Limit Warning
|
||||
- Generische Fehlerextraktion (FastAPI `detail` Array/String)
|
||||
- Generische Request-Methoden: `get<T>()`, `post<T>()`, `put<T>()`, `delete<T>()`, `upload()`
|
||||
- `Codable`-basierte JSON Serialisierung
|
||||
|
||||
**SSEClient.swift** -- Server-Sent Events:
|
||||
|
||||
- Analog zu `[src/utils/sseClient.ts](frontend_nyla/src/utils/sseClient.ts)`
|
||||
- URLSession mit `bytes(for:)` async stream
|
||||
- Parsing von `data:` Lines
|
||||
- Callbacks: `onMessage`, `onError`, `onComplete`
|
||||
- Wird benoetigt fuer: Workspace, Chatbot, CodeEditor, CommCoach Streaming
|
||||
|
||||
**WebSocketClient.swift** -- WebSockets:
|
||||
|
||||
- `URLSessionWebSocketTask`
|
||||
- Fuer Voice-Features (Teamsbot: `/api/teamsbot/{instanceId}/bot/ws/{sessionId}`)
|
||||
- Ping/Pong, Reconnect-Logik
|
||||
|
||||
**CSRFManager.swift**:
|
||||
|
||||
- Token-Generierung und -Speicherung
|
||||
- Analog zu `[src/utils/csrfUtils.ts](frontend_nyla/src/utils/csrfUtils.ts)`
|
||||
|
||||
### Phase 2: Authentication (3-5 Tage)
|
||||
|
||||
**Ziel**: Alle 3 Auth-Provider + Biometrie
|
||||
|
||||
**Mapping Web -> Swift:**
|
||||
|
||||
|
||||
| Web (authApi.ts) | Swift |
|
||||
| ---------------------------------------- | -------------------------------------------- |
|
||||
| `POST /api/local/login` (form-data) | `LocalAuthService.login(username:password:)` |
|
||||
| `POST /api/local/register` | `LocalAuthService.register(...)` |
|
||||
| `POST /api/local/password-reset-request` | `LocalAuthService.requestPasswordReset(...)` |
|
||||
| `POST /api/local/password-reset` | `LocalAuthService.resetPassword(...)` |
|
||||
| `GET /api/local/available?username=` | `LocalAuthService.checkAvailability(...)` |
|
||||
| `GET /api/local/me` | `AuthManager.fetchCurrentUser()` |
|
||||
| `POST /api/local/logout` | `AuthManager.logout()` |
|
||||
| MSAL Login/Callback | `MSALAuthService` via MSAL SDK |
|
||||
| `GET /api/msft/me` | `MSALAuthService.fetchUser()` |
|
||||
| Google Login/Callback | `GoogleAuthService` via Google Sign-In SDK |
|
||||
| `GET /api/google/me` | `GoogleAuthService.fetchUser()` |
|
||||
|
||||
|
||||
**AuthManager.swift** (zentral):
|
||||
|
||||
- Verwaltet aktiven Auth-Provider (`local` / `msft` / `google`)
|
||||
- Speichert Auth-State in Keychain (nicht UserDefaults!)
|
||||
- Published `isAuthenticated`, `currentUser`, `authAuthority`
|
||||
- Analog zu `[src/providers/auth/AuthProvider.tsx](frontend_nyla/src/providers/auth/AuthProvider.tsx)`
|
||||
|
||||
**BiometricAuthService.swift**:
|
||||
|
||||
- `LAContext.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics)`
|
||||
- Nach erstem erfolgreichen Login: Credentials in Keychain speichern
|
||||
- Bei App-Start: Face ID/Touch ID -> Keychain Credentials -> Auto-Login
|
||||
|
||||
**Login Screen (SwiftUI)**:
|
||||
|
||||
- Username/Password Felder
|
||||
- "Anmelden mit Microsoft" Button (MSAL)
|
||||
- "Anmelden mit Google" Button (Google Sign-In)
|
||||
- "Face ID / Touch ID" Option (wenn verfuegbar)
|
||||
- Registrierung / Passwort vergessen Links
|
||||
- Analog zu `[src/pages/Login.tsx](frontend_nyla/src/pages/Login.tsx)`
|
||||
|
||||
### Phase 3: Domain Models + Feature Store (2-3 Tage)
|
||||
|
||||
**Ziel**: Alle geteilten Datenmodelle + Feature-State
|
||||
|
||||
Zentrale Models (analog zu `[src/types/mandate.ts](frontend_nyla/src/types/mandate.ts)`):
|
||||
|
||||
```swift
|
||||
// Mandate.swift
|
||||
struct I18nLabel: Codable { var de: String; var en: String; var fr: String? }
|
||||
enum AccessLevel: String, Codable { case none = "n", my = "m", group = "g", all = "a" }
|
||||
struct TablePermission: Codable { var view: Bool; var read, create, update, delete: AccessLevel }
|
||||
struct FieldPermission: Codable { var read: Bool; var write: Bool }
|
||||
struct InstancePermissions: Codable { var tables: [String: TablePermission]; var fields: [String: [String: FieldPermission]]?; var views: [String: Bool]; var isAdmin: Bool? }
|
||||
struct FeatureInstance: Codable, Identifiable { var id: String; var featureCode, mandateId, mandateName, instanceLabel: String; var userRoles: [String]; var permissions: InstancePermissions }
|
||||
struct MandateFeature: Codable { var code: String; var label: I18nLabel; var icon: String; var instances: [FeatureInstance] }
|
||||
struct Mandate: Codable, Identifiable { var id, name: String; var label, code: String?; var features: [MandateFeature] }
|
||||
struct FeaturesMyResponse: Codable { var mandates: [Mandate] }
|
||||
```
|
||||
|
||||
**FeatureStore.swift** (analog zu `[src/stores/featureStore.tsx](frontend_nyla/src/stores/featureStore.tsx)`):
|
||||
|
||||
- `@Observable class FeatureStore`
|
||||
- `loadFeatures()` -> `GET /api/features/my`
|
||||
- Cache: `[String: FeatureInstance]` fuer schnellen Zugriff
|
||||
- Methoden: `getMandateById()`, `getInstanceById()`, `getAllInstances()`, etc.
|
||||
- Injected via SwiftUI `@Environment`
|
||||
|
||||
### Phase 4: App Shell + Navigation (4-6 Tage)
|
||||
|
||||
**Ziel**: MainLayout + FeatureLayout + backend-driven Navigation
|
||||
|
||||
**Adaptive Layout:**
|
||||
|
||||
- **iPad**: `NavigationSplitView` (Sidebar + Detail) -- analog Web-Sidebar
|
||||
- **iPhone**: `TabView` mit Hauptbereichen + Navigation Stack pro Tab
|
||||
|
||||
**Sidebar / Navigation:**
|
||||
|
||||
- Backend-driven: `GET /api/navigation?language={lang}` liefert Navigationsbaum
|
||||
- Analog zu `[src/components/Navigation/MandateNavigation.tsx](frontend_nyla/src/components/Navigation/MandateNavigation.tsx)`
|
||||
- Hierarchie: Mandate > Feature > Instance > Views
|
||||
- Icon-Mapping: SF Symbols statt React Icons (Mapping-Tabelle erstellen)
|
||||
|
||||
**Screen-Routing:**
|
||||
|
||||
- `NavigationStack` mit `NavigationPath` fuer programmatische Navigation
|
||||
- Deep-Link-Schema: `nyla://mandates/{mandateId}/{featureCode}/{instanceId}/{view}`
|
||||
- Feature-View-Dispatcher: analog zu `[src/pages/FeatureView.tsx](frontend_nyla/src/pages/FeatureView.tsx)` `VIEW_COMPONENTS`
|
||||
|
||||
**Screens in Phase 4:**
|
||||
|
||||
- Dashboard (`/`) -- Mandate/Instance-Karten, analog `[src/pages/Dashboard.tsx](frontend_nyla/src/pages/Dashboard.tsx)`
|
||||
- Settings (`/settings`) -- Theme-Toggle, Sprache (de/en/fr), Profil
|
||||
- UserSection im Sidebar-Footer
|
||||
|
||||
### Phase 5: i18n + Theme (2-3 Tage)
|
||||
|
||||
**Internationalisierung:**
|
||||
|
||||
- Xcode String Catalog (`.xcstrings`) fuer de/en/fr
|
||||
- Alle statischen Strings aus den Web-Locales uebernehmen: `[src/locales/de.ts](frontend_nyla/src/locales/de.ts)`, `en.ts`, `fr.ts`
|
||||
- Dynamische Labels (I18nLabel vom Backend): Helper `label.localized(lang:)` analog `getLabel()` im Web
|
||||
- `LanguageManager` speichert Praeferenz in UserDefaults
|
||||
|
||||
**Theme:**
|
||||
|
||||
- SwiftUI `.preferredColorScheme()` fuer System-Integration
|
||||
- Custom `DesignTokens` fuer konsistente Farben/Spacing
|
||||
- Analog zu `[src/styles/themes/light.css](frontend_nyla/src/styles/themes/light.css)` + `.dark-theme`
|
||||
|
||||
### Phase 6: Core Pages (5-7 Tage)
|
||||
|
||||
**Store** (Feature Marketplace):
|
||||
|
||||
- `GET /api/store/features` -> Feature-Liste
|
||||
- `POST /api/store/activate` / `POST /api/store/deactivate`
|
||||
- Analog `[src/pages/Store.tsx](frontend_nyla/src/pages/Store.tsx)`
|
||||
|
||||
**GDPR**:
|
||||
|
||||
- `GET /api/user/me/data-export` + `/data-portability`
|
||||
- `DELETE /api/user/me/`
|
||||
- Analog `[src/pages/GDPR.tsx](frontend_nyla/src/pages/GDPR.tsx)`
|
||||
|
||||
**Basedata - Prompts** (`/basedata/prompts`):
|
||||
|
||||
- CRUD auf `/api/prompts` mit FormGenerator
|
||||
- Analog `[src/pages/PromptsPage.tsx](frontend_nyla/src/pages/PromptsPage.tsx)`
|
||||
|
||||
**Basedata - Files** (`/basedata/files`):
|
||||
|
||||
- `GET /api/files/list`, Upload, Download, Preview
|
||||
- Analog `[src/pages/FilesPage.tsx](frontend_nyla/src/pages/FilesPage.tsx)`
|
||||
- Nutzung von `UIDocumentPickerViewController` (via UIKit-Bridge) fuer File-Upload
|
||||
- `QuickLook` fuer Dateivorschau
|
||||
|
||||
**Basedata - Connections** (`/basedata/connections`):
|
||||
|
||||
- CRUD auf `/api/connections/`
|
||||
- Connect/Disconnect Aktionen
|
||||
- Analog `[src/pages/ConnectionsPage.tsx](frontend_nyla/src/pages/ConnectionsPage.tsx)`
|
||||
|
||||
**Billing** (`/billing/transactions`):
|
||||
|
||||
- `GET /api/billing/balance`, `/transactions`, `/statistics/{period}`
|
||||
- Swift Charts fuer Statistik-Visualisierung
|
||||
- Analog `[src/pages/billing/BillingDataView.tsx](frontend_nyla/src/pages/billing/BillingDataView.tsx)`
|
||||
|
||||
### Phase 7: Shared UI Components (5-8 Tage)
|
||||
|
||||
**FormGenerator** (zentral, wird von fast allen Features genutzt):
|
||||
|
||||
- Analog zu `[src/components/FormGenerator/](frontend_nyla/src/components/FormGenerator/)`
|
||||
- Dynamische Formulare basierend auf `AttributeDefinition[]` vom Backend (`GET /api/attributes/{entityType}`)
|
||||
- Feldtypen: String, Email, Select, Multiselect, Textarea, Checkbox, File, Number, DateTime, Multilingual
|
||||
- Tabellen-Ansicht (`FormGeneratorTable`) + Listen-Ansicht (`FormGeneratorList`)
|
||||
- Action Buttons (Edit, Delete, Download, Custom)
|
||||
- Pagination-Support
|
||||
|
||||
**ContentPreview**:
|
||||
|
||||
- PDF: `PDFKitView` (UIKit PDFView in UIViewRepresentable)
|
||||
- Bilder: AsyncImage
|
||||
- JSON: Syntax-Highlighting
|
||||
- HTML: WKWebView
|
||||
- Analog `[src/components/ContentPreview/](frontend_nyla/src/components/ContentPreview/)`
|
||||
|
||||
**NotificationBell**:
|
||||
|
||||
- `GET /api/notifications/unread-count` (Polling)
|
||||
- Push Notifications via APNs
|
||||
- In-App Notification Sheet
|
||||
- Analog `[src/components/NotificationBell/](frontend_nyla/src/components/NotificationBell/)`
|
||||
|
||||
**Chat Message Components**:
|
||||
|
||||
- Message-Bubbles mit Markdown-Rendering
|
||||
- File-Attachments
|
||||
- Streaming-Indicator (typing animation)
|
||||
- Auto-Scroll
|
||||
- Analog `[src/components/UiComponents/Messages/](frontend_nyla/src/components/UiComponents/Messages/)`
|
||||
|
||||
**AccessRules Components**:
|
||||
|
||||
- Tabelle + Editor fuer RBAC-Regeln
|
||||
- Analog `[src/components/AccessRules/](frontend_nyla/src/components/AccessRules/)`
|
||||
|
||||
### Phase 8: Push Notifications (2-3 Tage)
|
||||
|
||||
- APNs-Registrierung in `AppDelegate`
|
||||
- Device Token an Backend senden (neuer Endpoint oder bestehender `/api/messaging/subscriptions`)
|
||||
- `UNUserNotificationCenter` fuer lokale + remote Notifications
|
||||
- Deep-Link Handling aus Notification-Tap
|
||||
|
||||
### Phase 9: Admin Module (5-7 Tage)
|
||||
|
||||
Alle Admin-Seiten analog zu `[src/pages/admin/](frontend_nyla/src/pages/admin/)`:
|
||||
|
||||
|
||||
| Admin-Seite | API-Endpunkte |
|
||||
| -------------------- | ------------------------------------------ |
|
||||
| Mandates | CRUD `/api/mandates/` |
|
||||
| Users | CRUD `/api/users/` |
|
||||
| User-Mandates | `/api/mandates/{id}/users` |
|
||||
| Access Hub | `/api/rbac/permissions`, `/api/rbac/rules` |
|
||||
| Feature Instances | `/api/features/instances` |
|
||||
| Feature Roles | `/api/features/templates/roles` |
|
||||
| Feature Users | `/api/features/instances/{id}/users` |
|
||||
| Invitations | CRUD `/api/invitations/` |
|
||||
| Mandate Roles | `/api/rbac/roles` |
|
||||
| Role Permissions | `/api/rbac/rules/by-role/{roleId}` |
|
||||
| User Access Overview | `/api/admin/user-access-overview/`* |
|
||||
| Billing Admin | `/api/billing/admin/`* |
|
||||
| Automation Events | `/api/admin/automation-events` |
|
||||
| Logs | `/api/admin/logs` |
|
||||
| Mandate Wizard | Kombination mehrerer Endpoints |
|
||||
| Invitation Wizard | Kombination mehrerer Endpoints |
|
||||
|
||||
|
||||
### Phase 10-20: Feature-Module (je 3-7 Tage pro Feature)
|
||||
|
||||
Jedes Feature folgt demselben Pattern:
|
||||
|
||||
1. **API-Modul** erstellen (alle Endpunkte des Features)
|
||||
2. **ViewModels** fuer jede View
|
||||
3. **SwiftUI Views** fuer jede registrierte View
|
||||
4. **Feature-spezifische Komponenten** wo noetig
|
||||
|
||||
---
|
||||
|
||||
#### Phase 10: Trustee (5-7 Tage)
|
||||
|
||||
Views: Dashboard, Documents, Positions, Instance-Roles, Expense-Import, Scan-Upload, Accounting Settings
|
||||
|
||||
API-Basis: `/api/trustee/{instanceId}/`
|
||||
|
||||
- Organisations, Roles, Access, Contracts, Documents, Positions CRUD
|
||||
- Accounting: Connectors, Config, Sync
|
||||
- Document Upload mit base64-Konvertierung
|
||||
- Options-Endpoints fuer Dropdowns
|
||||
|
||||
Besonderheiten:
|
||||
|
||||
- Viele verschachtelte CRUD-Entitaeten (Organisation > Contract > Document > Position)
|
||||
- Scan-Upload: iOS-Kamera-Integration + VisionKit (OCR)
|
||||
|
||||
#### Phase 11: Workspace (5-7 Tage)
|
||||
|
||||
Views: Dashboard (Chat-Stream), Settings
|
||||
|
||||
API-Basis: `/api/workspace/{instanceId}/`
|
||||
|
||||
- SSE-Streaming fuer Chat (`POST .../start/stream`)
|
||||
- Workflows, Messages, Files, Datasources CRUD
|
||||
- Voice: Transcribe, Synthesize, Settings
|
||||
- File Browser mit Ordnerstruktur
|
||||
|
||||
Besonderheiten:
|
||||
|
||||
- **Zentrales SSE-Streaming** -- das Keep-Alive-Pattern aus dem Web (`WorkspaceKeepAlive`) muss in Swift via Task/Actor geloest werden
|
||||
- Voice: AVFoundation fuer Audio-Aufnahme, URLSession fuer Upload
|
||||
|
||||
#### Phase 12: Chatbot (3-5 Tage)
|
||||
|
||||
Views: Conversations, Settings
|
||||
|
||||
API-Basis: `/api/chatbot/{instanceId}/`
|
||||
|
||||
- `POST .../start/stream` -- SSE-Streaming via fetch (nicht Axios!)
|
||||
- Threads: List, Get, Delete
|
||||
- Stop Workflow
|
||||
|
||||
Besonderheiten:
|
||||
|
||||
- Streaming-Chat mit File-Attachments
|
||||
- Analog zu `chatbotApi.startChatbotStreamApi` -- Custom SSE via POST
|
||||
|
||||
#### Phase 13: Teamsbot (4-6 Tage)
|
||||
|
||||
Views: Dashboard, Sessions, Settings
|
||||
|
||||
API-Basis: `/api/teamsbot/{instanceId}/`
|
||||
|
||||
- Sessions CRUD + Stream (EventSource/SSE)
|
||||
- Config, System Bots, User Account
|
||||
- Voice Test
|
||||
- MFA fuer Sessions
|
||||
- WebSocket fuer Bot-Kommunikation (`/bot/ws/{sessionId}`)
|
||||
|
||||
Besonderheiten:
|
||||
|
||||
- **WebSocket** fuer Live-Bot-Interaction
|
||||
- SSE via EventSource fuer Session-Stream
|
||||
- Screenshot-Anzeige
|
||||
|
||||
#### Phase 14: CommCoach (4-6 Tage)
|
||||
|
||||
Views: Dashboard, Coaching, Dossier, Settings
|
||||
|
||||
API-Basis: `/api/commcoach/{instanceId}/`
|
||||
|
||||
- Contexts CRUD + Archive/Activate
|
||||
- Sessions: Start, Message-Stream, Audio-Stream, Complete, Cancel
|
||||
- Tasks CRUD + Status
|
||||
- Personas CRUD, Documents, Badges, Score History
|
||||
- Voice: Languages, Voices, TTS
|
||||
- Export (Dossier, Session)
|
||||
|
||||
Besonderheiten:
|
||||
|
||||
- **Audio-Streaming**: Mikrofon-Aufnahme -> POST Audio-Stream
|
||||
- SSE fuer Session-Nachrichten
|
||||
- Score/Badge-Visualisierung
|
||||
|
||||
#### Phase 15: ChatPlayground (3-5 Tage)
|
||||
|
||||
Views: Playground, Workflows
|
||||
|
||||
API-Basis: `/api/chatplayground/{instanceId}/`
|
||||
|
||||
- Start/Stop Workflow (mit SSE-Stream)
|
||||
- Workflows CRUD + Status/Logs/Messages
|
||||
- Attributes, Actions
|
||||
|
||||
#### Phase 16: Automation (3-5 Tage)
|
||||
|
||||
Views: Definitions, Templates, Logs
|
||||
|
||||
API-Basis: `/api/automations/`
|
||||
|
||||
- Automations CRUD + Execute + Duplicate
|
||||
- Templates CRUD
|
||||
- Workflow-Management (gleiche API wie ChatPlayground, anderer Base-Path)
|
||||
|
||||
#### Phase 17: CodeEditor (3-5 Tage)
|
||||
|
||||
Views: Editor, Workflows
|
||||
|
||||
API-Basis: `/api/codeeditor/{instanceId}/`
|
||||
|
||||
- Start/Stop/Apply (mit SSE-Stream)
|
||||
- ChatData, Workflows, Files, File Content
|
||||
|
||||
Besonderheiten:
|
||||
|
||||
- Code-Darstellung: Syntax-Highlighting (z.B. via `Highlightr` SPM Package oder custom)
|
||||
- Diff-Ansicht fuer Code-Apply
|
||||
|
||||
#### Phase 18: RealEstate / PEK (5-7 Tage)
|
||||
|
||||
Views: Dashboard (Map), Instance-Roles
|
||||
|
||||
API-Basis: `/api/realestate/{instanceId}/`
|
||||
|
||||
- Projects + Parcels CRUD
|
||||
- Parcel Search, WFS, Selection Summary, Adjacent Parcels
|
||||
- Address Autocomplete
|
||||
- BZO Information, Parcel Documents
|
||||
- Gemeinden
|
||||
|
||||
Besonderheiten:
|
||||
|
||||
- **MapKit** Integration: Parcel-Visualisierung auf Karte
|
||||
- Address-Autocomplete: MKLocalSearchCompleter oder Backend-API
|
||||
- Komplexe Karteninteraktion (Parcel-Selektion, Adjacent Parcels)
|
||||
|
||||
#### Phase 19: Neutralization (2-3 Tage)
|
||||
|
||||
Views: Dashboard/Playground (gleiche View)
|
||||
|
||||
API-Basis: `/api/neutralization/`
|
||||
|
||||
- Config GET/POST
|
||||
- Neutralize File/Text, Resolve Text
|
||||
- Process SharePoint, Batch Process
|
||||
- Stats, Attributes
|
||||
|
||||
#### Phase 20: Billing View-Erweiterung (1-2 Tage)
|
||||
|
||||
Admin-Billing-Views falls in Phase 9 nicht vollstaendig abgedeckt:
|
||||
|
||||
- Checkout (Stripe -- SFSafariViewController fuer Redirect)
|
||||
- Mandate/User Balances und Transaktionen
|
||||
|
||||
---
|
||||
|
||||
## API-Header-Konvention (fuer alle Requests)
|
||||
|
||||
Jeder API-Request muss folgende Header senden (analog `[src/api.ts](frontend_nyla/src/api.ts)`):
|
||||
|
||||
|
||||
| Header | Quelle | Wann |
|
||||
| -------------------------------- | ------------------ | --------------------- |
|
||||
| `Authorization: Bearer {token}` | Keychain | Wenn JWT vorhanden |
|
||||
| `X-Mandate-Id: {mandateId}` | Navigation Context | Bei Feature-Seiten |
|
||||
| `X-Instance-Id: {instanceId}` | Navigation Context | Bei Feature-Seiten |
|
||||
| `X-CSRF-Token: {token}` | CSRFManager | POST/PUT/PATCH/DELETE |
|
||||
| `Content-Type: application/json` | Standard | JSON Bodies |
|
||||
| Cookie (httpOnly) | URLSession | Automatisch |
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Gesamtaufwand-Schaetzung
|
||||
|
||||
|
||||
| Phase | Tage (geschaetzt) |
|
||||
| ------------------------------- | ----------------- |
|
||||
| Phase 0: Setup | 1-2 |
|
||||
| Phase 1: Networking | 3-5 |
|
||||
| Phase 2: Authentication | 3-5 |
|
||||
| Phase 3: Domain Models + Store | 2-3 |
|
||||
| Phase 4: App Shell + Navigation | 4-6 |
|
||||
| Phase 5: i18n + Theme | 2-3 |
|
||||
| Phase 6: Core Pages | 5-7 |
|
||||
| Phase 7: Shared UI Components | 5-8 |
|
||||
| Phase 8: Push Notifications | 2-3 |
|
||||
| Phase 9: Admin | 5-7 |
|
||||
| Phase 10: Trustee | 5-7 |
|
||||
| Phase 11: Workspace | 5-7 |
|
||||
| Phase 12: Chatbot | 3-5 |
|
||||
| Phase 13: Teamsbot | 4-6 |
|
||||
| Phase 14: CommCoach | 4-6 |
|
||||
| Phase 15: ChatPlayground | 3-5 |
|
||||
| Phase 16: Automation | 3-5 |
|
||||
| Phase 17: CodeEditor | 3-5 |
|
||||
| Phase 18: RealEstate | 5-7 |
|
||||
| Phase 19: Neutralization | 2-3 |
|
||||
| Phase 20: Billing Erweit. | 1-2 |
|
||||
| **Gesamt** | **~70-105 Tage** |
|
||||
|
||||
|
||||
Hinweis: Dies ist eine Einzelperson-Schaetzung. Mit Team (z.B. 2-3 Devs) kann parallelisiert werden, besonders ab Phase 10+ (Features sind unabhaengig voneinander).
|
||||
|
||||
---
|
||||
|
||||
## Offene Punkte / Risiken
|
||||
|
||||
1. **Backend-Anpassungen**: Das Backend setzt teilweise httpOnly Cookies nach Browser-Redirect (MSAL, Google). Fuer eine native App muss das Backend ggf. alternative Token-Flows unterstuetzen (z.B. Device Code Flow oder Token-Exchange).
|
||||
2. **Push Notifications**: Das Backend hat aktuell kein APNs-Token-Management. Ein neuer Endpoint `/api/notifications/register-device` muss im Gateway implementiert werden.
|
||||
3. **SSE ueber POST**: Die Web-App nutzt `fetch` POST + ReadableStream fuer SSE (nicht standard EventSource GET). In Swift muss dies mit `URLSession.bytes(for:)` nachgebaut werden.
|
||||
4. **Stripe Checkout**: Im Web oeffnet sich ein Stripe-Redirect. In iOS: SFSafariViewController oder Stripe iOS SDK.
|
||||
5. **SharePoint Integration**: Einige Features nutzen SharePoint-Folder-Picker. In iOS muss eine alternative UI gebaut werden (Liste statt Filepicker).
|
||||
6. **WebSocket Auth**: Der Web-Client nutzt Cookies fuer WebSocket-Auth. iOS `URLSessionWebSocketTask` unterstuetzt Cookies via URLSession Configuration.
|
||||
|
||||
30
.forgejo/workflows/deploy.yml
Normal file
30
.forgejo/workflows/deploy.yml
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
name: Deploy Gateway
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Deploy to Infomaniak VM
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "$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.poweron.swiss "
|
||||
cd /srv/gateway/current &&
|
||||
git remote set-url origin ssh://git@git.poweron.swiss:2222/PowerOn/gateway.git &&
|
||||
git pull &&
|
||||
cp env-gateway-prod-forgejo.env .env &&
|
||||
rm -f env-*.env &&
|
||||
source .venv/bin/activate &&
|
||||
pip install -r requirements.txt --no-cache-dir &&
|
||||
sudo systemctl restart gateway
|
||||
"
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
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
|
||||
"
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
name: Deploy Plattform-Core
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
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.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 main
|
||||
git reset --hard origin/main
|
||||
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
|
||||
"
|
||||
|
||||
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.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 main
|
||||
git reset --hard origin/main
|
||||
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
|
||||
"
|
||||
151
.github/workflows/deploy-gcp.yml
vendored
Normal file
151
.github/workflows/deploy-gcp.yml
vendored
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
# 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:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
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 }}"
|
||||
88
.github/workflows/int_gateway-int.yml
vendored
Normal file
88
.github/workflows/int_gateway-int.yml
vendored
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# 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:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
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
|
||||
|
||||
# Optional: Add step to run tests here (PyTest, Django test suites, etc.)
|
||||
|
||||
- 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 }}
|
||||
88
.github/workflows/main_gateway-prod.yml
vendored
Normal file
88
.github/workflows/main_gateway-prod.yml
vendored
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# 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:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
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
|
||||
|
||||
# Optional: Add step to run tests here (PyTest, Django test suites, etc.)
|
||||
|
||||
- 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 }}
|
||||
51
.github/workflows/update-requirements-lock.yml
vendored
Normal file
51
.github/workflows/update-requirements-lock.yml
vendored
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# 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: Commit and push requirements.lock
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add requirements.lock
|
||||
if git diff --staged --quiet; then
|
||||
echo "No changes to requirements.lock"
|
||||
else
|
||||
git commit -m "chore: update requirements.lock"
|
||||
git push
|
||||
fi
|
||||
|
|
@ -46,4 +46,5 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
|||
CMD python -c "import requests; requests.get('http://localhost:8000/api/admin/health', timeout=5)" || exit 1
|
||||
|
||||
# Run the application
|
||||
CMD exec gunicorn app:app --bind 0.0.0.0:${PORT:-8000} --timeout 600 --worker-class uvicorn.workers.UvicornWorker --workers 1
|
||||
# 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
|
||||
|
|
|
|||
281
app.py
281
app.py
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
import os
|
||||
import sys
|
||||
|
|
@ -61,13 +61,6 @@ class DailyRotatingFileHandler(RotatingFileHandler):
|
|||
return True
|
||||
return False
|
||||
|
||||
def doRollover(self):
|
||||
"""Size-based rollover that tolerates Windows file locks."""
|
||||
try:
|
||||
super().doRollover()
|
||||
except PermissionError:
|
||||
pass
|
||||
|
||||
def emit(self, record):
|
||||
"""Emit a log record, switching files if date has changed"""
|
||||
# Check if we need to switch to a new file
|
||||
|
|
@ -289,7 +282,7 @@ initLogging()
|
|||
logger = logging.getLogger(__name__)
|
||||
instanceLabel = APP_CONFIG.get("APP_ENV_LABEL")
|
||||
|
||||
# Pre-warm AI connectors on process load (before lifespan). Critical for AI/agent latency.
|
||||
# Pre-warm AI connectors on process load (before lifespan). Critical for chatbot latency.
|
||||
try:
|
||||
import modules.aicore.aicoreModelRegistry # noqa: F401
|
||||
logger.info("AI connectors pre-warm (app load) triggered")
|
||||
|
|
@ -302,7 +295,7 @@ async def lifespan(app: FastAPI):
|
|||
logger.info("Application is starting up")
|
||||
|
||||
# Validate FK metadata on all Pydantic models (fail-fast, no silent fallbacks)
|
||||
from modules.dbHelpers.fkRegistry import validateFkTargets
|
||||
from modules.shared.fkRegistry import validateFkTargets
|
||||
fkErrors = validateFkTargets()
|
||||
if fkErrors:
|
||||
for err in fkErrors:
|
||||
|
|
@ -311,31 +304,6 @@ async def lifespan(app: FastAPI):
|
|||
|
||||
# AI connectors already pre-warmed at module-load via _eager_prewarm() in aicoreModelRegistry.
|
||||
|
||||
# Register system-component lifecycle hooks (Composition Root — inverts L4->L5b dependency)
|
||||
from modules.shared.systemComponentRegistry import registerLifecycleHook
|
||||
from modules.workflowAutomation.mainWorkflowAutomation import (
|
||||
onBootstrap as _waOnBootstrap,
|
||||
onMandateDelete as _waOnMandateDelete,
|
||||
onInstanceCreate as _waOnInstanceCreate,
|
||||
)
|
||||
from modules.interfaces.interfaceDbBilling import (
|
||||
onMandateDelete as _billingOnMandateDelete,
|
||||
onMandateProvision as _billingOnMandateProvision,
|
||||
onStorageChanged as _billingOnStorageChanged,
|
||||
onUserMandateCreate as _billingOnUserMandateCreate,
|
||||
onUserMandateDelete as _billingOnUserMandateDelete,
|
||||
onUserBudgetAdjust as _billingOnUserBudgetAdjust,
|
||||
)
|
||||
registerLifecycleHook("onBootstrap", _waOnBootstrap)
|
||||
registerLifecycleHook("onMandateDelete", _waOnMandateDelete)
|
||||
registerLifecycleHook("onMandateDelete", _billingOnMandateDelete)
|
||||
registerLifecycleHook("onMandateProvision", _billingOnMandateProvision)
|
||||
registerLifecycleHook("onStorageChanged", _billingOnStorageChanged)
|
||||
registerLifecycleHook("onInstanceCreate", _waOnInstanceCreate)
|
||||
registerLifecycleHook("onUserMandateCreate", _billingOnUserMandateCreate)
|
||||
registerLifecycleHook("onUserMandateDelete", _billingOnUserMandateDelete)
|
||||
registerLifecycleHook("onUserBudgetAdjust", _billingOnUserBudgetAdjust)
|
||||
|
||||
# Bootstrap database if needed (creates initial users, mandates, roles, etc.)
|
||||
# This must happen before getting root interface
|
||||
from modules.security.rootAccess import getRootDbAppConnector
|
||||
|
|
@ -354,14 +322,6 @@ async def lifespan(app: FastAPI):
|
|||
catalogService = getCatalogService()
|
||||
registerAllFeaturesInCatalog(catalogService)
|
||||
logger.info("Feature catalog registration completed")
|
||||
|
||||
# Register service center RBAC objects (Composition Root — avoids system→serviceCenter import)
|
||||
try:
|
||||
from modules.serviceCenter import registerServiceObjects
|
||||
registerServiceObjects(catalogService)
|
||||
except Exception as e:
|
||||
logger.warning(f"Service center RBAC registration failed: {e}")
|
||||
|
||||
# Persist the in-memory feature registry into the Feature DB-table so
|
||||
# the FeatureInstance.featureCode FK has real targets. Without this
|
||||
# every FeatureInstance row would be flagged as orphan by the
|
||||
|
|
@ -375,23 +335,8 @@ async def lifespan(app: FastAPI):
|
|||
|
||||
# Sync gateway i18n registry to DB and load translation cache
|
||||
try:
|
||||
from modules.system.i18nBootSync import syncRegistryToDb, loadCache
|
||||
from modules.serviceCenter.registry import IMPORTABLE_SERVICES
|
||||
serviceLabels = [svc.get("label") for svc in IMPORTABLE_SERVICES.values()]
|
||||
|
||||
accountingLabels = []
|
||||
try:
|
||||
from modules.features.trustee.accounting.accountingRegistry import getAccountingRegistry
|
||||
registry = getAccountingRegistry()
|
||||
for connectorType, connector in (registry._connectors or {}).items():
|
||||
for field in connector.getRequiredConfigFields():
|
||||
label = getattr(field, "label", "") or ""
|
||||
if label:
|
||||
accountingLabels.append({"label": label, "connectorType": connectorType})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await syncRegistryToDb(serviceLabels=serviceLabels, accountingLabels=accountingLabels)
|
||||
from modules.shared.i18nRegistry import syncRegistryToDb, loadCache
|
||||
await syncRegistryToDb()
|
||||
await loadCache()
|
||||
logger.info("i18n registry sync + cache load completed")
|
||||
except Exception as e:
|
||||
|
|
@ -424,74 +369,14 @@ async def lifespan(app: FastAPI):
|
|||
except Exception as e:
|
||||
logger.warning(f"Could not initialize feature containers: {e}")
|
||||
|
||||
# Bootstrap Stripe prices for paid plans (composition root — upward import allowed here)
|
||||
try:
|
||||
from modules.serviceCenter.services.serviceSubscription.stripeBootstrap import bootstrapStripePrices
|
||||
bootstrapStripePrices()
|
||||
except Exception as e:
|
||||
logger.error(f"Stripe price bootstrap failed: {e}")
|
||||
|
||||
# Bootstrap MIME map into ComponentObjects (composition root — upward import allowed here)
|
||||
try:
|
||||
from modules.serviceCenter.services.serviceExtraction.subRegistry import ExtractorRegistry
|
||||
from modules.interfaces.interfaceDbManagement import ComponentObjects
|
||||
_mimeRegistry = ExtractorRegistry()
|
||||
_extensionToMime = _mimeRegistry.getExtensionToMimeMap()
|
||||
_textMimes: set = set()
|
||||
_seen: set = set()
|
||||
for _ext in _mimeRegistry._map.values():
|
||||
_eid = id(_ext)
|
||||
if _eid in _seen:
|
||||
continue
|
||||
_seen.add(_eid)
|
||||
_mimes = _ext.getSupportedMimeTypes()
|
||||
if any(m.startswith("text/") for m in _mimes):
|
||||
_textMimes.update(_mimes)
|
||||
_textMimes.update({"application/json", "application/xml", "application/javascript", "application/sql", "application/x-yaml", "application/x-toml"})
|
||||
ComponentObjects.setMimeMap(_extensionToMime, _textMimes)
|
||||
except Exception as e:
|
||||
logger.warning(f"MIME map bootstrap failed: {e}")
|
||||
|
||||
# --- Init Managers ---
|
||||
import asyncio
|
||||
try:
|
||||
main_loop = asyncio.get_running_loop()
|
||||
eventManager.set_event_loop(main_loop)
|
||||
from modules.workflowAutomation.scheduler.mainScheduler import setMainLoop as setSchedulerMainLoop, setOnRunFailedCallback
|
||||
from modules.workflows.scheduler.mainScheduler import setMainLoop as setSchedulerMainLoop
|
||||
setSchedulerMainLoop(main_loop)
|
||||
|
||||
# Inject run-failed notification callback (Composition Root — avoids workflows→serviceCenter import)
|
||||
def _onRunFailed(workflowId, runId, error, mandateId=None, workflowLabel=None):
|
||||
from modules.serviceCenter import getService
|
||||
from modules.serviceCenter.context import ServiceCenterContext
|
||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||
from modules.datamodels.datamodelMessaging import MessagingEventParameters
|
||||
|
||||
rootInterface = getRootInterface()
|
||||
if not rootInterface:
|
||||
return
|
||||
eventUser = rootInterface.getUserByUsername("event")
|
||||
if not eventUser:
|
||||
return
|
||||
ctx = ServiceCenterContext(
|
||||
user=eventUser,
|
||||
mandate_id=mandateId or "",
|
||||
feature_instance_id="",
|
||||
feature_code="workflowAutomation",
|
||||
)
|
||||
messagingService = getService("messaging", ctx)
|
||||
subscriptionId = "WorkflowAutomationRunFailed"
|
||||
eventParams = MessagingEventParameters(triggerData={
|
||||
"workflowId": workflowId,
|
||||
"workflowLabel": workflowLabel or workflowId,
|
||||
"runId": runId,
|
||||
"error": error,
|
||||
"mandateId": mandateId or "",
|
||||
})
|
||||
messagingService.executeSubscription(subscriptionId, eventParams)
|
||||
|
||||
setOnRunFailedCallback(_onRunFailed)
|
||||
|
||||
# Suppress noisy ConnectionResetError from ProactorEventLoop on Windows
|
||||
# when clients (browsers) close connections abruptly. This is a known
|
||||
# asyncio issue on Windows: https://bugs.python.org/issue39010
|
||||
|
|
@ -501,24 +386,14 @@ async def lifespan(app: FastAPI):
|
|||
return
|
||||
if isinstance(exc, ConnectionAbortedError):
|
||||
return
|
||||
if exc and "LocalProtocolError" in type(exc).__name__:
|
||||
return
|
||||
loop.default_exception_handler(ctx)
|
||||
main_loop.set_exception_handler(_suppressClientDisconnect)
|
||||
except RuntimeError:
|
||||
pass
|
||||
eventManager.start()
|
||||
|
||||
# --- WorkflowAutomation: Scheduler boot (System-Lifespan, not Feature-onStart) ---
|
||||
try:
|
||||
from modules.workflowAutomation.scheduler.mainScheduler import start as _startWorkflowScheduler
|
||||
_startWorkflowScheduler(eventUser)
|
||||
logger.info("WorkflowAutomation scheduler started (system lifespan)")
|
||||
except Exception as e:
|
||||
logger.error(f"WorkflowAutomation scheduler failed to start: {e}")
|
||||
|
||||
|
||||
# Register audit log cleanup scheduler
|
||||
from modules.dbHelpers.auditLogger import registerAuditLogCleanupScheduler
|
||||
from modules.shared.auditLogger import registerAuditLogCleanupScheduler
|
||||
registerAuditLogCleanupScheduler()
|
||||
|
||||
# Register enterprise subscription auto-renewal scheduler
|
||||
|
|
@ -529,10 +404,8 @@ async def lifespan(app: FastAPI):
|
|||
try:
|
||||
from modules.serviceCenter.services.serviceBackgroundJobs.mainBackgroundJobService import (
|
||||
recoverInterruptedJobs,
|
||||
registerZombieKillerScheduler,
|
||||
)
|
||||
recoverInterruptedJobs()
|
||||
registerZombieKillerScheduler(intervalMinutes=5)
|
||||
except Exception as e:
|
||||
logger.warning(f"BackgroundJob recovery failed (non-critical): {e}")
|
||||
|
||||
|
|
@ -543,96 +416,28 @@ async def lifespan(app: FastAPI):
|
|||
registerKnowledgeIngestionConsumer,
|
||||
)
|
||||
registerKnowledgeIngestionConsumer()
|
||||
# Side-effect import: registers all walker progress message keys
|
||||
# in the i18n registry so `syncRegistryToDb` picks them up.
|
||||
from modules.serviceCenter.services.serviceKnowledge import _progressMessages # noqa: F401
|
||||
except Exception as e:
|
||||
logger.warning(f"KnowledgeIngestionConsumer registration failed (non-critical): {e}")
|
||||
|
||||
# Install force-exit handler AFTER uvicorn has registered its own SIGINT
|
||||
# handler. Uvicorn's default timeout-graceful-shutdown is None (wait
|
||||
# forever), so frontend polling keep-alive connections block the process.
|
||||
# This wraps uvicorn's handler: on Ctrl+C, start a 3s timer that calls
|
||||
# os._exit() if the graceful shutdown hasn't completed by then.
|
||||
import signal as _sig
|
||||
import threading as _thr
|
||||
_prevSigint = _sig.getsignal(_sig.SIGINT)
|
||||
|
||||
def _onSigint(signum, frame):
|
||||
_t = _thr.Timer(3.0, lambda: os._exit(0))
|
||||
_t.daemon = True
|
||||
_t.start()
|
||||
if callable(_prevSigint) and _prevSigint not in (_sig.SIG_DFL, _sig.SIG_IGN):
|
||||
_prevSigint(signum, frame)
|
||||
else:
|
||||
raise KeyboardInterrupt
|
||||
|
||||
_sig.signal(_sig.SIGINT, _onSigint)
|
||||
|
||||
yield
|
||||
|
||||
# --- Shutdown sequence (protected against CancelledError) ---
|
||||
# --- Stop Managers ---
|
||||
eventManager.stop()
|
||||
|
||||
# --- Stop Feature Containers (Plug&Play) ---
|
||||
try:
|
||||
# 1. Drain SSE queues and cancel agent tasks FIRST so that open
|
||||
# streaming connections break out of their queue.get() loop
|
||||
# immediately. Without this, uvicorn waits for the SSE generators
|
||||
# to finish (up to 120 s keepalive timeout) before the rest of
|
||||
# the shutdown can proceed.
|
||||
try:
|
||||
from modules.shared.eventManager import get_event_manager as _getStreamingEM
|
||||
_getStreamingEM().shutdown()
|
||||
except Exception as e:
|
||||
logger.warning(f"Streaming EventManager shutdown failed: {e}")
|
||||
|
||||
# 2. Signal DB layer to abort in-flight borrow waits immediately.
|
||||
# This MUST happen early so that sync worker threads stuck in
|
||||
# _acquireConn (30 s poll loop) bail out within one backoff tick
|
||||
# instead of blocking process exit for the full borrow timeout.
|
||||
try:
|
||||
from modules.connectors.connectorDbPostgre import closeAllPools
|
||||
closeAllPools()
|
||||
except Exception as e:
|
||||
logger.warning(f"Closing DB connection pools failed: {e}")
|
||||
|
||||
# 3. Stop scheduler (removes all pending cron/interval jobs)
|
||||
eventManager.stop()
|
||||
|
||||
# 3.5 Stop WorkflowAutomation scheduler + email poller (System-Lifespan)
|
||||
try:
|
||||
from modules.workflowAutomation.scheduler.mainScheduler import stop as _stopWorkflowScheduler
|
||||
_stopWorkflowScheduler()
|
||||
except Exception as e:
|
||||
logger.warning(f"WorkflowAutomation scheduler stop failed: {e}")
|
||||
try:
|
||||
from modules.workflowAutomation.scheduler.emailPoller import stop as _stopEmailPoller
|
||||
_stopEmailPoller(eventUser)
|
||||
except Exception as e:
|
||||
logger.warning(f"Email poller stop failed: {e}")
|
||||
|
||||
# 4. 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}")
|
||||
|
||||
# 5. Close shared HTTP sessions (ResilientHttp) to avoid TCP keepalive hang
|
||||
try:
|
||||
from modules.shared.httpResilience import closeAllResilientHttp
|
||||
await closeAllResilientHttp()
|
||||
except Exception as e:
|
||||
logger.warning(f"Closing HTTP sessions failed: {e}")
|
||||
|
||||
logger.info("Application has been shut down")
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Shutdown interrupted (CancelledError) -- resources released")
|
||||
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")
|
||||
|
||||
|
||||
# Custom function to generate readable operation IDs for Swagger UI
|
||||
|
|
@ -705,8 +510,8 @@ def getAllowedOrigins():
|
|||
|
||||
|
||||
# CORS origin regex pattern for wildcard subdomain support
|
||||
# Matches all subdomains of poweron.swiss
|
||||
CORS_ORIGIN_REGEX = r"https://.*\.poweron\.swiss"
|
||||
# Matches all subdomains of poweron.swiss and poweron-center.net
|
||||
CORS_ORIGIN_REGEX = r"https://.*\.(poweron\.swiss|poweron-center\.net)"
|
||||
|
||||
|
||||
# SlowAPI rate limiter initialization
|
||||
|
|
@ -793,9 +598,6 @@ app.include_router(fileRouter)
|
|||
from modules.routes.routeDataSources import router as dataSourceRouter
|
||||
app.include_router(dataSourceRouter)
|
||||
|
||||
from modules.routes.routeUdb import router as udbRouter
|
||||
app.include_router(udbRouter)
|
||||
|
||||
from modules.routes.routeDataPrompts import router as promptRouter
|
||||
app.include_router(promptRouter)
|
||||
|
||||
|
|
@ -811,9 +613,6 @@ app.include_router(tableViewsRouter)
|
|||
from modules.routes.routeSecurityLocal import router as localRouter
|
||||
app.include_router(localRouter)
|
||||
|
||||
from modules.routes.routeMfa import router as mfaRouter
|
||||
app.include_router(mfaRouter)
|
||||
|
||||
from modules.routes.routeSecurityMsft import router as msftRouter
|
||||
app.include_router(msftRouter)
|
||||
|
||||
|
|
@ -890,8 +689,11 @@ from modules.routes.routeSystem import router as systemRouter, navigationRouter
|
|||
app.include_router(systemRouter)
|
||||
app.include_router(navigationRouter)
|
||||
|
||||
from modules.routes.routeWorkflowAutomation import router as workflowAutomationRouter
|
||||
app.include_router(workflowAutomationRouter)
|
||||
from modules.routes.routeWorkflowDashboard import router as workflowDashboardRouter
|
||||
app.include_router(workflowDashboardRouter)
|
||||
|
||||
from modules.routes.routeAutomationWorkspace import router as automationWorkspaceRouter
|
||||
app.include_router(automationWorkspaceRouter)
|
||||
|
||||
# ============================================================================
|
||||
# PLUG&PLAY FEATURE ROUTERS
|
||||
|
|
@ -900,23 +702,4 @@ app.include_router(workflowAutomationRouter)
|
|||
from modules.system.registry import loadFeatureRouters
|
||||
|
||||
featureLoadResults = loadFeatureRouters(app)
|
||||
logger.info(f"Feature router load results: {featureLoadResults}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
port = int(os.environ.get("PORT", 8000))
|
||||
|
||||
try:
|
||||
import gunicorn.app.wsgiapp # type: ignore[import-untyped] # noqa: F401
|
||||
import subprocess
|
||||
import sys
|
||||
subprocess.run([
|
||||
sys.executable, "-m", "gunicorn", "app:app",
|
||||
"--bind", f"0.0.0.0:{port}",
|
||||
"--timeout", "600",
|
||||
"--worker-class", "uvicorn.workers.UvicornWorker",
|
||||
"--workers", "1",
|
||||
], check=True)
|
||||
except ImportError:
|
||||
import uvicorn
|
||||
uvicorn.run("app:app", host="0.0.0.0", port=port, workers=1, timeout_graceful_shutdown=2)
|
||||
logger.info(f"Feature router load results: {featureLoadResults}")
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# All rights reserved.
|
||||
"""Generate tenant-dossier.pdf for neutralization demo. Run: python _generateTenantDossierPdf.py
|
||||
|
||||
Uses ReportLab so the PDF opens reliably in all viewers (stdlib-only PDFs are fragile).
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# All rights reserved.
|
||||
"""Generate the 3 fictitious PWG scan PDFs used by the pilot demo.
|
||||
|
||||
Run: python _generateScans.py
|
||||
|
|
|
|||
309
docs/althaus-bot-v2-aufwandsschaetzung.md
Normal file
309
docs/althaus-bot-v2-aufwandsschaetzung.md
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
# Aufwandsschätzung Althaus Bot v2 -- Unabhängige Analyse
|
||||
|
||||
**Projekt:** Althaus Bot v2 -- Weiterentwicklung & neue Use Cases
|
||||
**Kunde:** W. Althaus AG, Aarwangen
|
||||
**Erstellt:** 13. April 2026
|
||||
**Basis:** Code-Analyse Gateway-Repository + Offerte v2 vom 14.04.2026
|
||||
**Methodik:** Bottom-Up-Schätzung auf Basis der bestehenden Implementierung, Dreipunktschätzung (Min / Mitte / Max)
|
||||
|
||||
---
|
||||
|
||||
## 1. Ist-Zustand der Implementierung
|
||||
|
||||
### 1.1 Architekturübersicht
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ React Frontend (SSE-Streaming, Chat-UI) │
|
||||
└──────────────────────────┬──────────────────────────────────────┘
|
||||
│ /api/chatbot/*
|
||||
┌──────────────────────────▼──────────────────────────────────────┐
|
||||
│ Gateway (Python/FastAPI) │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ Chatbot Feature (modules/features/chatbot/) │ │
|
||||
│ │ ┌─────────┐ ┌──────────┐ ┌──────────┐ ┌────────┐ │ │
|
||||
│ │ │ Planner │→ │ SQL Plan │→ │ Parse & │→ │Formul. │ │ │
|
||||
│ │ │ Node │ │ Node │ │ Execute │ │ Node │ │ │
|
||||
│ │ └────┬────┘ └──────────┘ └────┬─────┘ └────────┘ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ ├→ Tavily (Web Search) │ │ │
|
||||
│ │ └→ Direct Answer │ │ │
|
||||
│ └──────────────────────────────────┼──────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────────────────────▼──────────────────────┐ │
|
||||
│ │ PreprocessorConnector (HTTP POST → Azure SQL API) │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ KnowledgeService (pgvector/RAG) -- NICHT IM CHATBOT │ │
|
||||
│ │ Produktiv im AgentService + CommCoach │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌──────────────────────────▼──────────────────────────────────────┐
|
||||
│ Azure Preprocessing Server (deployed, ERP-Daten deaktiviert) │
|
||||
│ Tabellen: Artikel, Einkaufspreis, Lagerplatz, Lagerplatz_Art. │
|
||||
│ Repo: github.com/valueonag/gateway_preprocessing │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 1.2 Vorhandene Komponenten (Wiederverwendung)
|
||||
|
||||
| Komponente | Datei / Modul | Status | Wiederverwendbar für |
|
||||
|---|---|---|---|
|
||||
| LangGraph-Workflow | `chatbot/chatbot.py` | Produktiv (deaktiviert) | Alle Positionen -- Grundgerüst |
|
||||
| PreprocessorConnector | `connectors/connectorPreprocessor.py` | Produktiv (deaktiviert) | Pos. 1, 2, 3, 4 -- SQL-Abfragen |
|
||||
| ChatbotConfig | `chatbot/config.py` | Produktiv | Alle -- Konfiguration pro Instanz |
|
||||
| Streaming-Bridge | `chatbot/service.py` | Produktiv | Alle -- SSE ans Frontend |
|
||||
| ChatbotDocument | `chatbot/interfaceFeatureChatbot.py` | Implementiert | Pos. 1.4, 2.1, 2.5 -- File-Handling |
|
||||
| KnowledgeService/RAG | `serviceCenter/services/serviceKnowledge/` | Produktiv (AgentService) | Pos. 5 -- Wiki-Integration |
|
||||
| Automation-Template | `automation/subAutomationTemplates.py` | Produktiv | Pos. 6 -- Preprocessor-Updates |
|
||||
| SQL-Sanitize | `chatbot.py` → `_sanitize_sql_typos` | Produktiv | Pos. 1.1 -- Gesperrte Artikel |
|
||||
| Markdown-Tabellen | `chatbot.py` → `_tool_output_to_markdown_table` | Produktiv | Pos. 1.3, 3.3 -- Darstellung |
|
||||
| File-Upload Backend | `service.py` → `_convert_file_ids_to_document_references` | Implementiert | Pos. 1.4 -- Upload-Pipeline |
|
||||
| Excel-Export | `service.py` → `_create_chat_document_from_action_document` | Implementiert | Pos. 2.5 -- Kalktool-Export |
|
||||
|
||||
### 1.3 Fehlende Komponenten (Neuentwicklung)
|
||||
|
||||
| Komponente | Benötigt für | Komplexität |
|
||||
|---|---|---|
|
||||
| Matching-Engine (exakt → fuzzy → KI) | Pos. 2.2 | Hoch |
|
||||
| Neuer Planner-Pfad "WIKI" | Pos. 5.2 | Mittel |
|
||||
| KnowledgeService → Chatbot Integration | Pos. 5.2 | Mittel |
|
||||
| Wiki-Connector (API/Crawling) | Pos. 5.1 | Unbekannt (Wiki-abhängig) |
|
||||
| Delta-Sync-Mechanismus | Pos. 5.3 | Mittel |
|
||||
| Preprocessor: 8-10 neue Tabellen/Views | Pos. 1.5, 3.1, 4.1 | Mittel (Code-Änderung) |
|
||||
| Frontend: File-Picker, Drag&Drop | Pos. 1.4 | Mittel |
|
||||
| Frontend: Thread-Liste, Suchfunktion | Pos. 1.2 | Mittel |
|
||||
| Kalktool-Excel-Format-Export | Pos. 2.5 | Mittel |
|
||||
| Schwellenwert-Insights | Pos. 4.5 | Mittel |
|
||||
|
||||
---
|
||||
|
||||
## 2. Detaillierte Aufwandsschätzung
|
||||
|
||||
### Position 1: Basics (Plattform-Verbesserungen)
|
||||
|
||||
| # | Anforderung | Offerte | Min | Mitte | Max | Begründung |
|
||||
|---|---|:-:|:-:|:-:|:-:|---|
|
||||
| 1.1 | Gesperrte Artikel filtern | 4 | 3 | 4 | 4 | System-Prompt + SQL-Sanitize-Regel. Kleine Änderung. |
|
||||
| 1.2 | Chat-Verlauf speichern | 12 | 12 | 14 | 16 | Backend existiert. Frontend-Aufwand (Thread-Liste, Suche). |
|
||||
| 1.3 | Längere Antworten | 6 | 4 | 5 | 6 | Streaming-Config + Frontend-Rendering. |
|
||||
| 1.4 | Datei-Upload | 16 | 16 | 18 | 20 | Full-Stack: Drag&Drop + LangGraph-Integration + Extraktion. |
|
||||
| 1.5 | Kundenartikelnummern | 8 | 10 | 12 | 14 | Preprocessor-Code + Prompt + Cross-Ref-Queries. ERP-abhängig. |
|
||||
| 1.6 | Abklärungen & Testing | 8 | 8 | 8 | 8 | Standard. |
|
||||
| | **Subtotal** | **54** | **53** | **61** | **68** | |
|
||||
|
||||
**Delta zur Offerte: +7h (Mitte) / +14h (Max)**
|
||||
**Haupttreiber:** Preprocessor-Erweiterung für Kundenartikelnummern (Pos. 1.5) erfordert Code-Änderung, nicht nur Config. Frontend-Aufwand bei Upload (Pos. 1.4) eher am oberen Ende.
|
||||
|
||||
---
|
||||
|
||||
### Position 2: Use Case Kalktool
|
||||
|
||||
| # | Anforderung | Offerte | Min | Mitte | Max | Begründung |
|
||||
|---|---|:-:|:-:|:-:|:-:|---|
|
||||
| 2.1 | Stücklisten-Upload & Extraktion | 12 | 10 | 12 | 14 | Nutzt Pos. 1.4. serviceExtraction vorhanden. |
|
||||
| 2.2 | Artikelidentifikation & Matching | 20 | 24 | 28 | 32 | **KRITISCH**: Neue Matching-Engine, 3 Stufen, ERP-abhängig. |
|
||||
| 2.3 | Automatische Feldergänzung | 16 | 14 | 16 | 18 | Preprocessor + Enrichment-Logik. |
|
||||
| 2.4 | Alternativartikel-Vorschläge | 12 | 12 | 14 | 16 | KI-Vorschläge + Bestätigungs-Workflow im Chat. |
|
||||
| 2.5 | Excel-Export (Kalktool-Format) | 12 | 10 | 12 | 14 | Basis existiert. Kalktool-Vorlage-Anpassung. |
|
||||
| 2.6 | Erweiterbarkeit neue Felder | 8 | 6 | 8 | 10 | Config-gesteuertes Feld-Mapping. |
|
||||
| 2.7 | Abklärungen & Testing | 12 | 12 | 12 | 12 | Kalktool-Vorlage, Testdaten, UAT. |
|
||||
| | **Subtotal** | **92** | **88** | **102** | **116** | |
|
||||
|
||||
**Delta zur Offerte: +10h (Mitte) / +24h (Max)**
|
||||
**Haupttreiber:** Die Matching-Engine (Pos. 2.2) ist die komplexeste Neuentwicklung im gesamten Projekt. Mehrstufiges Matching (exakt → fuzzy → KI-gestützt) ohne bestehende Basis. Die Qualität hängt stark von der ERP-Datenqualität und der Vielfalt der Kunden-Stücklisten-Formate ab.
|
||||
|
||||
---
|
||||
|
||||
### Position 3: Use Case Materialmanagement 1
|
||||
|
||||
| # | Anforderung | Offerte | Min | Mitte | Max | Begründung |
|
||||
|---|---|:-:|:-:|:-:|:-:|---|
|
||||
| 3.1 | ERP-Daten erweitern | 16 | 16 | 19 | 22 | Preprocessor: Bestellungen, Wareneingänge, Aufträge. Code nötig. |
|
||||
| 3.2 | System-Prompt Materialmanagement | 8 | 6 | 8 | 10 | Prompt-Engineering + SQL-Templates. |
|
||||
| 3.3 | Transparente Statusübersicht | 8 | 6 | 7 | 8 | Markdown-Rendering existiert, Erweiterung nötig. |
|
||||
| 3.4 | Auswirkungsanalyse & Empfehlungen | 12 | 14 | 16 | 18 | Cross-Table-Queries + KI-Analyse. Komplex. |
|
||||
| 3.5 | Abklärungen & Testing | 8 | 8 | 8 | 8 | Standard. |
|
||||
| | **Subtotal** | **52** | **50** | **58** | **66** | |
|
||||
|
||||
**Delta zur Offerte: +6h (Mitte) / +14h (Max)**
|
||||
**Haupttreiber:** Auswirkungsanalyse (Pos. 3.4) erfordert Multi-Table-Joins und KI-gestützte Bewertung, was über einfache SQL-Abfragen hinausgeht.
|
||||
|
||||
---
|
||||
|
||||
### Position 4: Use Case Materialmanagement 2 (KPIs)
|
||||
|
||||
| # | Anforderung | Offerte | Min | Mitte | Max | Begründung |
|
||||
|---|---|:-:|:-:|:-:|:-:|---|
|
||||
| 4.1 | ERP-Daten erweitern | 16 | 16 | 19 | 22 | Lagerjournal, Preishistorie. Aggregierte Views. |
|
||||
| 4.2 | System-Prompt KPI-Analyse | 8 | 6 | 8 | 10 | Prompt-Engineering. |
|
||||
| 4.3 | Liefertermintreue-Analyse | 10 | 10 | 12 | 14 | Zeitreihen, Lieferantenvergleich, komplexe SQL. |
|
||||
| 4.4 | Preisentwicklungs-Analyse | 10 | 10 | 11 | 12 | Preishistorie, Abweichungsberechnung. |
|
||||
| 4.5 | Automatisierte Insights | 8 | 10 | 12 | 14 | Schwellenwert-Warnungen, proaktive Erkennung. Neues Konzept. |
|
||||
| 4.6 | Abklärungen & Testing | 8 | 8 | 8 | 8 | Standard. |
|
||||
| | **Subtotal** | **60** | **60** | **70** | **80** | |
|
||||
|
||||
**Delta zur Offerte: +10h (Mitte) / +20h (Max)**
|
||||
**Haupttreiber:** Automatisierte Insights (Pos. 4.5) erfordern eine neue Logikschicht, die proaktiv Schwellenwerte überwacht und Empfehlungen generiert. Das ist im aktuellen Chat-Flow nicht vorgesehen.
|
||||
|
||||
---
|
||||
|
||||
### Position 5: Use Case Wiki-Anbindung
|
||||
|
||||
| # | Anforderung | Offerte | Min | Mitte | Max | Begründung |
|
||||
|---|---|:-:|:-:|:-:|:-:|---|
|
||||
| 5.1 | Wiki-Anbindung & Indexierung | 16 | 16 | 20 | 24 | KnowledgeService existiert. Wiki-Zugang UNBEKANNT. |
|
||||
| 5.2 | RAG-Integration im Chatbot | 12 | 12 | 14 | 16 | Pattern existiert (AgentService), muss portiert werden. |
|
||||
| 5.3 | Inkrementelle Aktualisierung | 8 | 8 | 11 | 14 | Delta-Sync stark Wiki-abhängig. |
|
||||
| 5.4 | Abklärungen & Testing | 8 | 8 | 9 | 10 | Relevanz-Tuning ist iterativ. |
|
||||
| | **Subtotal** | **44** | **44** | **54** | **64** | |
|
||||
|
||||
**Delta zur Offerte: +10h (Mitte) / +20h (Max)**
|
||||
**Haupttreiber:** Wiki-System ist unbekannt. Bei Wiki mit guter API (Confluence, SharePoint) sind 44h erreichbar. Bei proprietärem System ohne API steigt der Aufwand erheblich.
|
||||
|
||||
**Synergie:** KnowledgeService mit pgvector, Chunking, Embedding und semanticSearch ist bereits produktiv. Die RAG-Pipeline (Ingestion → Embedding → Retrieval) muss nicht neu gebaut werden. Das spart geschätzt 20-30h gegenüber einer Neuentwicklung.
|
||||
|
||||
---
|
||||
|
||||
### Position 6: Azure-Migration
|
||||
|
||||
| # | Anforderung | Offerte | Min | Mitte | Max | Begründung |
|
||||
|---|---|:-:|:-:|:-:|:-:|---|
|
||||
| 6.1 | Migration Preprocessor | 6 | 4 | 6 | 8 | Config-Änderungen, Env-Files, Netzwerk. |
|
||||
| 6.2 | Validierung & Smoke-Tests | 4 | 4 | 4 | 4 | End-to-End-Tests. |
|
||||
| | **Subtotal** | **10** | **8** | **10** | **12** | |
|
||||
|
||||
**Delta zur Offerte: 0h (Mitte)**
|
||||
**Bewertung:** Realistisch. Einfachste Position.
|
||||
|
||||
---
|
||||
|
||||
### Position 7: Projektmanagement
|
||||
|
||||
| # | Anforderung | Offerte | Min | Mitte | Max | Begründung |
|
||||
|---|---|:-:|:-:|:-:|:-:|---|
|
||||
| 7.1 | Kick-off & Workshop | 4 | 4 | 4 | 4 | Standard. |
|
||||
| 7.2 | Projektmanagement | 8 | 10 | 12 | 14 | 10-14 Wochen, 3 Ansprechpartner, 7 Positionen. |
|
||||
| 7.3 | Deployment & Go-Live | 6 | 6 | 7 | 8 | Staging + Prod + erste Betriebswoche. |
|
||||
| | **Subtotal** | **18** | **20** | **23** | **26** | |
|
||||
|
||||
**Delta zur Offerte: +5h (Mitte) / +8h (Max)**
|
||||
**Haupttreiber:** PM-Aufwand bei 3-Monats-Projekt mit mehreren Stakeholdern ist erfahrungsgemäss höher.
|
||||
|
||||
---
|
||||
|
||||
## 3. Gesamtübersicht
|
||||
|
||||
| Pos. | Beschreibung | Offerte (h) | Min (h) | Mitte (h) | Max (h) | Offerte CHF | Mitte CHF |
|
||||
|---|---|:-:|:-:|:-:|:-:|:-:|:-:|
|
||||
| 1 | Basics | 54 | 53 | 61 | 68 | 8'100 | 9'150 |
|
||||
| 2 | Kalktool | 92 | 88 | 102 | 116 | 13'800 | 15'300 |
|
||||
| 3 | Materialmanagement 1 | 52 | 50 | 58 | 66 | 7'800 | 8'700 |
|
||||
| 4 | Materialmanagement 2 | 60 | 60 | 70 | 80 | 9'000 | 10'500 |
|
||||
| 5 | Wiki-Anbindung | 44 | 44 | 54 | 64 | 6'600 | 8'100 |
|
||||
| 6 | Azure-Migration | 10 | 8 | 10 | 12 | 1'500 | 1'500 |
|
||||
| 7 | Projektmanagement | 18 | 20 | 23 | 26 | 2'700 | 3'450 |
|
||||
| | **Gesamt** | **330** | **323** | **378** | **432** | **49'500** | **56'700** |
|
||||
|
||||
### Zusammenfassung
|
||||
|
||||
| Szenario | Stunden | CHF (à 150/h) | Differenz zur Offerte |
|
||||
|---|:-:|:-:|:-:|
|
||||
| Offerte (Kostendach) | 330 | 49'500 | -- |
|
||||
| Eigene Schätzung (Minimum) | 323 | 48'450 | -2% |
|
||||
| **Eigene Schätzung (Mitte)** | **378** | **56'700** | **+15%** |
|
||||
| Eigene Schätzung (Maximum) | 432 | 64'800 | +31% |
|
||||
|
||||
---
|
||||
|
||||
## 4. Risikobewertung
|
||||
|
||||
### Risikomatrix
|
||||
|
||||
| # | Risiko | Wahrscheinlichkeit | Auswirkung | Betroffene Pos. | Möglicher Mehraufwand |
|
||||
|---|---|:-:|:-:|---|:-:|
|
||||
| R1 | Matching-Engine komplexer als erwartet | Hoch | Hoch | 2.2 | +10-15h |
|
||||
| R2 | Wiki-System ohne API | Mittel | Hoch | 5.1, 5.3 | +10-20h |
|
||||
| R3 | ERP-Datenqualität mangelhaft | Mittel | Mittel | 1.5, 2.2, 3.1, 4.1 | +8-16h |
|
||||
| R4 | Preprocessor-Erweiterung aufwändiger | Mittel | Mittel | 1.5, 3.1, 4.1 | +8-12h |
|
||||
| R5 | Frontend-Aufwand unterschätzt | Mittel | Gering | 1.2, 1.4 | +4-8h |
|
||||
| R6 | KI-Modell-Qualität für SQL-Generierung | Gering | Mittel | 3, 4 | +4-8h |
|
||||
|
||||
### Synergien (Aufwandsreduktion durch bestehende Komponenten)
|
||||
|
||||
| Synergie | Geschätzte Einsparung | Betroffene Pos. |
|
||||
|---|:-:|---|
|
||||
| KnowledgeService/RAG existiert produktiv | 20-30h | Pos. 5 |
|
||||
| ChatbotDocument-Modell existiert | 4-6h | Pos. 1.4, 2.1 |
|
||||
| LangGraph modular erweiterbar | 6-10h | Pos. 3, 4, 5 |
|
||||
| Prompt-Engineering über DB-Config | 2-4h | Pos. 1.1, 3.2, 4.2 |
|
||||
| Excel-Export-Pattern existiert | 2-4h | Pos. 2.5 |
|
||||
| **Gesamt Einsparung** | **34-54h** | |
|
||||
|
||||
---
|
||||
|
||||
## 5. Empfehlungen
|
||||
|
||||
### 5.1 Zur Offerte
|
||||
|
||||
Die Offerte mit 330h als Kostendach ist **ambitioniert, aber bei idealem Verlauf erreichbar**. Die grössten Risiken liegen in:
|
||||
- Position 2 (Kalktool): Die Matching-Engine ist die komplexeste Neuentwicklung
|
||||
- Position 5 (Wiki): Komplett abhängig vom Wiki-System, das noch unklärt ist
|
||||
|
||||
**Empfehlung:** Offerte bei 330h als Kostendach belassen, aber intern mit 370-380h planen. Die Differenz (~40-50h) als interne Reserve einkalkulieren.
|
||||
|
||||
### 5.2 Priorisierung
|
||||
|
||||
1. **Must-Have (Prio 1):** Pos. 1 (Basics) + Pos. 6 (Azure-Migration) -- Voraussetzung für alles
|
||||
2. **High-Value (Prio 2):** Pos. 2 (Kalktool) -- Höchster Kundennutzen, aber auch höchstes Risiko
|
||||
3. **Quick-Win (Prio 3):** Pos. 3+4 (Materialmanagement) -- Nutzen vorhandene Architektur
|
||||
4. **Abhängig (Prio 4):** Pos. 5 (Wiki) -- Erst nach Wiki-Klärung starten
|
||||
|
||||
### 5.3 Offene Punkte (vor Projektstart zu klären)
|
||||
|
||||
| # | Offener Punkt | Verantwortlich | Kritisch für |
|
||||
|---|---|---|---|
|
||||
| O1 | Wiki-System und Zugangsart klären | Althaus (Samuel) | Pos. 5 |
|
||||
| O2 | ERP-System identifizieren und Datenstrukturen dokumentieren | Althaus (Stefan) | Pos. 1.5, 3.1, 4.1 |
|
||||
| O3 | Preprocessor-Code-Review für Erweiterbarkeit | PowerOn (Entwicklung) | Pos. 1.5, 3.1, 4.1 |
|
||||
| O4 | Kalktool-Vorlage erhalten und analysieren | Althaus (Reto) | Pos. 2.5 |
|
||||
| O5 | Muster-Stücklisten für Matching-Test | Althaus (Reto) | Pos. 2.2 |
|
||||
| O6 | Azure-Subscription-Details | Althaus | Pos. 6 |
|
||||
|
||||
---
|
||||
|
||||
## 6. Zeitplan (2 Entwickler)
|
||||
|
||||
```
|
||||
Woche 1-2: Kick-off + Azure-Migration (Pos. 6) + Basics 1.1-1.3
|
||||
Entwickler A: Azure-Migration + 1.1 (Gesperrte Artikel)
|
||||
Entwickler B: 1.2 (Chat-Verlauf Frontend) + 1.3 (Lange Antworten)
|
||||
|
||||
Woche 2-5: Basics 1.4-1.6 (Grundlage für Use Cases)
|
||||
Entwickler A: 1.4 (File-Upload Full-Stack)
|
||||
Entwickler B: 1.5 (Kundenartikelnummern + Preprocessor)
|
||||
|
||||
Woche 4-9: Kalktool (Pos. 2) -- längster Block, früh starten
|
||||
Entwickler A: 2.1-2.2 (Upload + Matching-Engine)
|
||||
Entwickler B: 2.3-2.5 (Feldergänzung + Export)
|
||||
|
||||
Woche 6-9: Materialmanagement 1+2 (Pos. 3+4) -- parallel zum Kalktool
|
||||
Entwickler B: 3.1-3.4 + 4.1-4.5 (Preprocessor + Prompts)
|
||||
(Entwickler A bleibt auf Kalktool)
|
||||
|
||||
Woche 9-12: Wiki-Anbindung (Pos. 5) -- nach Klärung des Wiki-Systems
|
||||
Entwickler A: 5.1-5.2 (Connector + RAG-Integration)
|
||||
Entwickler B: 5.3 (Delta-Sync) + Integrationstests
|
||||
|
||||
Woche 12-13: Integrationstests, UAT, Go-Live (Pos. 7.3)
|
||||
Beide Entwickler: E2E-Tests + Deployment + Monitoring
|
||||
```
|
||||
|
||||
**Gesamtdauer:** 12-14 Wochen
|
||||
**Kritischer Pfad:** Pos. 1 → Pos. 2 (Kalktool braucht Upload + Kundenartikelnummern)
|
||||
|
||||
---
|
||||
|
||||
*Dokument erstellt auf Basis der Code-Analyse des Gateway-Repository (Stand 13.04.2026)*
|
||||
143
docs/althaus-bot-v2-fragenkatalog.md
Normal file
143
docs/althaus-bot-v2-fragenkatalog.md
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
# Fragenkatalog Althaus Bot v2 -- Kick-off-Vorbereitung
|
||||
|
||||
**Zweck:** Strukturierte Fragen für den Anforderungsworkshop mit W. Althaus AG
|
||||
**Erstellt:** 13. April 2026
|
||||
**Zielgruppe:** Projektleitung PowerOn + Ansprechpartner Althaus (Reto, Stefan, Samuel)
|
||||
|
||||
---
|
||||
|
||||
## A. Wiki-System (Ansprechpartner: Samuel)
|
||||
|
||||
> **Kritisch für:** Position 5 (Wiki-Anbindung) -- Aufwandsschätzung schwankt zwischen 44h und 64h je nach Wiki-System.
|
||||
|
||||
### A.1 Wiki-Identifikation
|
||||
|
||||
| # | Frage | Hintergrund |
|
||||
|---|---|---|
|
||||
| A1.1 | Welches Wiki-System wird eingesetzt? (z.B. Confluence, SharePoint Wiki, MediaWiki, DokuWiki, Notion, anderes) | Bestimmt die Anbindungsstrategie (API vs. Export vs. Crawling) |
|
||||
| A1.2 | Wo wird das Wiki gehostet? (Cloud-SaaS, On-Premise, Azure) | Netzwerk-Zugang und Firewall-Konfiguration |
|
||||
| A1.3 | Wie viele Seiten/Artikel enthält das Wiki ungefähr? | Dimensionierung der Erstindexierung und Embedding-Kosten |
|
||||
| A1.4 | In welchen Formaten liegen die Inhalte vor? (reiner Text, HTML, Markdown, eingebettete PDFs/Bilder) | Bestimmt die Extraktions-Komplexität |
|
||||
|
||||
### A.2 Technischer Zugang
|
||||
|
||||
| # | Frage | Hintergrund |
|
||||
|---|---|---|
|
||||
| A2.1 | Gibt es eine REST-API oder ähnliche Schnittstelle zum Lesen der Wiki-Inhalte? | API-Zugang = deutlich weniger Aufwand als Crawling |
|
||||
| A2.2 | Gibt es eine Export-Funktion? (z.B. XML-Export, PDF-Export, Datenbank-Dump) | Fallback wenn keine API vorhanden |
|
||||
| A2.3 | Gibt es Authentifizierung (API-Key, OAuth, LDAP)? Welche Credentials werden benötigt? | Konfiguration des Connectors |
|
||||
| A2.4 | Gibt es eine Change-API oder Webhooks, die bei Änderungen notifizieren? | Bestimmt den Aufwand für inkrementelle Updates (Pos. 5.3) |
|
||||
| A2.5 | Gibt es Zugriffsbeschränkungen auf bestimmte Wiki-Bereiche? | RBAC-Überlegungen bei der Indexierung |
|
||||
|
||||
### A.3 Inhaltliche Abgrenzung
|
||||
|
||||
| # | Frage | Hintergrund |
|
||||
|---|---|---|
|
||||
| A3.1 | Soll das gesamte Wiki indexiert werden oder nur bestimmte Bereiche? | Scope-Begrenzung für Erstindexierung |
|
||||
| A3.2 | Gibt es vertrauliche Inhalte, die nicht in den Chatbot einfliessen dürfen? | Datenschutz-/Compliance-Anforderung |
|
||||
| A3.3 | Wie oft werden Wiki-Inhalte aktualisiert? (täglich, wöchentlich, selten) | Bestimmt die Sync-Frequenz |
|
||||
| A3.4 | Welche Sprache(n) haben die Wiki-Inhalte? (Deutsch, Englisch, gemischt) | Embedding-Modell-Auswahl |
|
||||
|
||||
---
|
||||
|
||||
## B. ERP-System & Datenstrukturen (Ansprechpartner: Stefan)
|
||||
|
||||
> **Kritisch für:** Positionen 1.5, 2.2-2.3, 3.1, 4.1 -- Preprocessor-Erweiterungen und Matching-Engine.
|
||||
|
||||
### B.1 ERP-Identifikation
|
||||
|
||||
| # | Frage | Hintergrund |
|
||||
|---|---|---|
|
||||
| B1.1 | Welches ERP-System wird eingesetzt? (z.B. Abacus, SAP, Microsoft Dynamics, bexio, Sage) | Bestimmt Datenstruktur und Zugriffsmöglichkeiten |
|
||||
| B1.2 | Wie werden die Daten aktuell an den Preprocessor geliefert? (direkter DB-Zugriff, API, Export-Datei) | Verständnis der bestehenden Datenpipeline |
|
||||
| B1.3 | In welchem Rhythmus werden die Daten aktualisiert? (Echtzeit, täglich, wöchentlich) | Aktualität der Chatbot-Antworten |
|
||||
|
||||
### B.2 Kundenartikelnummern (Position 1.5)
|
||||
|
||||
| # | Frage | Hintergrund |
|
||||
|---|---|---|
|
||||
| B2.1 | Gibt es im ERP eine dedizierte Tabelle für Kundenartikelnummern? Wenn ja, wie heisst sie? | Preprocessor-Schema-Erweiterung |
|
||||
| B2.2 | Wie ist die Zuordnung: 1 Kundenartikel → 1 ERP-Artikel, oder n:m? | Bestimmt die Mapping-Komplexität |
|
||||
| B2.3 | Wie viele Kundenartikelnummern gibt es ungefähr? | Dimensionierung |
|
||||
| B2.4 | Welche Felder hat die Kundenartikelnummern-Tabelle? (z.B. KundenNr, KundenArtikelNr, InterneArtikelNr, Bezeichnung) | Schema-Definition für Preprocessor |
|
||||
|
||||
### B.3 Bestellwesen & Materialmanagement (Positionen 3 + 4)
|
||||
|
||||
| # | Frage | Hintergrund |
|
||||
|---|---|---|
|
||||
| B3.1 | Welche ERP-Tabellen/Views gibt es für Bestellungen? (Bestellkopf, Bestellpositionen, Status) | Preprocessor-Erweiterung Pos. 3.1 |
|
||||
| B3.2 | Gibt es eine Tabelle für Wareneingänge mit Datum und Menge? | Liefertermin-Treue-Berechnung Pos. 4.3 |
|
||||
| B3.3 | Gibt es eine Preishistorie-Tabelle? Welche Felder enthält sie? (Datum, Preis, Lieferant, Währung) | Preisentwicklungs-Analyse Pos. 4.4 |
|
||||
| B3.4 | Gibt es ein Lagerjournal mit Buchungsdaten? | KPI-Analyse Pos. 4.1 |
|
||||
| B3.5 | Gibt es eine Bestandesbedarfsliste oder Dispositions-View? | Material-Analyse Pos. 3.4 |
|
||||
| B3.6 | Gibt es Felder für "bestätigter Liefertermin" vs. "gewünschter Liefertermin"? | Termintreue-KPI Pos. 4.3 |
|
||||
| B3.7 | Wie viele offene Bestellungen gibt es typischerweise gleichzeitig? | Performance-Dimensionierung |
|
||||
|
||||
### B.4 Datenqualität
|
||||
|
||||
| # | Frage | Hintergrund |
|
||||
|---|---|---|
|
||||
| B4.1 | Wie konsistent sind Lieferanten-Namen im ERP? (exakt gleich oder Varianten wie "Siemens AG" vs. "Siemens") | Matching-Qualität Pos. 2.2 |
|
||||
| B4.2 | Gibt es Pflichtfelder die häufig leer sind? | Feldergänzungs-Logik Pos. 2.3 |
|
||||
| B4.3 | Wie sind Preise gespeichert? (Netto, Brutto, mit/ohne MwSt., Währung) | SQL-Query-Generierung |
|
||||
| B4.4 | Werden gelöschte/gesperrte Datensätze physisch oder nur logisch gelöscht? | Filter-Logik Pos. 1.1 |
|
||||
|
||||
---
|
||||
|
||||
## C. Kalktool (Ansprechpartner: Reto)
|
||||
|
||||
> **Kritisch für:** Position 2 (Kalktool) -- Höchstes Risiko in der Offerte.
|
||||
|
||||
### C.1 Kalktool-Vorlage
|
||||
|
||||
| # | Frage | Hintergrund |
|
||||
|---|---|---|
|
||||
| C1.1 | Können wir die aktuelle Kalktool-Vorlage (Kalktool_Aktuell_2026_V1.4.xlsx) erhalten? | Zielformat für Excel-Export Pos. 2.5 |
|
||||
| C1.2 | Welche Spalten/Felder sind Pflicht in der Kalktool-Vorlage? | Feldergänzungs-Priorität Pos. 2.3 |
|
||||
| C1.3 | Gibt es Formeln in der Vorlage, die erhalten bleiben müssen? | Komplexität des Excel-Exports |
|
||||
| C1.4 | Welches Format haben die Kunden-Stücklisten typischerweise? (PDF, Excel, CSV) | Extraktions-Strategie Pos. 2.1 |
|
||||
|
||||
### C.2 Matching-Anforderungen
|
||||
|
||||
| # | Frage | Hintergrund |
|
||||
|---|---|---|
|
||||
| C2.1 | Können wir 3-5 Muster-Stücklisten von verschiedenen Kunden erhalten? | Testdaten für Matching-Engine Pos. 2.2 |
|
||||
| C2.2 | Welche Identifikationsmerkmale haben Kunden-Stücklisten? (Kundenartikelnr., Hersteller-Typ, Beschreibung) | Matching-Stufen definieren |
|
||||
| C2.3 | Wie hoch ist die erwartete Trefferquote beim exakten Match? (10%? 50%? 90%?) | Gewichtung exakt vs. fuzzy vs. KI |
|
||||
| C2.4 | Welche Felder sollen bei nicht-eindeutigem Match als "Alternative durch KI" markiert werden? | Bestätigungs-Workflow Pos. 2.4 |
|
||||
| C2.5 | Gibt es Produktgruppen, die besonders schwierig zu matchen sind? | Risikobewertung |
|
||||
|
||||
---
|
||||
|
||||
## D. Infrastruktur & Azure (Ansprechpartner: Stefan / IT)
|
||||
|
||||
| # | Frage | Hintergrund |
|
||||
|---|---|---|
|
||||
| D1 | Details zur neuen Azure-Subscription (Subscription-ID, Region, Resource Group) | Pos. 6 -- Migration |
|
||||
| D2 | Gibt es Netzwerk-Einschränkungen (VPN, Private Endpoints, Firewall)? | Zugang Preprocessor ↔ ERP |
|
||||
| D3 | Wer hat Admin-Zugang zur neuen Subscription? | Deployment-Planung |
|
||||
| D4 | Gibt es Budget-Limits auf der Azure-Subscription? | Betriebskosten-Planung |
|
||||
|
||||
---
|
||||
|
||||
## E. Priorisierung & Vorgehensweise
|
||||
|
||||
| # | Frage | Hintergrund |
|
||||
|---|---|---|
|
||||
| E1 | Sollen alle 7 Positionen umgesetzt werden, oder gibt es eine Priorisierung? | Scope-Bestätigung |
|
||||
| E2 | Gibt es einen gewünschten Go-Live-Termin? | Zeitplanung |
|
||||
| E3 | Wie soll die UAT organisiert werden? (dedizierte Testphase, laufend, Key-User) | Testplanung |
|
||||
| E4 | Wer sind die Pilot-User für den reaktivierten Bot? | UAT-Teilnehmer |
|
||||
| E5 | Sollen Schulungen für Endanwender durchgeführt werden? (nicht in Offerte enthalten) | Ggf. Nachtragsofferte |
|
||||
|
||||
---
|
||||
|
||||
## Nächste Schritte
|
||||
|
||||
1. **Vor dem Kick-off:** Fragenkatalog an Althaus senden, damit Antworten vorbereitet werden können
|
||||
2. **Im Kick-off:** Fragen durchgehen, fehlende Antworten als Action Items festhalten
|
||||
3. **Nach dem Kick-off:** Aufwandsschätzung anhand der Antworten finalisieren, insbesondere Pos. 2.2 (Matching) und Pos. 5 (Wiki)
|
||||
|
||||
---
|
||||
|
||||
*PowerOn AG -- Vorbereitung Anforderungsworkshop Althaus Bot v2*
|
||||
223
docs/althaus-bot-v2-preprocessor-assessment.md
Normal file
223
docs/althaus-bot-v2-preprocessor-assessment.md
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
# Preprocessor Assessment -- Althaus Bot v2
|
||||
|
||||
**Zweck:** Technische Analyse des Preprocessing-Servers für die Aufwandsschätzung der Erweiterungen
|
||||
**Erstellt:** 13. April 2026
|
||||
**Quellen:** Gateway-Code-Analyse (Repo nicht lokal verfügbar: github.com/valueonag/gateway_preprocessing)
|
||||
|
||||
---
|
||||
|
||||
## 1. Ist-Zustand (abgeleitet aus Gateway-Code)
|
||||
|
||||
### 1.1 Infrastruktur
|
||||
|
||||
| Eigenschaft | Wert |
|
||||
|---|---|
|
||||
| **Host** | Azure App Service (Switzerland North) |
|
||||
| **URL (Datenverarbeitung)** | `poweron-althaus-preprocess-prod-*.azurewebsites.net/api/v1/dataprocessor/update-db-with-config` |
|
||||
| **URL (Abfragen)** | `poweron-althaus-preprocess-prod-*.azurewebsites.net/api/v1/dataquery/query` |
|
||||
| **Authentifizierung** | `X-PP-API-Key` (Abfragen) / `X-DB-API-Key` (Abfragen) |
|
||||
| **Status** | Deployed, ERP-Datenanbindung deaktiviert |
|
||||
| **Quellcode** | `github.com/valueonag/gateway_preprocessing` (separates Repo) |
|
||||
|
||||
### 1.2 Aktuelle Tabellen-Konfiguration
|
||||
|
||||
Aus dem Automation-Template (`subAutomationTemplates.py`) extrahiert:
|
||||
|
||||
```json
|
||||
{
|
||||
"tables": [
|
||||
{
|
||||
"name": "Artikel",
|
||||
"powerbi_table_name": "Artikel",
|
||||
"steps": [
|
||||
{
|
||||
"keep": {
|
||||
"columns": [
|
||||
"I_ID", "Artikelbeschrieb", "Artikelbezeichnung",
|
||||
"Artikelgruppe", "Artikelkategorie", "Artikelkürzel",
|
||||
"Artikelnummer", "Einheit", "Gesperrt",
|
||||
"Keywords", "Lieferant", "Warengruppe"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"fillna": {
|
||||
"column": "Lieferant",
|
||||
"value": "Unbekannt"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Einkaufspreis",
|
||||
"powerbi_table_name": "Einkaufspreis",
|
||||
"steps": [
|
||||
{
|
||||
"to_numeric": {
|
||||
"column": "EP_CHF",
|
||||
"errors": "coerce"
|
||||
}
|
||||
},
|
||||
{
|
||||
"dropna": {
|
||||
"subset": ["EP_CHF"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 1.3 Zusätzliche Tabellen (im Chatbot referenziert, aber nicht in der Config)
|
||||
|
||||
Aus den SQL-Beispielen in `bridges/tools.py` und `chatbot.py`:
|
||||
|
||||
| Tabelle | Spalten (referenziert im Code) | Joins |
|
||||
|---|---|---|
|
||||
| `Lagerplatz_Artikel` | `R_ARTIKEL`, `R_LAGERPLATZ`, `S_IST_BESTAND`, `S_RESERVIERTER__BESTAND` | ON `Artikel.I_ID = Lagerplatz_Artikel.R_ARTIKEL` |
|
||||
| `Lagerplatz` | `I_ID`, `Lagerplatz` (Name) | ON `Lagerplatz_Artikel.R_LAGERPLATZ = Lagerplatz.I_ID` |
|
||||
|
||||
Diese Tabellen sind vermutlich in einer älteren Config-Version oder direkt im Preprocessor konfiguriert.
|
||||
|
||||
### 1.4 API-Schnittstellen
|
||||
|
||||
**Abfrage-API** (genutzt vom `PreprocessorConnector`):
|
||||
- Methode: `POST`
|
||||
- Payload: `{"query": "SELECT ..."}`
|
||||
- Header: `X-DB-API-Key: <api_key>`
|
||||
- Response: `{"success": true/false, "data": [...], "row_count": N, "message": "..."}`
|
||||
- Einschränkung: Nur SELECT-Queries (validiert im Gateway)
|
||||
|
||||
**Update-API** (genutzt vom Automation-Template):
|
||||
- Methode: `POST`
|
||||
- Payload: `configJson` (Tabellendefinitionen + Transformationsschritte)
|
||||
- Header: `X-PP-API-Key: <secret>`
|
||||
- Zweck: Datenbank mit neuer Konfiguration aktualisieren
|
||||
|
||||
### 1.5 Transformation-Steps (bekannte Operationen)
|
||||
|
||||
Aus der Config-JSON abgeleitet:
|
||||
|
||||
| Operation | Parameter | Beschreibung |
|
||||
|---|---|---|
|
||||
| `keep` | `columns: [...]` | Nur angegebene Spalten behalten |
|
||||
| `fillna` | `column`, `value` | NULL-Werte ersetzen |
|
||||
| `to_numeric` | `column`, `errors` | Spalte in numerischen Typ konvertieren |
|
||||
| `dropna` | `subset: [...]` | Zeilen mit NULL in angegebenen Spalten entfernen |
|
||||
|
||||
---
|
||||
|
||||
## 2. Benötigte Erweiterungen (nach Position)
|
||||
|
||||
### 2.1 Position 1.5: Kundenartikelnummern
|
||||
|
||||
**Neue Tabelle: `Kundenartikelnummer`**
|
||||
|
||||
| Spalte (geschätzt) | Typ | Beschreibung |
|
||||
|---|---|---|
|
||||
| `I_ID` | INT | Primary Key |
|
||||
| `R_ARTIKEL` | INT | FK auf Artikel.I_ID |
|
||||
| `Kundenummer` | VARCHAR | Kundennummer |
|
||||
| `Kundenartikelnummer` | VARCHAR | Kunden-eigene Artikelnummer |
|
||||
| `Bezeichnung` | VARCHAR | Kundenbezeichnung (optional) |
|
||||
|
||||
**Config-Erweiterung:**
|
||||
```json
|
||||
{
|
||||
"name": "Kundenartikelnummer",
|
||||
"powerbi_table_name": "Kundenartikelnummer",
|
||||
"steps": [
|
||||
{"keep": {"columns": ["I_ID", "R_ARTIKEL", "Kundenummer", "Kundenartikelnummer", "Bezeichnung"]}}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Aufwand-Bewertung:** Falls der Preprocessor neue Tabellen per Config akzeptiert: ~2-3h Config + Test. Falls neuer Code nötig: ~6-8h.
|
||||
|
||||
### 2.2 Position 3.1: Bestellwesen (Materialmanagement 1)
|
||||
|
||||
**Neue Tabellen (geschätzt 3-4 Tabellen):**
|
||||
|
||||
| Tabelle | Wichtige Spalten | Zweck |
|
||||
|---|---|---|
|
||||
| `Bestellkopf` | ID, Bestellnummer, Lieferant, Bestelldatum, Status, Wunschtermin | Bestellübersicht |
|
||||
| `Bestellposition` | ID, R_Bestellung, R_Artikel, Menge, Preis, Status, Bestätigter_Termin | Positionsdetails |
|
||||
| `Wareneingang` | ID, R_Bestellung, R_Position, Eingangsdatum, Menge, Qualität | Lieferverfolgung |
|
||||
| `Auftrag` | ID, Auftragsnummer, Kunde, R_Artikel, Menge, Termin | Betroffene Aufträge |
|
||||
|
||||
**Aufwand-Bewertung:** 4 Tabellen × ~4h pro Tabelle (Config + Code + Transformationen + Test) = ~16h. Bei komplexen Transformationen (Joins, Aggregationen): +4-6h.
|
||||
|
||||
### 2.3 Position 4.1: KPI-Daten (Materialmanagement 2)
|
||||
|
||||
**Neue Tabellen/Views (geschätzt 3-4):**
|
||||
|
||||
| Tabelle/View | Wichtige Spalten | Zweck |
|
||||
|---|---|---|
|
||||
| `Lagerjournal` | ID, R_Artikel, Buchungsdatum, Menge, Typ | Lagerbewegungen |
|
||||
| `Preishistorie` | ID, R_Artikel, R_Lieferant, Datum, Preis, Währung | Preisentwicklung |
|
||||
| `Bestandesbedarfsliste` | R_Artikel, Bedarf, Bestand, Fehlmenge, Datum | Dispositionsplanung |
|
||||
| `View_Termintreue` | R_Lieferant, Wunschtermin, Bestätigt, Geliefert, Abweichung_Tage | Aggregierte KPIs |
|
||||
|
||||
**Aufwand-Bewertung:** 4 Tabellen/Views × ~4h = ~16h. Aggregierte Views (Termintreue): +4-6h für Berechnungslogik im Preprocessor.
|
||||
|
||||
---
|
||||
|
||||
## 3. Gesamtbewertung Preprocessor-Erweiterungen
|
||||
|
||||
### 3.1 Zusammenfassung
|
||||
|
||||
| Position | Neue Tabellen | Config-Aufwand | Code-Aufwand | Test | Gesamt |
|
||||
|---|:-:|:-:|:-:|:-:|:-:|
|
||||
| 1.5 (Kundenartikelnummern) | 1 | 1h | 3-5h | 2h | **6-8h** |
|
||||
| 3.1 (Bestellwesen) | 3-4 | 2h | 8-12h | 4h | **14-18h** |
|
||||
| 4.1 (KPIs) | 3-4 | 2h | 8-12h | 4h | **14-18h** |
|
||||
| **Gesamt** | **7-9** | **5h** | **19-29h** | **10h** | **34-44h** |
|
||||
|
||||
### 3.2 Offene Fragen (Code-Review des Preprocessor-Repos erforderlich)
|
||||
|
||||
| # | Frage | Auswirkung |
|
||||
|---|---|---|
|
||||
| P1 | Unterstützt der Preprocessor neue Tabellen per Config-Erweiterung, oder muss für jede Tabelle Code geschrieben werden? | Bestimmt ob Config-only (~2h/Tabelle) oder Code (~4h/Tabelle) |
|
||||
| P2 | Können aggregierte Views/Berechnungen im Preprocessor definiert werden? | Termintreue-KPI, Bestandsreichweite |
|
||||
| P3 | Wie werden Joins zwischen Tabellen gehandhabt? (SQLite-seitig oder Preprocessor-seitig) | Komplexität der Cross-Table-Queries |
|
||||
| P4 | Gibt es Rate-Limits oder Grössen-Limits bei der Query-API? | Performance bei komplexen KPI-Abfragen |
|
||||
| P5 | Wie gross ist die aktuelle SQLite-Datenbank? Wie viele Artikel? | Dimensionierung für 8-10 neue Tabellen |
|
||||
|
||||
### 3.3 Empfehlung
|
||||
|
||||
**Vor Projektstart sollte ein Code-Review des Preprocessor-Repos durchgeführt werden** (geschätzter Aufwand: 2-4h). Dabei klären:
|
||||
|
||||
1. Erweiterbarkeit: Kann der Preprocessor neue Tabellen per Config akzeptieren?
|
||||
2. Transformationen: Welche Operationen sind neben `keep`, `fillna`, `to_numeric`, `dropna` verfügbar?
|
||||
3. Performance: Wie skaliert die SQLite-DB mit 8-10 zusätzlichen Tabellen?
|
||||
4. Deployment: Wie wird der Preprocessor deployed? (CI/CD, manuell, Azure DevOps)
|
||||
|
||||
Das Ergebnis dieses Reviews kann die Aufwandsschätzung für Pos. 1.5, 3.1 und 4.1 um jeweils 4-6h nach oben oder unten korrigieren.
|
||||
|
||||
---
|
||||
|
||||
## 4. Aktueller Datenfluss (zur Referenz)
|
||||
|
||||
```
|
||||
ERP (Althaus)
|
||||
│
|
||||
▼ (Power BI Export / API / DB-Zugriff -- Mechanismus unklar)
|
||||
Preprocessor Server (Azure)
|
||||
│
|
||||
├── /api/v1/dataprocessor/update-db-with-config ← Automation-Template
|
||||
│ (Tabellen laden, transformieren, in SQLite schreiben)
|
||||
│
|
||||
└── /api/v1/dataquery/query ← PreprocessorConnector (Gateway)
|
||||
(SQL SELECT auf SQLite ausführen)
|
||||
│
|
||||
▼
|
||||
Gateway (Chatbot LangGraph)
|
||||
│
|
||||
▼
|
||||
React Frontend (Chat-UI)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Assessment erstellt auf Basis der Gateway-Code-Analyse. Für eine genauere Schätzung ist ein Code-Review des Preprocessor-Repos erforderlich.*
|
||||
97
env-dev.env
97
env-dev.env
|
|
@ -1,97 +0,0 @@
|
|||
# Development Environment Configuration
|
||||
|
||||
# System Configuration
|
||||
APP_ENV_TYPE = dev
|
||||
APP_ENV_LABEL = Development Instance Patrick
|
||||
APP_API_URL = http://localhost:8000
|
||||
APP_KEY_SYSVAR = D:/Athi/Local/Web/poweron-swiss/local/notes/key.txt
|
||||
APP_INIT_PASS_ADMIN_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEeFFtRGtQeVUtcjlrU3dab1ZxUm9WSks0MlJVYUtERFlqUElHemZrOGNENk1tcmJNX3Vxc01UMDhlNU40VzZZRVBpUGNmT3podzZrOGhOeEJIUEt4eVlSWG5UYXA3d09DVXlLT21Kb1JYSUU9
|
||||
APP_INIT_PASS_EVENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERzZjNm56WGVBdjJTeG5Udjd6OGQwUVotYXUzQjJ1YVNyVXVBa3NZVml3ODU0MVNkZjhWWmJwNUFkc19BcHlHMTU1Q3BRcHU0cDBoZkFlR2l6UEZQU3d2U3MtMDh5UDZteGFoQ0EyMUE1ckE9
|
||||
|
||||
# PostgreSQL DB Host
|
||||
DB_HOST=localhost
|
||||
DB_USER=poweron_dev
|
||||
DB_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEcUIxNEFfQ2xnS0RrSC1KNnUxTlVvTGZoMHgzaEI4Z3NlVzVROTVLak5Ubi1vaEZubFZaMTFKMGd6MXAxekN2d2NvMy1hRjg2UVhybktlcFA5anZ1WjFlQmZhcXdwaGhWdzRDc3ExeUhzWTg9
|
||||
DB_PORT=5432
|
||||
|
||||
# Security Configuration
|
||||
APP_JWT_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERjlrSktmZHVuQnJ1VVJDdndLaUcxZGJsT2ZlUFRlcFdOZ001RnlzM2FhLWhRV2tjWWFhaWQwQ3hkcUFvbThMcndxSjFpYTdfRV9OZGhTcksxbXFTZWg5MDZvOHpCVXBHcDJYaHlJM0tyNWRZckZsVHpQcmxTZHJoZUs1M3lfU2ljRnJaTmNSQ0w0X085OXI0QW80M2xfQnJqZmZ6VEh3TUltX0xzeE42SGtZPQ==
|
||||
APP_TOKEN_EXPIRY=300
|
||||
|
||||
# MFA Configuration
|
||||
MFA_REQUIRE_ADMINS = False
|
||||
|
||||
# CORS Configuration
|
||||
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss
|
||||
|
||||
# Logging configuration
|
||||
APP_LOGGING_LOG_LEVEL = DEBUG
|
||||
APP_LOGGING_LOG_DIR = D:/Athi/Local/Web/poweron-swiss/local/logs
|
||||
APP_LOGGING_FORMAT = %(asctime)s - %(levelname)s - %(name)s - %(message)s
|
||||
APP_LOGGING_DATE_FORMAT = %Y-%m-%d %H:%M:%S
|
||||
APP_LOGGING_CONSOLE_ENABLED = True
|
||||
APP_LOGGING_FILE_ENABLED = True
|
||||
APP_LOGGING_ROTATION_SIZE = 10485760
|
||||
APP_LOGGING_BACKUP_COUNT = 5
|
||||
|
||||
# OAuth: Auth app (login/JWT) vs Data app (Microsoft Graph / Google APIs). Same IDs until you split apps in Azure / GCP.
|
||||
Service_MSFT_AUTH_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
|
||||
Service_MSFT_AUTH_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQnFBa1kxaG9WY1FJaWdCbVFVaTllUlJfU3Y3MmJkRmkzMDVDWUNtZEhlNVhISzJPcy00ZUVZcklYLXFMV0dIODV3NXNSSFBKQ0ZsZllES3diTEgySDF0T1ZCbFZHREZtcXFGSWNZN1NJbzJzczRRQWxoeVNsNzlsa0VzMHJPWHUydjBBclo=
|
||||
Service_MSFT_AUTH_REDIRECT_URI = http://localhost:8000/api/msft/auth/login/callback
|
||||
Service_MSFT_DATA_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
|
||||
Service_MSFT_DATA_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQnFBa1kyUW96aXFVOVJlLUdyRlVvT1hVU09ILWtMZnV2M19mVUxGMnFPV3FzNTdQa3dTbHVGTDBHTk01ZThLcjh6QUR5VldVZUpfcDlZNTh5YldtLWtjTll6VzJNQ3JCQ3ZubHdmd2JvaExDOXdvQ1pjWDVQTUtFWVAtUHhwS1lFQnJXWk4=
|
||||
Service_MSFT_DATA_REDIRECT_URI = http://localhost:8000/api/msft/auth/connect/callback
|
||||
|
||||
Service_GOOGLE_AUTH_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
|
||||
Service_GOOGLE_AUTH_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQnFBa1kyd1hPd09vcVFtbVg0Sm5Nd1VYVEEtWjZMZkFndmFVS0ZlcTU0dzJnYVYzRkZWbjh0QldyZkhseDV2cUgxYkNHTzF6MXhqQlZ2N0UtbmhPeWRKUHBVdzV0Q1ROaWNuN2xjMmVzMjNZQ2ZYZ3dOTHgxaU5sTGRjVHpfakhYeWF0ZGU=
|
||||
Service_GOOGLE_AUTH_REDIRECT_URI = http://localhost:8000/api/google/auth/login/callback
|
||||
Service_GOOGLE_DATA_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
|
||||
Service_GOOGLE_DATA_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQnFBa1kySXoyd1BmTnhOd1owTUJOWm53WlZMMjFHNGJhSUwyd2NDUW9BanlRWVJPLU5jYzRlcm5QeW96d0JYUkVWVWd2dGNBVEpJbElZY2lWb0o5S0gyNnhoV1pnNXhpSFEyaklZZjcwX2lVU0ktMEJGN01DMDhXQ3k4R1BXc1Q3ejFjOEg=
|
||||
Service_GOOGLE_DATA_REDIRECT_URI = http://localhost:8000/api/google/auth/connect/callback
|
||||
|
||||
# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly.
|
||||
Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
|
||||
Service_CLICKUP_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd4ZWVBeHVtRnpIT0VBN0tSZDhLRmFmN05DOVBOelJtLWhkVnJDRVBqUkh3bDFTZFRWaWQ1cWowdGNLUk5IQzlGN1J6RFVCaW8zRnBwLVBnclJfdWgxV3pVRzFEV2lwcW5Rc19Xa1ROWXNJcUF0ajZaYUxOUXk0WHRsRmJLM25FaHV5T2IxdV92ZW1nRjhzaGpwU0l2Wm9FTkRnY2lJVjhuNHUwT29salAxYV8wPQ==
|
||||
Service_CLICKUP_OAUTH_REDIRECT_URI = http://localhost:8000/api/clickup/auth/connect/callback
|
||||
|
||||
# Infomaniak: no OAuth client. Users paste a Personal Access Token (kdrive + mail) per UI.
|
||||
|
||||
# Stripe Billing (both end with _SECRET for encryption script)
|
||||
STRIPE_SECRET_KEY_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5aHNGejgzQmpTdmprdzQxR19KZkh3MlhYUTNseFN3WnlaWjh2SDZyalN6aU9xSktkbUQwUnZrVnlvbGVRQm4yZFdiRU5aSEk5WVJuUnR4VUwtTm9OVk1WWmJQeU5QaDdib0hfVWV5U1BfYTFXRmdoOWdnOWxkb3JFQmF3bm45UjFUVUxmWGtGRkFKUGd6bmhpQlFnaVI3Q2lLdDlsY1VESk1vOEM0ZFBJNW1qcVZ0N2tPYmRLNmVKajZ2M3o3S05lWnRRVG5LdkRseW4wQ3VjNHNQZTZUdz09
|
||||
STRIPE_WEBHOOK_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5dDJMSHBrVk8wTzJhU2xzTTZCZWdvWmU2NGI2WklfRXRJZVUzaVYyOU9GLUZsalUwa2lPdEgtUHo0dVVvRDU1cy1saHJyU0Rxa2xQZjBuakExQzk3bmxBcU9WbEIxUEtpR1JoUFMxZG9ISGRZUXFhdFpSMGxvQUV3a0VLQllfUUtCOHZwTGdteV9rYTFOazBfSlN3ekNWblFpakJlZVlCTmNkWWQ4Sm01a1RCWTlnTlFHWVA0MkZYMlprUExrWFN2V0NVU1BTd1NKczFJbVo3VHpLdlc4UT09
|
||||
STRIPE_API_VERSION = 2026-01-28.clover
|
||||
STRIPE_AUTOMATIC_TAX_ENABLED = false
|
||||
STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQd14OUoIL0Osj7A0ZQlr0
|
||||
|
||||
# AI configuration
|
||||
Connector_AiOpenai_API_SECRET = DEV_ENC:Z0FBQUFBQnFGdnVHZlpWWWVaV1dERUItVjRfNWFMRXZUVjY5Ulp1ZXkyMmVZWUJPNzJ5anRucGNlOUFYSVNzZ1FaWlZLemVNbk5pbDgwOEZxbkxxM3U3UU1EdDJzczBaYmRDbld1N0hHdjROQUFmMUJmbDRMS1JWc3c4ZTFPMHY3OWVublBsUjFxNjhDSGRCaE9PR1JUN29iQjRqVFRINHB5dnJXeGxiQ2FTdnNnN283b3o1MnV3X09uc1pXeXZUclNTbjN4YWZyb2tGVmtGVmRnQmNlczI0WlZKRGZSYWgycnB0R2RfR1Fvbkt5bXN1UVBwS202SkZyRmJGTEU3MkxpcTk2d0QtOGxRckNLaTFLRnJBYlVSZDAydlpjMDVqYmktdHhuV3FLa2xrYkh4cGc3S3FGcnM9
|
||||
Connector_AiAnthropic_API_SECRET = DEV_ENC:Z0FBQUFBQnFGd0hadGRhRFBOaXJSN2E1UnpLcHlkUHhoQS1FWFJBQlJ5cGsxcHNNMDlRM1JVbEJkWWE1ZXQzTThFQkRmQTBOeVNvUERXTVRTQ3Y4ekt5NU1XR3E2cWw4UlFNUXlGSmZmZU1JZXlwT3lUVGw0aWF4V1d6cVR6LTFsbGRjWWx5dVlodWNEUFJkZ0tUa3hSbjk3WjZ1Z3RPczZMYzA5QTlMbkVudFNVcG1xaTJuM3g3dDdSSFczbWJnODQ1S1J2djBnS3lQc2NFd0ttUThRVnFma3NOVlFIZm1ZZz09
|
||||
Connector_AiPerplexity_API_SECRET = DEV_ENC:Z0FBQUFBQnFCdlFlNV9felVPcHVyMU9kVGhGZEt0MG9iRzRrTVM4TFJvSHhGOVo0U1ROWkdEMzRSWjhtMnFrZUhHTHNXelpLZ014RzRkMlIxZDJwcjEwc1dRamY5ekJMR1VLb2w4eEZqZENBRnFaZlRhb1h5VE05Tml1ZlVBWHBaTkJaZUE5NWprVklva0ZFZnB4cFFudGdkalpmTlBhdV9nPT0=
|
||||
Connector_AiTavily_API_SECRET = DEV_ENC:Z0FBQUFBQnFCdlFlY1R2WGpuazk5M05SeDIyLWd3bHpKN3lUdlVFdjhvZEJXdlM4bGlBdTB1TjRia051YllDQ2lwM0V3R3dPd2lKVWxoSm9BNWl1ZFFlVkZ5cXh4TFRVU0Z4NVU5WVRjSUJPc01La3JyaVZSNkhYWU9PR00yMENEb0dRT3l5enEwSFlWZVVzTVR0UWQ4eUxvRmZvWHl0c0xRPT0=
|
||||
Connector_AiPrivateLlm_API_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGRHM5eFdUVmVZU1R1cHBwN1RlMUx4T0NlLTJLUFFVX3J2OElDWFpuZmJHVmp4Z3BNNWMwZUVVZUd2TFhRSjVmVkVlcFlVRWtybXh0ZHloZ01ZcnVvX195YjdlWVdEcjZSWFFTTlNBWUlaTlNoLWhqVFBIb0thVlBiaWhjYjFQOFY=
|
||||
Connector_AiMistral_API_SECRET = DEV_ENC:Z0FBQUFBQnFCdlFlelh2T2hqNGcxV0hMV1FKbmFDZjVHUWF6T2FXbGlCSnQzSzNXLWJHeXBFWE1nUlh1b1NHY1JRSEVtTVEtc1MtUnZrX2ZCcURqQ2FYNmFWa2xudGJtS3g2eVo4MFZMd09nZTBNMmo1ZHU0bzBJdFRqLVhHSVZNb2Zrc0VkUXI0SVk=
|
||||
|
||||
Service_MSFT_TENANT_ID = common
|
||||
|
||||
# Google Cloud Speech Services configuration
|
||||
Connector_GoogleSpeech_API_KEY_SECRET = DEV_ENC:Z0FBQUFBQnFIc3YtU0x4LTlHbTY1NUVGY2V2bUdmck85dDh1ZWVKa2ktR0N6NjdlTGFrUHMybVQ2bVRLN01XNFRZR2lyN0ZNSHhzWVVGNnVtZjRjV2hhR0ViTDYwT25lSmxJY0pSTkl3OUEyT0JxMFVYRndfUFJudExMajdTYUNXS01JU2lhQzZmNWFYdXA4aVZ5Zkh4Zko1Z00tcEE5ZFEwQkFVa1oyR296YXozRFI2WUdXN0ZSREFFclFNaTd6OUVlSmFxS1BTSlNJbnlWNHNfbkk4QzVOUGlkMzdfQUZxUlJOVEZzUlN1aWRWY01JZmlRM0JNZE1EZ3BmbW10c3BDdERpa2FMakstQUlqVEVlRC1hUmZoeFVoQ3pYNXRlRFVSTlI3ekJrU0QwSHBSaWxiSGU0akFGMXUtY2Q0RnUzS0tPOEQtcTdVdWhQeHFDM1hRRVVMcUxCeklvWHNWRUN2bjVHZUUwLTVtaGpUbWdPUnJabWlIcHZ5UjNtN0NMTUNRN29ZRGVXU28xQmhJTVg2eEZnaUdrcW9UVklHMHJycm1nT0JkdGJReVVHeV8tYm12UDlOU0lpNHFidXBQbUFSSVVmWUl1M1BVMFFncm0xSldkVzBrb2poRFMyaVUwcUZvMHl0QlZIZ1h1MjZwR3AtZWhqdzN4UVhtT2hUa1lQU3VudzNXdW1FcVY3VnQ3RmpkQnFQemlrQlF3WGhBNWxOZXJ6Zm9KVFlEZExUXzlqODhYaFNNMzVWTzFNMmVTcWdodDZoRmZTUzlhLVlOSU5fYW1vNXctaFpFMC1pUllRZW11d1JQN25sbldHVjI1anc2UC1ycndjTGtxWk55WmpJeU1wOVR0RnlTdFpad1dkRmlUNDE0d240TDlKc3JFUXdOYzd5UTFYSXUzLTQ2Y1ZGcWE3R2RyQ0I1WDMtMHBScEFzZDV4UEkyanh4ckJZUjdTYnJGZjAxQkU3MEJ6OXdybGRaWHNod1hZZEhVOXRpMWRLbVJsRGd0UDRDN3JsRzF4T0RpcnczRU5TM0RKVjVkWTRqNTl6bmhQdmdvaEg1U2kya0QtQ0l4ZHVUcGxkNi1vNVVVOEcyWXhxZWc5N1lKMk4tT0o3ZFVzYjJtT3NVZFJiSTFNUnpaSmFOeDZaLWVpZlc0VUhZRHdXOUMyQ3cwaXBQUDRJN1g1YkwzaTFiRVRxRFY5UTdZU1dSaGR6NUw3aEtac2RENXF3WEpVN0dXVTlQR0F6MFlpWl83MU44NVR1ZUtPVUNlZ205YUIwOFoxUDBvTlI0SU52emVvQ3VZXy1jTlFXRWZXQ0d5RHJ0eV9JeE5wMHl0b3FVSjNoVzg2d21hYVNYY3Q0dkFaVEZwa09tRnFBbEtoOUlGY2xkeVJoZGYzQUxYNFZfb0ZiaU5VRjJPbGhieXYtWTFKckZwenVCUGFva1IwVVFORVQ4SDMxWHVuRWhBRGd0cVlsc3kyQ0RyY2ZIVDlwcGh5ampySV9uOVpsVmlWbGoxMEg3SXh6NzRJbmZXRlhMMWc0RXhzeWtnQlJ0VnZSdENkbEpOdENwUzItUjZhZWFYRFhzbDM1WDBxaGFPX19CSG1KZjRTTU5JemcxZzJRSFY5bkx4TTlIZFNHOW1USWxBYWhEZ1FSNVdSSDJETUZwMi1Hd0RESkF2cVA1TVJGTEtPUl9oN3gzVEIwSzZOVzlOWXhNa2I1Vzc1SV9tdENfRy1rQTNzRlZGSTYwQmJIaGswZUNWSnRDVXFfdWFCckZZcnJOT2Rfb3FrcWI4S1lVRTMyRnZJQTRZV1VsU0xobGRjekhtbG9LamR2d1hfVklsM3JBeW9SRzJnWVdiWDRzN1ltcXdSVGoxRVBvczViVXNjMUxBazZUdS1WbkRQX0h1MzdNd3ltVDUzd2FGdi1XeUMybV9ia1YxQVBPdnUxY1dfT2M5eEpZR2JHMkdZbWdDZTRERXRYOWxodndkTXltVW40c0t0bVA5YWxuRzM3LWlCdmJiYmF5dkNBY3ozbUw1Zm5zRmpBdk5ORmFZRWJKM3Q2UDdKNl9zaUV5eVVGbkF0QmZSZzk5dGo3UjNIQWxwcjRlVTdUT2s1VGFjdndvX2c3d1VmaHRMZU10M1ZKVk9Ma3dZb1kwYVV5Z2NlTjUxdUYtZXRnRTRzQlp1aFp0OUF5TVBwN1gzU21kRmJ6OUlOeUFOOEhEOU5WSENNZndvLXdoVUFJYVFDTWEyakJEcTVSVDhJOWJscU8taThqNUZkdThCOUlXcldndFBTZk9QVnlMaUphUU5sUktpb1plZDZOQnFzNFNMUzRWbWFVQWhUWmJfem96X0cxWXVTcUxCeDhOc3E2OEpFa2lzWHFIV0p3eGdBZmN1aXBhYjExZTZqaUY4S0ZudTNhcUx2WlpuTU9lNUk2ZmNyN0JCODdYMGNEU2JsZkZXYlRFaTJQUTI5RU5SMmtkV1NHQTVTTjEyZGZLYnhTNTg2Nl9aaWJqX2Q1U1NwQ3pRTGRBSUw0N3FNQ0ItMks1QVZmbURYVWdHMWFZTWhGNURVOUg0bGVuMUozanlxTnRwbVlGX2RnN2FBVTZlZjhDaXVzZEtVR1Z5azhzWHRrS1dYSG9rYkowTjQ1N0hyRWdNVWMya1ZmWmZvSnVTdHNiMHFDODNLckpjQ081SFlieGxuM0picGhKMnNQRURwY2hpQzF3dHRnNEFWcUlPYjVxZEhod0JDbWZhU01Ob21UWmRwd0NQRlpjOE5CUFBOT004U2JKNkFSUlFzRklYZGJobUoxQzZzT2wzZ3J1Z05aYThRVVNzcFktMGJDcXFfSkxVS2hhajI3dTdrR2poa21ZM3Z4UzFRblFsOFlOZVVUM0YxaFRuNjFWQ2E4ZlhvZjZpMWFtOGRuaGx0MTZxZE9TY1dsTTMyMHhsNXJ2MkduaGRkZXpYUWJ3cEt1U3YwMC1IRzM5eWRCb0lvaUhTQ2R4XzhEZl9zRk5GeHhCSWx2X3BkUkJ4NFZLVzdVRFZkbnpNNkpjUTFHY1pDV0ZOMFBaNTVpLUlmSnFrX1N5X05MTjRUeTVERUs5MG9kMFJ3di03U3BpMUM4YXNwaG1fangwYURIVjBpSVdCUkt4UW5HbWtGOUh3TUdPZjMxYXpVZDcwTmlDcTR6WldZb3VzbHRpRUgyN2lFTjlpUV85T0M4blJxMWx0cC1iU0FDOHhueDBLYjdLZGhNbjFPbE1RdmhhNlEzX3ZpT2ZsYllwNkU5TE9fZWFabDE4RWRoRWxiMk5aVFZrWmxjaW5MX1VrUGhUN29vbU1tWldESnczYTNBQ1RPd1VTNGNJdjdJU3p3QXZQLVlDNkQ1cTh4Rk1WNnRMUi1DT3VGREFPa28xejc2NUl1dzJSa2hCTlJublBRNGkydlJVRjlFbFotOWtraWFqQkNNTXBpT1hZM0NXNEpObGMxQUNuS29rOExMSnMxT3NLbjNfLTdpQW1BcDMxR1RZdVRvbElGbENWbHJqRlVrTXhYbFdiMmItUzlxR2ZxT2FCWXpMVVJYZXBfSFVwNTczU3JHUVhET3hSWm80Ry1KcE9mV3FYejVHSEVSS0pxOUtCc3V2VHNFVkRqYk5Od20tM0ttdFQ1eGdsc091WGFYNFgybzNVd3ZvbzEwUDJ0T0hvTVd3YnlHNnpNWC0wbkJOQTIwQ3VYdlUzaXY5NFhDNlNOOW9UdGZNUk4zZ0VJakpwS21SZlJtQjVWLUxfejFYZFc1cjRwR3ZUOGdZb2VJaTdJUS1MYlRJb0ZFYW9uYzM3MDd4b09BR1pnTEh3RFpnaGhxZURQamllNUhqTHg0cHJfN08wMkdGSVQwQUlqWDhLVGViY3J5NlVFTzY3RGhGQ0R6aXNsb2w4dnBVYndTd1Jhd3IwS1BxY0h1X05RcGsySzVNbXR5YlBVQi1IOGFUNkh5QjhRZk5BQmZvcGF6ZTNXenZkdy1GRjFGdE1saGdMSnotUkIyX1VqTlZFWnJER1YyNGQtMFZHU3hmRVNPUWFCdXV3QUxzOGVSbF9EdEZGUFNxbTdiYm5oWHdYak5qa3Zoem5WY1ZUdDREVUxGX0VQeS1jckhqS2lRLXQ1Y2tyOFRjYnVhajNUZmZOUE9kbU9PYXdqdk5DYUtEOVFiMW9yZTYxMFNUaDdvUTExUFZ1bklYSkRKTnJ1RURvOTR3ODREcWdWeHpRS2RETjZqeXpvbUpxMW5lWl84RzVocmJFQ3JfZlpMd3RCZEo5RWZ0MzIxNWV6bHlwdWJJWXhoaWxlM2FHSjBhWG14Sk94ZV96cXFvU1JwWDdKZldmZWdvdWVKdXVfaS1jZjdENXQzSzNyb1d3eWhUMU53QzgxemRiTTlkdFRxZU1OdEN5c1kxOEd2MTJMcnBJWEE0eXdJdFpOYVNMQTNLR292UFlGb0Ztdz0=
|
||||
|
||||
# Teamsbot Browser Bot Service
|
||||
# For local testing: run the bot locally with `npm run dev` in service-teams-browser-bot
|
||||
# The bot will connect back to localhost:8000 via WebSocket
|
||||
TEAMSBOT_BROWSER_BOT_URL = http://localhost:4100
|
||||
|
||||
# Debug Configuration
|
||||
APP_DEBUG_CHAT_WORKFLOW_ENABLED = True
|
||||
APP_DEBUG_CHAT_WORKFLOW_DIR = D:/Athi/Local/Web/poweron-swiss/local/debug
|
||||
APP_DEBUG_ACCOUNTING_SYNC_ENABLED = True
|
||||
APP_DEBUG_ACCOUNTING_SYNC_DIR = D:/Athi/Local/Web/poweron-swiss/local/debug/sync
|
||||
|
||||
# Azure Communication Services Email Configuration
|
||||
MESSAGING_ACS_CONNECTION_STRING = endpoint=https://mailing-poweron-prod.switzerland.communication.azure.com/;accesskey=4UizRfBKBgMhDgQ92IYINM6dJsO1HIeL6W1DvIX9S0GtaS1PjIXqJQQJ99CAACULyCpHwxUcAAAAAZCSuSCt
|
||||
MESSAGING_ACS_SENDER_EMAIL = DoNotReply@poweron.swiss
|
||||
|
||||
# Zurich WFS Parcels (dynamic map layer). Default: Stadt Zürich OGD. Override for full canton if wfs.zh.ch resolves.
|
||||
# Connector_ZhWfsParcels_WFS_URL = https://wfs.zh.ch/av
|
||||
# Connector_ZhWfsParcels_TYPENAMES = av_li_liegenschaften_a
|
||||
|
||||
97
env-gateway-dev.env
Normal file
97
env-gateway-dev.env
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
# Development Environment Configuration
|
||||
|
||||
# System Configuration
|
||||
APP_ENV_TYPE = dev
|
||||
APP_ENV_LABEL = Development Instance Patrick
|
||||
APP_API_URL = http://localhost:8000
|
||||
APP_KEY_SYSVAR = D:/Athi/Local/Web/poweron/local/notes/key.txt
|
||||
APP_INIT_PASS_ADMIN_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEeFFtRGtQeVUtcjlrU3dab1ZxUm9WSks0MlJVYUtERFlqUElHemZrOGNENk1tcmJNX3Vxc01UMDhlNU40VzZZRVBpUGNmT3podzZrOGhOeEJIUEt4eVlSWG5UYXA3d09DVXlLT21Kb1JYSUU9
|
||||
APP_INIT_PASS_EVENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERzZjNm56WGVBdjJTeG5Udjd6OGQwUVotYXUzQjJ1YVNyVXVBa3NZVml3ODU0MVNkZjhWWmJwNUFkc19BcHlHMTU1Q3BRcHU0cDBoZkFlR2l6UEZQU3d2U3MtMDh5UDZteGFoQ0EyMUE1ckE9
|
||||
|
||||
# PostgreSQL DB Host
|
||||
DB_HOST=localhost
|
||||
DB_USER=poweron_dev
|
||||
DB_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEcUIxNEFfQ2xnS0RrSC1KNnUxTlVvTGZoMHgzaEI4Z3NlVzVROTVLak5Ubi1vaEZubFZaMTFKMGd6MXAxekN2d2NvMy1hRjg2UVhybktlcFA5anZ1WjFlQmZhcXdwaGhWdzRDc3ExeUhzWTg9
|
||||
DB_PORT=5432
|
||||
|
||||
# Security Configuration
|
||||
APP_JWT_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERjlrSktmZHVuQnJ1VVJDdndLaUcxZGJsT2ZlUFRlcFdOZ001RnlzM2FhLWhRV2tjWWFhaWQwQ3hkcUFvbThMcndxSjFpYTdfRV9OZGhTcksxbXFTZWg5MDZvOHpCVXBHcDJYaHlJM0tyNWRZckZsVHpQcmxTZHJoZUs1M3lfU2ljRnJaTmNSQ0w0X085OXI0QW80M2xfQnJqZmZ6VEh3TUltX0xzeE42SGtZPQ==
|
||||
APP_TOKEN_EXPIRY=300
|
||||
|
||||
# CORS Configuration
|
||||
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss,https://nyla.poweron-center.net,https://nyla-int.poweron-center.net
|
||||
|
||||
# Logging configuration
|
||||
APP_LOGGING_LOG_LEVEL = DEBUG
|
||||
APP_LOGGING_LOG_DIR = D:/Athi/Local/Web/poweron/local/logs
|
||||
APP_LOGGING_FORMAT = %(asctime)s - %(levelname)s - %(name)s - %(message)s
|
||||
APP_LOGGING_DATE_FORMAT = %Y-%m-%d %H:%M:%S
|
||||
APP_LOGGING_CONSOLE_ENABLED = True
|
||||
APP_LOGGING_FILE_ENABLED = True
|
||||
APP_LOGGING_ROTATION_SIZE = 10485760
|
||||
APP_LOGGING_BACKUP_COUNT = 5
|
||||
|
||||
# OAuth: Auth app (login/JWT) vs Data app (Microsoft Graph / Google APIs). Same IDs until you split apps in Azure / GCP.
|
||||
Service_MSFT_AUTH_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
|
||||
Service_MSFT_AUTH_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQnFBa1kxaG9WY1FJaWdCbVFVaTllUlJfU3Y3MmJkRmkzMDVDWUNtZEhlNVhISzJPcy00ZUVZcklYLXFMV0dIODV3NXNSSFBKQ0ZsZllES3diTEgySDF0T1ZCbFZHREZtcXFGSWNZN1NJbzJzczRRQWxoeVNsNzlsa0VzMHJPWHUydjBBclo=
|
||||
Service_MSFT_AUTH_REDIRECT_URI = http://localhost:8000/api/msft/auth/login/callback
|
||||
Service_MSFT_DATA_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
|
||||
Service_MSFT_DATA_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQnFBa1kyUW96aXFVOVJlLUdyRlVvT1hVU09ILWtMZnV2M19mVUxGMnFPV3FzNTdQa3dTbHVGTDBHTk01ZThLcjh6QUR5VldVZUpfcDlZNTh5YldtLWtjTll6VzJNQ3JCQ3ZubHdmd2JvaExDOXdvQ1pjWDVQTUtFWVAtUHhwS1lFQnJXWk4=
|
||||
Service_MSFT_DATA_REDIRECT_URI = http://localhost:8000/api/msft/auth/connect/callback
|
||||
|
||||
Service_GOOGLE_AUTH_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
|
||||
Service_GOOGLE_AUTH_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQnFBa1kyd1hPd09vcVFtbVg0Sm5Nd1VYVEEtWjZMZkFndmFVS0ZlcTU0dzJnYVYzRkZWbjh0QldyZkhseDV2cUgxYkNHTzF6MXhqQlZ2N0UtbmhPeWRKUHBVdzV0Q1ROaWNuN2xjMmVzMjNZQ2ZYZ3dOTHgxaU5sTGRjVHpfakhYeWF0ZGU=
|
||||
Service_GOOGLE_AUTH_REDIRECT_URI = http://localhost:8000/api/google/auth/login/callback
|
||||
Service_GOOGLE_DATA_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
|
||||
Service_GOOGLE_DATA_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQnFBa1kySXoyd1BmTnhOd1owTUJOWm53WlZMMjFHNGJhSUwyd2NDUW9BanlRWVJPLU5jYzRlcm5QeW96d0JYUkVWVWd2dGNBVEpJbElZY2lWb0o5S0gyNnhoV1pnNXhpSFEyaklZZjcwX2lVU0ktMEJGN01DMDhXQ3k4R1BXc1Q3ejFjOEg=
|
||||
Service_GOOGLE_DATA_REDIRECT_URI = http://localhost:8000/api/google/auth/connect/callback
|
||||
|
||||
# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly.
|
||||
Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
|
||||
Service_CLICKUP_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd4ZWVBeHVtRnpIT0VBN0tSZDhLRmFmN05DOVBOelJtLWhkVnJDRVBqUkh3bDFTZFRWaWQ1cWowdGNLUk5IQzlGN1J6RFVCaW8zRnBwLVBnclJfdWgxV3pVRzFEV2lwcW5Rc19Xa1ROWXNJcUF0ajZaYUxOUXk0WHRsRmJLM25FaHV5T2IxdV92ZW1nRjhzaGpwU0l2Wm9FTkRnY2lJVjhuNHUwT29salAxYV8wPQ==
|
||||
Service_CLICKUP_OAUTH_REDIRECT_URI = http://localhost:8000/api/clickup/auth/connect/callback
|
||||
|
||||
# Infomaniak: no OAuth client. Users paste a Personal Access Token (kdrive + mail) per UI.
|
||||
|
||||
# Stripe Billing (both end with _SECRET for encryption script)
|
||||
STRIPE_SECRET_KEY_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5aHNGejgzQmpTdmprdzQxR19KZkh3MlhYUTNseFN3WnlaWjh2SDZyalN6aU9xSktkbUQwUnZrVnlvbGVRQm4yZFdiRU5aSEk5WVJuUnR4VUwtTm9OVk1WWmJQeU5QaDdib0hfVWV5U1BfYTFXRmdoOWdnOWxkb3JFQmF3bm45UjFUVUxmWGtGRkFKUGd6bmhpQlFnaVI3Q2lLdDlsY1VESk1vOEM0ZFBJNW1qcVZ0N2tPYmRLNmVKajZ2M3o3S05lWnRRVG5LdkRseW4wQ3VjNHNQZTZUdz09
|
||||
STRIPE_WEBHOOK_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5dDJMSHBrVk8wTzJhU2xzTTZCZWdvWmU2NGI2WklfRXRJZVUzaVYyOU9GLUZsalUwa2lPdEgtUHo0dVVvRDU1cy1saHJyU0Rxa2xQZjBuakExQzk3bmxBcU9WbEIxUEtpR1JoUFMxZG9ISGRZUXFhdFpSMGxvQUV3a0VLQllfUUtCOHZwTGdteV9rYTFOazBfSlN3ekNWblFpakJlZVlCTmNkWWQ4Sm01a1RCWTlnTlFHWVA0MkZYMlprUExrWFN2V0NVU1BTd1NKczFJbVo3VHpLdlc4UT09
|
||||
STRIPE_API_VERSION = 2026-01-28.clover
|
||||
STRIPE_AUTOMATIC_TAX_ENABLED = false
|
||||
STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQd14OUoIL0Osj7A0ZQlr0
|
||||
|
||||
# AI configuration
|
||||
Connector_AiOpenai_API_SECRET = DEV_ENC:Z0FBQUFBQnBaSnM4TWFRRmxVQmNQblVIYmc1Y0Q3aW9zZUtDWlNWdGZjbFpncGp2NHN2QjkxMWxibUJnZDBId252MWk5TXN3Yk14ajFIdi1CTkx2ZWx2QzF5OFR6LUx5azQ3dnNLaXJBOHNxc0tlWmtZcTFVelF4eXBSM2JkbHd2eTM0VHNXdHNtVUprZWtPVzctNlJsZHNmM20tU1N6Q1Q2cHFYSi1tNlhZNDNabTVuaEVGWmIydEhadTcyMlBURmw2aUJxOF9GTzR0dTZiNGZfOFlHaVpPZ1A1LXhhOEFtN1J5TEVNNWtMcGpyNkMzSl8xRnZsaTF1WTZrOUZmb0cxVURjSGFLS2dIYTQyZEJtTm90bEYxVWxNNXVPdTVjaVhYbXhxT3JsVDM5VjZMVFZKSE1tZnM9
|
||||
Connector_AiAnthropic_API_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpENmFBWG16STFQUVZxNzZZRzRLYTA4X3lRanF1VkF4cU45OExNMzlsQmdISGFxTUxud1dXODBKcFhMVG9KNjdWVnlTTFFROVc3NDlsdlNHLUJXeG41NDBHaXhHR0VHVWl5UW9RNkVWbmlhakRKVW5pM0R4VHk0LUw0TV9LdkljNHdBLXJua21NQkl2b3l4UkVkMGN1YjBrMmJEeWtMay1jbmxrYWJNbUV0aktCXzU1djR2d2RSQXZORTNwcG92ZUVvVGMtQzQzTTVncEZTRGRtZUFIZWQ0dz09
|
||||
Connector_AiPerplexity_API_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5ZmdDZ3hrSElrMnQzNFAtel9wX191VjVzN2g1LWZoa0V1YklubEdmMEJDdEZiR1RWeVZrM3V3enBHX3p6WUtTS0kwYkFyVEF0Nm8zX05CelVQcFJUc0lwVW5iNFczc1p1WWJ2WFBmd0lpLUxxWndEeUh0b2hGUHVpN19vb19nMTBnV1A1VmNpWERVX05lQ29VS20wTjZ3PT0=
|
||||
Connector_AiTavily_API_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEQTdnUHMwd2pIaXNtMmtCTFREd0pyQXRKb1F5eGtHSnkyOGZiUnlBOFc0b3Vzcndrc3ViRm1nMDJIOEZKYWxqdWNkZGh5N0Z4R0JlQmxXSG5pVnJUR2VYckZhMWNMZ1FNeXJ3enJLVlpiblhOZTNleUg3ZzZyUzRZanFSeDlVMkI=
|
||||
Connector_AiPrivateLlm_API_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGRHM5eFdUVmVZU1R1cHBwN1RlMUx4T0NlLTJLUFFVX3J2OElDWFpuZmJHVmp4Z3BNNWMwZUVVZUd2TFhRSjVmVkVlcFlVRWtybXh0ZHloZ01ZcnVvX195YjdlWVdEcjZSWFFTTlNBWUlaTlNoLWhqVFBIb0thVlBiaWhjYjFQOFY=
|
||||
Connector_AiMistral_API_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGeEQxYUIxOHhia0JlQWpWQ2dWQWZzY3l6SWwyUnJoR1hRQWloX2lxb2lGNkc4UnA4U2tWNjJaYzB1d1hvNG9fWUp1N3V4OW9FMGhaWVhjSlVwWEc1X2loVDBSZDEtdHdfcTA5QkcxQTR4OHc4RkRzclJrU2d1RFZpNDJkRDRURlE=
|
||||
|
||||
Service_MSFT_TENANT_ID = common
|
||||
|
||||
# Google Cloud Speech Services configuration
|
||||
Connector_GoogleSpeech_API_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETk5FWWM3Q0JKMzhIYTlyMkhuNjA4NlF4dk82U2NScHhTVGY3UG83NkhfX3RrcWVtWWcyLXRjU1dTT21zWEl6YWRMMUFndXpsUnJOeHh3QThsNDZKRXROTzdXRUdsT0JZajZJNVlfb0gtMXkwWm9DOERPVnpjU0pyUEZfOGJsUnprT3ltMVVhalUyUm9hMUFtZEtHUnJqOGZ4dEZjZm5SWVVTckVCWnY1UkdVSHVmUlgwbnAyc0xDQW84R3ViSko5OHVCVWZRUVNiaG1pVFB6X3EwS0FPd2dUYjhiSmRjcXh2WEZiXzI4SFZqT21tbDduUWRyVWdFZXpmcVM5ZDR0VWtzZnF5UER6cGwwS2JlLV9CSTZ0Z0IyQ1h0YW9TcmhRTXZEckp4bWhmTkt6UTNYMk4zVkpnbUJmaDIxZnoyR2dWTEYwTUFEV0w2eUdUUGpoZk9XRkt4RVF1Z1NPdUpBeTcyWV9PY1Ffd2s0ZEdVekxGekhoeEl4TmNqaXYtbUJuSVdycFducERWdWtZajZnX011Q2w4eE9VMTBqQ1ZxRmdScWhXY1E3WWhzX1JZcHhxam9FbDVPN3Q1MWtrMUZuTUg3LVFQVHp1T1hpQWNDMzEzekVJWk9ybl91YUVjSkFob1VaMi1ONEtuMnRSOEg1S3QybUMwbVZDejItajBLTjM2Zy1hNzZQMW5LLVVDVGdFWm5BZUxNeEFnUkZzU3dxV0lCUlc0LWo4b05GczVpOGZSV2ZxbFBwUml6OU5tYjdnTks3Y3hrVEZVTHlmc1NPdFh4WE5pWldEZklOQUxBbjBpMTlkX3FFQVJ6c2NSZGdzTThycE92VW82enZKamhiRGFnU25aZGlHZHhZd2lUUmhuTVptNjhoWVlJQkxIOEkzbzJNMjZCZFJyM25tdXBnQ2ZWaHV3b2p6UWJpdk9xUEhBc1dyTlNmeF9wbm5yYUhHV01UZnVXWDFlNzBkdXlWUWhvcmJpSmljbmE3LUpUZEg4VzRwZ2JVSjdYUm1sODViQXVxUzdGTmZFbVpiN2V1YW5XV3U4b2VRWmxldGVGVHZsSldoekhVLU9wZ2V0cGZIYkNqM2pXVGctQVAyUm4xTHhpd1VVLXFhcnVEV21Rby1hbTlqTl84TjVveHdYTExUVkhHQ0ltaTB2WXJnY1NQVE5PbWg3ejgySElYc1JSTlQ3NDlFUWR6STZVUjVqaXFRN200NF9LY1ljQ0R2UldlWUtKY1NQVnJ4QXRyYTBGSWVuenhyM0Z0cWtndTd1eG8xRzY5a2dNZ1hkQm5MV3BHVzA2N1QwUkd6WlRGYTZQOUhnVWQ2S0Y5U0s1dXFNVXh5Q2pLWVUxSUQ2MlR1ak52NmRIZ2hlYTk1SGZGWS1RV3hWVU9rR3d1Rk9MLS11REZXbzhqMHpsSm1HYW1jMUNLT29YOHZsRWNaLTVvOFpmT3l3MHVwaERTT0dNLWFjcGRYZ25qT2szTkVFUnRFR3JWYS1aNXFIRnMyalozTlQzNFF2NXJLVHVPVF9zdTF6ZjlkbzJ4RFc2ZENmNFFxZDZzTzhfMUl0bW96V0lPZkh1dXFYZlEteFBlSG84Si1FNS1TTi1OMkFnX2pOYW8xY3MxMVJnVC02MDUyaXZfMEVHWDQtVlRpcENmV0h3V0dCWEFRS2prQXdNRlQ5dnRFVHU0Q1dNTmh0SlBCaU55bFMydWM1TTFFLW96ODBnV3dNZHFZTWZhRURYSHlrdzF3RlRuWDBoQUhSOUJWemtRM3pxcDJFbGJoaTJ3ZktRTlJxbXltaHBoZXVJVDlxS3cxNWo2c0ZBV0NzaUstRWdsMW1xLXFkanZGYUFiU0tSLXFQa0tkcDFoMV9kak41ZjQ0R214UmtOR1ZBanRuemY3Mmw1SkZ5aDZodGIzT3N2aV85MW9kcld6c0g0ZDgtTWo3b3Y3VjJCRnR2U2tMVm9rUXNVRnVHbzZXVTZ6RmI2RkNmajBfMWVnODVFbnpkT0oyci15czJHU0p1cUowTGZJMzVnd3hIRjQyTVhKOGRkcFRKdVpyQ3Yzd01Jb1lSajFmV0paeEV0cjk1SmpmdWpDVFJMUmMtUFctOGhaTmlKQXNRVlVUNlhJemxudHZCR056SVlBb3NOTEYxRTRLaFlVd2d3TWtxVlB6ZEtQLTkxOGMyY3N0a2pYRFUweDBNaGhja2xSSklPOUZla1dKTWRNbG8tUGdSNEV5cW90OWlOZFlIUExBd3U2b2hyS1owbXVMM3p0Qm41cUtzWUxYNzB1N3JpUTNBSGdsT0NuamNTb1lIbXR4MG1sakNPVkxBUXRLVE1xX0YxWDhOcERIY1lTQVFqS01CaXZKNllFaXlIR0JsM1pKMmV1OUo3TGI1WkRaVnYxUTl1LTM0SU1qN1V1b0RCT0x0VHNLTmNLZnk1S0MxYnBBcm03WnVua0xqaEhGUzhOU253ZkppRzdudXBSVlMxeFVOSWxtZ1o2RVBSQUhEUEFuQ1hxSVZMME4yWUtaU3VyRGo3RkUyRUNjT0pNcE1BdE1ZRzdXVl8ydUtXZjdMdHdEVW4teHUtTi1HSGliLUxud21TX0NtcGVkRFBHNkZ1WTlNczR4OUJfUVluc1BoV09oWS1scUdsNnB5d1U5M1huX3k4QzAyNldtb2hybktYN2xKZ1NTNWFsaWwzV3pCRVhkaGR5eTNlV1d6ZzFfaFZTT0E4UjRpQ3pKdEZxUlJ6UFZXM3laUndyWEk2NlBXLUpoajVhZzVwQXpWVzUtVjVNZFBwdWdQa3AxZC1KdGdqNnhibjN4dmFYb2cxcEVwc1g5R09zRUdINUZtOE5QRjVUU0dpZy1QVl9odnFtVDNuWFZLSURtMXlSMlhRNTBWSVFJbEdOOWpfVWV0SmdRWDdlUXZZWE8xRUxDN1I0aEN6MHYwNzM1cmpJS0ZpMnBYWkxfb3FsbEV1VnlqWGxqdVJ6SHlwSjAzRlMycTBaQ295NXNnZERpUnJQcjhrUUd3bkI4bDVzRmxQblhkaFJPTTdISnVUQmhET3BOMTM4bjVvUEc2VmZhb2lrR1FyTUl2RWNEeGg0U0dsNnV6eU5zOUxiNDY5SXBxR0hBS00wOTgyWTFnWkQyaEtLVUloT3ZxZGh0RWVGRmJzenFsaUtfZENQM0JzdkVVeTdXR3hUSmJST1NBMUI1NkVFWncwNW5JZVVLX1p1RXdqVnFfQWpvQ08yQjZhN1NkTkpTSnUxOVRXZXE0WFEtZWxhZW1NNXYtQ2sya0VGLURmS01lMkctNVY3c2ZhN0ZGRFgwWHlabTFkeS1hcUZ1dDZ3cnpPQ3hha2IzVE11M0pqbklmU0diczBqTFBNZC1QZGp6VzNTSnJVSjJoWkJUQjVORG4tYUJmMEJtSUNUdVpEaGt6OTM3TjFOdVhXUHItZjRtZ25nU3NhZC1sVTVXNTRDTmxZbnlfeHNsdkpuMXhUYnE1MnpVQ0ZOclRWM1M4eHdXTzRXbFRZZVQtTS1iRVdXVWZMSGotcWg3MUxUYTFnSEEtanBCRHlZRUNIdGdpUFhsYjdYUndCZnRITzhMZVJ1dHFoVlVNb0duVjlxd0U4OGRuQVV3MG90R0hiYW5MWkxWVklzbWFRNzBfSUNrdzc5bVdtTXg0dExEYnRCaDI3c1I4TWFwLXZKR0wxSjRZYjZIV3ZqZjNqTWhFT0RGSDVMc1A1UzY2bDBiMGFSUy1fNVRQRzRJWDVydUpqb1ZfSHNVbldVeUN2YlAxSW5WVDdxVzJ1WHpLeUdmb0xWMDNHN05oQzY3YnhvUUdhS2xaOHNidkVvbTZtSHFlblhOYmwyR3NQdVJDRUdxREhWdF9ZcXhwUWxHc2hyLW5vUGhIUVhJNUNhY0hFU0ptVnI0TFVhZDE1TFBBUEstSkRoZWJ5MHJhUmZrR1ZrRlFtRGpxS1pOMmFMQjBsdjluY3FiYUU4eGJVVXlZVEpuNWdHVVhJMGtwaTdZR2NDbXd2eHpOQ09SeTV6N1BaVUpsR1pQVDBZcElJUUt6VnVpQmxSYnE4Y1BCWV9IRWdVV0p3enBGVHItdnBGN3NyNWFBWmkySnByWThsbDliSlExQmp3LVlBaDIyZXp6UnR6cU9rTzJmTDBlSVpON0tiWllMdm1oME1zTFl2S2ZYYllhQlY2VHNZRGtHUDY4U1lIVExLZTU4VzZxSTZrZHl1ZTBDc0g4SjI4WGYyZHV1bm9wQ3R2Z09ld1ZmUkN5alJGeHZKSHl1bWhQVXpNMzdjblpLcUhfSm02Qlh5S1FVN3lIcHl0NnlRPT0=
|
||||
|
||||
# Feature SyncDelta JIRA configuration
|
||||
Feature_SyncDelta_JIRA_DELTA_TOKEN_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEbm0yRUJ6VUJKbUwyRW5kMnRaNW4wM2YxMkJUTXVXZUdmdVRCaUZIVHU2TTV2RWZLRmUtZkcwZE4yRUNlNDQ0aUJWYjNfdVg5YjV5c2JwMHhoUUYxZWdkeS11bXR0eGxRLWRVaVU3cUVQZWJlNDRtY1lWUDdqeDVFSlpXS0VFX21WajlRS3lHQjc0bS11akkybWV3QUFlR2hNWUNYLUdiRjZuN2dQODdDSExXWG1Dd2ZGclI2aUhlSWhETVZuY3hYdnhkb2c2LU1JTFBvWFpTNmZtMkNVOTZTejJwbDI2eGE0OS1xUlIwQnlCSmFxRFNCeVJNVzlOMDhTR1VUamx4RDRyV3p6Tk9qVHBrWWdySUM3TVRaYjd3N0JHMFhpdzFhZTNDLTFkRVQ2RVE4U19COXRhRWtNc0NVOHRqUS1CRDFpZ19xQmtFLU9YSDU3TXBZQXpVcld3PT0=
|
||||
|
||||
# Teamsbot Browser Bot Service
|
||||
# For local testing: run the bot locally with `npm run dev` in service-teams-browser-bot
|
||||
# The bot will connect back to localhost:8000 via WebSocket
|
||||
TEAMSBOT_BROWSER_BOT_URL = http://localhost:4100
|
||||
|
||||
# Debug Configuration
|
||||
APP_DEBUG_CHAT_WORKFLOW_ENABLED = True
|
||||
APP_DEBUG_CHAT_WORKFLOW_DIR = D:/Athi/Local/Web/poweron/local/debug
|
||||
APP_DEBUG_ACCOUNTING_SYNC_ENABLED = True
|
||||
APP_DEBUG_ACCOUNTING_SYNC_DIR = D:/Athi/Local/Web/poweron/local/debug/sync
|
||||
|
||||
# Azure Communication Services Email Configuration
|
||||
MESSAGING_ACS_CONNECTION_STRING = endpoint=https://mailing-poweron-prod.switzerland.communication.azure.com/;accesskey=4UizRfBKBgMhDgQ92IYINM6dJsO1HIeL6W1DvIX9S0GtaS1PjIXqJQQJ99CAACULyCpHwxUcAAAAAZCSuSCt
|
||||
MESSAGING_ACS_SENDER_EMAIL = DoNotReply@poweron.swiss
|
||||
|
||||
# Zurich WFS Parcels (dynamic map layer). Default: Stadt Zürich OGD. Override for full canton if wfs.zh.ch resolves.
|
||||
# Connector_ZhWfsParcels_WFS_URL = https://wfs.zh.ch/av
|
||||
# Connector_ZhWfsParcels_TYPENAMES = av_li_liegenschaften_a
|
||||
|
||||
92
env-gateway-int.env
Normal file
92
env-gateway-int.env
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
# Integration Environment Configuration
|
||||
|
||||
# System Configuration
|
||||
APP_ENV_TYPE = int
|
||||
APP_ENV_LABEL = Integration Instance
|
||||
APP_API_URL = https://gateway-int.poweron.swiss
|
||||
# Force SameSite=None+Secure for auth cookies (cross-site UI on poweron-center.net). Optional if APP_API_URL is https://
|
||||
APP_COOKIE_SECURE = true
|
||||
APP_KEY_SYSVAR = CONFIG_KEY
|
||||
APP_INIT_PASS_ADMIN_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjWm41MWZ4TUZGaVlrX3pWZWNwakJsY3Facm0wLVZDd1VKeTFoZEVZQnItcEdUUnVJS1NXeDBpM2xKbGRsYmxOSmRhc29PZjJSU2txQjdLbUVrTTE1NEJjUXBHbV9NOVJWZUR3QlJkQnJvTEU9
|
||||
APP_INIT_PASS_EVENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjdmtrakgxa0djekZVNGtTZV8wM2I5UUpCZllveVBMWXROYk5yS3BiV3JEelJSM09VYTRONHpnY3VtMGxDRk5JTEZSRFhtcDZ0RVRmZ1RicTFhb3c5dVZRQ1o4SmlkLVpPTW5MMTU2eTQ0Vkk9
|
||||
|
||||
# PostgreSQL DB Host
|
||||
DB_HOST=gateway-int-server.postgres.database.azure.com
|
||||
DB_USER=heeshkdlby
|
||||
DB_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjczYzOUtTa21MMGJVTUQ5UmFfdWc3YlhCbWZOeXFaNEE1QzdJV3BLVjhnalBkLVVCMm5BZzdxdlFXQXc2RHYzLWtPSFZkZE1iWG9rQ1NkVWlpRnF5TURVbnl1cm9iYXlSMGYxd1BGYVc0VDA9
|
||||
DB_PORT=5432
|
||||
|
||||
# Security Configuration
|
||||
APP_JWT_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNUctb2RwU25iR3ZnanBOdHZhWUtIajZ1RnZzTEp4aDR0MktWRjNoeVBrY1Npd1R0VE9YVHp3M2w1cXRzbUxNaU82QUJvaDNFeVQyN05KblRWblBvbWtoT0VXbkNBbDQ5OHhwSUFnaDZGRG10Vmgtdm1YUkRsYUhFMzRVZURmSFlDTFIzVWg4MXNueDZyMGc5aVpFdWRxY3dkTExGM093ZTVUZVl5LUhGWnlRPQ==
|
||||
APP_TOKEN_EXPIRY=300
|
||||
|
||||
# CORS Configuration
|
||||
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss,https://nyla.poweron-center.net,https://nyla-int.poweron-center.net
|
||||
|
||||
# Logging configuration
|
||||
APP_LOGGING_LOG_LEVEL = DEBUG
|
||||
APP_LOGGING_LOG_DIR = /home/site/wwwroot/
|
||||
APP_LOGGING_FORMAT = %(asctime)s - %(levelname)s - %(name)s - %(message)s
|
||||
APP_LOGGING_DATE_FORMAT = %Y-%m-%d %H:%M:%S
|
||||
APP_LOGGING_CONSOLE_ENABLED = True
|
||||
APP_LOGGING_FILE_ENABLED = True
|
||||
APP_LOGGING_ROTATION_SIZE = 10485760
|
||||
APP_LOGGING_BACKUP_COUNT = 5
|
||||
|
||||
# OAuth: Auth app (login/JWT) vs Data app (Graph / Google APIs)
|
||||
Service_MSFT_AUTH_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
|
||||
Service_MSFT_AUTH_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnFBa1kydlVubld1d1h6SUNSWW1aZ3p4X3Zod1NDTjhZVnVYS2lqOERGTFp2OXJ4TGRiNlRLVFpzLUVDTUhkZGhGUWdxa1djdEV5UWkyblN1UHZoaFBjaExNTEpGMG1PRGJEbDdHVll0Ungwcl9JemZ4ZXFzZUNFQmFlZi1DZFlCekU1S3E=
|
||||
Service_MSFT_AUTH_REDIRECT_URI = https://gateway-int.poweron.swiss/api/msft/auth/login/callback
|
||||
Service_MSFT_DATA_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
|
||||
Service_MSFT_DATA_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnFBa1kyS1hWZXEzUzZTTE5MUlJncVowMU95Y0hmV1hveDBZOWdLU1RIUWt3SGlXNGxVTXVKc2QyQmtmWTlJRU43ZnRDdnlDTGxQY0hTU25CWWFFdDhUem9HU0VYcTFJTVFEbVk0dUhmVzJNVlEzNTNWdjdmaW9WeUVDVW5PRmNFZEQzNTY=
|
||||
Service_MSFT_DATA_REDIRECT_URI = https://gateway-int.poweron.swiss/api/msft/auth/connect/callback
|
||||
|
||||
Service_GOOGLE_AUTH_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
|
||||
Service_GOOGLE_AUTH_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnFBa1kyUTUwNXNGaHRNaGxxbF9sdWJ3Q0xLYU5yOHB4Yk8zMDZvQ29yaEhWOE5JMENXRk5jb2ZBdzRKQ2ZTTld6ZlIxemhOYzN1VE10TjBDRWZEMXlLVWRNYjZ0VG5RZ3I3NWt0SEJzMzdsUmRzcVNmbktRNHZqTUF6a2EyUkVUSFJnZFE=
|
||||
Service_GOOGLE_AUTH_REDIRECT_URI = https://gateway-int.poweron.swiss/api/google/auth/login/callback
|
||||
Service_GOOGLE_DATA_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
|
||||
Service_GOOGLE_DATA_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnFBa1kyV1FRVjF0c0d3d0dyWU1TdW9HdXVkdHdsVWZKYTJjbGZPRDhMRjA2M0FkaUZIVmhIUmFKNjg2ekFodHd6NG80VTI3TC1icW1LZ01jWVZuQ1pKRm5nMW5UREJEaGp2Wl9oRDRCSmZVT0JpTnkwXzgwY0pkV29yczQ5akF2d1ZGcVY=
|
||||
Service_GOOGLE_DATA_REDIRECT_URI = https://gateway-int.poweron.swiss/api/google/auth/connect/callback
|
||||
|
||||
# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly.
|
||||
Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
|
||||
Service_CLICKUP_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnB5dkd5SE1uVURMNVE3NkM4cHBKa2R2TjBnLWdpSXI5dHpKWGExZVFiUF95TFNnZ1NwLWFLdmh6eWFZTHVHYTBzU2FGRUpLYkVyM1NvZjZkWDZHN21qUER5ZVNOaGpCc3NrUGd3VnFTclF3OW1nUlVuWXQ1UVhDLVpyb1BwRExOeFpDeVhtbEhDVnd4TVdpbzNBNk5QQWFPdjdza0xBWGxFY1E3WFpCSUlNa1l4RDlBPQ==
|
||||
Service_CLICKUP_OAUTH_REDIRECT_URI = https://gateway-int.poweron.swiss/api/clickup/auth/connect/callback
|
||||
|
||||
# Infomaniak: no OAuth client. Users paste a Personal Access Token (kdrive + mail) per UI.
|
||||
|
||||
# Stripe Billing (both end with _SECRET for encryption script)
|
||||
STRIPE_SECRET_KEY_SECRET = INT_ENC:Z0FBQUFBQnB5dkd5ekdBaGNGVUlOQUpncTlzLWlTV0V5OWZzQkpDczhCUGw4U1JpTHZ0d3pfYlFNWElLRlNiNlNsaDRYTGZUTkg2OUFrTW1GZXpOUjBVbmRQWjN6ekhHd2ZSQ195OHlaeWh1TmxrUm10V2R3YmdncmFLbFMzVjdqcWJMSUJPR2xuSEozclNoZG1rZVBTaWg3OFQ1Qzdxb0wyQ2RKazc2dG1aZXBUTXlvbDZqLS1KOVI5M3BGc3NQZkZRbnFpRjIwWmh2ZHlVNlpxZVo2dWNmMjQ5eW02QmtzUT09
|
||||
STRIPE_WEBHOOK_SECRET = INT_ENC:Z0FBQUFBQnA4UXZiUUVqTl9lREVRWTh1aHFDcFpwcXRkOUx4MS1ham9Ddkl6T0xzMnJuM1hhUHdGNG5CenY1MUg4RlJBOGFQTWl5cVd5MjJ2REItcHYyRmdLX3ZlT2p5Z3BRVkMtQnRoTVkteXlfaU92MVBtOEI0Ni1kbGlfa0NiRmFRRXNHLVE2NHI=
|
||||
STRIPE_API_VERSION = 2026-01-28.clover
|
||||
STRIPE_AUTOMATIC_TAX_ENABLED = false
|
||||
STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQd14OUoIL0Osj7A0ZQlr0
|
||||
|
||||
# AI configuration
|
||||
Connector_AiOpenai_API_SECRET = INT_ENC:Z0FBQUFBQnBaSnM4MENkQ2xJVmE5WFZKUkh2SHJFby1YVXN3ZmVxRkptS3ZWRmlwdU93ZEJjSjlMV2NGbU5mS3NCdmFfcmFYTEJNZXFIQ3ozTWE4ZC1pemlQNk9wbjU1d3BPS0ZCTTZfOF8yWmVXMWx0TU1DamlJLVFhSTJXclZsY3hMVWlPcXVqQWtMdER4T252NHZUWEhUOTdIN1VGR3ltazEweXFqQ0lvb0hYWmxQQnpxb0JwcFNhRDNGWXdoRTVJWm9FalZpTUF5b1RqZlRaYnVKYkp0NWR5Vko1WWJ0Wmg2VWJzYXZ0Z3Q4UkpsTldDX2dsekhKMmM4YjRoa2RwemMwYVQwM2cyMFlvaU5mOTVTWGlROU8xY2ZVRXlxZzJqWkxURWlGZGI2STZNb0NpdEtWUnM9
|
||||
Connector_AiAnthropic_API_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjT1ZlRWVJdVZMT3ljSFJDcFdxRFBRVkZhS204NnN5RDBlQ0tpenhTM0FFVktuWW9mWHNwRWx2dHB0eDBSZ0JFQnZKWlp6c01pVGREWHd1eGpERnU0Q2xhaks1clQ1ZXVsdnd2ZzhpNXNQS1BhY3FjSkdkVEhHalNaRGR4emhpakZncnpDQUVxOHVXQzVUWmtQc0FsYmFwTF9TSG5FOUFtWk5Ick1NcHFvY2s1T1c2WXlRUFFJZnh6TWhuaVpMYmppcDR0QUx0a0R6RXlwbGRYb1R4dzJkUT09
|
||||
Connector_AiPerplexity_API_SECRET = INT_ENC:Z0FBQUFBQnB5dkd6UkhtU3lhYmZMSlo0bklQZ2s3UTFBSkprZTNwWkg5Q2lVa0wtenhxWXpva21xVDVMRjdKSmhpTmxWS05IUTRoRHdCbktSRVVjcVFnY1RfV0N2S2dyV0dTMlhxQlRFVm41RkFTWVQzQThuVkZwdlNuVC05QlVRVXB6Qjk3akNpYmY1MFR6R1ByMzlIMllRZlRRYVVRN2ZBPT0=
|
||||
Connector_AiTavily_API_SECRET = INT_ENC:Z0FBQUFBQm8xSVRkdkJMTDY0akhXNzZDWHVYSEt1cDZoOWEzSktneHZEV2JndTNmWlNSMV9KbFNIZmQzeVlrNE5qUEIwcUlBSGM1a0hOZ3J6djIyOVhnZzI3M1dIUkdicl9FVXF3RGktMmlEYmhnaHJfWTdGUkktSXVUSGdQMC1vSEV6VE8zR2F1SVk=
|
||||
Connector_AiPrivateLlm_API_SECRET = INT_ENC:Z0FBQUFBQnBudkpGSjZ1NWh0aWc1R3Z4MHNaeS1HamtUbndhcUZFZDlqUDhjSmg5eHFfdlVkU0RsVkJ2UVRaMWs3aWhraG5jSlc0YkxNWHVmR2JoSW5ENFFCdkJBM0VienlKSnhzNnBKbTJOUTFKczRfWlQ3bWpmUkRTT1I1OGNUSTlQdExacGRpeXg=
|
||||
Connector_AiMistral_API_SECRET = INT_ENC:Z0FBQUFBQnBudkpGZTNtZ1E4TWIxSEU1OUlreUpxZkJIR0Vxcm9xRHRUbnBxbTQ1cXlkbnltWkJVdTdMYWZ4c3Fsam42TERWUTVhNzZFMU9xVjdyRGFCYml6bmZsZFd2YmJzemlrSWN6Q3o3X0NXX2xXNUQteTNONHdKYzJ5YVpLLWdhU2JhSTJQZnI=
|
||||
|
||||
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
|
||||
91
env-gateway-prod-forgejo.env
Normal file
91
env-gateway-prod-forgejo.env
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
# Production Environment Configuration
|
||||
|
||||
# System Configuration
|
||||
APP_ENV_TYPE = prod
|
||||
APP_ENV_LABEL = Production Instance Forgejo
|
||||
APP_KEY_SYSVAR = /srv/gateway/shared/secrets/master_key.txt
|
||||
APP_INIT_PASS_ADMIN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3UnJRV0sySFlDblpXUlREclREaW1WbUt6bGtQYkdrNkZDOXNOLXFua1hqeFF2RHJnRXJ5VlVGV3hOZm41QjZOMlNTb0duYXNxZi05dXVTc2xDVkx0SVBFLUhncVo5T0VUZHE0UTZLWWw3ck09
|
||||
APP_INIT_PASS_EVENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3QVpIY19DQVZSSzJmc2F0VEZvQlU1cHBhTEgxdHdnR3g4eW01aTEzYTUxc1gxTDR1RVVpSHRXYjV6N1BLZUdCUGlfOW1qdy0xSHFVRkNBcGZvaGlSSkZycXRuUllaWnpyVGRoeFg1dGEyNUk9
|
||||
APP_API_URL = https://api.poweron.swiss
|
||||
|
||||
# PostgreSQL DB Host
|
||||
DB_HOST=10.20.0.21
|
||||
DB_USER=poweron_dev
|
||||
DB_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnA4UXZiMnRoUzVlbVRLX3JTRl94cVpMaURtMndZVmFBYXdvdnIxLV81dWwxWmhmcUlCMUFZbDhRT2NsQmNqSl9ZMmRWRVN1Y2JqNlVwOXRJY1VBTm1oSjNiaFE9PQ==
|
||||
DB_PORT=5432
|
||||
|
||||
# Security Configuration
|
||||
APP_JWT_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3elhfV0Rnd2pQRjlMdkVwX1FnSmRhSzNZUlV5SVpaWXBNX1hpa2xPZGdMSWpnN2ZINHQxeGZnNHJweU5pZjlyYlY5Qm9zOUZEbl9wUEgtZHZXd1NhR19JSG9kbFU4MnFGQnllbFhRQVphRGQyNHlFVWR5VHQyUUpqN0stUmRuY2QyTi1oalczRHpLTEJqWURjZWs4YjZvT2U5YnFqcXEwdEpxV05fX05QMmtrPQ==
|
||||
APP_TOKEN_EXPIRY=300
|
||||
|
||||
# CORS Configuration
|
||||
APP_ALLOWED_ORIGINS=https://porta.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 = PROD_ENC:Z0FBQUFBQnFBa1kyeUZORDYxOFdlNHk1N25kV3pSQVJMUVFwLUFlMzlzQjQ1eVljOTlzX184RndsTmtTV1FjdWkyQlBiUkdCbGt5S2ltZjJxa2I2dHBMdnJqZnhFSnBCampHYjB3RG5URDM1YzZSLVd6TGdaRXRVcEdadE5zM2thNV9SZy1KZDdLSHY=
|
||||
Service_MSFT_AUTH_REDIRECT_URI=https://api.poweron.swiss/api/msft/auth/login/callback
|
||||
Service_MSFT_DATA_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
|
||||
Service_MSFT_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnFBa1kySk5uMmlWczBWTE00MHBIcWlBbVJmVmc3MlBWbDA1YTFaS3psZjVLd3d1X2FvRHV0X0c5blpLV0FpY05aMTJMMzUtcG8wakF2TlM3SGQ2VjFZM3JLT1MwTlZ0bm9BRlpkbHVPQTFNaXJvazlQRzN4M2ZZNEVhV1JHV190dWluSUk=
|
||||
Service_MSFT_DATA_REDIRECT_URI = https://api.poweron.swiss/api/msft/auth/connect/callback
|
||||
|
||||
Service_GOOGLE_AUTH_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
|
||||
Service_GOOGLE_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnFBa1kybjVVZ0FldUE1NTJiY2U1N0I0aVU0Z2hfeWlYc2tTdmlxTS1NdGxsRnFHdjZVcW5RRHZkUFhzUTVyX2RaZHlrQThRdTdCRmVBelBOcDlsbFQyd19SZExuWEM5aTcwQ0FvY3ctMUlWU1pndDE0MkdzeTZZRHkwLWU3aW56LW1jS20=
|
||||
Service_GOOGLE_AUTH_REDIRECT_URI = https://api.poweron.swiss/api/google/auth/login/callback
|
||||
Service_GOOGLE_DATA_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
|
||||
Service_GOOGLE_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnFBa1kyMnFma3VPOVJtTFFrNDRLN0NkWHY2dUZDWlJzdDVMd3p3N19IY0tWdURRRzExOGZCMjJOYmpKT1E0cTVwYlgtcVJINTY0anZPc1VoTW00cHl6NVh3ZHVTek1oT1RqWUhtamRkZ1dENWlwNTlZSU1oNWczeGdEOC1Gbk5XU2RBcmI=
|
||||
Service_GOOGLE_DATA_REDIRECT_URI = https://api.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://api.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:Z0FBQUFBQnBaSnM4TWJOVm4xVkx6azRlNDdxN3UxLUdwY2hhdGYxRGp4VFJqYXZIcmkxM1ZyOWV2M0Z4MHdFNkVYQ0ROb1d6LUZFUEdvMHhLMEtXYVBCRzM5TlYyY3ROYWtJRk41cDZxd0tYYi00MjVqMTh4QVcyTXl0bmVocEFHbXQwREpwNi1vODdBNmwzazE5bkpNelE2WXpvblIzWlQwbGdEelI2WXFqT1RibXVHcjNWbVhwYzBOM25XTzNmTDAwUjRvYk4yNjIyZHc5c2RSZzREQUFCdUwyb0ZuOXN1dzI2c2FKdXI4NGxEbk92czZWamJXU3ZSbUlLejZjRklRRk4tLV9aVUFZekI2bTU4OHYxNTUybDg3RVo0ZTh6dXNKRW5GNXVackZvcm9laGI0X3R6V3M9
|
||||
Connector_AiAnthropic_API_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3TnhYdlhSLW5RbXJyMHFXX0V0bHhuTDlTaFJsRDl2dTdIUTFtVFAwTE8tY3hLbzNSMnVTLXd3RUZualN3MGNzc1kwOTIxVUN2WW1rYi1TendFRVVBSVNqRFVjckEzNExyTGNaUkJLMmozazUwemI1cnhrcEtZVXJrWkdaVFFramp3MWZ6RmY2aGlRMXVEYjM2M3ZlbmxMdnNCRDM1QWR0Wmd6MWVnS1I1c01nV3hRLXg3d2NTZXVfTi1Wdm16UnRyNGsyRTZ0bG9TQ1g1OFB5Z002bmQ3QT09
|
||||
Connector_AiPerplexity_API_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6NG5CTm9QOFZRV1BIVC0tV2RKTGtCQWFOUXlpRnhEdjN1U2x3VUdDamtIZV9CQzQ5ZmRmcUh3ZUVUa0NxbGhlenVVdWtaYjdpcnhvUlNFLXZfOWh2dWFZai0xUGU5cWpuYmpnRVRWakh0RVNUUTFyX0w5V0NXVWFrQlZuOTd5TkI0eVRoQ0ZBSm9HYUlYamoyY1FCMmlBPT0=
|
||||
Connector_AiTavily_API_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3NmItcDh6V0JpcE5Jc0NlUWZqcmllRHB5eDlNZmVnUlNVenhNTm5xWExzbjJqdE1GZ0hTSUYtb2dvdWNhTnlQNmVWQ2NGVDgwZ0MwMWZBMlNKWEhzdlF3TlZzTXhCZWM4Z1Uwb18tSTRoU1JBVTVkSkJHOTJwX291b3dPaVphVFg=
|
||||
Connector_AiPrivateLlm_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGanZ6U3pzZWkwXzVPWGtIQ040XzFrTXc5QWRnazdEeEktaUJ0akJmNnEzbWUzNHczLTJfc2dIdzBDY0FTaXZYcDhxNFdNbTNtbEJTb2VRZ0ZYd05hdlNLR1h6SUFzVml2Z1FLY1BjTl90UWozUGxtak1URnhhZmNDRWFTb0dKVUo=
|
||||
Connector_AiMistral_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGc2tQc2lvMk1YZk01Q1dob1U5cnR0dG03WWE3WkpoOWo0SEpvLU9Rc2lCNDExdy1wZExaN3lpT2FEQkxnaHRmWmZUUUZUUUJmblZreGlpaFpOdnFhbzlEd1RsVVJtX216cmhxTm5BcTN2eUZ2T054cDE5bmlEamJ3NGR6MVpFQnA=
|
||||
|
||||
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
|
||||
92
env-gateway-prod.env
Normal file
92
env-gateway-prod.env
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
# Production Environment Configuration
|
||||
|
||||
# System Configuration
|
||||
APP_ENV_TYPE = prod
|
||||
APP_ENV_LABEL = Production Instance
|
||||
APP_KEY_SYSVAR = CONFIG_KEY
|
||||
APP_INIT_PASS_ADMIN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3UnJRV0sySFlDblpXUlREclREaW1WbUt6bGtQYkdrNkZDOXNOLXFua1hqeFF2RHJnRXJ5VlVGV3hOZm41QjZOMlNTb0duYXNxZi05dXVTc2xDVkx0SVBFLUhncVo5T0VUZHE0UTZLWWw3ck09
|
||||
APP_INIT_PASS_EVENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3QVpIY19DQVZSSzJmc2F0VEZvQlU1cHBhTEgxdHdnR3g4eW01aTEzYTUxc1gxTDR1RVVpSHRXYjV6N1BLZUdCUGlfOW1qdy0xSHFVRkNBcGZvaGlSSkZycXRuUllaWnpyVGRoeFg1dGEyNUk9
|
||||
APP_API_URL = https://gateway-prod.poweron.swiss
|
||||
APP_COOKIE_SECURE = true
|
||||
|
||||
# PostgreSQL DB Host
|
||||
DB_HOST=gateway-prod-server.postgres.database.azure.com
|
||||
DB_USER=gzxxmcrdhn
|
||||
DB_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3Y1JScGxjZG9TdUkwaHRzSHZhRHpNcDV3N1U2TnIwZ21PRG5TWFFfR1k0N3BiRk5WelVadjlnXzVSTDZ6NXFQNFpqbnJ1R3dNVkJocm1zVEgtSk0xaDRiR19zNDBEbVIzSk51ekNlQ0Z3b0U9
|
||||
DB_PORT=5432
|
||||
|
||||
# Security Configuration
|
||||
APP_JWT_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3elhfV0Rnd2pQRjlMdkVwX1FnSmRhSzNZUlV5SVpaWXBNX1hpa2xPZGdMSWpnN2ZINHQxeGZnNHJweU5pZjlyYlY5Qm9zOUZEbl9wUEgtZHZXd1NhR19JSG9kbFU4MnFGQnllbFhRQVphRGQyNHlFVWR5VHQyUUpqN0stUmRuY2QyTi1oalczRHpLTEJqWURjZWs4YjZvT2U5YnFqcXEwdEpxV05fX05QMmtrPQ==
|
||||
APP_TOKEN_EXPIRY=300
|
||||
|
||||
# CORS Configuration
|
||||
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss,https://nyla.poweron-center.net,https://nyla-int.poweron-center.net
|
||||
|
||||
# Logging configuration
|
||||
APP_LOGGING_LOG_LEVEL = DEBUG
|
||||
APP_LOGGING_LOG_DIR = /home/site/wwwroot/
|
||||
APP_LOGGING_FORMAT = %(asctime)s - %(levelname)s - %(name)s - %(message)s
|
||||
APP_LOGGING_DATE_FORMAT = %Y-%m-%d %H:%M:%S
|
||||
APP_LOGGING_CONSOLE_ENABLED = True
|
||||
APP_LOGGING_FILE_ENABLED = True
|
||||
APP_LOGGING_ROTATION_SIZE = 10485760
|
||||
APP_LOGGING_BACKUP_COUNT = 5
|
||||
|
||||
# OAuth: Auth app (login/JWT) vs Data app (Graph / Google APIs)
|
||||
Service_MSFT_AUTH_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
|
||||
Service_MSFT_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnFBa1kySFR2NjBKM084QTNpeUlyUmM4R0N0SU1BZ2x4MmVTZTVHQkVzRE9GdmFkV041MzhudFhobjU0RWNnd3lqeXpKUXA5aGtNZkhtYU12QjBtX0NjemVmdEZBdC1TbXVBSXJTcF9vMlJXd0ZNRTRKRFBMUXNjTF85eTBxakR4RVNfYmU=
|
||||
Service_MSFT_AUTH_REDIRECT_URI = https://gateway-prod.poweron.swiss/api/msft/auth/login/callback
|
||||
Service_MSFT_DATA_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
|
||||
Service_MSFT_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnFBa1kyNVU4cVRIZFdjS3l2S1RJVTVlc1ozQ1liZXZDX1VwdFZQUzFtS0N6UWYyeGxkNGNmY1hoaWxEUDBXVU5QR2t3Vi1ZV1A2QkxqbnpobzJwOXdzYTBZaFZYdnNkeDE1VVl0bm4weHFiLXdON2gtZzAwMTkxNWRoZldFM2djSkNHVS0=
|
||||
Service_MSFT_DATA_REDIRECT_URI = https://gateway-prod.poweron.swiss/api/msft/auth/connect/callback
|
||||
|
||||
Service_GOOGLE_AUTH_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
|
||||
Service_GOOGLE_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnFBa1kyUmJleVpTOF9OaFV3NGVfcWVBX2oxSjUwMWRGOFZRWFRIN1FZRzZ6U3VQMlg5a21RY1drTHh3U254LW4zM1A1cXQ1TTFWYlNoek9hSHJIeE4tbm1wU1lKRXlKNU5HVWI4VGZwTVE0VnJGaV8wZmNvdkVrMjJGeXdmZ3UyNmVXN1E=
|
||||
Service_GOOGLE_AUTH_REDIRECT_URI = https://gateway-prod.poweron.swiss/api/google/auth/login/callback
|
||||
Service_GOOGLE_DATA_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
|
||||
Service_GOOGLE_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnFBa1kyY2pxMDh0U0RqWERianBMTTNtSUZPSzhKUzh4S0RTenR2MmxnRDlvQzJjbDVTczRWLUJtVnhxWTE2MmUxQjJia2xJcVUzVlFlUnpma040NFdHRzVNRUt0OXR0c2JkTkRmQ1RIYllXbXFFaExIQWNycFVHbUxHbmtYOVhOVUV2MFY=
|
||||
Service_GOOGLE_DATA_REDIRECT_URI = https://gateway-prod.poweron.swiss/api/google/auth/connect/callback
|
||||
|
||||
# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly.
|
||||
Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
|
||||
Service_CLICKUP_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6VGw5WDdhdDRsVENSalhSSUV0OFFxbEx0V1l6aktNV0E5Y18xU3JHLUlqMWVJdmxyajAydVZRaDJkZzJOVXhxRV9ROFRZbWxlRjh4c3NtQnRFMmRtZWpzTWVsdngtWldlNXRKTURHQjJCOEt6alMwQlkwOFYyVVJWNURJUGJIZDIxYVlfNnBrMU54M0Q3TVdVbFZqRkJKTUtqa05wUkV4eGZvbXNsVi1nNVdBPQ==
|
||||
Service_CLICKUP_OAUTH_REDIRECT_URI = https://gateway-prod.poweron.swiss/api/clickup/auth/connect/callback
|
||||
|
||||
# Infomaniak: no OAuth client. Users paste a Personal Access Token (kdrive + mail) per UI.
|
||||
|
||||
# Stripe Billing (both end with _SECRET for encryption script)
|
||||
STRIPE_SECRET_KEY_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6aVA3R3VRS3VHMUgzUEVjYkR4eUZKWFhPUzFTTVlHNnBvT3FienNQaUlBWVpPLXJyVGpGMWk4LXktMXphX0J6ZTVESkJxdjNNa3ZJbF9wX2ppYzdjYlF0cmdVamlEWWJDSmJYYkJseHctTlh4dnNoQWs4SG5haVl2TTNDdXpuaFpqeDBtNkFCbUxMa0RaWG14dmxyOEdILTNrZ2licmNpbXVkN2lFSWoxZW1BODNpV0ZTQ0VaeXRmR1d4RjExMlVFS3MtQU9zZXZlZE1mTmY3OWctUXJHdz09
|
||||
STRIPE_WEBHOOK_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGNUpTWldsakYydFhFelBrR1lSaWxYT3kyMENOMUljZTJUZHBWcEhhdWVCMzYxZXQ5b3VlTFVRalFiTVdsbGxrdUx0RDFwSEpsOC1sTDJRTEJNQlA3S3ZaQzBtV1h6bWp5VnlMZUgwUlF3cXYxcnljZVE5SWdzLVg3V0syOWRYS08=
|
||||
STRIPE_API_VERSION = 2026-01-28.clover
|
||||
STRIPE_AUTOMATIC_TAX_ENABLED = false
|
||||
STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQZG8WqlVsabrfFEu49pah
|
||||
|
||||
|
||||
# AI configuration
|
||||
Connector_AiOpenai_API_SECRET = PROD_ENC:Z0FBQUFBQnBaSnM4TWJOVm4xVkx6azRlNDdxN3UxLUdwY2hhdGYxRGp4VFJqYXZIcmkxM1ZyOWV2M0Z4MHdFNkVYQ0ROb1d6LUZFUEdvMHhLMEtXYVBCRzM5TlYyY3ROYWtJRk41cDZxd0tYYi00MjVqMTh4QVcyTXl0bmVocEFHbXQwREpwNi1vODdBNmwzazE5bkpNelE2WXpvblIzWlQwbGdEelI2WXFqT1RibXVHcjNWbVhwYzBOM25XTzNmTDAwUjRvYk4yNjIyZHc5c2RSZzREQUFCdUwyb0ZuOXN1dzI2c2FKdXI4NGxEbk92czZWamJXU3ZSbUlLejZjRklRRk4tLV9aVUFZekI2bTU4OHYxNTUybDg3RVo0ZTh6dXNKRW5GNXVackZvcm9laGI0X3R6V3M9
|
||||
Connector_AiAnthropic_API_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3TnhYdlhSLW5RbXJyMHFXX0V0bHhuTDlTaFJsRDl2dTdIUTFtVFAwTE8tY3hLbzNSMnVTLXd3RUZualN3MGNzc1kwOTIxVUN2WW1rYi1TendFRVVBSVNqRFVjckEzNExyTGNaUkJLMmozazUwemI1cnhrcEtZVXJrWkdaVFFramp3MWZ6RmY2aGlRMXVEYjM2M3ZlbmxMdnNCRDM1QWR0Wmd6MWVnS1I1c01nV3hRLXg3d2NTZXVfTi1Wdm16UnRyNGsyRTZ0bG9TQ1g1OFB5Z002bmQ3QT09
|
||||
Connector_AiPerplexity_API_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6NG5CTm9QOFZRV1BIVC0tV2RKTGtCQWFOUXlpRnhEdjN1U2x3VUdDamtIZV9CQzQ5ZmRmcUh3ZUVUa0NxbGhlenVVdWtaYjdpcnhvUlNFLXZfOWh2dWFZai0xUGU5cWpuYmpnRVRWakh0RVNUUTFyX0w5V0NXVWFrQlZuOTd5TkI0eVRoQ0ZBSm9HYUlYamoyY1FCMmlBPT0=
|
||||
Connector_AiTavily_API_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3NmItcDh6V0JpcE5Jc0NlUWZqcmllRHB5eDlNZmVnUlNVenhNTm5xWExzbjJqdE1GZ0hTSUYtb2dvdWNhTnlQNmVWQ2NGVDgwZ0MwMWZBMlNKWEhzdlF3TlZzTXhCZWM4Z1Uwb18tSTRoU1JBVTVkSkJHOTJwX291b3dPaVphVFg=
|
||||
Connector_AiPrivateLlm_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGanZ6U3pzZWkwXzVPWGtIQ040XzFrTXc5QWRnazdEeEktaUJ0akJmNnEzbWUzNHczLTJfc2dIdzBDY0FTaXZYcDhxNFdNbTNtbEJTb2VRZ0ZYd05hdlNLR1h6SUFzVml2Z1FLY1BjTl90UWozUGxtak1URnhhZmNDRWFTb0dKVUo=
|
||||
Connector_AiMistral_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGc2tQc2lvMk1YZk01Q1dob1U5cnR0dG03WWE3WkpoOWo0SEpvLU9Rc2lCNDExdy1wZExaN3lpT2FEQkxnaHRmWmZUUUZUUUJmblZreGlpaFpOdnFhbzlEd1RsVVJtX216cmhxTm5BcTN2eUZ2T054cDE5bmlEamJ3NGR6MVpFQnA=
|
||||
|
||||
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
|
||||
92
env-int.env
92
env-int.env
|
|
@ -1,92 +0,0 @@
|
|||
# Integration Environment Configuration
|
||||
|
||||
# System Configuration
|
||||
APP_ENV_TYPE = int
|
||||
APP_ENV_LABEL = Integration Instance
|
||||
APP_API_URL = https://api-int.poweron.swiss
|
||||
# Force SameSite=None+Secure for auth cookies. Optional if APP_API_URL is https://
|
||||
APP_COOKIE_SECURE = true
|
||||
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 (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
|
||||
APP_JWT_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNUctb2RwU25iR3ZnanBOdHZhWUtIajZ1RnZzTEp4aDR0MktWRjNoeVBrY1Npd1R0VE9YVHp3M2w1cXRzbUxNaU82QUJvaDNFeVQyN05KblRWblBvbWtoT0VXbkNBbDQ5OHhwSUFnaDZGRG10Vmgtdm1YUkRsYUhFMzRVZURmSFlDTFIzVWg4MXNueDZyMGc5aVpFdWRxY3dkTExGM093ZTVUZVl5LUhGWnlRPQ==
|
||||
APP_TOKEN_EXPIRY=300
|
||||
|
||||
# MFA Configuration
|
||||
MFA_REQUIRE_ADMINS = True
|
||||
|
||||
# CORS Configuration
|
||||
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://porta.poweron.swiss,https://porta-int.poweron.swiss,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss
|
||||
|
||||
# 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:Z0FBQUFBQnFBa1kydlVubld1d1h6SUNSWW1aZ3p4X3Zod1NDTjhZVnVYS2lqOERGTFp2OXJ4TGRiNlRLVFpzLUVDTUhkZGhGUWdxa1djdEV5UWkyblN1UHZoaFBjaExNTEpGMG1PRGJEbDdHVll0Ungwcl9JemZ4ZXFzZUNFQmFlZi1DZFlCekU1S3E=
|
||||
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:Z0FBQUFBQnFBa1kyV1FRVjF0c0d3d0dyWU1TdW9HdXVkdHdsVWZKYTJjbGZPRDhMRjA2M0FkaUZIVmhIUmFKNjg2ekFodHd6NG80VTI3TC1icW1LZ01jWVZuQ1pKRm5nMW5UREJEaGp2Wl9oRDRCSmZVT0JpTnkwXzgwY0pkV29yczQ5akF2d1ZGcVY=
|
||||
Service_GOOGLE_DATA_REDIRECT_URI = https://api-int.poweron.swiss/api/google/auth/connect/callback
|
||||
|
||||
# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly.
|
||||
Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
|
||||
Service_CLICKUP_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnB5dkd5SE1uVURMNVE3NkM4cHBKa2R2TjBnLWdpSXI5dHpKWGExZVFiUF95TFNnZ1NwLWFLdmh6eWFZTHVHYTBzU2FGRUpLYkVyM1NvZjZkWDZHN21qUER5ZVNOaGpCc3NrUGd3VnFTclF3OW1nUlVuWXQ1UVhDLVpyb1BwRExOeFpDeVhtbEhDVnd4TVdpbzNBNk5QQWFPdjdza0xBWGxFY1E3WFpCSUlNa1l4RDlBPQ==
|
||||
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.
|
||||
|
||||
# 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:Z0FBQUFBQnFGdnVIcUxPOUVXT0NlUlJNVENjRi1iLXdsR1ZuOXU3Sk1qbmVZOThYdUZrNGlDREJmMkttRVNyWlNsMHlDc2pnQ1VyZ0lzYXVkc0hHNm95bjFrejNRWVVGUWZOVTVYOGpKcF9QNGttc001TE9VdFdKa2FyUEYxY1VYOE1RenBObmNMbVFHeTdHbGpORVAwOWc3Rng1dWtlUEphZmVKV1otSE03a2FTVHVvMlNONWZ3N3hMR2FmdTEtdkdWOHV5d0RYWlZ3dVV1SEpKNHBjWG1QTEZ6SE5oS1VESEJ2MmVSRmh4azd3d1RmQ3FjMDVsbWxNc2EzZDdWMXYyaFBOMTZFSUltUkk2ZTEtZ0FHRW5pZkhON1Rna3lYX1Z5TWRQNkZEX3NXVHlPYkVuMW5zcE09
|
||||
Connector_AiAnthropic_API_SECRET = INT_ENC:Z0FBQUFBQnFGd0hhbGxMRUlZc1A2d1RvOWdFX3NkQXM0RG5LOTZQYWpOc21tTTJWU09nbS12M29YLVVzVk8zWGdrWXIzb05meW56dkRtTElVN1ZndkZ5eHdWMGxGVjBPTlRvTGxpTzVzcFlzdnVhTTh0R0gtM2M2Mk9ac3dnc0RYYkx2c3BDdnoxVXJLX2tPMTVpZXdmQll3cHF3dEhGWGRlb3JLZjlNTVJpZTN1TzFtMU5yZmdXTnZuZ1lXN0p5VUdsVXBDUXJoY1Y3aFBkUW1HbmJJZmZaR1cwTVNQR0VZUT09
|
||||
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:Z0FBQUFBQnFIc3YtSjhlcklrU2JCOW5mdHFHd0dLTUZZZk9PT3o5RWt5RjAxX2s3ekJRLUUzU0dNSnNseTE4bUpNTnZSTWg0QV9mWm5iX19aWjV4YnRXU1JBSm1INVB5dXNRT2JiYk1tLWRSS29pdTRMdS1lMDZxMkx4VTh3bU5aVWh3cEwyOE1QcXVockgtZWh5bzdNVXQyemFuSmZqRzZZYmNGN21JdjNwNWpPRXB6WU1qSU5rZUVSb3JBS0lhcThvakkwbTRUUHhBdjRZdWNsZ1Z1RmFaNGZLcEpaNVNLdFAxYzFXdTJydU9COWJ0bkNyYUF2X2FNc1BfT05teEs1SE9PeGhPd3VJSFY2VFJ5VEl6V3R3bzd6OTVKTEVRcmt5ZzdBMXBFY1A5dUFJRFJONFBlaDlJcjNBQnBraC0wMTBhNW8wYWZaeHNWclVTOVotLTdWSmVuYzJKcUZSUkdrdXB3VEVESzd4UTI0bGd6SzdCajdoazZXVTVCaGRiaWJaOHg5Z2thSWItcS05U25DbUdrT2M1QV81WEg2dlJfMlBtZU9Bc3V5bmtBWHRoRUVLR2lWNHY3M3hHcU1raFRFOWQwSEtUU1RDWDFRNFlkNHVnTkZDbk5zS3RZeGR2Z015RnRGc3NndFVEQjc4bVpNeE81bXc1MnQ2QjNZeHZCbUJJZVJ2TE5xWEd4M3hHT2hJWW5DOWMxQlNmZE9uMVRGVnRwTUlXZjZCRUZBLU9GWVZGWFpZbUE3WVlpZU1DX1Z0bWQ0bjlaRThHOE9WR3VOVzlYWS1JampTNmxkNmFxWG54WDJjallIT3UyT0tGSzJpeG1tX0JoQjZxbEpESHBhMWZFa205bjdvTVFwSVVidnVzdURZVDAzVVpkekJ2SVZTZmhxQVJ2OWpuRGR2WFE3elMtb3B2ZzhpQVNvRmkzbzRrY1BuamVzM0E2eVM0bXBHTHgtYmhsVG5jNlB1Q1JHZU9HUlNfaTJSQkcwS2FSZnZSOW9oZzdXa1RUVTVTZTgwY01GYXQyQ0xWX1Fnb0xaOTRQY3hTclgweVJ5clc5OVpRWWlDb0JQVXoxVDA0bW8zUE55aGowb1ZZNEpBN2UtSTZTY2llRGhISFFkYWFYVlVBQ0IzbGxzVTQ2V2dsUGV1Y2I5bEZLRnlwdXRHMWZVcnBaTXNzNzNkUVFqR2xnSEQ1VlpTdXpwMFVVYjQ0enFlUnk0d3dDQUtSS1dUVnNyYnBKQW9TRjJxN2JNY2NhRWNONWRpWU5RbzNNZVJBS3EzN2ZMZ1E5VXQtMDFTZklLY1JiSDNYRlFuOF9VYUktS0xoY2IyR0xkT19qTEpIV1p6RFExUWNCQTdqN1kyS0Jaa2lyMDluenc1MS1vdmhPVlE5OUphWEY2dXFYNE04Z3lBUG5DNGZjTUVnYzEzYWhzTHpMdVBzT0dzRGJaT2x5b0pVbWJtUzJxdEd2VGtrc01kTlNPNURoVHhwZzU1d3pTZGJiTUZIME5tQ0xqNWJ2QS1QSEJHV2FEOExHWDByV19rVnc2R2pibnNENEo1cTh4bGNMX2ZpSTBMcjRvQWRhbW5xYVBiZkZzWTRERlVESEU2aHpvdzNMTjlCazRYeEJhMmZwdXY5T25IYkFTaUM3SmdIV1FCX2xxRXctWHZQOHgxLXI1c1JkWmcydkFTUmxFSU03cGtnallnTXplOElQbEJRSEE2aW5KREU0YUxwX25wOFhuS2RIbms1dXNIRHBtNjFtb3B3UGVGb0hwOENKM1hMclBwa3NBa2pFYnZYbEtFbUF0Y3pmeFRmMDNMaTZrR1BZWnBrNUQ1WlU1NVZQSWUxN3dwcXhhcjdXNTl4LVVpYVF3Y0wtRmFyNXZRNTE3UUc2cHVaVVNpaVdHbXRqQVJNZWZmNjdQQ2lwTGd6RFFZN2tSY2NEdmxvaXk4MTZMcmg0VGo3MTN2R2V6cmV3YjdQVlNEZTQySUpaY2pkTHZzUzdJLVJ2WnlOQ3Vmem5FZXRaWjBMWjF4ZEF3ZHJ4VF8tMVNsRnljejVsaEpGOU5JbnhydjNVdzNMOENrWUVsbXp0ZEhuVE1Vd0RJcnp2N0RXUGFuNDM2OXBPbV9LRDUwTWk1NHYwaDhlVEhKUmtEa09INURwNjV5ZE1VWmpRSGdjeXJNc3FqcjZDdmx5WXluNWZ2VlpsWmR2TXVXVnBubEFmQlRfaGRwRndCVXVkMjkyLWVhaDQtZDN1cmFZLUoybGRwbGQ5MTExU2NnZ2lueVNfSjFDQ2NkWGtNX2M1T2I4YnVJOUFueGIxbG1EYlZOcFYtQlE3cm90SE40X0ZjalhLdXM5S2l5aW84ZUJPMlR4MU9EVkhZcHdrX1Zqc0NhWEJacDZHMzQwSzdkdi1Rd2s4Y1dfLS1ES0NfYTNxYl84UTN1S0lIM0pVTTNEYlJ0YW55Tk4yVjBONXNTQWtVZTJ2V3B5eHBJcG9IWGRMMklob0hMbVVZZzJKbTFMUExOQm5HSEZzWHU0VGVIWlJMVzFLeFB0NkkyWFkwWk0wdjdHRmxSWFFoSkJ2Vm5NUWNQQlp6YWlIc2NKLUdhOVVycHd5N3NFMDNVWlAxZGQ1NzRGbm9LcWxEb2tKR1RnVEtvRUc1d3l4aU1IOUQ5RldUT3Z0a3lpRHpVSWJ4MjU4RWY5MEpCQ0VFdHNMbnkxOGswcE44QzJwNXFCVGpIa0VGc2VNXy1qdzVNRU9DaXg2MW9VX3FjUk41QVFVLURwVGFLRTkyNWlENy1IcGZjNW9wY0Y5Q3d5eFg5emVUUF9hV3ZTQWNaNEN0VzdJRlFBR0picXJoUERacWNLbDZhTE8wdWlfZ3kxd2QzOXBOZV9uaUNGMkNJbGhNd3k0S2t3dTRGWVVxTTFRRlg3Ui1zLW1FLU1Mai1yaURjb2Fob2c4MDUyRHN5aldUVWMxLTVNbm5VQTdrYy0zLVFyOHRkNzZ3dGdhbXZXN3JHNkdfZ2RuRXFDM3R2TVB1cDNOdWZGTmpFNnNFTmMxTmFuZDdJUld5bERyQkJ0TGZXRk54NEdqN09hSmVMYV91NXUwNXFvMl9KV0hBNlB4bklNQ2U5WGZLUTdlX2dJenVGcDYwWHBsdTNpbE5mWGhWeXFuUkFPV0puR2h0RkhrR2MwTzJGUmp4bUR6UFlUWTlNbTJLa19hTUZZR0dscVpBbFBReTBRMDNseXo4SXNnZWt4VFdpOERqLV9ZczRkR0QwRFJQM0pqdHluWktDUlp6WU9XSjVNZi1tYnNzcVlGTDRFMzNlSmRTazFfTkNxSjAwM0wxNk9Sd2h1SWpfOW5MVWMtVXYyYlVZR0VuaHRpN1pnNnpHME5raVBMd2h2dDRyMV8yZGFJNnlkcmhtSWdmNlpLN19NcjNkc002dXFxQzhTaDZzRlgzNUJ1SzVpVnp6NVU1Y2luUlM4UEJoajNTOUJadnE1MlhzV0kxSzBObXkteVhNM3RKYW9heDVWWFJ1NGlDM0l0elRPbThwUU9oYkVkbC1PZFNLSHY3WHJiZWpEamNIVC00MlNNWV9qcHdjNDRjRlVhZXlrLTlicVBNaDlDeXdRb0Fwc3RmUGFvbURQZ29yckliaS1VUDNxcXVlYTJJRUhXNUVobk1KUDhHZE16UzBLeDViYVRwZWY3d2w0d253eEZYcExKRGpsaGlBUElaTzB3eUVadnROX1dabENGb3R4ZF9aS05KY0dHTVZaYzRFc1Z4TlZGbFd2NjdYRzJMTzVwU2NaN1Y3MzQ2Z2pzV2RSMzJBbjg0MEhaZmhoREloY0oxOFdjNDZNdVZfYlRKU1Q1M2hYdHgwUjVsTV9USjZCZXlQTTdNRWc3bUxOcXRDVkpTdnJxR0hkWWpaRUdrOEFyNHk4MENwVzdob0hUSkJvam4zZW1kcGxZUjg0RXFRNnBxSUg1MDVHdHRwVlFkWWhHM0ZyZVFvMF96R2V5YjBuMnVZTU5CQ3pVci16SGJlQTQtbnFLa1E2eHFncUg3UmYyYlZvOF82a3d2ZE4tbmxIUlNYYjlrck9QYk5CcV9faXludS1yem1JNjFBdVYyb21RQWFMMFkxX0s1TjQ4czZ2WXI3X0FzRWdNTlZndHl4bnVOTHl2YlZfaURQV053dHl4N1czRFdzaVFnRHB0MWRDV2ZuU2lzX1NZZkRQYzhsT3ItZWw0dVJlVmtFWUM5cEppOGxuYVdpQkN5dV9hQ2dodTJvV3REVkw2dVVDaGtvc0Zqd0V2dldLZEVNRVRRNVRUVmw5aHZmZEpHdk1wS0xwRFc5Vmx4dTdfdGZDRUtCU29qdEVIOW5VdjBmeGpFMFZHSUthamtVN1E2bDZqaEFackVSQnZMN0tyaUhIcUs1ZHMzMzl2TnhadGIwZW5QNS1BM3pSODY3WVFsLU1jeUpCMG1PWmhPVT0=
|
||||
|
||||
# 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
|
||||
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
|
||||
91
env-prod.env
91
env-prod.env
|
|
@ -1,91 +0,0 @@
|
|||
# Production Environment Configuration
|
||||
|
||||
# System Configuration
|
||||
APP_ENV_TYPE = prod
|
||||
APP_ENV_LABEL = Production Instance Forgejo
|
||||
APP_KEY_SYSVAR = /srv/gateway/shared/secrets/master_key.txt
|
||||
APP_INIT_PASS_ADMIN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3UnJRV0sySFlDblpXUlREclREaW1WbUt6bGtQYkdrNkZDOXNOLXFua1hqeFF2RHJnRXJ5VlVGV3hOZm41QjZOMlNTb0duYXNxZi05dXVTc2xDVkx0SVBFLUhncVo5T0VUZHE0UTZLWWw3ck09
|
||||
APP_INIT_PASS_EVENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3QVpIY19DQVZSSzJmc2F0VEZvQlU1cHBhTEgxdHdnR3g4eW01aTEzYTUxc1gxTDR1RVVpSHRXYjV6N1BLZUdCUGlfOW1qdy0xSHFVRkNBcGZvaGlSSkZycXRuUllaWnpyVGRoeFg1dGEyNUk9
|
||||
APP_API_URL = https://api.poweron.swiss
|
||||
|
||||
# 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
|
||||
|
||||
# Security Configuration
|
||||
APP_JWT_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3elhfV0Rnd2pQRjlMdkVwX1FnSmRhSzNZUlV5SVpaWXBNX1hpa2xPZGdMSWpnN2ZINHQxeGZnNHJweU5pZjlyYlY5Qm9zOUZEbl9wUEgtZHZXd1NhR19JSG9kbFU4MnFGQnllbFhRQVphRGQyNHlFVWR5VHQyUUpqN0stUmRuY2QyTi1oalczRHpLTEJqWURjZWs4YjZvT2U5YnFqcXEwdEpxV05fX05QMmtrPQ==
|
||||
APP_TOKEN_EXPIRY=300
|
||||
|
||||
# MFA Configuration
|
||||
MFA_REQUIRE_ADMINS = True
|
||||
|
||||
# CORS Configuration
|
||||
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://porta.poweron.swiss,https://porta-int.poweron.swiss,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss
|
||||
|
||||
# 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 = PROD_ENC:Z0FBQUFBQnFBa1kyeUZORDYxOFdlNHk1N25kV3pSQVJMUVFwLUFlMzlzQjQ1eVljOTlzX184RndsTmtTV1FjdWkyQlBiUkdCbGt5S2ltZjJxa2I2dHBMdnJqZnhFSnBCampHYjB3RG5URDM1YzZSLVd6TGdaRXRVcEdadE5zM2thNV9SZy1KZDdLSHY=
|
||||
Service_MSFT_AUTH_REDIRECT_URI=https://api.poweron.swiss/api/msft/auth/login/callback
|
||||
Service_MSFT_DATA_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
|
||||
Service_MSFT_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnFBa1kySk5uMmlWczBWTE00MHBIcWlBbVJmVmc3MlBWbDA1YTFaS3psZjVLd3d1X2FvRHV0X0c5blpLV0FpY05aMTJMMzUtcG8wakF2TlM3SGQ2VjFZM3JLT1MwTlZ0bm9BRlpkbHVPQTFNaXJvazlQRzN4M2ZZNEVhV1JHV190dWluSUk=
|
||||
Service_MSFT_DATA_REDIRECT_URI = https://api.poweron.swiss/api/msft/auth/connect/callback
|
||||
|
||||
Service_GOOGLE_AUTH_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
|
||||
Service_GOOGLE_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnFBa1kybjVVZ0FldUE1NTJiY2U1N0I0aVU0Z2hfeWlYc2tTdmlxTS1NdGxsRnFHdjZVcW5RRHZkUFhzUTVyX2RaZHlrQThRdTdCRmVBelBOcDlsbFQyd19SZExuWEM5aTcwQ0FvY3ctMUlWU1pndDE0MkdzeTZZRHkwLWU3aW56LW1jS20=
|
||||
Service_GOOGLE_AUTH_REDIRECT_URI = https://api.poweron.swiss/api/google/auth/login/callback
|
||||
Service_GOOGLE_DATA_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
|
||||
Service_GOOGLE_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnFBa1kyMnFma3VPOVJtTFFrNDRLN0NkWHY2dUZDWlJzdDVMd3p3N19IY0tWdURRRzExOGZCMjJOYmpKT1E0cTVwYlgtcVJINTY0anZPc1VoTW00cHl6NVh3ZHVTek1oT1RqWUhtamRkZ1dENWlwNTlZSU1oNWczeGdEOC1Gbk5XU2RBcmI=
|
||||
Service_GOOGLE_DATA_REDIRECT_URI = https://api.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://api.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:Z0FBQUFBQnFGdnVKZ2Z0U2s4cnpUN01mRVkzQUYyVm43NzZLOWJBODlvRlNFdTNGbzZHblNzUFJ2X0I3SXRFQTRXWlFYZjY1aUVVOTgxSU1KemZ4Wkl1NzFQb2JIcnM3bjg5bkRDYmpNVjVjTG55QmtSUVpZejEtckZTd20xRzd2NmhVSkJUQTFUZWk0dzhrUnJuNWZPa2NPSDR6QnQ1a0RCbWM4Y1h3Mmh2NmQ5SHFOR2FISndEMzF4Y29YcVlKaVNyNGM2VWFINUg4MjVMcHZJTUxVWXJNNUZVdW9GUkx0ZkZlZTJqRGI4ZkVuaklHMEotb3FyOEFka1c2WC1VclJZeHFucmJlRjhlUUhLNWdFX0xaRFp0ODNFNFZaWEdSTU5QbDcxbUxlclN0X2t0c1dpWXVJeFE9
|
||||
Connector_AiAnthropic_API_SECRET = PROD_ENC:Z0FBQUFBQnFGd0hjRzFXSXhjMVZWOW40ZFRRREItclVxODVDdFdSa2tzVVJ2TWVZaVl5UE40YzgxR2d4RVdhVUFaQy1VZVRRMzFnZW1NcjlNY1h6ZVJta3F5STI5Y2taRVlXbFREb2paMTZpRVZpdEVBVnJrSjlvS1lSMzB3V3FkWW56WlNhQUFiby1Mb2RCb0VHQ2NYYmNOUGZ5UEdseGJic2ZSQk1ReXlTRnJITVY3SEdPb296eGNIdXNRME5LOTlZUlRvclJRX2R3ZHlxM0puXzlWRzY2eHliY1FUNmxSZz09
|
||||
Connector_AiPerplexity_API_SECRET = PROD_ENC:Z0FBQUFBQnFGdnVLODRmYXo5T3BxSDJnZXgzRlFfR0oyWXVkeVRZbk14VkdDV3pTaWVfV3Y3R21LaWJpSC1laTg1T3NYREI2RzBBWWtraFJud0U2ZnhVQzJ0bnViVzJtOWh4dDZ3VUdoZUxaUzdhSkM4N3ZOOTFINmV1TGNmRE9RRmtfeTduVEV6QnYyRTZJaGxGb3ZFSmZmZ1JxUDdFSVBRPT0=
|
||||
Connector_AiTavily_API_SECRET = PROD_ENC:Z0FBQUFBQnFGdnVLcTlLSFJ5b0gwRmJLMFB5MzA3S3FYbmhKV2VzbHI0ZFUzOUJNdHVYQlQ0ckdicW1WWG5CNEkyWVlrR0gwQ0ZramJ1c19JS290MmlvWVhYWW92cEhIdmRTRXdPQzZpVFdDaU9MQzFlMEdPYUVnYy1HZlM1ODVuYnZGRnVZVFZpYzZBcUNRekVBZFFzVExQV254OUZ0aHVBPT0=
|
||||
Connector_AiPrivateLlm_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGanZ6U3pzZWkwXzVPWGtIQ040XzFrTXc5QWRnazdEeEktaUJ0akJmNnEzbWUzNHczLTJfc2dIdzBDY0FTaXZYcDhxNFdNbTNtbEJTb2VRZ0ZYd05hdlNLR1h6SUFzVml2Z1FLY1BjTl90UWozUGxtak1URnhhZmNDRWFTb0dKVUo=
|
||||
Connector_AiMistral_API_SECRET = PROD_ENC:Z0FBQUFBQnFGdnVLRHplbzNheDhIdndsU0xUeGlBYVVXWDRzOF9Tek41WjEtSmNqbnVHRXFaZ0dramlfZWlQelpJWVh5T0F2azBaQWU3ajU0TWljaGpMeTlra0g0LVhKeTRKNGxKY0ZqSkxwdTJLdWM5cWdMVC1TVkpLb2lPdHhyeWtieFJFOHdkVy0=
|
||||
|
||||
Service_MSFT_TENANT_ID = common
|
||||
|
||||
# Google Cloud Speech Services configuration
|
||||
Connector_GoogleSpeech_API_KEY_SECRET = PROD_ENC:Z0FBQUFBQnFIc3YtNDZzenJuZEZiQnVMOWRmZjl3R29QOWZRaGlPdk56WG1DR0FSZU5DM3dENWdoMmRpaks1U1VDNDJkZ3d3UXhSbXlkZ2h3SGZfdk54WXVidF82VkdJQXZiRTk0UlhZaUY1b2kwNzNPSm52VFdsdkwtaHJBb2dpRDBVLXRwd19Bb0dUZDkyV1VWZDJ1TG5mZ0ktYXpuS3U1U0JkZUk5TXpMdnhOaUtMN3BIb0pEZ1N0SlpFN3NNby15VTRfWWtxaF9DYjlJcnVKb0ZualVMTUx2aVNGY0JJdE1oZy1xSVBUZDF1aDM0TGVlTzVrNkFHcjlhcEk0SmRIMTFGdDFTMVUxX1dERk9NTXZMb0tVTFRoc20xME1uRkdVV0Z5N200ZTQzSjVsVExoa2VRZmFBU21ZczF0Vm9Ib3BZM2ZneDkwak12UmFyWWd0eng3ZVVFTUFLVzNOazcxeUhLVWUxcEFIZWtNRi1mT29kM1pqNGJJUUh3UVBlNGY3SlotOWZFUk5aQXFXcUFVdnUzc0Z5bERXYUNPbG14VnBNenFvb2tiQ3lZeHNHUVBlQTdTdVdXOEkxaGxCX016WWktWmN2WFcwM0VmVHdvMHVnY212VFE2cjJwUjdENkFCZF9GcUktWWpmWlNXNWVTMHBPdzVxRi15d3FSRDFra2k0NEFmTmpUeVh3SHRuZWE3WGJ4eUNIcE5tdnRqX2NCZnJoMEI2emU4U0ZYN1Nmdlhva1NacFo3UFh3WnpSdGw5ZmNpSGhicFo0ZThReXl3LW9vUzZaMkFHX2lJalFEMWtjZVdqbVpIZGk0cEdEU01TMl9xQkdSNDllTS1GV3lXS0xROTJvSlhaTjlXenJhQ3lOd2p0VjR5ZjEyektUZGJ3UThJOVJuMzhsTTVBVW9BcDFtcjk5Y0pVeW0zX3R0Nk81R3VDRWEzZnRqSXhFUW5ONHFTSWlwQU4yazlDb01KYlFQRjBFVTljdEJIY29WdF9hUkRJOThVTVFfWlJQUXI0Z3RzWFlzR1ZxUWFBd2I1SW1EMWlKdVprT3dKYTlaREp6TkZEZmVsZGEyalZGc3dHaUkyamdmQWtUT2czNzBCZEg0Vk1HSHFpRnhRYzBRNnN3TFkyaE9uMTVXN1VJTmJwbTNUMTdZbVRyc2d6Yl9aaVBXNmFvanROQVhfbWpXTDRlR1RfbklnYnJUQTZPX2JfNnlrWDVDUWJ4Z3YwNXVsTkJFQlRhTG5DVHpwejdsMGl1bzRfRXRTU2dmb3BVMUo4VkQwa0hsTmFBZnVjVzRrQmNzS2R0ZHNGV24yQnktWENtMUp6eG1MQW1ENE1vWFpFUF9PMEpWZVlxX05hSW1QUGlVT1l3MFp4bDBDZVVldHlEUlVCY1VvVlBNTlBhWFlmcVRobDNqRHo0QjZvNDBqVUVKN3JOb2dtYXQxSWw5NERSeEVRdHNUWndzUkY5RjdBOG1FZFRiVTNVSzl5bDNwdTl2SVd5aW5Ub2Q1YlBDRnpBUDkteU44YnV5X05ONmNndm9teUpqaFZVcVlHdGVRcXRpZkJLVnRuMTJSUFhGWndibExqRW03YUJTWXZXUXJ5WXlvd01ISDFuUFpaMFJzNFVQbWRUb2h1Zi1rcXJXMkRQSUFPeWFJN3lzOFc1d3BjWG1kbWlQWGUwelNiSnJXbUpnajdlQTlQR19XNTF0Q3JYcUMzaGp3eU0yZGhKa3FtX0tleHBfekZaWlRJRlZlSzNDVU56cml0TnFJeUc3b09uYVlwbGxFVFR6WFJVMzRmak5yWjBhcjl5ZmJpQ3hpajRXV1dwbDF5N25tNnI2bWtFem1TS08yV3JybUF0enYxRXpkUVdTNVp4WVB0aldJUUN3TnhHcHdMczh5MTFETzNWLXZFSktsdU1vM1JSNXhraDlJRDl0MEhvR1NOQWRaQW1NdzhpZnFVa1hvdXNwY2FvaThHQjVMOXdySnNIcWJlWERfLXVOcHhpN2ZZOW4yVzB3VTI2a3hvVmFkc29aX2ZUZkY5bi04WEV4MTlxNXQ4cTcwaHE4X3hDWkQxelRwSUl2amZOQ0JXRlJjRFhJNVhjNjRmaXp5eG15LTN1MFRvN3BHTFRZQ1ZFVFYyNUxleFpKTHlIVzRnVHk1Y3ZUbV9RUDdqN1Z2M2ZqVG8wa2RoVHJPeENFRDNHV0wwdi1DbEdOVDFJZnRiZGEydlZyM2tQVExOVlo3LXhIUnhZUnB6a2UzZXNtTjR0S2NzUmFNOWNiSHhHTnJDWHowWk1tbVFKUC14M25aQ1hyYjhJM2pxOEtZY0J1WTZrU3l6cDJOdk5iSXpBUk41MFFVellVZFU4UWVDZXFkQnJFbGxQX2J0S3pReU8zZUdsZUgtTnJuSlpfTjdxR3UxWTBEV0JaRV93eE9qa2dNa2tVTHRxMWNyeUh2VWNrYkdKM3BZOURkUlBxUDA3R2M4NnlMTVR2dmNMZi1lZlhzalRJWlFocGRleVRJYXBBY2hCXzFGZEU4ZVFxbHNic3RDV2FYN1dNaWpkaGdwYTEzRkZYRlEtRXR1cERHdnJKX1Zzb1Q0MnVYZkVhb0VYU1JPdFhoV29TMlhTaEppR1lTTURLYmZnNS1pSzl4T1k5MXJ0YV9qX0ZyQ1R6RFFzRndrTW9IUVlxcG5jcTEyYVU3dkpIR0tZZTZiOXNIRFpIalRtUDFBLVNyd1NfNUMtLW52NVpFZGpQenJCOGw0UlJZNlZVT1ZXTm92R3k4c3hTQXFoNFE3TUFHcjRWc01zT082anJZT0laakl5VUk1WDdDaWlubjIwS3RNcjBjTTdpbUNxSmxNR05JaWtEQURlS1h6N2h0NE9CcW5rQ3NXWkwyNXVBUU5mLTU5MG8xX29xZ0t6Z2pKWmhMNG1BNXBhYWkzY0loSmluUXNKdURwQWRIV2laM2dHQTFxV19lbkZXWmdfWEdiWEZsMGVIWDdoMnJ5dzM0ZGtBM3BSRVp2QzFNbFJSWXBManN5WmFVMlp6aUpWMF9jMTRPbWptM1lsTE41NG1kUW4tT0ZqTzNaZnZ5ZzBLZzNNc1N1X2FMMVJ0N3o4a25LMkxKVUE0dTNhU3hZX3RFMUtKcEgtX1B0cTdEMmYyMzdPaEhoeWhaUGRITC11NzRWYTJnZldiUkFvdG95a1RwWnNKaERkT0kxN1RJMzZQZzFiSjl1SlJieTJjaHBMYmZDUlhTT2hvQnRPaTNhS3NzaVc1Tms0X0FyUHRsSXdCLW1OUWk1RkRKc3pqSjVQTFFROEN5M3pxUGVjZHI4SVM3Qmx1S1A2bEEzNWlVWkFndGpUSm4wcV9jRjQ5T0l1c3ZqN0w3Z1dMV2ZtbU9MbTVSOXphX3VLMko2ZEs3U0NIaFFIMVFIcnN0OGIxSjdxNGlHUHRnOEJDaGwzcXJYNFBnOGdFSVFuSGUyOWJ3WmtlVGhGQWk0THdZd1hUbGRydk83SWVzWUJrb21tSlNvVkJjdWYtcWo0aEc1Ri1XNTZoSENaRWJISmp3UlJNMU9vSnNzZ0VudXpxMDA3aGdfSDBNZlA0Y1gybkF4dGl6SzFOc1VMN0dzVkQxVllkSDhyby12SWNxTFRYdThJUm13S3p3cGFYc05TbVc2YVNtZEdCOFBCUXhadkIzNmdkbXpnc1pLYUhzOEtsY2kxVmNYZm9wOS1LOERLRHJhY2VhanNjaThUZW1rS01wUW05SFJxOGd1VF9STlJZWDRiTV92dXlQTkdxN3BYYTN1SUhRSjRNTy1PZWpGd0xhUlVES0hiWE5LUkM5dHNvenR3TVMySC1ueUZXUkxFY2VyRmhISGc2U2ZxeXY2VkJULV9pOTU1QkI5VUNndnVQcVItTW96VTBqRTdzem1IQ1UxVWtWdjhvTERFeGJ6M3dJNERUV1BTeUlRcG1fbUVjQ0lNREF5QkpLeHJHRkFxQS1kZEE4bXJ2aVVSckVoTkZwNGtoRElIcUktQjA1bkNRclM4dWlqUVRXXzdlQ0VjQWZGSTZlR01NQmU5bHQ3bGNtZWU1eHVvRVdQRVU4Rmx0OFRTaWF3cGgyeFJoM25sRk1GNXJtdEpfcEJmYVFrZXd4eXl0c0ZKVjQ3MkFNRjh5bDBTbFZNd256dmxpQlo5Z1FRM1ZmVTJSb3VrZTk3cXVQYmZ6SnNUWGhlSUhrUjVWUHFwemNmbW1scWVxTkcxT1p5dVlvUjhCSVJaSnBjU0dpc3YzVkt1WUtrd2xoQlVNQXh1eDhmTXNISWMyUnBUMmIwamxlS0tjMVRiWDlBcE03b1BHR1FmdmlsX2ZlMTNCaFNvNG1TeTNiQXRNZ2Y1eE1IaFAxTUZGZ1YyZjEzTG9PaGRCdHJzVlB5Mm12T1NiX2RyT2d2RERCRWFHT0dadW5DZjNtdXE4cHhEQlpub2l3bz0=
|
||||
|
||||
# Teamsbot Browser Bot Service
|
||||
TEAMSBOT_BROWSER_BOT_URL = http://teamsbot.poweron.swiss:4100
|
||||
|
||||
# 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,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Base connector interface for AI connectors.
|
||||
|
|
@ -11,15 +11,15 @@ IMPORTANT: Model Registration Requirements
|
|||
- If duplicate displayNames are detected during registration, an error will be raised
|
||||
"""
|
||||
|
||||
import re
|
||||
import re as _re
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List, Dict, Any, Optional, AsyncGenerator, Union
|
||||
from modules.datamodels.datamodelAi import AiModel, AiModelCall, AiModelResponse
|
||||
|
||||
|
||||
_RETRY_AFTER_PATTERN = re.compile(
|
||||
r"(?:try again in|retry after)\s+(\d+(?:\.\d+)?)\s*s", re.IGNORECASE
|
||||
_RETRY_AFTER_PATTERN = _re.compile(
|
||||
r"(?:try again in|retry after)\s+(\d+(?:\.\d+)?)\s*s", _re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Dynamic model registry that collects models from all AI connectors.
|
||||
|
|
@ -12,9 +12,10 @@ import time
|
|||
import threading
|
||||
from typing import Dict, List, Optional, Any, Tuple
|
||||
from modules.datamodels.datamodelAi import AiModel
|
||||
from modules.datamodels.datamodelRbac import AccessRuleContext, RbacProtocol
|
||||
from .aicoreBase import BaseConnectorAi
|
||||
from modules.datamodels.datamodelUam import User
|
||||
from modules.security.rbacHelpers import checkResourceAccess
|
||||
from modules.security.rbac import RbacClass
|
||||
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -185,7 +186,7 @@ class ModelRegistry:
|
|||
def getAvailableModels(
|
||||
self,
|
||||
currentUser: Optional[User] = None,
|
||||
rbacInstance: Optional[RbacProtocol] = None,
|
||||
rbacInstance: Optional[RbacClass] = None,
|
||||
mandateId: Optional[str] = None,
|
||||
featureInstanceId: Optional[str] = None
|
||||
) -> List[AiModel]:
|
||||
|
|
@ -236,7 +237,7 @@ class ModelRegistry:
|
|||
self,
|
||||
models: List[AiModel],
|
||||
currentUser: User,
|
||||
rbacInstance: RbacProtocol,
|
||||
rbacInstance: RbacClass,
|
||||
mandateId: Optional[str] = None,
|
||||
featureInstanceId: Optional[str] = None
|
||||
) -> List[AiModel]:
|
||||
|
|
@ -261,7 +262,7 @@ class ModelRegistry:
|
|||
logger.debug(f"User {currentUser.username} does not have access to model {model.displayName} (connector: {model.connectorType})")
|
||||
return filteredModels
|
||||
|
||||
def getModel(self, displayName: str, currentUser: Optional[User] = None, rbacInstance: Optional[RbacProtocol] = None) -> Optional[AiModel]:
|
||||
def getModel(self, displayName: str, currentUser: Optional[User] = None, rbacInstance: Optional[RbacClass] = None) -> Optional[AiModel]:
|
||||
"""Get a specific model by displayName, optionally checking RBAC permissions.
|
||||
|
||||
Args:
|
||||
|
|
@ -283,15 +284,8 @@ class ModelRegistry:
|
|||
connectorResourcePath = f"ai.model.{model.connectorType}"
|
||||
modelResourcePath = f"ai.model.{model.connectorType}.{model.displayName}"
|
||||
|
||||
try:
|
||||
connPerms = rbacInstance.getUserPermissions(currentUser, AccessRuleContext.RESOURCE, connectorResourcePath)
|
||||
modelPerms = rbacInstance.getUserPermissions(currentUser, AccessRuleContext.RESOURCE, modelResourcePath)
|
||||
hasConnectorAccess = connPerms.view if connPerms else False
|
||||
hasModelAccess = modelPerms.view if modelPerms else False
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking resource access for {modelResourcePath}: {e}")
|
||||
hasConnectorAccess = False
|
||||
hasModelAccess = False
|
||||
hasConnectorAccess = checkResourceAccess(rbacInstance, currentUser, connectorResourcePath)
|
||||
hasModelAccess = checkResourceAccess(rbacInstance, currentUser, modelResourcePath)
|
||||
|
||||
if not (hasConnectorAccess or hasModelAccess):
|
||||
logger.warning(f"User {currentUser.username} does not have access to model {displayName}")
|
||||
|
|
@ -347,8 +341,8 @@ class ModelRegistry:
|
|||
modelRegistry = ModelRegistry()
|
||||
|
||||
# Eager pre-warm on first import: ensures connectors are ready in this process.
|
||||
# Critical for AI/agent performance — avoids 4–8 s latency on first request.
|
||||
# Runs when this module is first imported (lifespan or first AI request).
|
||||
# Critical for chatbot performance — avoids 4–8 s latency on first request.
|
||||
# Runs when this module is first imported (lifespan or first chatbot request).
|
||||
def _eager_prewarm() -> None:
|
||||
try:
|
||||
modelRegistry.ensureConnectorsRegistered()
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Simplified model selection based on model properties and priority-based sorting.
|
||||
|
|
@ -140,10 +140,11 @@ class ModelSelector:
|
|||
promptFiltered.append(model)
|
||||
else:
|
||||
maxAllowedTokens = model.contextLength * 0.8
|
||||
if totalTokens <= maxAllowedTokens:
|
||||
# Compare prompt tokens (not bytes) with model's token limit
|
||||
if promptTokens <= maxAllowedTokens:
|
||||
promptFiltered.append(model)
|
||||
else:
|
||||
logger.debug(f"Model {model.name} filtered out: totalTokens={totalTokens:.0f} > maxAllowed={maxAllowedTokens:.0f} tokens (80% of {model.contextLength} tokens)")
|
||||
logger.debug(f"Model {model.name} filtered out: promptSize={promptTokens:.0f} tokens > maxAllowed={maxAllowedTokens:.0f} tokens (80% of {model.contextLength} tokens)")
|
||||
|
||||
logger.debug(f"After prompt size filtering: {len(promptFiltered)} models")
|
||||
|
||||
|
|
@ -323,4 +324,4 @@ class ModelSelector:
|
|||
|
||||
|
||||
# Global model selector instance
|
||||
modelSelector = ModelSelector()
|
||||
modelSelector = ModelSelector()
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import httpx
|
||||
|
|
@ -31,8 +30,6 @@ def _supportsCustomTemperature(modelName: str) -> bool:
|
|||
if not modelName:
|
||||
return True
|
||||
name = modelName.lower()
|
||||
if name.startswith("claude-opus-4-8"):
|
||||
return False
|
||||
if name.startswith("claude-opus-4-7"):
|
||||
return False
|
||||
if name.startswith("claude-sonnet-4-7"):
|
||||
|
|
@ -81,54 +78,6 @@ class AiAnthropic(BaseConnectorAi):
|
|||
def getModels(self) -> List[AiModel]:
|
||||
# Get all available Anthropic models.
|
||||
return [
|
||||
AiModel(
|
||||
name="claude-opus-4-8",
|
||||
displayName="Anthropic Claude Opus 4.8",
|
||||
connectorType="anthropic",
|
||||
apiUrl="https://api.anthropic.com/v1/messages",
|
||||
temperature=0.2,
|
||||
maxTokens=128000,
|
||||
contextLength=1000000,
|
||||
costPer1kTokensInput=0.005, # $5/M tokens (Anthropic API, 2026-05)
|
||||
costPer1kTokensOutput=0.025, # $25/M tokens
|
||||
speedRating=5,
|
||||
qualityRating=10,
|
||||
functionCall=self.callAiBasic,
|
||||
functionCallStream=self.callAiBasicStream,
|
||||
priority=PriorityEnum.QUALITY,
|
||||
processingMode=ProcessingModeEnum.DETAILED,
|
||||
operationTypes=createOperationTypeRatings(
|
||||
(OperationTypeEnum.PLAN, 10),
|
||||
(OperationTypeEnum.DATA_ANALYSE, 9),
|
||||
(OperationTypeEnum.DATA_GENERATE, 10),
|
||||
(OperationTypeEnum.DATA_EXTRACT, 9),
|
||||
(OperationTypeEnum.AGENT, 10),
|
||||
(OperationTypeEnum.DATA_QUERY, 3),
|
||||
),
|
||||
version="claude-opus-4-8",
|
||||
calculatepriceCHF=lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.005 + (bytesReceived / 4 / 1000) * 0.025
|
||||
),
|
||||
AiModel(
|
||||
name="claude-opus-4-8",
|
||||
displayName="Anthropic Claude Opus 4.8 Vision",
|
||||
connectorType="anthropic",
|
||||
apiUrl="https://api.anthropic.com/v1/messages",
|
||||
temperature=0.2,
|
||||
maxTokens=128000,
|
||||
contextLength=1000000,
|
||||
costPer1kTokensInput=0.005,
|
||||
costPer1kTokensOutput=0.025,
|
||||
speedRating=5,
|
||||
qualityRating=10,
|
||||
functionCall=self.callAiImage,
|
||||
priority=PriorityEnum.QUALITY,
|
||||
processingMode=ProcessingModeEnum.DETAILED,
|
||||
operationTypes=createOperationTypeRatings(
|
||||
(OperationTypeEnum.IMAGE_ANALYSE, 10)
|
||||
),
|
||||
version="claude-opus-4-8",
|
||||
calculatepriceCHF=lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.005 + (bytesReceived / 4 / 1000) * 0.025
|
||||
),
|
||||
AiModel(
|
||||
name="claude-opus-4-7",
|
||||
displayName="Anthropic Claude Opus 4.7",
|
||||
|
|
@ -655,9 +604,9 @@ class AiAnthropic(BaseConnectorAi):
|
|||
mimeType = parts[0].replace("data:", "")
|
||||
base64Data = parts[1]
|
||||
|
||||
_SUPPORTED = {"image/jpeg", "image/png", "image/gif", "image/webp"}
|
||||
import base64 as _b64
|
||||
try:
|
||||
rawHead = base64.b64decode(base64Data[:32])
|
||||
rawHead = _b64.b64decode(base64Data[:32])
|
||||
if rawHead[:3] == b"\xff\xd8\xff":
|
||||
mimeType = "image/jpeg"
|
||||
elif rawHead[:8] == b"\x89PNG\r\n\x1a\n":
|
||||
|
|
@ -668,9 +617,6 @@ class AiAnthropic(BaseConnectorAi):
|
|||
mimeType = "image/webp"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if mimeType not in _SUPPORTED:
|
||||
raise ValueError(f"Unsupported image media_type '{mimeType}' for Anthropic (supported: {', '.join(sorted(_SUPPORTED))})")
|
||||
|
||||
# Convert to Anthropic's vision format
|
||||
anthropicMessages = [{
|
||||
|
|
@ -862,4 +808,4 @@ def _convertToolsToAnthropicFormat(openaiTools: List[Dict[str, Any]]) -> List[Di
|
|||
"description": fn.get("description", ""),
|
||||
"input_schema": fn.get("parameters", {"type": "object", "properties": {}})
|
||||
})
|
||||
return anthropicTools
|
||||
return anthropicTools
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
import logging
|
||||
from typing import List
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
import logging
|
||||
import json
|
||||
import json as _json
|
||||
import httpx
|
||||
from typing import List, Dict, Any, AsyncGenerator, Union
|
||||
from fastapi import HTTPException
|
||||
|
|
@ -274,7 +274,7 @@ class AiMistral(BaseConnectorAi):
|
|||
bodyStr = body.decode()
|
||||
if response.status_code == 429:
|
||||
try:
|
||||
errorMsg = json.loads(bodyStr).get("error", {}).get("message", "Rate limit exceeded")
|
||||
errorMsg = _json.loads(bodyStr).get("error", {}).get("message", "Rate limit exceeded")
|
||||
except (ValueError, KeyError):
|
||||
errorMsg = f"Rate limit exceeded for {model.name}"
|
||||
raise RateLimitExceededException(f"Rate limit exceeded for {model.name}: {errorMsg}")
|
||||
|
|
@ -287,8 +287,8 @@ class AiMistral(BaseConnectorAi):
|
|||
if data.strip() == "[DONE]":
|
||||
break
|
||||
try:
|
||||
chunk = json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
chunk = _json.loads(data)
|
||||
except _json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
delta = chunk.get("choices", [{}])[0].get("delta", {})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
import logging
|
||||
import json
|
||||
import json as _json
|
||||
import httpx
|
||||
from typing import List, Dict, Any, AsyncGenerator, Union
|
||||
from fastapi import HTTPException
|
||||
|
|
@ -319,24 +319,25 @@ class AiOpenai(BaseConnectorAi):
|
|||
calculatepriceCHF=lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.00013
|
||||
),
|
||||
AiModel(
|
||||
name="gpt-image-1",
|
||||
displayName="OpenAI GPT Image",
|
||||
name="dall-e-3",
|
||||
displayName="OpenAI DALL-E 3",
|
||||
connectorType="openai",
|
||||
apiUrl="https://api.openai.com/v1/images/generations",
|
||||
temperature=0.0,
|
||||
maxTokens=0,
|
||||
temperature=0.0, # Image generation doesn't use temperature
|
||||
maxTokens=0, # Image generation doesn't use tokens
|
||||
contextLength=0,
|
||||
costPer1kTokensInput=0.04,
|
||||
costPer1kTokensOutput=0.0,
|
||||
speedRating=5,
|
||||
qualityRating=9,
|
||||
speedRating=5, # Slow for image generation
|
||||
qualityRating=9, # High quality art generation
|
||||
# capabilities removed (not used in business logic)
|
||||
functionCall=self.generateImage,
|
||||
priority=PriorityEnum.QUALITY,
|
||||
processingMode=ProcessingModeEnum.DETAILED,
|
||||
operationTypes=createOperationTypeRatings(
|
||||
(OperationTypeEnum.IMAGE_GENERATE, 10)
|
||||
),
|
||||
version="gpt-image-1",
|
||||
version="dall-e-3",
|
||||
calculatepriceCHF=lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.04
|
||||
)
|
||||
]
|
||||
|
|
@ -477,7 +478,7 @@ class AiOpenai(BaseConnectorAi):
|
|||
bodyStr = body.decode()
|
||||
if response.status_code == 429:
|
||||
try:
|
||||
errorMsg = json.loads(bodyStr).get("error", {}).get("message", "Rate limit exceeded")
|
||||
errorMsg = _json.loads(bodyStr).get("error", {}).get("message", "Rate limit exceeded")
|
||||
except (ValueError, KeyError):
|
||||
errorMsg = f"Rate limit exceeded for {model.name}"
|
||||
raise RateLimitExceededException(f"Rate limit exceeded for {model.name}: {errorMsg}")
|
||||
|
|
@ -490,8 +491,8 @@ class AiOpenai(BaseConnectorAi):
|
|||
if data.strip() == "[DONE]":
|
||||
break
|
||||
try:
|
||||
chunk = json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
chunk = _json.loads(data)
|
||||
except _json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
delta = chunk.get("choices", [{}])[0].get("delta", {})
|
||||
|
|
@ -652,82 +653,105 @@ class AiOpenai(BaseConnectorAi):
|
|||
)
|
||||
|
||||
async def generateImage(self, modelCall: AiModelCall) -> AiModelResponse:
|
||||
"""Generate an image using GPT Image model (gpt-image-1)."""
|
||||
"""
|
||||
Generate an image using DALL-E 3 using standardized pattern.
|
||||
|
||||
Args:
|
||||
modelCall: AiModelCall with messages and generation options
|
||||
|
||||
Returns:
|
||||
AiModelResponse with generated image data
|
||||
"""
|
||||
try:
|
||||
import json
|
||||
|
||||
# Extract parameters from modelCall
|
||||
messages = modelCall.messages
|
||||
model = modelCall.model
|
||||
options = modelCall.options
|
||||
|
||||
# Get prompt from messages
|
||||
promptContent = messages[0]["content"] if messages else ""
|
||||
|
||||
|
||||
# Parse prompt using AiCallPromptImage model
|
||||
import json
|
||||
|
||||
try:
|
||||
# Try to parse as JSON
|
||||
promptData = json.loads(promptContent)
|
||||
promptModel = AiCallPromptImage(**promptData)
|
||||
except Exception:
|
||||
except:
|
||||
# If not JSON, use plain text prompt
|
||||
promptModel = AiCallPromptImage(
|
||||
prompt=promptContent,
|
||||
size=options.size if options and hasattr(options, "size") else "1024x1024",
|
||||
quality=options.quality if options and hasattr(options, "quality") else "auto",
|
||||
size=options.size if options and hasattr(options, 'size') else "1024x1024",
|
||||
quality=options.quality if options and hasattr(options, 'quality') else "standard",
|
||||
style=options.style if options and hasattr(options, 'style') else "vivid"
|
||||
)
|
||||
|
||||
|
||||
# Extract parameters from Pydantic model
|
||||
prompt = promptModel.prompt
|
||||
size = promptModel.size or "1024x1024"
|
||||
rawQuality = promptModel.quality or "auto"
|
||||
quality = {"standard": "auto", "hd": "high"}.get(rawQuality, rawQuality)
|
||||
|
||||
quality = promptModel.quality or "standard"
|
||||
style = promptModel.style or "vivid"
|
||||
|
||||
logger.debug(f"Starting image generation with prompt: '{prompt[:100]}...'")
|
||||
|
||||
|
||||
# DALL-E 3 API endpoint
|
||||
dalle_url = "https://api.openai.com/v1/images/generations"
|
||||
|
||||
payload = {
|
||||
"model": "gpt-image-1",
|
||||
"model": "dall-e-3",
|
||||
"prompt": prompt,
|
||||
"size": size,
|
||||
"quality": quality,
|
||||
"style": style,
|
||||
"n": 1,
|
||||
"response_format": "b64_json" # Get base64 data directly instead of URLs
|
||||
}
|
||||
|
||||
|
||||
# Use existing httpClient to benefit from connection pooling
|
||||
# This avoids TLS connection issues that can occur with fresh clients
|
||||
response = await self.httpClient.post(
|
||||
"https://api.openai.com/v1/images/generations",
|
||||
json=payload,
|
||||
dalle_url,
|
||||
json=payload
|
||||
)
|
||||
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Image generation API error: {response.status_code} - {response.text}")
|
||||
logger.error(f"DALL-E API error: {response.status_code} - {response.text}")
|
||||
return AiModelResponse(
|
||||
content="",
|
||||
success=False,
|
||||
error=f"Image generation API error: {response.status_code} - {response.text}",
|
||||
error=f"DALL-E API error: {response.status_code} - {response.text}"
|
||||
)
|
||||
|
||||
|
||||
responseJson = response.json()
|
||||
|
||||
|
||||
if "data" in responseJson and len(responseJson["data"]) > 0:
|
||||
imageData = responseJson["data"][0].get("b64_json", "")
|
||||
if not imageData:
|
||||
imageData = responseJson["data"][0].get("url", "")
|
||||
|
||||
logger.info(f"Successfully generated image: {len(imageData)} characters")
|
||||
image_data = responseJson["data"][0]["b64_json"]
|
||||
|
||||
logger.info(f"Successfully generated image: {len(image_data)} characters")
|
||||
return AiModelResponse(
|
||||
content=imageData,
|
||||
content=image_data,
|
||||
success=True,
|
||||
modelId="gpt-image-1",
|
||||
modelId="dall-e-3",
|
||||
metadata={
|
||||
"size": size,
|
||||
"quality": quality,
|
||||
"response_id": responseJson.get("id", ""),
|
||||
},
|
||||
"style": style,
|
||||
"response_id": responseJson.get("id", "")
|
||||
}
|
||||
)
|
||||
else:
|
||||
logger.error("No image data in generation response")
|
||||
logger.error("No image data in DALL-E response")
|
||||
return AiModelResponse(
|
||||
content="",
|
||||
success=False,
|
||||
error="No image data in generation response",
|
||||
error="No image data in DALL-E response"
|
||||
)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during image generation: {str(e)}", exc_info=True)
|
||||
return AiModelResponse(
|
||||
content="",
|
||||
success=False,
|
||||
error=f"Error during image generation: {str(e)}",
|
||||
)
|
||||
error=f"Error during image generation: {str(e)}"
|
||||
)
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
import logging
|
||||
import httpx
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
AI Connector for PowerOn Private-LLM Service.
|
||||
|
|
@ -6,17 +6,14 @@ AI Connector for PowerOn Private-LLM Service.
|
|||
Connects to the private-llm service running on-premise with Ollama backend.
|
||||
Provides OCR and Vision capabilities via local AI models.
|
||||
|
||||
Models (current — L4 24 GB):
|
||||
- poweron-text-general: Text (qwen2.5:7b); NEUTRALIZATION_TEXT + data/plan ops
|
||||
- poweron-vision-general: Vision (qwen2.5vl:7b); IMAGE_ANALYSE + NEUTRALIZATION_IMAGE
|
||||
Models:
|
||||
- poweron-text-general: Text (qwen2.5); NEUTRALIZATION_TEXT + data/plan ops
|
||||
- poweron-vision-general: Vision (qwen2.5vl); IMAGE_ANALYSE + NEUTRALIZATION_IMAGE
|
||||
- poweron-vision-deep: Vision (granite3.2); IMAGE_ANALYSE + NEUTRALIZATION_IMAGE
|
||||
|
||||
Models (next-gen — RTX PRO 6000 96 GB, auto-activated when pulled in Ollama):
|
||||
- poweron-text-reasoning: Reasoning (deepseek-r1:70b); complex logic, math, planning
|
||||
- poweron-vision-general: Vision (llama4:scout); multimodal, long-context documents
|
||||
- poweron-embed: Embedding (nomic-embed-text); local RAG embedding
|
||||
|
||||
Pricing: byte-based (~per-token via bytes/4), configured via the PRICE_* constants below.
|
||||
Pricing (CHF per call):
|
||||
- Text models: CHF 0.010
|
||||
- Vision models: CHF 0.100
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
|
@ -39,20 +36,9 @@ from modules.datamodels.datamodelAi import (
|
|||
# Configure logger
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Pricing constants (CHF per 1k tokens; billed byte-based via bytes/4 ~ 1 token)
|
||||
PRICE_INPUT_PER_1K = 0.0075
|
||||
PRICE_OUTPUT_PER_1K = 0.0375
|
||||
PRICE_EMBED_PER_1K = 0.0005
|
||||
|
||||
|
||||
def _calcPrivatePriceCHF(processingTime, bytesSent, bytesReceived):
|
||||
"""Byte-based price for private text/vision/reasoning models."""
|
||||
return (bytesSent / 4 / 1000) * PRICE_INPUT_PER_1K + (bytesReceived / 4 / 1000) * PRICE_OUTPUT_PER_1K
|
||||
|
||||
|
||||
def _calcPrivateEmbedPriceCHF(processingTime, bytesSent, bytesReceived):
|
||||
"""Byte-based price for private embedding (input only)."""
|
||||
return (bytesSent / 4 / 1000) * PRICE_EMBED_PER_1K
|
||||
# Pricing constants (CHF)
|
||||
PRICE_TEXT_PER_CALL = 0.01 # CHF 0.010 per text model call
|
||||
PRICE_VISION_PER_CALL = 0.10 # CHF 0.100 per vision model call
|
||||
|
||||
|
||||
# Private-LLM Service URL (fix, nicht via env konfigurierbar)
|
||||
|
|
@ -247,8 +233,8 @@ class AiPrivateLlm(BaseConnectorAi):
|
|||
temperature=0.1,
|
||||
maxTokens=4096,
|
||||
contextLength=8192, # Reduced for RAM constraints
|
||||
costPer1kTokensInput=PRICE_INPUT_PER_1K,
|
||||
costPer1kTokensOutput=PRICE_OUTPUT_PER_1K,
|
||||
costPer1kTokensInput=0.0, # Flat rate pricing
|
||||
costPer1kTokensOutput=0.0, # Flat rate pricing
|
||||
speedRating=8, # Fast and efficient
|
||||
qualityRating=9, # High quality text model
|
||||
functionCall=self.callAiText,
|
||||
|
|
@ -264,7 +250,7 @@ class AiPrivateLlm(BaseConnectorAi):
|
|||
(OperationTypeEnum.AGENT, 8),
|
||||
),
|
||||
version="qwen2.5:7b",
|
||||
calculatepriceCHF=_calcPrivatePriceCHF
|
||||
calculatepriceCHF=lambda processingTime, bytesSent, bytesReceived: PRICE_TEXT_PER_CALL
|
||||
),
|
||||
"ollamaModel": "qwen2.5:7b"
|
||||
},
|
||||
|
|
@ -278,8 +264,8 @@ class AiPrivateLlm(BaseConnectorAi):
|
|||
temperature=0.2,
|
||||
maxTokens=2048,
|
||||
contextLength=4096, # Reduced for RAM constraints (vision needs more)
|
||||
costPer1kTokensInput=PRICE_INPUT_PER_1K,
|
||||
costPer1kTokensOutput=PRICE_OUTPUT_PER_1K,
|
||||
costPer1kTokensInput=0.0, # Flat rate pricing
|
||||
costPer1kTokensOutput=0.0, # Flat rate pricing
|
||||
speedRating=7,
|
||||
qualityRating=9,
|
||||
functionCall=self.callAiVision,
|
||||
|
|
@ -290,7 +276,7 @@ class AiPrivateLlm(BaseConnectorAi):
|
|||
(OperationTypeEnum.NEUTRALIZATION_IMAGE, 9),
|
||||
),
|
||||
version="qwen2.5vl:7b",
|
||||
calculatepriceCHF=_calcPrivatePriceCHF
|
||||
calculatepriceCHF=lambda processingTime, bytesSent, bytesReceived: PRICE_VISION_PER_CALL
|
||||
),
|
||||
"ollamaModel": "qwen2.5vl:7b"
|
||||
},
|
||||
|
|
@ -304,8 +290,8 @@ class AiPrivateLlm(BaseConnectorAi):
|
|||
temperature=0.1,
|
||||
maxTokens=2048,
|
||||
contextLength=4096, # Reduced for RAM constraints
|
||||
costPer1kTokensInput=PRICE_INPUT_PER_1K,
|
||||
costPer1kTokensOutput=PRICE_OUTPUT_PER_1K,
|
||||
costPer1kTokensInput=0.0, # Flat rate pricing
|
||||
costPer1kTokensOutput=0.0, # Flat rate pricing
|
||||
speedRating=9, # Fast due to small 2B model
|
||||
qualityRating=8, # Good for document understanding
|
||||
functionCall=self.callAiVision,
|
||||
|
|
@ -316,92 +302,10 @@ class AiPrivateLlm(BaseConnectorAi):
|
|||
(OperationTypeEnum.NEUTRALIZATION_IMAGE, 9),
|
||||
),
|
||||
version="granite3.2-vision",
|
||||
calculatepriceCHF=_calcPrivatePriceCHF
|
||||
calculatepriceCHF=lambda processingTime, bytesSent, bytesReceived: PRICE_VISION_PER_CALL
|
||||
),
|
||||
"ollamaModel": "granite3.2-vision"
|
||||
},
|
||||
# --- Next-gen models (auto-activated when available in Ollama) ---
|
||||
# Reasoning Model (deepseek-r1:70b — chain-of-thought, math, logic)
|
||||
{
|
||||
"model": AiModel(
|
||||
name="poweron-text-reasoning",
|
||||
displayName="PowerOn Reasoning",
|
||||
connectorType="privatellm",
|
||||
apiUrl=f"{self.baseUrl}/api/analyze",
|
||||
temperature=0.1,
|
||||
maxTokens=8192,
|
||||
contextLength=65536,
|
||||
costPer1kTokensInput=PRICE_INPUT_PER_1K,
|
||||
costPer1kTokensOutput=PRICE_OUTPUT_PER_1K,
|
||||
speedRating=5,
|
||||
qualityRating=10,
|
||||
functionCall=self.callAiText,
|
||||
priority=PriorityEnum.QUALITY,
|
||||
processingMode=ProcessingModeEnum.DETAILED,
|
||||
operationTypes=createOperationTypeRatings(
|
||||
(OperationTypeEnum.PLAN, 10),
|
||||
(OperationTypeEnum.DATA_ANALYSE, 10),
|
||||
(OperationTypeEnum.DATA_GENERATE, 9),
|
||||
(OperationTypeEnum.DATA_EXTRACT, 9),
|
||||
(OperationTypeEnum.NEUTRALIZATION_TEXT, 10),
|
||||
(OperationTypeEnum.AGENT, 9),
|
||||
),
|
||||
version="deepseek-r1:70b",
|
||||
calculatepriceCHF=_calcPrivatePriceCHF
|
||||
),
|
||||
"ollamaModel": "deepseek-r1:70b"
|
||||
},
|
||||
# Vision Multimodal (llama4:scout — native vision, 10M context)
|
||||
{
|
||||
"model": AiModel(
|
||||
name="poweron-vision-multimodal",
|
||||
displayName="PowerOn Vision Multimodal",
|
||||
connectorType="privatellm",
|
||||
apiUrl=f"{self.baseUrl}/api/analyze",
|
||||
temperature=0.2,
|
||||
maxTokens=4096,
|
||||
contextLength=131072,
|
||||
costPer1kTokensInput=PRICE_INPUT_PER_1K,
|
||||
costPer1kTokensOutput=PRICE_OUTPUT_PER_1K,
|
||||
speedRating=7,
|
||||
qualityRating=10,
|
||||
functionCall=self.callAiVision,
|
||||
priority=PriorityEnum.QUALITY,
|
||||
processingMode=ProcessingModeEnum.DETAILED,
|
||||
operationTypes=createOperationTypeRatings(
|
||||
(OperationTypeEnum.IMAGE_ANALYSE, 10),
|
||||
(OperationTypeEnum.NEUTRALIZATION_IMAGE, 10),
|
||||
),
|
||||
version="llama4:scout",
|
||||
calculatepriceCHF=_calcPrivatePriceCHF
|
||||
),
|
||||
"ollamaModel": "llama4:scout"
|
||||
},
|
||||
# Local Embedding (nomic-embed-text — replaces OpenAI text-embedding-3-small)
|
||||
{
|
||||
"model": AiModel(
|
||||
name="poweron-embed",
|
||||
displayName="PowerOn Embedding",
|
||||
connectorType="privatellm",
|
||||
apiUrl=f"{self.baseUrl}/v1/embeddings",
|
||||
temperature=0.0,
|
||||
maxTokens=0,
|
||||
contextLength=8192,
|
||||
costPer1kTokensInput=PRICE_EMBED_PER_1K,
|
||||
costPer1kTokensOutput=0.0,
|
||||
speedRating=10,
|
||||
qualityRating=8,
|
||||
functionCall=self.callAiText,
|
||||
priority=PriorityEnum.COST,
|
||||
processingMode=ProcessingModeEnum.BASIC,
|
||||
operationTypes=createOperationTypeRatings(
|
||||
(OperationTypeEnum.EMBEDDING, 9),
|
||||
),
|
||||
version="nomic-embed-text",
|
||||
calculatepriceCHF=_calcPrivateEmbedPriceCHF
|
||||
),
|
||||
"ollamaModel": "nomic-embed-text"
|
||||
},
|
||||
]
|
||||
|
||||
# Filter models by Ollama availability
|
||||
|
|
@ -416,7 +320,7 @@ class AiPrivateLlm(BaseConnectorAi):
|
|||
unavailableModels.append(modelDef["model"].name)
|
||||
|
||||
if unavailableModels:
|
||||
logger.info(
|
||||
logger.warning(
|
||||
f"Private-LLM: {len(unavailableModels)} models not available in Ollama: {', '.join(unavailableModels)}. "
|
||||
f"Install with: ollama pull <model-name>"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Tavily web search class.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Authentication and authorization modules for routes and services.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Authentication module for backend API.
|
||||
|
|
@ -437,7 +437,7 @@ def requireSysAdmin(currentUser: User = Depends(getCurrentUser)) -> User:
|
|||
|
||||
# Audit for all SysAdmin actions
|
||||
try:
|
||||
from modules.dbHelpers.auditLogger import audit_logger
|
||||
from modules.shared.auditLogger import audit_logger
|
||||
audit_logger.logSecurityEvent(
|
||||
userId=str(currentUser.id),
|
||||
mandateId="system",
|
||||
|
|
@ -483,7 +483,7 @@ def requirePlatformAdmin(currentUser: User = Depends(getCurrentUser)) -> User:
|
|||
|
||||
# Audit for all Platform-Admin actions
|
||||
try:
|
||||
from modules.dbHelpers.auditLogger import audit_logger
|
||||
from modules.shared.auditLogger import audit_logger
|
||||
audit_logger.logSecurityEvent(
|
||||
userId=str(currentUser.id),
|
||||
mandateId="system",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
CSRF Protection Middleware for PowerOn Gateway
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
JWT Service
|
||||
Centralizes local JWT creation and cookie helpers.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
from typing import Optional, Tuple
|
||||
from fastapi import Response
|
||||
from jose import jwt
|
||||
|
|
|
|||
|
|
@ -1,132 +0,0 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# All rights reserved.
|
||||
"""
|
||||
MFA (Multi-Factor Authentication) Service.
|
||||
|
||||
TOTP-based MFA using pyotp. Secrets are encrypted at rest via
|
||||
encryptValue/decryptValue from the configuration module.
|
||||
|
||||
MFA obligation is resolved by three OR-linked rules:
|
||||
1. Any mandate the user belongs to has ``mfaRequired=True``.
|
||||
2. User is sysAdmin OR platformAdmin AND config key ``MFA_REQUIRE_ADMINS``
|
||||
is truthy.
|
||||
3. User has opted in (``mfaEnabled=True`` without any mandate/admin rule).
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import pyotp
|
||||
|
||||
from modules.shared.configuration import APP_CONFIG, encryptValue, decryptValue
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MFA_DIGITS = 6
|
||||
_MFA_INTERVAL = 30
|
||||
_MFA_VALID_WINDOW = 1
|
||||
|
||||
|
||||
def getMfaIssuer() -> str:
|
||||
"""Build the TOTP issuer name, e.g. 'PowerOn' or 'PowerOn (Dev)'."""
|
||||
envType = (APP_CONFIG.get("APP_ENV_TYPE") or "").strip().lower()
|
||||
if envType in ("prod", ""):
|
||||
return "PowerOn"
|
||||
return f"PowerOn ({envType.upper()})"
|
||||
|
||||
|
||||
def _generateSecret() -> str:
|
||||
"""Generate a fresh base32-encoded TOTP secret."""
|
||||
return pyotp.random_base32()
|
||||
|
||||
|
||||
def _encryptSecret(plainSecret: str, userId: str = "system") -> str:
|
||||
return encryptValue(plainSecret, userId=userId, keyName="mfa_secret")
|
||||
|
||||
|
||||
def decryptSecret(encryptedSecret: str, userId: str = "system") -> str:
|
||||
return decryptValue(encryptedSecret, userId=userId, keyName="mfa_secret")
|
||||
|
||||
|
||||
def buildTotp(plainSecret: str) -> pyotp.TOTP:
|
||||
return pyotp.TOTP(plainSecret, digits=_MFA_DIGITS, interval=_MFA_INTERVAL)
|
||||
|
||||
|
||||
def generateSetup(userId: str, username: str) -> dict:
|
||||
"""Start MFA enrolment: return secret + provisioning URI (for QR code).
|
||||
|
||||
Returns dict with keys ``secret`` (encrypted for DB storage) and
|
||||
``provisioningUri`` (otpauth:// URI the frontend renders as QR).
|
||||
The plaintext secret is NOT returned -- the URI already contains it.
|
||||
"""
|
||||
plain = _generateSecret()
|
||||
encrypted = _encryptSecret(plain, userId=userId)
|
||||
totp = buildTotp(plain)
|
||||
uri = totp.provisioning_uri(name=username, issuer_name=getMfaIssuer())
|
||||
return {
|
||||
"encryptedSecret": encrypted,
|
||||
"provisioningUri": uri,
|
||||
}
|
||||
|
||||
|
||||
def confirmSetup(encryptedSecret: str, code: str, userId: str = "system") -> bool:
|
||||
"""Verify a TOTP code against an encrypted secret (enrolment confirmation)."""
|
||||
try:
|
||||
plain = decryptSecret(encryptedSecret, userId=userId)
|
||||
totp = buildTotp(plain)
|
||||
return totp.verify(code, valid_window=_MFA_VALID_WINDOW)
|
||||
except Exception:
|
||||
logger.exception("MFA confirmSetup failed for userId=%s", userId)
|
||||
return False
|
||||
|
||||
|
||||
def verifyCode(encryptedSecret: str, code: str, userId: str = "system") -> bool:
|
||||
"""Verify a TOTP code during login."""
|
||||
try:
|
||||
plain = decryptSecret(encryptedSecret, userId=userId)
|
||||
totp = buildTotp(plain)
|
||||
return totp.verify(code, valid_window=_MFA_VALID_WINDOW)
|
||||
except Exception:
|
||||
logger.exception("MFA verifyCode failed for userId=%s", userId)
|
||||
return False
|
||||
|
||||
|
||||
def _isMfaRequireAdminsEnabled() -> bool:
|
||||
"""Read ``MFA_REQUIRE_ADMINS`` from config / env."""
|
||||
raw = (APP_CONFIG.get("MFA_REQUIRE_ADMINS") or "").strip().lower()
|
||||
return raw in ("1", "true", "yes")
|
||||
|
||||
|
||||
def isMfaRequired(user, userMandates=None, mandates=None) -> bool:
|
||||
"""Resolve whether MFA is mandatory for *user*.
|
||||
|
||||
Rules (OR):
|
||||
1. At least one of the user's mandates has ``mfaRequired=True``.
|
||||
2. User is sysAdmin or platformAdmin AND ``MFA_REQUIRE_ADMINS`` config
|
||||
key is truthy.
|
||||
3. User already opted in (``mfaEnabled=True``).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
user : User | UserInDB
|
||||
The user object.
|
||||
userMandates : list | None
|
||||
List of UserMandate records for the user (each has ``mandateId``).
|
||||
mandates : list | None
|
||||
List of Mandate objects the user has access to. If provided directly
|
||||
this avoids a second lookup.
|
||||
"""
|
||||
if getattr(user, "mfaEnabled", False):
|
||||
return True
|
||||
|
||||
isSys = getattr(user, "isSysAdmin", False)
|
||||
isPlat = getattr(user, "isPlatformAdmin", False)
|
||||
if (isSys or isPlat) and _isMfaRequireAdminsEnabled():
|
||||
return True
|
||||
|
||||
if mandates:
|
||||
for m in mandates:
|
||||
if getattr(m, "mfaRequired", False):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# All rights reserved.
|
||||
"""
|
||||
Short-lived signed tickets for OAuth data-connection popups.
|
||||
|
||||
The UI authenticates API calls with a Bearer token in localStorage, but
|
||||
``window.open(authUrl)`` cannot send that header. Cross-origin httpOnly cookies
|
||||
are unreliable in cross-origin setups (UI and API on different subdomains).
|
||||
Login popups work without a session because ``/auth/login`` is public; connect
|
||||
popups hit ``/auth/connect``, which used to require ``getCurrentUser``.
|
||||
|
||||
Flow: POST ``/api/connections/{id}/connect`` (Bearer-authenticated) issues a
|
||||
ticket; the popup opens ``/auth/connect?connectTicket=...`` which validates the
|
||||
ticket instead of cookies.
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Any, Dict, Tuple
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from jose import JWTError, jwt as jose_jwt
|
||||
|
||||
from modules.auth.jwtService import ALGORITHM, SECRET_KEY
|
||||
from modules.datamodels.datamodelUam import AuthAuthority, User, UserConnection
|
||||
from modules.interfaces.interfaceDbApp import getInterface, getRootInterface
|
||||
from modules.shared.i18nRegistry import apiRouteContext
|
||||
|
||||
_msg = apiRouteContext("oauthConnectTicket")
|
||||
|
||||
_CONNECT_TICKET_TTL_SEC = 600
|
||||
|
||||
|
||||
def issue_connect_ticket(flow: str, connection_id: str, user_id: str) -> str:
|
||||
"""Issue a short-lived JWT for starting a data-connection OAuth popup."""
|
||||
body = {
|
||||
"flow": flow,
|
||||
"connectionId": connection_id,
|
||||
"userId": str(user_id),
|
||||
"exp": int(time.time()) + _CONNECT_TICKET_TTL_SEC,
|
||||
}
|
||||
return jose_jwt.encode(body, SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def parse_connect_ticket(ticket: str, expected_flow: str) -> Dict[str, Any]:
|
||||
"""Validate connect ticket signature, expiry, and flow."""
|
||||
try:
|
||||
data = jose_jwt.decode(ticket, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
except JWTError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=_msg("Invalid or expired connect ticket"),
|
||||
) from e
|
||||
if data.get("flow") != expected_flow:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=_msg("Invalid connect ticket flow"),
|
||||
)
|
||||
connection_id = data.get("connectionId")
|
||||
user_id = data.get("userId")
|
||||
if not connection_id or not user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=_msg("Incomplete connect ticket"),
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
def resolve_connect_context(
|
||||
connect_ticket: str,
|
||||
connection_id: str,
|
||||
expected_flow: str,
|
||||
authority: AuthAuthority,
|
||||
) -> Tuple[User, UserConnection]:
|
||||
"""Validate ticket and return the user + connection for OAuth redirect."""
|
||||
state = parse_connect_ticket(connect_ticket, expected_flow)
|
||||
if state.get("connectionId") != connection_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=_msg("Connection ID does not match connect ticket"),
|
||||
)
|
||||
|
||||
root = getRootInterface()
|
||||
user = root.getUser(state["userId"])
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=_msg("User not found"),
|
||||
)
|
||||
|
||||
interface = getInterface(user)
|
||||
connection = None
|
||||
for conn in interface.getUserConnections(user.id):
|
||||
if conn.id == connection_id and conn.authority == authority:
|
||||
connection = conn
|
||||
break
|
||||
if not connection:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=_msg("Connection not found"),
|
||||
)
|
||||
return user, connection
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""OAuth scope sets for split Auth- vs Data-apps (Google / Microsoft)."""
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Token Manager Service
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Token Refresh Middleware for PowerOn Gateway
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Token Refresh Service for PowerOn Gateway
|
||||
|
|
@ -12,7 +12,7 @@ import logging
|
|||
from typing import Dict, Any
|
||||
from modules.datamodels.datamodelUam import UserConnection, AuthAuthority
|
||||
from modules.shared.timeUtils import getUtcTimestamp
|
||||
from modules.dbHelpers.auditLogger import audit_logger
|
||||
from modules.shared.auditLogger import audit_logger
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Azure Communication Services Email Connector
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Twilio SMS Connector
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# All rights reserved.
|
||||
"""
|
||||
ÖREB WFS Connector
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Preprocessor connector for executing SQL queries via HTTP API.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Abstract base classes for the Provider-Connector architecture (1:n).
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""ConnectorResolver -- resolves a connectionId to the correct ProviderConnector and ServiceAdapter.
|
||||
|
||||
|
|
@ -15,15 +15,6 @@ from modules.connectors.connectorProviderBase import ProviderConnector, ServiceA
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _connection_uuid(connection: Any) -> str:
|
||||
"""Resolve UserConnection primary key (tokens are stored by UUID, not reference string)."""
|
||||
if connection is None:
|
||||
return ""
|
||||
if isinstance(connection, dict):
|
||||
return str(connection.get("id") or "").strip()
|
||||
return str(getattr(connection, "id", None) or "").strip()
|
||||
|
||||
|
||||
class ConnectorResolver:
|
||||
"""Resolves connectionId → ProviderConnector (with fresh token) → ServiceAdapter."""
|
||||
|
||||
|
|
@ -44,31 +35,31 @@ class ConnectorResolver:
|
|||
if ConnectorResolver._providerRegistry:
|
||||
return
|
||||
try:
|
||||
from modules.connectors.connectorProviderMsft import MsftConnector
|
||||
from modules.connectors.providerMsft.connectorMsft import MsftConnector
|
||||
ConnectorResolver._providerRegistry["msft"] = MsftConnector
|
||||
except ImportError:
|
||||
logger.warning("MsftConnector not available")
|
||||
|
||||
try:
|
||||
from modules.connectors.connectorProviderGoogle import GoogleConnector
|
||||
from modules.connectors.providerGoogle.connectorGoogle import GoogleConnector
|
||||
ConnectorResolver._providerRegistry["google"] = GoogleConnector
|
||||
except ImportError:
|
||||
logger.debug("GoogleConnector not available (stub)")
|
||||
|
||||
try:
|
||||
from modules.connectors.connectorProviderFtp import FtpConnector
|
||||
from modules.connectors.providerFtp.connectorFtp import FtpConnector
|
||||
ConnectorResolver._providerRegistry["local:ftp"] = FtpConnector
|
||||
except ImportError:
|
||||
logger.debug("FtpConnector not available (stub)")
|
||||
|
||||
try:
|
||||
from modules.connectors.connectorProviderClickup import ClickupConnector
|
||||
from modules.connectors.providerClickup.connectorClickup import ClickupConnector
|
||||
ConnectorResolver._providerRegistry["clickup"] = ClickupConnector
|
||||
except ImportError:
|
||||
logger.warning("ClickupConnector not available")
|
||||
|
||||
try:
|
||||
from modules.connectors.connectorProviderInfomaniak import InfomaniakConnector
|
||||
from modules.connectors.providerInfomaniak.connectorInfomaniak import InfomaniakConnector
|
||||
ConnectorResolver._providerRegistry["infomaniak"] = InfomaniakConnector
|
||||
except ImportError:
|
||||
logger.warning("InfomaniakConnector not available")
|
||||
|
|
@ -88,16 +79,9 @@ class ConnectorResolver:
|
|||
if not providerClass:
|
||||
raise ValueError(f"No ProviderConnector registered for authority: {authorityStr}")
|
||||
|
||||
resolved_id = _connection_uuid(connection)
|
||||
if not resolved_id:
|
||||
raise ValueError(f"Connection {connectionId} has no id")
|
||||
|
||||
token = self._security.getFreshToken(resolved_id)
|
||||
token = self._security.getFreshToken(connectionId)
|
||||
if not token or not token.tokenAccess:
|
||||
raise ValueError(
|
||||
f"No valid token for connection {resolved_id}"
|
||||
+ (f" (ref: {connectionId})" if connectionId != resolved_id else "")
|
||||
)
|
||||
raise ValueError(f"No valid token for connection {connectionId}")
|
||||
|
||||
return providerClass(connection, token.tokenAccess)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# All rights reserved.
|
||||
"""
|
||||
Swiss Topo MapServer Connector (Simplified)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""ClickUp connector for CRUD operations (compatible with TicketInterface).
|
||||
|
||||
|
|
@ -9,7 +9,7 @@ from typing import Optional
|
|||
import logging
|
||||
import aiohttp
|
||||
from modules.datamodels.datamodelTickets import TicketBase, TicketFieldAttribute
|
||||
from modules.connectors.connectorProviderClickup import clickupAuthorizationHeader
|
||||
from modules.serviceCenter.services.serviceClickup.mainServiceClickup import clickup_authorization_header
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -31,7 +31,7 @@ class ConnectorTicketClickup(TicketBase):
|
|||
|
||||
def _headers(self) -> dict:
|
||||
return {
|
||||
"Authorization": clickupAuthorizationHeader(self.apiToken),
|
||||
"Authorization": clickup_authorization_header(self.apiToken),
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Jira connector for CRUD operations (neutralized to generic ticket interface).
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2026 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Redmine REST connector.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Google Cloud Speech-to-Text and Translation Connector
|
||||
|
|
@ -15,7 +15,7 @@ from google.cloud import speech
|
|||
from google.cloud import translate_v2 as translate
|
||||
from google.cloud import texttospeech
|
||||
from modules.shared.configuration import APP_CONFIG
|
||||
from modules.shared.voiceCatalog import getDefaultVoice
|
||||
from modules.shared.voiceCatalog import getDefaultVoice as _catalogDefaultVoice
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -1097,7 +1097,7 @@ class ConnectorGoogleSpeech:
|
|||
voice exists, in which case the caller omits `name` and Google
|
||||
auto-selects based on languageCode + ssml_gender.
|
||||
"""
|
||||
return getDefaultVoice(languageCode)
|
||||
return _catalogDefaultVoice(languageCode)
|
||||
|
||||
async def getAvailableVoices(self, languageCode: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# All rights reserved.
|
||||
"""
|
||||
Swiss Parcel (Liegenschaften) Connector
|
||||
|
||||
|
|
|
|||
7
modules/connectors/providerClickup/__init__.py
Normal file
7
modules/connectors/providerClickup/__init__.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""ClickUp provider connector."""
|
||||
|
||||
from .connectorClickup import ClickupConnector
|
||||
|
||||
__all__ = ["ClickupConnector"]
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""ClickUp ProviderConnector — virtual paths for teams → lists → tasks (table rows).
|
||||
|
||||
|
|
@ -13,13 +13,10 @@ Path convention (leading slash, no trailing slash except root):
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
import aiohttp
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from modules.connectors.connectorProviderBase import (
|
||||
ProviderConnector,
|
||||
|
|
@ -27,11 +24,11 @@ from modules.connectors.connectorProviderBase import (
|
|||
DownloadResult,
|
||||
)
|
||||
from modules.datamodels.datamodelDataSource import ExternalEntry
|
||||
from modules.serviceCenter.services.serviceClickup.mainServiceClickup import ClickupService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CLICKUP_API_BASE = "https://api.clickup.com/api/v2"
|
||||
|
||||
# type metadata for ExternalEntry.metadata["cuType"]
|
||||
_CU_TEAM = "team"
|
||||
_CU_SPACE = "space"
|
||||
_CU_FOLDER = "folder"
|
||||
|
|
@ -48,118 +45,14 @@ def _norm(path: str) -> str:
|
|||
return p
|
||||
|
||||
|
||||
def clickupAuthorizationHeader(token: str) -> str:
|
||||
"""ClickUp: personal tokens are `pk_...` without Bearer; OAuth uses Bearer."""
|
||||
t = (token or "").strip()
|
||||
if t.startswith("pk_"):
|
||||
return t
|
||||
return f"Bearer {t}"
|
||||
|
||||
|
||||
class ClickupApiClient:
|
||||
"""Low-level ClickUp REST API v2 client. Pure HTTP — no service dependencies."""
|
||||
|
||||
def __init__(self, accessToken: str):
|
||||
self.accessToken = accessToken
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
json_body: Optional[Dict[str, Any]] = None,
|
||||
data: Optional[aiohttp.FormData] = None,
|
||||
) -> Union[Dict[str, Any], List[Any], bytes, None]:
|
||||
if not self.accessToken:
|
||||
return {"error": "Access token is not set."}
|
||||
url = f"{_CLICKUP_API_BASE}/{path.lstrip('/')}"
|
||||
headers: Dict[str, str] = {
|
||||
"Authorization": clickupAuthorizationHeader(self.accessToken),
|
||||
}
|
||||
if json_body is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
|
||||
timeout = aiohttp.ClientTimeout(total=60)
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
kwargs: Dict[str, Any] = {"headers": headers, "params": params}
|
||||
if json_body is not None:
|
||||
kwargs["json"] = json_body
|
||||
if data is not None:
|
||||
kwargs["data"] = data
|
||||
async with session.request(method.upper(), url, **kwargs) as resp:
|
||||
if resp.status == 204:
|
||||
return {}
|
||||
text = await resp.text()
|
||||
if resp.status >= 400:
|
||||
log = logger.warning if resp.status == 404 else logger.error
|
||||
log(f"ClickUp API {method} {url} -> {resp.status}: {text[:500]}")
|
||||
return {"error": f"HTTP {resp.status}", "body": text}
|
||||
if not text:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(text)
|
||||
except Exception:
|
||||
return {"raw": text}
|
||||
except asyncio.TimeoutError:
|
||||
return {"error": f"ClickUp API timeout: {path}"}
|
||||
except Exception as e:
|
||||
logger.error(f"ClickUp API error: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
async def getAuthorizedTeams(self) -> Dict[str, Any]:
|
||||
return await self._request("GET", "/team")
|
||||
|
||||
async def getSpaces(self, teamId: str) -> Dict[str, Any]:
|
||||
return await self._request("GET", f"/team/{teamId}/space")
|
||||
|
||||
async def getFolders(self, spaceId: str) -> Dict[str, Any]:
|
||||
return await self._request("GET", f"/space/{spaceId}/folder")
|
||||
|
||||
async def getFolderlessLists(self, spaceId: str) -> Dict[str, Any]:
|
||||
return await self._request("GET", f"/space/{spaceId}/list")
|
||||
|
||||
async def getListsInFolder(self, folderId: str) -> Dict[str, Any]:
|
||||
return await self._request("GET", f"/folder/{folderId}/list")
|
||||
|
||||
async def getTasksInList(self, listId: str, *, page: int = 0) -> Dict[str, Any]:
|
||||
params: Dict[str, Any] = {"page": page, "subtasks": "true", "include_closed": "false"}
|
||||
return await self._request("GET", f"/list/{listId}/task", params=params)
|
||||
|
||||
async def getTask(self, taskId: str) -> Dict[str, Any]:
|
||||
params = {"include_subtasks": "true"}
|
||||
return await self._request("GET", f"/task/{taskId}", params=params)
|
||||
|
||||
async def searchTeamTasks(self, teamId: str, *, query: str, page: int = 0) -> Dict[str, Any]:
|
||||
params = {"query": query, "page": page}
|
||||
return await self._request("GET", f"/team/{teamId}/task", params=params)
|
||||
|
||||
async def uploadTaskAttachment(self, taskId: str, fileBytes: bytes, fileName: str) -> Dict[str, Any]:
|
||||
if not self.accessToken:
|
||||
return {"error": "Access token is not set."}
|
||||
url = f"{_CLICKUP_API_BASE}/task/{taskId}/attachment"
|
||||
headers = {"Authorization": clickupAuthorizationHeader(self.accessToken)}
|
||||
formData = aiohttp.FormData()
|
||||
formData.add_field("attachment", fileBytes, filename=fileName, content_type="application/octet-stream")
|
||||
timeout = aiohttp.ClientTimeout(total=120)
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.post(url, headers=headers, data=formData) as resp:
|
||||
text = await resp.text()
|
||||
if resp.status >= 400:
|
||||
return {"error": f"HTTP {resp.status}", "body": text}
|
||||
return json.loads(text) if text else {}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
class ClickupListsAdapter(ServiceAdapter):
|
||||
"""Maps ClickUp hierarchy + list tasks to browse/download/upload/search."""
|
||||
|
||||
def __init__(self, access_token: str):
|
||||
self._token = access_token
|
||||
self._svc = ClickupApiClient(access_token)
|
||||
# Minimal service instance for API calls (no ServiceCenter context)
|
||||
self._svc = ClickupService(context=None, get_service=lambda _: None)
|
||||
self._svc.setAccessToken(access_token)
|
||||
|
||||
async def browse(
|
||||
self,
|
||||
3
modules/connectors/providerFtp/__init__.py
Normal file
3
modules/connectors/providerFtp/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""FTP/SFTP Provider Connector stub."""
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""FTP/SFTP ProviderConnector stub.
|
||||
|
||||
3
modules/connectors/providerGoogle/__init__.py
Normal file
3
modules/connectors/providerGoogle/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Google Provider Connector -- 1 Connection : n Services (Drive, Gmail)."""
|
||||
|
|
@ -1,78 +1,36 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Google ProviderConnector -- Drive and Gmail via Google OAuth."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
import re
|
||||
import urllib.parse
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
from modules.connectors.connectorProviderBase import ProviderConnector, ServiceAdapter, DownloadResult
|
||||
from modules.shared.httpResilience import ResilientHttp
|
||||
from modules.datamodels.datamodelDataSource import ExternalEntry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_http = ResilientHttp("Google", maxConcurrent=8, defaultTimeoutS=20)
|
||||
|
||||
_DRIVE_BASE = "https://www.googleapis.com/drive/v3"
|
||||
_GMAIL_BASE = "https://gmail.googleapis.com/gmail/v1"
|
||||
_CALENDAR_BASE = "https://www.googleapis.com/calendar/v3"
|
||||
_PEOPLE_BASE = "https://people.googleapis.com/v1"
|
||||
|
||||
|
||||
def _parseGoogleDateRange(text: Optional[str]) -> tuple:
|
||||
"""Parse a date range from a filter/query string for Calendar timeMin/timeMax.
|
||||
|
||||
Supports two ISO dates, a single ISO date (~31 day window) or a YYYY-MM
|
||||
month pattern. Returns RFC3339 UTC strings (timeMin, timeMax) or (None, None).
|
||||
"""
|
||||
if not text:
|
||||
return (None, None)
|
||||
|
||||
def _toRfc3339(value: str) -> str:
|
||||
value = value.strip().rstrip("Z")
|
||||
if "T" not in value:
|
||||
value = f"{value}T00:00:00"
|
||||
return f"{value}Z"
|
||||
|
||||
isoMatch = re.findall(r'\d{4}-\d{2}-\d{2}(?:T[\d:]+)?', text)
|
||||
if len(isoMatch) >= 2:
|
||||
return (_toRfc3339(isoMatch[0]), _toRfc3339(isoMatch[1]))
|
||||
if len(isoMatch) == 1:
|
||||
try:
|
||||
dt = datetime.fromisoformat(isoMatch[0])
|
||||
return (_toRfc3339(isoMatch[0]), _toRfc3339((dt + timedelta(days=31)).strftime('%Y-%m-%dT00:00:00')))
|
||||
except ValueError:
|
||||
pass
|
||||
monthMatch = re.match(r'^(\d{4})-(\d{2})$', text.strip())
|
||||
if monthMatch:
|
||||
year, month = int(monthMatch.group(1)), int(monthMatch.group(2))
|
||||
start = f"{year}-{month:02d}-01T00:00:00"
|
||||
end = f"{year + 1}-01-01T00:00:00" if month == 12 else f"{year}-{month + 1:02d}-01T00:00:00"
|
||||
return (_toRfc3339(start), _toRfc3339(end))
|
||||
return (None, None)
|
||||
|
||||
|
||||
async def googleGet(token: str, url: str) -> Dict[str, Any]:
|
||||
async def _googleGet(token: str, url: str) -> Dict[str, Any]:
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
return await _http.getJson(url, headers=headers)
|
||||
|
||||
|
||||
def _raiseGoogleError(result: Dict[str, Any], ctx: str) -> None:
|
||||
"""Raise a clear error for a failed Google API response.
|
||||
|
||||
Browse/search must NOT swallow API failures into an empty result list, which
|
||||
masks a real error as 'empty'. Callers wrap these in try/except.
|
||||
"""
|
||||
err = result.get("error") if isinstance(result, dict) else None
|
||||
logger.warning("Google error (%s): %s", ctx, err or result)
|
||||
raise RuntimeError(f"Google error ({ctx}): {err or result}")
|
||||
timeout = aiohttp.ClientTimeout(total=20)
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(url, headers=headers) as resp:
|
||||
if resp.status in (200, 201):
|
||||
return await resp.json()
|
||||
errorText = await resp.text()
|
||||
logger.warning(f"Google API {resp.status}: {errorText[:300]}")
|
||||
return {"error": f"{resp.status}: {errorText[:200]}"}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
class DriveAdapter(ServiceAdapter):
|
||||
|
|
@ -93,9 +51,10 @@ class DriveAdapter(ServiceAdapter):
|
|||
pageSize = max(1, min(int(limit or 100), 1000))
|
||||
url = f"{_DRIVE_BASE}/files?q={query}&fields={fields}&pageSize={pageSize}&orderBy=folder,name"
|
||||
|
||||
result = await googleGet(self._token, url)
|
||||
result = await _googleGet(self._token, url)
|
||||
if "error" in result:
|
||||
_raiseGoogleError(result, "Google Drive browse")
|
||||
logger.warning(f"Google Drive browse failed: {result['error']}")
|
||||
return []
|
||||
|
||||
entries = []
|
||||
for f in result.get("files", []):
|
||||
|
|
@ -122,33 +81,37 @@ class DriveAdapter(ServiceAdapter):
|
|||
if not fileId:
|
||||
return b""
|
||||
headers = {"Authorization": f"Bearer {self._token}"}
|
||||
dlTimeout = aiohttp.ClientTimeout(total=60)
|
||||
timeout = aiohttp.ClientTimeout(total=60)
|
||||
try:
|
||||
url = f"{_DRIVE_BASE}/files/{fileId}?alt=media"
|
||||
data = await _http.getBytes(url, headers=headers, timeout=dlTimeout)
|
||||
if data is not None:
|
||||
return data
|
||||
logger.debug(f"Google Drive direct download returned None for {fileId}")
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
# Try direct download first
|
||||
url = f"{_DRIVE_BASE}/files/{fileId}?alt=media"
|
||||
async with session.get(url, headers=headers) as resp:
|
||||
if resp.status == 200:
|
||||
return await resp.read()
|
||||
logger.debug(f"Google Drive direct download returned {resp.status} for {fileId}")
|
||||
|
||||
metaUrl = f"{_DRIVE_BASE}/files/{fileId}?fields=mimeType,name"
|
||||
meta = await _http.getJson(metaUrl, headers=headers)
|
||||
if "error" in meta:
|
||||
logger.warning(f"Google Drive metadata fetch failed for {fileId}: {meta['error']}")
|
||||
return b""
|
||||
fileMime = meta.get("mimeType", "")
|
||||
fileName = meta.get("name", fileId)
|
||||
# If 403/404, check if it's a native Google file that needs export
|
||||
metaUrl = f"{_DRIVE_BASE}/files/{fileId}?fields=mimeType,name"
|
||||
async with session.get(metaUrl, headers=headers) as metaResp:
|
||||
if metaResp.status != 200:
|
||||
logger.warning(f"Google Drive metadata fetch failed ({metaResp.status}) for {fileId}")
|
||||
return b""
|
||||
meta = await metaResp.json()
|
||||
fileMime = meta.get("mimeType", "")
|
||||
fileName = meta.get("name", fileId)
|
||||
|
||||
exportMime = self._EXPORT_MIME_MAP.get(fileMime)
|
||||
if not exportMime:
|
||||
logger.warning(f"Google Drive: unsupported mimeType '{fileMime}' for file '{fileName}' ({fileId})")
|
||||
return b""
|
||||
exportMime = self._EXPORT_MIME_MAP.get(fileMime)
|
||||
if not exportMime:
|
||||
logger.warning(f"Google Drive: unsupported mimeType '{fileMime}' for file '{fileName}' ({fileId})")
|
||||
return b""
|
||||
|
||||
exportUrl = f"{_DRIVE_BASE}/files/{fileId}/export?mimeType={exportMime}"
|
||||
logger.info(f"Google Drive: exporting '{fileName}' as {exportMime}")
|
||||
exported = await _http.getBytes(exportUrl, headers=headers, timeout=dlTimeout)
|
||||
if exported is not None:
|
||||
return exported
|
||||
logger.warning(f"Google Drive export failed for '{fileName}'")
|
||||
exportUrl = f"{_DRIVE_BASE}/files/{fileId}/export?mimeType={exportMime}"
|
||||
logger.info(f"Google Drive: exporting '{fileName}' as {exportMime}")
|
||||
async with session.get(exportUrl, headers=headers) as exportResp:
|
||||
if exportResp.status == 200:
|
||||
return await exportResp.read()
|
||||
logger.warning(f"Google Drive export failed ({exportResp.status}) for '{fileName}'")
|
||||
except Exception as e:
|
||||
logger.error(f"Google Drive download failed for {fileId}: {e}")
|
||||
return b""
|
||||
|
|
@ -162,51 +125,27 @@ class DriveAdapter(ServiceAdapter):
|
|||
path: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> List[ExternalEntry]:
|
||||
safeQuery = query.replace("\\", "\\\\").replace("'", "\\'")
|
||||
safeQuery = query.replace("'", "\\'")
|
||||
folderId = (path or "").strip("/")
|
||||
# `fullText contains` matches file name AND content (and some metadata),
|
||||
# which is what users expect from a search -- not just the file name.
|
||||
qParts = [f"fullText contains '{safeQuery}'", "trashed=false"]
|
||||
qParts = [f"name contains '{safeQuery}'", "trashed=false"]
|
||||
if folderId:
|
||||
qParts.append(f"'{folderId}' in parents")
|
||||
qStr = " and ".join(qParts)
|
||||
effectiveLimit = max(1, int(limit)) if limit is not None else None
|
||||
pageSize = min(effectiveLimit or 100, 1000)
|
||||
pageSize = max(1, min(int(limit or 100), 1000))
|
||||
url = f"{_DRIVE_BASE}/files?q={qStr}&fields=files(id,name,mimeType,size)&pageSize={pageSize}"
|
||||
logger.debug(f"Google Drive search: q={qStr}")
|
||||
entries: List[ExternalEntry] = []
|
||||
pageToken: Optional[str] = None
|
||||
hardCap = effectiveLimit or 1000
|
||||
while len(entries) < hardCap:
|
||||
params = {
|
||||
"q": qStr,
|
||||
"fields": "nextPageToken,files(id,name,mimeType,size,modifiedTime)",
|
||||
"pageSize": str(pageSize),
|
||||
}
|
||||
if pageToken:
|
||||
params["pageToken"] = pageToken
|
||||
url = f"{_DRIVE_BASE}/files?{urllib.parse.urlencode(params)}"
|
||||
result = await googleGet(self._token, url)
|
||||
if "error" in result:
|
||||
if not entries:
|
||||
_raiseGoogleError(result, "Google Drive search")
|
||||
break
|
||||
for f in result.get("files", []):
|
||||
entries.append(ExternalEntry(
|
||||
name=f.get("name", ""),
|
||||
path=f"/{f.get('id', '')}",
|
||||
isFolder=f.get("mimeType") == "application/vnd.google-apps.folder",
|
||||
size=int(f.get("size", 0)) if f.get("size") else None,
|
||||
mimeType=f.get("mimeType"),
|
||||
metadata={"id": f.get("id"), "modifiedTime": f.get("modifiedTime")},
|
||||
))
|
||||
if len(entries) >= hardCap:
|
||||
break
|
||||
pageToken = result.get("nextPageToken")
|
||||
if not pageToken:
|
||||
break
|
||||
if effectiveLimit is not None:
|
||||
entries = entries[:effectiveLimit]
|
||||
return entries
|
||||
result = await _googleGet(self._token, url)
|
||||
if "error" in result:
|
||||
return []
|
||||
return [
|
||||
ExternalEntry(
|
||||
name=f.get("name", ""),
|
||||
path=f"/{f.get('id', '')}",
|
||||
isFolder=f.get("mimeType") == "application/vnd.google-apps.folder",
|
||||
size=int(f.get("size", 0)) if f.get("size") else None,
|
||||
)
|
||||
for f in result.get("files", [])
|
||||
]
|
||||
|
||||
|
||||
class GmailAdapter(ServiceAdapter):
|
||||
|
|
@ -216,8 +155,7 @@ class GmailAdapter(ServiceAdapter):
|
|||
self._token = accessToken
|
||||
|
||||
_DEFAULT_MESSAGE_LIMIT = 100
|
||||
_MAX_MESSAGE_LIMIT = 1000
|
||||
_METADATA_FETCH_CAP = 200
|
||||
_MAX_MESSAGE_LIMIT = 500
|
||||
|
||||
async def browse(
|
||||
self,
|
||||
|
|
@ -229,9 +167,10 @@ class GmailAdapter(ServiceAdapter):
|
|||
|
||||
if not cleanPath:
|
||||
url = f"{_GMAIL_BASE}/users/me/labels"
|
||||
result = await googleGet(self._token, url)
|
||||
result = await _googleGet(self._token, url)
|
||||
if "error" in result:
|
||||
_raiseGoogleError(result, "Gmail labels")
|
||||
logger.warning(f"Gmail labels failed: {result['error']}")
|
||||
return []
|
||||
_SYSTEM_LABELS = {"INBOX", "SENT", "DRAFT", "TRASH", "SPAM", "STARRED", "IMPORTANT"}
|
||||
labels = []
|
||||
for lbl in result.get("labels", []):
|
||||
|
|
@ -249,116 +188,23 @@ class GmailAdapter(ServiceAdapter):
|
|||
return labels
|
||||
|
||||
effectiveLimit = self._DEFAULT_MESSAGE_LIMIT if limit is None else max(1, min(int(limit), self._MAX_MESSAGE_LIMIT))
|
||||
labelId = await self._resolveLabelId(cleanPath)
|
||||
if not labelId:
|
||||
raise ValueError(
|
||||
f"Gmail label not found: '{cleanPath}'. Browse the mailbox root ('/') "
|
||||
f"to list available labels."
|
||||
)
|
||||
msgIds, totalEstimate = await self._listMessageIds(
|
||||
params={"labelIds": labelId}, limit=effectiveLimit,
|
||||
)
|
||||
entries = await self._fetchMessageEntries(
|
||||
msgIds[:self._METADATA_FETCH_CAP], labelPath=labelId,
|
||||
)
|
||||
if totalEstimate and totalEstimate > len(msgIds):
|
||||
entries.append(ExternalEntry(
|
||||
name=f"(~{totalEstimate} total messages estimated, {len(msgIds)} listed)",
|
||||
path=f"/{labelId}/_count", isFolder=False,
|
||||
metadata={"totalEstimate": totalEstimate, "listed": len(msgIds)},
|
||||
))
|
||||
elif len(msgIds) > self._METADATA_FETCH_CAP:
|
||||
entries.append(ExternalEntry(
|
||||
name=f"({len(msgIds)} messages listed, metadata shown for first {self._METADATA_FETCH_CAP})",
|
||||
path=f"/{labelId}/_count", isFolder=False,
|
||||
metadata={"listed": len(msgIds), "metadataShown": self._METADATA_FETCH_CAP},
|
||||
))
|
||||
return entries
|
||||
|
||||
async def _resolveLabelId(self, ref: str) -> Optional[str]:
|
||||
"""Resolve a Gmail label reference (display name / system name / id) to a
|
||||
label id. Returns None if nothing matches so the caller can raise a clear
|
||||
error instead of querying with an invalid label."""
|
||||
if not ref:
|
||||
return None
|
||||
r = ref.strip()
|
||||
result = await googleGet(self._token, f"{_GMAIL_BASE}/users/me/labels")
|
||||
url = f"{_GMAIL_BASE}/users/me/messages?labelIds={cleanPath}&maxResults={effectiveLimit}"
|
||||
result = await _googleGet(self._token, url)
|
||||
if "error" in result:
|
||||
_raiseGoogleError(result, "Gmail labels")
|
||||
labels = result.get("labels", [])
|
||||
# 1) exact id match (already-resolved id passes through)
|
||||
for lbl in labels:
|
||||
if lbl.get("id") == r:
|
||||
return r
|
||||
# 2) case-insensitive display-name match
|
||||
for lbl in labels:
|
||||
if (lbl.get("name") or "").strip().lower() == r.lower():
|
||||
return lbl.get("id")
|
||||
# 3) system label by uppercased name (INBOX, SENT, ...)
|
||||
up = r.upper()
|
||||
for lbl in labels:
|
||||
if lbl.get("id") == up:
|
||||
return up
|
||||
return None
|
||||
|
||||
async def _listMessageIds(
|
||||
self, params: Dict[str, str], limit: int,
|
||||
) -> tuple:
|
||||
"""Page through ``messages.list`` and return (msgIds, totalEstimate).
|
||||
|
||||
Gmail's ``maxResults`` caps at 500 per page, so we follow
|
||||
``nextPageToken`` until we have ``limit`` ids or there are no more pages.
|
||||
``resultSizeEstimate`` from the first page gives the agent an approximate
|
||||
total count without having to download every message.
|
||||
"""
|
||||
msgIds: List[str] = []
|
||||
totalEstimate: Optional[int] = None
|
||||
pageToken: Optional[str] = None
|
||||
pageSize = min(limit, 500)
|
||||
while len(msgIds) < limit:
|
||||
p = {**params, "maxResults": str(pageSize)}
|
||||
if pageToken:
|
||||
p["pageToken"] = pageToken
|
||||
url = f"{_GMAIL_BASE}/users/me/messages?{urllib.parse.urlencode(p)}"
|
||||
result = await googleGet(self._token, url)
|
||||
if "error" in result:
|
||||
if not msgIds:
|
||||
_raiseGoogleError(result, "Gmail list messages")
|
||||
break
|
||||
if totalEstimate is None:
|
||||
totalEstimate = result.get("resultSizeEstimate")
|
||||
for m in result.get("messages", []):
|
||||
mid = m.get("id", "")
|
||||
if mid:
|
||||
msgIds.append(mid)
|
||||
if len(msgIds) >= limit:
|
||||
break
|
||||
pageToken = result.get("nextPageToken")
|
||||
if not pageToken:
|
||||
break
|
||||
return msgIds, totalEstimate
|
||||
|
||||
async def _fetchMessageEntries(self, msgIds: List[str], labelPath: str = "") -> List[ExternalEntry]:
|
||||
"""Resolve a list of Gmail message ids into ExternalEntries with
|
||||
Subject/From/Date metadata. Detail fetches run concurrently to avoid a
|
||||
slow sequential N+1 round-trip per message."""
|
||||
if not msgIds:
|
||||
return []
|
||||
pathPrefix = f"/{labelPath}" if labelPath else ""
|
||||
|
||||
async def _one(msgId: str) -> ExternalEntry:
|
||||
detailUrl = (
|
||||
f"{_GMAIL_BASE}/users/me/messages/{msgId}"
|
||||
f"?format=metadata&metadataHeaders=Subject&metadataHeaders=From&metadataHeaders=Date"
|
||||
)
|
||||
detail = await googleGet(self._token, detailUrl)
|
||||
entries = []
|
||||
for msg in result.get("messages", [])[:effectiveLimit]:
|
||||
msgId = msg.get("id", "")
|
||||
detailUrl = f"{_GMAIL_BASE}/users/me/messages/{msgId}?format=metadata&metadataHeaders=Subject&metadataHeaders=From&metadataHeaders=Date"
|
||||
detail = await _googleGet(self._token, detailUrl)
|
||||
if "error" in detail:
|
||||
return ExternalEntry(name=f"Message {msgId}", path=f"{pathPrefix}/{msgId}", isFolder=False,
|
||||
metadata={"id": msgId})
|
||||
entries.append(ExternalEntry(name=f"Message {msgId}", path=f"/{cleanPath}/{msgId}", isFolder=False))
|
||||
continue
|
||||
headers = {h.get("name", ""): h.get("value", "") for h in detail.get("payload", {}).get("headers", [])}
|
||||
return ExternalEntry(
|
||||
entries.append(ExternalEntry(
|
||||
name=headers.get("Subject", "(no subject)"),
|
||||
path=f"{pathPrefix}/{msgId}",
|
||||
path=f"/{cleanPath}/{msgId}",
|
||||
isFolder=False,
|
||||
metadata={
|
||||
"id": msgId,
|
||||
|
|
@ -366,19 +212,20 @@ class GmailAdapter(ServiceAdapter):
|
|||
"date": headers.get("Date", ""),
|
||||
"snippet": detail.get("snippet", ""),
|
||||
},
|
||||
)
|
||||
|
||||
return list(await asyncio.gather(*[_one(mid) for mid in msgIds]))
|
||||
))
|
||||
return entries
|
||||
|
||||
async def download(self, path: str) -> DownloadResult:
|
||||
"""Download a Gmail message as RFC 822 EML via format=raw."""
|
||||
import base64
|
||||
import re
|
||||
cleanPath = (path or "").strip("/")
|
||||
msgId = cleanPath.split("/")[-1] if cleanPath else ""
|
||||
if not msgId:
|
||||
return DownloadResult()
|
||||
|
||||
url = f"{_GMAIL_BASE}/users/me/messages/{msgId}?format=raw"
|
||||
result = await googleGet(self._token, url)
|
||||
result = await _googleGet(self._token, url)
|
||||
if "error" in result:
|
||||
return DownloadResult()
|
||||
|
||||
|
|
@ -389,7 +236,7 @@ class GmailAdapter(ServiceAdapter):
|
|||
emlBytes = base64.urlsafe_b64decode(rawB64)
|
||||
|
||||
metaUrl = f"{_GMAIL_BASE}/users/me/messages/{msgId}?format=metadata&metadataHeaders=Subject"
|
||||
meta = await googleGet(self._token, metaUrl)
|
||||
meta = await _googleGet(self._token, metaUrl)
|
||||
subject = msgId
|
||||
if "error" not in meta:
|
||||
for h in meta.get("payload", {}).get("headers", []):
|
||||
|
|
@ -414,34 +261,19 @@ class GmailAdapter(ServiceAdapter):
|
|||
limit: Optional[int] = None,
|
||||
) -> list:
|
||||
effectiveLimit = self._DEFAULT_MESSAGE_LIMIT if limit is None else max(1, min(int(limit), self._MAX_MESSAGE_LIMIT))
|
||||
params: Dict[str, str] = {"q": query}
|
||||
labelPath = (path or "").strip("/")
|
||||
if labelPath:
|
||||
labelId = await self._resolveLabelId(labelPath)
|
||||
if not labelId:
|
||||
raise ValueError(
|
||||
f"Gmail label not found: '{labelPath}'. Browse the mailbox root ('/') "
|
||||
f"to list available labels, or search without a label scope."
|
||||
)
|
||||
labelPath = labelId
|
||||
params["labelIds"] = labelId
|
||||
msgIds, totalEstimate = await self._listMessageIds(params, limit=effectiveLimit)
|
||||
entries = await self._fetchMessageEntries(
|
||||
msgIds[:self._METADATA_FETCH_CAP], labelPath=labelPath,
|
||||
)
|
||||
if totalEstimate and totalEstimate > len(msgIds):
|
||||
entries.append(ExternalEntry(
|
||||
name=f"(~{totalEstimate} total results estimated, {len(msgIds)} listed)",
|
||||
path=f"/{labelPath or 'search'}/_count", isFolder=False,
|
||||
metadata={"totalEstimate": totalEstimate, "listed": len(msgIds)},
|
||||
))
|
||||
elif len(msgIds) > self._METADATA_FETCH_CAP:
|
||||
entries.append(ExternalEntry(
|
||||
name=f"({len(msgIds)} results listed, metadata shown for first {self._METADATA_FETCH_CAP})",
|
||||
path=f"/{labelPath or 'search'}/_count", isFolder=False,
|
||||
metadata={"listed": len(msgIds), "metadataShown": self._METADATA_FETCH_CAP},
|
||||
))
|
||||
return entries
|
||||
url = f"{_GMAIL_BASE}/users/me/messages?q={query}&maxResults={effectiveLimit}"
|
||||
result = await _googleGet(self._token, url)
|
||||
if "error" in result:
|
||||
return []
|
||||
return [
|
||||
ExternalEntry(
|
||||
name=f"Message {m.get('id', '')}",
|
||||
path=f"/{m.get('id', '')}",
|
||||
isFolder=False,
|
||||
metadata={"id": m.get("id")},
|
||||
)
|
||||
for m in result.get("messages", [])
|
||||
]
|
||||
|
||||
|
||||
class CalendarAdapter(ServiceAdapter):
|
||||
|
|
@ -468,9 +300,10 @@ class CalendarAdapter(ServiceAdapter):
|
|||
cleanPath = (path or "").strip("/")
|
||||
if not cleanPath:
|
||||
url = f"{_CALENDAR_BASE}/users/me/calendarList?maxResults=250"
|
||||
result = await googleGet(self._token, url)
|
||||
result = await _googleGet(self._token, url)
|
||||
if "error" in result:
|
||||
_raiseGoogleError(result, "Google Calendar list")
|
||||
logger.warning(f"Google Calendar list failed: {result['error']}")
|
||||
return []
|
||||
calendars = result.get("items", [])
|
||||
if filter:
|
||||
f = filter.lower()
|
||||
|
|
@ -498,14 +331,10 @@ class CalendarAdapter(ServiceAdapter):
|
|||
f"{_CALENDAR_BASE}/calendars/{quote(calendarId, safe='')}/events"
|
||||
f"?maxResults={effectiveLimit}&orderBy=startTime&singleEvents=true"
|
||||
)
|
||||
# Restrict to a date window when the filter is a date range, so large
|
||||
# multi-year calendars only return the relevant period.
|
||||
timeMin, timeMax = _parseGoogleDateRange(filter)
|
||||
if timeMin and timeMax:
|
||||
url += f"&timeMin={quote(timeMin, safe='')}&timeMax={quote(timeMax, safe='')}"
|
||||
result = await googleGet(self._token, url)
|
||||
result = await _googleGet(self._token, url)
|
||||
if "error" in result:
|
||||
_raiseGoogleError(result, "Google Calendar events")
|
||||
logger.warning(f"Google Calendar events failed: {result['error']}")
|
||||
return []
|
||||
events = result.get("items", [])
|
||||
return [
|
||||
ExternalEntry(
|
||||
|
|
@ -533,7 +362,7 @@ class CalendarAdapter(ServiceAdapter):
|
|||
return DownloadResult()
|
||||
calendarId, eventId = cleanPath.split("/", 1)
|
||||
url = f"{_CALENDAR_BASE}/calendars/{quote(calendarId, safe='')}/events/{quote(eventId, safe='')}"
|
||||
ev = await googleGet(self._token, url)
|
||||
ev = await _googleGet(self._token, url)
|
||||
if "error" in ev:
|
||||
logger.warning(f"Google Calendar event fetch failed: {ev['error']}")
|
||||
return DownloadResult()
|
||||
|
|
@ -558,23 +387,13 @@ class CalendarAdapter(ServiceAdapter):
|
|||
from urllib.parse import quote
|
||||
calendarId = (path or "").strip("/").split("/", 1)[0] or "primary"
|
||||
effectiveLimit = self._DEFAULT_EVENT_LIMIT if limit is None else max(1, min(int(limit), self._MAX_EVENT_LIMIT))
|
||||
# A date-range query maps to timeMin/timeMax (efficient window fetch);
|
||||
# otherwise fall back to the free-text q parameter.
|
||||
timeMin, timeMax = _parseGoogleDateRange(query)
|
||||
if timeMin and timeMax:
|
||||
url = (
|
||||
f"{_CALENDAR_BASE}/calendars/{quote(calendarId, safe='')}/events"
|
||||
f"?timeMin={quote(timeMin, safe='')}&timeMax={quote(timeMax, safe='')}"
|
||||
f"&maxResults={effectiveLimit}&orderBy=startTime&singleEvents=true"
|
||||
)
|
||||
else:
|
||||
url = (
|
||||
f"{_CALENDAR_BASE}/calendars/{quote(calendarId, safe='')}/events"
|
||||
f"?q={quote(query, safe='')}&maxResults={effectiveLimit}&singleEvents=true"
|
||||
)
|
||||
result = await googleGet(self._token, url)
|
||||
url = (
|
||||
f"{_CALENDAR_BASE}/calendars/{quote(calendarId, safe='')}/events"
|
||||
f"?q={quote(query, safe='')}&maxResults={effectiveLimit}&singleEvents=true"
|
||||
)
|
||||
result = await _googleGet(self._token, url)
|
||||
if "error" in result:
|
||||
_raiseGoogleError(result, "Google Calendar search")
|
||||
return []
|
||||
return [
|
||||
ExternalEntry(
|
||||
name=ev.get("summary", "(no title)"),
|
||||
|
|
@ -628,7 +447,7 @@ class ContactsAdapter(ServiceAdapter):
|
|||
),
|
||||
]
|
||||
url = f"{_PEOPLE_BASE}/contactGroups?pageSize=200"
|
||||
result = await googleGet(self._token, url)
|
||||
result = await _googleGet(self._token, url)
|
||||
if "error" not in result:
|
||||
for grp in result.get("contactGroups", []):
|
||||
name = grp.get("formattedName") or grp.get("name") or ""
|
||||
|
|
@ -658,9 +477,10 @@ class ContactsAdapter(ServiceAdapter):
|
|||
f"{_PEOPLE_BASE}/people/me/connections"
|
||||
f"?pageSize={min(effectiveLimit, 1000)}&personFields={self._PERSON_FIELDS}"
|
||||
)
|
||||
result = await googleGet(self._token, url)
|
||||
result = await _googleGet(self._token, url)
|
||||
if "error" in result:
|
||||
_raiseGoogleError(result, "Google People connections")
|
||||
logger.warning(f"Google People connections failed: {result['error']}")
|
||||
return []
|
||||
people = result.get("connections", [])
|
||||
else:
|
||||
groupResource = groupRef
|
||||
|
|
@ -668,9 +488,10 @@ class ContactsAdapter(ServiceAdapter):
|
|||
f"{_PEOPLE_BASE}/{quote(groupResource, safe='/')}"
|
||||
f"?maxMembers={min(effectiveLimit, 1000)}"
|
||||
)
|
||||
grpResult = await googleGet(self._token, grpUrl)
|
||||
grpResult = await _googleGet(self._token, grpUrl)
|
||||
if "error" in grpResult:
|
||||
_raiseGoogleError(grpResult, "Google contactGroup detail")
|
||||
logger.warning(f"Google contactGroup detail failed: {grpResult['error']}")
|
||||
return []
|
||||
memberResourceNames = grpResult.get("memberResourceNames") or []
|
||||
if not memberResourceNames:
|
||||
return []
|
||||
|
|
@ -680,7 +501,7 @@ class ContactsAdapter(ServiceAdapter):
|
|||
chunk = memberResourceNames[i : i + chunkSize]
|
||||
params = "&".join(f"resourceNames={quote(rn, safe='/')}" for rn in chunk)
|
||||
batchUrl = f"{_PEOPLE_BASE}/people:batchGet?{params}&personFields={self._PERSON_FIELDS}"
|
||||
batchResult = await googleGet(self._token, batchUrl)
|
||||
batchResult = await _googleGet(self._token, batchUrl)
|
||||
if "error" in batchResult:
|
||||
logger.warning(f"Google People batchGet failed: {batchResult['error']}")
|
||||
continue
|
||||
|
|
@ -716,7 +537,7 @@ class ContactsAdapter(ServiceAdapter):
|
|||
if not personSuffix:
|
||||
return DownloadResult()
|
||||
url = f"{_PEOPLE_BASE}/people/{quote(personSuffix, safe='')}?personFields={self._PERSON_FIELDS}"
|
||||
person = await googleGet(self._token, url)
|
||||
person = await _googleGet(self._token, url)
|
||||
if "error" in person:
|
||||
logger.warning(f"Google People fetch failed: {person['error']}")
|
||||
return DownloadResult()
|
||||
|
|
@ -745,9 +566,9 @@ class ContactsAdapter(ServiceAdapter):
|
|||
f"?query={quote(query, safe='')}&pageSize={min(effectiveLimit, 30)}"
|
||||
f"&readMask={self._PERSON_FIELDS}"
|
||||
)
|
||||
result = await googleGet(self._token, url)
|
||||
result = await _googleGet(self._token, url)
|
||||
if "error" in result:
|
||||
_raiseGoogleError(result, "Google Contacts search")
|
||||
return []
|
||||
entries: List[ExternalEntry] = []
|
||||
for r in result.get("results", []):
|
||||
p = r.get("person") or {}
|
||||
|
|
@ -760,8 +581,6 @@ class ContactsAdapter(ServiceAdapter):
|
|||
metadata={
|
||||
"id": p.get("resourceName"),
|
||||
"emails": [e.get("value") for e in (p.get("emailAddresses") or []) if e.get("value")],
|
||||
"phones": [pn.get("value") for pn in (p.get("phoneNumbers") or []) if pn.get("value")],
|
||||
"organization": (p.get("organizations") or [{}])[0].get("name") if p.get("organizations") else None,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
|
@ -769,6 +588,7 @@ class ContactsAdapter(ServiceAdapter):
|
|||
|
||||
|
||||
def _googleSafeFileName(name: str) -> str:
|
||||
import re
|
||||
return re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name or "")[:80].strip(". ")
|
||||
|
||||
|
||||
|
|
@ -788,6 +608,7 @@ def _googleIcsDateTime(value: Optional[str]) -> Optional[str]:
|
|||
"""Convert a Google Calendar dateTime/date string to RFC 5545 format (UTC)."""
|
||||
if not value:
|
||||
return None
|
||||
from datetime import datetime, timezone
|
||||
try:
|
||||
if "T" not in value:
|
||||
dt = datetime.strptime(value, "%Y-%m-%d")
|
||||
|
|
@ -803,6 +624,7 @@ def _googleIcsDateTime(value: Optional[str]) -> Optional[str]:
|
|||
|
||||
def _googleEventToIcs(event: Dict[str, Any]) -> bytes:
|
||||
"""Build a minimal RFC 5545 VCALENDAR/VEVENT for a Google Calendar event."""
|
||||
from datetime import datetime, timezone
|
||||
uid = event.get("iCalUID") or event.get("id") or "unknown@poweron"
|
||||
summary = _googleIcsEscape(event.get("summary") or "")
|
||||
location = _googleIcsEscape(event.get("location") or "")
|
||||
3
modules/connectors/providerInfomaniak/__init__.py
Normal file
3
modules/connectors/providerInfomaniak/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Infomaniak Provider Connector -- 1 Connection : n Services (kDrive, Mail)."""
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Infomaniak ProviderConnector -- kDrive + Calendar + Contacts via PAT.
|
||||
|
||||
|
|
@ -31,7 +31,6 @@ Path conventions (leading slash, ``ServiceAdapter`` paths always start with
|
|||
/{addressBookId}/{contactId} -- single contact (.vcf download)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
|
@ -45,13 +44,10 @@ from modules.connectors.connectorProviderBase import (
|
|||
ServiceAdapter,
|
||||
DownloadResult,
|
||||
)
|
||||
from modules.shared.httpResilience import ResilientHttp
|
||||
from modules.datamodels.datamodelDataSource import ExternalEntry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_http = ResilientHttp("Infomaniak", maxConcurrent=6, defaultTimeoutS=20)
|
||||
|
||||
_API_BASE = "https://api.infomaniak.com"
|
||||
_CALENDAR_BASE = "https://calendar.infomaniak.com"
|
||||
_CONTACTS_BASE = "https://contacts.infomaniak.com"
|
||||
|
|
@ -86,18 +82,18 @@ async def _infomaniakGet(
|
|||
"""
|
||||
url = f"{baseUrl.rstrip('/')}/{endpoint.lstrip('/')}"
|
||||
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
|
||||
return await _http.getJson(url, headers=headers, allowRedirects=False)
|
||||
|
||||
|
||||
def _raiseInfomaniakError(result: Dict[str, Any], ctx: str) -> None:
|
||||
"""Raise a clear error for a failed Infomaniak API response.
|
||||
|
||||
Browse/search must NOT swallow API failures into an empty result list, which
|
||||
masks a real error as 'empty'. Callers wrap these in try/except.
|
||||
"""
|
||||
err = result.get("error") if isinstance(result, dict) else None
|
||||
logger.warning("Infomaniak error (%s): %s", ctx, err or result)
|
||||
raise RuntimeError(f"Infomaniak error ({ctx}): {err or result}")
|
||||
timeout = aiohttp.ClientTimeout(total=20)
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(url, headers=headers, allow_redirects=False) as resp:
|
||||
if resp.status in (200, 201):
|
||||
return await resp.json()
|
||||
errorText = await resp.text()
|
||||
logger.warning(f"Infomaniak GET {url} -> {resp.status}: {errorText[:300]}")
|
||||
return {"error": f"{resp.status}: {errorText[:200]}"}
|
||||
except Exception as e:
|
||||
logger.error(f"Infomaniak GET {url} crashed: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
async def _infomaniakDownload(
|
||||
|
|
@ -117,7 +113,20 @@ async def _infomaniakDownload(
|
|||
"""
|
||||
url = f"{baseUrl.rstrip('/')}/{endpoint.lstrip('/')}"
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
return await _http.getBytes(url, headers=headers, timeout=aiohttp.ClientTimeout(total=120))
|
||||
timeout = aiohttp.ClientTimeout(total=120)
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(url, headers=headers, allow_redirects=True) as resp:
|
||||
if resp.status == 200:
|
||||
return await resp.read()
|
||||
logger.warning(
|
||||
f"Infomaniak download {url} -> {resp.status}: "
|
||||
f"{(await resp.text())[:300]}"
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Infomaniak download {url} crashed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _unwrapData(payload: Any) -> Any:
|
||||
|
|
@ -349,7 +358,10 @@ class KdriveAdapter(ServiceAdapter):
|
|||
|
||||
result = await _infomaniakGet(self._token, endpoint)
|
||||
if isinstance(result, dict) and result.get("error"):
|
||||
_raiseInfomaniakError(result, f"kDrive list-children {driveId}/{fileId or 'root'}")
|
||||
logger.warning(
|
||||
f"kDrive list-children {driveId}/{fileId or 'root'} failed: {result['error']}"
|
||||
)
|
||||
return []
|
||||
data = _unwrapData(result)
|
||||
items = data if isinstance(data, list) else data.get("items", []) if isinstance(data, dict) else []
|
||||
|
||||
|
|
@ -392,115 +404,8 @@ class KdriveAdapter(ServiceAdapter):
|
|||
return DownloadResult()
|
||||
return DownloadResult(data=content, fileName=fileName, mimeType=mimeType)
|
||||
|
||||
async def _createDirectory(self, driveId: str, parentId: str, name: str) -> Optional[str]:
|
||||
"""Create a single directory and return its ID.
|
||||
|
||||
If the directory already exists (409), lists the parent to find
|
||||
the existing folder's ID -- kDrive directory creation is not
|
||||
idempotent.
|
||||
"""
|
||||
url = f"{_API_BASE}/3/drive/{driveId}/files/{parentId}/directory"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self._token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
body = json.dumps({"name": name})
|
||||
result = await _http.request("POST", url, headers=headers, data=body)
|
||||
|
||||
if isinstance(result, dict) and not result.get("error"):
|
||||
data = _unwrapData(result)
|
||||
if isinstance(data, dict) and data.get("id"):
|
||||
return str(data["id"])
|
||||
|
||||
errorStr = str(result.get("error", "")) if isinstance(result, dict) else ""
|
||||
if "already_exists" in errorStr or "409" in errorStr:
|
||||
children = await self._listChildren(driveId, fileId=parentId, limit=1000)
|
||||
for child in children:
|
||||
if child.isFolder and child.name == name:
|
||||
return (child.metadata or {}).get("id") or child.path.strip("/").split("/")[-1]
|
||||
|
||||
logger.warning("kDrive mkdir %s/%s in %s failed: %s", driveId, name, parentId, result)
|
||||
return None
|
||||
|
||||
async def _ensureDirectoryPath(self, driveId: str, parentId: str, pathSegments: List[str]) -> Optional[str]:
|
||||
"""Walk *pathSegments* and create each level that does not exist yet.
|
||||
|
||||
Returns the numeric folder ID of the deepest directory, or
|
||||
``None`` if any step fails.
|
||||
"""
|
||||
currentId = parentId
|
||||
for segment in pathSegments:
|
||||
folderId = await self._createDirectory(driveId, currentId, segment)
|
||||
if not folderId:
|
||||
return None
|
||||
currentId = folderId
|
||||
return currentId
|
||||
|
||||
async def upload(self, path: str, data: bytes, fileName: str) -> dict:
|
||||
"""Upload a file to kDrive.
|
||||
|
||||
Path formats:
|
||||
/{driveId} -> upload to drive root
|
||||
/{driveId}/{folderId} -> upload into folder by numeric ID
|
||||
/{driveId}/{folderId}/Sub/Path -> create Sub/Path under folderId, then upload
|
||||
/{driveId}/Some/Human/Path -> create path from drive root (id 1), then upload
|
||||
|
||||
Directories are created step-by-step via the v3 mkdir endpoint;
|
||||
existing directories are reused (idempotent). File upload uses
|
||||
the v3 upload endpoint (max 1 GB).
|
||||
"""
|
||||
segments = [s for s in (path or "").strip("/").split("/") if s]
|
||||
if not segments:
|
||||
return {"error": "Upload path must include at least a drive ID"}
|
||||
driveId = segments[0]
|
||||
|
||||
targetDirId: Optional[str] = None
|
||||
if len(segments) > 1:
|
||||
subSegments = segments[1:]
|
||||
numericPrefix: List[str] = []
|
||||
nameSegments: List[str] = []
|
||||
for i, seg in enumerate(subSegments):
|
||||
if seg.isdigit() and not nameSegments:
|
||||
numericPrefix.append(seg)
|
||||
else:
|
||||
nameSegments = subSegments[i:]
|
||||
break
|
||||
|
||||
parentId = numericPrefix[-1] if numericPrefix else "1"
|
||||
|
||||
if nameSegments and nameSegments[-1] == fileName:
|
||||
nameSegments = nameSegments[:-1]
|
||||
|
||||
if nameSegments:
|
||||
targetDirId = await self._ensureDirectoryPath(driveId, parentId, nameSegments)
|
||||
if not targetDirId:
|
||||
return {"error": f"Failed to create directory path: {'/'.join(nameSegments)}"}
|
||||
else:
|
||||
targetDirId = parentId
|
||||
|
||||
params = [
|
||||
f"file_name={quote(fileName)}",
|
||||
f"total_size={len(data)}",
|
||||
"conflict=version",
|
||||
]
|
||||
if targetDirId:
|
||||
params.append(f"directory_id={targetDirId}")
|
||||
|
||||
endpoint = f"/3/drive/{driveId}/upload?{'&'.join(params)}"
|
||||
url = f"{_API_BASE.rstrip('/')}/{endpoint.lstrip('/')}"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self._token}",
|
||||
"Content-Type": "application/octet-stream",
|
||||
}
|
||||
|
||||
result = await _http.request(
|
||||
"POST", url, headers=headers, data=data,
|
||||
timeout=aiohttp.ClientTimeout(total=120),
|
||||
)
|
||||
if isinstance(result, dict) and result.get("error"):
|
||||
return result
|
||||
unwrapped = _unwrapData(result) if isinstance(result, dict) else result
|
||||
return unwrapped if isinstance(unwrapped, dict) else {"data": unwrapped}
|
||||
return {"error": "kDrive upload not yet implemented"}
|
||||
|
||||
async def search(
|
||||
self,
|
||||
|
|
@ -521,7 +426,7 @@ class KdriveAdapter(ServiceAdapter):
|
|||
endpoint = f"/2/drive/{driveId}/files/search?query={query}&per_page={pageSize}"
|
||||
result = await _infomaniakGet(self._token, endpoint)
|
||||
if isinstance(result, dict) and result.get("error"):
|
||||
_raiseInfomaniakError(result, "kDrive search")
|
||||
return []
|
||||
data = _unwrapData(result)
|
||||
items = data if isinstance(data, list) else data.get("items", []) if isinstance(data, dict) else []
|
||||
|
||||
|
|
@ -590,7 +495,7 @@ class CalendarAdapter(ServiceAdapter):
|
|||
if not segments:
|
||||
return await self._listCalendars()
|
||||
if len(segments) == 1:
|
||||
return await self._listEvents(segments[0], limit=limit, filter=filter)
|
||||
return await self._listEvents(segments[0], limit=limit)
|
||||
return []
|
||||
|
||||
async def _listCalendars(self) -> List[ExternalEntry]:
|
||||
|
|
@ -598,7 +503,8 @@ class CalendarAdapter(ServiceAdapter):
|
|||
self._token, f"{_PIM_PREFIX}/calendar", baseUrl=_CALENDAR_BASE
|
||||
)
|
||||
if isinstance(result, dict) and result.get("error"):
|
||||
_raiseInfomaniakError(result, "Calendar list-calendars")
|
||||
logger.warning(f"Calendar list-calendars failed: {result['error']}")
|
||||
return []
|
||||
data = _unwrapData(result)
|
||||
calendars = data.get("calendars", []) if isinstance(data, dict) else []
|
||||
entries: List[ExternalEntry] = []
|
||||
|
|
@ -621,64 +527,18 @@ class CalendarAdapter(ServiceAdapter):
|
|||
))
|
||||
return entries
|
||||
|
||||
def _eventWindow(self, filter: Optional[str] = None) -> tuple:
|
||||
# Honour an explicit date range from the agent (e.g. "2026-06" or
|
||||
# "2026-06-01 2026-06-30"), clamped to the vendor's <3 month limit.
|
||||
# Otherwise fall back to the default 90-day browsing window.
|
||||
rng = self._parseFilterWindow(filter)
|
||||
if rng:
|
||||
return rng
|
||||
def _eventWindow(self) -> tuple:
|
||||
now = datetime.now(timezone.utc)
|
||||
fromStr = (now - timedelta(days=self._PAST_DAYS)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
toStr = (now + timedelta(days=self._FUTURE_DAYS)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
return fromStr, toStr
|
||||
|
||||
@staticmethod
|
||||
def _parseFilterWindow(filter: Optional[str]) -> Optional[tuple]:
|
||||
"""Parse a date range from a filter string into Infomaniak's
|
||||
'Y-m-d H:i:s' from/to window, clamped to <3 months. Returns None when
|
||||
the filter is not a parseable date range."""
|
||||
if not filter:
|
||||
return None
|
||||
iso = re.findall(r'\d{4}-\d{2}-\d{2}', filter)
|
||||
start = end = None
|
||||
if len(iso) >= 2:
|
||||
start, end = iso[0], iso[1]
|
||||
elif len(iso) == 1:
|
||||
start = iso[0]
|
||||
else:
|
||||
month = re.match(r'^(\d{4})-(\d{2})$', filter.strip())
|
||||
if not month:
|
||||
return None
|
||||
year, mon = int(month.group(1)), int(month.group(2))
|
||||
start = f"{year}-{mon:02d}-01"
|
||||
end = f"{year + 1}-01-01" if mon == 12 else f"{year}-{mon + 1:02d}-01"
|
||||
try:
|
||||
startDt = datetime.fromisoformat(start)
|
||||
except ValueError:
|
||||
return None
|
||||
if end:
|
||||
try:
|
||||
endDt = datetime.fromisoformat(end)
|
||||
except ValueError:
|
||||
endDt = startDt + timedelta(days=31)
|
||||
else:
|
||||
endDt = startDt + timedelta(days=31)
|
||||
# Clamp to vendor limit (<3 months).
|
||||
if endDt - startDt > timedelta(days=85):
|
||||
endDt = startDt + timedelta(days=85)
|
||||
return (
|
||||
startDt.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
endDt.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
)
|
||||
|
||||
async def _listEvents(
|
||||
self,
|
||||
calendarId: str,
|
||||
limit: Optional[int],
|
||||
filter: Optional[str] = None,
|
||||
) -> List[ExternalEntry]:
|
||||
fromStr, toStr = self._eventWindow(filter)
|
||||
fromStr, toStr = self._eventWindow()
|
||||
endpoint = (
|
||||
f"{_PIM_PREFIX}/event"
|
||||
f"?calendar_id={calendarId}"
|
||||
|
|
@ -687,7 +547,8 @@ class CalendarAdapter(ServiceAdapter):
|
|||
)
|
||||
result = await _infomaniakGet(self._token, endpoint, baseUrl=_CALENDAR_BASE)
|
||||
if isinstance(result, dict) and result.get("error"):
|
||||
_raiseInfomaniakError(result, f"Calendar list-events {calendarId}")
|
||||
logger.warning(f"Calendar list-events {calendarId} failed: {result['error']}")
|
||||
return []
|
||||
data = _unwrapData(result)
|
||||
events = data if isinstance(data, list) else data.get("events", []) if isinstance(data, dict) else []
|
||||
entries: List[ExternalEntry] = []
|
||||
|
|
@ -765,14 +626,11 @@ class CalendarAdapter(ServiceAdapter):
|
|||
)
|
||||
if not calendars:
|
||||
return []
|
||||
# A date-range query maps directly to the event window; a free-text
|
||||
# query keeps the default window and filters on title/location.
|
||||
dateWindow = self._parseFilterWindow(query)
|
||||
needle = "" if dateWindow else (query or "").strip().lower()
|
||||
needle = (query or "").strip().lower()
|
||||
results: List[ExternalEntry] = []
|
||||
for cal in calendars:
|
||||
calId = (cal.metadata or {}).get("id") or cal.path.strip("/")
|
||||
for ev in await self._listEvents(calId, limit=limit, filter=query if dateWindow else None):
|
||||
for ev in await self._listEvents(calId, limit=limit):
|
||||
hay = " ".join(
|
||||
str(v) for v in (
|
||||
ev.name,
|
||||
|
|
@ -910,7 +768,8 @@ class ContactAdapter(ServiceAdapter):
|
|||
self._token, f"{_PIM_PREFIX}/addressbook", baseUrl=_CONTACTS_BASE
|
||||
)
|
||||
if isinstance(result, dict) and result.get("error"):
|
||||
_raiseInfomaniakError(result, "Contacts list-addressbooks")
|
||||
logger.warning(f"Contacts list-addressbooks failed: {result['error']}")
|
||||
return []
|
||||
data = _unwrapData(result)
|
||||
books = data.get("addressbooks", []) if isinstance(data, dict) else []
|
||||
entries: List[ExternalEntry] = []
|
||||
|
|
@ -950,7 +809,10 @@ class ContactAdapter(ServiceAdapter):
|
|||
)
|
||||
result = await _infomaniakGet(self._token, endpoint, baseUrl=_CONTACTS_BASE)
|
||||
if isinstance(result, dict) and result.get("error"):
|
||||
_raiseInfomaniakError(result, f"Contacts list-contacts {addressBookId}")
|
||||
logger.warning(
|
||||
f"Contacts list-contacts {addressBookId} failed: {result['error']}"
|
||||
)
|
||||
return []
|
||||
data = _unwrapData(result)
|
||||
if isinstance(data, list):
|
||||
return [c for c in data if isinstance(c, dict)]
|
||||
3
modules/connectors/providerMsft/__init__.py
Normal file
3
modules/connectors/providerMsft/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Microsoft Provider Connector -- 1 Connection : n Services (SharePoint, Outlook, Teams, OneDrive)."""
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Microsoft ProviderConnector -- one MSFT connection serves SharePoint, Outlook, Teams, OneDrive.
|
||||
|
||||
|
|
@ -6,23 +6,17 @@ All ServiceAdapters share the same OAuth access token obtained from the
|
|||
UserConnection (authority=msft).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import aiohttp
|
||||
import asyncio
|
||||
import urllib.parse
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
from modules.connectors.connectorProviderBase import ProviderConnector, ServiceAdapter, DownloadResult
|
||||
from modules.shared.httpResilience import ResilientHttp
|
||||
from modules.datamodels.datamodelDataSource import ExternalEntry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_GRAPH_BASE = "https://graph.microsoft.com/v1.0"
|
||||
_http = ResilientHttp("Graph", maxConcurrent=10, defaultTimeoutS=30)
|
||||
|
||||
|
||||
class _GraphApiMixin:
|
||||
|
|
@ -49,25 +43,63 @@ class _GraphApiMixin:
|
|||
async def _graphDownload(self, endpoint: str) -> Optional[bytes]:
|
||||
"""Download binary content from Graph API."""
|
||||
headers = {"Authorization": f"Bearer {self._accessToken}"}
|
||||
timeout = aiohttp.ClientTimeout(total=60)
|
||||
url = f"{_GRAPH_BASE}/{endpoint.lstrip('/')}"
|
||||
return await _http.getBytes(url, headers=headers, timeout=aiohttp.ClientTimeout(total=60))
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(url, headers=headers) as resp:
|
||||
if resp.status == 200:
|
||||
return await resp.read()
|
||||
logger.error(f"Download failed {resp.status}: {await resp.text()}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Graph download error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def _makeGraphCall(
|
||||
token: str, endpoint: str, method: str = "GET", data: Any = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute a single Microsoft Graph API call via shared resilient HTTP client."""
|
||||
"""Execute a single Microsoft Graph API call."""
|
||||
url = f"{_GRAPH_BASE}/{endpoint.lstrip('/')}"
|
||||
contentType = "application/json; charset=utf-8"
|
||||
contentType = "application/json"
|
||||
if method == "PUT" and isinstance(data, bytes):
|
||||
contentType = "application/octet-stream"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": contentType,
|
||||
}
|
||||
if "$count=true" in endpoint:
|
||||
headers["ConsistencyLevel"] = "eventual"
|
||||
return await _http.request(method, url, headers=headers, data=data)
|
||||
timeout = aiohttp.ClientTimeout(total=30)
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
kwargs: Dict[str, Any] = {"headers": headers}
|
||||
if data is not None:
|
||||
kwargs["data"] = data
|
||||
|
||||
if method == "GET":
|
||||
async with session.get(url, **kwargs) as resp:
|
||||
return await _handleResponse(resp)
|
||||
elif method == "POST":
|
||||
async with session.post(url, **kwargs) as resp:
|
||||
return await _handleResponse(resp)
|
||||
elif method == "PUT":
|
||||
async with session.put(url, **kwargs) as resp:
|
||||
return await _handleResponse(resp)
|
||||
elif method == "PATCH":
|
||||
async with session.patch(url, **kwargs) as resp:
|
||||
return await _handleResponse(resp)
|
||||
elif method == "DELETE":
|
||||
async with session.delete(url, **kwargs) as resp:
|
||||
if resp.status in (200, 204):
|
||||
return {}
|
||||
return await _handleResponse(resp)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
return {"error": f"Graph API timeout: {endpoint}"}
|
||||
except Exception as e:
|
||||
return {"error": f"Graph API error: {e}"}
|
||||
|
||||
return {"error": f"Unsupported method: {method}"}
|
||||
|
||||
|
||||
async def _handleResponse(resp: aiohttp.ClientResponse) -> Dict[str, Any]:
|
||||
|
|
@ -82,7 +114,7 @@ async def _handleResponse(resp: aiohttp.ClientResponse) -> Dict[str, Any]:
|
|||
return {"error": f"{resp.status}: {errorText}"}
|
||||
|
||||
|
||||
def stripGraphBase(url: str) -> str:
|
||||
def _stripGraphBase(url: str) -> str:
|
||||
"""Convert an absolute Graph URL (used by @odata.nextLink) into the
|
||||
relative endpoint that ``_makeGraphCall`` expects."""
|
||||
if not url:
|
||||
|
|
@ -92,18 +124,6 @@ def stripGraphBase(url: str) -> str:
|
|||
return url
|
||||
|
||||
|
||||
def _raiseGraphError(result: Dict[str, Any], ctx: str) -> None:
|
||||
"""Raise a clear error for a failed Graph response.
|
||||
|
||||
Browse/search must NOT swallow API failures into an empty result list, which
|
||||
makes a real error look like 'empty directory'. Callers (data-source tools,
|
||||
tree-builder, sync jobs) already wrap these in try/except.
|
||||
"""
|
||||
err = result.get("error") if isinstance(result, dict) else None
|
||||
logger.warning("Graph error (%s): %s", ctx, err or result)
|
||||
raise RuntimeError(f"Graph error ({ctx}): {err or result}")
|
||||
|
||||
|
||||
def _graphItemToExternalEntry(item: Dict[str, Any], basePath: str = "") -> ExternalEntry:
|
||||
isFolder = "folder" in item
|
||||
# Graph exposes the driveItem content hash as ``eTag`` (quoted) or
|
||||
|
|
@ -169,8 +189,7 @@ class SharepointAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
while endpoint and len(items) < hardCap:
|
||||
result = await self._graphGet(endpoint)
|
||||
if "error" in result:
|
||||
if not items:
|
||||
_raiseGraphError(result, "SharePoint browse")
|
||||
logger.warning(f"SharePoint browse failed: {result['error']}")
|
||||
break
|
||||
for raw in result.get("value", []) or []:
|
||||
items.append(raw)
|
||||
|
|
@ -179,7 +198,7 @@ class SharepointAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
if effectiveLimit is not None and len(items) >= effectiveLimit:
|
||||
break
|
||||
nextLink = result.get("@odata.nextLink")
|
||||
endpoint = stripGraphBase(nextLink) if nextLink else None
|
||||
endpoint = _stripGraphBase(nextLink) if nextLink else None
|
||||
|
||||
entries = [_graphItemToExternalEntry(item, path) for item in items]
|
||||
if filter:
|
||||
|
|
@ -192,7 +211,8 @@ class SharepointAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
"""Discover accessible SharePoint sites."""
|
||||
result = await self._graphGet("sites?search=*&$top=50")
|
||||
if "error" in result:
|
||||
_raiseGraphError(result, "SharePoint site discovery")
|
||||
logger.warning(f"SharePoint site discovery failed: {result['error']}")
|
||||
return []
|
||||
return [
|
||||
ExternalEntry(
|
||||
name=s.get("displayName") or s.get("name", ""),
|
||||
|
|
@ -233,37 +253,17 @@ class SharepointAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
path: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> List[ExternalEntry]:
|
||||
siteId, folderPath = _parseSharepointPath(path or "")
|
||||
siteId, _ = _parseSharepointPath(path or "")
|
||||
if not siteId:
|
||||
return []
|
||||
safeQuery = query.replace("'", "''")
|
||||
cleanFolder = (folderPath or "").strip("/")
|
||||
# Scope the search to the attached folder when one is given, so the agent
|
||||
# does not get hits from unrelated parts of the site drive.
|
||||
if cleanFolder:
|
||||
endpoint: Optional[str] = f"sites/{siteId}/drive/root:/{cleanFolder}:/search(q='{safeQuery}')?$top=200"
|
||||
else:
|
||||
endpoint = f"sites/{siteId}/drive/root/search(q='{safeQuery}')?$top=200"
|
||||
effectiveLimit = int(limit) if limit is not None else None
|
||||
items: List[Dict[str, Any]] = []
|
||||
hardCap = 1000
|
||||
while endpoint and len(items) < hardCap:
|
||||
result = await self._graphGet(endpoint)
|
||||
if "error" in result:
|
||||
if not items:
|
||||
_raiseGraphError(result, "SharePoint search")
|
||||
break
|
||||
for raw in result.get("value", []) or []:
|
||||
items.append(raw)
|
||||
if effectiveLimit is not None and len(items) >= effectiveLimit:
|
||||
break
|
||||
if effectiveLimit is not None and len(items) >= effectiveLimit:
|
||||
break
|
||||
nextLink = result.get("@odata.nextLink")
|
||||
endpoint = stripGraphBase(nextLink) if nextLink else None
|
||||
entries = [_graphItemToExternalEntry(item) for item in items]
|
||||
if effectiveLimit is not None:
|
||||
entries = entries[: max(1, effectiveLimit)]
|
||||
endpoint = f"sites/{siteId}/drive/root/search(q='{safeQuery}')"
|
||||
result = await self._graphGet(endpoint)
|
||||
if "error" in result:
|
||||
return []
|
||||
entries = [_graphItemToExternalEntry(item) for item in result.get("value", [])]
|
||||
if limit is not None:
|
||||
entries = entries[: max(1, int(limit))]
|
||||
return entries
|
||||
|
||||
|
||||
|
|
@ -271,59 +271,6 @@ class SharepointAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
# Outlook Adapter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CHARSET_META = '<meta charset="utf-8">'
|
||||
|
||||
|
||||
def _parseDateRange(filterStr: Optional[str]) -> tuple:
|
||||
"""Parse a date range from a filter/query string.
|
||||
|
||||
Supports two ISO dates ("2026-06-01 2026-06-30"), a single ISO date
|
||||
(treated as a ~31 day window), or a YYYY-MM month pattern. Returns
|
||||
(startDateTime, endDateTime) ISO strings, or (None, None) if not parseable.
|
||||
"""
|
||||
if not filterStr:
|
||||
return (None, None)
|
||||
isoMatch = re.findall(r'\d{4}-\d{2}-\d{2}(?:T[\d:]+)?', filterStr)
|
||||
if len(isoMatch) >= 2:
|
||||
return (isoMatch[0], isoMatch[1])
|
||||
if len(isoMatch) == 1:
|
||||
try:
|
||||
dt = datetime.fromisoformat(isoMatch[0])
|
||||
return (isoMatch[0], (dt + timedelta(days=31)).strftime('%Y-%m-%dT00:00:00'))
|
||||
except ValueError:
|
||||
pass
|
||||
monthMatch = re.match(r'^(\d{4})-(\d{2})$', filterStr.strip())
|
||||
if monthMatch:
|
||||
year, month = int(monthMatch.group(1)), int(monthMatch.group(2))
|
||||
start = f"{year}-{month:02d}-01T00:00:00"
|
||||
if month == 12:
|
||||
end = f"{year + 1}-01-01T00:00:00"
|
||||
else:
|
||||
end = f"{year}-{month + 1:02d}-01T00:00:00"
|
||||
return (start, end)
|
||||
return (None, None)
|
||||
|
||||
|
||||
def _toGraphUtc(isoStr: str) -> str:
|
||||
"""Normalise an ISO date/datetime to a Graph-compatible UTC string
|
||||
(always 'YYYY-MM-DDTHH:MM:SSZ')."""
|
||||
if not isoStr:
|
||||
return isoStr
|
||||
value = isoStr.strip().rstrip("Z")
|
||||
if "T" not in value:
|
||||
value = f"{value}T00:00:00"
|
||||
return f"{value}Z"
|
||||
|
||||
|
||||
def _ensureHtmlCharset(html: str) -> str:
|
||||
"""Ensure HTML body has a charset meta tag so Outlook renders UTF-8 correctly."""
|
||||
if "charset" in html.lower():
|
||||
return html
|
||||
if html.strip().lower().startswith("<html"):
|
||||
return html.replace("<html>", f"<html><head>{_CHARSET_META}</head>", 1)
|
||||
return f"<html><head>{_CHARSET_META}</head><body>{html}</body></html>"
|
||||
|
||||
|
||||
class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
||||
"""ServiceAdapter for Outlook (mail, calendar) via Microsoft Graph."""
|
||||
|
||||
|
|
@ -369,7 +316,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
if not nextLink:
|
||||
endpoint = None
|
||||
else:
|
||||
endpoint = stripGraphBase(nextLink)
|
||||
endpoint = _stripGraphBase(nextLink)
|
||||
|
||||
# Guarantee Inbox is present (well-known name, locale-independent)
|
||||
if not any((f.get("displayName") or "").lower() in ("inbox", "posteingang") for f in folders):
|
||||
|
|
@ -392,62 +339,25 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
for f in folders
|
||||
]
|
||||
|
||||
# The incoming path segment may be a display name ("MGB-Ablage"), a
|
||||
# well-known shortcut ("inbox") or an already-resolved Graph folder id.
|
||||
# Resolve it to a real id first; otherwise Graph rejects the URL with
|
||||
# 400 ErrorInvalidIdMalformed.
|
||||
folderRef = path.strip("/")
|
||||
folderId = await self._resolveFolderId(folderRef)
|
||||
if not folderId:
|
||||
raise ValueError(
|
||||
f"Outlook folder not found: '{folderRef}'. Browse the mailbox root "
|
||||
f"(path '/') or call listMailFolders to obtain a valid folder id."
|
||||
)
|
||||
folderId = path.strip("/")
|
||||
effectiveLimit = self._DEFAULT_MESSAGE_LIMIT if limit is None else max(1, min(int(limit), self._MAX_MESSAGE_LIMIT))
|
||||
pageSize = min(self._PAGE_SIZE, effectiveLimit)
|
||||
# Optional date-range filter (e.g. "2026-06" or "2026-06-01 2026-06-30")
|
||||
# so only that period is fetched server-side instead of paging the whole
|
||||
# folder. Falls back to a plain newest-first listing otherwise.
|
||||
startDateTime, endDateTime = _parseDateRange(filter)
|
||||
countParam = "&$count=true"
|
||||
if startDateTime and endDateTime:
|
||||
dateFilter = (
|
||||
f"receivedDateTime ge {_toGraphUtc(startDateTime)} and "
|
||||
f"receivedDateTime lt {_toGraphUtc(endDateTime)}"
|
||||
)
|
||||
endpoint: Optional[str] = (
|
||||
f"me/mailFolders/{folderId}/messages"
|
||||
f"?$top={pageSize}&$orderby=receivedDateTime desc"
|
||||
f"&$filter={urllib.parse.quote(dateFilter)}{countParam}"
|
||||
)
|
||||
else:
|
||||
endpoint = (
|
||||
f"me/mailFolders/{folderId}/messages"
|
||||
f"?$top={pageSize}&$orderby=receivedDateTime desc{countParam}"
|
||||
)
|
||||
endpoint: Optional[str] = (
|
||||
f"me/mailFolders/{folderId}/messages"
|
||||
f"?$top={pageSize}&$orderby=receivedDateTime desc"
|
||||
)
|
||||
messages: List[Dict[str, Any]] = []
|
||||
totalCount: Optional[int] = None
|
||||
firstPage = True
|
||||
while endpoint and len(messages) < effectiveLimit:
|
||||
result = await self._graphGet(endpoint)
|
||||
if "error" in result:
|
||||
if firstPage:
|
||||
err = result.get("error") or {}
|
||||
raise RuntimeError(
|
||||
f"Graph error listing messages in folder '{folderRef}': "
|
||||
f"{err.get('message') or err}"
|
||||
)
|
||||
break
|
||||
if firstPage and "@odata.count" in result:
|
||||
totalCount = result["@odata.count"]
|
||||
firstPage = False
|
||||
for m in result.get("value", []):
|
||||
messages.append(m)
|
||||
if len(messages) >= effectiveLimit:
|
||||
break
|
||||
nextLink = result.get("@odata.nextLink")
|
||||
endpoint = stripGraphBase(nextLink) if nextLink else None
|
||||
entries = [
|
||||
endpoint = _stripGraphBase(nextLink) if nextLink else None
|
||||
return [
|
||||
ExternalEntry(
|
||||
name=m.get("subject", "(no subject)"),
|
||||
path=f"{path}/{m.get('id', '')}",
|
||||
|
|
@ -461,16 +371,10 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
)
|
||||
for m in messages
|
||||
]
|
||||
if totalCount is not None and totalCount > len(entries):
|
||||
entries.append(ExternalEntry(
|
||||
name=f"({totalCount} total messages in folder, {len(entries)} listed)",
|
||||
path=f"{path}/_count", isFolder=False,
|
||||
metadata={"totalCount": totalCount, "listed": len(entries)},
|
||||
))
|
||||
return entries
|
||||
|
||||
async def download(self, path: str) -> DownloadResult:
|
||||
"""Download a mail message as RFC 822 EML via Graph API $value endpoint."""
|
||||
import re
|
||||
messageId = path.strip("/").split("/")[-1]
|
||||
|
||||
meta = await self._graphGet(f"me/messages/{messageId}?$select=subject")
|
||||
|
|
@ -497,28 +401,14 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
path: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> List[ExternalEntry]:
|
||||
safeQuery = query.replace('"', '\\"')
|
||||
safeQuery = query.replace("'", "''")
|
||||
effectiveLimit = self._DEFAULT_MESSAGE_LIMIT if limit is None else max(1, min(int(limit), self._MAX_MESSAGE_LIMIT))
|
||||
# Scope the search to the attached folder when one is given, so the agent
|
||||
# gets hits only from e.g. the Inbox instead of the whole mailbox. Resolve
|
||||
# the folder reference (display name / well-known / id) to a real id first.
|
||||
folderRef = (path or "").strip("/")
|
||||
base = "me/messages"
|
||||
if folderRef:
|
||||
folderId = await self._resolveFolderId(folderRef)
|
||||
if not folderId:
|
||||
raise ValueError(
|
||||
f"Outlook folder not found: '{folderRef}'. Call listMailFolders "
|
||||
f"to obtain a valid folder id, or search without a folder scope."
|
||||
)
|
||||
base = f"me/mailFolders/{folderId}/messages"
|
||||
# NOTE: Graph $search does not support $orderby and may return a single
|
||||
# page (no @odata.nextLink). We still pass $top to lift the implicit 25.
|
||||
endpoint = f"{base}?$search=\"{safeQuery}\"&$top={effectiveLimit}"
|
||||
endpoint = f"me/messages?$search=\"{safeQuery}\"&$top={effectiveLimit}"
|
||||
result = await self._graphGet(endpoint)
|
||||
if "error" in result:
|
||||
err = result.get("error") or {}
|
||||
raise RuntimeError(f"Graph error searching mail: {err.get('message') or err}")
|
||||
return []
|
||||
return [
|
||||
ExternalEntry(
|
||||
name=m.get("subject", "(no subject)"),
|
||||
|
|
@ -543,12 +433,9 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
|
||||
attachments: list of {"name": str, "contentBytes": str (base64), "contentType": str}
|
||||
"""
|
||||
content = body
|
||||
if bodyType.upper() == "HTML":
|
||||
content = _ensureHtmlCharset(body)
|
||||
message: Dict[str, Any] = {
|
||||
"subject": subject,
|
||||
"body": {"contentType": bodyType, "content": content},
|
||||
"body": {"contentType": bodyType, "content": body},
|
||||
"toRecipients": [{"emailAddress": {"address": addr}} for addr in to],
|
||||
}
|
||||
if cc:
|
||||
|
|
@ -572,6 +459,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
attachments: Optional[List[Dict]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Send an email via Microsoft Graph. bodyType: 'Text' or 'HTML'."""
|
||||
import json
|
||||
message = self._buildMessage(to, subject, body, bodyType, cc, attachments)
|
||||
payload = json.dumps({"message": message, "saveToSentItems": True}).encode("utf-8")
|
||||
result = await self._graphPost("me/sendMail", payload)
|
||||
|
|
@ -586,6 +474,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
attachments: Optional[List[Dict]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a draft email in the user's Drafts folder via Microsoft Graph."""
|
||||
import json
|
||||
message = self._buildMessage(to, subject, body, bodyType, cc, attachments)
|
||||
payload = json.dumps(message).encode("utf-8")
|
||||
result = await self._graphPost("me/messages", payload)
|
||||
|
|
@ -615,6 +504,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
Preserves the conversation thread and the ``AW:`` prefix in Outlook --
|
||||
unlike sendMail() which creates a brand-new conversation.
|
||||
"""
|
||||
import json
|
||||
endpointAction = "replyAll" if replyAll else "reply"
|
||||
payload = json.dumps({"comment": comment}).encode("utf-8")
|
||||
result = await self._graphPost(f"me/messages/{messageId}/{endpointAction}", payload)
|
||||
|
|
@ -626,6 +516,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
self, messageId: str, to: List[str], comment: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
"""Forward an existing message to new recipients."""
|
||||
import json
|
||||
payload = json.dumps({
|
||||
"comment": comment,
|
||||
"toRecipients": [{"emailAddress": {"address": addr}} for addr in to],
|
||||
|
|
@ -640,6 +531,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
replyAll: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a reply-draft (in the Drafts folder) that the user can edit before sending."""
|
||||
import json
|
||||
endpointAction = "createReplyAll" if replyAll else "createReply"
|
||||
payload = json.dumps({"comment": comment}).encode("utf-8") if comment else b"{}"
|
||||
result = await self._graphPost(f"me/messages/{messageId}/{endpointAction}", payload)
|
||||
|
|
@ -651,6 +543,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
self, messageId: str, to: Optional[List[str]] = None, comment: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a forward-draft (in the Drafts folder) that the user can edit before sending."""
|
||||
import json
|
||||
body: Dict[str, Any] = {}
|
||||
if comment:
|
||||
body["comment"] = comment
|
||||
|
|
@ -721,7 +614,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
"childFolderCount": f.get("childFolderCount", 0),
|
||||
})
|
||||
nextLink = result.get("@odata.nextLink")
|
||||
endpoint = stripGraphBase(nextLink) if nextLink else None
|
||||
endpoint = _stripGraphBase(nextLink) if nextLink else None
|
||||
return folders
|
||||
|
||||
async def _resolveFolderId(self, folderRef: str) -> Optional[str]:
|
||||
|
|
@ -758,6 +651,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
self, messageId: str, destinationFolder: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Move a message to another folder (well-known name, displayName, or folder id)."""
|
||||
import json
|
||||
destId = await self._resolveFolderId(destinationFolder)
|
||||
if not destId:
|
||||
return {"error": f"Folder not found: '{destinationFolder}'. Use listMailFolders to inspect available folders."}
|
||||
|
|
@ -771,6 +665,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
self, messageId: str, destinationFolder: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Copy a message into another folder (original stays in place)."""
|
||||
import json
|
||||
destId = await self._resolveFolderId(destinationFolder)
|
||||
if not destId:
|
||||
return {"error": f"Folder not found: '{destinationFolder}'. Use listMailFolders to inspect available folders."}
|
||||
|
|
@ -810,6 +705,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
|
||||
async def markMailAsRead(self, messageId: str) -> Dict[str, Any]:
|
||||
"""Mark a message as read (sets ``isRead=true``)."""
|
||||
import json
|
||||
payload = json.dumps({"isRead": True}).encode("utf-8")
|
||||
result = await self._graphPatch(f"me/messages/{messageId}", payload)
|
||||
if "error" in result:
|
||||
|
|
@ -818,6 +714,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
|
||||
async def markMailAsUnread(self, messageId: str) -> Dict[str, Any]:
|
||||
"""Mark a message as unread (sets ``isRead=false``)."""
|
||||
import json
|
||||
payload = json.dumps({"isRead": False}).encode("utf-8")
|
||||
result = await self._graphPatch(f"me/messages/{messageId}", payload)
|
||||
if "error" in result:
|
||||
|
|
@ -835,6 +732,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
``"notFlagged"`` -- the three values Microsoft Graph recognises for
|
||||
``followupFlag.flagStatus``.
|
||||
"""
|
||||
import json
|
||||
if flagStatus not in ("flagged", "complete", "notFlagged"):
|
||||
return {"error": f"Invalid flagStatus '{flagStatus}'. Use one of: flagged, complete, notFlagged."}
|
||||
payload = json.dumps({"flag": {"flagStatus": flagStatus}}).encode("utf-8")
|
||||
|
|
@ -862,7 +760,8 @@ class TeamsAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
if not cleanPath:
|
||||
result = await self._graphGet("me/joinedTeams")
|
||||
if "error" in result:
|
||||
_raiseGraphError(result, "Teams browse")
|
||||
logger.warning(f"Teams browse failed: {result['error']}")
|
||||
return []
|
||||
return [
|
||||
ExternalEntry(
|
||||
name=t.get("displayName", ""),
|
||||
|
|
@ -878,7 +777,7 @@ class TeamsAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
if len(parts) == 1:
|
||||
result = await self._graphGet(f"teams/{teamId}/channels")
|
||||
if "error" in result:
|
||||
_raiseGraphError(result, "Teams channels")
|
||||
return []
|
||||
return [
|
||||
ExternalEntry(
|
||||
name=ch.get("displayName", ""),
|
||||
|
|
@ -921,33 +820,18 @@ class OneDriveAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
) -> List[ExternalEntry]:
|
||||
cleanPath = (path or "").strip("/")
|
||||
if not cleanPath:
|
||||
endpoint: Optional[str] = "me/drive/root/children?$top=200"
|
||||
endpoint = "me/drive/root/children"
|
||||
else:
|
||||
endpoint = f"me/drive/root:/{cleanPath}:/children?$top=200"
|
||||
endpoint = f"me/drive/root:/{cleanPath}:/children"
|
||||
|
||||
effectiveLimit = int(limit) if limit is not None else None
|
||||
items: List[Dict[str, Any]] = []
|
||||
hardCap = 5000
|
||||
while endpoint and len(items) < hardCap:
|
||||
result = await self._graphGet(endpoint)
|
||||
if "error" in result:
|
||||
if not items:
|
||||
_raiseGraphError(result, "OneDrive browse")
|
||||
break
|
||||
for raw in result.get("value", []) or []:
|
||||
items.append(raw)
|
||||
if effectiveLimit is not None and len(items) >= effectiveLimit:
|
||||
break
|
||||
if effectiveLimit is not None and len(items) >= effectiveLimit:
|
||||
break
|
||||
nextLink = result.get("@odata.nextLink")
|
||||
endpoint = stripGraphBase(nextLink) if nextLink else None
|
||||
|
||||
entries = [_graphItemToExternalEntry(item, path) for item in items]
|
||||
result = await self._graphGet(endpoint)
|
||||
if "error" in result:
|
||||
return []
|
||||
entries = [_graphItemToExternalEntry(item, path) for item in result.get("value", [])]
|
||||
if filter:
|
||||
entries = [e for e in entries if _matchFilter(e, filter)]
|
||||
if effectiveLimit is not None:
|
||||
entries = entries[: max(1, effectiveLimit)]
|
||||
if limit is not None:
|
||||
entries = entries[: max(1, int(limit))]
|
||||
return entries
|
||||
|
||||
async def download(self, path: str) -> bytes:
|
||||
|
|
@ -970,32 +854,13 @@ class OneDriveAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
limit: Optional[int] = None,
|
||||
) -> List[ExternalEntry]:
|
||||
safeQuery = query.replace("'", "''")
|
||||
cleanPath = (path or "").strip("/")
|
||||
# Scope to the attached folder if given, otherwise search the whole drive.
|
||||
if cleanPath:
|
||||
endpoint: Optional[str] = f"me/drive/root:/{cleanPath}:/search(q='{safeQuery}')?$top=200"
|
||||
else:
|
||||
endpoint = f"me/drive/root/search(q='{safeQuery}')?$top=200"
|
||||
effectiveLimit = int(limit) if limit is not None else None
|
||||
items: List[Dict[str, Any]] = []
|
||||
hardCap = 1000
|
||||
while endpoint and len(items) < hardCap:
|
||||
result = await self._graphGet(endpoint)
|
||||
if "error" in result:
|
||||
if not items:
|
||||
_raiseGraphError(result, "OneDrive search")
|
||||
break
|
||||
for raw in result.get("value", []) or []:
|
||||
items.append(raw)
|
||||
if effectiveLimit is not None and len(items) >= effectiveLimit:
|
||||
break
|
||||
if effectiveLimit is not None and len(items) >= effectiveLimit:
|
||||
break
|
||||
nextLink = result.get("@odata.nextLink")
|
||||
endpoint = stripGraphBase(nextLink) if nextLink else None
|
||||
entries = [_graphItemToExternalEntry(item) for item in items]
|
||||
if effectiveLimit is not None:
|
||||
entries = entries[: max(1, effectiveLimit)]
|
||||
endpoint = f"me/drive/root/search(q='{safeQuery}')"
|
||||
result = await self._graphGet(endpoint)
|
||||
if "error" in result:
|
||||
return []
|
||||
entries = [_graphItemToExternalEntry(item) for item in result.get("value", [])]
|
||||
if limit is not None:
|
||||
entries = entries[: max(1, int(limit))]
|
||||
return entries
|
||||
|
||||
|
||||
|
|
@ -1029,7 +894,8 @@ class CalendarAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
if not cleanPath:
|
||||
result = await self._graphGet("me/calendars?$top=100")
|
||||
if "error" in result:
|
||||
_raiseGraphError(result, "MSFT Calendar list")
|
||||
logger.warning(f"MSFT Calendar list failed: {result['error']}")
|
||||
return []
|
||||
calendars = result.get("value", [])
|
||||
if filter:
|
||||
calendars = [c for c in calendars if filter.lower() in (c.get("name") or "").lower()]
|
||||
|
|
@ -1049,46 +915,25 @@ class CalendarAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
for c in calendars
|
||||
]
|
||||
|
||||
# The path segment may be a calendar display name or an already-resolved
|
||||
# calendar id; resolve first so a name does not produce a malformed URL.
|
||||
calendarRef = cleanPath.split("/", 1)[0]
|
||||
calendarId = await self._resolveCalendarId(calendarRef)
|
||||
if not calendarId:
|
||||
raise ValueError(
|
||||
f"Calendar not found: '{calendarRef}'. Browse the root ('/') to list "
|
||||
f"calendars and use the returned id."
|
||||
)
|
||||
calendarId = cleanPath.split("/", 1)[0]
|
||||
effectiveLimit = self._DEFAULT_EVENT_LIMIT if limit is None else max(1, min(int(limit), self._MAX_EVENT_LIMIT))
|
||||
pageSize = min(self._PAGE_SIZE, effectiveLimit)
|
||||
|
||||
startDateTime, endDateTime = self._parseDateRange(filter)
|
||||
if startDateTime and endDateTime:
|
||||
endpoint: Optional[str] = (
|
||||
f"me/calendars/{calendarId}/calendarView"
|
||||
f"?startDateTime={startDateTime}&endDateTime={endDateTime}"
|
||||
f"&$top={pageSize}&$orderby=start/dateTime"
|
||||
f"&$select=id,subject,start,end,location,organizer,isAllDay,webLink"
|
||||
)
|
||||
else:
|
||||
endpoint = (
|
||||
f"me/calendars/{calendarId}/events"
|
||||
f"?$top={pageSize}&$orderby=start/dateTime desc"
|
||||
f"&$select=id,subject,start,end,location,organizer,isAllDay,webLink"
|
||||
)
|
||||
|
||||
endpoint: Optional[str] = (
|
||||
f"me/calendars/{calendarId}/events"
|
||||
f"?$top={pageSize}&$orderby=start/dateTime desc"
|
||||
)
|
||||
events: List[Dict[str, Any]] = []
|
||||
while endpoint and len(events) < effectiveLimit:
|
||||
result = await self._graphGet(endpoint)
|
||||
if "error" in result:
|
||||
if not events:
|
||||
_raiseGraphError(result, "MSFT Calendar events")
|
||||
logger.warning(f"MSFT Calendar events failed: {result['error']}")
|
||||
break
|
||||
for ev in result.get("value", []):
|
||||
events.append(ev)
|
||||
if len(events) >= effectiveLimit:
|
||||
break
|
||||
nextLink = result.get("@odata.nextLink")
|
||||
endpoint = stripGraphBase(nextLink) if nextLink else None
|
||||
endpoint = _stripGraphBase(nextLink) if nextLink else None
|
||||
|
||||
return [
|
||||
ExternalEntry(
|
||||
|
|
@ -1109,35 +954,6 @@ class CalendarAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
for ev in events
|
||||
]
|
||||
|
||||
async def _resolveCalendarId(self, ref: str) -> Optional[str]:
|
||||
"""Resolve a calendar reference (display name / 'default' / id) to a Graph
|
||||
calendar id. Returns None if nothing matches."""
|
||||
if not ref:
|
||||
return None
|
||||
r = ref.strip()
|
||||
# Heuristic: Graph ids are long URL-safe strings without spaces.
|
||||
if len(r) > 60 and " " not in r:
|
||||
return r
|
||||
result = await self._graphGet("me/calendars?$top=100")
|
||||
if "error" in result:
|
||||
_raiseGraphError(result, "MSFT Calendar list")
|
||||
cals = result.get("value", [])
|
||||
for c in cals:
|
||||
if c.get("id") == r:
|
||||
return r
|
||||
if r.lower() in ("default", "primary", "calendar", "kalender"):
|
||||
for c in cals:
|
||||
if c.get("isDefaultCalendar"):
|
||||
return c.get("id")
|
||||
for c in cals:
|
||||
if (c.get("name") or "").strip().lower() == r.lower():
|
||||
return c.get("id")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _parseDateRange(filterStr: Optional[str]) -> tuple:
|
||||
return _parseDateRange(filterStr)
|
||||
|
||||
async def download(self, path: str) -> DownloadResult:
|
||||
cleanPath = (path or "").strip("/")
|
||||
if "/" not in cleanPath:
|
||||
|
|
@ -1165,37 +981,22 @@ class CalendarAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
path: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> List[ExternalEntry]:
|
||||
safeQuery = query.replace("'", "''")
|
||||
effectiveLimit = self._DEFAULT_EVENT_LIMIT if limit is None else max(1, min(int(limit), self._MAX_EVENT_LIMIT))
|
||||
|
||||
startDateTime, endDateTime = self._parseDateRange(query)
|
||||
if startDateTime and endDateTime:
|
||||
endpoint = (
|
||||
f"me/calendarView"
|
||||
f"?startDateTime={startDateTime}&endDateTime={endDateTime}"
|
||||
f"&$top={effectiveLimit}&$orderby=start/dateTime"
|
||||
f"&$select=id,subject,start,end,location,organizer,isAllDay"
|
||||
)
|
||||
else:
|
||||
safeQuery = query.replace("'", "''").replace('"', '\\"')
|
||||
endpoint = f'me/events?$search="{safeQuery}"&$top={effectiveLimit}&$select=id,subject,start,end,location,organizer,isAllDay'
|
||||
|
||||
endpoint = f"me/events?$search=\"{safeQuery}\"&$top={effectiveLimit}"
|
||||
result = await self._graphGet(endpoint)
|
||||
if "error" in result:
|
||||
_raiseGraphError(result, "MSFT Calendar search")
|
||||
calendarId = (path or "").strip("/").split("/")[0] if path else "search"
|
||||
return []
|
||||
return [
|
||||
ExternalEntry(
|
||||
name=ev.get("subject", "(no subject)"),
|
||||
path=f"/{calendarId}/{ev.get('id', '')}",
|
||||
path=f"/search/{ev.get('id', '')}",
|
||||
isFolder=False,
|
||||
mimeType="text/calendar",
|
||||
metadata={
|
||||
"id": ev.get("id"),
|
||||
"start": (ev.get("start") or {}).get("dateTime"),
|
||||
"end": (ev.get("end") or {}).get("dateTime"),
|
||||
"location": (ev.get("location") or {}).get("displayName"),
|
||||
"organizer": (ev.get("organizer") or {}).get("emailAddress", {}).get("address"),
|
||||
"isAllDay": ev.get("isAllDay", False),
|
||||
},
|
||||
)
|
||||
for ev in result.get("value", [])
|
||||
|
|
@ -1257,15 +1058,7 @@ class ContactsAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
logger.warning(f"MSFT contactFolders list failed: {result['error']}")
|
||||
return folders
|
||||
|
||||
# The path segment may be a contact-folder display name or an already-
|
||||
# resolved folder id (or the virtual 'default'); resolve first.
|
||||
folderRef = cleanPath.split("/", 1)[0]
|
||||
folderId = await self._resolveContactFolderId(folderRef)
|
||||
if not folderId:
|
||||
raise ValueError(
|
||||
f"Contact folder not found: '{folderRef}'. Browse the root ('/') to "
|
||||
f"list folders and use the returned id."
|
||||
)
|
||||
folderId = cleanPath.split("/", 1)[0]
|
||||
effectiveLimit = self._DEFAULT_CONTACT_LIMIT if limit is None else max(1, min(int(limit), self._MAX_CONTACT_LIMIT))
|
||||
pageSize = min(self._PAGE_SIZE, effectiveLimit)
|
||||
if folderId == self._DEFAULT_FOLDER_ID:
|
||||
|
|
@ -1277,15 +1070,14 @@ class ContactsAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
while endpoint and len(contacts) < effectiveLimit:
|
||||
result = await self._graphGet(endpoint)
|
||||
if "error" in result:
|
||||
if not contacts:
|
||||
_raiseGraphError(result, "MSFT contacts list")
|
||||
logger.warning(f"MSFT contacts list failed: {result['error']}")
|
||||
break
|
||||
for c in result.get("value", []):
|
||||
contacts.append(c)
|
||||
if len(contacts) >= effectiveLimit:
|
||||
break
|
||||
nextLink = result.get("@odata.nextLink")
|
||||
endpoint = stripGraphBase(nextLink) if nextLink else None
|
||||
endpoint = _stripGraphBase(nextLink) if nextLink else None
|
||||
|
||||
return [
|
||||
ExternalEntry(
|
||||
|
|
@ -1306,28 +1098,6 @@ class ContactsAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
for c in contacts
|
||||
]
|
||||
|
||||
async def _resolveContactFolderId(self, ref: str) -> Optional[str]:
|
||||
"""Resolve a contact-folder reference (display name / 'default' / id) to a
|
||||
folder id. Returns None if nothing matches."""
|
||||
if not ref:
|
||||
return None
|
||||
r = ref.strip()
|
||||
if r == self._DEFAULT_FOLDER_ID or r.lower() in ("kontakte", "contacts", "default"):
|
||||
return self._DEFAULT_FOLDER_ID
|
||||
# Heuristic: Graph ids are long URL-safe strings without spaces.
|
||||
if len(r) > 60 and " " not in r:
|
||||
return r
|
||||
result = await self._graphGet("me/contactFolders?$top=100")
|
||||
if "error" in result:
|
||||
_raiseGraphError(result, "MSFT contactFolders list")
|
||||
for f in result.get("value", []):
|
||||
if f.get("id") == r:
|
||||
return r
|
||||
for f in result.get("value", []):
|
||||
if (f.get("displayName") or "").strip().lower() == r.lower():
|
||||
return f.get("id")
|
||||
return None
|
||||
|
||||
async def download(self, path: str) -> DownloadResult:
|
||||
cleanPath = (path or "").strip("/")
|
||||
if "/" not in cleanPath:
|
||||
|
|
@ -1355,27 +1125,19 @@ class ContactsAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
path: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> List[ExternalEntry]:
|
||||
safeQuery = query.replace('"', '\\"')
|
||||
safeQuery = query.replace("'", "''")
|
||||
effectiveLimit = self._DEFAULT_CONTACT_LIMIT if limit is None else max(1, min(int(limit), self._MAX_CONTACT_LIMIT))
|
||||
endpoint = f"me/contacts?$search=\"{safeQuery}\"&$top={effectiveLimit}"
|
||||
result = await self._graphGet(endpoint)
|
||||
if "error" in result:
|
||||
_raiseGraphError(result, "MSFT contacts search")
|
||||
return []
|
||||
return [
|
||||
ExternalEntry(
|
||||
name=c.get("displayName") or _personLabel(c) or "(no name)",
|
||||
path=f"/search/{c.get('id', '')}",
|
||||
isFolder=False,
|
||||
mimeType="text/vcard",
|
||||
metadata={
|
||||
"id": c.get("id"),
|
||||
"givenName": c.get("givenName"),
|
||||
"surname": c.get("surname"),
|
||||
"companyName": c.get("companyName"),
|
||||
"emailAddresses": [e.get("address") for e in (c.get("emailAddresses") or []) if e.get("address")],
|
||||
"businessPhones": c.get("businessPhones") or [],
|
||||
"mobilePhone": c.get("mobilePhone"),
|
||||
},
|
||||
metadata={"id": c.get("id")},
|
||||
)
|
||||
for c in result.get("value", [])
|
||||
]
|
||||
|
|
@ -1437,6 +1199,7 @@ def _matchFilter(entry: ExternalEntry, pattern: str) -> bool:
|
|||
|
||||
def _safeFileName(name: str) -> str:
|
||||
"""Strip path-unsafe characters and trim length so the result is a usable file name."""
|
||||
import re
|
||||
return re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name or "")[:80].strip(". ")
|
||||
|
||||
|
||||
|
|
@ -1466,6 +1229,7 @@ def _icsDateTime(value: Optional[str]) -> Optional[str]:
|
|||
"""Convert an ISO datetime string to an RFC 5545 DATE-TIME value (UTC)."""
|
||||
if not value:
|
||||
return None
|
||||
from datetime import datetime, timezone
|
||||
try:
|
||||
normalized = value.replace("Z", "+00:00") if value.endswith("Z") else value
|
||||
dt = datetime.fromisoformat(normalized)
|
||||
|
|
@ -1478,6 +1242,7 @@ def _icsDateTime(value: Optional[str]) -> Optional[str]:
|
|||
|
||||
def _eventToIcs(event: Dict[str, Any]) -> bytes:
|
||||
"""Build a minimal RFC 5545 VCALENDAR/VEVENT for a Graph event payload."""
|
||||
from datetime import datetime, timezone
|
||||
uid = event.get("iCalUId") or event.get("id") or "unknown@poweron"
|
||||
summary = _icsEscape(event.get("subject") or "")
|
||||
location = _icsEscape((event.get("location") or {}).get("displayName") or "")
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Unified modules.datamodels package.
|
||||
|
|
@ -13,5 +13,4 @@ from . import datamodelSecurity as security
|
|||
from . import datamodelChat as chat
|
||||
from . import datamodelFiles as files
|
||||
from . import datamodelVoice as voice
|
||||
from . import datamodelUtils as utils
|
||||
from . import jsonContinuation
|
||||
from . import datamodelUtils as utils
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
from typing import Optional, List, Dict, Any, Callable, TYPE_CHECKING, Tuple
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
|
|
@ -245,10 +245,11 @@ class AiCallPromptWebCrawl(BaseModel):
|
|||
|
||||
class AiCallPromptImage(BaseModel):
|
||||
"""Structured prompt format for image generation."""
|
||||
|
||||
|
||||
prompt: str = Field(description="Text description of the image to generate")
|
||||
size: Optional[str] = Field(default="1024x1024", description="Image size (1024x1024, 1536x1024, 1024x1536)")
|
||||
quality: Optional[str] = Field(default="auto", description="Image quality (auto, high, medium, low)")
|
||||
size: Optional[str] = Field(default="1024x1024", description="Image size (1024x1024, 1792x1024, 1024x1792)")
|
||||
quality: Optional[str] = Field(default="standard", description="Image quality (standard, hd)")
|
||||
style: Optional[str] = Field(default="vivid", description="Image style (vivid, natural)")
|
||||
|
||||
|
||||
class AiProcessParameters(BaseModel):
|
||||
|
|
@ -351,4 +352,4 @@ class CodeContentPromptArgs(BaseModel):
|
|||
class CodeStructurePromptArgs(BaseModel):
|
||||
"""Type-safe arguments for code structure prompt builder."""
|
||||
userPrompt: str
|
||||
contentParts: List[ContentPart] = Field(default_factory=list)
|
||||
contentParts: List[ContentPart] = Field(default_factory=list)
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""AI Audit Log data model for Compliance & AI-Datenfluss tracking.
|
||||
|
||||
|
|
@ -9,15 +9,14 @@ for compliance, audit, and data-protection reporting.
|
|||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from modules.datamodels.datamodelBase import PowerOnModel
|
||||
from modules.shared.i18nRegistry import i18nModel
|
||||
from modules.shared.timeUtils import getUtcTimestamp
|
||||
|
||||
|
||||
@i18nModel("AI-Audit-Eintrag")
|
||||
class AiAuditLogEntry(PowerOnModel):
|
||||
class AiAuditLogEntry(BaseModel):
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
description="Primary key",
|
||||
|
|
@ -35,7 +34,7 @@ class AiAuditLogEntry(PowerOnModel):
|
|||
|
||||
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", "softFk": True}},
|
||||
json_schema_extra={"label": "Benutzer-ID", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
|
||||
)
|
||||
username: Optional[str] = Field(
|
||||
default=None,
|
||||
|
|
@ -44,17 +43,17 @@ class AiAuditLogEntry(PowerOnModel):
|
|||
)
|
||||
mandateId: str = Field(
|
||||
description="Mandate context of the call",
|
||||
json_schema_extra={"label": "Mandanten-ID", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label", "softFk": True}},
|
||||
json_schema_extra={"label": "Mandanten-ID", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}},
|
||||
)
|
||||
featureInstanceId: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Feature instance context",
|
||||
json_schema_extra={"label": "Feature-Instanz-ID", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label", "softFk": True}},
|
||||
json_schema_extra={"label": "Feature-Instanz-ID", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
|
||||
)
|
||||
featureCode: Optional[str] = Field(
|
||||
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", "softFk": True}},
|
||||
json_schema_extra={"label": "Feature", "fk_target": {"db": "poweron_app", "table": "Feature", "column": "code", "labelField": "code"}},
|
||||
)
|
||||
instanceLabel: Optional[str] = Field(
|
||||
default=None,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Audit Log Data Model for database-based audit logging.
|
||||
|
|
@ -19,7 +19,6 @@ 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
|
||||
|
||||
|
|
@ -84,7 +83,7 @@ class AuditAction(str, Enum):
|
|||
|
||||
|
||||
@i18nModel("Audit-Log-Eintrag")
|
||||
class AuditLogEntry(PowerOnModel):
|
||||
class AuditLogEntry(BaseModel):
|
||||
"""
|
||||
Audit log entry for database storage.
|
||||
|
||||
|
|
@ -112,7 +111,7 @@ class AuditLogEntry(PowerOnModel):
|
|||
"frontend_type": "text",
|
||||
"frontend_readonly": True,
|
||||
"frontend_required": True,
|
||||
"fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username", "softFk": True},
|
||||
"fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"},
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -131,7 +130,7 @@ class AuditLogEntry(PowerOnModel):
|
|||
"frontend_type": "text",
|
||||
"frontend_readonly": True,
|
||||
"frontend_required": False,
|
||||
"fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label", "softFk": True},
|
||||
"fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"},
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -143,7 +142,7 @@ class AuditLogEntry(PowerOnModel):
|
|||
"frontend_type": "text",
|
||||
"frontend_readonly": True,
|
||||
"frontend_required": False,
|
||||
"fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label", "softFk": True},
|
||||
"fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"},
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Background job models: generic, reusable infrastructure for long-running tasks.
|
||||
|
||||
|
|
@ -96,17 +96,6 @@ class BackgroundJob(PowerOnModel):
|
|||
description="Human-readable current step (e.g. 'Importing journal entries...')",
|
||||
json_schema_extra={"label": "Fortschritts-Nachricht"},
|
||||
)
|
||||
progressMessageData: Optional[Dict[str, Any]] = Field(
|
||||
None,
|
||||
description=(
|
||||
"Structured i18n payload for `progressMessage`. Shape: "
|
||||
"{'key': '<de-text-with-{placeholders}>', 'params': {...}}. "
|
||||
"Frontend renders via `t(key, params)`; older clients fall back "
|
||||
"to `progressMessage`. Single source of truth — keep `progressMessage` "
|
||||
"as the rendered fallback in the producing language."
|
||||
),
|
||||
json_schema_extra={"label": "Fortschritts-Nachricht (i18n)"},
|
||||
)
|
||||
|
||||
payload: Dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Base Pydantic model with system-managed fields (DB + API + UI metadata)."""
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Billing models: BillingAccount, BillingTransaction, BillingSettings, UsageStatistics."""
|
||||
|
||||
|
|
@ -123,7 +123,7 @@ class BillingTransaction(PowerOnModel):
|
|||
|
||||
|
||||
@i18nModel("Abrechnungseinstellungen")
|
||||
class BillingSettings(PowerOnModel):
|
||||
class BillingSettings(BaseModel):
|
||||
"""Billing settings per mandate. Only PREPAY_MANDATE model."""
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
|
|
@ -186,7 +186,7 @@ class BillingSettings(PowerOnModel):
|
|||
)
|
||||
|
||||
|
||||
class StripeWebhookEvent(PowerOnModel):
|
||||
class StripeWebhookEvent(BaseModel):
|
||||
"""Stores processed Stripe webhook event IDs for idempotency."""
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
|
|
@ -201,7 +201,7 @@ class StripeWebhookEvent(PowerOnModel):
|
|||
|
||||
|
||||
@i18nModel("Nutzungsstatistik")
|
||||
class UsageStatistics(PowerOnModel):
|
||||
class UsageStatistics(BaseModel):
|
||||
"""Aggregated usage statistics for quick retrieval."""
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Chat models: ChatWorkflow, ChatMessage, ChatLog, ChatDocument."""
|
||||
|
||||
|
|
@ -111,6 +111,7 @@ class ChatMessage(PowerOnModel):
|
|||
class WorkflowModeEnum(str, Enum):
|
||||
WORKFLOW_DYNAMIC = "Dynamic"
|
||||
WORKFLOW_AUTOMATION = "Automation"
|
||||
WORKFLOW_CHATBOT = "Chatbot"
|
||||
|
||||
@i18nModel("Chat-Workflow")
|
||||
class ChatWorkflow(PowerOnModel):
|
||||
|
|
@ -131,7 +132,7 @@ class ChatWorkflow(PowerOnModel):
|
|||
None,
|
||||
description=(
|
||||
"Optional foreign key linking this chat to an entity outside the "
|
||||
"ChatWorkflow table (e.g. an Automation2Workflow in WorkflowAutomation "
|
||||
"ChatWorkflow table (e.g. an Automation2Workflow in the GraphicalEditor "
|
||||
"AI editor chat). NULL for the default workspace chats. Combined with "
|
||||
"featureInstanceId this gives a 1:1 relation entity ↔ chat per feature."
|
||||
),
|
||||
|
|
@ -168,6 +169,10 @@ class ChatWorkflow(PowerOnModel):
|
|||
"value": WorkflowModeEnum.WORKFLOW_AUTOMATION.value,
|
||||
"label": "Automation",
|
||||
},
|
||||
{
|
||||
"value": WorkflowModeEnum.WORKFLOW_CHATBOT.value,
|
||||
"label": "Chatbot",
|
||||
},
|
||||
]})
|
||||
maxSteps: int = Field(default=10, description="Maximum number of iterations in dynamic mode", json_schema_extra={"label": "Max. Schritte", "frontend_type": "integer", "frontend_readonly": False, "frontend_required": False})
|
||||
expectedFormats: Optional[List[str]] = Field(None, description="List of expected file format extensions from user request (e.g., ['xlsx', 'pdf']). Extracted during intent analysis.", json_schema_extra={"label": "Erwartete Formate", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||
|
|
@ -314,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(PowerOnModel):
|
||||
class ActionItem(BaseModel):
|
||||
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"})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Content Object data models for the container and content extraction pipeline.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""DataSource and ExternalEntry models for external data integration.
|
||||
|
||||
|
|
@ -62,14 +62,9 @@ class DataSource(PowerOnModel):
|
|||
description="Owner user ID",
|
||||
json_schema_extra={"label": "Benutzer-ID", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
|
||||
)
|
||||
ragIndexEnabled: Optional[bool] = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Three-state RAG indexing flag with cascade-inherit semantics. "
|
||||
"None = inherit from nearest ancestor DataSource (path-traversal); "
|
||||
"True/False = explicit override that propagates to descendants. "
|
||||
"Walker computes effective value via getEffectiveFlag()."
|
||||
),
|
||||
ragIndexEnabled: bool = Field(
|
||||
default=False,
|
||||
description="When true this tree element is indexed into the RAG knowledge store",
|
||||
json_schema_extra={"label": "Im RAG indexieren", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False},
|
||||
)
|
||||
lastIndexed: Optional[float] = Field(
|
||||
|
|
@ -77,34 +72,21 @@ class DataSource(PowerOnModel):
|
|||
description="Timestamp of last successful RAG indexing run",
|
||||
json_schema_extra={"label": "Letzte Indexierung", "frontend_type": "timestamp"},
|
||||
)
|
||||
# scope was removed (privacy, 2026-06). Personal sources must not be
|
||||
# shared across scopes. Only Files (folder-files) retain scope.
|
||||
# The DB column is kept as deprecated-nullable to avoid a migration;
|
||||
# it is never read or written by UDB/ingest/knowledge anymore.
|
||||
scope: Optional[str] = Field(
|
||||
default=None,
|
||||
description="DEPRECATED (2026-06, privacy). Always None. Use Files scope instead.",
|
||||
json_schema_extra={"frontend_readonly": True, "frontend_hidden": True},
|
||||
scope: str = Field(
|
||||
default="personal",
|
||||
description="Data visibility scope: personal, featureInstance, mandate, global",
|
||||
json_schema_extra={"label": "Sichtbarkeit", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
|
||||
{"value": "personal", "label": "Persönlich"},
|
||||
{"value": "featureInstance", "label": "Feature-Instanz"},
|
||||
{"value": "mandate", "label": "Mandant"},
|
||||
{"value": "global", "label": "Global"},
|
||||
]},
|
||||
)
|
||||
neutralize: Optional[bool] = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Three-state neutralization flag with cascade-inherit semantics. "
|
||||
"None = inherit from nearest ancestor DataSource (path-traversal); "
|
||||
"True/False = explicit override that propagates to descendants."
|
||||
),
|
||||
neutralize: bool = Field(
|
||||
default=False,
|
||||
description="Whether this data source should be neutralized before AI processing",
|
||||
json_schema_extra={"label": "Neutralisieren", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False},
|
||||
)
|
||||
settings: Optional[Dict[str, Any]] = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"DataSource-scoped settings (JSON). Currently used keys: "
|
||||
"ragLimits.{maxBytes,maxFileSize,maxItems,maxDepth}. "
|
||||
"Walker reads these directly; missing keys fall back to RAG_LIMITS_DEFAULT "
|
||||
"and are lazily persisted on next bootstrap."
|
||||
),
|
||||
json_schema_extra={"label": "Einstellungen", "frontend_type": "json", "frontend_readonly": True, "frontend_required": False},
|
||||
)
|
||||
|
||||
|
||||
class ExternalEntry(BaseModel):
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Document reference models for typed document references in workflows.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
from typing import Any, Dict, List, Optional, Literal, Union
|
||||
from pydantic import BaseModel, Field, field_serializer
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
from typing import Any, Dict, List, Optional, Literal
|
||||
from pydantic import BaseModel, Field
|
||||
|
|
@ -112,4 +112,4 @@ class ExtractionOptions(BaseModel):
|
|||
|
||||
# Additional processing options
|
||||
enableParallelProcessing: bool = Field(default=True, description="Enable parallel processing of chunks")
|
||||
maxConcurrentChunks: int = Field(default=5, ge=1, le=20, description="Maximum number of chunks to process concurrently")
|
||||
maxConcurrentChunks: int = Field(default=5, ge=1, le=20, description="Maximum number of chunks to process concurrently")
|
||||
82
modules/datamodels/datamodelFeatureDataSource.py
Normal file
82
modules/datamodels/datamodelFeatureDataSource.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""FeatureDataSource model for exposing feature instance data to the AI workspace.
|
||||
|
||||
A FeatureDataSource links a FeatureInstance table (DATA_OBJECT) to a workspace
|
||||
so the agent can query structured feature data (e.g. TrusteePosition rows).
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from modules.datamodels.datamodelBase import PowerOnModel
|
||||
from modules.shared.i18nRegistry import i18nModel
|
||||
import uuid
|
||||
|
||||
|
||||
@i18nModel("Feature-Datenquelle")
|
||||
class FeatureDataSource(PowerOnModel):
|
||||
"""Feature-Instanz-Tabelle als Datenquelle im AI-Workspace."""
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
description="Primary key",
|
||||
json_schema_extra={"label": "ID"},
|
||||
)
|
||||
featureInstanceId: str = Field(
|
||||
description="FK to FeatureInstance",
|
||||
json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
|
||||
)
|
||||
featureCode: str = Field(
|
||||
description="Feature code (e.g. trustee, commcoach)",
|
||||
json_schema_extra={"label": "Feature", "fk_target": {"db": "poweron_app", "table": "Feature", "column": "code", "labelField": "code"}},
|
||||
)
|
||||
tableName: str = Field(
|
||||
description="Table name from DATA_OBJECTS meta (e.g. TrusteePosition)",
|
||||
json_schema_extra={"label": "Tabelle"},
|
||||
)
|
||||
objectKey: str = Field(
|
||||
description="RBAC object key (e.g. data.feature.trustee.TrusteePosition)",
|
||||
json_schema_extra={"label": "Objekt-Schluessel"},
|
||||
)
|
||||
label: str = Field(
|
||||
description="User-visible label",
|
||||
json_schema_extra={"label": "Bezeichnung"},
|
||||
)
|
||||
mandateId: str = Field(
|
||||
default="",
|
||||
description="Mandate scope",
|
||||
json_schema_extra={"label": "Mandant", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}},
|
||||
)
|
||||
userId: str = Field(
|
||||
default="",
|
||||
description="Owner user ID",
|
||||
json_schema_extra={"label": "Benutzer", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
|
||||
)
|
||||
workspaceInstanceId: str = Field(
|
||||
description="Workspace feature instance where this source is used",
|
||||
json_schema_extra={"label": "Workspace", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
|
||||
)
|
||||
scope: str = Field(
|
||||
default="personal",
|
||||
description="Data visibility scope: personal, featureInstance, mandate, global",
|
||||
json_schema_extra={"label": "Sichtbarkeit", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
|
||||
{"value": "personal", "label": "Persönlich"},
|
||||
{"value": "featureInstance", "label": "Feature-Instanz"},
|
||||
{"value": "mandate", "label": "Mandant"},
|
||||
{"value": "global", "label": "Global"},
|
||||
]},
|
||||
)
|
||||
neutralize: bool = Field(
|
||||
default=False,
|
||||
description="Whether this data source should be neutralized before AI processing",
|
||||
json_schema_extra={"label": "Neutralisieren", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False},
|
||||
)
|
||||
neutralizeFields: Optional[List[str]] = Field(
|
||||
default=None,
|
||||
description="Column names whose values are replaced with placeholders before AI processing",
|
||||
json_schema_extra={"label": "Zu neutralisierende Felder", "frontend_type": "multiselect", "frontend_readonly": False, "frontend_required": False},
|
||||
)
|
||||
recordFilter: Optional[Dict[str, str]] = Field(
|
||||
default=None,
|
||||
description="Record-level filter applied when querying this table, e.g. {'sessionId': 'abc-123'}",
|
||||
json_schema_extra={"label": "Datensatzfilter"},
|
||||
)
|
||||
|
|
@ -1,24 +1,20 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Feature models: Feature definitions, instances, data sources, and shared feature types."""
|
||||
"""Feature models: Feature, FeatureInstance."""
|
||||
|
||||
import uuid
|
||||
from typing import Optional, Dict, Any, List
|
||||
from typing import Optional, Dict, Any
|
||||
from pydantic import BaseModel, Field
|
||||
from modules.datamodels.datamodelBase import PowerOnModel
|
||||
from modules.shared.i18nRegistry import i18nModel
|
||||
from modules.datamodels.datamodelUtils import TextMultilingual
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Feature & FeatureInstance
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@i18nModel("Feature")
|
||||
class Feature(PowerOnModel):
|
||||
"""Feature-Definition (global, z.B. 'trustee', 'commcoach'). Verfuegbare Funktionalitaeten der Plattform."""
|
||||
"""Feature-Definition (global, z.B. 'trustee', 'chatbot'). Verfuegbare Funktionalitaeten der Plattform."""
|
||||
code: str = Field(
|
||||
description="Unique feature code (Primary Key), z.B. 'trustee', 'commcoach'",
|
||||
description="Unique feature code (Primary Key), z.B. 'trustee', 'chatbot'",
|
||||
json_schema_extra={"label": "Code", "frontend_type": "text", "frontend_readonly": False, "frontend_required": True}
|
||||
)
|
||||
label: TextMultilingual = Field(
|
||||
|
|
@ -75,147 +71,3 @@ class FeatureInstance(PowerOnModel):
|
|||
description="Instance-specific configuration (JSONB). Structure depends on featureCode.",
|
||||
json_schema_extra={"label": "Konfiguration", "frontend_type": "json", "frontend_readonly": False, "frontend_required": False}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FeatureDataSource
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@i18nModel("Feature-Datenquelle")
|
||||
class FeatureDataSource(PowerOnModel):
|
||||
"""Feature-Instanz-Tabelle als Datenquelle im AI-Workspace."""
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
description="Primary key",
|
||||
json_schema_extra={"label": "ID"},
|
||||
)
|
||||
featureInstanceId: str = Field(
|
||||
description="FK to FeatureInstance",
|
||||
json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
|
||||
)
|
||||
featureCode: str = Field(
|
||||
description="Feature code (e.g. trustee, commcoach)",
|
||||
json_schema_extra={"label": "Feature", "fk_target": {"db": "poweron_app", "table": "Feature", "column": "code", "labelField": "code"}},
|
||||
)
|
||||
tableName: str = Field(
|
||||
description="Table name from DATA_OBJECTS meta (e.g. TrusteePosition)",
|
||||
json_schema_extra={"label": "Tabelle"},
|
||||
)
|
||||
objectKey: str = Field(
|
||||
description="RBAC object key (e.g. data.feature.trustee.TrusteePosition)",
|
||||
json_schema_extra={"label": "Objekt-Schluessel"},
|
||||
)
|
||||
label: str = Field(
|
||||
description="User-visible label",
|
||||
json_schema_extra={"label": "Bezeichnung"},
|
||||
)
|
||||
mandateId: str = Field(
|
||||
default="",
|
||||
description="Mandate scope (set automatically from featureInstance.mandateId on create).",
|
||||
json_schema_extra={"label": "Mandant", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}},
|
||||
)
|
||||
neutralize: Optional[bool] = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Three-state neutralization flag with cascade-inherit semantics. "
|
||||
"None = inherit; True/False = explicit. Cascade-reset on parent toggle."
|
||||
),
|
||||
json_schema_extra={"label": "Neutralisieren", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False},
|
||||
)
|
||||
ragIndexEnabled: Optional[bool] = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Three-state RAG-indexing flag with cascade-inherit semantics. "
|
||||
"None = inherit; True/False = explicit. Cascade-reset on parent toggle."
|
||||
),
|
||||
json_schema_extra={"label": "RAG-Indexierung", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False},
|
||||
)
|
||||
neutralizeFields: Optional[List[str]] = Field(
|
||||
default=None,
|
||||
description="Column names whose values are replaced with placeholders before AI processing",
|
||||
json_schema_extra={"label": "Zu neutralisierende Felder", "frontend_type": "multiselect", "frontend_readonly": False, "frontend_required": False},
|
||||
)
|
||||
recordFilter: Optional[Dict[str, str]] = Field(
|
||||
default=None,
|
||||
description="Record-level filter applied when querying this table, e.g. {'sessionId': 'abc-123'}",
|
||||
json_schema_extra={"label": "Datensatzfilter"},
|
||||
)
|
||||
settings: Optional[Dict[str, Any]] = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"FeatureDataSource-scoped settings (JSON). Currently used keys: "
|
||||
"ragLimits.{maxBytes,maxFileSize,maxItems,maxDepth}. "
|
||||
"Mirror of DataSource.settings so the UDB settings modal can target both."
|
||||
),
|
||||
json_schema_extra={"label": "Einstellungen", "frontend_type": "json", "frontend_readonly": True, "frontend_required": False},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DataNeutralizerAttributes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@i18nModel("Neutralisiertes Datenattribut")
|
||||
class DataNeutralizerAttributes(PowerOnModel):
|
||||
"""Zuordnung Originaltext zu Platzhalter fuer neutralisierte Daten."""
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
description="Unique ID of the attribute mapping (used as UID in neutralized files)",
|
||||
json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
|
||||
)
|
||||
mandateId: str = Field(
|
||||
description="ID of the mandate this attribute belongs to",
|
||||
json_schema_extra={
|
||||
"label": "Mandanten-ID",
|
||||
"frontend_type": "text",
|
||||
"frontend_readonly": True,
|
||||
"frontend_required": True,
|
||||
"fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"},
|
||||
},
|
||||
)
|
||||
featureInstanceId: str = Field(
|
||||
description="ID of the feature instance this attribute belongs to",
|
||||
json_schema_extra={
|
||||
"label": "Feature-Instanz-ID",
|
||||
"frontend_type": "text",
|
||||
"frontend_readonly": True,
|
||||
"frontend_required": True,
|
||||
"fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"},
|
||||
},
|
||||
)
|
||||
userId: str = Field(
|
||||
description="ID of the user who created this attribute",
|
||||
json_schema_extra={
|
||||
"label": "Benutzer-ID",
|
||||
"frontend_type": "text",
|
||||
"frontend_readonly": True,
|
||||
"frontend_required": True,
|
||||
"fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"},
|
||||
},
|
||||
)
|
||||
originalText: str = Field(
|
||||
description="Original text that was neutralized",
|
||||
json_schema_extra={"label": "Originaltext", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
|
||||
)
|
||||
fileId: Optional[str] = Field(
|
||||
default=None,
|
||||
description="ID of the file this attribute belongs to",
|
||||
json_schema_extra={
|
||||
"label": "Datei-ID",
|
||||
"frontend_type": "text",
|
||||
"frontend_readonly": True,
|
||||
"frontend_required": False,
|
||||
"fk_target": {"db": "poweron_management", "table": "FileItem", "labelField": "fileName"},
|
||||
},
|
||||
)
|
||||
patternType: str = Field(
|
||||
description="Type of pattern that matched (email, phone, name, etc.)",
|
||||
json_schema_extra={"label": "Mustertyp", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AutoWorkflow — re-exported from canonical location (datamodelWorkflowAutomation)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from modules.datamodels.datamodelWorkflowAutomation import AutoWorkflow # noqa: F401
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""File-related datamodels: FileItem, FilePreview, FileData."""
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Invitation model for self-service onboarding.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Unified JSON document schema and helpers used by both generation prompts and renderers.
|
||||
|
|
@ -16,9 +16,6 @@ supportedSectionTypes: List[str] = [
|
|||
"paragraph",
|
||||
"code_block",
|
||||
"image",
|
||||
# Layout primitives (A3): type-specific document layout.
|
||||
"cover_page", # centered title page (subtitle/author/date/logo), ends with page break
|
||||
"image_grid", # N-column arrangement of images (marketing-style layouts)
|
||||
]
|
||||
|
||||
class InlineRun(TypedDict, total=False):
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Knowledge Store data models: FileContentIndex, ContentChunk, WorkflowMemory.
|
||||
|
||||
|
|
@ -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", "fk_target": {"db": "poweron_app", "table": "UserConnection", "labelField": "authority"}},
|
||||
json_schema_extra={"label": "Connection-ID"},
|
||||
)
|
||||
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", "fk_target": {"db": "poweron_knowledge", "table": "FileContentIndex", "labelField": "fileName"}},
|
||||
json_schema_extra={"label": "Inhaltsobjekt-ID"},
|
||||
)
|
||||
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", "fk_target": {"db": "poweron_chat", "table": "ChatWorkflow", "labelField": "name"}},
|
||||
json_schema_extra={"label": "Workflow-ID"},
|
||||
)
|
||||
roundNumber: int = Field(
|
||||
default=0,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Membership models: UserMandate, FeatureAccess, and Junction Tables.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Messaging models: MessagingSubscription, MessagingSubscriptionRegistration, MessagingDelivery."""
|
||||
|
||||
|
|
@ -112,7 +112,7 @@ class MessagingSubscription(PowerOnModel):
|
|||
|
||||
|
||||
@i18nModel("Messaging-Registrierung")
|
||||
class MessagingSubscriptionRegistration(PowerOnModel):
|
||||
class MessagingSubscriptionRegistration(BaseModel):
|
||||
"""Data model for user registrations to messaging subscriptions"""
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
|
|
@ -203,7 +203,7 @@ class MessagingSubscriptionRegistration(PowerOnModel):
|
|||
|
||||
|
||||
@i18nModel("Messaging-Zustellung")
|
||||
class MessagingDelivery(PowerOnModel):
|
||||
class MessagingDelivery(BaseModel):
|
||||
"""Data model for individual message deliveries"""
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
|
|
|
|||
|
|
@ -1,357 +0,0 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# All rights reserved.
|
||||
"""
|
||||
Navigation structure data (Layer L1 - datamodels).
|
||||
Single source of truth for UI navigation sections used by RBAC and frontend.
|
||||
"""
|
||||
|
||||
from modules.shared.i18nRegistry import t
|
||||
|
||||
# =============================================================================
|
||||
# Navigation Structure (Single Source of Truth)
|
||||
# =============================================================================
|
||||
#
|
||||
# Block Order (gemaess Navigation-API-Konzept):
|
||||
# - System: 10
|
||||
# - <dynamic/features>: 15 (wird in routeSystem.py eingefuegt)
|
||||
# - Basisdaten: 30
|
||||
# - Administration: 200
|
||||
#
|
||||
# NOTE: Workflows and Migrate sections removed - now handled as features
|
||||
#
|
||||
# Item Order: Default-Abstand 10 pro Item
|
||||
# uiComponent: Abgeleitet von objectKey (ui.system.home -> page.system.home)
|
||||
# icon: Wird intern gehalten aber NICHT in der API Response zurueckgegeben
|
||||
|
||||
NAVIGATION_SECTIONS = [
|
||||
# --- Meine Sicht (with top-level item + subgroups) ---
|
||||
{
|
||||
"id": "system",
|
||||
"title": t("Meine Sicht"),
|
||||
"order": 10,
|
||||
"items": [
|
||||
{
|
||||
"id": "home",
|
||||
"objectKey": "ui.system.home",
|
||||
"label": t("Start"),
|
||||
"icon": "FaHome",
|
||||
"path": "/",
|
||||
"order": 10,
|
||||
"public": True,
|
||||
},
|
||||
],
|
||||
"subgroups": [
|
||||
{
|
||||
"id": "system-overviews",
|
||||
"title": t("Übersichten"),
|
||||
"order": 15,
|
||||
"items": [
|
||||
{
|
||||
"id": "integrations",
|
||||
"objectKey": "ui.system.integrations",
|
||||
"label": t("Integrationen"),
|
||||
"icon": "FaProjectDiagram",
|
||||
"path": "/integrations",
|
||||
"order": 10,
|
||||
"public": True,
|
||||
},
|
||||
{
|
||||
"id": "compliance-audit",
|
||||
"objectKey": "ui.system.complianceAudit",
|
||||
"label": t("Compliance & Audit"),
|
||||
"icon": "FaShieldAlt",
|
||||
"path": "/compliance-audit",
|
||||
"order": 20,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "system-basedata",
|
||||
"title": t("Basisdaten"),
|
||||
"order": 20,
|
||||
"items": [
|
||||
{
|
||||
"id": "connections",
|
||||
"objectKey": "ui.system.connections",
|
||||
"label": t("Verbindungen"),
|
||||
"icon": "FaLink",
|
||||
"path": "/basedata/connections",
|
||||
"order": 10,
|
||||
"public": True,
|
||||
},
|
||||
{
|
||||
"id": "files",
|
||||
"objectKey": "ui.system.files",
|
||||
"label": t("Dateien"),
|
||||
"icon": "FaRegFileAlt",
|
||||
"path": "/basedata/files",
|
||||
"order": 20,
|
||||
"public": True,
|
||||
},
|
||||
{
|
||||
"id": "prompts",
|
||||
"objectKey": "ui.system.prompts",
|
||||
"label": t("Prompts"),
|
||||
"icon": "FaLightbulb",
|
||||
"path": "/basedata/prompts",
|
||||
"order": 30,
|
||||
"public": True,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "system-usage",
|
||||
"title": t("Nutzung"),
|
||||
"order": 30,
|
||||
"items": [
|
||||
{
|
||||
"id": "billing-admin",
|
||||
"objectKey": "ui.system.billingAdmin",
|
||||
"label": t("Abrechnung"),
|
||||
"icon": "FaMoneyBillAlt",
|
||||
"path": "/billing/admin",
|
||||
"order": 10,
|
||||
},
|
||||
{
|
||||
"id": "statistics",
|
||||
"objectKey": "ui.system.statistics",
|
||||
"label": t("Statistiken"),
|
||||
"icon": "FaChartBar",
|
||||
"path": "/billing/transactions",
|
||||
"order": 20,
|
||||
},
|
||||
{
|
||||
"id": "rag-inventory",
|
||||
"objectKey": "ui.system.ragInventory",
|
||||
"label": t("RAG-Inventar"),
|
||||
"icon": "FaDatabase",
|
||||
"path": "/rag-inventory",
|
||||
"order": 35,
|
||||
},
|
||||
{
|
||||
"id": "store",
|
||||
"objectKey": "ui.system.store",
|
||||
"label": t("Store"),
|
||||
"icon": "FaStore",
|
||||
"path": "/store",
|
||||
"order": 40,
|
||||
"public": True,
|
||||
},
|
||||
{
|
||||
"id": "settings",
|
||||
"objectKey": "ui.system.settings",
|
||||
"label": t("Einstellungen"),
|
||||
"icon": "FaCog",
|
||||
"path": "/settings",
|
||||
"order": 50,
|
||||
"public": True,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
# --- Solution Design (System-Komponente, cross-mandate) ---
|
||||
# Single nav entry; tabs are managed internally by WorkflowAutomationHubPage.
|
||||
{
|
||||
"id": "workflowAutomation",
|
||||
"title": t("Lösungsdesign"),
|
||||
"order": 25,
|
||||
"items": [
|
||||
{
|
||||
"id": "wa-hub",
|
||||
"objectKey": "ui.system.workflowAutomation",
|
||||
"label": t("Workflow-Automation"),
|
||||
"icon": "FaSitemap",
|
||||
"path": "/workflow-automation",
|
||||
"order": 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
# --- Administration (with subgroups) ---
|
||||
{
|
||||
"id": "admin",
|
||||
"title": t("Administration"),
|
||||
"order": 200,
|
||||
"subgroups": [
|
||||
{
|
||||
"id": "admin-wizards",
|
||||
"title": t("Wizards"),
|
||||
"order": 10,
|
||||
"items": [
|
||||
{
|
||||
"id": "admin-mandate-wizard",
|
||||
"objectKey": "ui.admin.mandateWizard",
|
||||
"label": t("Mandanten-Wizard"),
|
||||
"icon": "FaMagic",
|
||||
"path": "/admin/mandate-wizard",
|
||||
"order": 10,
|
||||
"adminOnly": True,
|
||||
},
|
||||
{
|
||||
"id": "admin-invitation-wizard",
|
||||
"objectKey": "ui.admin.invitationWizard",
|
||||
"label": t("Einladungs-Wizard"),
|
||||
"icon": "FaEnvelopeOpenText",
|
||||
"path": "/admin/invitation-wizard",
|
||||
"order": 20,
|
||||
"adminOnly": True,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "admin-users-group",
|
||||
"title": t("Benutzer"),
|
||||
"order": 20,
|
||||
"items": [
|
||||
{
|
||||
"id": "admin-users",
|
||||
"objectKey": "ui.admin.users",
|
||||
"label": t("Übersicht"),
|
||||
"icon": "FaUsers",
|
||||
"path": "/admin/users",
|
||||
"order": 10,
|
||||
"adminOnly": True,
|
||||
},
|
||||
{
|
||||
"id": "admin-invitations",
|
||||
"objectKey": "ui.admin.invitations",
|
||||
"label": t("Einladungen"),
|
||||
"icon": "FaEnvelopeOpenText",
|
||||
"path": "/admin/invitations",
|
||||
"order": 20,
|
||||
"adminOnly": True,
|
||||
},
|
||||
{
|
||||
"id": "admin-user-access-overview",
|
||||
"objectKey": "ui.admin.userAccessOverview",
|
||||
"label": t("Zugriffe"),
|
||||
"icon": "FaClipboardList",
|
||||
"path": "/admin/user-access-overview",
|
||||
"order": 30,
|
||||
"adminOnly": True,
|
||||
},
|
||||
{
|
||||
"id": "admin-subscriptions",
|
||||
"objectKey": "ui.admin.subscriptions",
|
||||
"label": t("Abonnements"),
|
||||
"icon": "FaFileContract",
|
||||
"path": "/admin/subscriptions",
|
||||
"order": 40,
|
||||
"adminOnly": True,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "admin-system-group",
|
||||
"title": t("System"),
|
||||
"order": 30,
|
||||
"items": [
|
||||
{
|
||||
"id": "admin-roles",
|
||||
"objectKey": "ui.admin.roles",
|
||||
"label": t("Rollen"),
|
||||
"icon": "FaUserTag",
|
||||
"path": "/admin/mandate-roles",
|
||||
"order": 10,
|
||||
"adminOnly": True,
|
||||
},
|
||||
{
|
||||
"id": "admin-mandate-role-permissions",
|
||||
"objectKey": "ui.admin.mandateRolePermissions",
|
||||
"label": t("Rollen-Berechtigungen"),
|
||||
"icon": "FaKey",
|
||||
"path": "/admin/mandate-role-permissions",
|
||||
"order": 20,
|
||||
"adminOnly": True,
|
||||
},
|
||||
{
|
||||
"id": "admin-mandates",
|
||||
"objectKey": "ui.admin.mandates",
|
||||
"label": t("Mandanten"),
|
||||
"icon": "FaBuilding",
|
||||
"path": "/admin/mandates",
|
||||
"order": 30,
|
||||
"adminOnly": True,
|
||||
},
|
||||
{
|
||||
"id": "admin-user-mandates",
|
||||
"objectKey": "ui.admin.userMandates",
|
||||
"label": t("Mandanten-Mitglieder"),
|
||||
"icon": "FaUserFriends",
|
||||
"path": "/admin/user-mandates",
|
||||
"order": 40,
|
||||
"adminOnly": True,
|
||||
},
|
||||
{
|
||||
"id": "admin-access",
|
||||
"objectKey": "ui.admin.access",
|
||||
"label": t("Zugriffsverwaltung"),
|
||||
"icon": "FaBuilding",
|
||||
"path": "/admin/access",
|
||||
"order": 50,
|
||||
"adminOnly": True,
|
||||
},
|
||||
{
|
||||
"id": "admin-feature-instances",
|
||||
"objectKey": "ui.admin.featureInstances",
|
||||
"label": t("Feature-Instanzen"),
|
||||
"icon": "FaCubes",
|
||||
"path": "/admin/feature-instances",
|
||||
"order": 60,
|
||||
"adminOnly": True,
|
||||
},
|
||||
{
|
||||
"id": "admin-feature-roles",
|
||||
"objectKey": "ui.admin.featureRoles",
|
||||
"label": t("Features Rollen-Vorlagen"),
|
||||
"icon": "FaShieldAlt",
|
||||
"path": "/admin/feature-roles",
|
||||
"order": 70,
|
||||
"adminOnly": True,
|
||||
"sysAdminOnly": True,
|
||||
},
|
||||
{
|
||||
"id": "admin-logs",
|
||||
"objectKey": "ui.admin.logs",
|
||||
"label": t("Logs"),
|
||||
"icon": "FaFileAlt",
|
||||
"path": "/admin/logs",
|
||||
"order": 90,
|
||||
"adminOnly": True,
|
||||
"sysAdminOnly": True,
|
||||
},
|
||||
{
|
||||
"id": "admin-languages",
|
||||
"objectKey": "ui.admin.languages",
|
||||
"label": t("UI-Sprachen"),
|
||||
"icon": "FaGlobe",
|
||||
"path": "/admin/languages",
|
||||
"order": 95,
|
||||
"adminOnly": True,
|
||||
"sysAdminOnly": True,
|
||||
},
|
||||
{
|
||||
"id": "admin-database-health",
|
||||
"objectKey": "ui.admin.databaseHealth",
|
||||
"label": t("Datenbank-Gesundheit"),
|
||||
"icon": "FaDatabase",
|
||||
"path": "/admin/database-health",
|
||||
"order": 98,
|
||||
"adminOnly": True,
|
||||
"sysAdminOnly": True,
|
||||
},
|
||||
{
|
||||
"id": "admin-demo-config",
|
||||
"objectKey": "ui.admin.demoConfig",
|
||||
"label": t("Demo Config"),
|
||||
"icon": "FaCubes",
|
||||
"path": "/admin/demo-config",
|
||||
"order": 100,
|
||||
"adminOnly": True,
|
||||
"sysAdminOnly": True,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Notification model for in-app notifications.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Pagination models for server-side pagination, sorting, and filtering.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
RBAC models: AccessRule, AccessRuleContext, Role.
|
||||
|
|
@ -10,7 +10,7 @@ Multi-Tenant Design:
|
|||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Optional, Dict, List, Protocol, runtime_checkable
|
||||
from typing import Optional
|
||||
from enum import Enum
|
||||
from pydantic import BaseModel, Field
|
||||
from modules.datamodels.datamodelBase import PowerOnModel
|
||||
|
|
@ -174,20 +174,6 @@ class AccessRule(PowerOnModel):
|
|||
)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class RbacProtocol(Protocol):
|
||||
"""Structural type for RBAC checkers — allows aicore (L3) to reference
|
||||
the RBAC contract without importing from security (L4)."""
|
||||
|
||||
def checkResourceAccessBulk(
|
||||
self,
|
||||
user: "User",
|
||||
resourcePaths: List[str],
|
||||
mandateId: Optional[str] = None,
|
||||
featureInstanceId: Optional[str] = None,
|
||||
) -> Dict[str, bool]: ...
|
||||
|
||||
|
||||
# IMMUTABLE Fields Definition - für Enforcement auf Application-Level
|
||||
IMMUTABLE_FIELDS = {
|
||||
"Role": ["mandateId", "featureInstanceId", "featureCode"],
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Security models: Token and AuthEvent.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Subscription models: SubscriptionPlan (catalog), MandateSubscription (instance per mandate),
|
||||
StripePlanPrice (persisted Stripe IDs per plan).
|
||||
|
|
@ -153,7 +153,7 @@ class SubscriptionPlan(BaseModel):
|
|||
# ============================================================================
|
||||
|
||||
@i18nModel("Stripe-Planpreise")
|
||||
class StripePlanPrice(PowerOnModel):
|
||||
class StripePlanPrice(BaseModel):
|
||||
"""Persistierte Zuordnung planKey zu Stripe Product/Price IDs."""
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Ticket datamodels used across Jira/ClickUp connectors."""
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Utility data models and classes for common tools and mappings.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
UAM models: User, Mandate, UserConnection.
|
||||
|
|
@ -197,26 +197,6 @@ class Mandate(PowerOnModel):
|
|||
# `customer.email`, `customer.tax_id_data` mappen kann
|
||||
# (Stripe verlangt die Adresse strukturiert, nicht als Freitext).
|
||||
# ``order`` 200-209 gruppiert die Felder visuell am Ende des Formulars.
|
||||
mfaRequired: bool = Field(
|
||||
default=False,
|
||||
description="When true, all users with access to this mandate must have MFA enabled.",
|
||||
json_schema_extra={
|
||||
"frontend_type": "checkbox",
|
||||
"frontend_readonly": False,
|
||||
"frontend_required": False,
|
||||
"label": "MFA-Pflicht",
|
||||
"frontend_format_labels": ["Ja", "-", "Nein"],
|
||||
"order": 190,
|
||||
},
|
||||
)
|
||||
|
||||
@field_validator("mfaRequired", mode="before")
|
||||
@classmethod
|
||||
def _coerceMfaRequired(cls, v):
|
||||
if v is None:
|
||||
return False
|
||||
return v
|
||||
|
||||
invoiceCompanyName: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Firmenname / Empfaenger der Rechnung (falls abweichend vom Voller Name).",
|
||||
|
|
@ -495,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: Optional[bool] = Field(
|
||||
knowledgeIngestionEnabled: 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"},
|
||||
|
|
@ -643,25 +623,6 @@ class User(PowerOnModel):
|
|||
return v
|
||||
|
||||
|
||||
mfaEnabled: bool = Field(
|
||||
default=False,
|
||||
description="Whether the user has completed MFA setup and has TOTP active.",
|
||||
json_schema_extra={
|
||||
"frontend_type": "checkbox",
|
||||
"frontend_readonly": True,
|
||||
"frontend_required": False,
|
||||
"label": "MFA aktiv",
|
||||
"frontend_format_labels": ["Ja", "-", "Nein"],
|
||||
},
|
||||
)
|
||||
|
||||
@field_validator("mfaEnabled", mode="before")
|
||||
@classmethod
|
||||
def _coerceMfaEnabled(cls, v):
|
||||
if v is None:
|
||||
return False
|
||||
return v
|
||||
|
||||
authenticationAuthority: AuthAuthority = Field(
|
||||
default=AuthAuthority.LOCAL,
|
||||
description="Primary authentication authority",
|
||||
|
|
@ -694,11 +655,6 @@ class UserInDB(User):
|
|||
description="Hash of the user password",
|
||||
json_schema_extra={"label": "Passwort-Hash"},
|
||||
)
|
||||
mfaSecret: Optional[str] = Field(
|
||||
None,
|
||||
description="Encrypted TOTP secret for MFA. Stored via encryptValue/decryptValue.",
|
||||
json_schema_extra={"label": "MFA-Secret", "frontend_visible": False},
|
||||
)
|
||||
resetToken: Optional[str] = Field(
|
||||
None,
|
||||
description="Password reset token (UUID)",
|
||||
|
|
@ -791,3 +747,4 @@ class UserVoicePreferences(PowerOnModel):
|
|||
def _validateTtsVoiceMap(cls, value: Any) -> Optional[Dict[str, str]]:
|
||||
return normalizeTtsVoiceMap(value)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Unified Document Model (UDM) — hierarchical document tree and ContentPart bridge."""
|
||||
from __future__ import annotations
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""UI language sets: structured i18n entries (context, key, value)."""
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Utility datamodels: Prompt, TextMultilingual."""
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
View models for the /api/attributes/ endpoint.
|
||||
|
|
@ -24,7 +24,7 @@ from modules.datamodels.datamodelBilling import BillingTransaction
|
|||
from modules.datamodels.datamodelSubscription import MandateSubscription
|
||||
from modules.datamodels.datamodelUiLanguage import UiLanguageSet
|
||||
from modules.datamodels.datamodelRbac import Role
|
||||
from modules.datamodels.datamodelFeatures import DataNeutralizerAttributes
|
||||
from modules.features.neutralization.datamodelFeatureNeutralizer import DataNeutralizerAttributes
|
||||
from modules.shared.i18nRegistry import i18nModel
|
||||
|
||||
|
||||
|
|
@ -243,11 +243,11 @@ class RoleView(Role):
|
|||
# Automation Workflow — dashboard view with synthesized fields
|
||||
# ============================================================================
|
||||
|
||||
from modules.datamodels.datamodelFeatures import AutoWorkflow
|
||||
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoWorkflow
|
||||
|
||||
|
||||
@i18nModel("Workflow (Ansicht)")
|
||||
class AutoWorkflowView(AutoWorkflow):
|
||||
class Automation2WorkflowView(AutoWorkflow):
|
||||
"""AutoWorkflow extended with computed dashboard fields.
|
||||
|
||||
Used exclusively for /api/attributes/ so the frontend can resolve column
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue