Compare commits
124 Commits
0d052ab604
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e4fb0a4938 | |||
| 13deaf9e05 | |||
| fd4a1ec4ce | |||
| 49a9493ca0 | |||
| 2f5af6e192 | |||
| 421726700b | |||
| fdc4e36d14 | |||
| c9a5a6e198 | |||
| 8e744c982e | |||
| 001e4b415f | |||
| 62d107d142 | |||
| 693417ad27 | |||
| 5d6fe6b1f6 | |||
| 8c5682f669 | |||
| 5169b12795 | |||
| d3142e07cb | |||
| 015b7e32f3 | |||
| b2f75495de | |||
| a802159a65 | |||
| 17f990c61b | |||
| 25b4d61236 | |||
| 46463b5c65 | |||
| a17772ade5 | |||
| 632554bf28 | |||
| ad3575c64d | |||
| 47432651f1 | |||
| b8a556091e | |||
| ea39cf4667 | |||
| 824686c673 | |||
| f2a7206c7d | |||
| 8a26737680 | |||
| ee5545d58f | |||
| 4b97a1bca2 | |||
| b91ee5bf1b | |||
| 0383dd2f64 | |||
| e8e07fa13a | |||
| f38dfdeea1 | |||
| b3eaa0e39b | |||
| f8b07032e5 | |||
| 2d3ee216ea | |||
| b1c8891ee0 | |||
| 2fbffcd6e8 | |||
| 00f8f100d7 | |||
| 7097e28578 | |||
| 06b72843da | |||
| 71ffee021e | |||
| 0404c8f5dc | |||
| 4eba9eb1d9 | |||
| d734923636 | |||
| abdf9e7d83 | |||
| fccf0099d7 | |||
| 591ef06a82 | |||
| dcd2018335 | |||
| 3fd0c6981d | |||
| 9076983c0c | |||
| 50d6733df6 | |||
| 36e46f60f7 | |||
| 31154b9dc6 | |||
| 00bfcafb9c | |||
| 5ecadd5a89 | |||
| 0388ca2072 | |||
| 24423b6802 | |||
| e7b746809b | |||
| 3f8d1bd59d | |||
| a09d611cac | |||
| 33b4b52206 | |||
| c99d2f19ef | |||
| 289dfc8230 | |||
| a3201ae221 | |||
| 79ca1cbe6d | |||
| 8d8beebd38 | |||
| 4bdc6c66c7 | |||
| f9ff92bec9 | |||
| 36771d471d | |||
| b58c96ff71 | |||
| b666fe5b4c | |||
| 895f85dde0 | |||
| dbe9ded4f1 | |||
| 1b80090ad2 | |||
| 4210e164fa | |||
| 4a25ac1379 | |||
| 86cea5d6c4 | |||
| ac5edef3dc | |||
| ecc7a24c1c | |||
| 4eb05d96ad | |||
| 744f2a1dbf | |||
| 03dd477899 | |||
| 26506a5027 | |||
| b40adfdd3a | |||
| c25356c257 | |||
| 6ed4bb7f98 | |||
| 04e92794de | |||
| 335762dd3d | |||
| 63aa0cf788 | |||
| cd34bab3a8 | |||
| 7ed5349e86 | |||
| 24dc78977c | |||
| 3c496f4b6a | |||
| 9e254176c9 | |||
| 26948fdb51 | |||
| 74827156d0 | |||
| b3e259fc25 | |||
| 7a755d32e6 | |||
| 84cb82d2c4 | |||
| 5eade3e005 | |||
| 65c22e9200 | |||
| 3e5ce47798 | |||
| f6516e48ca | |||
| dfe46dff16 | |||
| 559bba69a4 | |||
| b311ab7aa1 | |||
| fa429c3a88 | |||
| 67c0dcd34c | |||
| df85fdcb5b | |||
| b5036a1fc0 | |||
| 36dd7c5101 | |||
| d9ca8af7e0 | |||
| 20ff5e2142 | |||
| eebc2cf4de | |||
| ad848a5053 | |||
| b7194f0d58 | |||
| c6decf5556 | |||
| d7b3c7c1b5 | |||
| cd988d6163 |
+4
-4
@@ -44,17 +44,17 @@ STORAGE_PATH=/data/storage
|
|||||||
# --- SMTP (for password reset emails) -----------------------------------------
|
# --- SMTP (for password reset emails) -----------------------------------------
|
||||||
SMTP_HOST=smtp.example.com
|
SMTP_HOST=smtp.example.com
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
SMTP_USER=noreply@example.com
|
SMTP_USERNAME=noreply@example.com
|
||||||
SMTP_PASSWORD=YOUR_SMTP_PASSWORD
|
SMTP_PASSWORD=YOUR_SMTP_PASSWORD
|
||||||
SMTP_FROM=noreply@example.com
|
SMTP_FROM_EMAIL=noreply@example.com
|
||||||
SMTP_TLS=true
|
SMTP_USE_TLS=true
|
||||||
|
|
||||||
# --- bcrypt tuning ----------------------------------------------------------
|
# --- bcrypt tuning ----------------------------------------------------------
|
||||||
BCRYPT_ROUNDS=12
|
BCRYPT_ROUNDS=12
|
||||||
|
|
||||||
# --- Admin user (seeded on first start) --------------------------------------
|
# --- Admin user (seeded on first start) --------------------------------------
|
||||||
ADMIN_EMAIL=admin@example.com
|
ADMIN_EMAIL=admin@example.com
|
||||||
ADMIN_PASSWORD=Admin123!
|
ADMIN_PASSWORD=CHANGE_ME_generate_a_strong_password
|
||||||
|
|
||||||
# --- MAIL_ENCRYPTION_KEY (REQUIRED) -------------------------------------------
|
# --- MAIL_ENCRYPTION_KEY (REQUIRED) -------------------------------------------
|
||||||
# AES-256 encryption key for mail account passwords (Fernet).
|
# AES-256 encryption key for mail account passwords (Fernet).
|
||||||
|
|||||||
+4
-4
@@ -90,10 +90,10 @@ S3_SECURE=true
|
|||||||
# === SMTP / EMAIL ===
|
# === SMTP / EMAIL ===
|
||||||
SMTP_HOST=localhost
|
SMTP_HOST=localhost
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
SMTP_USER=
|
SMTP_USERNAME=
|
||||||
SMTP_PASSWORD=
|
SMTP_PASSWORD=
|
||||||
SMTP_FROM=no-reply@localhost
|
SMTP_FROM_EMAIL=no-reply@localhost
|
||||||
SMTP_TLS=true
|
SMTP_USE_TLS=true
|
||||||
|
|
||||||
# === RATE LIMITING ===
|
# === RATE LIMITING ===
|
||||||
RATE_LIMIT_LOGIN_MAX=5
|
RATE_LIMIT_LOGIN_MAX=5
|
||||||
@@ -134,4 +134,4 @@ API_GIT_BRANCH=main
|
|||||||
|
|
||||||
# === Admin User (auto-seeded on first start) ===
|
# === Admin User (auto-seeded on first start) ===
|
||||||
ADMIN_EMAIL=admin@media-on.de
|
ADMIN_EMAIL=admin@media-on.de
|
||||||
ADMIN_PASSWORD=Admin123!
|
ADMIN_PASSWORD=CHANGE_ME_generate_a_strong_password
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ dump.rdb
|
|||||||
# Frontend build output (regenerated on deploy)
|
# Frontend build output (regenerated on deploy)
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
|
node_modules/
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
+361
-9
@@ -1210,20 +1210,20 @@ Trigger / Event / Cron / Webhook / Agent
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase L — UI-Overhaul (Status: geplant, NICHT gestartet)
|
## Phase O — UI-Overhaul (Status: geplant, 2026-08-30 verifiziert)
|
||||||
|
|
||||||
|
> **Umbenannt von 'Phase L' (2026-08-30):** Der Buchstabe L war doppelt vergeben (UI-Overhaul + Dokumente-Generator). UI-Overhaul ist jetzt Phase O; Phase L = Dokumente-Generator (abgeschlossen).
|
||||||
|
> **Bug-Verifikation Phase 1 (2026-08-30, Live-Messung):** 1.1 Kontakte-Invalidation ✓ gefixt (invalidateQueries vorhanden) · 1.2 Drag-Drop Kontakte→Ordner ✗ offen · 1.3 MoveDialog ✗ offen (existiert nicht) · 1.4 Wiki-Save ✓ verdrahtet (apiPost/apiPatch live) · 1.5 Kalender-Dialog ✓ gefixt (onSaved-Handler) · 1.6 Neuer Chat ✓ gefixt (createConversation + Button) · 1.7 Wiki doppelt ✓ kein Bug (1 Menü-Eintrag + 1 page_route, konsistent). Status 'NICHT gestartet' war falsch — 5/7 Bugs bereits erledigt.
|
||||||
|
|
||||||
> **Herkunft:** Am 2026-08-25 aus der eigenständigen Datei `UI_OVERHAUL_PLAN.md`
|
> **Herkunft:** Am 2026-08-25 aus der eigenständigen Datei `UI_OVERHAUL_PLAN.md`
|
||||||
> hier integriert - gemaess AGENTS.md-Regel "PLATFORM_ROADMAP.md ist EINZIGE
|
> hier integriert - gemaess AGENTS.md-Regel "PLATFORM_ROADMAP.md ist EINZIGE
|
||||||
> Planungs-Datei". Vollständiges Original inkl. ASCII-Mockups abrufbar via
|
> Planungs-Datei". Vollständiges Original inkl. ASCII-Mockups abrufbar via
|
||||||
> `git show c807aac:UI_OVERHAUL_PLAN.md`.
|
> `git show c807aac:UI_OVERHAUL_PLAN.md`.
|
||||||
>
|
>
|
||||||
> **Konflikt-Notiz (2026-08-25, Block I-D):** Phase 2 unten sieht "AI Assistant
|
> **Konflikt-Notiz (2026-08-25, Block I-D) — ENTSCHIEDEN (2026-08-30):** Option (b)
|
||||||
> Page entfernen" vor. Die Seite wurde jedoch in Commit 962e0ee bewusst GEBAUT,
|
> gilt — die AI-Assistant-Seite bleibt (962e0ee, repariert die Geister-Route
|
||||||
> um die Geister-Route /ai-assistant zu reparieren (im Backend-Manifest
|
> /ai-assistant). Phase 2 ("AI Assistant Page entfernen") ist UEBERHOLT und
|
||||||
> referenziert, aber nicht existent -> ErrorBoundary in Production). VOR
|
> wird nicht umgesetzt. Original-Notiz: git show f6516e4:PLATFORM_ROADMAP.md.
|
||||||
> Umsetzung von Phase 2 neu entscheiden: (a) Seite doch entfernen - dann auch
|
|
||||||
> Manifest-Route entfernen, oder (b) Phase 2 verwerfen zugunsten der aktuellen
|
|
||||||
> Architektur. Bitte nicht unkommentiert ausfuehren.
|
|
||||||
|
|
||||||
> **Erstellt:** 2026-08-21
|
> **Erstellt:** 2026-08-21
|
||||||
> **Aktualisiert:** 2026-08-21 — AI Assistent Integration hinzugefügt
|
> **Aktualisiert:** 2026-08-21 — AI Assistent Integration hinzugefügt
|
||||||
@@ -1529,7 +1529,7 @@ Der AI Assistent ist ein paralleles System das die Kommunikation-Plattform dupli
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase-L-Phasenübersicht
|
### Phase-O-Phasenübersicht
|
||||||
|
|
||||||
| Phase | Inhalt | Aufwand | Migration | Abhängigkeit |
|
| Phase | Inhalt | Aufwand | Migration | Abhängigkeit |
|
||||||
|-------|--------|---------|-----------|-------------|
|
|-------|--------|---------|-----------|-------------|
|
||||||
@@ -1590,8 +1590,360 @@ Der AI Assistent ist ein paralleles System das die Kommunikation-Plattform dupli
|
|||||||
| I — Integration & Human-AI Workstream | 6 Wochen | Agent↔Workflow↔Knowledge↔Communication, echte MiniApps, Shared/Proactive/Mobile Workstreams, Dashboard, MCP, Polish |
|
| I — Integration & Human-AI Workstream | 6 Wochen | Agent↔Workflow↔Knowledge↔Communication, echte MiniApps, Shared/Proactive/Mobile Workstreams, Dashboard, MCP, Polish |
|
||||||
| J — Controlled Self-Improvement | 5 Wochen | Improvement Signals/Proposals, Evaluation/Dry-Run, Approval, Versionierung/Rollback, Wirkungsmessung |
|
| J — Controlled Self-Improvement | 5 Wochen | Improvement Signals/Proposals, Evaluation/Dry-Run, Approval, Versionierung/Rollback, Wirkungsmessung |
|
||||||
| K — EU Compliance Finalization | 1 Woche | AI-Use-Case-Register, DPIA/AI-Impact-Support, Incident/Retention, Compliance-E2E, Betriebsdoku |
|
| K — EU Compliance Finalization | 1 Woche | AI-Use-Case-Register, DPIA/AI-Impact-Support, Incident/Retention, Compliance-E2E, Betriebsdoku |
|
||||||
|
| **L — Dokumente-Generator** | **~3 Wochen** | Briefpapier + Block-System + Drag/Drop-Editor + KI-Steuerung + E-Rechnung (Contract-Muster wie Import/Export) |
|
||||||
| **Total** | **52 Wochen** | **LeoPlatform Endstand-Kern inkl. Privacy/DSGVO/EU-AI-Act-by-Design** |
|
| **Total** | **52 Wochen** | **LeoPlatform Endstand-Kern inkl. Privacy/DSGVO/EU-AI-Act-by-Design** |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Diese Roadmap basiert auf dem Endstand-Audit des aktuellen Code-Archivs und der gemeinsamen Detail-Review. Ziel bleibt: keine unnötigen Universalmodelle, keine Massenrefactorings und keine parallelen Mechanismen. Gemeinsame technische Kerne werden dort genutzt, wo Semantik wirklich gleich ist; fachliche Speziallogik bleibt erlaubt. Bestehender funktionierender Code wird respektiert. Die eingebauten Privacy-/AI-Compliance-Funktionen schaffen technische Voraussetzungen und Nachweise; die rechtliche Konformität eines konkreten Deployments/Branchenplugins hängt zusätzlich von dessen tatsächlichem Zweck, Datenverarbeitung, Betreiberrolle und organisatorischen Maßnahmen ab.*
|
*Diese Roadmap basiert auf dem Endstand-Audit des aktuellen Code-Archivs und der gemeinsamen Detail-Review. Ziel bleibt: keine unnötigen Universalmodelle, keine Massenrefactorings und keine parallelen Mechanismen. Gemeinsame technische Kerne werden dort genutzt, wo Semantik wirklich gleich ist; fachliche Speziallogik bleibt erlaubt. Bestehender funktionierender Code wird respektiert. Die eingebauten Privacy-/AI-Compliance-Funktionen schaffen technische Voraussetzungen und Nachweise; die rechtliche Konformität eines konkreten Deployments/Branchenplugins hängt zusätzlich von dessen tatsächlichem Zweck, Datenverarbeitung, Betreiberrolle und organisatorischen Maßnahmen ab.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase L — Dokumente-Generator ✓ ABGESCHLOSSEN (2026-08-29/30, Commits b311ab7 + 559bba6, deployed, Health healthy, Alembic 0143)
|
||||||
|
|
||||||
|
**Ziel:** Zentrale Dokument-Generierung mit Briefpapier + dynamischen Blöcken, Drag/Drop-Editor, KI-Steuerung, E-Rechnung-Fähigkeit. Module registrieren ihre Blöcke als Contribution (Contract-Muster wie Import/Export).
|
||||||
|
|
||||||
|
**Basis:** report_generator-Plugin (Jinja2-Templates, pdf_generator.py, Background-Jobs, ReportTemplate/ReportInstance-Models) — Erweiterung statt Neubau.
|
||||||
|
|
||||||
|
### L1 — Block-System (2-3 Tage)
|
||||||
|
- Briefpapier-Modell (pro Tenant: Logo, Header/Footer, CSS)
|
||||||
|
- Block-Modell (typ: text/table/chart/placeholder, order, content)
|
||||||
|
- Block-Registrierung durch Module via Contract (`document_blocks` wie `importexport_entities`)
|
||||||
|
- print_templates-Tabelle (Briefpapier-Ref + Block-Komposition)
|
||||||
|
|
||||||
|
### L2 — Drag/Drop-Editor (3-5 Tage)
|
||||||
|
- Frontend: Block-Palette (registrierte Blöcke des Moduls), Canvas, Platzierung
|
||||||
|
- Placeholder-Editor (`{{firstname}}`, `{{company.logo}}`)
|
||||||
|
- Live-Preview
|
||||||
|
|
||||||
|
### L3 — Renderer-Integration (1-2 Tage)
|
||||||
|
- report_generator-Engine an Block-Komposition anbinden
|
||||||
|
- Jinja2-Templates aus Block-Komposition generieren
|
||||||
|
- PDF/Excel/CSV-Output über bestehende Engine
|
||||||
|
|
||||||
|
### L4 — KI-Steuerung (1-2 Tage)
|
||||||
|
- „Erstelle Rechnungsvorlage" via AI-Module (agent_loop existiert)
|
||||||
|
- Template-Vorschläge aus Block-Komposition
|
||||||
|
|
||||||
|
### L5 — E-Rechnung (2-3 Tage)
|
||||||
|
- XRechnung/ZUGFeRD-Format (Verkauf-Modul registriert Rechnungs-Blöcke)
|
||||||
|
- Klären: Steuer-Behörden (Deutschland, B2B-Pflicht ab 2027) oder Kunden-Lieferungen?
|
||||||
|
|
||||||
|
**Abhängigkeiten:** L5 benötigt Phase F (Agents) und das Verkaufs-Modul (noch nicht gebaut).
|
||||||
|
|
||||||
|
**Verwandte Issues:** #359 (Import/Export Contribution — gleiche Plugin-Philosophie).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase M — MiniApp-Plattform & Dashboard-Builder (geplant, user-abgestimmt 2026-08-29)
|
||||||
|
|
||||||
|
**Ziel:** MiniApps als universelles, teilbares UI-Baustein-System über alle Hosts (Chat, Dashboard, Windows, AI-Agenten). Dashboard-Builder mit Edit-Modus, Drag&Drop, Resize, Tabs und pro-Widget-Settings. System-Dashboard-Teile werden zurück in Plugins gebaut (Core wird zum reinen Host).
|
||||||
|
|
||||||
|
**Basis (Live-Bestand 2026-08-29):**
|
||||||
|
- `kommunikation/miniapp_registry.py` (92 Z., MiniAppDef mit register/unregister/unregister_plugin — inkl. Lifecycle-Cleanup)
|
||||||
|
- `MiniAppContribution` im Manifest-Schema (app_id, name, icon, description, render_schema) — **LÜCKE: kein permission-Feld**
|
||||||
|
- `FrontendDashboardWidget` im Manifest (id, component, col_span, row_span, permission) — **LÜCKE: kein settings_schema**
|
||||||
|
- `MiniAppBlock.tsx` als comm-Block-Typ (Chat-Host — fertig verdrahtet)
|
||||||
|
- `DashboardGrid`/`DashboardWidgetLoader` + 4 Widgets (RecentContacts, TasksSummary, CalendarUpcoming)
|
||||||
|
- Dashboard.tsx (170 Z.) mit hardcodierten StatCards (via contacts-Contract `get_counts`), ActivityFeed (via Audit-Log), System-Metrics (Admin-only) — **Rückbau-Bestand**
|
||||||
|
- `app/routes/dashboard.py` listet manifest `dashboard_widgets` (bereits permission-agnostisch, nur `dashboard:read` auf Endpoint-Ebene)
|
||||||
|
- @dnd-kit (core/sortable/utilities) bereits im Projekt (Referenz: SettingsMenuOrder, Dokumente-BlockEditor)
|
||||||
|
- windowStore (Window-Manager) existiert für spätere Hosts
|
||||||
|
|
||||||
|
**Architektur-Entscheidung (user-bestiätigt):** EINE Universal-Registry statt zweier paralleler Systeme — `dashboard_widgets` wird Alias von `miniapps`; jedes Plugin/System registriert MiniApps via Contribution (gleiches Muster wie settings_pages/print document blocks, #359-Philosophie). Ein Host-Set: Chat-Block (fertig), Dashboard (neu), Windows (M6), AI-Agenten-Tool-Ausgabe (M6).
|
||||||
|
|
||||||
|
|
||||||
|
**⚠️ Abgrenzung Workspace ≠ Dashboard (user-korrigiert 2026-08-30):**
|
||||||
|
- **Dashboard (diese Phase M)** = PERSÖNLICH: jeder User baut eigene Dashboards (Layout/Tabs/Instanzen) — Speicher ist die NEUE `dashboards`-Tabelle (owner-basiert). NIEMALS `workspace_widgets` dafür verwenden.
|
||||||
|
- **Workspace (Phase N)** = ADMIN-Kontext für Gruppen: welche Module sichtbar sind (fertig) + Modul-Teilmengen (Scopes) + welche Widget-TYPEN der Workspace anbietet (`workspace_widgets`, existiert bereits — Workspace-Eigentum).
|
||||||
|
- Schnittstelle: der aktive Workspace begrenzt nur die VERFÜGBAREN Widget-Typen; das persönliche Layout bleibt User-Eigentum und wird von keinem Workspace überschrieben.
|
||||||
|
|
||||||
|
|
||||||
|
### M1 — Universal-MiniApp-Registry (2-3 Tage)
|
||||||
|
- miniapp_registry aus kommunikation-Plugin in Plugin-Layer heben (Plattform-Konzept, kommunikation behält Chat-Hosting)
|
||||||
|
- MiniAppDef/MiniAppContribution erweitern: `permission` (Pflicht-Feld, fail-closed), `settings_schema` (generisches Settings-Form), `col_span`/`row_span`, `min_size`
|
||||||
|
- `dashboard_widgets` (Manifest) → Alias von `miniapps` (Rückwärtskompatibilität, ein Contribution-Typ)
|
||||||
|
- `/api/v1/miniapps`-Endpoint: Registry-Listing **server-seitig permission-gefiltert** (nur MiniApps sichtbar, für die der User die Permission hat)
|
||||||
|
- Host-Rendering prüft Permission zusätzlich beim Render (Defense-in-Depth wie Plugin-Routen)
|
||||||
|
- Lifecycle: Plugin-Deaktivierung → unregister_plugin → Widgets verschwinden aus allen Hosts
|
||||||
|
|
||||||
|
### M2 — Dashboard-Backend (2-3 Tage)
|
||||||
|
- `dashboards`-Tabelle: pro User mehrere Dashboards, Tabs, Layout als JSONB (`[{tab, widgets: [{app_id, settings, col, row, span}]}]`), RLS fail-closed + crm_api-Policy (0084-Muster)
|
||||||
|
- CRUD-Endpoints (list/create/update/delete + set-default), Tenant-Scoping, Owner-only oder Admin
|
||||||
|
- Dual-Path: Plugin-SQL idempotent + Alembic-Konvergenz (Gate-B-Muster wie 0143)
|
||||||
|
- Default-Dashboard-Seed beim ersten Aufruf (aus Registrierungs-Order abgeleitet)
|
||||||
|
|
||||||
|
### M3 — Dashboard-Builder-Frontend (3-5 Tage)
|
||||||
|
- Edit-Modus als Modus-Schalter: aktiv → Widgets hinzufügen/entfernen, Größe ändern (col/row-span), Einstellungen; beenden → persistiertes Layout, reine Ansicht
|
||||||
|
- Drag&Drop-Grid (@dnd-kit, Referenz BlockEditor/SettingsMenuOrder): Platzierung + Umsortieren
|
||||||
|
- Widget-Palette: verfügbare MiniApps (aus `/api/v1/miniapps`, permission-gefiltert), Suche/Kategorie
|
||||||
|
- Generisches Settings-Form pro Widget aus `settings_schema` (gleiche Philosophie wie Block-Config-Panels beim Dokumente-Editor)
|
||||||
|
- Tabs: mehrere Dashboards pro User, Tab-Verwaltung im Edit-Modus
|
||||||
|
- Dashboard.tsx wird zum reinen Host (keine hardcodierten Inhalte mehr)
|
||||||
|
|
||||||
|
### M4 — System-Rückbau (2-3 Tage)
|
||||||
|
- StatCards (Firmen-/Kontakt-Zähler via contacts-Contract) → contacts-Plugin-MiniApp
|
||||||
|
- Aktiv-diese-Woche/Neu-diesen-Monat + ActivityFeed (Audit-Log) → audit/auditlog-MiniApp
|
||||||
|
- System-Metrics-Block (DB/Redis/Worker/LLM-Kosten, Admin) → System-MiniApp mit `settings:read`-Permission
|
||||||
|
- Bestehende Dashboard-Widgets (RecentContacts, TasksSummary, CalendarUpcoming) zu MiniApps migrieren (gleiches Format, dann Chat-fähig)
|
||||||
|
|
||||||
|
### M5 — Plugin-MiniApps (2-3 Tage)
|
||||||
|
- contacts, tasks, calendar, wiki, dms, mail, knowledge (Graph-RAG), automation liefern jeweils MiniApps via Manifest-Contribution
|
||||||
|
- Jede MiniApp automatisch überall verfügbar: Chat senden + Dashboard platzieren
|
||||||
|
- Permission je MiniApp passend zum Owner-Modul (z.B. `tasks:read` für TaskSummary)
|
||||||
|
|
||||||
|
### M6 — Weitere Hosts (2-3 Tage)
|
||||||
|
- AI-Agenten-Tool: Agent kann MiniApp als Ausgabe-Block in Chat-Antwort einbetten (miniapp-Block-Typ existiert, Tool-Registry erweitern)
|
||||||
|
- Windows (windowStore): MiniApp per Klick/Expand in eigenem Fenster öffnen
|
||||||
|
- Evaluiert: Wiki-Einbettung (BlockRenderer-Muster) — nur wenn Bedarf bleibt
|
||||||
|
|
||||||
|
**Abhängigkeiten:** M3 benötigt M1+M2. M4/M5 nach M3 (Host muss stehen). M6 zuletzt.
|
||||||
|
|
||||||
|
**Verwandte Phasen/Issues:** Phase L (Gleiche Contribution-Philosophie), #359 (Contract-Muster), Phase F (Agenten für M6).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase N — Workspace-Scopes: Modul-Teilmengen pro Arbeitskontext (geplant, user-abgestimmt 2026-08-30)
|
||||||
|
|
||||||
|
**Ziel:** Workspaces werden zu voll anpassbaren Arbeitskontexten: jedes Modul kann pro Workspace auf eine Teilmenge eingeschränkt werden (z.B. nur Kontakt-Ordner X+Y, nur DMS-Ordner „Angebote", nur Mail-Postfach vertrieb@, nur Kalender „Vertrieb"). Admin-definiert für zugewiesene User-Gruppen — klar getrennt vom persönlichen Dashboard (Phase M).
|
||||||
|
|
||||||
|
**Klare Trennung (user-korrigiert):**
|
||||||
|
- Workspace = Admin-Kontext, Gruppen-Feature: WAS ist sichtbar/verfügbar (Module, Teilmengen, Widget-Typ-Angebot via `workspace_widgets`)
|
||||||
|
- Dashboard = persönlich, User-Feature: WIE ICH mein Dashboard baue (Phase M, `dashboards`-Tabelle)
|
||||||
|
- Beide Systeme berühren sich NUR an einer Schnittstelle: der aktive Workspace begrenzt das Widget-Typ-Angebot; das persönliche Layout bleibt unberührt.
|
||||||
|
|
||||||
|
**Basis (Live-Bestand, 0 Umbau):**
|
||||||
|
- `workspace_modules.config` (JSONB) — existiert, ungenutzt → Scope-Speicher pro Modul
|
||||||
|
- `X-Workspace-ID` Header + API-Client-Interceptor (pro Tab) — existiert, wird vom Backend gelesen
|
||||||
|
- `/api/v1/workspaces/context` — existiert, liefert Modul-Konfiguration aus
|
||||||
|
- Sidebar filtert bereits live (isModuleVisible — Consumer-Beweis)
|
||||||
|
- 17/17 Workspace-Tests grün, RLS auf allen 4 Tabellen
|
||||||
|
- Contract-Muster für die Scope-Registry (wie document_placeholders)
|
||||||
|
|
||||||
|
**Security-Invariante:** Scope = reine UND-Einschränkung. Sichtbarkeit = Workspace-Scope ∧ RLS ∧ ABAC ∧ Permissions. Ein Workspace kann NIE mehr sichtbar machen, nur weniger. Ohne aktiven Workspace = kein Filter (rückwärtskompatibel, wie Sidebar).
|
||||||
|
|
||||||
|
### N1 — Scope-Registry via Contract (2 Tage)
|
||||||
|
- Plugins deklarieren `workspace_scopes()` → verfügbare Scope-Dimensionen + Wertequellen (z.B. „folder_ids, Multiselect, via /contacts/folders")
|
||||||
|
- `/context` liefert `config` der Module mit aus; Scope-Definitionen-Endpoint für den Editor
|
||||||
|
|
||||||
|
### N2 — Dynamischer Scope-Editor (2-3 Tage)
|
||||||
|
- WorkspaceManager: pro Modul automatisches Filter-UI aus der Registry (Multiselects für Ordner/Postfächer/Kalender, Toggles, Standard-Ansichten)
|
||||||
|
- Speicherung in `workspace_modules.config`
|
||||||
|
|
||||||
|
### N3 — Erste vier Module integrieren (2-3 Tage)
|
||||||
|
- Contacts: Ordner-Teilmengen, Firmen/Personen-Filter, Standard-Saved-View
|
||||||
|
- DMS: Ordner-Teilmengen, Datei-Typ-Filter
|
||||||
|
- Mail: Postfach-Teilmengen
|
||||||
|
- Calendar: Kalender-Teilmengen, Standard-Ansicht
|
||||||
|
- Backend respektiert X-Workspace-ID bei Listen (additive Filter-Logik, kein Umbau bestehender Routes)
|
||||||
|
|
||||||
|
### N4 — Restliche Module (2-3 Tage)
|
||||||
|
- Tasks (Boards/Listen, „nur meine"), Kommunikation (Räume), Wiki (Kategorien), Reports/Dokumente (Vorlagen), Automation (Agenten), Tags, Suche (Provider), Navigation (Menü-Reihenfolge, Startseite pro Workspace)
|
||||||
|
- Dashboard-Schnittstelle: workspace_widgets bestimmt verfügbare Widget-TYPEN pro Workspace (Admin) — persönliches Layout bleibt Phase M
|
||||||
|
|
||||||
|
**Abhängigkeiten:** unabhängig von Phase M. N3/N4 nach N1+N2.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase P — Notizen-App (Notion-artig, ersetzt das Wiki komplett) (geplant, user-abgestimmt 2026-08-30)
|
||||||
|
|
||||||
|
**Ziel:** Aus dem Wiki wird eine Notion-artige Notizen-/Firmen-Wissen-App: Seiten-Baum (beliebig tief, statt flacher Kategorien), Inline-Block-Editor mit Slash-Menü und Drag&Drop, Quer-Verweise zwischen Seiten, MiniApp-Einbettung, vollständige Such-Indexierung. Das alte Wiki wird KOMPLETT ersetzt (keine Legacy-App parallel).
|
||||||
|
|
||||||
|
**User-Entscheidungen (2026-08-30):**
|
||||||
|
- Keine Notion-Datenbanken zunächst — stattdessen MiniApps als einbettbare Blöcke (Phase M-Synergie)
|
||||||
|
- Später: Plugin-Erweiterbarkeit (eigene Block-Typen via Contract), evtl. Datenbank-Block als Plugin nachlieferbar
|
||||||
|
- Vollständige Such-Indexierung ist PFLICHT (Notizen/Firmen-Wissen auffindbar)
|
||||||
|
- Quer-Verweise (Seiten verlinken Seiten)
|
||||||
|
|
||||||
|
**Edit-Konzept (Notion-Recherche 2026-08-30):** Notion hat KEINEN separaten Bearbeitungsmodus — "all content is editable by default": Klick in die Seite = tippen, Auto-Save im Hintergrund, Slash-Menü für Block-Typen. Confluence macht stattdessen Draft/Publish-Workflow. Für uns: Live-Inline-Editing wie Notion als Standard; der "Lese-Modus" entsteht natürlich über Permissions (nur-Lesen = gerenderte Seite ohne Editierfunktion) + optional Page-Lock. Kein Mode-Toggle im UI nötig.
|
||||||
|
|
||||||
|
**Basis:** Wiki-Plugin (486 Z. Backend: WikiCategory/WikiArticle/WikiArticleVersion + 10 Endpoints) wird erweitert, nicht neu gebaut. Block-Muster aus Phase L (JSONB {id, type, config}), dnd-kit vorhanden, Custom-Field-Engine für spätere Properties, Entity-Links für CRM-Quer-Verweise vorhanden. Unified Search: Provider-Registry + BaseSearchProvider (Embeddings + hybrid FTS/vector) + chunking existieren — die App liefert Provider + Re-Index-Hook.
|
||||||
|
|
||||||
|
### P1 — Datenmodell & Migration (2 Tage)
|
||||||
|
- WikiArticle → WikiPage: `blocks JSONB` (statt content Text), `parent_id` (Seiten-Hierarchie statt Kategorien), `icon`, `is_favorite`, **`is_locked` (Page-Lock, user-entschieden 2026-08-30)**; Quer-Verweise als Block-Typ page_link
|
||||||
|
- Migration: Markdown-Artikel → Text-Blöcke, Kategorien → Eltern-Seiten, Versionen (WikiArticleVersion) bleiben erhalten
|
||||||
|
- Dual-Path: Plugin-SQL idempotent + Alembic-Konvergenz (Gate-B-Muster)
|
||||||
|
|
||||||
|
### P2 — Sidebar mit Seiten-Baum (2 Tage)
|
||||||
|
- Notion-artiger Baum: Seiten anlegen/umbenennen/löschen, Drag&Drop-Umsortierung (dnd-kit), + Button, Kontextmenü, Favoriten, Seitensuche
|
||||||
|
- Ersetzt die alte Kategorien-Navigation komplett
|
||||||
|
|
||||||
|
### P3 — Inline-Block-Editor (4-5 Tage) — Herzstück
|
||||||
|
- Live-Inline-Editing (Klick = tippen, kein Mode-Toggle), Auto-Save debounced in blocks JSONB
|
||||||
|
- Slash-Menü: „/" → Block-Typ-Auswahl
|
||||||
|
- Block-Typen: Text, H1-H3, To-do, Toggle (auf/zu), Quote, Callout, Code, Divider, Bild, Page-Link (Quer-Verweis mit Auto-Vervollständigung), MiniApp
|
||||||
|
- Drag&Drop-Block-Umsortierung (dnd-kit, Phase-L-Erfahrung)
|
||||||
|
- **Page-Lock (user-bestaetigt 2026-08-30):** `is_locked = true` = Seite nicht editierbar (auch mit wiki:write). Backend lehnt Block-Updates mit 409 `page_locked` ab; Frontend zeigt rein gerenderte Seite + Schloss-Badge; Lock setzen/loeschen nur Owner oder Admin (Lock-Button in der Seitentoolbar); gelockte Seiten bleiben fuer Search/Versionen/Kommentare normal indexiert
|
||||||
|
|
||||||
|
### P4 — MiniApp-Blöcke + Plugin-Erweiterbarkeit (1-2 Tage)
|
||||||
|
- „/ MiniApp"-Block-Typ: registrierte MiniApps in Seiten rendern (erster MiniApp-Konsument neben Chat — treibt Phase M mit)
|
||||||
|
- Contract `wiki_blocks()`: Plugins melden eigene Block-Typen für den Editor an (Muster document_blocks) — Basis für späteren Datenbank-Block
|
||||||
|
|
||||||
|
### P5 — Vollständige Such-Indexierung (1-2 Tage)
|
||||||
|
- Content-Extraktion aus Blöcken (Text/Überschriften/To-do/Callout) → content_tsv + Embedding-Chunks (chunking.py)
|
||||||
|
- WikiPage-Search-Provider an unified_search (hybrid FTS + vector, Re-Index bei jedem Auto-Save)
|
||||||
|
- Suchergebnis verlinkt direkt auf Seite + Sprungmarke
|
||||||
|
|
||||||
|
**Abhängigkeiten:** P4 benötigt M1 (Universal-Registry). P1-P3, P5 unabhängig startbar.
|
||||||
|
|
||||||
|
## Phase Q — Frontend-Plugin-Architektur ✓ ABGESCHLOSSEN (2026-09-13, Commits 895f85d + b666fe5, deployed, Health healthy)
|
||||||
|
|
||||||
|
> **Umgesetzt am selben Tag wie geplant.** Q3 (Generator + PluginLoader) + Q4 (MiniAppHost)
|
||||||
|
> in 895f85d; Q1 (statische Plugin-Routen entfernt) + Q2 (Settings-Routen + Renderer-Variante)
|
||||||
|
> in b666fe5. Details und Live-Beweise: PROGRESS.md Phase-Q-Section.
|
||||||
|
|
||||||
|
|
||||||
|
**Ziel:** Die letzten verbliebenen Plugin-Grenzverletzungen im Frontend beseitigen — ein Plugin soll sein Backend, Manifest UND React-Seite liefern können, ohne dass zentrale Frontend-Dateien angefasst werden müssen. Basis: externes Architektur-Audit (2026-09-13), dessen Backend-Punkte bereits gefixt sind (siehe PROGRESS.md „Externer Architektur-Audit"); die vier Frontend-Punkte sind bewusst als eigene Phase geplant, weil sie ein durchdachtes Build-Time-Discovery-Konzept erfordern (Vite kann dynamische Imports zur Laufzeit im Production-Bundle nicht zuverlässig auflösen).
|
||||||
|
|
||||||
|
### Q1 — Statische Plugin-Routen aus routes/index.tsx entfernen (Doppel-Architektur)
|
||||||
|
- Status quo: `/calendar`, `/dms`, `/mail`, `/reports`, `/tasks`, `/communication`, `/workflows`, `/import-export`, `/wiki`, `/agents`, `/automation` sind statisch im zentralen Router eingetragen UND kommen gleichzeitig über die Plugin-Manifeste via PluginRouteRenderer.
|
||||||
|
- Ziel: Nur noch PluginRouteRenderer bedient Plugin-Seiten; statische Einträge nur für echte Core-Seiten (Dashboard, Settings-Shell, Login, Trash, Approvals bis Core-Migration).
|
||||||
|
- Risiko: Manifest-Routen müssen Permissions, Layout-Einbindung (AppShell-Children vs. eigenständig) und Ladezustände 1:1 abbilden.
|
||||||
|
|
||||||
|
### Q2 — Statische Settings-Routen ausdünnen
|
||||||
|
- Status quo: settings/roles, users, groups, mail, notifications, ai, ai-proactive, automation, documents sind statisch UND via settings_pages der Manifeste vorhanden.
|
||||||
|
- Ziel: settings_pages (Manifest) wird einzige Wahrheit für Plugin-Settings-Seiten; statische Einträge nur für Core-Settings (theme, system, backup, webhooks, menu, workspaces).
|
||||||
|
|
||||||
|
### Q3 — STATIC_COMPONENT_MAP ersetzen durch Build-Time-Discovery
|
||||||
|
- Status quo: PluginLoader.tsx hält eine zentrale Komponenten-Liste (~26 Einträge). Ein neues Plugin muss die Leo-Frontend-Codebasis anfassen.
|
||||||
|
- Ziel: Build-Skript scannt app/plugins/builtins/*/plugin.py auf FrontendPageRoute/SettingsPage/Component-Pfade und generiert automatisch eine Import-Map (generated, committet), die Vite statisch chunken kann. Keine manuelle Zentral-Liste mehr.
|
||||||
|
|
||||||
|
### Q4 — widgetRegistry in MiniAppHost durch generierte Map ersetzen
|
||||||
|
- Status quo: 11 Widget-Komponenten sind zentral hardcodiert (RecentContactsWidget, TasksSummaryWidget, ...).
|
||||||
|
- Ziel: Q3-Mechanismus deckt auch dashboard_widgets/miniapps component-Pfade ab; MiniAppHost nutzt dieselbe generierte Import-Map.
|
||||||
|
|
||||||
|
**Reihenfolge (wie umgesetzt):** Q3 → Q1/Q2 → Q4 (Q4 fiel mit Q3 mit, da MiniAppHost dieselbe generierte Map nutzt). Jeder Schritt mit Vitest-Sicherung der betroffenen Seiten und Production-Build-Verifikation (Chunk-Existenz prüfen).
|
||||||
|
|
||||||
|
## Externaudit Astra 2026-09-17 (41 Findings) — Sanierung PHASE S (bestätigt, NÄCHSTE PHASE, vor/neben R)
|
||||||
|
|
||||||
|
**Auditergebnis:** 2 P0 (KI führt nicht freigegebene Tools aus; Mandantenverwaltung kann globale Anmeldeidentitäten ändern), 29 P1, 10 P2. Geprüft am vollständigen Stand ee5545d (ZIP). Interne Verifikation am 2026-09-17: 10 Findings stichprobenartig am Code nachgelesen (F01, F02, F05, F08, F10, F12, F17, F24, F37, F41) — **alle 10 korrekt**. Übrige Findings: detailliert mit Zeilennummern belegt, Detail-Verifikation erfolgt jeweils bei Umsetzung. Volltext des Audits: [docs/audits/astra-audit-2026-09-17.md](docs/audits/astra-audit-2026-09-17.md); Kernpunkte je Finding in den Wellen-Issues.
|
||||||
|
|
||||||
|
**Strukturdiagnose (Astra):** Mehrere Stellen verwalten denselben Zustand (Plugin-Aktivität, Schema); Contracts garantieren zu wenig Verhalten; API- und Worker-Ausführung nicht gleichwertig; Berechtigungsprüfungen liegen zu weit vom Seiteneffekt entfernt; Statusanzeigen teils von tatsächlicher Funktion entkoppelt. — Bestätigt und deckt sich mit den realen Incidents (#389 Plugin down 4 Wochen, #380 158 Events failed).
|
||||||
|
|
||||||
|
**Sanierungswellen (Reihenfolge nach Risiko, an Astra-Empfehlung angelehnt):**
|
||||||
|
|
||||||
|
### Welle S1 — Sicherheitsgrenzen (P0 + Auth/Permission-Kette) — ZUERST
|
||||||
|
- **F01 (P0)** agent_loop._execute_tool: Tool-Ausführung ohne Allowlist- und Permission-Check — unmittelbar vor Handleraufruf prüfen: Tool in der dem LLM angebotenen Liste, required_permission gegen aktuelle User-Rechte, Verbote, Mandant, Plugin aktiv, ggf. Approval. Abnahme: nicht angebotenes Tool → Ablehnung, Handler bleibt null.
|
||||||
|
- **F02 (P0)** users.py update_user: globale User.email durch Mandanten-Admin (users:write) änderbar → globale Identitätsänderungen (email, is_system_admin global, Passwort) von Mandantenverwaltung trennen; nur Selbstservice oder echte globale Admin. Abnahme: Tenant-Admin kann globale E-Mail/Aktivstatus fremder Mandanten-Mitglieder nicht ändern.
|
||||||
|
- **F10** require_permission: Token-Scopes ersetzen User-Rechte (early-return) → effektive Rechte = Schnittmenge(User, Token-Scopes, Delegation), Verbote vorrangig. Abnahme: Token mail:write + User ohne mail:write → 403.
|
||||||
|
- **F05** require_active_plugin läuft vor Auth/ohne Mandantenkontext → Plugin-Gate an authentifizierten Kontext binden, fehlender Kontext = ablehnen. Abnahme: mandantendeaktiviertes Plugin → 403 auch bei gültiger Session.
|
||||||
|
- **F03** Session-Widerruf: Deaktivierung/Austritt/Löschen/Passwortwechsel müssen in Redis- UND DB-Fallback-Sessionpfaden wirken; Widerruf dauerhaft speichern. Abnahme: Widerruf wirkt auch bei Redis-Ausfall.
|
||||||
|
- **F11** Approval-Resolution: approver_id/Ablauf/Gruppe/Atomarität prüfen, Entscheider getrennt speichern, Approval an Aktion+Argumente+Revision binden.
|
||||||
|
- **F15** Workflow-HTTP: aufgelöste IPv4/6-Ziele gegen Privatnetz prüfen, Verbindung an geprüfte Auflösung binden, Redirects prüfen.
|
||||||
|
- **F20** prestart überschreibt gezielte Rechte-Entzüge (0100) mit pauschalem GRANT DELETE → Tabellenschutz nur migrieren; keine Rechteanhebung beim Start.
|
||||||
|
- **F21** test_migrations.sh: MIGRATION_DATABASE_URL überschreiben + Zielidentität vor DDL prüfen (sonst Gefahr für echte DB).
|
||||||
|
- **F23** Tenant-Backup-API triggert datenbankweiten Restore → Gesamtrestore als globale Betriebsoperation mit separater Berechtigung.
|
||||||
|
- **F30** Admin-Standardpasswort bei unkonfiguriertem Start → verpflichtendes Secret oder sicherer Einmal-Generierung.
|
||||||
|
|
||||||
|
### Welle S2 — Ausführung verbinden (Worker, Jobs, Contracts, Migrationen)
|
||||||
|
- **F06** Worker registriert keine der 44 Plugin-Event-Handler (BasePlugin.register_event_handlers ist leer) → API und Worker dieselbe idempotente Registrierung; Abnahme über echten Outbox-Durchgriff (Kontakt anlegen → Worker → Suchindex).
|
||||||
|
- **F07** Hintergrundjobs verlieren Mandantenkontext/Transaktionen → Mandant+Auftraggeber im Job-Payload Pflicht; Kontext vor erstem SQL; fachliche Änderung+Audit+Outbox gemeinsam committen.
|
||||||
|
- **F08** External-Agent-API: require_permission an Cookie-Auth gebunden (Bearer nie erreicht) + get_db() ist kein Contextmanager (TypeError) → gemeinsamer geprüfter Auth-Kontext für Cookie+Token; Session-Factory statt get_db.
|
||||||
|
- **F09** CRM-/MCP-Tools senden nicht anerkannte interne Header → Delegationsmechanismus (delegation_token.py) einbinden; UI und Agent gleiche Rechte-Antwort.
|
||||||
|
- **F12** Workflow approve/reject: approval["id"] auf ORM-Objekt (TypeError) + falsche resolve-Signatur → an zentralen Vertrag anpassen, wartende Freigabe auflösen statt Selbst-Genehmigung.
|
||||||
|
- **F13** Workflow-Engine: acquire_lock ohne Aufrufer, Idempotenz unvollständig, Resume ungesperrt → Engine als verbindlichen Zustandsübergang; Abnahme: Worker-Neustart + parallele Resume → keine Doppel-Mails.
|
||||||
|
- **F14** enforce_data_policy lässt Strings ungefiltert + läuft nur vor der Schleife mit db=None → strukturierte Filterung vor Serialisierung; JEDE LLM-Anfrage (inkl. Tool-Antworten) durch Policy; nicht ladbare Policy = Versand-Stop.
|
||||||
|
- **F16** Plugin-Lifecycle: prestart reaktiviert absichtlich deaktivierte Plugins; Aktivierungsfehler lassen DB-Zustand aktiv → gewünschten Zustand von Installation/Mandantenfreigabe/Laufzeitgesundheit trennen; Abnahme: Deaktivierung überlebt Neustart.
|
||||||
|
- **F17** 6 Produktionsstellen rufen ContractRegistry.get() auf (existiert nicht; nur get_contract) → Aufrufer fixen; Abnahme über reale Einstiegspunkte (Miniapp-Tools, proaktive Hinweise, Report-Jobs).
|
||||||
|
- **F18** Drei Schema-Verfahren (Alembic/Plugin-SQL/sync_plugin_schema) mit Sync-Verlust bei Unique/Partial-Indizes → einen Migrationsbesitzer pro Objekt; Startup-Sync als lesender Driftbericht.
|
||||||
|
- **F19** alembic/env.py lädt nur app.models (46/129 Tabellen; Sortierung scheitert) → deterministische vollständige Modelldiscovery.
|
||||||
|
- **F31** Provider-Registry vs. Reindex-Listen divergieren → Plugin-Beiträge als gemeinsame Quelle; Abnahme: neuer Provider wird vollständig indiziert.
|
||||||
|
- **F37** SMTP-Env-Namen (SMTP_USER vs smtp_username u.a.) → Compose/Config/Doku angleichen; Abnahme: Reset-/Alarm-Mail authentifiziert.
|
||||||
|
- **F40** Plugin-Migrationen nur Dateiname-Tracking → Hashes speichern und prüfen; Sollzustand vorhandener Tabellen (Spalten/FKs/Policies) vergleichen.
|
||||||
|
- **F41** Agenten-Stundenlimit zählt ab jetzt() statt letzte Stunde → timedelta(hours=1); Kontingent atomar reservieren.
|
||||||
|
|
||||||
|
### Welle S3 — Fachliche Integrität (Daten- und UI-Korrektheit)
|
||||||
|
- **F25** CSV-Import: Rollback vernichtet frühere Zeilen, Zähler behalten Erfolge, RLS-Kontext weg → Savepoints pro Zeile, Original-Zeilennummern; Zähler = Persistenz.
|
||||||
|
- **F26** DMS-Dedup vermischft Identität (fremder Datensatz statt eigener Upload) → Content-Storage vs. Fachobjekt trennen; jeder Upload eigene Identität/Rechte.
|
||||||
|
- **F27** Kalender: SQL-Filter wirft Serien weg bevor Wiederholungen berechnet werden; end_at-Dauer; Mehrtagesüberlappung → Serie nach Laufzeit selektieren, Wiederholungen im Fenster erzeugen.
|
||||||
|
- **F28** Import/Export ohne Fachrechte (import_export:write ≠ contacts:write; Export ohne Feldrechte) → Fachrechte UND Importrecht; Feldfilter vor Dateierzeugung.
|
||||||
|
- **F32** Suche: entity_types=[] = alle (soll 0), Filter nach Top-N, Offset unwirksam, before_search zu spät → None/[] unterscheiden; Filter vor Limit; Hook vor Parametern.
|
||||||
|
- **F33** Workspace-Wechsel invalidiert fachliche Querykeys nicht → Workspace in Query-Identität oder kontrolliert verwerfen.
|
||||||
|
- **F34** Mandantenwechsel: alte Daten bis Refetch sichtbar → kontrollierter Kontextwechsel (Abbrechen, Caches leeren, Header synchronisieren).
|
||||||
|
- **F38** pluginStore-Fehler → Dauerspinner (loaded bleibt false) → Fehler/Leer/Erfolg getrennt rendern, Retry anbieten.
|
||||||
|
- **F39** Office-Edit-Session verweist auf /preview (PDF-only) + Callback-Route existiert nicht → funktionsfähigen Ablauf anbinden oder Feature als nicht-betriebsbereit kennzeichnen.
|
||||||
|
|
||||||
|
### Welle S4 — Betriebsfreigabe (inkl. korrigierter Phase R)
|
||||||
|
- **F22** Backup im Container nicht betriebsfähig (pg_dump fehlt, Pfade nicht persistiert, Kontext-/User-Bugs) → dokumentierter Ablauf mit Programmen, Rechten, persistiertem Ziel.
|
||||||
|
- **F24** /health/ready liefert 200 bei not_ready; Worker-Check meldet up ohne Worker → korrekte HTTP-Codes (503), Heartbeat-Alter statt Queue-Länge.
|
||||||
|
- **F29** CI ohne Lockfiles/Testdienste/tatsächliches Artefakt → reproduzierbare Pipeline gegen eigenes Image.
|
||||||
|
- **F36** Komponenten-Map-Generator nicht verbindlich im Build → Check an npm-Build/Dockerfile/CI hängen.
|
||||||
|
- **F04** Suche: autocomplete/similar ohne Objekt-/Feldrechte; Snippet/Titel unfiltert zur LLM → ein Schutzpfad für ALLE Suchvarianten vor Snippet- und LLM-Übergabe.
|
||||||
|
- **F35** PWA abgeschaltet, aber Offline-Banner verspricht Schreibspeicherung → PWA wiederherstellen ODER Banner an Realität anpassen.
|
||||||
|
- Phase-R-Korrektur (siehe unten, bereits eingearbeitet).
|
||||||
|
|
||||||
|
**Abnahmeszenarien quer über alle Wellen (Astra-Vorschlag, verbindlich):**
|
||||||
|
1. Kontaktanlage → Audit/Outbox → separater Worker → Suchindex → erlaubte KI-Abfrage (F06, F07, F17, F04)
|
||||||
|
2. Mailentwurf → Freigabe → einmaliger Versand → nachvollziehbares Ergebnis (F11, F12, F13)
|
||||||
|
|
||||||
|
**Reihenfolge-Logik:** S1 zuerst (jede nicht autorisierte Aktion verboten), S2 parallel startbar nach S1-P0s, S3/S4 danach. Nach S1+S2 verifizierter Welle: Aufwand neu schätzen (Astra-Hinweis: die 9-14 Tage aus Phase R sind keine Schätzung für 41 Findings).
|
||||||
|
|
||||||
|
## Phase R — Betriebssicherheit & 95%-Produktionsreife (geplant, user-abgestimmt 2026-09-16 — läuft in S4 auf; korrigiert 2026-09-17 nach Astra-Kritik)
|
||||||
|
|
||||||
|
**Ziel:** Von „Produktion läuft stabil" zu „Produktion verlässlich": stille Ausfälle werden automatisch erkannt und alarmiert (Minuten statt Wochen), die Test-Suite wird zum vertrauenswürdigen Regressionsschutz, Schema-Drift wird automatisch erkannt, Kernprozesse werden nach jedem Deploy regressionsgetestet, Backups sind nachweislich wiederherstellbar.
|
||||||
|
|
||||||
|
**Warum diese Phase (Evidenz aus realen Incidents):**
|
||||||
|
- KI-Chat war 4 Wochen still down — ai_assistant migration_failed seit 2026-08-21, entdeckt am 2026-09-16 nur durch Zufall (#389)
|
||||||
|
- External-API war durch CSRF-Middleware für externe Systeme unbrauchbar (fix b91ee5b)
|
||||||
|
- Outbox: 158 failed Events wochenlang unbemerkt (#380)
|
||||||
|
- Suite-Isolation und alembic-check-Blockade verhindern verlässliche Regressionsschutz-Gates
|
||||||
|
|
||||||
|
**95%-Definition (korrigiert 2026-09-17 nach Astra-Kritik):** Die fünf Kriterien sind kein mathematischer Reifegrad, sondern **konkrete Freigabekriterien**. Dokumentiert wird: erfüllte Kriterien, verbleibende Risiken und bekannte Grenzen (Battle-Testing im Echtbetrieb). „95 %" = Zustand, in dem jeder Ausfall laut statt still wird; die restlichen ~5 % sind Echtbetriebs-Edge-Cases, die nur echte Nutzung findet.
|
||||||
|
|
||||||
|
**Astra-Kritik an Phase R (8 Punkte, 2026-09-17) — eingearbeitet:**
|
||||||
|
1. ARQ-Heartbeat überwacht sich nicht selbst → zusätzlich externe Überwachung außerhalb der ARQ/Redis-Ausfallkette (z.B. Cron auf Host oder externer Uptime-Check gegen /health/ready).
|
||||||
|
2. „Installiert aber inaktiv"-Alarm trifft absichtliche Deaktivierung → **Sollzustand** (DB desired state) mit tatsächlicher Betriebsbereitschaft vergleichen; nur Abweichung alarmiert.
|
||||||
|
3. Leere Queue ≠ laufender Worker → Worker-Heartbeat-ALTER und Verarbeitungsnachweis messen, nicht Queue-Länge.
|
||||||
|
4. Komplette Suite grün reicht nicht (Mocks/Admin-Tests können Rechtefehler verdecken) → zusätzlich echte API-/Worker-Prozesse mit tatsächlichen Laufzeitrollen prüfen.
|
||||||
|
5. Ein FK-Fix + Migrationshash genügt nicht → vollständige Modelldiscovery (F19) und eindeutige Schema-Verantwortung (F18) sind Voraussetzung; R3 hängt an S2.
|
||||||
|
6. E2E-Normalfälle prüfen Rechteentzug/Neustart nicht → Mehrmandanten-, Rollen-, Fehler- und Wiederaufnahme-Szenarien ergänzen.
|
||||||
|
7. Monatlicher Restorejob beweist keine sichere Zielwahl → isoliertes Ziel und tatsächliche DB-+Datei-Wiederherstellung nach Containerersatz nachweisen.
|
||||||
|
8. „95 % Produktionsreife" ist keine messbare Zahl → konkrete Freigabekriterien + verbleibende Risiken dokumentieren (siehe oben).
|
||||||
|
|
||||||
|
**Aufwandskorrektur (Astra):** Die 9-14 Tage gelten NICHT für die Behebung aller 41 Audit-Findings (Phase S). Neue Schätzung nach Abschluss von S1+S2.
|
||||||
|
|
||||||
|
### R1 — Stille-Ausfälle-Wächter + Alerting (2-3 Tage) — PRIORITY 1, größter Risikoreduktor
|
||||||
|
- ARQ-Heartbeat-Job (alle 5 Min) prüft: (a) /api/v1/plugins — installiert aber nicht active → ALARM (exakt der #389-Fall), (b) /health/ready — DB/Redis/Storage/Worker, (c) Outbox-DLQ — failed > 0 (#380-Klasse), (d) Worker-Queue-Länge
|
||||||
|
- Alarm-Kanal: E-Mail über bestehende Mail-Infra (SMTP) an Admins; Alarm-Zustand zusätzlich als rote Badge im Admin-UI (System-Dashboard)
|
||||||
|
- Abnahme live: Plugin absichtlich deaktivieren → Alarm muss nachweislich auslösen (Chaos-Test)
|
||||||
|
- Bestand, auf dem aufgebaut wird (kein Neubau): /health/ready (docs/monitoring.md), ARQ-Worker (app/core/worker.py), Mail-Plugin (SMTP), System-Dashboard-Routen
|
||||||
|
|
||||||
|
### R2 — Test-Suite verlässlich machen (2-3 Tage)
|
||||||
|
- Suite-Isolation fixen: Combo-Runs quaken mit „relation users does not exist" (Solo grün) — conftest.py-DB-Setup deterministisch machen
|
||||||
|
- Vitest-Worker-OOM fixen (Worker-/Fork-Konfiguration)
|
||||||
|
- Abnahme: `python -m pytest` kompletter Lauf grün + `npx vitest run` kompletter Lauf grün — erst DANACH gilt die Suite als verbindliches DoD-Gate
|
||||||
|
|
||||||
|
### R3 — Schema-Integrität automatisieren (1-2 Tage)
|
||||||
|
- entity_attachments-FK fixen → `alembic check` läuft als Schema-Drift-Gate
|
||||||
|
- Migration-Runner: Hash-Check ergänzen — geänderte getrackte Migration = Alarm statt stiller Skip (verhindert die #389-Bugklasse systemisch)
|
||||||
|
- scripts/schema_drift_check.py + scripts/check_migration_hashes.py in scripts/ci_pipeline.sh integrieren
|
||||||
|
|
||||||
|
### R4 — E2E-Kernprozess-Regression (2-3 Tage)
|
||||||
|
- Playwright-Suite über Kern-Flows: Login, Kontakte-CRUD, Mail senden/lesen, DMS upload/download, Kalender-Termin, KI-Chat-Antwort, Workflow-Ausführung, Gäste einladen
|
||||||
|
- Automatischer Run nach jedem Full-Deploy (fast-deploy.sh-Erweiterung)
|
||||||
|
- Bestand: Playwright-Setup existiert (frontend/e2e/, Login-E2E bewiesen funktioniert)
|
||||||
|
|
||||||
|
### R5 — Backup-/Restore-Nachweis (1-2 Tage)
|
||||||
|
- scripts/restore_drill.sh monatlich per ARQ-Job/Cron ausführen + Ergebnis alarmieren
|
||||||
|
- RTO/RPO messen und dokumentieren (scripts/backup.py, scripts/restore.py, restore_test.sh existieren)
|
||||||
|
|
||||||
|
### R6 — Ops-Runbook & Alarm-Kette final (1 Tag)
|
||||||
|
- Eskalationskette: Wer wird wie alarmiert (E-Mail/Handy), wer reagiert
|
||||||
|
- docs/incident-response-runbook.md um die realen Ausfallklassen ergänzen (Plugin-inactive, DLQ-Vollauf, Migration-Crash, CSRF/Auth-Layer, Worker-Stillstand) — jede mit Schritt-für-Schritt-Fix aus dem echten Incident
|
||||||
|
|
||||||
|
**Aufwand gesamt: ~9-14 Arbeitstage.** R1 zuerst (unabhängig startbar), R2 parallel, R3 nach R2, R4 nach R1, R5/R6 unabhängig. Kann mit Phase O/P verzahnt werden — aber R1-R3 vor neuen Features.
|
||||||
|
|
||||||
|
**Definition of Done Phase R:** Alle 5 Abnahmekriterien live gemessen und grün + ein dokumentierter Chaos-Test (absichtlicher Ausfall → Alarm in < 30 Min). Pro Task ein Forgejo-Issue mit Milestone „Phase R — Betriebssicherung" (AGENTS.md §9).
|
||||||
|
|
||||||
|
## UI-Backlog — Backend-Module ohne UI (laufend seit 2026-09-08, Source of Truth: PROGRESS.md-Tabelle)
|
||||||
|
|
||||||
|
**Kontext:** Frontend-Backend-Gegenüberstellung (2026-09-01) ergab 16 Backend-Module ohne UI (~64 Ops). User-Entscheidung: Module einzeln mit UI ausstatten, priorisiert nach Business-Nutzen. Jedes Modul folgt derselben Verifikationskette: Vitest → tsc → Production-Build → Deploy → Live-API-Check → Forgejo-Issue → PROGRESS.md-Update.
|
||||||
|
|
||||||
|
**Status 2026-09-16: 16/16 erledigt — UI-BACKLOG KOMPLETT.**
|
||||||
|
- Erledigt: 1 Approvals, 2 Delegations, 3 API-Tokens, 4 Tenants, 5 Marketplace, 6 Permission-Templates, 7 Skills, 8 Agent-Memory, 9 Outbox, 10 Policies, 11 Graph-RAG, 12 Companies, 13 Public-Share, 14 Guests, 15 External-Agent, 16 Ownership-Transfer (Commits + Issues #369, #372-#388 in PROGRESS.md-Tabelle)
|
||||||
|
- Alle 16 Backend-Module haben jetzt UI. Bei neuen Backend-Modulen ohne UI: analog verfahren.
|
||||||
|
|
||||||
|
**Architektur-Regel (seit Phase Q):** Plugin-Module (wie Marketplace, Skills, Agent-Memory) werden AUSSCHLIESSLICH via Plugin-Manifest registriert (page_routes + menu_items + Komponenten-Map-Generator) — routes/index.tsx und Sidebar.tsx bleiben unangetastet. Core-Module (wie Delegations, API-Tokens, Tenants, Permission-Templates) laufen als statische Core-Routen + Settings-Nav.
|
||||||
|
|
||||||
|
**Muster:** Jedes Modul = api/<modul>.ts (TanStack-Hooks) + pages/<Modul>.tsx (Karten/Dialoge/Permission-Gating) + i18n de/en + Vitest-Tests + Registrierung. Referenz-Implementierungen: Approvals (Core) und Marketplace (Plugin/Phase Q).
|
||||||
|
|
||||||
|
|||||||
+439
-5
@@ -1,7 +1,142 @@
|
|||||||
# LeoPlatform — Fortschritts-Tracking
|
# LeoPlatform — Fortschritts-Tracking
|
||||||
|
|
||||||
> **Letztes Update:** 2026-08-27
|
## Externer Architektur-Audit — 13 Backend-Fixes verifiziert & umgesetzt (2026-09-13, Commit 4a25ac1, [#370](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/370)) ✅
|
||||||
> **Status:** Phase A-K done (261/261 Tasks), 25 Plugins aktiv, Alembic 0136, 2174 Tests
|
|
||||||
|
**Ausgangslage:** Externes KI-Audit (leocrm-full.zip, Stand 86cea5d) meldete 17 Findings. Verifikation gegen den echten Code: **alle 17 BESTÄTIGT** (inkl. exakt der 12 gemeldeten fehlenden Permission-Keys — per AST-Scan 1:1 reproduziert). 13 Backend-/Lifecycle-Punkte sofort gefixt; die 4 Frontend-Plugin-Architektur-Punkte sind als **Phase Q** in die Roadmap eingeplant (Begründung dort).
|
||||||
|
|
||||||
|
**Fixes (alle mit Live-Verifikation, `tests/test_audit_architecture_fixes.py` 17/17):**
|
||||||
|
|
||||||
|
| # | Finding | Fix | Verifikation |
|
||||||
|
|---|---|---|---|
|
||||||
|
| P1 | `GET /workspaces` lieferte `modules: []` → Workspace-Editor überschrieb Konfig | `list_workspaces()` lädt Module+User-Counts gebündelt (2 Queries statt N+1) | test_f1: modules mit is_visible-Flags 1:1 |
|
||||||
|
| P1 | `/plugins/active-manifests` ignorierte Tenant-Deaktivierung (UI zeigte 403-Menüs) | Registry/Service/Route tragen `tenant_id` durch, filtern `tenant_plugin_activation.is_active=false` | test_f2: Plugin im Manifest ohne Filter, gefiltert mit Tenant-Eintrag |
|
||||||
|
| P1 | `uninstall()` umging PluginService-Cleanup (stale Permissions/Entity-Models) | `uninstall_plugin()` ruft `deactivate_plugin()` VOR `registry.uninstall()` | test_f3: Quellcode-Verifikation + Lifecycle-Verhalten |
|
||||||
|
| P1 | Contract-Lazy-Loading kannte DB-Aktivstatus nicht (Restart-Edge-Case) | Startup markiert `active=False`-Plugins (`mark_db_inactive`), Guard in `get_contract()`, Re-Activate cleart | test_f4: fail-closed + reopen |
|
||||||
|
| P1/P2 | `register_field_definitions()` ohne Unregister, nicht im Runtime-Lifecycle | `unregister_field_definitions()` + Aktivierung/Deaktivierung registrieren/entfernen Field-Defs | test_f5: voller Lifecycle über PluginService |
|
||||||
|
| P1/P2 | 39 Contact-Felddefinitionen lagen im Core (`CORE_FIELD_DEFINITIONS`) | Verschoben ins ContactsPlugin-Manifest (`field_definitions=`); Core behält nur users-Felder; `sensitive_data.py` nutzt jetzt die Registry-Gesamtsicht | test_f10: Core ohne contacts-Module, Plugin mit 39 Defs, Sensitivities erhalten |
|
||||||
|
| P1 | 12 verwendete Permission-Keys nicht registriert | 9 in CORE_PERMISSIONS (automation:admin, bank-accounts:*, delegations:*, policies:*, templates:*), 2 im permissions-Plugin (permissions:read/admin), 1 im forgejo-Reporter (system:read) | AST-Re-Scan: 146 Keys, **0 fehlend**; test_f9 |
|
||||||
|
| P2 | `contact_folder` als Core-Entity | Ins ContactsPlugin verschoben; `register_entity_model(..., plugin_name=...)` befüllt jetzt ENTITY_PLUGIN_OWNERS (war tot) | test_f15: `get_entity_read_permission('contact_folder') == 'contacts:read'` via Owner |
|
||||||
|
| P2 | Entity-Permission-Fallback `contacts:read` | Fail-closed Sentinel `__unmapped__:read` (nicht grantbar → 403); unbekannte Entities werden vorher via 422 abgelehnt | test_f15 |
|
||||||
|
| P2 | Forgejo-Error-Reporter `is_core=True` trotz „test/staging only" | `is_core=False` (deaktivierbar) | test_f11 |
|
||||||
|
| P1/P2 | Core-FK `entity_attachments.files` vs. „DMS = Plugin" Widerspruch | **ADR-020:** DMS als Plattform-Core-Plugin deklariert (`is_core=True`) — FK-Richtung ist damit legitim, Registry erzwingt Nicht-Deaktivierbarkeit | test_f12 |
|
||||||
|
| P2 | Core-Worker importierte Contact für Trash-Cleanup | `cleanup_contacts_trash` ins Contacts-Plugin ausgelagert (jobs.py, `get_job_modules()`-Discovery wie knowledge), Cron 04:15 | test_f13: kein `app.models.contact`-Import im Worker + Job registriert |
|
||||||
|
| P1/P2 | DSGVO-Export doppelt (Legacy-Route kannte Contacts direkt) | `GET /dsgvo-export` delegiert an `_dsar_collect_user_data` (autoritativer DSAR-Collector, Plugin-Contracts) | test_f14: Delegation, kein Contact-Import |
|
||||||
|
| P2 | False-green Tests (`or True`, irreführender Name, veraltete >100-Routes-Assertion) | 3 Assertions durch echte Prüfungen ersetzt; Test umbenannt (`_simulated`); Route-Count-Assertion auf Plugin-Architektur umgestellt (vorher schon auf HEAD rot — pre-existing) | Suite grün |
|
||||||
|
|
||||||
|
**Nicht als Code-Fix, sondern als Phase Q geplant** (Roadmap „Phase Q“, user-pending): statische Plugin-Routen + Settings-Routen im zentralen Router (Doppel-Architektur), STATIC_COMPONENT_MAP, widgetRegistry — benötigt Build-Time-Discovery-Konzept.
|
||||||
|
|
||||||
|
**Beweis Suite-Isolation (nicht durch Fixes verursacht):** test_m4_system_miniapps solo 7/7 grün (mit UND ohne Fixes), test_n4_scope_declarations solo 18/18 grün — Combo-Failures sind das bekannte „relation users does not exist“-Problem.
|
||||||
|
|
||||||
|
**Regressionen:** test_contacts_lifecycle 8/8, test_custom_field_definitions, test_contacts_entity_registry 3/3, test_contacts_model_ownership, test_workspace_scopes 18/18, test_rbac_comprehensive, test_plugin_lifecycle_service, test_einvoice_generator — alles grün. Cross-Plugin-Checker: 497 Dateien, 0 verbotene Imports. compileall sauber. Ruff auf 7-Error-Baseline.
|
||||||
|
|
||||||
|
**Deployiert & live bewiesen (Commits 4a25ac1 + 1b80090, 2x Full Deploy SUCCESS, Health healthy, Worker up):**
|
||||||
|
- GET /workspaces: Standard-Workspace liefert modules=24 (vorher []) — Editor-Overwrite-Bug behoben
|
||||||
|
- GET /plugins/active-manifests: 26 Manifeste
|
||||||
|
- GET /roles/permissions: 141 Keys, 12/12 neue Keys sichtbar (erste Deploy-Runde nur 11/12 — system:read fehlte, weil der ursprüngliche Patch die permissions-Liste versehentlich in PluginRouteDef-kwargs platziert hatte; in 1b80090 korrekt auf Manifest-Ebene, Test f9 prüft jetzt echte Manifeste statt manueller Registrierung — Live-Check ist DoD-Pflicht)
|
||||||
|
- field_definitions: contacts=39 (Plugin), users=4 (Core) — Ownership-Verschiebung live bestätigt
|
||||||
|
|
||||||
|
## Phase Q — Frontend-Plugin-Architektur vollendet (2026-09-13) ✅ — PHASE Q KOMPLETT
|
||||||
|
|
||||||
|
**Ausgangslage:** Die 4 Frontend-Findings des externen Audits (Doppel-Architektur
|
||||||
|
Routen/Settings, STATIC_COMPONENT_MAP, widgetRegistry) wurden als Phase Q geplant
|
||||||
|
und jetzt vollständig umgesetzt. Ein Plugin meldet ab sofort Backend, Manifest
|
||||||
|
UND React-Komponenten über sein Manifest — keine zentrale Frontend-Datei muss
|
||||||
|
mehr angefasst werden.
|
||||||
|
|
||||||
|
**Q3+Q4 (Commit 895f85d) — Build-Time-Discovery statt Zentral-Listen:**
|
||||||
|
- `scripts/generate_component_map.py`: scannt alle builtin-Manifeste +
|
||||||
|
system_miniapps.py, generiert `frontend/src/generated/pluginComponents.generated.ts`
|
||||||
|
(37 Komponenten). Fail-Hard bei Ghost-Komponenten (bewiesen: exit 1), erkennt
|
||||||
|
default- vs. named-exports, deterministisch, `--check`-Modus für CI.
|
||||||
|
- PluginLoader.tsx: STATIC_COMPONENT_MAP (26 Einträge) GELÖSCHT → generierte Map.
|
||||||
|
- MiniAppHost.tsx: widgetRegistry (11 Einträge) GELÖSCHT → generierte Map (löst Q4 mit).
|
||||||
|
- Contacts-Manifest: DedupMergePage-Pfad-Alias auf echte Datei korrigiert.
|
||||||
|
|
||||||
|
**Q1+Q2 (Commit b666fe5) — Manifeste = einzige Routen-Quelle:**
|
||||||
|
- routes/index.tsx: 14 statische AppShell-Plugin-Routen + 9 statische
|
||||||
|
Settings-Routen + 20 tote Lazy-Imports entfernt. Nur noch Core-Routen + die
|
||||||
|
StartLayout-Hub-Bäume (/agents, /automation, /logs, /help — verschachtelte
|
||||||
|
Sub-Navigation) bleiben statisch (bewusste Entscheidung: Layout-Routen mit
|
||||||
|
Sub-Navigation werden von flachen Manifest-Einträgen nicht abgebildet).
|
||||||
|
- PluginRouteRenderer: neue `variant`-Prop — 'pages' (absolute Pfade, AppShell-
|
||||||
|
Catch-all) vs. 'settings' (bare Sub-Segmente, Descendant-Matching im
|
||||||
|
/settings-Subtree). Getrennte Entry-Listen verhindern Pfad-Kollisionen.
|
||||||
|
- Manifeste ergänzt: Calendar +/calendar/kanban, Tags +/tags (+ Menü-Item),
|
||||||
|
Automation: /workflows auf workflows:read (Parität zur ersetzten statischen
|
||||||
|
Route), tote flache /agents-+/automation-Einträge entfernt.
|
||||||
|
|
||||||
|
**Verifikation (jeder Schritt live gemessen):**
|
||||||
|
- tsc --noEmit exit 0 (nach Q3 und nach Q1/Q2) · production build exit 0 (2×)
|
||||||
|
- Ghost-Fail-Hard: Generator exit 1 mit Fehlermeldung bei eingepflanzter Ghost-Komponente
|
||||||
|
- Vitest: Dashboard + MiniAppWindow 17/17, pluginStore 18/18, kombiniert 35/35
|
||||||
|
- Backend-Regressionen: Route-Order, M5-MiniApps, N4-Scope, N3-Filtering 49/49
|
||||||
|
- compileall sauber · Cross-Plugin-Checker 497/0 · ruff clean
|
||||||
|
- Full Deploy SUCCESS · Health healthy · Live-Manifest-Checks: /calendar/kanban,
|
||||||
|
/tags, workflows:read, keine toten Einträge — alle OK · SPA-Routen 200
|
||||||
|
|
||||||
|
**Nächster Schritt (Roadmap):** Re-Audit durch den externen Prüfer — alle 17
|
||||||
|
Audit-Findings sind behoben (13 Backend + 4 Frontend). Danach Phase O UI-Overhaul,
|
||||||
|
Phase P Notizen-App oder UI-Backlog-Module 2-16.
|
||||||
|
|
||||||
|
|
||||||
|
## Weitermachen (2026-09-15, Übergabe — für das nächste Modell/jede KI)
|
||||||
|
|
||||||
|
**Produktion läuft stabil** (HEAD b91ee5b = origin/main, 0 ungepushte Commits, Health healthy, Alembic 0144, RLS 113 Tabellen, Worker up). Alle Forgejo-Issues bis #389 geschlossen. Outbox sauber: 158 Events published (Webhook-Fix #380).
|
||||||
|
|
||||||
|
**2026-09-13 bis 16 abgeschlossen:** (1) Externer Architektur-Audit verifiziert — alle 17 Findings bestätigt, 13 Backend-Fixes (Commit 4a25ac1, #370). (2) PHASE Q KOMPLETT — Frontend-Plugin-Architektur: generierte Komponenten-Map (scripts/generate_component_map.py, jetzt 43 Eintraege, Fail-Hard bei Ghosts) ersetzt STATIC_COMPONENT_MAP + widgetRegistry; Plugin-Routen/Settings nur noch aus Manifesten via PluginRouteRenderer (variant pages/settings). (3) Drei Produktions-Bugfixes 2026-09-14 (siehe Bugfix-Tabelle unten): Webhook-JSONB-Containment (#380), Zustands-Selector-Spinner-Hang (#381), Consumer-Registry-qualname. (4) ZWEI weitere Produktions-Bugfixes 2026-09-16 (Bugfix-Tabelle): ai_assistant-Reaktivierung (#389 — KI-Chat war seit 0137 down) + CSRF-Bearer-Skip (External-API fuer Integrationen). (5) **UI-BACKLOG 16/16 KOMPLETT** — alle 16 Backend-Module haben jetzt UI (Commits + Issues #369, #372-#388, siehe Tabelle): Module 11-13 an einem Tag (2026-09-15), Module 14-16 am 2026-09-16 (Guests #386, External-Agent #387 inkl. 2 Backend-Fixes, Ownership-Transfer #388).
|
||||||
|
|
||||||
|
**OFFENE THREADS (alles Weitere hängt hier, nichts geht verloren):**
|
||||||
|
1. **Re-Audit ausstehend:** Externer Prüfer prueft leocrm-reaudit.zip (Stand b58c96f, liegt beim User). Bei neuen Findings: erst die fixen. Hinweis: ZIP enthaelt NICHT die UI-Module 2-16 — bei Bedarf frischen ZIP erstellen (git archive HEAD).
|
||||||
|
2. **Traefik no-cache-Header fuer index.html** (User-Angebot offen, prevents stale JS-Chunks nach Deploys). HINWEIS: Der "Dashboard loads forever"-Incident wurde 2026-09-14 aufgeklaert — es war der Zustands-Selector-Bug (#381), kein Caching-Problem. Der no-cache-Header bleibt trotzdem sinnvoll gegen stale Chunks nach Deploys.
|
||||||
|
3. **Server-Entlastung** (User-Thema offen): Cron gegen alte Browser-Prozesse (Incident: 3 Zombie-Chromium, 500+ h CPU) und/oder VPS-Upgrade-Diskussion (22 Container auf 7,6 GB).
|
||||||
|
4. **Phase O UI-Overhaul:** offen 1.2 Kontakte-Drag-Drop in Ordner, 1.3 MoveDialog.
|
||||||
|
5. **Phase P Notizen-App** (P1-P5, user-abgestimmt, Roadmap-Details stehen).
|
||||||
|
6. **Vorbestands-Findings (nicht blockierend):** entity_attachments-FK blockiert alembic check; Suite-Isolation (Combo-Runs "relation users does not exist", Solo gruen); Vitest-Worker-OOM.
|
||||||
|
7. **Marketplace ist leer:** Keine Listings in der DB (API 200, listings=0). Demo-Listings koennen via Admin-API (MarketplaceListingCreate, marketplace:admin) angelegt werden — User fragen.
|
||||||
|
|
||||||
|
**Offene Roadmap-Phasen (user-abgestimmt, startklar):**
|
||||||
|
- **Phase S** — Astra-Sanierung (S1-S4, bestätigt 2026-09-17). **NÄCHSTE PHASE.** Externaudit Astra: 41 Findings (2 P0, 29 P1, 10 P2), 10 stichprobenartig intern verifiziert — alle korrekt. Volltext: [docs/audits/astra-audit-2026-09-17.md](docs/audits/astra-audit-2026-09-17.md). Wellen: S1 Sicherheitsgrenzen [#396](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/396) (**KOMPLETT 11/11, deployed 2026-09-18**: F01 ✓ f2a7206 KI-Tool-Guard, F02 ✓ 824686c globale Identität, F03 ✓ 4743265 Session-Widerruf beide Stores, F05 ✓ a17772a Plugin-Gate nach Auth, F10 ✓ b8a5560 Token-Scopes Obergrenze, F11 ✓ 015b7e3 Approval-Bindung (resolved_by, 403/409/410), F15 ✓ a802159 SSRF DNS-Auflösung, F20 ✓ b2f7549 DELETE-Grants Migration 0145 (Live-SQL-Beweis: crm_api DELETE nur auf users/user_tenants/sessions/plugins/notification_types; audit_log/api_tokens/tenants entzogen), F21 ✓ 17f990c Migrationstest-Ziel-Beweis, F23 ✓ 25b4d61 Restore System-Admin, F30 ✓ 632554b kein Default-Passwort; S1-Guards-Suite 18/18; Logout-Smoke 200), S2 Ausführung verbinden [#397](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/397) (**12/16**: F12 ✓ 5169b12 Workflow-approve/reject an zentralen Vertrag; F17 ✓ 8c5682f 6× Registry.get()→get_contract(); F37 ✓ 5d6fe6b SMTP-Env-Namen; F41 ✓ 5d6fe6b Agenten-Stundenlimit; F06 ✓ 62d107d Worker registriert Plugin-Event-Handler; F08 ✓ 001e4b4+8e744c9 External-API Bearer + Migration 0147 RLS-Henne-Ei; F14 ✓ fdc4e36 KI-Datenrichtlinie komplett; F09 ✓ 4217267 CRM-/MCP-Tools HMAC-Delegation + Worker-URL; F19 ✓ 49a9493 Alembic vollständige Model-Discovery (129 Tabellen, Sortierung OK — Voraussetzung für R3); F40 ✓ fd4a1ec Plugin-Migrations-Hashes + DRIFT-Warnung (Migration 0148, verhindert #389-Klasse systemisch); F31 ✓ 13deaf9 Reindex aus gemeinsamer Quelle (dynamisch über Registry); offen: F07 Jobs-Mandantenkontext, F13 Workflow-Locks, F16 Plugin-Lifecycle, F18 Schema-Verfahren — die 4 konsolidierungsintensivsten), S3 Fachliche Integrität [#398](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/398) (F25-F28, F32-F35, F38, F39), S4 Betriebsfreigabe [#399](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/399) (F04, F22, F24, F29, F36 + korrigierte Phase R). Milestone 16. Abnahmen quer: Kontaktanlage→Outbox→Worker→Suchindex→KI-Abfrage; Mailentwurf→Freigabe→einmaliger Versand. Neue Aufwandsschätzung nach S1+S2.
|
||||||
|
- **Phase R** — Betriebssicherheit & 95%-Produktionsreife (R1-R6, user-abgestimmt 2026-09-16). Läuft in Phase S Welle 4 auf; korrigiert 2026-09-17 nach Astra-Kritik (8 Punkte in Roadmap eingearbeitet). R1 Alerting gegen stille Ausfälle [#390](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/390) (PRIORITY 1 — Evidenz: KI-Chat 4 Wochen still down #389), R2 Suite verlässlich [#391](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/391), R3 Schema-Integrität [#392](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/392), R4 E2E-Kernflows [#393](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/393), R5 Backup-Restore-Drill [#394](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/394), R6 Ops-Runbook [#395](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/395). Milestone 15. 5 messbare Abnahmekriterien = die 95%-Definition (Details + DoD: Roadmap Phase R).
|
||||||
|
- **Phase M** — MiniApp-Plattform & Dashboard-Builder (M1-M6). **M1 ✓** (Universal-Registry, `/api/v1/miniapps`), **M2 ✓** (persönliche Dashboards: Tabelle, CRUD, Seed, RLS), **M3 ✓** (Dashboard-Builder: Edit-Modus, Drag&Drop, Palette, Tabs), **M4 ✓** (System-Rückbau, Core = reiner Host), **M5 ✓** (Plugin-MiniApps), **M6 ✓ erledigt — PHASE M KOMPLETT** (Windows-Host + AI-Agenten-Tool send_miniapp — siehe Phase-M6-Section).
|
||||||
|
- **Phase N** — Workspace-Scopes (N1-N4). **N1 ✓** (Scope-Registry via Contract), **N2 ✓** (Dynamischer Scope-Editor), **N3 ✓** (Backend-Filterung contacts/dms/mail/calendar + Frontend-Defaults), **N4 ✓ erledigt — PHASE N KOMPLETT** (7 weitere Module: Tasks nur-meine, Kommunikation-Räume, Wiki-Kategorien-Subtree, Reports-Vorlagen, Agents, Tags, Search-Entity-Types + Navigation Startseite/Menü-Reihenfolge + Dashboard-Schnittstelle — siehe Phase-N4-Section). **Nächster Schritt:** Phase O UI-Overhaul (offen: 1.2 Kontakte-Drag-Drop in Ordner, 1.3 MoveDialog) oder Phase P Notizen-App (P1-P5).
|
||||||
|
- **Phase O** — UI-Overhaul (umbenannt von Doppel-L, Bug-Verifikation steht im Roadmap-Eintrag: 5/7 Bugs bereits erledigt, offen: 1.2 Kontakte-Drag-Drop in Ordner, 1.3 MoveDialog)
|
||||||
|
|
||||||
|
**Vorbestands-Findings (nicht blockierend, dokumentiert):**
|
||||||
|
1. `entity_attachments.dms_file_id → files` (Core-FK auf DMS-Tabelle) blockiert `alembic check`
|
||||||
|
2. Suite-Isolation: kombinierte Test-Runs quicken mit "relation users does not exist" (Solo-Runs grün)
|
||||||
|
3. AppShell vitest worker OOM bei Solo/Combo-Runs
|
||||||
|
|
||||||
|
**Modul-Bauplan (bewaehrtes Muster, Module 14-16 direkt anwendbar):** Backend lesen (Routes/Schemas/Permissions) → `api/<modul>.ts` (TanStack-Hooks) oder bestehenden Client erweitern → `pages/<Modul>.tsx` → Registrierung (Plugin: manifest plugin.py page_routes+menu_items + generate_component_map.py + ICON_MAP-Icon; Core: routes/index.tsx + Settings.tsx; oeffentlich: statische Route ausserhalb ProtectedRoute) → i18n de/en (Python-Patch-Skript, JSON-Roundtrip pruefen) → Vitest → tsc → Build → Deploy (frontend-only wenn kein plugin.py; full bei plugin.py) → Live-Verifikation (API curl + echter-Login Playwright DOM-Check) → Forgejo-Issue (Label 5=task, danach schliessen) → PROGRESS.md + Roadmap-Zeile.
|
||||||
|
|
||||||
|
**Session-Lektionen fuer Tests/Implementation (2026-09-15, wiederkehrende Stolperfallen):**
|
||||||
|
- Vitest: Mutation-Mocks mit `vi.hoisted()` definieren (Top-Level const = ReferenceError durch Hoisting)
|
||||||
|
- TanStack Query v5 ruft `mutationFn(variable, context)` — Assertion auf `mock.calls[0][0]`, nicht `toHaveBeenCalledWith(...)`
|
||||||
|
- Query-Ergebnisse asynchron: `await screen.findByTestId(...)` statt synchronem getByTestId
|
||||||
|
- `window.confirm`: Direkt-Zuweisung im beforeEach (`window.confirm = () => true`), spyOn nur in-Test
|
||||||
|
- Hook-Mocks (`useXxx: () => (...)`) sind robuster als queryFn-Mocks — synchron, kein isLoading-Handling
|
||||||
|
- text_editor verschluckt gelegentlich JSX-Kommentar-Schliessungen (`*/` ohne `}`): vor tsc mit `grep '{/*'` pruefen
|
||||||
|
- Frontend-Catch: der Client-Interceptor wirft `ApiError` mit `.status` auf Top-Level — NICHT `err.response.status` lesen
|
||||||
|
- i18n: Block ggf. bereits vorhanden (ungenutzte Alt-Keys) — nur fehlende Keys mergen, nicht ueberschreiben
|
||||||
|
- Oeffentliche Seiten (ohne Login): statische Route analog `/guest/*`, NIEMALS in die AppShell/ProtectedRoute
|
||||||
|
|
||||||
|
**Wichtig:** AGENTS.md-Regeln zuerst lesen (§0.0 Sub-Agents nur für einfache Jobs, §0.2 auf bestehendem Code aufbauen, §10 'PROGRESS.md als Source of Truth').
|
||||||
|
|
||||||
|
> **Letztes Update:** 2026-09-15
|
||||||
|
|
||||||
|
## Produktions-Bugfixes (2026-09-16)
|
||||||
|
|
||||||
|
| Bug | Issue | Fix | Verifikation (Live-Messung 2026-09-16) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Plugin ai_assistant seit Alembic 0137 (2026-08-21) migration_failed/inactive — KI-Chat und /api/v1/ai/* in Produktion down (403 plugin_inactive); Migration 0003 exec ALTER TABLE ai_chat_sessions crashte bei jedem Container-Start (Tabelle von 0137 gedroppt) | [#389](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/389) | Migrationen 0001-0003 von Referenzen auf gedroppte ai_chat-Tabellen befreit (0003: nur ai_chat_folders behalten; 0001: Ghost-CREATEs entfernt; 0002: attachments+ALTER entfernt). Runner skipt getrackte Migrationen per Dateiname (kein Hash-Check) → kein Prod-Risiko (Commit 0383dd2) | Prod-DB-Diagnose: nur ai_chat_folders existiert, 0001+0002 getrackt; nach Full Deploy: Plugin status=active, GET /api/v1/ai/agents → 200 mit echtem LeoCRM Assistant, KI-Chat wieder live |
|
||||||
|
| CSRF-Middleware verlangte Origin+X-CSRF-Token auch auf Bearer-authentifizierten API-Calls → External-Agent-API (/api/v1/external/agent/*) fuer externe Systeme unbrauchbar (403 ohne Origin/CSRF) | (in #387 aufgegangen) | Authorization: Bearer-Requests skippen die CSRF-Pruefung — Bearer ist CSRF-immun per Design (Browser haengen Authorization-Header nie automatisch an); Session-Requests unverändert voll geprueft (Commit b91ee5b) | Live: Dummy-Bearer → 401 not_authenticated (Auth-Ebene erreicht statt 403 CSRF); Session-Request ohne CSRF bleibt 403 csrf_missing_token; pytest test_auth.py 11/11 |
|
||||||
|
|
||||||
|
## Produktions-Bugfixes (2026-09-14)
|
||||||
|
|
||||||
|
| Bug | Issue | Fix | Verifikation (Live-Messung 2026-09-14) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Jeder Outbox-Event-Publish crashte im Webhook-Dispatcher mit `Neither 'AnnotatedColumn' nor 'Comparator' object has an attribute 'any'` → 158 failed Events (`file.deleted`, 2026-08-27) | [#380](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/380) | `Webhook.events` ist JSONB-Column (KEINE Relationship): `.any()` an 2 Stellen (webhook_dispatcher.py, webhook_service.py) ersetzt durch `cast(events, JSONB).contains([event])` (Commit 50d6733) | pytest test_webhooks.py 6/6 (SQL: `CAST(webhooks.events AS JSONB) @> ...`); Full Deploy Health 200; Live: `replay-all` → 158 replayed, danach stats `{published:158, failed:0}` (vorher `{failed:158}`) |
|
||||||
|
| Alle Core-Lazy-Routen im AppShell-Baum hingen ewig im Route-Suspense-Spinner ("Dashboard loads forever"-Incident) bei Direkt-Aufruf/Reload | [#381](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/381) | Root Cause via Bisekt: `useWorkspaceStore(s => s.moduleMenuOrder())` + `s.visibleModuleKeys()` erzeugten bei jedem getSnapshot NEUE Map/Set-Objekte → useSyncExternalStore-Render-Loop → Suspense-Commits landeten nie. Fix: stabile `context`-Referenz selektieren + useMemo-Ableitung (Commit 3fd0c69) | Bisekt-Beweis: Min-AppShell rendert alles, +Sidebar → Hang; Import-Bisect: Chunk resolved aber kein Commit. Live PROD: /dashboard frischer Kontext `h1='Dashboard', spinner=false` (vorher hängender Spinner); /outbox echter Login: h1='Event Outbox', Published 158, Failed 0, 35 Registry-Karten, mainTextLen 1699 |
|
||||||
|
| Consumer-Registry zeigte scheinbare Duplikate: `on_contact_created` 3x (drei Plugins mit gleichem Methodennamen ununterscheidbar) | Commits 591ef06 + fccf009 | `_get_handler_name` nutzt `__qualname__` für bound methods (Plugin-Handler): Registry zeigt `AutomationPlugin.on_contact_created` vs `UnifiedSearchPlugin.on_contact_created` vs `SystemNotifPlugin.on_contact_created`; plain functions behalten `__name__` | pytest test_outbox_phase5 17/17; Full Deploy Health 200; Live: Registry 46 Handler, eindeutige Plugin-Namen, Duplikat-Check: nur `_noop_handler` (korrekt — 1 Placeholder-Fn für mehrere Events) |
|
||||||
|
|
||||||
## Produktions-Bugfixes (2026-08-27)
|
## Produktions-Bugfixes (2026-08-27)
|
||||||
|
|
||||||
@@ -12,6 +147,303 @@
|
|||||||
|
|
||||||
**Gates:** ruff exit=0 · tsc --noEmit exit=0 · pytest 11 passed · Vitest 2 passed
|
**Gates:** ruff exit=0 · tsc --noEmit exit=0 · pytest 11 passed · Vitest 2 passed
|
||||||
|
|
||||||
|
## W4c — Custom-Fields-Routen in ContactsPlugin migriert (2026-08-28) ✅
|
||||||
|
|
||||||
|
**Verify-first:** `app/routes/custom_fields.py` war 100% Contact-spezifisch (importiert Contact, nutzt contacts:read/write, Route /{contact_id}/custom-fields) — lag aber als scheinbar generischer Core-Service (Kritikpunkt 14).
|
||||||
|
|
||||||
|
**Fix (c6decf5):** Die komplette Logik (2 Endpoints GET/PATCH, `_collect_custom_field_definitions`, `_merge_definitions_with_values`, `CustomFieldUpdateRequest`) wandert in `app/plugins/builtins/contacts/routes.py` (gleicher Router-Prefix /api/v1/contacts, bereits via manifest.routes gemounted). `app/routes/custom_fields.py` gelöscht, main.py bereinigt. Der generische `custom_field_definitions.py`-Endpoint bleibt im Core.
|
||||||
|
|
||||||
|
**Verifikation:** tests/test_custom_fields.py **11/11 passed** (Funktionserhalt) · create_app OK · ruff grün · Full Deploy SUCCESS · Health healthy
|
||||||
|
|
||||||
|
## Phase L1-L3 — Dokumente-Generator Backend+Editor (2026-08-29) ✅
|
||||||
|
|
||||||
|
**Scope:** Briefpapier (letterheads) + Druckvorlagen (print_templates) + Assets (document_assets) + Block-Registry + Contract-Beiträge + Drag&Drop-Editor + globaler Dokument-Dialog. Erweiterung des report_generator-Plugins (kein Neubau).
|
||||||
|
|
||||||
|
**Umgesetzt:**
|
||||||
|
- Backend: `documents.py` (13 Endpoints), `document_blocks.py` (Registry: text/image/shape/table/spacer/divider/placeholder/pagebreak + Modul-Beiträge via `document_blocks()`-Contract), `document_renderer.py` (Blocks→HTML→PDF, WeasyPrint data:-URI-only SSRF-Policy, Briefpapier-@page-Frame mit running header/footer)
|
||||||
|
- Contract-Beitrag contacts: `document_placeholders(entity_type)`, `document_data(db, tenant_id, entity_id, entity_type)` (#359-Muster wie importexport_entities)
|
||||||
|
- Migration: Plugin-SQL 0003 (idempotent) + Alembic 0143 (Dual-Path-Konvergenz, RLS fail-closed nach 0084-Muster)
|
||||||
|
- Frontend: `api/documents.ts` + `DocumentSettings`-Page (Settings→Dokumente, eigener Menüpunkt via settings_pages) + `BlockEditor` (@dnd-kit: Palette/Canvas/Config-Panel/Live-Preview-iframe) + `LetterheadEditor` + `PrintTemplateEditor` + `DocumentGenerationDialog` (global für Module, integriert in ContactDetailPage)
|
||||||
|
- i18n de/en vollständig
|
||||||
|
|
||||||
|
**Verifiziert:**
|
||||||
|
- ✅ tests/test_documents_generator.py: 32/32 (CRUD, Tenant-Isolation, RBAC 403, Block-Validierung 422, Preview, Render-PDF `%PDF`, Assets, Contract-Unit)
|
||||||
|
- ✅ Regression: test_report_generator.py + test_plugin_route_order.py 9/9
|
||||||
|
- ✅ tsc --noEmit Exit 0; Production-Build OK (2.79s)
|
||||||
|
- ✅ Alembic-Fresh-DB: 0001→0143 komplett, letterheads/print_templates/document_assets mit RLS+FORCE+crm_api-Policy bewiesen (Scratch-DB wieder gedroppt)
|
||||||
|
- ✅ ruff check clean; check_migration_hashes 93/93 OK
|
||||||
|
- ⚠️ Bekannt: Router-Reihenfolge im Manifest — documents-Router muss VOR routes stehen (/{report_id}-Catch-all)
|
||||||
|
|
||||||
|
**Offen (Folgepakete):**
|
||||||
|
- L4: KI-Steuerung („Erstelle Rechnungsvorlage") via agent_loop
|
||||||
|
- L5: E-Rechnung XRechnung/ZUGFeRD (benötigt Verkaufs-Modul)
|
||||||
|
- Weitere Module können Blöcke/Platzhalter beisteuern (Contract-Muster dokumentiert in plugin-development-guide.md)
|
||||||
|
|
||||||
|
## Phase L4-L5 — KI-Vorschlag + XRechnung-Format-Layer (2026-08-29) ✅
|
||||||
|
|
||||||
|
**User-Klärung:** Verkaufsmodul kommt später — aber das XRechnung-FORMAT ist jetzt implementiert (reiner Format-Layer, kein Rechnungs-CRUD).
|
||||||
|
|
||||||
|
**Umgesetzt:**
|
||||||
|
- L5 Format-Layer: `einvoice.py` — EN16931/XRechnung CII-XML-Generator (ElementTree, XML-Escaping gratis), Pflichtfeld-Validierung mit BT/BG-Codes (BT-1/2/3/5, BT-10, BT-27, BT-31/32, BG-25, BT-126/146), Decimal-kommerzielles Rounding, Header-Tax-Breakdown pro VAT-Satz, Profile en16931|xrechnung (Guideline urn:xoev-de:kosit:standard:xrechnung_3.0)
|
||||||
|
- Endpoints: `/einvoice/render` (inline → XML), `/einvoice/validate` (422 mit Fehlliste), `/einvoice/render-for` (Contract-Resolver `einvoice_data()` — Andockpunkt Verkaufsmodul, ohne Beitrag 404 no_data_source)
|
||||||
|
- L4 KI-Steuerung: `/documents/suggest` — natürliche Sprache → Block-Komposition via zentralem llm_complete (gpt-4o-mini, Cost-Tracking, Tenant-Budget), Registry-Sanitizing (ungültige KI-Blöcke gefiltert, IDs serverseitig), Code-Fence-Stripping, 502 ai_unavailable/invalid_ai_response
|
||||||
|
- Frontend: KI-Vorschlag-Panel im PrintTemplateEditor (Sparkles, Prompt-Textarea, Vorschläge werden an Blöcke angehängt), i18n de/en
|
||||||
|
|
||||||
|
**Verifiziert:**
|
||||||
|
- ✅ TDD: Rot 25 failed → ✅ Grün **25/25** (tests/test_einvoice_generator.py: Validierung 6 Unit, XML-Struktur 5 Unit inkl. Escaping/Profil/Summen, Contract-Resolution 2 mit Mock-Registry, API 6: 200-XML/422-BT-Codes/403/404, Suggest 6: Mock-LLM/Filter/Fence/502/403)
|
||||||
|
- ✅ tsc exit 0 (useMutation-Typisierung SuggestResult,Error,SuggestInput), Production-Build BUILD_EXIT=0
|
||||||
|
- ✅ ruff clean
|
||||||
|
|
||||||
|
**Offen:** Verkaufsmodul dockt später via `einvoice_data()` an — Contract + Doku (plugin-development-guide.md) fertig.
|
||||||
|
|
||||||
|
|
||||||
|
## Phase M — MiniApp-Plattform & Dashboard-Builder (2026-08-29 geplant, user-abgestimmt)
|
||||||
|
|
||||||
|
**User-Vision:** Universelle MiniApps (Chat + Dashboard + Windows + AI-Agenten), Dashboard-Builder mit Edit-Modus/Drag&Drop/Resize/Tabs/pro-Widget-Settings, System-Dashboard-Teile zurück in Plugins (Core = reiner Host), Permission-Integration fail-closed.
|
||||||
|
|
||||||
|
**Status:** done — M1–M6 alle erledigt (siehe Sections unten). Phase-Gate: alle Tasks implementiert, getestet (TDD), deployed und auf Produktion verifiziert (live curl-Beweise je Section).
|
||||||
|
|
||||||
|
**Live-Bestand analysiert (2026-08-29):** miniapp_registry (kommunikation, 92 Z.), MiniAppContribution (LÜCKE: kein permission-Feld), FrontendDashboardWidget (LÜCKE: kein settings_schema), MiniAppBlock.tsx (Chat-Host fertig), DashboardGrid + 4 Widgets, Dashboard.tsx (170 Z.) mit hardcodierten StatCards/ActivityFeed/System-Metrics (Rückbau-Bestand für M4), @dnd-kit vorhanden.
|
||||||
|
|
||||||
|
## Phase M1 — Universal-MiniApp-Registry (2026-08-30) ✅
|
||||||
|
|
||||||
|
**Umgesetzt:**
|
||||||
|
- `app/plugins/miniapp_registry.py` (154 Z.): Registry in den Plugin-Layer gehoben (Plattform-Konzept). MiniAppDef erweitert um `permission` (fail-closed, leer = jeder), `settings_schema`, `col_span`/`row_span`, `hosts` (chat/dashboard/window), `component`, `order`, `builtin`.
|
||||||
|
- Kompatibilitäts-Brücke: `kommunikation/miniapp_registry.py` re-exportiert die Universal-Registry — alle Bestands-Importer (kommunikation contracts, automation routes, tests) unverändert lauffähig.
|
||||||
|
- Lifecycle: `BasePlugin.on_activate` registriert Manifest-Beiträge automatisch (miniapps + dashboard_widgets-Alias mit component/spans/permission — ein Contribution-Typ, #359-Philosophie); `on_deactivate` entfernt per `unregister_plugin` nur die eigenen Apps.
|
||||||
|
- Manifest-Schema: `MiniAppContribution` + `FrontendDashboardWidget` um M1-Felder erweitert (settings_schema, hosts etc.).
|
||||||
|
- API: `GET /api/v1/miniapps` (Server-seitig permission-gefiltert, ?host=), `GET /api/v1/miniapps/{app_id}` (403 fail-closed / 404).
|
||||||
|
|
||||||
|
**Verifiziert (2026-08-30):**
|
||||||
|
- TDD: Rot 16 errors/failed → ✅ Grün **16/16** (tests/test_miniapp_registry.py: Registry-Unit 6, Bridge-Import 1, Manifest-Registrierung+Lifecycle 3, API 6 inkl. Viewer-Filter-Beweis + Host-Filter + 403/404)
|
||||||
|
- ✅ Regression: test_contracts.py 23/23, plugin_lifecycle + route_order 4/4, create_app OK
|
||||||
|
- ✅ ruff clean (M1-Dateien); 2 Ruff-Funde in automation/knowledge = per Stash bewiesener Vorbestand
|
||||||
|
- ✅ Doku: api-documentation.md (2 Endpoints), plugin-development-guide.md (MiniApp-Beitrag-Muster)
|
||||||
|
|
||||||
|
**Offen in Phase M:** — (Phase M abgeschlossen).
|
||||||
|
|
||||||
|
## UI-Backlog: Frontend-Backend-Gap (2026-09-08 laufend)
|
||||||
|
|
||||||
|
**Kontext:** Frontend-Backend-Gegenüberstellung (2026-09-01) ergab 16 Backend-Module ohne UI (~64 Ops) bei 76-84% Business-UI-Coverage. User-Entscheidung: Module einzeln mit UI ausstatten, priorisiert nach Business-Nutzen.
|
||||||
|
|
||||||
|
| # | Modul | Ops | Status | Issue |
|
||||||
|
|---|-------|-----|--------|-------|
|
||||||
|
| 1 | Approvals (Freigaben) | 6 | ✅ Done | #369 |
|
||||||
|
| 2 | Delegations (Vertretungen) | 5 | done | Commit 36771d4, [#372](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/372): UI live — Vitest 9/9, tsc 0, Build 0, Frontend-Deploy, API 200 ({items:[],total:0} + active-check OK) |
|
||||||
|
| 3 | API-Tokens | 3 | done | Commit 4bdc6c6, [#373](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/373): Settings-Page /settings/api-tokens — Vitest 8/8, tsc 0, Build 0, Frontend-Deploy, live: API 200 (echter TestToken sichtbar) + SPA 200 |
|
||||||
|
| 4 | Tenants (Mandanten) | 4 | done | Commit 79ca1cb, [#374](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/374): Settings-Page /settings/tenants — Vitest 8/8, tsc 0, Build 0, Frontend-Deploy, live: API 200 (Default Org sichtbar) + SPA 200 |
|
||||||
|
| 5 | Marketplace (Plugin-Markt) | 5 | done | Commit 289dfc8, [#375](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/375): ERSTES Modul via Phase-Q-Manifest-Architektur — Vitest 10/10, tsc 0, Build 0, Full Deploy, live: Manifest page_route+menu_item OK, API 200, SPA 200 |
|
||||||
|
| 6 | Permission-Templates (Berechtigungs-Vorlagen) | 5 | done | Commit 33b4b52, [#376](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/376): Settings-Page /settings/permission-templates — Vitest 10/10, tsc 0, Build 0, Frontend-Deploy, live: API 200 + SPA 200 |
|
||||||
|
| 7 | Skills (AI-Skill-Definitionen) | 5 | done | Commit 3f8d1bd, [#377](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/377): /skills via automation-Manifest (Phase Q) — Vitest 10/10, tsc 0, Build 0, Full Deploy, live: Manifest page_route+menu_item OK, API 200, SPA 200 |
|
||||||
|
| 8 | Agent-Memory | 5 | done | Commit 24423b6, [#378](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/378): /agent-memory via agent_memory-Manifest (Phase Q) + ICON_MAP-Fix (Brain/Store/Tags) — Vitest 11/11, tsc 0, Build 0, Full Deploy, live: Manifest OK, API 422 ohne agent_id (Pflichtfeld bewiesen), SPA 200 |
|
||||||
|
| 9 | Outbox (Event-Verwaltung) | 7 | done | Commit 31154b9, [#379](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/379): Core-Route /outbox + Sidebar order 93 mit Admin-Filter — Vitest 11/11, tsc 0, Build 0, Frontend-Deploy, live: stats/failed/consumer-registry API 200 (158 echte failed events `file.deleted` 2026-08-27, 20+ Handler), Nav-Link gerendert, Chunk MD5-identisch |
|
||||||
|
| 10 | Policies (ABAC-Richtlinien) | 4 | done | Commit d734923, [#382](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/382): Settings-Page /settings/policies mit Entity-Typ-Tabs (8), Conditions-Builder (AND/OR, Whitelist-Felder, 12 Ops), Principal-Picker (User/Group/Role) — Vitest 12/12, tsc 0, Build 0, Frontend-Deploy, live: API 200 (items=[]) + echter-Login DOM-Check (Page gerendert, alle Tabs, kein Spinner) |
|
||||||
|
| 11 | Graph-RAG Traversal | 4 | done | Commit 0404c8f, [#383](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/383): /graph-rag via graph_rag-Manifest (Phase Q, Share2-ICON_MAP, Komponenten-Map 41) + knowledge.ts-Erweiterung (traverse/create/delete) — Vitest 11/11, tsc 0, Build 0, Full Deploy, live: Manifest page_route+menu_item OK, API 200, echter-Login DOM-Check (Page gerendert, kein Spinner) |
|
||||||
|
| 12 | Companies (Firmen-API) | 9 | done | Commit 06b7284, [#384](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/384): /companies via contacts-Manifest (Phase Q, Building2-ICON_MAP, Komponenten-Map 42) — Vitest 12/12, tsc 0, Build 0, Full Deploy, live: Manifest OK, API 200 (echte Firmendaten), echter-Login DOM-Check (3 Firmenkarten, Export-Buttons, kein Spinner) |
|
||||||
|
| 13 | Public-Share | 3 | done | Commits 00f8f10 + 2fbffcd, [#385](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/385): Oeffentliche Seite /share/:token (Passwort-Support, Download, 404/410-Zustaende) + ShareDialog kopiert jetzt SPA-Links statt API-JSON — Vitest 9/9, tsc 0, Frontend-Deploy, live ohne Login: Fehlerseite 'Link not found' gerendert, kein Login-Redirect |
|
||||||
|
| 14 | Guests | 3 | done | Commit b3eaa0e, [#386](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/386): Settings-Page /settings/guests mit Admin-Gate (Outbox-Muster), Gästeliste mit Status-Badges (invited/active/disabled), Invite-Modal (RHF+zod), Revoke-ConfirmDialog — Vitest 8/8, tsc 0, Frontend-Deploy, live: API GET /api/v1/guests 200 [], echter-Login DOM-Check (Page, EmptyState, Invite-Button, Nav-Eintrag gerendert) |
|
||||||
|
| 15 | External-Agent | 3 | done | Commit e8e07fa, [#387](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/387): Settings-Page /settings/external-agents via ai_assistant-Manifest (Phase Q, permission ai:read, Komponenten-Map 43) — Agentenliste mit curl-Snippets (run/status/stream), Copy-Buttons, Bearer/Rate-Limit-Hinweis, Token-Link — Vitest 6/6, tsc 0, Full Deploy, live: echte Agent Card (LeoCRM Assistant), Snippets+Copy-Buttons, Nav-Eintrag; VORAUSSETZUNG waren 2 Backend-Fixes: ai_assistant-Reaktivierung (Commit 0383dd2, [#389](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/389)) + CSRF-Bearer-Skip (Commit b91ee5b) |
|
||||||
|
| 16 | Ownership-Transfer | 1 | done | Commit e8e07fa, [#388](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/388): Settings-Page /settings/ownership (statische Core-Route) mit Admin-Gate, From/To-User-Selects, 10 Entity-Type-Chips, ConfirmDialog, Ergebnis-Tabelle — Vitest 6/6, tsc 0, Full Deploy, live: Formular/Chips/Submit/Nav gerendert, API 422 mit korrekten Pydantic-Fehlern (Admin-Route erreichbar) |
|
||||||
|
|
||||||
|
**Modul 1 — Approvals (2026-09-08) ✅:** Review-Queue (Status-Tabs Offen/Alle/Genehmigt/Abgelehnt/Abgelaufen), Approve/Reject mit Kommentar-Modal, Permission-Gating (approvals:approve), Metadata-Anzeige. Phantom-Permission-Bug gefixt (approvals:read/write/approve fehlten in CORE_PERMISSIONS — Rollen konnten sie nie erhalten, M2-Fehlerklasse). Vitest 10/10, RBAC 102/102, live: /approvals 200, Prod-Bundle enthält UI. Deploy: ecc7a24 (Full).
|
||||||
|
|
||||||
|
## Phase N4 — Restliche Module (2026-09-01) ✅ — PHASE N KOMPLETT
|
||||||
|
|
||||||
|
**Spec:** [#368](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/368) | **Roadmap:** Phase N, N4 (letzter Task) | **Milestone:** Phase N — Workspace-Scopes (#14)
|
||||||
|
|
||||||
|
**Umgesetzt:**
|
||||||
|
- **Scope-Deklarationen (7 Plugins):** tasks only_mine-Toggle („nur meine"), kommunikation conversation_ids (Räume), wiki category_ids (Subtree — NEUE contracts.py, wiki hatte zuvor keinen Contract), report_generator template_ids (Vorlagen), automation agent_ids (module_key agents — page route ohne Menüeintrag), tags tag_ids (Root-Array), unified_search entity_types DYNAMISCH aus Provider-Registry (13 Entity-Types, Live-Set + deterministischer Klassen-Fallback).
|
||||||
|
- **Core-Beiträge (Aggregator):** navigation default_route (Startseite pro Workspace, Optionen aus CORE_PERMISSIONS + bekannten Frontend-Routen) + dashboard widget_app_ids (begrenzt das Widget-TYP-Angebot — workspace_widgets-Boundary; persönliches Layout bleibt Phase M).
|
||||||
|
- **Backend-Filter (additive UND, kein Umbau):** GET /tasks (assigned_to OR created_by), GET /comm/conversations (Subset), GET /wiki/articles + /categories (expand_folder_scope-Subtree), GET /reports/print-templates (Subset), GET /agents (Subset), GET /tags (Subset), GET+POST /search (apply_entity_type_scope: requested ∧ scope), GET /miniapps?host=dashboard (widget_app_ids begrenzt NUR Dashboard-Angebot, chat/window unberührt).
|
||||||
|
- **Frontend-Navigation:** WorkspaceSwitcher navigiert nach default_route beim Wechsel (Validierung: muss mit / beginnen); Sidebar sortiert nach workspace menu_order als Admin-Default (persönliche savedOrder bleibt Override); workspaceStore moduleMenuOrder()-Helper.
|
||||||
|
|
||||||
|
**Verifiziert (2026-09-01):**
|
||||||
|
- TDD: Deklarationen **18/18** (rot: 18 failed → Implementation → grün), Filter **11/11** (rot: 8 failed + 1 error → grün; inkl. Dashboard-Boundary: scoped {w1} vs. unscoped Superset, chat unberührt)
|
||||||
|
- ✅ Frontend: Vitest Switcher-Navigation 2/2, Store 18/18 (moduleMenuOrder +2), tsc clean, Build OK
|
||||||
|
- ✅ Kombi-Regression (N1+N3+N4-Dateien): 64 passed / 4 failed — alle 4 per Solo-Lauf als Suite-Isolation bewiesen (N1 solo 18/18, N3-Test solo grün — bekannter Vorbestand, unterschiedliche Plugin-Fixtures in einem Prozess)
|
||||||
|
- ✅ Cross-Plugin-Checker: 0 Verstöße; Ruff: 7 Fehler = exakt Vorbestand (Stash-Beweis: clean HEAD identisch 7)
|
||||||
|
|
||||||
|
**Phase N Gesamtbilanz:** Workspace-Scopes komplett — Registry via Contract (N1), dynamischer Editor (N2), Backend-Filterung für alle 11 Module (N3: contacts/dms/mail/calendar + N4: tasks/communication/wiki/reports/agents/tags/search) + Navigation (Startseite, Menü-Reihenfolge) + Dashboard-Schnittstelle (widget_app_ids). Security-Invariante durchgehend: Scope = reine UND-Einschränkung, Exemptions nur System-Admin + configure_modules-Inhaber (Editor-Deadlock). Issues #365-#368 alle geschlossen.
|
||||||
|
|
||||||
|
## Phase N3 — Erste vier Module integrieren (2026-09-01) ✅
|
||||||
|
|
||||||
|
**Spec:** [#367](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/367) | **Roadmap:** Phase N, N3 | **Milestone:** Phase N — Workspace-Scopes (#14)
|
||||||
|
|
||||||
|
**Umgesetzt:**
|
||||||
|
- **Core-Resolver** `resolve_workspace_scope()` (workspace_scope_service.py): X-Workspace-ID → Workspace aktiv/Tenant → User-Zuweisung → Modul-config; leere Dimensionswerte fallen weg. Exemptions: System-Admins + `workspaces:configure_modules`-Inhaber — löst den Editor-Deadlock (N2-Scope-Editor lädt Wertoptionen über dieselben Endpoints).
|
||||||
|
- **FastAPI-Dependency** `require_workspace_scope(module_key)` (deps.py) — Header-Parsing gekapselt, einzeilige Nutzung pro Route.
|
||||||
|
- **Ordner-Subtree** `expand_folder_scope()`: self + descendants (zyklensicher) für ContactFolder + DMS Folder — Ordner-Scopes gelten inkl. Unterordnern. `scope_uuid_set()`: fail-closed (garbage UUIDs → leere Menge).
|
||||||
|
- **Listen-Filter (additive UND-Einschränkung, kein Umbau):** contacts (folder_ids-Subtree + contact_types auf GET /contacts; List-Cache bei aktivem Scope deaktiviert — Cross-Workspace-Leak-Gefahr beseitigt), dms (folder_ids-Subtree + file_types auf GET /files, Baum-Reduktion auf GET /folders; semantische Typ-Matcher pdf/image/spreadsheet/word/other), mail (account_ids auf GET /mails, /threads, /accounts-Picker), calendar (calendar_ids auf GET /calendar/entries + /calendars-Picker).
|
||||||
|
- **Frontend-Defaults:** `getModuleConfig(moduleKey)` im workspaceStore; ContactsList wendet `default_saved_view_id` beim Mount an (admin-definierte Standard-Ansicht), Calendar setzt `default_view` (day/week/month/range) bei Workspace-Wechsel.
|
||||||
|
|
||||||
|
**Verifiziert (2026-09-01):**
|
||||||
|
- TDD: Rot (7 ImportError + 14 Fixture-Errors) → ✅ Grün **21/21** (Resolver 7, Contacts 5 mit Cache-Bypass-Beweis + scharfem AND-Beweis (Beta=Person im Ordner-Scope fällt raus), DMS 3, Mail 3, Calendar 3 inkl. Admin-Bypass)
|
||||||
|
- ✅ Regression: N1 + N2 + Workspaces + test_mail **81 passed**
|
||||||
|
- ✅ Cross-Plugin-Checker: 0 Verstöße; Ruff: nur per Stash bewiesener Vorbestand (N806/UP017)
|
||||||
|
- ✅ Frontend: tsc clean, Vitest (workspaceStore 16/16, CalendarPage, ContactsList) grün, Production-Build OK
|
||||||
|
|
||||||
|
**Offen in Phase N:** N4 restliche Module (Tasks „nur meine", Kommunikation-Räume, Wiki-Kategorien, Reports-Vorlagen, Automation-Agenten, Tags, Search-Provider, Navigation-Defaults) + Dashboard-Schnittstelle (workspace_widgets begrenzt Widget-TYP-Angebot).
|
||||||
|
|
||||||
|
## Phase N2 — Dynamischer Scope-Editor (2026-09-01) ✅
|
||||||
|
|
||||||
|
**Spec:** [#366](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/366) | **Roadmap:** Phase N, N2 | **Milestone:** Phase N — Workspace-Scopes (#14)
|
||||||
|
|
||||||
|
**Umgesetzt:**
|
||||||
|
- **WorkspaceScopeEditor.tsx** (neu): generisches Filter-UI aus /scope-definitions — multiselect (statische Options ODER value_source-Fetch), select (mit 'Keine Einschränkung'-Placeholder), toggle. WidgetSettingsForm-Philosophie (M3): die Komponente kennt keine spezifischen Module, Plugins deklarieren via Contracts.
|
||||||
|
- **resolveScopeItems** (api/hooks/workspaces.ts): Wertequellen-Auflösung für alle N1-Formate — items-Wrapper (contact-folders), Root-Listen (mail/accounts, calendars, saved-views), DMS-Ordner-Baum (children-Flattening). Nie-crashend: defekte Responses → leere Liste.
|
||||||
|
- **Hooks:** useWorkspaceScopeDefinitions (queryKey workspace-scope-definitions) + useScopeValues (endpoint-spezifisch, staleTime 60s).
|
||||||
|
- **WorkspaceManager:** JSON-Textarea-Editor ENTFERNT — dynamischer Scope-Editor inline pro sichtbarem Modul; Speicherung unverändert über POST /{id}/modules in workspace_modules.config.
|
||||||
|
- **i18n:** workspaces.scopeEditor.* 5 Keys (de/en) — hint (Security-Invariante im UI), noRestriction, noDimensions, noValues, loadError.
|
||||||
|
|
||||||
|
**Verifiziert (2026-09-01):**
|
||||||
|
- TDD: Rot 4 failed → ✅ Grün **21/21** (WorkspaceScopeEditor 17: resolveScopeItems-Unit 4, Rendering 7, onChange 6; WorkspaceManager-Integration 4: Textarea weg + Scope-Fields da, Config-Roundtrip checked, Save-Payload config korrekt, No-Dimensions-Hinweis nach Toggle)
|
||||||
|
- ✅ npx tsc --noEmit: clean (0 Errors)
|
||||||
|
- ✅ Production-Build: OK (vite build, nur Chunk-Size-Warnung Vorbestand)
|
||||||
|
- ✅ Frontend-only-Deploy + Bundle live verifiziert
|
||||||
|
|
||||||
|
**Offen in Phase N:** N3 Backend-Listen-Filterung via X-Workspace-ID (additive UND-Einschränkung: contacts Ordner/Typen/View, dms Ordner/Typen, mail Postfächer, calendar Kalender/View), N4 restliche Module.
|
||||||
|
|
||||||
|
## Phase N1 — Scope-Registry via Contract (2026-08-31) ✅
|
||||||
|
|
||||||
|
**Spec:** [#365](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/365) | **Roadmap:** Phase N, N1 | **Milestone:** Phase N — Workspace-Scopes (#14)
|
||||||
|
|
||||||
|
**Umgesetzt:**
|
||||||
|
- **Contract-Hook `workspace_scopes()`** (document_placeholders-Muster, #359-Philosophie): Plugins deklarieren Scope-Dimensionen ihres Moduls inkl. Wertequellen; der generische Editor bleibt modul-agnostisch.
|
||||||
|
- **Deklarationen (4 N3-Module):** contacts (folder_ids via /api/v1/contact-folders, contact_types Firmen/Personen, default_saved_view_id via /api/v1/saved-views?entity_type=contact), dms (folder_ids via /api/v1/dms/folders, file_types PDF/Bilder/Tabellen/Dokumente/Sonstige), mail (account_ids via /api/v1/mail/accounts), calendar (calendar_ids via /api/v1/calendars, default_view Tag/Woche/Monat/Zeitraum — Frontend-Ansichten live abgeglichen).
|
||||||
|
- **Pydantic fail-closed** (app/schemas/workspace.py): ScopeOption, ScopeValueSource (nur interne /api/v1/-Pfade — SSRF-sicher per Konstruktion; Validator), WorkspaceScopeDimension (multiselect/select ohne options UND value_source → ValidationError), WorkspaceModuleScopes (module_key + min. 1 Dimension).
|
||||||
|
- **Aggregator** (app/services/workspace_scope_service.py): iteriert discovered Plugins, lazy-loadet Contracts, ARCH-014-safe (deaktivierte bleiben weg), Crash-sicher pro Plugin, ungültige Deklarationen verworfen (Warning-Log).
|
||||||
|
- **Endpoint** `GET /api/v1/workspaces/scope-definitions` (workspaces:configure_modules — Admin-Kontext) — VOR /{workspace_id} registriert (Route-Order-Falle, test_plugin_route_order-Klasse).
|
||||||
|
|
||||||
|
**Verifiziert (2026-08-31):**
|
||||||
|
- TDD: Rot (ImportError) → ✅ Grün **18/18** (tests/test_workspace_scopes.py: Pydantic-Unit 4, Contract-Deklarationen 8, Aggregator fail-closed 1, HTTP-Endpoint 2 (Admin bekommt alle 4 Module, Viewer-403), Route-Order 1, Value-Endpoint-Existenz via OpenAPI 1 (431 Pfade, app.routes enthält nur _IncludedRouter-Wrapper — isinstance-Scan versagt, OpenAPI kanonisch), /context-config-Regression 1)
|
||||||
|
- ✅ Regression: test_workspaces.py **17/17**
|
||||||
|
- ✅ Cross-Plugin-Checker: 0 Verstöße (495 Dateien)
|
||||||
|
- ✅ Ruff clean (alle 8 geänderten Dateien; UP037-Quote-Fix)
|
||||||
|
|
||||||
|
**Offen in Phase N:** N2 Dynamischer Scope-Editor (WorkspaceManager rendert Filter-UI aus /scope-definitions, Speicherung in workspace_modules.config), N3 Listen-Filterung via X-Workspace-ID (additive UND-Einschränkung), N4 restliche Module.
|
||||||
|
|
||||||
|
## Phase M6 — Weitere Hosts (2026-08-30) ✅ — PHASE M KOMPLETT
|
||||||
|
|
||||||
|
**Spec:** [#364](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/364) | **Roadmap:** Phase M, M6 (letzter Task)
|
||||||
|
|
||||||
|
**Umgesetzt:**
|
||||||
|
- **Windows-Host:** `openMiniAppWindow`-Helper + `MiniAppWindowContent` (windowStore, schwebende/verschiebbare Fenster, kompakte Default-Größe 520×480). Öffnen-Buttons: Chat-Block (MiniAppBlock, ExternalLink-Icon) und Dashboard-Widget (DashboardBuilder, auch View-Modus).
|
||||||
|
- **AI-Agenten-Host:** Core-Tool `send_miniapp` (app/ai/miniapp_tools.py) — Agent bettet MiniApp als interaktiven Ausgabe-Block (block_type miniapp, approval_request-Präzedenz) in seinen Chat-Raum ein. Permission fail-closed gegen den aufrufenden User pro App (resolve_permissions + check_permission); Registrierung im lifespan-Startup.
|
||||||
|
- **agent_loop:** tool_context um agent_name erweitert (Raum-Auflösung "Agent: {name}").
|
||||||
|
- **Fix:** MiniAppBlock nutzt jetzt useMiniapps (universelle Registry MIT component-Feld) statt Legacy /comm/miniapps — der Fenster-Button erscheint damit erstmals zuverlässig.
|
||||||
|
- **Fix (Vorbestand, live gemessen):** /api/v1/agents/tools rief registry.list_tools() auf (Methode existiert nicht → 500) — auf list_for_api() mit korrektem Feld-Mapping umgestellt + Regressionstest gesichert.
|
||||||
|
|
||||||
|
**Verifiziert (2026-08-30):**
|
||||||
|
- TDD: Rot 7 failed → ✅ Grün **8/8** (tests/test_m6_miniapp_hosts.py: Tool-Registrierung 1, Handler 5 (unknown/nie-posten/Permission-deny/Block-Posting mit exakter block_data/no-room-degradation), agent_name-Kontext 1, list_for_api-Regression 1)
|
||||||
|
- ✅ Backend-Regression: M6 + M5 + Phase-F-Agenten **57/57**
|
||||||
|
- ✅ Frontend: Vitest **26/26** (4 neue Window-Tests: MiniAppWindowContent-Rendering + openMiniAppWindow-Store-Integration, Typ/Title); `npx tsc --noEmit` clean; Production-Build OK (2.74s)
|
||||||
|
- ✅ Deploy (335762d + 04e9279, Full): Health healthy, Alembic 0144, RLS 113 Tabellen
|
||||||
|
- ✅ Produktions-Verifikation (curl): /api/v1/agents/tools listet **send_miniapp live** (18 Tools total, plugin system) — Vorbestands-500 gefixt; neuer Frontend-Bundle live
|
||||||
|
|
||||||
|
**Phase M Gesamtbilanz:** MiniApp-Plattform komplett — universelle Registry (M1), persönliche Dashboards mit RLS (M2), Builder mit Drag&Drop/Tabs/Settings (M3), Core als reiner Host (M4), 8 Plugins + 2 System-Apps liefern Widgets (M5), Chat + Dashboard + Fenster + AI-Agenten als Hosts (M6). 17 Apps, 11 renderbar, in Produktion live.
|
||||||
|
|
||||||
|
## Phase M5 — Plugin-MiniApps (2026-08-30) ✅
|
||||||
|
|
||||||
|
**Spec:** [#363](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/363) | **Roadmap:** Phase M, M5
|
||||||
|
|
||||||
|
**Umgesetzt:**
|
||||||
|
- 5 Manifest-Beiträge (MiniAppContribution, gleiche Philosophie wie contacts_stats):
|
||||||
|
- dms: `dms_folders` (dms:read, Ordner mit Dateizählern, Settings max_items)
|
||||||
|
- mail: `mail_unread` (mail:read, ungelesene Mails je Ordner, Settings max_items)
|
||||||
|
- wiki: `wiki_recent` (wiki:read, zuletzt aktualisierte Artikel, Settings max_items)
|
||||||
|
- graph_rag: `graph_overview` (graph:read, Beziehungsübersicht, Settings max_items)
|
||||||
|
- automation: `automation_status` (automation:read, aktive/inaktive Automationen, Settings max_items)
|
||||||
|
- 5 Frontend-Widgets auf bestehenden API-Clients (keine neuen Backend-Endpoints, §0.2): DmsFoldersWidget (fetchFolders), MailUnreadWidget (fetchAccounts+fetchFolders/unread_count), WikiRecentWidget (fetchWikiArticles), GraphOverviewWidget (fetchGraphRelationships), AutomationStatusWidget (useAutomations). MiniAppHost-Registry +5.
|
||||||
|
- **Bug gefunden & gefixt (live gemessen):** automation/plugin.py on_activate re-registrierte Manifest-MiniApps in einem Legacy-Block OHNE component/permission — überschrieb die korrekte M1-Registrierung aus super().on_activate(). Legacy-Block entfernt + Regressionstest gesichert (test_automation_legacy_reregistration_removed).
|
||||||
|
- ruff: wiki I001 Import-Sortierung gefixt.
|
||||||
|
|
||||||
|
**Verifiziert (2026-08-30):**
|
||||||
|
- TDD: Rot 7 failed → ✅ Grün **8/8** (tests/test_m5_plugin_miniapps.py: Manifest-Felder 5, Lifecycle-component-Beweis 1, settings_schema 1, Legacy-Regressionstest 1)
|
||||||
|
- ✅ Backend-Regression: M5 + Registry + M4 + M2 **53/53**; nach automation-Fix: M5 + lifecycle + registry **26/26**
|
||||||
|
- ✅ Frontend: Vitest **22/22**; `npx tsc --noEmit` clean; Production-Build OK
|
||||||
|
- ✅ Deploy (7ed5349 + cd34bab, Full): Health healthy, Alembic 0144, RLS 113 Tabellen
|
||||||
|
- ✅ Produktions-Verifikation (curl): /api/v1/miniapps?host=dashboard liefert **17 Apps, 11 renderable** — alle 5 neuen Apps live mit component und Permission (automation_status nach Fix: comp=YES, perm=automation:read)
|
||||||
|
|
||||||
|
## Phase M4 — System-Rückbau (2026-08-30) ✅
|
||||||
|
|
||||||
|
**Spec:** [#362](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/362) | **Roadmap:** Phase M, M4
|
||||||
|
|
||||||
|
**Umgesetzt:**
|
||||||
|
- `app/core/system_miniapps.py`: Core-eigene MiniApps — `audit_activity` (Aktivitäten, audit:read, settings_schema max_items 1-50, Standard 10) + `system_metrics` (DB/Redis/Worker/API, settings:read; Endpoint /system/dashboard bleibt require_admin, Widget zeigt ohne Admin-Rechte kompakten Hinweis). Registrierung im lifespan-Startup (main.py), unabhängig von Plugin-Aktivierung.
|
||||||
|
- **base.py-Fix (M1-Lücke):** native Manifest-MiniApps reichen jetzt `component` an die Registry durch (vorher nur der dashboard_widgets-Alias — Ursache, warum Chat-Apps kein component hatten).
|
||||||
|
- **contacts-Manifest:** `contacts_stats` als native MiniApp (ContactsStatsWidget, contacts:read, settings: show_companies/show_persons) — Nachfolger der StatCards.
|
||||||
|
- **Seed-Fix (routes/dashboards.py):** Dashboard-Seed platziert nur renderbare Apps (component vorhanden) — Chat-Interaktions-Apps ohne Frontend-Component bleiben aus Layouts raus (Produktions-Messung M2: 9 Widgets, nur 3 renderbar → jetzt gefiltert).
|
||||||
|
- **Frontend-Widgets:** ContactsStatsWidget (Firmen-/Personen-Zähler), AuditActivityWidget (ActivityFeed-Nachfolger, max_items), SystemMetricsWidget (DB/Redis/Worker/API-Karten) — alle mit WidgetComponentProps (settings). MiniAppHost-Registry +3.
|
||||||
|
- `Dashboard.tsx` ist reiner Host (26 Z.): keine hardcodierten Inhalte mehr — StatCards/ActivityFeed/SystemMetrics existieren ausschließlich als persönliche MiniApp-Instanzen.
|
||||||
|
- i18n: systemMetricsNoAccess (de/en). M2-Seed-Tests auf renderbare Apps umgestellt (neue Seed-Spezifikation).
|
||||||
|
|
||||||
|
**Verifiziert (2026-08-30):**
|
||||||
|
- TDD: Rot 7 errors → ✅ Grün **7/7** (tests/test_m4_system_miniapps.py: System-App-Definitionen 4, API-Permission-Filter 2, Seed-component-Filter 1)
|
||||||
|
- ✅ Backend-Regression: M4 + M2 (angepasst) + MiniApp-Registry **46/46** — base.py-Fix und Seed-Änderung brechen keine Bestandstests
|
||||||
|
- ✅ Frontend: Vitest **22/22** (Builder 13, Page-Pure-Host 4 neu geschrieben, i18n 5); `npx tsc --noEmit` clean; Production-Build OK
|
||||||
|
- ✅ Deploy (3c496f4, Full): Health healthy, Alembic 0144, RLS 113 Tabellen
|
||||||
|
- ✅ Produktions-Verifikation (curl): /api/v1/miniapps?host=dashboard liefert **12 Apps, 6 renderable** (vorher 3) — contacts_stats/audit_activity/system_metrics live mit component; Chat-Apps korrekt comp=NONE
|
||||||
|
|
||||||
|
## Phase M3 — Dashboard-Builder-Frontend (2026-08-30) ✅
|
||||||
|
|
||||||
|
**Spec:** [#361](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/361) | **Roadmap:** Phase M, M3
|
||||||
|
|
||||||
|
**Umgesetzt:**
|
||||||
|
- `api/miniapps.ts` + `api/dashboards.ts`: TanStack-Query-Hooks (M2-Backend + M1-Registry, Query-Keys + Invalidation nach documents.ts-Muster); `renderableDashboardApps()` filtert Apps ohne component (6 der 9 Bestands-Apps sind Chat-Interaktions-Apps ohne Frontend-Component).
|
||||||
|
- `MiniAppHost.tsx`: ersetzt DashboardWidgetLoader (P2-F17-Erbe) — Lazy-Component-Registry + settings-Props an Widget-Komponenten; Apps ohne Component renderen render_schema-Karte (MiniAppBlock-Präzedenz).
|
||||||
|
- `DashboardBuilder.tsx` (605 Z.): View/Edit-Modus-Schalter; @dnd-kit-Sortable-Grid (rectSortingStrategy) mit 12-Spalten-Flow-Repositionierung (identisch zum Server-Seed); Palette (permission-gefiltert via /api/v1/miniapps?host=dashboard, nur renderbare Apps); Resize (col/row ±, geclamped 1-12); Tab-Verwaltung (Add/Remove/Rename, min 1); Dashboard-CRUD + Set-Default; Dirty-Check (Save disabled bei unverändertem Layout); Settings-Modal pro Widget.
|
||||||
|
- `WidgetSettingsForm.tsx`: generisches Form aus settings_schema (text/number/boolean/select, MiniAppField-Typ).
|
||||||
|
- Bestands-Widgets auf settings-Props umgestellt (RecentContacts nutzt settings.limit, geclamped 1-50; Tasks/Calendar kompatibel optional).
|
||||||
|
- `Dashboard.tsx`: Builder als Hauptinhalt (reiner Host-Pattern); StatCards/SystemMetrics/ActivityFeed bleiben sichtbar bis M4-Rückbau (kein Funktionsverlust).
|
||||||
|
- Legacy `DashboardGrid.tsx` + `DashboardWidgetLoader.tsx` gelöscht; Geister-Test ersetzt (§10: UI-Änderung → Test-Nachzug).
|
||||||
|
- i18n: dashboard.builder.* 22 Keys (de+en).
|
||||||
|
|
||||||
|
**Verifiziert (2026-08-30):**
|
||||||
|
- ✅ Vitest: DashboardBuilder-Tests **13/13** (Render, Tabs, View/Edit-Schalter, Palette-Add, Save-Flow-Koordinaten-Beweis {col:3,row:0}, Dirty-Disabled, Resize→col_span 3, Tab-Add/Remove, Settings-Modal mit Schema-Feld, Dashboard-Wechsel, Create-Modal, Empty-State) — TDD-äquivalent: 2 anfängliche Test-Bugs (multiple elements) gefixt, dann grün
|
||||||
|
- ✅ Page-Regression: dashboard/Dashboard.test.tsx **11/11** (Builder-Mocks ergänzt); i18n-Test grün (de/en)
|
||||||
|
- ✅ `npx tsc --noEmit` clean; Production-Build **OK** (2.98s)
|
||||||
|
- ⚠️ Sidebar-Test-Run: Worker-OOM (0 Tests ausgeführt) = PROGRESS.md Finding #3 Vorbestand (identisch bei AppShell-Runs vor M3)
|
||||||
|
- ✅ Frontend-Deploy (26948fd, ~20s): neuer Bundle live (index-BGuPWW7I.js), Health healthy, Login 200; /api/v1/miniapps?host=dashboard liefert 3 renderbare Apps von 9 (component-Filter greift: 6 Chat-Interaktions-Apps ohne Frontend-Component)
|
||||||
|
|
||||||
|
## Phase M2 — Dashboard-Backend (2026-08-30) ✅
|
||||||
|
|
||||||
|
**Spec:** [#360](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/360) | **Roadmap:** Phase M, M2
|
||||||
|
|
||||||
|
**Umgesetzt:**
|
||||||
|
- `app/models/dashboard.py`: `dashboards`-Tabelle (persönlich, saved_views-Präzedenz: user_id NOT NULL CASCADE, TenantMixin, kein OwnedMixin). Layout JSONB, `is_default`, partial unique index (tenant, user, name) WHERE deleted_at IS NULL — soft-deleted Boards geben Namen frei (Verbesserung ggü. saved_views-Wart).
|
||||||
|
- `app/schemas/dashboard.py`: DashboardLayout/Tab/Widget (12-Spalten-Grid: col/row ≥ 0, Spans 1-12) → 422 auf invalide Layouts, bevor persistiert wird.
|
||||||
|
- `app/routes/dashboards.py` (313 Z.): 6 Endpoints — GET (Liste + lazy Seed), POST (409 dup, erstes = default, ein leerer Start-Tab), GET/{id}, PUT/{id} (Name/Layout, db.refresh gegen MissingGreenlet), DELETE/{id} (Soft-Delete, Default-Promotion), POST/{id}/set-default (exakt ein Default). Owner-only (tenant + user_id Filter, fremde = 404), Audit-Log bei allen Mutationen.
|
||||||
|
- Lazy Default-Seed: erste GET-Abrufung erzeugt „Mein Dashboard“ aus MiniApp-Registry (permission-gefiltert via geteiltem `user_permits`, Registry-Order, 12-Spalten-Flow mit Wrap).
|
||||||
|
- `user_permits()` in miniapp_registry.py als geteilter Fail-Closed-Filter (miniapps.py behält `_user_permits`-Alias).
|
||||||
|
- CORE_PERMISSIONS: `dashboard:read`/`dashboard:write` — **fixt Phantom-Permission** (app/routes/dashboard.py verlangte dashboard:read, nirgends registriert → Nicht-Admins konnten sie nie erhalten).
|
||||||
|
- Migration `0144_personal_dashboards.py`: dashboards-Tabelle + RLS im 0090-Muster (**crm_api + crm_worker**) + **konvergenter Fix der 3 Phase-L-Policies** (letterheads/print_templates/document_assets waren `TO crm_api`-only — live auf Produktion gemessen, s. Verifikation). Plugin-SQL 0003 ebenfalls auf beide Rollen korrigiert.
|
||||||
|
- Doku: api-documentation.md (neue Core-Section dashboards, 6 Endpoints).
|
||||||
|
|
||||||
|
**Verifiziert (2026-08-30):**
|
||||||
|
- TDD: Rot 21 failed/1 passed → ✅ Grün **23/23** (tests/test_dashboards_backend.py: Model/Permission-Unit 3, Layout-Validation 5, CRUD 9, Ownership/Isolation 4, RLS-Konvergenz 2)
|
||||||
|
- ✅ Live-Messung (psql): Produktion vor Fix — 3 Policies `{crm_api}`-only (letterheads, print_templates, document_assets); lokal nach 0144 — alle 4 Tabellen `{crm_api,crm_worker}`
|
||||||
|
- ✅ Regression: rls_coverage + miniapp_registry + dashboard + lifecycle + route_order 37/38 — 1 Failure (test_dashboard cross-tenant, POST /companies 405) = **per Stash bewiesener Vorbestand** (identischer Failure auf clean HEAD); solo 5/5 grün
|
||||||
|
- ✅ Regression Welle 2: rbac_comprehensive + arch_block_a **125/125**
|
||||||
|
- ✅ ruff clean (alle M2-Dateien inkl. Testdatei); create_app OK (85 Router-Routen)
|
||||||
|
- ✅ Deploy (b3e259f, Full-Deploy): Health healthy, Alembic 0144, RLS 113 Tabellen
|
||||||
|
- ✅ Produktions-Verifikation (psql + curl, 2026-08-30): alle 4 Policies {crm_api,crm_worker}, 0 fehlende Rollen; GET /api/v1/dashboards liefert Lazy-Seed („Mein Dashboard", default, 1 Tab, 9 Widgets aus Registry)
|
||||||
|
|
||||||
|
## Phase N — Workspace-Scopes (2026-08-30 geplant, user-abgestimmt)
|
||||||
|
|
||||||
|
**User-Vision:** Workspaces als voll anpassbare Arbeitskontexte — jedes Modul pro Workspace auf Teilmengen einschränkbar (z.B. nur Kontakt-Ordner X+Y, nur DMS-Ordner "Angebote", nur Mail-Postfach vertrieb@, nur Kalender "Vertrieb"). Admin-definiert, für zugewiesene User-Gruppen.
|
||||||
|
|
||||||
|
**WICHTIG — Klarstellung Workspace ≠ Dashboard (user-korrigiert):** Zwei getrennte Systeme. Workspace = Admin-Kontext (WAS ist sichtbar/verfügbar, Gruppen-Feature). Dashboard = persönlich (WIE ICH mein Dashboard baue, Phase M). workspace_widgets bleibt Workspace-Eigentum (verfügbare Widget-TYPEN), dashboards-Tabelle (Phase M2) bleibt User-Eigentum (persönliches Layout). Kein Überbau, keine Vermischung.
|
||||||
|
|
||||||
|
**Status:** not_started — Phase N (N1-N4) in PLATFORM_ROADMAP.md verankert. 0 Umbau nötig: Speicher (workspace_modules.config JSONB), Transport (X-Workspace-ID-Interceptor), Context-Endpoint und Sidebar-Consumer existieren bereits; N3/N4 = additive Scope-Anwendung in Modul-Listen (kein Refactoring).
|
||||||
|
|
||||||
|
**Security-Invariante:** Scope = reine UND-Einschränkung (Workspace-Scope ∧ RLS ∧ ABAC ∧ Permissions). Workspace kann NIE mehr sichtbar machen, nur weniger. Ohne Workspace = kein Filter (rückwärtskompatibel).
|
||||||
|
|
||||||
|
## Phase P — Notizen-App (Notion-artig, ersetzt Wiki) (2026-08-30 geplant, user-abgestimmt)
|
||||||
|
|
||||||
|
**User-Entscheidung:** Wiki wird komplett ersetzt durch Notion-artige Notizen-/Firmen-Wissen-App. Keine Legacy-App, keine Notion-Datenbanken erstmal — MiniApp-Blöcke stattdessen. Quer-Verweise + vollständige Such-Indexierung Pflicht. Edit-Konzept: Live-Inline-Editing wie Notion (kein Mode-Toggle, Auto-Save), Lese-Modus entsteht über Permissions + optional Page-Lock.
|
||||||
|
|
||||||
|
**Status:** not_started — Phase P (P1-P5) in PLATFORM_ROADMAP.md verankert. P1-P3+P5 unabhängig startbar; P4 braucht M1 (MiniApp-Registry).
|
||||||
|
|
||||||
## W3b — Settings Contribution-Wahrheit (2026-08-28) ✅
|
## W3b — Settings Contribution-Wahrheit (2026-08-28) ✅
|
||||||
|
|
||||||
**Verify-first (Live-Messung):** 7 Plugins liefern `settings_pages` via Manifest (mail, ai_assistant, ai_proactive, automation, permissions ×3, system_notif) — die hardcoded Items in `Settings.tsx` für mail/ai/notifications waren identische Duplikate.
|
**Verify-first (Live-Messung):** 7 Plugins liefern `settings_pages` via Manifest (mail, ai_assistant, ai_proactive, automation, permissions ×3, system_notif) — die hardcoded Items in `Settings.tsx` für mail/ai/notifications waren identische Duplikate.
|
||||||
@@ -562,14 +994,16 @@ Siehe `ENTERPRISE_READINESS_PLAN.md` für Details.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Offene Findings (einzige gueltige Tracking-Sektion, Stand 2026-08-27)
|
## Offene Findings (einzige gueltige Tracking-Sektion, Stand 2026-08-28)
|
||||||
|
|
||||||
> Ab hier gilt: Nur Findings mit Live-Messung vom selben Tag. Scanner-/Plan-Aussagen ohne Beweiszaehler zaehlen nicht.
|
> Ab hier gilt: Nur Findings mit Live-Messung vom selben Tag. Scanner-/Plan-Aussagen ohne Beweiszaehler zaehlen nicht.
|
||||||
|
|
||||||
| Finding | Verifiziert am | Messwert | Ort |
|
| Finding | Verifiziert am | Messwert | Ort |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Cross-Plugin Import Core→Plugin (real) | 2026-08-27 | app/core/jobs.py:333 importiert `kommunikation.models.CommMessage` direkt statt via get_contract | scripts/check_cross_plugin_imports.py |
|
| Cross-Plugin Import Core→Plugin | ✅ **erledigt 2026-08-28** (ad5601e: DSAR auf Contracts umgestellt, Checker 4→0 gegen 482 Dateien) | 0 Verstöße | scripts/check_cross_plugin_imports.py |
|
||||||
| God Objects >500 Z. (real, Refactoring-Programm) | 2026-08-27 | 59 Dateien; Top: mail/routes.py 1950, mail/imap_sync.py 1148, self_improvement/services.py 1058, calendar/routes.py 1026, plugins/registry.py 907 | wc -l |
|
| God Objects >500 Z. (real, Refactoring-Programm) | 2026-08-27 | 59 Dateien; Top: mail/routes.py 1950, mail/imap_sync.py 1148, self_improvement/services.py 1058, calendar/routes.py 1026, plugins/registry.py 907 | wc -l |
|
||||||
| AppShell 4 Testfailures (bewusst auf Baseline) | 2026-08-27 | Mock-Fix-Versuch induziert Worker-Hang; Clean-Tree per Stash bewiesen identisch | src/__tests__/shell/AppShell.test.tsx |
|
| Frontend-Vorbestand: 8 Test-Failures | ✅ **erledigt 2026-08-29** (Router ×2 per QueryClientProvider+Mocks 2/2 passed; AppShell-Mock existierte bereits, Plan-Eintrag veraltet; ContactEditModal = Geister-Test nach §10 gelöscht — Komponente weg seit db4701b) | Pakete 2+3, Commit 36dd7c5 | src/__tests__/shell/Router.test.tsx |
|
||||||
|
| Core-FK auf Plugin-Tabelle bricht `alembic check` | 2026-08-29 (Live-Messung: frische DB → upgrade head OK → `alembic check` NoReferencedTableError `entity_attachments.dms_file_id → files`; per Stash identisch auf clean HEAD = Vorbestand, kein Paket-6-Regression; event_outbox-Pendant im selben Lauf gefunden und FIX in 67c0dcd: models/__init__.py outbox-Import) | 1 verbleibender FK: entity_attachments.dms_file_id → files (DMS-Plugin-Tabelle); Metadata kennt `files` nur nach DMS-Plugin-Model-Import | app/models/entity_attachment.py + alembic/env.py (laedt nur app.models) |
|
||||||
|
| test_saved_filters 422-vs-400 | ✅ **gefixt 2026-08-28** | `_validate_entity_type` wirft jetzt 422 (FastAPI-Konvention), Test passed | app/routes/saved_filters.py + saved_views.py |
|
||||||
|
|
||||||
Erledigt und archiviert: BUG-006/012/015-Teile/021/022/025–035/039/069–070/075–078/080–082/093–100, ARCH-004/006/007/019/024/028/045 — Details in docs/archive/.
|
Erledigt und archiviert: BUG-006/012/015-Teile/021/022/025–035/039/069–070/075–078/080–082/093–100, ARCH-004/006/007/019/024/028/045 — Details in docs/archive/.
|
||||||
|
|||||||
@@ -3,6 +3,15 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
|
# F19 (Astra P1): deterministic full-model discovery for Alembic.
|
||||||
|
# `from app.models import *` only loads CORE models (48 tables in a fresh
|
||||||
|
# process). Contact and ~80 other tables physically live in plugins
|
||||||
|
# (e.g. app.plugins.builtins.contacts.models) — the lazy package
|
||||||
|
# __getattr__ never fires for wildcard imports. Without the plugin models
|
||||||
|
# the metadata sort fails (contact_merge_history → contacts FK) and
|
||||||
|
# `alembic check` compares against an incomplete schema.
|
||||||
|
import importlib
|
||||||
from logging.config import fileConfig
|
from logging.config import fileConfig
|
||||||
|
|
||||||
from sqlalchemy import pool
|
from sqlalchemy import pool
|
||||||
@@ -13,6 +22,15 @@ from alembic import context
|
|||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.core.db import Base
|
from app.core.db import Base
|
||||||
from app.models import * # noqa: F401,F403
|
from app.models import * # noqa: F401,F403
|
||||||
|
from app.plugins.registry import get_registry
|
||||||
|
|
||||||
|
_registry = get_registry()
|
||||||
|
_registry.discover_builtins()
|
||||||
|
for _plugin_name in _registry.list_discovered():
|
||||||
|
try:
|
||||||
|
importlib.import_module(f"app.plugins.builtins.{_plugin_name}.models")
|
||||||
|
except ImportError:
|
||||||
|
pass # plugin has no models module
|
||||||
|
|
||||||
config = context.config
|
config = context.config
|
||||||
if config.config_file_name is not None:
|
if config.config_file_name is not None:
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""Documents Generator tables (Phase L1): letterheads, print_templates,
|
||||||
|
document_assets.
|
||||||
|
|
||||||
|
Revision ID: 0143
|
||||||
|
Revises: 0142
|
||||||
|
Create Date: 2026-08-29
|
||||||
|
|
||||||
|
Dual-path convergence (Gate B): on plugin-first installs the report_generator
|
||||||
|
plugin migration 0003 has already created these tables — skip instead of
|
||||||
|
failing. Both paths converge to the identical schema (see
|
||||||
|
app/plugins/builtins/report_generator/migrations/0003_documents_generator.sql).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0143"
|
||||||
|
down_revision = "0142"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(conn, table_name: str) -> bool:
|
||||||
|
row = conn.execute(
|
||||||
|
sa.text("SELECT to_regclass(:tname) IS NOT NULL"),
|
||||||
|
{"tname": f"public.{table_name}"},
|
||||||
|
).scalar()
|
||||||
|
return bool(row)
|
||||||
|
|
||||||
|
|
||||||
|
def _rls(table: str) -> None:
|
||||||
|
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
|
||||||
|
op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY")
|
||||||
|
op.execute(f"DROP POLICY IF EXISTS {table}_tenant_isolation ON {table}")
|
||||||
|
op.execute(
|
||||||
|
f"CREATE POLICY {table}_tenant_isolation ON {table} AS PERMISSIVE "
|
||||||
|
f"FOR ALL TO crm_api "
|
||||||
|
f"USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid) "
|
||||||
|
f"WITH CHECK (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
if _table_exists(conn, "letterheads"):
|
||||||
|
return
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"letterheads",
|
||||||
|
sa.Column("id", PGUUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=False, server_default=""),
|
||||||
|
sa.Column("config", JSONB(), nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||||
|
sa.Column("is_default", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("tenant_id", PGUUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("owner_id", PGUUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("deleted_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("created_by", PGUUID(as_uuid=True), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_letterheads_tenant", "letterheads", ["tenant_id"])
|
||||||
|
op.create_index("ix_letterheads_name", "letterheads", ["name"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"print_templates",
|
||||||
|
sa.Column("id", PGUUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=False, server_default=""),
|
||||||
|
sa.Column("letterhead_id", PGUUID(as_uuid=True), sa.ForeignKey("letterheads.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("entity_type", sa.String(100), nullable=False, server_default="contact"),
|
||||||
|
sa.Column("blocks", JSONB(), nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||||
|
sa.Column("output_format", sa.String(20), nullable=False, server_default="pdf"),
|
||||||
|
sa.Column("tenant_id", PGUUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("owner_id", PGUUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("deleted_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("created_by", PGUUID(as_uuid=True), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_print_templates_tenant", "print_templates", ["tenant_id"])
|
||||||
|
op.create_index("ix_print_templates_name", "print_templates", ["name"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"document_assets",
|
||||||
|
sa.Column("id", PGUUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("letterhead_id", PGUUID(as_uuid=True), sa.ForeignKey("letterheads.id", ondelete="CASCADE"), nullable=True),
|
||||||
|
sa.Column("filename", sa.String(255), nullable=False),
|
||||||
|
sa.Column("mime_type", sa.String(100), nullable=False),
|
||||||
|
sa.Column("size_bytes", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("storage_path", sa.String(1024), nullable=False),
|
||||||
|
sa.Column("tenant_id", PGUUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("owner_id", PGUUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("deleted_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("created_by", PGUUID(as_uuid=True), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_document_assets_tenant", "document_assets", ["tenant_id"])
|
||||||
|
op.create_index("ix_document_assets_letterhead", "document_assets", ["letterhead_id"])
|
||||||
|
|
||||||
|
for table in ("letterheads", "print_templates", "document_assets"):
|
||||||
|
_rls(table)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
if not _table_exists(conn, "letterheads"):
|
||||||
|
return
|
||||||
|
for table in ("document_assets", "print_templates", "letterheads"):
|
||||||
|
op.execute(f"DROP POLICY IF EXISTS {table}_tenant_isolation ON {table}")
|
||||||
|
op.drop_table(table)
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""Personal dashboards table (Phase M2) + RLS policy-role convergence.
|
||||||
|
|
||||||
|
Revision ID: 0144
|
||||||
|
Revises: 0143
|
||||||
|
Create Date: 2026-08-30
|
||||||
|
|
||||||
|
Part 1 — dashboards: personal per-user dashboard layouts (JSONB tabs /
|
||||||
|
widgets). RLS follows the 0090 fail-closed pattern scoped to BOTH runtime
|
||||||
|
roles (crm_api, crm_worker).
|
||||||
|
|
||||||
|
Part 2 — convergence fix (measured live on production 2026-08-30):
|
||||||
|
migration 0143 created the letterheads/print_templates/document_assets
|
||||||
|
tenant-isolation policies with ``TO crm_api`` only, while the established
|
||||||
|
pattern (0090, verified by tests/test_rls_coverage.py) requires both
|
||||||
|
crm_api AND crm_worker. This migration recreates those policies with both
|
||||||
|
roles so both install paths (plugin-SQL 0003 / alembic 0143) converge.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0144"
|
||||||
|
down_revision = "0143"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
_TENANT_USING = (
|
||||||
|
"tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(conn, table_name: str) -> bool:
|
||||||
|
row = conn.execute(
|
||||||
|
sa.text("SELECT to_regclass(:tname) IS NOT NULL"),
|
||||||
|
{"tname": f"public.{table_name}"},
|
||||||
|
).scalar()
|
||||||
|
return bool(row)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_policy(table: str) -> None:
|
||||||
|
op.execute(f"DROP POLICY IF EXISTS {table}_tenant_isolation ON {table}")
|
||||||
|
op.execute(
|
||||||
|
f"CREATE POLICY {table}_tenant_isolation ON {table} AS PERMISSIVE "
|
||||||
|
f"FOR ALL TO crm_api, crm_worker "
|
||||||
|
f"USING ({_TENANT_USING}) "
|
||||||
|
f"WITH CHECK ({_TENANT_USING})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rls(table: str) -> None:
|
||||||
|
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
|
||||||
|
op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY")
|
||||||
|
_create_policy(table)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# ── Part 1: dashboards table ──
|
||||||
|
if not _table_exists(conn, "dashboards"):
|
||||||
|
op.create_table(
|
||||||
|
"dashboards",
|
||||||
|
sa.Column("id", PGUUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("name", sa.String(100), nullable=False),
|
||||||
|
sa.Column("layout", JSONB(), nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||||
|
sa.Column("is_default", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("user_id", PGUUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("tenant_id", PGUUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("deleted_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"uq_dashboards_tenant_user_name",
|
||||||
|
"dashboards",
|
||||||
|
["tenant_id", "user_id", "name"],
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=sa.text("deleted_at IS NULL"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_dashboards_tenant_user", "dashboards", ["tenant_id", "user_id"])
|
||||||
|
_rls("dashboards")
|
||||||
|
else:
|
||||||
|
# Dual-path convergence: table exists (plugin SQL), ensure policy roles
|
||||||
|
_create_policy("dashboards")
|
||||||
|
|
||||||
|
# ── Part 2: converge Phase L policies to crm_api + crm_worker ──
|
||||||
|
for table in ("letterheads", "print_templates", "document_assets"):
|
||||||
|
if _table_exists(conn, table):
|
||||||
|
_create_policy(table)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
# Revert the convergence fix to the (buggy) Phase L state first…
|
||||||
|
for table in ("letterheads", "print_templates", "document_assets"):
|
||||||
|
if _table_exists(conn, table):
|
||||||
|
op.execute(f"DROP POLICY IF EXISTS {table}_tenant_isolation ON {table}")
|
||||||
|
op.execute(
|
||||||
|
f"CREATE POLICY {table}_tenant_isolation ON {table} AS PERMISSIVE "
|
||||||
|
f"FOR ALL TO crm_api "
|
||||||
|
f"USING ({_TENANT_USING}) "
|
||||||
|
f"WITH CHECK ({_TENANT_USING})"
|
||||||
|
)
|
||||||
|
if _table_exists(conn, "dashboards"):
|
||||||
|
op.execute("DROP POLICY IF EXISTS dashboards_tenant_isolation ON dashboards")
|
||||||
|
op.drop_index("ix_dashboards_tenant_user", table_name="dashboards")
|
||||||
|
op.drop_index("uq_dashboards_tenant_user_name", table_name="dashboards")
|
||||||
|
op.drop_table("dashboards")
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Converged DELETE grants (F20/Astra).
|
||||||
|
|
||||||
|
Removes the effect of the blanket ``GRANT DELETE ON ALL TABLES`` that
|
||||||
|
prestart.sh applied on every boot — which silently undid migration
|
||||||
|
0100's protections on every container start.
|
||||||
|
|
||||||
|
Documented target state:
|
||||||
|
|
||||||
|
Runtime-legitimate DELETEs (crm_api only):
|
||||||
|
- users, user_tenants (user deletion on last membership, BUG-030)
|
||||||
|
- sessions (logout session invalidation)
|
||||||
|
- plugins, notification_types (plugin uninstall + registry sync)
|
||||||
|
|
||||||
|
Protected — DELETE stays REVOKED from crm_api AND crm_worker:
|
||||||
|
- audit_log (Astra acceptance: API/Worker write, never delete)
|
||||||
|
- api_tokens (revoke is an UPDATE on revoked_at)
|
||||||
|
- password_reset_tokens (consumption is an UPDATE on used_at)
|
||||||
|
- plugin_allowlist, plugin_migrations (install/migration path only —
|
||||||
|
plugin_migrations rows are deleted via the migration factory)
|
||||||
|
- tenants (never deleted at runtime)
|
||||||
|
- tenant_plugin_activation (deactivation is an UPDATE)
|
||||||
|
|
||||||
|
crm_worker receives no DELETE on any protected table (workers never
|
||||||
|
delete users, sessions or plugin rows).
|
||||||
|
|
||||||
|
Revision ID: 0145
|
||||||
|
Revises: 0144
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0145"
|
||||||
|
down_revision = "0144"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
# Tables where runtime DELETE is a documented, legitimate operation (crm_api)
|
||||||
|
RUNTIME_DELETE_TABLES = [
|
||||||
|
"users",
|
||||||
|
"user_tenants",
|
||||||
|
"sessions",
|
||||||
|
"plugins",
|
||||||
|
"notification_types",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Tables where DELETE must stay revoked from BOTH runtime roles (0100 + F20)
|
||||||
|
PROTECTED_TABLES = [
|
||||||
|
"audit_log",
|
||||||
|
"api_tokens",
|
||||||
|
"password_reset_tokens",
|
||||||
|
"plugin_allowlist",
|
||||||
|
"plugin_migrations",
|
||||||
|
"tenants",
|
||||||
|
"tenant_plugin_activation",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# 1. Re-assert 0100's revocations — production DBs have lived with the
|
||||||
|
# blanket boot grant, so revoke first for a deterministic baseline.
|
||||||
|
for table in PROTECTED_TABLES:
|
||||||
|
op.execute(f"REVOKE DELETE ON TABLE {table} FROM crm_api;")
|
||||||
|
op.execute(f"REVOKE DELETE ON TABLE {table} FROM crm_worker;")
|
||||||
|
|
||||||
|
# 2. Grant the runtime-legitimate DELETEs to crm_api (BUG-030 stays
|
||||||
|
# fixed, logout keeps working, plugin management keeps working).
|
||||||
|
for table in RUNTIME_DELETE_TABLES:
|
||||||
|
op.execute(f"GRANT DELETE ON TABLE {table} TO crm_api;")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Best-effort inverse: revoke the runtime grants, re-grant the
|
||||||
|
# protected tables (matching the pre-F20 blanket state).
|
||||||
|
for table in RUNTIME_DELETE_TABLES:
|
||||||
|
op.execute(f"REVOKE DELETE ON TABLE {table} FROM crm_api;")
|
||||||
|
for table in PROTECTED_TABLES:
|
||||||
|
op.execute(f"GRANT DELETE ON TABLE {table} TO crm_api;")
|
||||||
|
op.execute(f"GRANT DELETE ON TABLE {table} TO crm_worker;")
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Add resolved_by to approval_requests (F11/Astra).
|
||||||
|
|
||||||
|
Separates the assigned approver (approver_id — who the request was
|
||||||
|
addressed TO) from the actual decider (resolved_by — who decided).
|
||||||
|
Previously resolve_approval_request overwrote approver_id with the
|
||||||
|
acting user, destroying the assignment record.
|
||||||
|
|
||||||
|
Revision ID: 0146
|
||||||
|
Revises: 0145
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0146"
|
||||||
|
down_revision = "0145"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"approval_requests",
|
||||||
|
sa.Column("resolved_by", PGUUID(as_uuid=True), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("approval_requests", "resolved_by")
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Disable RLS on api_tokens (F08 bootstrap fix, Astra S2).
|
||||||
|
|
||||||
|
verify_api_token() must look up the token hash via the request session
|
||||||
|
(crm_api) BEFORE any tenant context exists — the TOKEN is what determines
|
||||||
|
the tenant. Forced RLS with a tenant-isolation policy on api_tokens made
|
||||||
|
that lookup return zero rows, so EVERY Bearer token was rejected with 401
|
||||||
|
"token_invalid", including freshly created ones (verified live on
|
||||||
|
production 2026-09-18).
|
||||||
|
|
||||||
|
This restores the documented decision from migration 0080 ("written
|
||||||
|
during login before tenant context") which 0084 inadvertently overrode
|
||||||
|
by blindly re-enabling fail-closed RLS everywhere. sessions and
|
||||||
|
password_reset_tokens remain RLS-off for the same bootstrap reason.
|
||||||
|
|
||||||
|
Security unchanged: the SHA-256 token hash IS the access secret — a
|
||||||
|
lookup by hash cannot enumerate other tenants' tokens, and every use of
|
||||||
|
the row still goes through the authenticated verify path.
|
||||||
|
|
||||||
|
Revision ID: 0147
|
||||||
|
Revises: 0146
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0147"
|
||||||
|
down_revision = "0146"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
POLICY_NAME = "api_tokens_tenant_isolation"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Remove the tenant-isolation policy first (it only covered the
|
||||||
|
# runtime roles anyway), then disable + unforce RLS.
|
||||||
|
op.execute(f"DROP POLICY IF EXISTS {POLICY_NAME} ON api_tokens;")
|
||||||
|
op.execute("ALTER TABLE api_tokens DISABLE ROW LEVEL SECURITY;")
|
||||||
|
op.execute("ALTER TABLE api_tokens NO FORCE ROW LEVEL SECURITY;")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Best-effort inverse: restore forced RLS + the previous policy.
|
||||||
|
op.execute("ALTER TABLE api_tokens ENABLE ROW LEVEL SECURITY;")
|
||||||
|
op.execute("ALTER TABLE api_tokens FORCE ROW LEVEL SECURITY;")
|
||||||
|
op.execute(
|
||||||
|
"CREATE POLICY api_tokens_tenant_isolation ON api_tokens "
|
||||||
|
"FOR ALL TO crm_api, crm_worker "
|
||||||
|
"USING (tenant_id = (NULLIF(current_setting('app.current_tenant_id', true), ''))::uuid)"
|
||||||
|
)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Add content_hash to plugin_migrations (F40/Astra).
|
||||||
|
|
||||||
|
The migration runner previously tracked migrations by FILENAME only —
|
||||||
|
editing an already-applied migration stayed unnoticed (the #389 bug
|
||||||
|
class: a broken migration was fixed, but the runner silently skipped
|
||||||
|
it because the filename was already tracked).
|
||||||
|
|
||||||
|
Now every applied migration records the SHA-256 of its SQL content.
|
||||||
|
On subsequent runs the runner compares hashes and logs a loud warning
|
||||||
|
when an applied migration was modified (repaired) — the skip stays
|
||||||
|
idempotent, but drift becomes VISIBLE.
|
||||||
|
|
||||||
|
Revision ID: 0148
|
||||||
|
Revises: 0147
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0148"
|
||||||
|
down_revision = "0147"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"plugin_migrations",
|
||||||
|
sa.Column("content_hash", sa.String(64), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_plugin_migrations_content_hash",
|
||||||
|
"plugin_migrations",
|
||||||
|
["content_hash"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_plugin_migrations_content_hash", table_name="plugin_migrations")
|
||||||
|
op.drop_column("plugin_migrations", "content_hash")
|
||||||
+148
-2
@@ -122,6 +122,10 @@ async def _execute_tool(
|
|||||||
"""Execute a single tool call via the registry.
|
"""Execute a single tool call via the registry.
|
||||||
|
|
||||||
Returns the tool result as a string, or an error message.
|
Returns the tool result as a string, or an error message.
|
||||||
|
|
||||||
|
Note: access control (allowlist + required_permission) is enforced by
|
||||||
|
``_check_tool_access`` in ``run_react_loop`` BEFORE any execution path
|
||||||
|
(dry-run, approval, execute) is reached.
|
||||||
"""
|
"""
|
||||||
tool = tool_registry.get(tool_name)
|
tool = tool_registry.get(tool_name)
|
||||||
if tool is None:
|
if tool is None:
|
||||||
@@ -135,6 +139,131 @@ async def _execute_tool(
|
|||||||
return f"Error: {exc}"
|
return f"Error: {exc}"
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_observation(observation: str) -> str:
|
||||||
|
"""F14 (Astra P1): sanitize a tool observation before it re-enters
|
||||||
|
the LLM conversation.
|
||||||
|
|
||||||
|
Tool responses are raw data (CRM records, mail payloads, settings) and
|
||||||
|
may contain sensitive fields (smtp_password, api keys, ...). The data
|
||||||
|
policy runs BEFORE the loop — observations arise INSIDE it and used to
|
||||||
|
reach the provider verbatim. Parse JSON observations and strip
|
||||||
|
sensitive fields (same SENSITIVE_FIELDS set as the data policy).
|
||||||
|
"""
|
||||||
|
stripped = observation.strip()
|
||||||
|
if not stripped.startswith(("{", "[")):
|
||||||
|
return observation
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
|
||||||
|
parsed = json.loads(stripped)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
return observation
|
||||||
|
|
||||||
|
from app.core.sensitive_data import SENSITIVE_FIELDS
|
||||||
|
|
||||||
|
sensitive_names: set[str] = set()
|
||||||
|
for fields in SENSITIVE_FIELDS.values():
|
||||||
|
sensitive_names |= fields
|
||||||
|
|
||||||
|
def _strip(data: Any) -> Any:
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return {
|
||||||
|
k: _strip(v)
|
||||||
|
for k, v in data.items()
|
||||||
|
if k not in sensitive_names
|
||||||
|
}
|
||||||
|
if isinstance(data, list):
|
||||||
|
return [_strip(v) for v in data]
|
||||||
|
return data
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
return json.dumps(_strip(parsed))
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_allowed_tool_names(tools: list[dict[str, Any]] | None) -> set[str]:
|
||||||
|
"""Extract the tool names actually offered to the LLM.
|
||||||
|
|
||||||
|
Always returns a set (possibly empty) — empty means no tools were
|
||||||
|
offered, so the caller fails closed on ANY tool call.
|
||||||
|
"""
|
||||||
|
if not tools:
|
||||||
|
return set()
|
||||||
|
names: set[str] = set()
|
||||||
|
for t in tools:
|
||||||
|
fn = (t or {}).get("function") or {}
|
||||||
|
name = fn.get("name")
|
||||||
|
if name:
|
||||||
|
names.add(str(name))
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_permissions(ctx: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
|
"""Normalize a user/agent context into the resolved-permissions shape
|
||||||
|
expected by ``check_permission`` (permissions / denied / is_system_admin).
|
||||||
|
|
||||||
|
Session user contexts carry ``denied_permissions`` while resolved
|
||||||
|
permission dicts use ``denied`` — both are accepted here.
|
||||||
|
"""
|
||||||
|
if not isinstance(ctx, dict):
|
||||||
|
return {"permissions": [], "denied": [], "is_system_admin": False}
|
||||||
|
return {
|
||||||
|
"permissions": list(ctx.get("permissions", []) or []),
|
||||||
|
"denied": list(ctx.get("denied", ctx.get("denied_permissions", [])) or []),
|
||||||
|
"is_system_admin": bool(ctx.get("is_system_admin", False)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _check_tool_access(
|
||||||
|
tool_registry: ToolRegistry,
|
||||||
|
tool_name: str,
|
||||||
|
allowed_tools: set[str] | None,
|
||||||
|
user_permissions: dict[str, Any] | None,
|
||||||
|
) -> str | None:
|
||||||
|
"""F01 guard: enforce allowlist + required_permission before execution.
|
||||||
|
|
||||||
|
Must run before EVERY execution path (dry-run, approval, execute).
|
||||||
|
Returns None when access is granted, otherwise an error observation.
|
||||||
|
|
||||||
|
Checks (fail-closed):
|
||||||
|
1. Allowlist — the tool must be among the schemas actually offered to
|
||||||
|
the LLM. A hallucinated/injected tool name never reaches a handler.
|
||||||
|
2. required_permission — when the tool declares one, the acting user's
|
||||||
|
CURRENT permissions must grant it. Without a permission context the
|
||||||
|
call is rejected (deny list first, system admin bypass).
|
||||||
|
"""
|
||||||
|
tool = tool_registry.get(tool_name)
|
||||||
|
if tool is None:
|
||||||
|
return None # "not found" is handled by _execute_tool
|
||||||
|
|
||||||
|
# 1. Allowlist: only tools offered to the LLM may run.
|
||||||
|
if allowed_tools is not None and tool_name not in allowed_tools:
|
||||||
|
logger.warning(
|
||||||
|
"F01 guard: tool '%s' is registered but NOT offered to this agent — rejected",
|
||||||
|
tool_name,
|
||||||
|
)
|
||||||
|
return f"Error: Tool '{tool_name}' is not available to this agent"
|
||||||
|
|
||||||
|
# 2. required_permission: enforce against the user's CURRENT permissions.
|
||||||
|
required = getattr(tool, "required_permission", None)
|
||||||
|
if isinstance(required, str) and required:
|
||||||
|
resolved = _normalize_permissions(user_permissions)
|
||||||
|
from app.core.permissions import check_permission
|
||||||
|
|
||||||
|
if not check_permission(resolved, required):
|
||||||
|
logger.warning(
|
||||||
|
"F01 guard: tool '%s' requires '%s' which the acting user lacks — rejected",
|
||||||
|
tool_name,
|
||||||
|
required,
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"Error: Permission '{required}' required for tool '{tool_name}' "
|
||||||
|
"and not granted to the acting user"
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def run_react_loop(
|
async def run_react_loop(
|
||||||
agent_definition: Any, # AgentDefinition from automation models
|
agent_definition: Any, # AgentDefinition from automation models
|
||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
@@ -151,6 +280,7 @@ async def run_react_loop(
|
|||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
require_approval: bool = False,
|
require_approval: bool = False,
|
||||||
approval_tools: list[str] | None = None,
|
approval_tools: list[str] | None = None,
|
||||||
|
user_permissions: dict[str, Any] | None = None,
|
||||||
) -> ReActResult:
|
) -> ReActResult:
|
||||||
"""Execute a ReAct loop: LLM reasoning → tool execution → repeat.
|
"""Execute a ReAct loop: LLM reasoning → tool execution → repeat.
|
||||||
|
|
||||||
@@ -197,8 +327,13 @@ async def run_react_loop(
|
|||||||
"tenant_id": str(tenant_id),
|
"tenant_id": str(tenant_id),
|
||||||
"user_id": str(user_id),
|
"user_id": str(user_id),
|
||||||
"db": db,
|
"db": db,
|
||||||
|
"agent_name": getattr(agent_definition, "name", "Agent"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# F01 (Astra P0): allowlist — only tools actually offered to the LLM
|
||||||
|
# may ever execute. Empty set = no tools offered = every call rejected.
|
||||||
|
allowed_tool_names: set[str] = _extract_allowed_tool_names(tools)
|
||||||
|
|
||||||
# Audit helper — records every tool call in the audit log.
|
# Audit helper — records every tool call in the audit log.
|
||||||
async def _audit_tool_call(
|
async def _audit_tool_call(
|
||||||
step_number: int,
|
step_number: int,
|
||||||
@@ -365,7 +500,15 @@ async def run_react_loop(
|
|||||||
args = {}
|
args = {}
|
||||||
logger.warning("Invalid JSON arguments for tool '%s': %s", tool_name, tc["arguments"])
|
logger.warning("Invalid JSON arguments for tool '%s': %s", tool_name, tc["arguments"])
|
||||||
|
|
||||||
if dry_run:
|
# F01 (Astra P0): enforce allowlist + required_permission before
|
||||||
|
# every execution path (dry-run, approval, execute). Fail-closed:
|
||||||
|
# a hallucinated or injected tool name never reaches a handler.
|
||||||
|
guard_error = _check_tool_access(
|
||||||
|
tool_registry, tool_name, allowed_tool_names, user_permissions
|
||||||
|
)
|
||||||
|
if guard_error is not None:
|
||||||
|
observation = guard_error
|
||||||
|
elif dry_run:
|
||||||
observation = json.dumps(
|
observation = json.dumps(
|
||||||
{
|
{
|
||||||
"dry_run": True,
|
"dry_run": True,
|
||||||
@@ -394,7 +537,7 @@ async def run_react_loop(
|
|||||||
if agent_run_id:
|
if agent_run_id:
|
||||||
try:
|
try:
|
||||||
from app.plugins.builtins.contracts import get_contract_registry
|
from app.plugins.builtins.contracts import get_contract_registry
|
||||||
komm = get_contract_registry().get("kommunikation")
|
komm = get_contract_registry().get_contract("kommunikation")
|
||||||
if komm:
|
if komm:
|
||||||
agent_id = getattr(agent_definition, "id", uuid.uuid4())
|
agent_id = getattr(agent_definition, "id", uuid.uuid4())
|
||||||
room_title = f"Agent: {getattr(agent_definition, 'name', 'Agent')}"
|
room_title = f"Agent: {getattr(agent_definition, 'name', 'Agent')}"
|
||||||
@@ -442,6 +585,9 @@ async def run_react_loop(
|
|||||||
observation = json.dumps({"error": f"Approval required but failed to create request: {e}"})
|
observation = json.dumps({"error": f"Approval required but failed to create request: {e}"})
|
||||||
else:
|
else:
|
||||||
observation = await _execute_tool(tool_registry, tool_name, args, tool_context)
|
observation = await _execute_tool(tool_registry, tool_name, args, tool_context)
|
||||||
|
# F14 (Astra P1): tool responses are raw data — sanitize the
|
||||||
|
# observation before it re-enters the LLM conversation.
|
||||||
|
observation = _filter_observation(observation)
|
||||||
observations.append(observation)
|
observations.append(observation)
|
||||||
|
|
||||||
# Audit every tool call (real or simulated)
|
# Audit every tool call (real or simulated)
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from typing import TYPE_CHECKING, Any, AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from app.ai.agent_loop import ReActStep, run_react_loop
|
from app.ai.agent_loop import ReActStep, run_react_loop
|
||||||
|
|
||||||
@@ -71,6 +72,7 @@ async def stream_react_loop(
|
|||||||
max_steps: int = 20,
|
max_steps: int = 20,
|
||||||
timeout_seconds: int = 300,
|
timeout_seconds: int = 300,
|
||||||
trace_id: str | None = None,
|
trace_id: str | None = None,
|
||||||
|
user_permissions: dict[str, Any] | None = None,
|
||||||
) -> AsyncGenerator[str, None]:
|
) -> AsyncGenerator[str, None]:
|
||||||
"""Run the ReAct loop and yield SSE-formatted events.
|
"""Run the ReAct loop and yield SSE-formatted events.
|
||||||
|
|
||||||
@@ -116,6 +118,7 @@ async def stream_react_loop(
|
|||||||
timeout_seconds=timeout_seconds,
|
timeout_seconds=timeout_seconds,
|
||||||
trace_id=trace_id,
|
trace_id=trace_id,
|
||||||
on_step=on_step,
|
on_step=on_step,
|
||||||
|
user_permissions=user_permissions, # F01: enforce at execution time
|
||||||
)
|
)
|
||||||
await queue.put(
|
await queue.put(
|
||||||
_sse(
|
_sse(
|
||||||
|
|||||||
@@ -82,6 +82,14 @@ async def enforce_data_policy(
|
|||||||
else c
|
else c
|
||||||
for c in content
|
for c in content
|
||||||
]
|
]
|
||||||
|
elif isinstance(content, str):
|
||||||
|
# F14 (Astra P1): JSON-serialized strings passed through
|
||||||
|
# UNFILTERED before — a payload like '{"smtp_password": ...}'
|
||||||
|
# reached the provider verbatim. Parse, filter, re-serialize;
|
||||||
|
# non-JSON strings stay unchanged (plain prose is fine).
|
||||||
|
content = _filter_json_string_content(
|
||||||
|
content, metadata, compliance, agent_definition
|
||||||
|
)
|
||||||
new_msg = dict(msg)
|
new_msg = dict(msg)
|
||||||
new_msg["content"] = content
|
new_msg["content"] = content
|
||||||
filtered.append(new_msg)
|
filtered.append(new_msg)
|
||||||
@@ -89,6 +97,44 @@ async def enforce_data_policy(
|
|||||||
return filtered
|
return filtered
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_json_string_content(
|
||||||
|
content: str,
|
||||||
|
metadata: AIUseCaseMetadata,
|
||||||
|
compliance: dict[str, Any] | None,
|
||||||
|
agent_definition: Any,
|
||||||
|
) -> str:
|
||||||
|
"""Filter a JSON-serialized string payload (F14).
|
||||||
|
|
||||||
|
Tries to parse the string as a JSON object/array and runs the SAME
|
||||||
|
dict-level filtering on it. Non-JSON strings are returned unchanged.
|
||||||
|
"""
|
||||||
|
stripped = content.strip()
|
||||||
|
if not stripped.startswith(("{", "[")):
|
||||||
|
return content
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
|
||||||
|
parsed = json.loads(stripped)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
return content # not JSON — plain string content is not a leak vector
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
filtered = _filter_dict_content(parsed, metadata, compliance, agent_definition)
|
||||||
|
import json
|
||||||
|
|
||||||
|
return json.dumps(filtered)
|
||||||
|
if isinstance(parsed, list):
|
||||||
|
filtered = [
|
||||||
|
_filter_dict_content(item, metadata, compliance, agent_definition)
|
||||||
|
if isinstance(item, dict)
|
||||||
|
else item
|
||||||
|
for item in parsed
|
||||||
|
]
|
||||||
|
import json
|
||||||
|
|
||||||
|
return json.dumps(filtered)
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
def _filter_dict_content(
|
def _filter_dict_content(
|
||||||
data: dict[str, Any],
|
data: dict[str, Any],
|
||||||
metadata: AIUseCaseMetadata,
|
metadata: AIUseCaseMetadata,
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""Core AI agent tools for MiniApp output (Phase M6).
|
||||||
|
|
||||||
|
``send_miniapp`` lets an agent embed a MiniApp as an interactive output
|
||||||
|
block in its chat room (block_type "miniapp", approval_request precedent
|
||||||
|
from agent_loop). Permission is checked fail-closed against the CALLING
|
||||||
|
user for the target app's own permission — the tool never widens access.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.ai.tool_registry import get_tool_registry
|
||||||
|
from app.core.permissions import check_permission, resolve_permissions
|
||||||
|
from app.plugins.miniapp_registry import get_miniapp_registry
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_komm_contract() -> Any | None:
|
||||||
|
"""Resolve the kommunikation contract (None when plugin inactive)."""
|
||||||
|
from app.plugins.builtins.contracts import get_contract_registry
|
||||||
|
|
||||||
|
return get_contract_registry().get_contract("kommunikation")
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_miniapp_handler(arguments: dict[str, Any], context: dict[str, Any]) -> str:
|
||||||
|
"""Send a MiniApp as an output block to the agent's chat room."""
|
||||||
|
app_id = str(arguments.get("app_id") or "")
|
||||||
|
settings = arguments.get("settings") or {}
|
||||||
|
if not isinstance(settings, dict):
|
||||||
|
settings = {}
|
||||||
|
|
||||||
|
app = get_miniapp_registry().get_app(app_id)
|
||||||
|
if app is None:
|
||||||
|
return f"Error: MiniApp '{app_id}' not found"
|
||||||
|
|
||||||
|
db = context.get("db")
|
||||||
|
tenant_id = context.get("tenant_id")
|
||||||
|
user_id = context.get("user_id")
|
||||||
|
if not tenant_id or not user_id or db is None:
|
||||||
|
return "Error: Missing tenant/user context"
|
||||||
|
|
||||||
|
try:
|
||||||
|
tenant_uuid = uuid.UUID(str(tenant_id))
|
||||||
|
user_uuid = uuid.UUID(str(user_id))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return "Error: Invalid tenant/user context"
|
||||||
|
|
||||||
|
# Fail-closed permission check against the calling user
|
||||||
|
resolved = await resolve_permissions(db, user_uuid, tenant_uuid)
|
||||||
|
if app.permission and not check_permission(resolved, app.permission):
|
||||||
|
return f"Error: Permission '{app.permission}' required for MiniApp '{app.app_id}'"
|
||||||
|
|
||||||
|
komm = _get_komm_contract()
|
||||||
|
if komm is None:
|
||||||
|
return "Error: Communication plugin not available"
|
||||||
|
|
||||||
|
agent_name = str(context.get("agent_name") or "Agent")
|
||||||
|
conv_id = await komm.find_locked_room_id(
|
||||||
|
db=db,
|
||||||
|
tenant_id=tenant_uuid,
|
||||||
|
plugin_name="automation",
|
||||||
|
title=f"Agent: {agent_name}",
|
||||||
|
)
|
||||||
|
if conv_id is None:
|
||||||
|
return f"Info: No agent chat room found for '{agent_name}' — MiniApp not posted"
|
||||||
|
|
||||||
|
agent_id_raw = context.get("agent_id")
|
||||||
|
try:
|
||||||
|
sender_id = uuid.UUID(str(agent_id_raw)) if agent_id_raw else None
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
sender_id = None
|
||||||
|
|
||||||
|
await komm.send_message(
|
||||||
|
db=db,
|
||||||
|
tenant_id=tenant_uuid,
|
||||||
|
conversation_id=conv_id,
|
||||||
|
sender_id=sender_id,
|
||||||
|
sender_type="agent",
|
||||||
|
content=f"MiniApp: {app.name}",
|
||||||
|
content_format="text",
|
||||||
|
blocks=[
|
||||||
|
{
|
||||||
|
"block_type": "miniapp",
|
||||||
|
"block_data": {"app_id": app_id, "config": settings},
|
||||||
|
"sort_order": 0,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return f"MiniApp '{app_id}' sent to chat"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Registration ───
|
||||||
|
|
||||||
|
|
||||||
|
def register_miniapp_tools() -> None:
|
||||||
|
"""Register the MiniApp agent tools in the global tool registry."""
|
||||||
|
registry = get_tool_registry()
|
||||||
|
registry.register(
|
||||||
|
name="send_miniapp",
|
||||||
|
description=(
|
||||||
|
"Eine MiniApp als interaktiven Ausgabe-Block in den Agent-Chat senden "
|
||||||
|
"(z.B. ein Widget mit Einstellungen anzeigen). Verfügbare App-IDs "
|
||||||
|
"stehen in /api/v1/miniapps."
|
||||||
|
),
|
||||||
|
parameters={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"app_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "ID der MiniApp (z.B. recent_contacts, tasks_summary)",
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Optionale Einstellungen für die MiniApp-Instanz",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["app_id"],
|
||||||
|
},
|
||||||
|
handler=_send_miniapp_handler,
|
||||||
|
plugin_name="system",
|
||||||
|
required_permission=None, # per-app check inside the handler (fail-closed)
|
||||||
|
category="ui",
|
||||||
|
)
|
||||||
+120
-7
@@ -52,6 +52,12 @@ class ApprovalRequest(Base, TenantMixin):
|
|||||||
PGUUID(as_uuid=True), nullable=True
|
PGUUID(as_uuid=True), nullable=True
|
||||||
)
|
)
|
||||||
approver_group: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
approver_group: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||||
|
# F11: who actually decided — approver_id stays the ASSIGNMENT,
|
||||||
|
# resolved_by records the ACTUAL decider (previously the assignment
|
||||||
|
# was overwritten by whoever decided).
|
||||||
|
resolved_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), nullable=True
|
||||||
|
)
|
||||||
status: Mapped[str] = mapped_column(
|
status: Mapped[str] = mapped_column(
|
||||||
String(20), nullable=False, default="pending"
|
String(20), nullable=False, default="pending"
|
||||||
)
|
)
|
||||||
@@ -103,6 +109,41 @@ async def create_approval_request(
|
|||||||
return req
|
return req
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalDecisionError(Exception):
|
||||||
|
"""Raised when an approval decision is invalid (F11/Astra).
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
code: machine-readable reason for the HTTP layer.
|
||||||
|
http_status: suggested HTTP status code.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, code: str, message: str, http_status: int = 403):
|
||||||
|
super().__init__(message)
|
||||||
|
self.code = code
|
||||||
|
self.http_status = http_status
|
||||||
|
|
||||||
|
|
||||||
|
async def _user_in_approver_group(
|
||||||
|
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, group_name: str
|
||||||
|
) -> bool:
|
||||||
|
"""Check whether the user is a member of the named approver group."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.models.group import Group, UserGroup
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(UserGroup.id)
|
||||||
|
.join(Group, UserGroup.group_id == Group.id)
|
||||||
|
.where(
|
||||||
|
UserGroup.user_id == user_id,
|
||||||
|
UserGroup.tenant_id == tenant_id,
|
||||||
|
Group.name == group_name,
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
return result.first() is not None
|
||||||
|
|
||||||
|
|
||||||
async def resolve_approval_request(
|
async def resolve_approval_request(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
tenant_id: uuid.UUID,
|
tenant_id: uuid.UUID,
|
||||||
@@ -111,12 +152,31 @@ async def resolve_approval_request(
|
|||||||
decision: str,
|
decision: str,
|
||||||
approver_id: uuid.UUID,
|
approver_id: uuid.UUID,
|
||||||
comment: str | None = None,
|
comment: str | None = None,
|
||||||
|
is_system_admin: bool = False,
|
||||||
) -> ApprovalRequest | None:
|
) -> ApprovalRequest | None:
|
||||||
"""Approve or reject a pending approval request.
|
"""Approve or reject a pending approval request (F11 hardened).
|
||||||
|
|
||||||
Returns the updated request, or ``None`` if not found / not pending.
|
Returns the updated request, or ``None`` if not found.
|
||||||
|
|
||||||
|
Raises ApprovalDecisionError when the decision is invalid:
|
||||||
|
- ``expired`` (410): the request's expires_at has passed — it is
|
||||||
|
marked expired and can no longer be decided.
|
||||||
|
- ``not_pending`` (409): the request was already decided concurrently.
|
||||||
|
- ``wrong_approver``(403): the acting user is neither the assigned
|
||||||
|
approver (approver_id) nor a member of the assigned approver_group.
|
||||||
|
Unassigned requests (no approver_id AND no approver_group) may be
|
||||||
|
decided by anyone holding approvals:approve; system admins may
|
||||||
|
decide any request (documented operations override).
|
||||||
|
|
||||||
|
The assignment (approver_id) is NEVER overwritten — the actual decider
|
||||||
|
is recorded in resolved_by (F11: assignment and decider are separate).
|
||||||
"""
|
"""
|
||||||
from sqlalchemy import select
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
|
||||||
|
if decision not in ("approved", "rejected"):
|
||||||
|
raise ValueError(f"invalid decision: {decision!r}")
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(ApprovalRequest).where(
|
select(ApprovalRequest).where(
|
||||||
@@ -125,14 +185,67 @@ async def resolve_approval_request(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
req = result.scalar_one_or_none()
|
req = result.scalar_one_or_none()
|
||||||
if req is None or req.status != "pending":
|
if req is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
req.status = decision
|
# 1. Expiry check — an expired request can no longer be decided.
|
||||||
req.approver_id = approver_id
|
if (
|
||||||
req.comment = comment
|
req.status == "pending"
|
||||||
|
and req.expires_at is not None
|
||||||
|
and req.expires_at < datetime.now(UTC)
|
||||||
|
):
|
||||||
|
req.status = "expired"
|
||||||
req.resolved_at = datetime.now(UTC)
|
req.resolved_at = datetime.now(UTC)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
raise ApprovalDecisionError(
|
||||||
|
"expired", "Approval request has expired", http_status=410
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Approver check — who may decide this request?
|
||||||
|
if not is_system_admin:
|
||||||
|
assigned_user = req.approver_id
|
||||||
|
assigned_group = req.approver_group
|
||||||
|
allowed = False
|
||||||
|
if assigned_user is not None:
|
||||||
|
allowed = assigned_user == approver_id
|
||||||
|
if not allowed and assigned_group:
|
||||||
|
allowed = await _user_in_approver_group(
|
||||||
|
db, tenant_id, approver_id, assigned_group
|
||||||
|
)
|
||||||
|
if not allowed and assigned_user is None and assigned_group is None:
|
||||||
|
# Unassigned request: anyone with approvals:approve may decide.
|
||||||
|
allowed = True
|
||||||
|
if not allowed:
|
||||||
|
raise ApprovalDecisionError(
|
||||||
|
"wrong_approver",
|
||||||
|
"This approval request is assigned to a different approver",
|
||||||
|
http_status=403,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Atomic status transition — a concurrent decision must not win twice.
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
upd = await db.execute(
|
||||||
|
update(ApprovalRequest)
|
||||||
|
.where(
|
||||||
|
ApprovalRequest.id == request_id,
|
||||||
|
ApprovalRequest.tenant_id == tenant_id,
|
||||||
|
ApprovalRequest.status == "pending",
|
||||||
|
)
|
||||||
|
.values(
|
||||||
|
status=decision,
|
||||||
|
resolved_by=approver_id,
|
||||||
|
comment=comment,
|
||||||
|
resolved_at=now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if upd.rowcount == 0:
|
||||||
|
raise ApprovalDecisionError(
|
||||||
|
"not_pending",
|
||||||
|
"Approval request was already decided",
|
||||||
|
http_status=409,
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.refresh(req)
|
||||||
return req
|
return req
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,55 @@ async def revoke_user_redis_sessions(user_id: str | uuid.UUID) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
async def revoke_user_sessions_all_stores(user_id: str | uuid.UUID) -> None:
|
||||||
|
"""F03 (Astra): revoke ALL sessions for a user in BOTH session stores.
|
||||||
|
|
||||||
|
Deactivation, deletion and password changes must take effect immediately —
|
||||||
|
including when Redis is down and requests fall back to the PostgreSQL
|
||||||
|
sessions table.
|
||||||
|
|
||||||
|
1. Redis runtime sessions are deleted (revoke_user_redis_sessions).
|
||||||
|
2. PostgreSQL session records are EXPIRED by setting ``expires_at = now()``
|
||||||
|
(not deleted — they stay as audit trail). The DB fallback path in
|
||||||
|
``get_session_data`` rejects sessions whose ``expires_at`` is past.
|
||||||
|
|
||||||
|
Never raises — best-effort per store, but errors are logged loudly.
|
||||||
|
"""
|
||||||
|
# 1. Redis runtime sessions
|
||||||
|
await revoke_user_redis_sessions(user_id)
|
||||||
|
|
||||||
|
# 2. PostgreSQL fallback sessions — expire instead of delete (audit trail)
|
||||||
|
try:
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import update
|
||||||
|
|
||||||
|
from app.core.db import get_session_factory
|
||||||
|
from app.models.session import Session as SessionModel
|
||||||
|
|
||||||
|
uid = user_id if isinstance(user_id, uuid.UUID) else uuid.UUID(str(user_id))
|
||||||
|
factory = get_session_factory()
|
||||||
|
async with factory() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
update(SessionModel)
|
||||||
|
.where(
|
||||||
|
SessionModel.user_id == uid,
|
||||||
|
SessionModel.expires_at > datetime.now(UTC),
|
||||||
|
)
|
||||||
|
.values(expires_at=datetime.now(UTC))
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
if result.rowcount:
|
||||||
|
logger.info(
|
||||||
|
"F03: expired %d PostgreSQL fallback sessions for user %s",
|
||||||
|
result.rowcount, uid,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"F03: failed to expire PostgreSQL sessions for user %s", user_id, exc_info=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def hash_token(token: str) -> str:
|
def hash_token(token: str) -> str:
|
||||||
"""SHA-256 hash a token for storage."""
|
"""SHA-256 hash a token for storage."""
|
||||||
return hashlib.sha256(token.encode()).hexdigest()
|
return hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
|||||||
@@ -74,6 +74,23 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
|||||||
if request.headers.get("upgrade", "").lower() == "websocket":
|
if request.headers.get("upgrade", "").lower() == "websocket":
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
|
# Bearer-token requests are CSRF-immune by design: the Authorization
|
||||||
|
# header is never attached automatically by browsers, so cross-site
|
||||||
|
# requests cannot forge it. Exempts programmatic API clients
|
||||||
|
# (external agent API, MCP, integrations) from Origin+CSRF checks —
|
||||||
|
# they authenticate via get_current_user_bearer instead.
|
||||||
|
auth_header = request.headers.get("authorization", "")
|
||||||
|
if auth_header.startswith("Bearer "):
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
# F09 (Astra P1): internal delegation calls carry a short-lived
|
||||||
|
# HMAC-signed X-Delegation-Token (created server-side by the
|
||||||
|
# CRM-API tool / MCP server, max 60 seconds) — CSRF-immune for the
|
||||||
|
# same reason as Bearer: browsers never attach this header to a
|
||||||
|
# cross-site request.
|
||||||
|
if request.headers.get("x-delegation-token"):
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
if request.method in self.UNSAFE_METHODS:
|
if request.method in self.UNSAFE_METHODS:
|
||||||
# 1. Origin header check
|
# 1. Origin header check
|
||||||
origin = request.headers.get("origin")
|
origin = request.headers.get("origin")
|
||||||
|
|||||||
+15
-1
@@ -212,7 +212,21 @@ def _json_payload(payload: dict[str, Any]) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _get_handler_name(handler: Any) -> str:
|
def _get_handler_name(handler: Any) -> str:
|
||||||
"""Extract a human-readable name from a handler callable."""
|
"""Extract a human-readable name from a handler callable.
|
||||||
|
|
||||||
|
For bound methods (plugin handlers are bound methods, e.g.
|
||||||
|
``AutomationPlugin.on_contact_created``) prefers ``__qualname__`` so
|
||||||
|
the consumer registry distinguishes handlers that share a method name
|
||||||
|
across plugins (automation/unified_search/system_notif all define
|
||||||
|
``on_contact_created`` — three distinct handlers, same short name).
|
||||||
|
|
||||||
|
Plain functions keep their ``__name__`` (nested test functions have
|
||||||
|
verbose qualnames like ``test_x.<locals>.handler``).
|
||||||
|
"""
|
||||||
|
if hasattr(handler, "__self__"):
|
||||||
|
qualname = getattr(handler, "__qualname__", None)
|
||||||
|
if qualname:
|
||||||
|
return qualname
|
||||||
name = getattr(handler, "__name__", None)
|
name = getattr(handler, "__name__", None)
|
||||||
if name:
|
if name:
|
||||||
return name
|
return name
|
||||||
|
|||||||
@@ -64,9 +64,25 @@ CORE_PERMISSIONS: list[dict[str, str]] = [
|
|||||||
{"key": "workspaces:delete", "label": "Workspaces: Delete", "category": "core", "module": "workspaces"},
|
{"key": "workspaces:delete", "label": "Workspaces: Delete", "category": "core", "module": "workspaces"},
|
||||||
{"key": "workspaces:assign_users", "label": "Workspaces: Assign Users", "category": "core", "module": "workspaces"},
|
{"key": "workspaces:assign_users", "label": "Workspaces: Assign Users", "category": "core", "module": "workspaces"},
|
||||||
{"key": "workspaces:configure_modules", "label": "Workspaces: Configure Modules", "category": "core", "module": "workspaces"},
|
{"key": "workspaces:configure_modules", "label": "Workspaces: Configure Modules", "category": "core", "module": "workspaces"},
|
||||||
|
{"key": "approvals:read", "label": "Approvals: Read", "category": "core", "module": "approvals"},
|
||||||
|
{"key": "approvals:write", "label": "Approvals: Write", "category": "core", "module": "approvals"},
|
||||||
|
{"key": "approvals:approve", "label": "Approvals: Approve/Reject", "category": "core", "module": "approvals"},
|
||||||
|
{"key": "dashboard:read", "label": "Dashboard: Read", "category": "core", "module": "dashboard"},
|
||||||
|
{"key": "dashboard:write", "label": "Dashboard: Write", "category": "core", "module": "dashboard"},
|
||||||
{"key": "system:admin", "label": "System: Admin (cross-tenant)", "category": "system", "module": "system"},
|
{"key": "system:admin", "label": "System: Admin (cross-tenant)", "category": "system", "module": "system"},
|
||||||
|
# Audit P1 (permission catalog): these keys were required by core routes
|
||||||
|
# but never registered, so non-admin roles could never be granted them.
|
||||||
|
{"key": "automation:admin", "label": "Automation: Admin (backups, self-improvement)", "category": "core", "module": "automation"},
|
||||||
|
{"key": "bank-accounts:read", "label": "Bank Accounts: Read", "category": "core", "module": "bank_accounts"},
|
||||||
|
{"key": "bank-accounts:write", "label": "Bank Accounts: Write", "category": "core", "module": "bank_accounts"},
|
||||||
|
{"key": "delegations:read", "label": "Delegations: Read", "category": "core", "module": "delegations"},
|
||||||
|
{"key": "delegations:write", "label": "Delegations: Write", "category": "core", "module": "delegations"},
|
||||||
|
{"key": "policies:read", "label": "Policies: Read", "category": "core", "module": "policies"},
|
||||||
|
{"key": "policies:write", "label": "Policies: Write", "category": "core", "module": "policies"},
|
||||||
|
{"key": "templates:read", "label": "Permission Templates: Read", "category": "core", "module": "templates"},
|
||||||
|
{"key": "templates:write", "label": "Permission Templates: Write", "category": "core", "module": "templates"},
|
||||||
# NOTE: Plugin permissions (calendar, dms, mail, tasks, comm, automation, ai,
|
# NOTE: Plugin permissions (calendar, dms, mail, tasks, comm, automation, ai,
|
||||||
# tags, entity_links, reports, search, mcp, permissions, agents, dashboard)
|
# tags, entity_links, reports, search, mcp, permissions, agents)
|
||||||
# are registered dynamically via register_plugin_permissions() from plugin
|
# are registered dynamically via register_plugin_permissions() from plugin
|
||||||
# manifests at activation time. They are NOT hardcoded here (P0-4 fix).
|
# manifests at activation time. They are NOT hardcoded here (P0-4 fix).
|
||||||
]
|
]
|
||||||
@@ -74,50 +90,12 @@ CORE_PERMISSIONS: list[dict[str, str]] = [
|
|||||||
|
|
||||||
# ── Core field definitions for field-level permissions ──
|
# ── Core field definitions for field-level permissions ──
|
||||||
CORE_FIELD_DEFINITIONS: list[dict[str, str]] = [
|
CORE_FIELD_DEFINITIONS: list[dict[str, str]] = [
|
||||||
# ── Contact fields ──
|
# Audit P1/P2 (contact field definitions): all contacts:* field
|
||||||
{"module": "contacts", "field": "firstname", "label": "First Name", "sensitivity": "normal"},
|
# definitions moved to the ContactsPlugin manifest (field_definitions=)
|
||||||
{"module": "contacts", "field": "surname", "label": "Last Name", "sensitivity": "normal"},
|
# so the plugin fully owns its field structure. The core keeps only
|
||||||
{"module": "contacts", "field": "displayname", "label": "Display Name", "sensitivity": "normal"},
|
# genuinely core-owned fields (users). Plugin field definitions are
|
||||||
{"module": "contacts", "field": "name", "label": "Name", "sensitivity": "normal"},
|
# registered at activation time via register_field_definitions().
|
||||||
{"module": "contacts", "field": "email_1", "label": "Email 1", "sensitivity": "normal"},
|
# ── User fields (core-owned) ──
|
||||||
{"module": "contacts", "field": "email_2", "label": "Email 2", "sensitivity": "normal"},
|
|
||||||
{"module": "contacts", "field": "phone_1", "label": "Phone 1", "sensitivity": "normal"},
|
|
||||||
{"module": "contacts", "field": "phone_2", "label": "Phone 2", "sensitivity": "normal"},
|
|
||||||
{"module": "contacts", "field": "mobilephone", "label": "Mobile", "sensitivity": "sensitive"},
|
|
||||||
{"module": "contacts", "field": "function", "label": "Position", "sensitivity": "normal"},
|
|
||||||
{"module": "contacts", "field": "website", "label": "Website", "sensitivity": "normal"},
|
|
||||||
{"module": "contacts", "field": "status", "label": "Status", "sensitivity": "normal"},
|
|
||||||
{"module": "contacts", "field": "type", "label": "Type", "sensitivity": "normal"},
|
|
||||||
{"module": "contacts", "field": "gender", "label": "Gender", "sensitivity": "normal"},
|
|
||||||
{"module": "contacts", "field": "suffix", "label": "Suffix", "sensitivity": "normal"},
|
|
||||||
{"module": "contacts", "field": "ext_name_line", "label": "Extra Name Line", "sensitivity": "normal"},
|
|
||||||
{"module": "contacts", "field": "country", "label": "Country", "sensitivity": "normal"},
|
|
||||||
# ── Financial / sensitive fields ──
|
|
||||||
{"module": "contacts", "field": "code", "label": "Code", "sensitivity": "sensitive"},
|
|
||||||
{"module": "contacts", "field": "accounting_code", "label": "Accounting Code", "sensitivity": "sensitive"},
|
|
||||||
{"module": "contacts", "field": "vendor_accounting_code", "label": "Vendor Accounting Code", "sensitivity": "sensitive"},
|
|
||||||
{"module": "contacts", "field": "vat_code", "label": "VAT Code", "sensitivity": "sensitive"},
|
|
||||||
{"module": "contacts", "field": "fiscal_code", "label": "Fiscal Code", "sensitivity": "sensitive"},
|
|
||||||
{"module": "contacts", "field": "commerce_code", "label": "Commerce Code", "sensitivity": "sensitive"},
|
|
||||||
{"module": "contacts", "field": "purchase_number", "label": "Purchase Number", "sensitivity": "sensitive"},
|
|
||||||
{"module": "contacts", "field": "bic", "label": "BIC", "sensitivity": "sensitive"},
|
|
||||||
# ── Addresses ──
|
|
||||||
{"module": "contacts", "field": "mailing_street", "label": "Mailing Street", "sensitivity": "normal"},
|
|
||||||
{"module": "contacts", "field": "mailing_city", "label": "Mailing City", "sensitivity": "normal"},
|
|
||||||
{"module": "contacts", "field": "mailing_postalcode", "label": "Mailing Postal Code", "sensitivity": "normal"},
|
|
||||||
{"module": "contacts", "field": "mailing_country", "label": "Mailing Country", "sensitivity": "normal"},
|
|
||||||
{"module": "contacts", "field": "visit_street", "label": "Visit Street", "sensitivity": "normal"},
|
|
||||||
{"module": "contacts", "field": "visit_city", "label": "Visit City", "sensitivity": "normal"},
|
|
||||||
{"module": "contacts", "field": "visit_postalcode", "label": "Visit Postal Code", "sensitivity": "normal"},
|
|
||||||
{"module": "contacts", "field": "visit_country", "label": "Visit Country", "sensitivity": "normal"},
|
|
||||||
{"module": "contacts", "field": "invoice_street", "label": "Invoice Street", "sensitivity": "normal"},
|
|
||||||
{"module": "contacts", "field": "invoice_city", "label": "Invoice City", "sensitivity": "normal"},
|
|
||||||
{"module": "contacts", "field": "invoice_postalcode", "label": "Invoice Postal Code", "sensitivity": "normal"},
|
|
||||||
{"module": "contacts", "field": "invoice_country", "label": "Invoice Country", "sensitivity": "normal"},
|
|
||||||
# ── Notes & Tags ──
|
|
||||||
{"module": "contacts", "field": "notes", "label": "Notes", "sensitivity": "sensitive"},
|
|
||||||
{"module": "contacts", "field": "tags", "label": "Tags", "sensitivity": "sensitive"},
|
|
||||||
# ── User fields ──
|
|
||||||
{"module": "users", "field": "email", "label": "Email", "sensitivity": "normal"},
|
{"module": "users", "field": "email", "label": "Email", "sensitivity": "normal"},
|
||||||
{"module": "users", "field": "name", "label": "Name", "sensitivity": "normal"},
|
{"module": "users", "field": "name", "label": "Name", "sensitivity": "normal"},
|
||||||
{"module": "users", "field": "role", "label": "Role", "sensitivity": "normal"},
|
{"module": "users", "field": "role", "label": "Role", "sensitivity": "normal"},
|
||||||
@@ -224,6 +202,18 @@ class PermissionRegistry:
|
|||||||
self._field_definitions[plugin_name] = field_defs
|
self._field_definitions[plugin_name] = field_defs
|
||||||
logger.info("Registered %d field definitions for plugin '%s'", len(field_defs), plugin_name)
|
logger.info("Registered %d field definitions for plugin '%s'", len(field_defs), plugin_name)
|
||||||
|
|
||||||
|
def unregister_field_definitions(self, plugin_name: str) -> None:
|
||||||
|
"""Remove field definitions of a deactivated/uninstalled plugin.
|
||||||
|
|
||||||
|
Audit P1/P2 (field-definitions lifecycle): the contribution type was
|
||||||
|
only half-integrated — register_field_definitions() existed but no
|
||||||
|
matching unregister, so a deactivated plugin kept serving its field
|
||||||
|
definitions in the permission UI.
|
||||||
|
"""
|
||||||
|
removed = self._field_definitions.pop(plugin_name, None)
|
||||||
|
if removed is not None:
|
||||||
|
logger.info("Unregistered %d field definitions for plugin '%s'", len(removed), plugin_name)
|
||||||
|
|
||||||
def get_all_field_definitions(self) -> list[dict[str, str]]:
|
def get_all_field_definitions(self) -> list[dict[str, str]]:
|
||||||
"""Return all registered field definitions."""
|
"""Return all registered field definitions."""
|
||||||
result = list(self._core_field_definitions)
|
result = list(self._core_field_definitions)
|
||||||
|
|||||||
@@ -431,7 +431,12 @@ def check_permission(resolved: dict[str, Any], required: str) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
permissions = set(resolved.get("permissions", []))
|
permissions = set(resolved.get("permissions", []))
|
||||||
denied = set(resolved.get("denied", []))
|
# F01/Astra: session user contexts carry ``denied_permissions`` while
|
||||||
|
# resolved permission dicts use ``denied`` — accept both so the deny
|
||||||
|
# list is never silently ignored.
|
||||||
|
denied = set(
|
||||||
|
resolved.get("denied", resolved.get("denied_permissions", [])) or []
|
||||||
|
)
|
||||||
|
|
||||||
# Check deny list first
|
# Check deny list first
|
||||||
for d in denied:
|
for d in denied:
|
||||||
|
|||||||
@@ -173,12 +173,15 @@ def _derive_policy_from_sensitivity(
|
|||||||
if field_name in entity_policy:
|
if field_name in entity_policy:
|
||||||
return dict(entity_policy[field_name])
|
return dict(entity_policy[field_name])
|
||||||
|
|
||||||
# Try to get sensitivity from permission registry (lazy import to avoid
|
# Try to get sensitivity from the permission registry (lazy import to
|
||||||
# circular dependencies at module load time).
|
# avoid circular dependencies at module load time). Use the registry's
|
||||||
|
# combined view (core + plugin field definitions) — contact fields moved
|
||||||
|
# to the ContactsPlugin manifest (audit P1/P2), so CORE_FIELD_DEFINITIONS
|
||||||
|
# alone no longer covers them.
|
||||||
try:
|
try:
|
||||||
from app.core.permission_registry import CORE_FIELD_DEFINITIONS
|
from app.core.permission_registry import get_permission_registry
|
||||||
|
|
||||||
for fd in CORE_FIELD_DEFINITIONS:
|
for fd in get_permission_registry().get_all_field_definitions():
|
||||||
if fd.get("module") == entity_type and fd.get("field") == field_name:
|
if fd.get("module") == entity_type and fd.get("field") == field_name:
|
||||||
sensitivity = fd.get("sensitivity", "normal")
|
sensitivity = fd.get("sensitivity", "normal")
|
||||||
return dict(_SENSITIVITY_DEFAULTS.get(sensitivity, _ALL_ALLOWED))
|
return dict(_SENSITIVITY_DEFAULTS.get(sensitivity, _ALL_ALLOWED))
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Core-owned system MiniApps (Phase M4).
|
||||||
|
|
||||||
|
Host-level MiniApps that are not owned by a single plugin: audit activity
|
||||||
|
feed and system metrics. They register in the universal registry with
|
||||||
|
``plugin_name="system"`` at app startup and unregister with the registry
|
||||||
|
reset (tests) — they never depend on plugin activation state.
|
||||||
|
|
||||||
|
Permissions follow the owning data source:
|
||||||
|
- audit_activity -> audit:read (audit log route guard, CORE_PERMISSIONS)
|
||||||
|
- system_metrics -> settings:read (Roadmap M4; the /system/dashboard
|
||||||
|
endpoint itself stays require_admin — the widget degrades gracefully
|
||||||
|
with a permission hint for non-admins)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.plugins.miniapp_registry import get_miniapp_registry
|
||||||
|
|
||||||
|
SYSTEM_PLUGIN_NAME = "system"
|
||||||
|
|
||||||
|
|
||||||
|
def register_system_miniapps() -> None:
|
||||||
|
"""Register the core system MiniApps in the universal registry."""
|
||||||
|
registry = get_miniapp_registry()
|
||||||
|
|
||||||
|
registry.register(
|
||||||
|
app_id="audit_activity",
|
||||||
|
name="Aktivitäten",
|
||||||
|
icon="History",
|
||||||
|
description="Letzte Aktivitäten aus dem Audit-Log (Benutzer, Aktion, Zeitpunkt).",
|
||||||
|
plugin_name=SYSTEM_PLUGIN_NAME,
|
||||||
|
permission="audit:read",
|
||||||
|
settings_schema={
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"name": "max_items",
|
||||||
|
"label": "Max. Einträge",
|
||||||
|
"type": "number",
|
||||||
|
"default": 10,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
col_span=2,
|
||||||
|
row_span=1,
|
||||||
|
hosts=["chat", "dashboard", "window"],
|
||||||
|
component="@/components/dashboard/AuditActivityWidget",
|
||||||
|
order=40,
|
||||||
|
)
|
||||||
|
|
||||||
|
registry.register(
|
||||||
|
app_id="system_metrics",
|
||||||
|
name="System Status",
|
||||||
|
icon="Server",
|
||||||
|
description="Datenbank-, Redis-, Worker- und API-Metriken (Administration).",
|
||||||
|
plugin_name=SYSTEM_PLUGIN_NAME,
|
||||||
|
permission="settings:read",
|
||||||
|
settings_schema={},
|
||||||
|
col_span=2,
|
||||||
|
row_span=1,
|
||||||
|
hosts=["chat", "dashboard", "window"],
|
||||||
|
component="@/components/dashboard/SystemMetricsWidget",
|
||||||
|
order=50,
|
||||||
|
)
|
||||||
@@ -7,7 +7,8 @@ import logging
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import cast, select
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
|
||||||
from app.core.db import get_session_factory
|
from app.core.db import get_session_factory
|
||||||
from app.core.event_bus import EventBus, get_event_bus
|
from app.core.event_bus import EventBus, get_event_bus
|
||||||
@@ -48,7 +49,12 @@ async def _dispatch_event(payload: dict[str, Any]) -> None:
|
|||||||
stmt = select(Webhook).where(
|
stmt = select(Webhook).where(
|
||||||
Webhook.tenant_id == tenant_id,
|
Webhook.tenant_id == tenant_id,
|
||||||
Webhook.is_active == True, # noqa: E712
|
Webhook.is_active == True, # noqa: E712
|
||||||
Webhook.events.any(event_name),
|
# Webhook.events is a JSONB array column (NOT a relationship):
|
||||||
|
# events @> '["<event_name>"]' — JSONB containment instead of
|
||||||
|
# the invalid relationship .any() call that crashed every event
|
||||||
|
# with "Neither 'AnnotatedColumn' nor 'Comparator' object has an
|
||||||
|
# attribute 'any'" (158 failed outbox events in production).
|
||||||
|
cast(Webhook.events, JSONB).contains([event_name]),
|
||||||
)
|
)
|
||||||
result = await db.execute(stmt)
|
result = await db.execute(stmt)
|
||||||
webhooks = list(result.scalars().all())
|
webhooks = list(result.scalars().all())
|
||||||
|
|||||||
+8
-10
@@ -396,7 +396,6 @@ async def cleanup_trash_job(ctx: dict[str, Any]) -> None:
|
|||||||
from sqlalchemy import text as sa_text
|
from sqlalchemy import text as sa_text
|
||||||
|
|
||||||
from app.core.db import get_worker_session_factory
|
from app.core.db import get_worker_session_factory
|
||||||
from app.models.contact import Contact
|
|
||||||
from app.models.entity_attachment import EntityAttachment
|
from app.models.entity_attachment import EntityAttachment
|
||||||
|
|
||||||
factory = get_worker_session_factory()
|
factory = get_worker_session_factory()
|
||||||
@@ -414,16 +413,9 @@ async def cleanup_trash_job(ctx: dict[str, Any]) -> None:
|
|||||||
{"tid": str(tenant_id)},
|
{"tid": str(tenant_id)},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Delete soft-deleted contacts
|
|
||||||
result = await db.execute(
|
|
||||||
sa_delete(Contact).where(
|
|
||||||
Contact.deleted_at.is_not(None),
|
|
||||||
Contact.deleted_at < cutoff,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
total_deleted += result.rowcount
|
|
||||||
|
|
||||||
# Delete soft-deleted entity attachments
|
# Delete soft-deleted entity attachments
|
||||||
|
# (Contacts trash cleanup moved to the contacts plugin:
|
||||||
|
# cleanup_contacts_trash — audit P2, no core->contacts import)
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
sa_delete(EntityAttachment).where(
|
sa_delete(EntityAttachment).where(
|
||||||
EntityAttachment.deleted_at.is_not(None),
|
EntityAttachment.deleted_at.is_not(None),
|
||||||
@@ -488,6 +480,12 @@ class WorkerSettings:
|
|||||||
_wrap_cron_with_lock("cleanup_trash", cleanup_trash_job, ttl_seconds=300),
|
_wrap_cron_with_lock("cleanup_trash", cleanup_trash_job, ttl_seconds=300),
|
||||||
hour=4, minute=0,
|
hour=4, minute=0,
|
||||||
),
|
),
|
||||||
|
# Contacts trash cleanup — daily at 04:15, owned by the contacts
|
||||||
|
# plugin (audit P2: no core->contacts import in the worker).
|
||||||
|
cron(
|
||||||
|
_wrap_cron_with_lock("cleanup_contacts_trash", get_job("cleanup_contacts_trash"), ttl_seconds=300),
|
||||||
|
hour=4, minute=15,
|
||||||
|
),
|
||||||
# Knowledge retention cleanup — daily at 05:00 (90 days, keeps approved).
|
# Knowledge retention cleanup — daily at 05:00 (90 days, keeps approved).
|
||||||
# Function comes from the knowledge plugin via the job registry.
|
# Function comes from the knowledge plugin via the job registry.
|
||||||
cron(
|
cron(
|
||||||
|
|||||||
+167
-16
@@ -7,7 +7,7 @@ import uuid
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import redis.asyncio as aioredis
|
import redis.asyncio as aioredis
|
||||||
from fastapi import Depends, HTTPException, Request, status
|
from fastapi import Depends, Header, HTTPException, Request, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -74,7 +74,62 @@ async def get_current_user(
|
|||||||
|
|
||||||
Returns session data dict with user_id, tenant_id, email, name, role,
|
Returns session data dict with user_id, tenant_id, email, name, role,
|
||||||
and resolved permissions from Redis cache.
|
and resolved permissions from Redis cache.
|
||||||
|
|
||||||
|
F09 (Astra P1): also accepts a short-lived HMAC-signed delegation
|
||||||
|
token (X-Delegation-Token header) for INTERNAL calls made on behalf
|
||||||
|
of a user — e.g. the generic CRM-API tool used by AI agents and the
|
||||||
|
MCP server. Previously those tools sent unauthenticated
|
||||||
|
X-Internal-Call headers that the protected API never accepted.
|
||||||
"""
|
"""
|
||||||
|
# F09: internal delegation path — HMAC-signed, max 60 seconds
|
||||||
|
delegation_header = request.headers.get("X-Delegation-Token", "")
|
||||||
|
if delegation_header:
|
||||||
|
from app.core.delegation_token import verify_delegation_token
|
||||||
|
|
||||||
|
payload = verify_delegation_token(delegation_header)
|
||||||
|
if payload is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail={"detail": "Invalid or expired delegation token", "code": "delegation_invalid"},
|
||||||
|
)
|
||||||
|
deleg_user_id = str(payload.get("user_id", ""))
|
||||||
|
deleg_tenant_id = str(payload.get("tenant_id", ""))
|
||||||
|
if not deleg_user_id or not deleg_tenant_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail={"detail": "Delegation token missing user/tenant", "code": "delegation_invalid"},
|
||||||
|
)
|
||||||
|
tenant_id = uuid.UUID(deleg_tenant_id)
|
||||||
|
user_id = uuid.UUID(deleg_user_id)
|
||||||
|
await set_tenant_context(db, tenant_id)
|
||||||
|
from app.core.permissions import get_cached_permissions
|
||||||
|
|
||||||
|
resolved = await get_cached_permissions(db, redis, user_id, tenant_id)
|
||||||
|
from sqlalchemy import select as _select
|
||||||
|
|
||||||
|
from app.models.group import UserGroup
|
||||||
|
|
||||||
|
groups_q = await db.execute(
|
||||||
|
_select(UserGroup.group_id)
|
||||||
|
.where(UserGroup.user_id == user_id)
|
||||||
|
.where(UserGroup.tenant_id == tenant_id)
|
||||||
|
)
|
||||||
|
group_ids = [row[0] for row in groups_q]
|
||||||
|
is_admin = bool(resolved.get("is_system_admin", False))
|
||||||
|
await set_user_context(db, user_id, group_ids, is_admin)
|
||||||
|
return {
|
||||||
|
"user_id": deleg_user_id,
|
||||||
|
"tenant_id": deleg_tenant_id,
|
||||||
|
"email": "", # not needed for permission decisions
|
||||||
|
"name": "delegated",
|
||||||
|
"role": "",
|
||||||
|
"permissions": resolved.get("permissions", []),
|
||||||
|
"denied_permissions": resolved.get("denied", []),
|
||||||
|
"field_permissions": resolved.get("field_permissions", {}),
|
||||||
|
"is_system_admin": is_admin,
|
||||||
|
"delegated_by": payload.get("agent_id", ""),
|
||||||
|
}
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
session_id = request.cookies.get(settings.session_cookie_name)
|
session_id = request.cookies.get(settings.session_cookie_name)
|
||||||
@@ -130,10 +185,20 @@ async def get_current_user(
|
|||||||
membership_row = membership_q.first()
|
membership_row = membership_q.first()
|
||||||
membership_status = membership_row[0] if membership_row else None
|
membership_status = membership_row[0] if membership_row else None
|
||||||
role_id = membership_row[1] if membership_row else None
|
role_id = membership_row[1] if membership_row else None
|
||||||
if membership_status is not None and membership_status != "active":
|
# F03 (Astra): a MISSING tenant membership must be rejected, not waved
|
||||||
|
# through. Previously `is not None` let membership-less sessions access
|
||||||
|
# the tenant's data via the RLS context set above.
|
||||||
|
if membership_status is None or membership_status != "active":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail={"detail": f"Mitgliedschaft ist {membership_status}, Zugriff verweigert", "code": "membership_suspended"},
|
detail={
|
||||||
|
"detail": (
|
||||||
|
f"Mitgliedschaft ist {membership_status}, Zugriff verweigert"
|
||||||
|
if membership_status
|
||||||
|
else "Keine aktive Mandanten-Mitgliedschaft, Zugriff verweigert"
|
||||||
|
),
|
||||||
|
"code": "membership_suspended",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Cache user principals for this request — avoids N+1 queries in visibility.py
|
# Cache user principals for this request — avoids N+1 queries in visibility.py
|
||||||
@@ -247,6 +312,54 @@ async def get_current_user_or_bearer(
|
|||||||
return await get_current_user(request, db, redis)
|
return await get_current_user(request, db, redis)
|
||||||
|
|
||||||
|
|
||||||
|
def require_permission_or_bearer(permission: str):
|
||||||
|
"""F08 (Astra P1): permission dependency for routes that serve BOTH
|
||||||
|
session-cookie clients (SPA) and pure Bearer API clients.
|
||||||
|
|
||||||
|
``require_permission`` resolves via ``get_current_user`` (session
|
||||||
|
cookie only) — a Bearer client fails with 401 before the route's own
|
||||||
|
Bearer verification is ever reached. This dependency accepts either
|
||||||
|
auth path and enforces the SAME effective permission:
|
||||||
|
|
||||||
|
- session users: normal permission check
|
||||||
|
- Bearer tokens: token scopes are an UPPER BOUND (F10) — the user's
|
||||||
|
own permissions must grant the permission AND the scope must match
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def _check(
|
||||||
|
current_user: dict[str, Any] = Depends(get_current_user_or_bearer),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
token_scopes = current_user.get("_token_scopes")
|
||||||
|
if token_scopes is not None:
|
||||||
|
from app.core.permissions import _permission_matches_any
|
||||||
|
|
||||||
|
if not _permission_matches_any(set(token_scopes), permission):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail={
|
||||||
|
"detail": f"Token scope '{permission}' required",
|
||||||
|
"code": "insufficient_scope",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# fall through — user permissions apply too (F10 semantics)
|
||||||
|
|
||||||
|
if current_user.get("is_system_admin"):
|
||||||
|
return current_user
|
||||||
|
from app.core.permissions import check_permission
|
||||||
|
|
||||||
|
if check_permission(current_user, permission):
|
||||||
|
return current_user
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail={
|
||||||
|
"detail": f"Permission '{permission}' required",
|
||||||
|
"code": "forbidden",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return _check
|
||||||
|
|
||||||
|
|
||||||
async def require_admin(
|
async def require_admin(
|
||||||
current_user: dict[str, Any] = Depends(get_current_user),
|
current_user: dict[str, Any] = Depends(get_current_user),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
@@ -311,6 +424,10 @@ def require_permission(permission: str):
|
|||||||
current_user: dict[str, Any] = Depends(get_current_user),
|
current_user: dict[str, Any] = Depends(get_current_user),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
# API token scope enforcement (Problem 2 fix)
|
# API token scope enforcement (Problem 2 fix)
|
||||||
|
# F10 (Astra): token scopes are an UPPER BOUND, not a replacement —
|
||||||
|
# the user's own permissions must ALSO grant the permission. A token
|
||||||
|
# can never grant more than its owner has; revoking the user's
|
||||||
|
# permission takes effect on existing tokens.
|
||||||
token_scopes = current_user.get("_token_scopes")
|
token_scopes = current_user.get("_token_scopes")
|
||||||
if token_scopes is not None:
|
if token_scopes is not None:
|
||||||
from app.core.permissions import _permission_matches_any
|
from app.core.permissions import _permission_matches_any
|
||||||
@@ -322,7 +439,7 @@ def require_permission(permission: str):
|
|||||||
"code": "insufficient_scope",
|
"code": "insufficient_scope",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return current_user
|
# fall through: the normal user-permission check applies too
|
||||||
|
|
||||||
if current_user.get("is_system_admin"):
|
if current_user.get("is_system_admin"):
|
||||||
return current_user
|
return current_user
|
||||||
@@ -382,6 +499,30 @@ async def get_current_user_id(
|
|||||||
return uuid.UUID(current_user["user_id"])
|
return uuid.UUID(current_user["user_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def require_workspace_scope(module_key: str):
|
||||||
|
"""FastAPI dependency factory (Phase N3): resolve the active workspace
|
||||||
|
scope config for a module from the X-Workspace-ID header.
|
||||||
|
|
||||||
|
Returns the scope dict (e.g. ``{"folder_ids": [...]}``) or ``None``
|
||||||
|
when no restriction applies (no header, admin, unassigned, empty config).
|
||||||
|
Callers apply it as a pure AND-restriction — never a grant.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
scope: dict | None = Depends(require_workspace_scope("contacts"))
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def _resolve(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict[str, Any] = Depends(get_current_user),
|
||||||
|
x_workspace_id: str | None = Header(None, alias="X-Workspace-ID"),
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
from app.services.workspace_scope_service import resolve_workspace_scope
|
||||||
|
|
||||||
|
return await resolve_workspace_scope(db, current_user, x_workspace_id, module_key)
|
||||||
|
|
||||||
|
return _resolve
|
||||||
|
|
||||||
|
|
||||||
def require_active_plugin(plugin_name: str):
|
def require_active_plugin(plugin_name: str):
|
||||||
"""FastAPI dependency factory: require that a plugin is active.
|
"""FastAPI dependency factory: require that a plugin is active.
|
||||||
|
|
||||||
@@ -400,7 +541,16 @@ def require_active_plugin(plugin_name: str):
|
|||||||
"""
|
"""
|
||||||
async def _check(
|
async def _check(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict[str, Any] = Depends(get_current_user_or_bearer),
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""F05 (Astra P1): the plugin gate runs AFTER authentication.
|
||||||
|
|
||||||
|
Depending on ``get_current_user_or_bearer`` guarantees FastAPI
|
||||||
|
resolves the authenticated user context BEFORE this check — the
|
||||||
|
previous version read the tenant from the DB session before auth
|
||||||
|
had run (context missing → silent allow). Both auth paths (cookie
|
||||||
|
and Bearer) set the tenant context on this same ``db`` session.
|
||||||
|
"""
|
||||||
from app.core.permission_registry import get_permission_registry
|
from app.core.permission_registry import get_permission_registry
|
||||||
try:
|
try:
|
||||||
registry = get_permission_registry()
|
registry = get_permission_registry()
|
||||||
@@ -412,19 +562,20 @@ def require_active_plugin(plugin_name: str):
|
|||||||
"code": "plugin_inactive",
|
"code": "plugin_inactive",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
# Get tenant_id from existing db session (NOT a new session)
|
# Tenant comes from the AUTHENTICATED user context — never from
|
||||||
# The tenant context is set by middleware/get_current_user on this same session
|
# the DB session (which may not have the context set yet).
|
||||||
from sqlalchemy import text as sa_text
|
raw_tid = current_user.get("tenant_id")
|
||||||
|
if not raw_tid:
|
||||||
result = await db.execute(
|
# Fail-closed: no authenticated tenant context → reject.
|
||||||
sa_text("SELECT NULLIF(current_setting('app.current_tenant_id', true), '')::uuid")
|
# (Previously this returned silently = plugin active.)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail={
|
||||||
|
"detail": "Plugin gate requires an authenticated tenant context",
|
||||||
|
"code": "plugin_gate_no_tenant",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
tenant_id = result.scalar()
|
tenant_id = uuid.UUID(str(raw_tid))
|
||||||
|
|
||||||
if tenant_id is None:
|
|
||||||
# No tenant context — plugin is active by default (backward compatible)
|
|
||||||
# TODO: Fix in production to deny access when no tenant context
|
|
||||||
return
|
|
||||||
|
|
||||||
# Per-tenant activation check with Redis cache
|
# Per-tenant activation check with Redis cache
|
||||||
import json
|
import json
|
||||||
|
|||||||
+31
-3
@@ -45,8 +45,8 @@ from app.routes import ( # noqa: E402
|
|||||||
compliance,
|
compliance,
|
||||||
currencies,
|
currencies,
|
||||||
custom_field_definitions,
|
custom_field_definitions,
|
||||||
custom_fields,
|
|
||||||
dashboard,
|
dashboard,
|
||||||
|
dashboards,
|
||||||
delegations,
|
delegations,
|
||||||
entity_history,
|
entity_history,
|
||||||
entity_permissions,
|
entity_permissions,
|
||||||
@@ -56,6 +56,7 @@ from app.routes import ( # noqa: E402
|
|||||||
health,
|
health,
|
||||||
import_export,
|
import_export,
|
||||||
metrics,
|
metrics,
|
||||||
|
miniapps,
|
||||||
notifications,
|
notifications,
|
||||||
outbox,
|
outbox,
|
||||||
owner_transfer,
|
owner_transfer,
|
||||||
@@ -217,6 +218,16 @@ async def lifespan(app: FastAPI):
|
|||||||
registry.initialize(get_migration_engine(), app)
|
registry.initialize(get_migration_engine(), app)
|
||||||
registry.discover_builtins()
|
registry.discover_builtins()
|
||||||
|
|
||||||
|
# Core system MiniApps (Phase M4): host-level, independent of plugin state
|
||||||
|
from app.core.system_miniapps import register_system_miniapps
|
||||||
|
|
||||||
|
register_system_miniapps()
|
||||||
|
|
||||||
|
# Core AI agent tools for MiniApp output (Phase M6)
|
||||||
|
from app.ai.miniapp_tools import register_miniapp_tools
|
||||||
|
|
||||||
|
register_miniapp_tools()
|
||||||
|
|
||||||
# Install discovered builtin plugins and activate only those marked active in DB
|
# Install discovered builtin plugins and activate only those marked active in DB
|
||||||
from sqlalchemy import select as sa_select
|
from sqlalchemy import select as sa_select
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||||
@@ -326,6 +337,22 @@ async def lifespan(app: FastAPI):
|
|||||||
if plugin and plugin.manifest.permissions:
|
if plugin and plugin.manifest.permissions:
|
||||||
register_plugin_permissions(record.name, plugin.manifest.permissions)
|
register_plugin_permissions(record.name, plugin.manifest.permissions)
|
||||||
|
|
||||||
|
# Audit P1 (contract lazy loading, restart edge case): plugins that
|
||||||
|
# were already inactive in the DB when this process started never get
|
||||||
|
# a runtime deactivate() call, so the ContractRegistry would
|
||||||
|
# lazy-load their contracts module and resurrect the contract.
|
||||||
|
# Mark them once here so get_contract() fails closed for them.
|
||||||
|
inactive_result = await db.execute(
|
||||||
|
sa_select(PluginModel.name).where(PluginModel.active == False) # noqa: E712
|
||||||
|
)
|
||||||
|
inactive_names = {row[0] for row in inactive_result}
|
||||||
|
if inactive_names:
|
||||||
|
from app.plugins.builtins.contracts import get_contract_registry
|
||||||
|
get_contract_registry().mark_db_inactive(inactive_names)
|
||||||
|
logger.info(
|
||||||
|
"Contract registry: %d plugins marked DB-inactive", len(inactive_names)
|
||||||
|
)
|
||||||
|
|
||||||
init_permission_registry(active_plugin_names)
|
init_permission_registry(active_plugin_names)
|
||||||
logger.info("Permission registry initialized with %d active plugins", len(active_plugin_names))
|
logger.info("Permission registry initialized with %d active plugins", len(active_plugin_names))
|
||||||
|
|
||||||
@@ -348,7 +375,7 @@ async def lifespan(app: FastAPI):
|
|||||||
plugin = registry.get_plugin(name)
|
plugin = registry.get_plugin(name)
|
||||||
if plugin:
|
if plugin:
|
||||||
for entity_type, model_class in plugin.get_entity_models().items():
|
for entity_type, model_class in plugin.get_entity_models().items():
|
||||||
register_entity_model(entity_type, model_class)
|
register_entity_model(entity_type, model_class, plugin_name=name)
|
||||||
logger.info("Entity models registered for %d active plugins", len(active_plugin_names))
|
logger.info("Entity models registered for %d active plugins", len(active_plugin_names))
|
||||||
|
|
||||||
# Register field definitions from active plugins only
|
# Register field definitions from active plugins only
|
||||||
@@ -566,6 +593,7 @@ def create_app() -> FastAPI:
|
|||||||
# require_active_plugin("contacts") protection.
|
# require_active_plugin("contacts") protection.
|
||||||
app.include_router(entity_permissions.router)
|
app.include_router(entity_permissions.router)
|
||||||
app.include_router(dashboard.router)
|
app.include_router(dashboard.router)
|
||||||
|
app.include_router(dashboards.router)
|
||||||
app.include_router(entity_history.router)
|
app.include_router(entity_history.router)
|
||||||
app.include_router(import_export.router)
|
app.include_router(import_export.router)
|
||||||
app.include_router(plugins.router)
|
app.include_router(plugins.router)
|
||||||
@@ -584,7 +612,6 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(compliance.router)
|
app.include_router(compliance.router)
|
||||||
app.include_router(owner_transfer.router)
|
app.include_router(owner_transfer.router)
|
||||||
app.include_router(custom_field_definitions.router)
|
app.include_router(custom_field_definitions.router)
|
||||||
app.include_router(custom_fields.router)
|
|
||||||
app.include_router(saved_filters.router)
|
app.include_router(saved_filters.router)
|
||||||
app.include_router(saved_views.router)
|
app.include_router(saved_views.router)
|
||||||
app.include_router(webhooks.router)
|
app.include_router(webhooks.router)
|
||||||
@@ -597,6 +624,7 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(outbox.router)
|
app.include_router(outbox.router)
|
||||||
app.include_router(api_tokens.router)
|
app.include_router(api_tokens.router)
|
||||||
app.include_router(approvals.router)
|
app.include_router(approvals.router)
|
||||||
|
app.include_router(miniapps.router)
|
||||||
|
|
||||||
# ── Register plugin routes for all discovered plugins ──
|
# ── Register plugin routes for all discovered plugins ──
|
||||||
# Routes are registered at app creation time so OpenAPI docs are complete.
|
# Routes are registered at app creation time so OpenAPI docs are complete.
|
||||||
|
|||||||
+22
-1
@@ -8,16 +8,22 @@ from app.models.backup import Backup
|
|||||||
from app.models.bank_account import BankAccount
|
from app.models.bank_account import BankAccount
|
||||||
from app.models.compliance import ComplianceIncident
|
from app.models.compliance import ComplianceIncident
|
||||||
from app.models.consumer_inbox import ConsumerInbox
|
from app.models.consumer_inbox import ConsumerInbox
|
||||||
from app.models.contact import Contact, ContactPerson
|
|
||||||
|
# Contact/ContactPerson: lazy via package __getattr__ (Paket 6) — the physical
|
||||||
|
# model lives in app.plugins.builtins.contacts.models; importing the plugin
|
||||||
|
# framework while app.models is still initializing caused a proven circular
|
||||||
|
# ImportError (app.core.auth -> app.models.session -> ... -> app.plugins).
|
||||||
from app.models.contact_folder import ContactFolder
|
from app.models.contact_folder import ContactFolder
|
||||||
from app.models.contact_merge import ContactMergeHistory
|
from app.models.contact_merge import ContactMergeHistory
|
||||||
from app.models.currency import Currency
|
from app.models.currency import Currency
|
||||||
from app.models.custom_field_definition import CustomFieldDefinition
|
from app.models.custom_field_definition import CustomFieldDefinition
|
||||||
|
from app.models.dashboard import Dashboard
|
||||||
from app.models.entity_history import EntityHistory
|
from app.models.entity_history import EntityHistory
|
||||||
from app.models.entity_permission import EntityPermission
|
from app.models.entity_permission import EntityPermission
|
||||||
from app.models.entity_policy import EntityPolicy
|
from app.models.entity_policy import EntityPolicy
|
||||||
from app.models.group import Group, UserGroup
|
from app.models.group import Group, UserGroup
|
||||||
from app.models.notification import Notification, NotificationPreference, NotificationType
|
from app.models.notification import Notification, NotificationPreference, NotificationType
|
||||||
|
from app.models.outbox import EventOutbox, OutboxDelivery # noqa: F401
|
||||||
from app.models.owned_mixin import OwnedMixin
|
from app.models.owned_mixin import OwnedMixin
|
||||||
from app.models.permission_delegation import PermissionDelegation
|
from app.models.permission_delegation import PermissionDelegation
|
||||||
from app.models.permission_template import PermissionTemplate
|
from app.models.permission_template import PermissionTemplate
|
||||||
@@ -75,6 +81,7 @@ __all__ = [
|
|||||||
"WorkflowInstance",
|
"WorkflowInstance",
|
||||||
"WorkflowStepHistory",
|
"WorkflowStepHistory",
|
||||||
"SavedView",
|
"SavedView",
|
||||||
|
"Dashboard",
|
||||||
]
|
]
|
||||||
from app.models.entity_attachment import EntityAttachment # noqa: F401
|
from app.models.entity_attachment import EntityAttachment # noqa: F401
|
||||||
from app.models.workspace import ( # noqa: F401
|
from app.models.workspace import ( # noqa: F401
|
||||||
@@ -83,3 +90,17 @@ from app.models.workspace import ( # noqa: F401
|
|||||||
WorkspaceUser,
|
WorkspaceUser,
|
||||||
WorkspaceWidget,
|
WorkspaceWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Lazy Contact re-export (Paket 6, #357) ──────────────────────────────────
|
||||||
|
# The physical home of Contact/ContactPerson is the ContactsPlugin
|
||||||
|
# (app.plugins.builtins.contacts.models). Resolving them lazily via package
|
||||||
|
# __getattr__ keeps ``from app.models import *`` (alembic/env.py) working
|
||||||
|
# while avoiding a plugin-framework import during app.models initialization
|
||||||
|
# (proven circular ImportError, see app/models/contact.py).
|
||||||
|
def __getattr__(name: str):
|
||||||
|
if name in {"Contact", "ContactPerson"}:
|
||||||
|
from app.models.contact import Contact, ContactPerson
|
||||||
|
|
||||||
|
return {"Contact": Contact, "ContactPerson": ContactPerson}[name]
|
||||||
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||||
|
|||||||
+31
-248
@@ -1,258 +1,41 @@
|
|||||||
"""Unified Contact model — company or person, with inline addresses.
|
"""Contact model - backwards-compatibility re-export (Paket 6, #357).
|
||||||
|
|
||||||
Based on Rentman's contact model: a single table with type field
|
The physical home of Contact/ContactPerson moved to the ContactsPlugin:
|
||||||
('company' or 'person'). ContactPerson is a 1:N child for
|
app/plugins.builtins.contacts.models
|
||||||
ansprechpartner (company employees / contact persons).
|
|
||||||
|
This module re-exports both classes lazily (PEP 562 ``__getattr__``) so every
|
||||||
|
existing import keeps working - ``from app.models.contact import Contact``
|
||||||
|
resolves at attribute-access time:
|
||||||
|
- alembic/env.py (``from app.models import *`` -> Base.metadata stays
|
||||||
|
complete; Autogenerate never sees the tables as removed)
|
||||||
|
- Core services (worker.py, jobs.py, address_service.py, ...)
|
||||||
|
- 19 test files and scripts
|
||||||
|
|
||||||
|
Why LAZY and not a top-level import: app/models/__init__.py is imported very
|
||||||
|
early (app.core.auth imports app.models.session). A top-level plugin import
|
||||||
|
here would pull in app.plugins -> registry -> service_container -> cache ->
|
||||||
|
app.core.auth while app.core.auth is still initializing -> circular ImportError
|
||||||
|
(proven in the Paket 6 red run). With PEP 562 the plugin framework is only
|
||||||
|
touched when Contact is actually accessed, long after app.models finished
|
||||||
|
initializing - every entry order is cycle-free.
|
||||||
|
|
||||||
|
The cross-plugin checker (scripts/check_cross_plugin_imports.py) lists this
|
||||||
|
file in EXEMPT_PATHS: the re-export is the deliberate, documented bridge -
|
||||||
|
the plugin OWNS the model; the core only mirrors it for import stability.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
_EXPORTS = {"Contact", "ContactPerson"}
|
||||||
from decimal import Decimal
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from sqlalchemy import (
|
|
||||||
Computed,
|
|
||||||
DateTime,
|
|
||||||
Float,
|
|
||||||
ForeignKey,
|
|
||||||
Index,
|
|
||||||
Numeric,
|
|
||||||
String,
|
|
||||||
Text,
|
|
||||||
UniqueConstraint,
|
|
||||||
)
|
|
||||||
from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
||||||
|
|
||||||
from app.core.db import Base, TenantMixin
|
|
||||||
from app.models.owned_mixin import OwnedMixin
|
|
||||||
|
|
||||||
|
|
||||||
class Contact(Base, TenantMixin, OwnedMixin):
|
def __getattr__(name: str):
|
||||||
"""Unified contact entity — can be a company or a person.
|
if name in _EXPORTS:
|
||||||
|
from app.plugins.builtins.contacts.models import Contact, ContactPerson
|
||||||
|
|
||||||
type='company': name is the company name, firstname/surname empty.
|
return {"Contact": Contact, "ContactPerson": ContactPerson}[name]
|
||||||
type='person': firstname/surname are the person's name, name empty.
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||||
Both types can have contactpersons (1:N) and inline addresses
|
|
||||||
(mailing, visit, invoice).
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "contacts"
|
|
||||||
indexed_at: Mapped[Any] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint("tenant_id", "code", name="uq_contacts_tenant_code"),
|
|
||||||
UniqueConstraint("tenant_id", "accounting_code", name="uq_contacts_tenant_accounting_code"),
|
|
||||||
Index("ix_contacts_tenant_deleted", "tenant_id", "deleted_at"),
|
|
||||||
Index("ix_contacts_tenant_type", "tenant_id", "type"),
|
|
||||||
Index("ix_contacts_tenant_name", "tenant_id", "name"),
|
|
||||||
Index("ix_contacts_tenant_displayname", "tenant_id", "displayname"),
|
|
||||||
Index("ix_contacts_email", "email_1"),
|
|
||||||
Index("ix_contacts_code", "code"),
|
|
||||||
Index("ix_contacts_search_vec", "search_tsv", postgresql_using="gin"),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(
|
|
||||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Identity & Type ──
|
|
||||||
type: Mapped[str] = mapped_column(String(20), nullable=False, default="company") # 'company' or 'person'
|
|
||||||
displayname: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
|
||||||
|
|
||||||
# ── Lifecycle Status (state machine: lead → qualified → customer → inactive) ──
|
|
||||||
status: Mapped[str] = mapped_column(String(30), nullable=False, default="lead", index=True)
|
|
||||||
name: Mapped[str | None] = mapped_column(String(255), nullable=True) # company name
|
|
||||||
firstname: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
||||||
surname: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
||||||
suffix: Mapped[str | None] = mapped_column(String(50), nullable=True) # name prefix (Dr., Prof.)
|
|
||||||
ext_name_line: Mapped[str | None] = mapped_column(String(255), nullable=True) # additional name line / subtitle
|
|
||||||
gender: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
|
||||||
|
|
||||||
# ── Customer / Accounting ──
|
|
||||||
code: Mapped[str | None] = mapped_column(String(100), nullable=True) # customer number
|
|
||||||
accounting_code: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
||||||
vendor_accounting_code: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
||||||
|
|
||||||
# ── Mailing Address (inline) ──
|
|
||||||
mailing_street: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
||||||
mailing_number: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
|
||||||
mailing_unit_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
|
||||||
mailing_district: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
||||||
mailing_extra_address_line: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
||||||
mailing_postalcode: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
|
||||||
mailing_city: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
||||||
mailing_state: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
||||||
mailing_country: Mapped[str | None] = mapped_column(String(2), nullable=True)
|
|
||||||
|
|
||||||
# ── Visit Address (inline) ──
|
|
||||||
visit_street: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
||||||
visit_number: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
|
||||||
visit_unit_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
|
||||||
visit_district: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
||||||
visit_extra_address_line: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
||||||
visit_postalcode: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
|
||||||
visit_city: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
||||||
visit_state: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
||||||
|
|
||||||
# ── Invoice Address (inline) ──
|
|
||||||
invoice_street: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
||||||
invoice_number: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
|
||||||
invoice_unit_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
|
||||||
invoice_district: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
||||||
invoice_extra_address_line: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
||||||
invoice_postalcode: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
|
||||||
invoice_city: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
||||||
invoice_state: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
||||||
invoice_country: Mapped[str | None] = mapped_column(String(2), nullable=True)
|
|
||||||
|
|
||||||
# ── General country ──
|
|
||||||
country: Mapped[str | None] = mapped_column(String(2), nullable=True)
|
|
||||||
|
|
||||||
# ── Communication ──
|
|
||||||
phone_1: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
|
||||||
phone_2: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
|
||||||
email_1: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
||||||
email_2: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
||||||
website: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
|
||||||
|
|
||||||
# ── Financial & Tax ──
|
|
||||||
vat_code: Mapped[str | None] = mapped_column(String(50), nullable=True) # USt-IdNr.
|
|
||||||
fiscal_code: Mapped[str | None] = mapped_column(String(50), nullable=True) # Steuernummer
|
|
||||||
commerce_code: Mapped[str | None] = mapped_column(String(100), nullable=True) # Handelsregister
|
|
||||||
purchase_number: Mapped[str | None] = mapped_column(String(100), nullable=True) # Bestellnummer
|
|
||||||
bic: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
|
||||||
bank_account: Mapped[str | None] = mapped_column(String(50), nullable=True) # IBAN
|
|
||||||
|
|
||||||
# ── Discounts ──
|
|
||||||
discount_crew: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
|
|
||||||
discount_transport: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
|
|
||||||
discount_rental: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
|
|
||||||
discount_sale: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
|
|
||||||
discount_subrent: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
|
|
||||||
discount_total: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
|
|
||||||
|
|
||||||
# ── Geo ──
|
|
||||||
latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
||||||
longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
||||||
|
|
||||||
# ── Notes & Warnings ──
|
|
||||||
projectnote: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
projectnote_title: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
||||||
contact_warning: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
tags: Mapped[str | None] = mapped_column(String(500), nullable=True) # comma-separated
|
|
||||||
image: Mapped[str | None] = mapped_column(Text, nullable=True) # logo/image URL or base64
|
|
||||||
|
|
||||||
# ── Default contact persons (self-referential via contactpersons table) ──
|
|
||||||
default_person_id: Mapped[uuid.UUID | None] = mapped_column(
|
|
||||||
PGUUID(as_uuid=True), ForeignKey("contactpersons.id", ondelete="SET NULL"), nullable=True
|
|
||||||
)
|
|
||||||
admin_contactperson_id: Mapped[uuid.UUID | None] = mapped_column(
|
|
||||||
PGUUID(as_uuid=True), ForeignKey("contactpersons.id", ondelete="SET NULL"), nullable=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Folder assignment ──
|
|
||||||
folder_id: Mapped[uuid.UUID | None] = mapped_column(
|
|
||||||
PGUUID(as_uuid=True),
|
|
||||||
ForeignKey("contact_folders.id", ondelete="SET NULL"),
|
|
||||||
nullable=True,
|
|
||||||
index=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Custom fields ──
|
|
||||||
custom: Mapped[dict | None] = mapped_column(JSONB, nullable=True, default=dict)
|
|
||||||
|
|
||||||
# ── FTS ──
|
|
||||||
search_tsv: Mapped[Any] = mapped_column(
|
|
||||||
TSVECTOR,
|
|
||||||
Computed(
|
|
||||||
"to_tsvector('german', coalesce(name, '') || ' ' || coalesce(displayname, '') || ' ' || coalesce(firstname, '') || ' ' || coalesce(surname, '') || ' ' || coalesce(email_1, '') || ' ' || coalesce(email_2, '') || ' ' || coalesce(code, '') || ' ' || coalesce(phone_1, '') || ' ' || coalesce(phone_2, '') || ' ' || coalesce(mailing_city, '') || ' ' || coalesce(mailing_postalcode, '') || ' ' || coalesce(tags, ''))",
|
|
||||||
persisted=True,
|
|
||||||
),
|
|
||||||
nullable=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Embedding (pgvector, 768-dim) ──
|
|
||||||
from pgvector.sqlalchemy import Vector
|
|
||||||
embedding: Mapped[Any | None] = mapped_column(
|
|
||||||
Vector(768), nullable=True, default=None
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Audit ──
|
|
||||||
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
|
||||||
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
|
||||||
)
|
|
||||||
updated_by: Mapped[uuid.UUID | None] = mapped_column(
|
|
||||||
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Relationships ──
|
|
||||||
contact_persons: Mapped[list[ContactPerson]] = relationship(
|
|
||||||
back_populates="contact", cascade="all, delete-orphan", foreign_keys="ContactPerson.contact_id"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ContactPerson(Base, TenantMixin, OwnedMixin):
|
def __dir__() -> list[str]:
|
||||||
"""Ansprechpartner — 1:N child of a Contact.
|
return sorted(_EXPORTS | {"__getattr__", "__dir__"})
|
||||||
|
|
||||||
Represents a person working at / associated with a company contact.
|
|
||||||
Has its own address and communication fields.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "contactpersons"
|
|
||||||
__table_args__ = (
|
|
||||||
Index("ix_contactpersons_tenant_deleted", "tenant_id", "deleted_at"),
|
|
||||||
Index("ix_contactpersons_contact", "contact_id"),
|
|
||||||
Index("ix_contactpersons_email", "email"),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(
|
|
||||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Parent contact ──
|
|
||||||
contact_id: Mapped[uuid.UUID] = mapped_column(
|
|
||||||
PGUUID(as_uuid=True), ForeignKey("contacts.id", ondelete="CASCADE"), nullable=False
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Name ──
|
|
||||||
displayname: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
|
||||||
firstname: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
||||||
middle_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
||||||
lastname: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
||||||
function: Mapped[str | None] = mapped_column(String(255), nullable=True) # position/role
|
|
||||||
|
|
||||||
# ── Communication ──
|
|
||||||
phone: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
|
||||||
mobilephone: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
|
||||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
||||||
|
|
||||||
# ── Own address ──
|
|
||||||
street: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
||||||
number: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
|
||||||
postalcode: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
|
||||||
city: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
||||||
state: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
||||||
country: Mapped[str | None] = mapped_column(String(2), nullable=True)
|
|
||||||
|
|
||||||
# ── Other ──
|
|
||||||
tags: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
|
||||||
custom: Mapped[dict | None] = mapped_column(JSONB, nullable=True, default=dict)
|
|
||||||
|
|
||||||
# ── Audit ──
|
|
||||||
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
|
||||||
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
|
||||||
)
|
|
||||||
updated_by: Mapped[uuid.UUID | None] = mapped_column(
|
|
||||||
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Relationship ──
|
|
||||||
contact: Mapped[Contact] = relationship(
|
|
||||||
back_populates="contact_persons", foreign_keys=[contact_id]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Keep old names for backward compat during migration
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Dashboard model — personal per-user dashboards (Phase M2).
|
||||||
|
|
||||||
|
Dashboards are personal (user-owned) layouts of MiniApp instances: tabs,
|
||||||
|
widget placements and per-instance settings, stored as JSONB. Access is
|
||||||
|
owner-only (saved_views precedent) — the active workspace limits only the
|
||||||
|
available widget types (Phase N), never this personal layout.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, ForeignKey, Index, String, text
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.db import Base, TenantMixin
|
||||||
|
|
||||||
|
|
||||||
|
class Dashboard(Base, TenantMixin):
|
||||||
|
"""Personal dashboard — per-user layout of MiniApp instances."""
|
||||||
|
|
||||||
|
__tablename__ = "dashboards"
|
||||||
|
__table_args__ = (
|
||||||
|
# Partial unique: soft-deleted dashboards free their name (unlike the
|
||||||
|
# saved_views plain constraint, which keeps names occupied forever).
|
||||||
|
Index(
|
||||||
|
"uq_dashboards_tenant_user_name",
|
||||||
|
"tenant_id",
|
||||||
|
"user_id",
|
||||||
|
"name",
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=text("deleted_at IS NULL"),
|
||||||
|
),
|
||||||
|
Index("ix_dashboards_tenant_user", "tenant_id", "user_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
# Layout JSONB (validated by app.schemas.dashboard.DashboardLayout):
|
||||||
|
# {version, tabs: [{id, name, widgets: [{app_id, settings, col, row, spans}]}]}
|
||||||
|
layout: Mapped[dict[str, Any]] = mapped_column(
|
||||||
|
JSONB, nullable=False, default=dict
|
||||||
|
)
|
||||||
|
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
@@ -58,3 +58,7 @@ class PluginMigration(Base, TimestampMixin):
|
|||||||
plugin_name: Mapped[str] = mapped_column(String(80), nullable=False)
|
plugin_name: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||||
migration_file: Mapped[str] = mapped_column(String(255), nullable=False)
|
migration_file: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="applied")
|
status: Mapped[str] = mapped_column(String(20), nullable=False, default="applied")
|
||||||
|
# F40 (Astra): SHA-256 of the applied SQL content. The runner compares
|
||||||
|
# this on subsequent runs — a modified already-applied migration
|
||||||
|
# (repaired) becomes VISIBLE instead of being silently skipped.
|
||||||
|
content_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
|||||||
+82
-8
@@ -53,15 +53,16 @@ class BasePlugin(ABC):
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Called when the plugin is activated.
|
"""Called when the plugin is activated.
|
||||||
|
|
||||||
Override to register event listeners and prepare runtime state.
|
Default implementation subscribes to events listed in the manifest and
|
||||||
Default implementation subscribes to events listed in the manifest.
|
registers manifest MiniApps (Phase M1): ``miniapps`` contributions plus
|
||||||
|
``dashboard_widgets`` entries (alias — one contribution type, #359
|
||||||
|
philosophy). Registered automatically here; no per-plugin code needed.
|
||||||
"""
|
"""
|
||||||
for event_name in self.manifest.events:
|
self._register_manifest_events(event_bus)
|
||||||
handler = self._make_event_handler(event_name)
|
|
||||||
self._event_handlers[event_name] = handler
|
|
||||||
event_bus.subscribe(event_name, handler)
|
|
||||||
self._container = service_container
|
self._container = service_container
|
||||||
|
|
||||||
|
self._register_manifest_miniapps()
|
||||||
|
|
||||||
async def on_deactivate(
|
async def on_deactivate(
|
||||||
self, db: AsyncSession, service_container: ServiceContainer, event_bus: EventBus
|
self, db: AsyncSession, service_container: ServiceContainer, event_bus: EventBus
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -80,6 +81,57 @@ class BasePlugin(ABC):
|
|||||||
|
|
||||||
get_hook_registry().unregister_all_for_plugin(self.manifest.name)
|
get_hook_registry().unregister_all_for_plugin(self.manifest.name)
|
||||||
|
|
||||||
|
# Unregister MiniApps owned by this plugin (Phase M1)
|
||||||
|
from app.plugins.miniapp_registry import get_miniapp_registry
|
||||||
|
|
||||||
|
get_miniapp_registry().unregister_plugin(self.manifest.name)
|
||||||
|
|
||||||
|
def _register_manifest_miniapps(self) -> None:
|
||||||
|
"""Register manifest MiniApps in the universal registry (Phase M1).
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- ``manifest.miniapps`` — native MiniApp contributions
|
||||||
|
- ``manifest.dashboard_widgets`` — alias: FrontendDashboardWidget entries
|
||||||
|
become MiniApps with component path + spans + permission so existing
|
||||||
|
plugin manifests keep working without changes.
|
||||||
|
"""
|
||||||
|
from app.plugins.miniapp_registry import get_miniapp_registry
|
||||||
|
|
||||||
|
registry = get_miniapp_registry()
|
||||||
|
name = self.manifest.name
|
||||||
|
|
||||||
|
for m in getattr(self.manifest, "miniapps", None) or []:
|
||||||
|
registry.register(
|
||||||
|
app_id=m.app_id,
|
||||||
|
name=m.name,
|
||||||
|
icon=m.icon,
|
||||||
|
description=m.description,
|
||||||
|
plugin_name=name,
|
||||||
|
render_schema=m.render_schema,
|
||||||
|
permission=getattr(m, "permission", ""),
|
||||||
|
settings_schema=getattr(m, "settings_schema", {}),
|
||||||
|
col_span=getattr(m, "col_span", 1),
|
||||||
|
row_span=getattr(m, "row_span", 1),
|
||||||
|
hosts=getattr(m, "hosts", None),
|
||||||
|
component=getattr(m, "component", ""),
|
||||||
|
order=getattr(m, "order", 100),
|
||||||
|
)
|
||||||
|
|
||||||
|
for w in getattr(self.manifest, "dashboard_widgets", None) or []:
|
||||||
|
registry.register(
|
||||||
|
app_id=w.id,
|
||||||
|
name=w.label or w.id,
|
||||||
|
icon=w.icon,
|
||||||
|
description="",
|
||||||
|
plugin_name=name,
|
||||||
|
permission=w.permission,
|
||||||
|
col_span=w.col_span,
|
||||||
|
row_span=w.row_span,
|
||||||
|
hosts=["chat", "dashboard", "window"],
|
||||||
|
component=w.component,
|
||||||
|
order=w.order,
|
||||||
|
)
|
||||||
|
|
||||||
async def on_uninstall(self, db: AsyncSession, service_container: ServiceContainer) -> None:
|
async def on_uninstall(self, db: AsyncSession, service_container: ServiceContainer) -> None:
|
||||||
"""Called when the plugin is uninstalled (before data tables are dropped).
|
"""Called when the plugin is uninstalled (before data tables are dropped).
|
||||||
|
|
||||||
@@ -102,9 +154,31 @@ class BasePlugin(ABC):
|
|||||||
|
|
||||||
The worker calls this on every active plugin at startup so plugins
|
The worker calls this on every active plugin at startup so plugins
|
||||||
can subscribe to events even when the web process is separate.
|
can subscribe to events even when the web process is separate.
|
||||||
Default: no-op. Override to subscribe handlers.
|
|
||||||
|
F06 (Astra P1): the default now subscribes the plugin's manifest
|
||||||
|
events via the SAME idempotent path as ``on_activate`` — previously
|
||||||
|
this was a no-op, so the worker registered 0 of the 44 declared
|
||||||
|
event handlers and Outbox events reached no plugin handler. Plugins
|
||||||
|
that override this MUST call ``await super().register_event_handlers(
|
||||||
|
event_bus)`` to keep the manifest subscription.
|
||||||
"""
|
"""
|
||||||
return None
|
self._register_manifest_events(event_bus)
|
||||||
|
|
||||||
|
def _register_manifest_events(self, event_bus: EventBus) -> None:
|
||||||
|
"""Subscribe to manifest events — shared, idempotent (F06).
|
||||||
|
|
||||||
|
Used by BOTH the API activation path (``on_activate``) and the
|
||||||
|
worker startup hook (``register_event_handlers``). Idempotent: an
|
||||||
|
event already subscribed in this instance is not subscribed twice.
|
||||||
|
DB-writing lifecycle work (seeding, cron registration) stays in
|
||||||
|
``on_activate`` — the worker path deliberately skips it.
|
||||||
|
"""
|
||||||
|
for event_name in self.manifest.events:
|
||||||
|
if event_name in self._event_handlers:
|
||||||
|
continue # already subscribed — idempotent
|
||||||
|
handler = self._make_event_handler(event_name)
|
||||||
|
self._event_handlers[event_name] = handler
|
||||||
|
event_bus.subscribe(event_name, handler)
|
||||||
|
|
||||||
# ─── Job Modules ───
|
# ─── Job Modules ───
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from app.plugins.base import BasePlugin
|
from app.plugins.base import BasePlugin
|
||||||
from app.plugins.manifest import PluginManifest, PluginRouteDef
|
from app.plugins.manifest import (
|
||||||
|
FrontendMenuItem,
|
||||||
|
FrontendPageRoute,
|
||||||
|
PluginManifest,
|
||||||
|
PluginRouteDef,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AgentMemoryPlugin(BasePlugin):
|
class AgentMemoryPlugin(BasePlugin):
|
||||||
@@ -28,6 +33,26 @@ class AgentMemoryPlugin(BasePlugin):
|
|||||||
"agent_memory:read",
|
"agent_memory:read",
|
||||||
"agent_memory:write",
|
"agent_memory:write",
|
||||||
],
|
],
|
||||||
|
# UI-Backlog Modul 8 (2026-09-13): agent memory page, registered
|
||||||
|
# via the manifest (Phase Q pattern).
|
||||||
|
menu_items=[
|
||||||
|
FrontendMenuItem(
|
||||||
|
label_key="nav.agentMemory",
|
||||||
|
label="Agent Memory",
|
||||||
|
path="/agent-memory",
|
||||||
|
icon="Brain",
|
||||||
|
order=86,
|
||||||
|
permission="agent_memory:read",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
page_routes=[
|
||||||
|
FrontendPageRoute(
|
||||||
|
path="/agent-memory",
|
||||||
|
component="@/pages/AgentMemory",
|
||||||
|
protected=True,
|
||||||
|
permission="agent_memory:read",
|
||||||
|
),
|
||||||
|
],
|
||||||
is_core=True,
|
is_core=True,
|
||||||
author="LeoCRM Team",
|
author="LeoCRM Team",
|
||||||
min_app_version="1.0.0",
|
min_app_version="1.0.0",
|
||||||
|
|||||||
@@ -20,12 +20,56 @@ _openapi_cache: dict[str, Any] | None = None
|
|||||||
|
|
||||||
|
|
||||||
def _get_base_url() -> str:
|
def _get_base_url() -> str:
|
||||||
"""Get the internal base URL for API calls."""
|
"""Get the internal base URL for API calls.
|
||||||
|
|
||||||
|
F09 (Astra P1): inside the API container this is 127.0.0.1; the
|
||||||
|
WORKER must reach the API service instead - 127.0.0.1 there points
|
||||||
|
at the worker itself. INTERNAL_API_URL overrides (compose sets it
|
||||||
|
to http://crm_app:PORT for the worker service).
|
||||||
|
"""
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
override = os.environ.get("INTERNAL_API_URL")
|
||||||
|
if override:
|
||||||
|
return override.rstrip("/")
|
||||||
port = os.environ.get("PORT", "8000")
|
port = os.environ.get("PORT", "8000")
|
||||||
return f"http://127.0.0.1:{port}"
|
return f"http://127.0.0.1:{port}"
|
||||||
|
|
||||||
|
|
||||||
|
async def _make_internal_api_request(method, path, tenant_id, user_id, body=None):
|
||||||
|
"""F09 (Astra P1): authenticated internal API request.
|
||||||
|
|
||||||
|
Sends a short-lived HMAC-signed delegation token (max 60 s) instead
|
||||||
|
of the previous unauthenticated X-Internal-Call headers that the
|
||||||
|
protected API never accepted. The token acts ON BEHALF OF the user
|
||||||
|
- their real permissions apply (RBAC + RLS), no special rights.
|
||||||
|
"""
|
||||||
|
from app.core.delegation_token import create_delegation_token
|
||||||
|
|
||||||
|
token = create_delegation_token(
|
||||||
|
user_id=str(user_id),
|
||||||
|
tenant_id=str(tenant_id),
|
||||||
|
agent_id="crm-api-tool",
|
||||||
|
)
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Delegation-Token": token,
|
||||||
|
}
|
||||||
|
url = f"{_get_base_url()}{path}"
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
if method == "GET":
|
||||||
|
return await client.get(url, headers=headers, timeout=30.0)
|
||||||
|
if method == "POST":
|
||||||
|
return await client.post(url, headers=headers, json=body, timeout=30.0)
|
||||||
|
if method == "PATCH":
|
||||||
|
return await client.patch(url, headers=headers, json=body, timeout=30.0)
|
||||||
|
if method == "PUT":
|
||||||
|
return await client.put(url, headers=headers, json=body, timeout=30.0)
|
||||||
|
if method == "DELETE":
|
||||||
|
return await client.delete(url, headers=headers, timeout=30.0)
|
||||||
|
raise ValueError(f"Unsupported method: {method}")
|
||||||
|
|
||||||
|
|
||||||
async def get_openapi_spec() -> dict[str, Any]:
|
async def get_openapi_spec() -> dict[str, Any]:
|
||||||
"""Get the CRM OpenAPI spec, cached."""
|
"""Get the CRM OpenAPI spec, cached."""
|
||||||
global _openapi_cache
|
global _openapi_cache
|
||||||
@@ -101,34 +145,15 @@ async def call_crm_api_handler(arguments: dict[str, Any], context: dict[str, Any
|
|||||||
path = "/" + path
|
path = "/" + path
|
||||||
|
|
||||||
try:
|
try:
|
||||||
base_url = _get_base_url()
|
|
||||||
|
|
||||||
# Get user context for auth
|
# Get user context for auth
|
||||||
tenant_id = context.get("tenant_id", "")
|
tenant_id = context.get("tenant_id", "")
|
||||||
user_id = context.get("user_id", "")
|
user_id = context.get("user_id", "")
|
||||||
|
|
||||||
# Create a DB session to resolve a valid session token for this user
|
# F09 (Astra P1): authenticated request via short-lived delegation
|
||||||
# We'll use internal service-level auth bypass
|
# token - the acting user's real permissions apply.
|
||||||
headers = {
|
resp = await _make_internal_api_request(
|
||||||
"Content-Type": "application/json",
|
method, path, tenant_id=tenant_id, user_id=user_id, body=body
|
||||||
"X-Internal-Call": "true",
|
)
|
||||||
"X-Tenant-Id": str(tenant_id),
|
|
||||||
"X-User-Id": str(user_id),
|
|
||||||
}
|
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
if method == "GET":
|
|
||||||
resp = await client.get(f"{base_url}{path}", headers=headers, timeout=30.0)
|
|
||||||
elif method == "POST":
|
|
||||||
resp = await client.post(f"{base_url}{path}", headers=headers, json=body, timeout=30.0)
|
|
||||||
elif method == "PATCH":
|
|
||||||
resp = await client.patch(f"{base_url}{path}", headers=headers, json=body, timeout=30.0)
|
|
||||||
elif method == "PUT":
|
|
||||||
resp = await client.put(f"{base_url}{path}", headers=headers, json=body, timeout=30.0)
|
|
||||||
elif method == "DELETE":
|
|
||||||
resp = await client.delete(f"{base_url}{path}", headers=headers, timeout=30.0)
|
|
||||||
else:
|
|
||||||
return json.dumps({"error": f"Unsupported method: {method}"})
|
|
||||||
|
|
||||||
# Return response body (truncated if too large)
|
# Return response body (truncated if too large)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ from fastapi.responses import StreamingResponse
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.db import get_db, set_tenant_context
|
from app.core.db import get_db, get_session_factory, set_tenant_context
|
||||||
from app.deps import get_current_user_bearer, require_permission
|
from app.deps import get_current_user_bearer, require_permission_or_bearer
|
||||||
from app.plugins.builtins.ai_assistant.schemas import (
|
from app.plugins.builtins.ai_assistant.schemas import (
|
||||||
ExternalAgentRequest,
|
ExternalAgentRequest,
|
||||||
ExternalAgentResponse,
|
ExternalAgentResponse,
|
||||||
@@ -39,7 +39,7 @@ async def _check_external_rate_limit(request: Request, tenant_id: str, token_pre
|
|||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{agent_id}/run",
|
"/{agent_id}/run",
|
||||||
dependencies=[Depends(require_permission("ai:write"))],
|
dependencies=[Depends(require_permission_or_bearer("ai:write"))],
|
||||||
)
|
)
|
||||||
async def run_agent_external(
|
async def run_agent_external(
|
||||||
agent_id: str,
|
agent_id: str,
|
||||||
@@ -123,7 +123,8 @@ async def run_agent_external(
|
|||||||
# Run the agent via streaming chat (non-streaming mode)
|
# Run the agent via streaming chat (non-streaming mode)
|
||||||
|
|
||||||
full_response = ""
|
full_response = ""
|
||||||
async with get_db() as stream_db:
|
_factory = get_session_factory()
|
||||||
|
async with _factory() as stream_db:
|
||||||
await set_tenant_context(stream_db, tenant_id)
|
await set_tenant_context(stream_db, tenant_id)
|
||||||
async for chunk in stream_chat(
|
async for chunk in stream_chat(
|
||||||
stream_db,
|
stream_db,
|
||||||
@@ -155,7 +156,7 @@ async def run_agent_external(
|
|||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{agent_id}/status",
|
"/{agent_id}/status",
|
||||||
dependencies=[Depends(require_permission("ai:read"))],
|
dependencies=[Depends(require_permission_or_bearer("ai:read"))],
|
||||||
)
|
)
|
||||||
async def get_agent_status_external(
|
async def get_agent_status_external(
|
||||||
agent_id: str,
|
agent_id: str,
|
||||||
@@ -218,7 +219,7 @@ async def get_agent_status_external(
|
|||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{agent_id}/stream",
|
"/{agent_id}/stream",
|
||||||
dependencies=[Depends(require_permission("ai:write"))],
|
dependencies=[Depends(require_permission_or_bearer("ai:write"))],
|
||||||
)
|
)
|
||||||
async def stream_agent_external(
|
async def stream_agent_external(
|
||||||
agent_id: str,
|
agent_id: str,
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
-- AI Assistant plugin initial migration
|
-- AI Assistant plugin initial migration
|
||||||
|
-- FIX 2026-09-16: ai_chat_sessions/ai_chat_messages removed — AI chat moved
|
||||||
|
-- to comm_conversations/comm_messages (Alembic 0137 dropped these tables).
|
||||||
|
-- Fresh installs must NOT recreate the ghost tables.
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ai_providers (
|
CREATE TABLE IF NOT EXISTS ai_providers (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
@@ -68,35 +71,3 @@ CREATE TABLE IF NOT EXISTS ai_agents (
|
|||||||
deleted_at TIMESTAMPTZ
|
deleted_at TIMESTAMPTZ
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS ix_ai_agents_tenant ON ai_agents(tenant_id);
|
CREATE INDEX IF NOT EXISTS ix_ai_agents_tenant ON ai_agents(tenant_id);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ai_chat_sessions (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID NOT NULL,
|
|
||||||
agent_id UUID REFERENCES ai_agents(id) ON DELETE SET NULL,
|
|
||||||
title VARCHAR(255) NOT NULL DEFAULT 'Neuer Chat',
|
|
||||||
is_pinned BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
is_sidebar BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
tenant_id UUID NOT NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
deleted_at TIMESTAMPTZ
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS ix_ai_sessions_user ON ai_chat_sessions(user_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS ix_ai_sessions_tenant ON ai_chat_sessions(tenant_id);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ai_chat_messages (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
session_id UUID NOT NULL REFERENCES ai_chat_sessions(id) ON DELETE CASCADE,
|
|
||||||
role VARCHAR(20) NOT NULL,
|
|
||||||
content TEXT NOT NULL DEFAULT '',
|
|
||||||
tool_calls JSONB,
|
|
||||||
tool_results JSONB,
|
|
||||||
tokens INTEGER NOT NULL DEFAULT 0,
|
|
||||||
model_used VARCHAR(200) NOT NULL DEFAULT '',
|
|
||||||
tenant_id UUID NOT NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
deleted_at TIMESTAMPTZ
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS ix_ai_messages_session ON ai_chat_messages(session_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS ix_ai_messages_tenant ON ai_chat_messages(tenant_id);
|
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
-- AI Assistant plugin migration 0002: chat folders + attachments
|
-- AI Assistant plugin migration 0002: chat folders
|
||||||
|
-- FIX 2026-09-16: ai_chat_attachments and the folder_id ALTER on
|
||||||
|
-- ai_chat_sessions removed — AI chat moved to comm_conversations/
|
||||||
|
-- comm_messages (Alembic 0137 dropped the legacy tables). Only the
|
||||||
|
-- ai_chat_folders table remains (still used for chat folder ordering).
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ai_chat_folders (
|
CREATE TABLE IF NOT EXISTS ai_chat_folders (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
@@ -13,22 +17,3 @@ CREATE TABLE IF NOT EXISTS ai_chat_folders (
|
|||||||
CREATE INDEX IF NOT EXISTS ix_ai_folders_user ON ai_chat_folders(user_id);
|
CREATE INDEX IF NOT EXISTS ix_ai_folders_user ON ai_chat_folders(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS ix_ai_folders_tenant ON ai_chat_folders(tenant_id);
|
CREATE INDEX IF NOT EXISTS ix_ai_folders_tenant ON ai_chat_folders(tenant_id);
|
||||||
CREATE INDEX IF NOT EXISTS ix_ai_folders_parent ON ai_chat_folders(parent_id);
|
CREATE INDEX IF NOT EXISTS ix_ai_folders_parent ON ai_chat_folders(parent_id);
|
||||||
|
|
||||||
ALTER TABLE ai_chat_sessions ADD COLUMN IF NOT EXISTS folder_id UUID REFERENCES ai_chat_folders(id) ON DELETE SET NULL;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ai_chat_attachments (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
message_id UUID REFERENCES ai_chat_messages(id) ON DELETE CASCADE,
|
|
||||||
session_id UUID NOT NULL REFERENCES ai_chat_sessions(id) ON DELETE CASCADE,
|
|
||||||
filename VARCHAR(255) NOT NULL,
|
|
||||||
mime_type VARCHAR(255) NOT NULL DEFAULT 'application/octet-stream',
|
|
||||||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
|
||||||
storage_path VARCHAR(1024) NOT NULL,
|
|
||||||
tenant_id UUID NOT NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
deleted_at TIMESTAMPTZ
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS ix_ai_attachments_message ON ai_chat_attachments(message_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS ix_ai_attachments_session ON ai_chat_attachments(session_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS ix_ai_attachments_tenant ON ai_chat_attachments(tenant_id);
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
-- Migration 0003: Add sort_order columns for drag&drop reordering
|
-- Migration 0003: Add sort_order columns for drag&drop reordering
|
||||||
|
-- FIX 2026-09-16: ai_chat_sessions was dropped by Alembic 0137 (AI chat
|
||||||
|
-- moved to comm_conversations/comm_messages). The ALTER/INDEX statements
|
||||||
|
-- targeting ai_chat_sessions made this migration fail on every startup
|
||||||
|
-- ("relation ai_chat_sessions does not exist"), which deactivated the
|
||||||
|
-- whole ai_assistant plugin. Only the ai_chat_folders statements remain
|
||||||
|
-- (that table still exists and is used for chat folder ordering).
|
||||||
|
|
||||||
ALTER TABLE ai_chat_sessions ADD COLUMN IF NOT EXISTS sort_order INTEGER NOT NULL DEFAULT 0;
|
|
||||||
ALTER TABLE ai_chat_folders ADD COLUMN IF NOT EXISTS sort_order INTEGER NOT NULL DEFAULT 0;
|
ALTER TABLE ai_chat_folders ADD COLUMN IF NOT EXISTS sort_order INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS ix_ai_sessions_folder ON ai_chat_sessions(folder_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS ix_ai_sessions_sort ON ai_chat_sessions(sort_order);
|
|
||||||
CREATE INDEX IF NOT EXISTS ix_ai_folders_sort ON ai_chat_folders(sort_order);
|
CREATE INDEX IF NOT EXISTS ix_ai_folders_sort ON ai_chat_folders(sort_order);
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ class AIAssistantPlugin(BasePlugin):
|
|||||||
],
|
],
|
||||||
settings_pages=[
|
settings_pages=[
|
||||||
FrontendSettingsPage(path='ai', label_key='settings.ai', label='AI Settings', component='@/pages/AISettings', icon='Bot', order=60),
|
FrontendSettingsPage(path='ai', label_key='settings.ai', label='AI Settings', component='@/pages/AISettings', icon='Bot', order=60),
|
||||||
|
FrontendSettingsPage(path='external-agents', label_key='settings.externalAgents', label='External Agents API', component='@/pages/SettingsExternalAgents', icon='Bot', order=61, permission='ai:read'),
|
||||||
],
|
],
|
||||||
author="LeoCRM Team",
|
author="LeoCRM Team",
|
||||||
min_app_version="1.0.0",
|
min_app_version="1.0.0",
|
||||||
|
|||||||
@@ -234,6 +234,10 @@ async def stream_chat_comm(
|
|||||||
tools.append(crm_api_tool)
|
tools.append(crm_api_tool)
|
||||||
tool_schemas = [t.to_openai_schema() for t in tools] if tools else None
|
tool_schemas = [t.to_openai_schema() for t in tools] if tools else None
|
||||||
|
|
||||||
|
# F01 (Astra P0): allowlist — only the tools offered above may execute.
|
||||||
|
# A hallucinated/injected tool name must never reach a handler.
|
||||||
|
allowed_tool_names = {t.name for t in tools}
|
||||||
|
|
||||||
# Build LLM params
|
# Build LLM params
|
||||||
params, model_id = await build_litellm_params(db, agent, messages, tenant_id)
|
params, model_id = await build_litellm_params(db, agent, messages, tenant_id)
|
||||||
|
|
||||||
@@ -290,8 +294,17 @@ async def stream_chat_comm(
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
tool_args = {}
|
tool_args = {}
|
||||||
|
|
||||||
|
# F01 (Astra P0): allowlist enforcement — reject any tool
|
||||||
|
# name that was not offered to the LLM before it reaches a
|
||||||
|
# handler. registered ≠ permitted.
|
||||||
tool = registry.get(tool_name)
|
tool = registry.get(tool_name)
|
||||||
if tool is None:
|
if tool_name not in allowed_tool_names:
|
||||||
|
logger.warning(
|
||||||
|
"stream_chat_comm F01 guard: tool '%s' is registered but NOT offered to this agent — rejected",
|
||||||
|
tool_name,
|
||||||
|
)
|
||||||
|
result = f"Error: Tool '{tool_name}' is not available to this agent"
|
||||||
|
elif tool is None:
|
||||||
result = f"Tool '{tool_name}' not found"
|
result = f"Tool '{tool_name}' not found"
|
||||||
else:
|
else:
|
||||||
result = await execute_tool_call(tool, tool_args, user_context)
|
result = await execute_tool_call(tool, tool_args, user_context)
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ async def push_suggestion(user_id: str, suggestion: dict[str, Any]) -> None:
|
|||||||
from app.core.db import get_worker_session_factory
|
from app.core.db import get_worker_session_factory
|
||||||
from app.plugins.builtins.contracts import get_contract_registry
|
from app.plugins.builtins.contracts import get_contract_registry
|
||||||
from app.plugins.builtins.kommunikation.models import CommConversation
|
from app.plugins.builtins.kommunikation.models import CommConversation
|
||||||
komm = get_contract_registry().get("kommunikation")
|
komm = get_contract_registry().get_contract("kommunikation")
|
||||||
if komm:
|
if komm:
|
||||||
factory = get_worker_session_factory()
|
factory = get_worker_session_factory()
|
||||||
async with factory() as db:
|
async with factory() as db:
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from pydantic import BaseModel
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.deps import get_current_user, require_permission
|
from app.deps import get_current_user, require_permission, require_workspace_scope
|
||||||
from app.plugins.builtins.automation.models import (
|
from app.plugins.builtins.automation.models import (
|
||||||
AgentDefinition,
|
AgentDefinition,
|
||||||
AgentRun,
|
AgentRun,
|
||||||
@@ -118,8 +118,13 @@ async def list_agents(
|
|||||||
offset: int = Query(0, ge=0),
|
offset: int = Query(0, ge=0),
|
||||||
current_user: dict[str, Any] = Depends(get_current_user),
|
current_user: dict[str, Any] = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
workspace_scope: dict | None = Depends(require_workspace_scope("agents")),
|
||||||
):
|
):
|
||||||
"""List agent definitions with optional filters."""
|
"""List agent definitions with optional filters.
|
||||||
|
|
||||||
|
Phase N4: an active workspace scope restricts the list to the
|
||||||
|
configured agent subset (pure AND — never a grant).
|
||||||
|
"""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
user_id = uuid.UUID(current_user["user_id"])
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
is_system_admin = current_user.get("is_system_admin", False)
|
is_system_admin = current_user.get("is_system_admin", False)
|
||||||
@@ -127,6 +132,14 @@ async def list_agents(
|
|||||||
db, tenant_id, is_active=is_active, mode=mode, limit=limit, offset=offset,
|
db, tenant_id, is_active=is_active, mode=mode, limit=limit, offset=offset,
|
||||||
user_id=user_id, is_system_admin=is_system_admin,
|
user_id=user_id, is_system_admin=is_system_admin,
|
||||||
)
|
)
|
||||||
|
# Phase N4: workspace scope — agent subset (pure AND)
|
||||||
|
if workspace_scope:
|
||||||
|
from app.services.workspace_scope_service import scope_uuid_set
|
||||||
|
|
||||||
|
agent_scope = scope_uuid_set(workspace_scope.get("agent_ids"))
|
||||||
|
if agent_scope is not None:
|
||||||
|
items = [a for a in items if a.id in agent_scope]
|
||||||
|
total = len(items)
|
||||||
return AgentDefinitionListResponse(
|
return AgentDefinitionListResponse(
|
||||||
items=[_agent_to_response(a) for a in items],
|
items=[_agent_to_response(a) for a in items],
|
||||||
total=total,
|
total=total,
|
||||||
@@ -166,14 +179,14 @@ async def list_tools(
|
|||||||
)
|
)
|
||||||
|
|
||||||
registry = get_tool_registry()
|
registry = get_tool_registry()
|
||||||
tools = registry.list_tools()
|
tools = registry.list_for_api()
|
||||||
return {
|
return {
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"id": t.get("id", t.get("name", "")),
|
"id": t.get("name", ""),
|
||||||
"name": t.get("name", ""),
|
"name": t.get("name", ""),
|
||||||
"description": t.get("description", ""),
|
"description": t.get("description", ""),
|
||||||
"plugin": t.get("plugin", ""),
|
"plugin": t.get("plugin_name", ""),
|
||||||
}
|
}
|
||||||
for t in tools
|
for t in tools
|
||||||
],
|
],
|
||||||
@@ -651,6 +664,7 @@ async def stream_agent_run(
|
|||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
agent_run_id=aid,
|
agent_run_id=aid,
|
||||||
|
user_permissions=current_user, # F01: enforce allowlist+permission at execution time
|
||||||
),
|
),
|
||||||
media_type="text/event-stream",
|
media_type="text/event-stream",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
@@ -65,7 +65,7 @@ async def run_agent(
|
|||||||
# ── Safety Check 1: Rate Limit ──
|
# ── Safety Check 1: Rate Limit ──
|
||||||
if agent.max_executions_per_hour:
|
if agent.max_executions_per_hour:
|
||||||
async with factory() as db:
|
async with factory() as db:
|
||||||
one_hour_ago = datetime.now(UTC)
|
one_hour_ago = datetime.now(UTC) - timedelta(hours=1)
|
||||||
count_result = await db.execute(
|
count_result = await db.execute(
|
||||||
select(func.count())
|
select(func.count())
|
||||||
.select_from(AgentRun)
|
.select_from(AgentRun)
|
||||||
@@ -113,7 +113,7 @@ async def run_agent(
|
|||||||
mail_contract = get_contract("mail")
|
mail_contract = get_contract("mail")
|
||||||
if mail_contract and hasattr(mail_contract, "Mail"):
|
if mail_contract and hasattr(mail_contract, "Mail"):
|
||||||
from sqlalchemy import select as _select
|
from sqlalchemy import select as _select
|
||||||
Mail = mail_contract.Mail
|
Mail = mail_contract.Mail # noqa: N806 — class alias
|
||||||
async with factory() as db:
|
async with factory() as db:
|
||||||
mail_q = await db.execute(
|
mail_q = await db.execute(
|
||||||
_select(Mail)
|
_select(Mail)
|
||||||
@@ -238,9 +238,18 @@ async def run_agent(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# ── Enforce data policy: filter sensitive fields from messages (Punkt 4) ──
|
# ── Enforce data policy: filter sensitive fields from messages (Punkt 4) ──
|
||||||
|
# F14 (Astra P1): pass a REAL DB session so provider compliance
|
||||||
|
# (data residency / allowed data classes) is actually loaded —
|
||||||
|
# previously db=None silently skipped the compliance check.
|
||||||
from app.ai.data_policy import enforce_data_policy
|
from app.ai.data_policy import enforce_data_policy
|
||||||
|
from app.core.db import get_session_factory as _dp_factory
|
||||||
|
from app.core.db import set_tenant_context as _dp_set_tenant
|
||||||
|
|
||||||
|
_factory = _dp_factory()
|
||||||
|
async with _factory() as _dp_db:
|
||||||
|
await _dp_set_tenant(_dp_db, agent.tenant_id)
|
||||||
messages = await enforce_data_policy(
|
messages = await enforce_data_policy(
|
||||||
db=None,
|
db=_dp_db,
|
||||||
tenant_id=agent.tenant_id,
|
tenant_id=agent.tenant_id,
|
||||||
messages=messages,
|
messages=messages,
|
||||||
agent_definition=agent,
|
agent_definition=agent,
|
||||||
@@ -260,6 +269,7 @@ async def run_agent(
|
|||||||
timeout_seconds=max_duration,
|
timeout_seconds=max_duration,
|
||||||
require_approval=bool(getattr(agent, "require_approval", False)),
|
require_approval=bool(getattr(agent, "require_approval", False)),
|
||||||
approval_tools=getattr(agent, "approval_tools", None),
|
approval_tools=getattr(agent, "approval_tools", None),
|
||||||
|
user_permissions=perm_ctx.user_permissions, # F01: enforce at execution time
|
||||||
),
|
),
|
||||||
timeout=max_duration + 10, # Extra buffer beyond loop's own timeout
|
timeout=max_duration + 10, # Extra buffer beyond loop's own timeout
|
||||||
)
|
)
|
||||||
@@ -377,7 +387,7 @@ async def run_agent(
|
|||||||
# ── Post agent result to Communication (F-COMM) ──
|
# ── Post agent result to Communication (F-COMM) ──
|
||||||
try:
|
try:
|
||||||
from app.plugins.builtins.contracts import get_contract_registry
|
from app.plugins.builtins.contracts import get_contract_registry
|
||||||
komm = get_contract_registry().get("kommunikation")
|
komm = get_contract_registry().get_contract("kommunikation")
|
||||||
if komm:
|
if komm:
|
||||||
async with factory() as db:
|
async with factory() as db:
|
||||||
# Find or create agent conversation room via contract
|
# Find or create agent conversation room via contract
|
||||||
|
|||||||
@@ -63,6 +63,31 @@ class AutomationContract:
|
|||||||
# ─── agent_comm ───
|
# ─── agent_comm ───
|
||||||
send_agent_message = staticmethod(send_agent_message)
|
send_agent_message = staticmethod(send_agent_message)
|
||||||
|
|
||||||
|
# ─── Workspace Scopes contribution (Phase N4) ───
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def workspace_scopes() -> list[dict]:
|
||||||
|
"""Scope-Dimensionen des agents-Moduls: Agenten-Teilmengen (N4)."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"module_key": "agents",
|
||||||
|
"dimensions": [
|
||||||
|
{
|
||||||
|
"key": "agent_ids",
|
||||||
|
"label": "Agenten",
|
||||||
|
"control": "multiselect",
|
||||||
|
"options": [],
|
||||||
|
"value_source": {
|
||||||
|
"endpoint": "/api/v1/agents",
|
||||||
|
"items_path": "items",
|
||||||
|
"value_key": "id",
|
||||||
|
"label_key": "name",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_function(cls, name: str):
|
def get_function(cls, name: str):
|
||||||
"""Return a callable exposed by this contract, or None if absent."""
|
"""Return a callable exposed by this contract, or None if absent."""
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from app.plugins.manifest import (
|
|||||||
FrontendMenuItem,
|
FrontendMenuItem,
|
||||||
FrontendPageRoute,
|
FrontendPageRoute,
|
||||||
FrontendSettingsPage,
|
FrontendSettingsPage,
|
||||||
|
MiniAppContribution,
|
||||||
PluginManifest,
|
PluginManifest,
|
||||||
PluginRouteDef,
|
PluginRouteDef,
|
||||||
)
|
)
|
||||||
@@ -63,6 +64,25 @@ class AutomationPlugin(BasePlugin):
|
|||||||
"workflow.timeout",
|
"workflow.timeout",
|
||||||
],
|
],
|
||||||
migrations=["0001_initial.sql", "0002_agent_subtasks.sql", "0003_skill_definitions.sql", "0004_run_steps_phase_f.sql"],
|
migrations=["0001_initial.sql", "0002_agent_subtasks.sql", "0003_skill_definitions.sql", "0004_run_steps_phase_f.sql"],
|
||||||
|
miniapps=[
|
||||||
|
MiniAppContribution(
|
||||||
|
app_id="automation_status",
|
||||||
|
name="Automationen",
|
||||||
|
icon="Workflow",
|
||||||
|
description="Aktive und inaktive Automations-Definitionen auf einen Blick.",
|
||||||
|
permission="automation:read",
|
||||||
|
settings_schema={
|
||||||
|
"fields": [
|
||||||
|
{"name": "max_items", "label": "Max. Einträge", "type": "number", "default": 6},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
col_span=2,
|
||||||
|
row_span=1,
|
||||||
|
hosts=["chat", "dashboard", "window"],
|
||||||
|
component="@/components/dashboard/AutomationStatusWidget",
|
||||||
|
order=100,
|
||||||
|
),
|
||||||
|
],
|
||||||
permissions=[
|
permissions=[
|
||||||
"automation:read",
|
"automation:read",
|
||||||
"automation:write",
|
"automation:write",
|
||||||
@@ -82,7 +102,7 @@ class AutomationPlugin(BasePlugin):
|
|||||||
path="/workflows",
|
path="/workflows",
|
||||||
icon="Workflow",
|
icon="Workflow",
|
||||||
order=52,
|
order=52,
|
||||||
permission="automation:read",
|
permission="workflows:read",
|
||||||
),
|
),
|
||||||
FrontendMenuItem(
|
FrontendMenuItem(
|
||||||
label_key="nav.importExport",
|
label_key="nav.importExport",
|
||||||
@@ -92,6 +112,15 @@ class AutomationPlugin(BasePlugin):
|
|||||||
order=53,
|
order=53,
|
||||||
permission="import_export:read",
|
permission="import_export:read",
|
||||||
),
|
),
|
||||||
|
# UI-Backlog Modul 7: skills menu item (Phase Q pattern)
|
||||||
|
FrontendMenuItem(
|
||||||
|
label_key="nav.skills",
|
||||||
|
label="Skills",
|
||||||
|
path="/skills",
|
||||||
|
icon="Sparkles",
|
||||||
|
order=54,
|
||||||
|
permission="automation:read",
|
||||||
|
),
|
||||||
FrontendMenuItem(
|
FrontendMenuItem(
|
||||||
label_key="nav.dedupMerge",
|
label_key="nav.dedupMerge",
|
||||||
label="Duplikate",
|
label="Duplikate",
|
||||||
@@ -117,24 +146,15 @@ class AutomationPlugin(BasePlugin):
|
|||||||
permission="contacts:read",
|
permission="contacts:read",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
# Phase Q1: /agents and /automation are served by the static
|
||||||
|
# StartLayout hub trees (sub-navigation). Flat manifest entries for
|
||||||
|
# them were dead duplicates (never matched) and were removed.
|
||||||
page_routes=[
|
page_routes=[
|
||||||
FrontendPageRoute(
|
|
||||||
path="/automation",
|
|
||||||
component="@/pages/AutomationDashboard",
|
|
||||||
order=50,
|
|
||||||
permission="automation:read",
|
|
||||||
),
|
|
||||||
FrontendPageRoute(
|
|
||||||
path="/agents",
|
|
||||||
component="@/pages/AgentDashboard",
|
|
||||||
order=51,
|
|
||||||
permission="agents:read",
|
|
||||||
),
|
|
||||||
FrontendPageRoute(
|
FrontendPageRoute(
|
||||||
path="/workflows",
|
path="/workflows",
|
||||||
component="@/pages/Workflows",
|
component="@/pages/Workflows",
|
||||||
order=52,
|
order=52,
|
||||||
permission="automation:read",
|
permission="workflows:read",
|
||||||
),
|
),
|
||||||
FrontendPageRoute(
|
FrontendPageRoute(
|
||||||
path="/import-export",
|
path="/import-export",
|
||||||
@@ -142,6 +162,14 @@ class AutomationPlugin(BasePlugin):
|
|||||||
order=53,
|
order=53,
|
||||||
permission="import_export:read",
|
permission="import_export:read",
|
||||||
),
|
),
|
||||||
|
# UI-Backlog Modul 7 (2026-09-13): skills definitions page,
|
||||||
|
# registered via the manifest (Phase Q pattern).
|
||||||
|
FrontendPageRoute(
|
||||||
|
path="/skills",
|
||||||
|
component="@/pages/Skills",
|
||||||
|
order=54,
|
||||||
|
permission="automation:read",
|
||||||
|
),
|
||||||
],
|
],
|
||||||
settings_pages=[
|
settings_pages=[
|
||||||
FrontendSettingsPage(
|
FrontendSettingsPage(
|
||||||
@@ -224,22 +252,10 @@ class AutomationPlugin(BasePlugin):
|
|||||||
self._register_workflow_agent_tools()
|
self._register_workflow_agent_tools()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to register workflow agent tools")
|
logger.exception("Failed to register workflow agent tools")
|
||||||
# Register MiniApps from manifest
|
# NOTE: Manifest MiniApps are registered by super().on_activate()
|
||||||
try:
|
# (BasePlugin, Phase M1) WITH all fields (permission, component,
|
||||||
from app.plugins.builtins.kommunikation.contracts import get_miniapp_registry
|
# settings_schema). The legacy re-registration here dropped those
|
||||||
registry = get_miniapp_registry()
|
# fields and overwrote the correct entries — removed (M5 fix).
|
||||||
for miniapp in self.manifest.miniapps:
|
|
||||||
registry.register(
|
|
||||||
app_id=miniapp.app_id,
|
|
||||||
name=miniapp.name,
|
|
||||||
icon=miniapp.icon,
|
|
||||||
description=miniapp.description,
|
|
||||||
plugin_name=self.manifest.name,
|
|
||||||
render_schema=miniapp.render_schema,
|
|
||||||
)
|
|
||||||
logger.info("Registered MiniApp '%s' from manifest", miniapp.app_id)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to register MiniApps from manifest")
|
|
||||||
# Register own cron jobs from manifest
|
# Register own cron jobs from manifest
|
||||||
try:
|
try:
|
||||||
await self.register_plugin_contributions(db, self.manifest.name, self.manifest)
|
await self.register_plugin_contributions(db, self.manifest.name, self.manifest)
|
||||||
|
|||||||
@@ -47,6 +47,41 @@ class CalendarContract:
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ─── Workspace Scopes contribution (Phase N1, #359 pattern) ───
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def workspace_scopes() -> list[dict]:
|
||||||
|
"""Scope-Dimensionen des calendar-Moduls für den Workspace-Editor (N1)."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"module_key": "calendar",
|
||||||
|
"dimensions": [
|
||||||
|
{
|
||||||
|
"key": "calendar_ids",
|
||||||
|
"label": "Kalender",
|
||||||
|
"control": "multiselect",
|
||||||
|
"value_source": {
|
||||||
|
"endpoint": "/api/v1/calendars",
|
||||||
|
"items_path": "",
|
||||||
|
"value_key": "id",
|
||||||
|
"label_key": "name",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "default_view",
|
||||||
|
"label": "Standard-Ansicht",
|
||||||
|
"control": "select",
|
||||||
|
"options": [
|
||||||
|
{"value": "day", "label": "Tag"},
|
||||||
|
{"value": "week", "label": "Woche"},
|
||||||
|
{"value": "month", "label": "Monat"},
|
||||||
|
{"value": "range", "label": "Zeitraum"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_function(cls, name: str):
|
def get_function(cls, name: str):
|
||||||
"""Return a callable exposed by this contract, or None if absent."""
|
"""Return a callable exposed by this contract, or None if absent."""
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ class CalendarPlugin(BasePlugin):
|
|||||||
],
|
],
|
||||||
page_routes=[
|
page_routes=[
|
||||||
FrontendPageRoute(path='/calendar', component='@/pages/Calendar', protected=True, permission='calendar:read'),
|
FrontendPageRoute(path='/calendar', component='@/pages/Calendar', protected=True, permission='calendar:read'),
|
||||||
|
# Q1: kanban view was static-only before - now manifest-declared.
|
||||||
|
FrontendPageRoute(path='/calendar/kanban', component='@/pages/CalendarKanban', protected=True, permission='calendar:read'),
|
||||||
],
|
],
|
||||||
# BUG (ghost component): ContactCalendarTab does not exist in the
|
# BUG (ghost component): ContactCalendarTab does not exist in the
|
||||||
# frontend — tab removed until implemented (Block I-D).
|
# frontend — tab removed until implemented (Block I-D).
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.core.audit import log_audit
|
from app.core.audit import log_audit
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.deps import get_current_user, require_admin, require_permission
|
from app.deps import get_current_user, require_admin, require_permission, require_workspace_scope
|
||||||
from app.plugins.builtins.calendar.ics_utils import (
|
from app.plugins.builtins.calendar.ics_utils import (
|
||||||
export_entries_to_ics,
|
export_entries_to_ics,
|
||||||
ics_events_to_entry_data,
|
ics_events_to_entry_data,
|
||||||
@@ -168,8 +168,13 @@ async def _check_write_permission(
|
|||||||
async def list_calendars(
|
async def list_calendars(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
|
workspace_scope: dict | None = Depends(require_workspace_scope("calendar")),
|
||||||
):
|
):
|
||||||
"""AC1: GET /api/v1/calendars → 200 + calendar list."""
|
"""AC1: GET /api/v1/calendars → 200 + calendar list.
|
||||||
|
|
||||||
|
Phase N3: applies the active workspace scope (X-Workspace-ID) as a pure
|
||||||
|
AND-restriction (calendar subsets) — never a grant.
|
||||||
|
"""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Calendar).where(
|
select(Calendar).where(
|
||||||
@@ -178,6 +183,13 @@ async def list_calendars(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
cals = result.scalars().all()
|
cals = result.scalars().all()
|
||||||
|
# Phase N3: workspace scope — calendar picker restriction
|
||||||
|
if workspace_scope:
|
||||||
|
from app.services.workspace_scope_service import scope_uuid_set
|
||||||
|
|
||||||
|
calendar_scope = scope_uuid_set(workspace_scope.get("calendar_ids"))
|
||||||
|
if calendar_scope is not None:
|
||||||
|
cals = [c for c in cals if c.id in calendar_scope]
|
||||||
return [_calendar_to_dict(c) for c in cals]
|
return [_calendar_to_dict(c) for c in cals]
|
||||||
|
|
||||||
|
|
||||||
@@ -359,8 +371,13 @@ async def list_entries(
|
|||||||
end: str | None = None,
|
end: str | None = None,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
|
workspace_scope: dict | None = Depends(require_workspace_scope("calendar")),
|
||||||
):
|
):
|
||||||
"""AC7: GET /api/v1/calendar/entries?start=...&end=... → 200 + entries in range."""
|
"""AC7: GET /api/v1/calendar/entries?start=...&end=... → 200 + entries in range.
|
||||||
|
|
||||||
|
Phase N3: applies the active workspace scope (X-Workspace-ID) as a pure
|
||||||
|
AND-restriction (calendar subsets) — never a grant.
|
||||||
|
"""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
user_id = uuid.UUID(current_user["user_id"])
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
role = current_user.get("role", "viewer")
|
role = current_user.get("role", "viewer")
|
||||||
@@ -370,6 +387,14 @@ async def list_entries(
|
|||||||
CalendarEntry.deleted_at.is_(None),
|
CalendarEntry.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Phase N3: workspace scope — calendar subsets, pure AND
|
||||||
|
if workspace_scope:
|
||||||
|
from app.services.workspace_scope_service import scope_uuid_set
|
||||||
|
|
||||||
|
calendar_scope = scope_uuid_set(workspace_scope.get("calendar_ids"))
|
||||||
|
if calendar_scope is not None:
|
||||||
|
query = query.where(CalendarEntry.calendar_id.in_(calendar_scope))
|
||||||
|
|
||||||
# Filter private entries: only owner + admin can see
|
# Filter private entries: only owner + admin can see
|
||||||
if role != "admin":
|
if role != "admin":
|
||||||
query = query.where(
|
query = query.where(
|
||||||
|
|||||||
@@ -197,8 +197,17 @@ class ContactsContract:
|
|||||||
entity_type: str,
|
entity_type: str,
|
||||||
user_id: Any = None,
|
user_id: Any = None,
|
||||||
is_system_admin: bool = False,
|
is_system_admin: bool = False,
|
||||||
|
contact_type: str | None = None,
|
||||||
|
search: str | None = None,
|
||||||
) -> tuple[list[str], list[dict[str, Any]]]:
|
) -> tuple[list[str], list[dict[str, Any]]]:
|
||||||
"""Fetch export rows (headers + row dicts), visibility-filtered."""
|
"""Fetch export rows (headers + row dicts), visibility-filtered.
|
||||||
|
|
||||||
|
Optional filters mirror the former export_service.py semantics:
|
||||||
|
- contact_type: 'company' or 'person' (None = both)
|
||||||
|
- search: FTS full-text search via contacts.search_tsv
|
||||||
|
"""
|
||||||
|
from app.core.sensitive_data import get_sensitive_fields
|
||||||
|
|
||||||
q = select(Contact).where(
|
q = select(Contact).where(
|
||||||
Contact.tenant_id == tenant_id,
|
Contact.tenant_id == tenant_id,
|
||||||
Contact.deleted_at.is_(None),
|
Contact.deleted_at.is_(None),
|
||||||
@@ -206,47 +215,43 @@ class ContactsContract:
|
|||||||
if entity_type == "companies":
|
if entity_type == "companies":
|
||||||
q = q.where(Contact.type == "company").order_by(Contact.name)
|
q = q.where(Contact.type == "company").order_by(Contact.name)
|
||||||
else:
|
else:
|
||||||
|
if contact_type:
|
||||||
|
q = q.where(Contact.type == contact_type)
|
||||||
q = q.order_by(Contact.surname, Contact.firstname)
|
q = q.order_by(Contact.surname, Contact.firstname)
|
||||||
|
if search:
|
||||||
|
q = q.where(Contact.search_tsv.op("@@")(func.plainto_tsquery("german", search)))
|
||||||
if user_id:
|
if user_id:
|
||||||
q = await apply_visibility_filter(
|
q = await apply_visibility_filter(
|
||||||
db, q, "contact", Contact, user_id, tenant_id, is_system_admin
|
db, q, "contact", Contact, user_id, tenant_id, is_system_admin
|
||||||
)
|
)
|
||||||
records = (await db.execute(q)).scalars().all()
|
records = (await db.execute(q)).scalars().all()
|
||||||
|
|
||||||
|
# Sensitive-data safety net (core policy) — drop sensitive headers
|
||||||
|
sensitive = get_sensitive_fields("contact")
|
||||||
|
|
||||||
if entity_type == "companies":
|
if entity_type == "companies":
|
||||||
headers = ["id", "type", "name", "email", "phone", "website", "city", "postalcode", "country"]
|
all_headers = ["id", "type", "name", "email", "phone", "website", "city", "postalcode", "country"]
|
||||||
|
export_headers = [h for h in all_headers if h not in sensitive]
|
||||||
rows = [
|
rows = [
|
||||||
{
|
{h: (getattr(c, h, None) or "") for h in export_headers}
|
||||||
"id": str(c.id),
|
|
||||||
"type": c.type or "company",
|
|
||||||
"name": c.name or "",
|
|
||||||
"email": c.email_1 or "",
|
|
||||||
"phone": c.phone_1 or "",
|
|
||||||
"website": c.website or "",
|
|
||||||
"city": c.mailing_city or "",
|
|
||||||
"postalcode": c.mailing_postalcode or "",
|
|
||||||
"country": c.mailing_country or "",
|
|
||||||
}
|
|
||||||
for c in records
|
for c in records
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
headers = ["id", "type", "firstname", "surname", "name", "email", "phone", "mobile", "city", "postalcode", "country"]
|
# Original export_service.py profile (test_performance.py contract):
|
||||||
|
# 17 columns incl. displayname, code, email_1/email_2, phone_1/phone_2,
|
||||||
|
# website, mailing_*, vat_code, tags — NOT the import profile.
|
||||||
|
all_headers = [
|
||||||
|
"id", "type", "displayname", "name", "firstname", "surname", "code",
|
||||||
|
"email_1", "email_2", "phone_1", "phone_2", "website",
|
||||||
|
"mailing_city", "mailing_postalcode", "mailing_country",
|
||||||
|
"vat_code", "tags",
|
||||||
|
]
|
||||||
|
export_headers = [h for h in all_headers if h not in sensitive]
|
||||||
rows = [
|
rows = [
|
||||||
{
|
{h: (getattr(c, h, None) or "") for h in export_headers}
|
||||||
"id": str(c.id),
|
|
||||||
"type": c.type or "person",
|
|
||||||
"firstname": c.firstname or "",
|
|
||||||
"surname": c.surname or "",
|
|
||||||
"name": c.name or "",
|
|
||||||
"email": c.email_1 or "",
|
|
||||||
"phone": c.phone_1 or "",
|
|
||||||
"mobile": c.phone_2 or "",
|
|
||||||
"city": c.mailing_city or "",
|
|
||||||
"postalcode": c.mailing_postalcode or "",
|
|
||||||
"country": c.mailing_country or "",
|
|
||||||
}
|
|
||||||
for c in records
|
for c in records
|
||||||
]
|
]
|
||||||
return headers, rows
|
return export_headers, rows
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def ie_persist_row(
|
async def ie_persist_row(
|
||||||
@@ -303,6 +308,94 @@ class ContactsContract:
|
|||||||
)
|
)
|
||||||
return _serialize_contact(contact)
|
return _serialize_contact(contact)
|
||||||
|
|
||||||
|
# ─── Document Generator contribution (Phase L1, #359 pattern) ───
|
||||||
|
# The documents generator resolves placeholders + entity data via these
|
||||||
|
# contract hooks. Same philosophy as importexport_entities(): the module
|
||||||
|
# owns its domain data, the generic renderer stays module-agnostic.
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def document_entity_types() -> list[str]:
|
||||||
|
"""Entity types this plugin serves in the documents generator."""
|
||||||
|
return ["contact", "company", "person"]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def document_placeholders(entity_type: str) -> list[dict]:
|
||||||
|
"""Placeholder descriptors (key/label/example) for the drag/drop editor."""
|
||||||
|
return _placeholders_for(entity_type)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def document_data(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: Any,
|
||||||
|
entity_id: Any,
|
||||||
|
entity_type: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Load one entity as template data ({} when not found)."""
|
||||||
|
contact = (
|
||||||
|
await db.execute(
|
||||||
|
select(Contact).where(
|
||||||
|
Contact.id == entity_id,
|
||||||
|
Contact.tenant_id == tenant_id,
|
||||||
|
Contact.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if contact is None:
|
||||||
|
return {}
|
||||||
|
fields = _contacts_document_fields()
|
||||||
|
data: dict[str, Any] = {}
|
||||||
|
for key in fields:
|
||||||
|
value = getattr(contact, key, None)
|
||||||
|
data[key] = value if value is not None else ""
|
||||||
|
return data
|
||||||
|
|
||||||
|
# ─── Workspace Scopes contribution (Phase N1, #359 pattern) ───
|
||||||
|
# Declares the scope dimensions the contacts module supports; the N2
|
||||||
|
# workspace editor renders its filter UI from these definitions. Scope
|
||||||
|
# VALUES live per workspace in workspace_modules.config (JSONB).
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def workspace_scopes() -> list[dict]:
|
||||||
|
"""Scope-Dimensionen des contacts-Moduls für den Workspace-Editor."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"module_key": "contacts",
|
||||||
|
"dimensions": [
|
||||||
|
{
|
||||||
|
"key": "folder_ids",
|
||||||
|
"label": "Kontakt-Ordner",
|
||||||
|
"control": "multiselect",
|
||||||
|
"value_source": {
|
||||||
|
"endpoint": "/api/v1/contact-folders",
|
||||||
|
"items_path": "items",
|
||||||
|
"value_key": "id",
|
||||||
|
"label_key": "name",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "contact_types",
|
||||||
|
"label": "Kontakt-Typen",
|
||||||
|
"control": "multiselect",
|
||||||
|
"options": [
|
||||||
|
{"value": "company", "label": "Firmen"},
|
||||||
|
{"value": "person", "label": "Personen"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "default_saved_view_id",
|
||||||
|
"label": "Standard-Ansicht",
|
||||||
|
"control": "select",
|
||||||
|
"value_source": {
|
||||||
|
"endpoint": "/api/v1/saved-views?entity_type=contact",
|
||||||
|
"items_path": "",
|
||||||
|
"value_key": "id",
|
||||||
|
"label_key": "name",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_function(cls, name: str):
|
def get_function(cls, name: str):
|
||||||
"""Return a callable exposed by this contract, or None if absent."""
|
"""Return a callable exposed by this contract, or None if absent."""
|
||||||
@@ -313,3 +406,70 @@ class ContactsContract:
|
|||||||
|
|
||||||
_contract = ContactsContract()
|
_contract = ContactsContract()
|
||||||
get_contract_registry().register("contacts", _contract)
|
get_contract_registry().register("contacts", _contract)
|
||||||
|
|
||||||
|
|
||||||
|
def _contacts_document_fields() -> dict[str, str]:
|
||||||
|
"""Contact/company fields available in document templates (L1).
|
||||||
|
|
||||||
|
Keys map to Contact model attributes; labels/examples feed the
|
||||||
|
drag/drop editor palette and the preview fallback values.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"displayname": "Anzeigename",
|
||||||
|
"firstname": "Vorname",
|
||||||
|
"surname": "Nachname",
|
||||||
|
"name": "Firmenname",
|
||||||
|
"email": "E-Mail",
|
||||||
|
"email_1": "E-Mail 1",
|
||||||
|
"email_2": "E-Mail 2",
|
||||||
|
"phone": "Telefon",
|
||||||
|
"phone_1": "Telefon 1",
|
||||||
|
"phone_2": "Telefon 2",
|
||||||
|
"mobile": "Mobil",
|
||||||
|
"website": "Website",
|
||||||
|
"industry": "Branche",
|
||||||
|
"city": "Stadt",
|
||||||
|
"postalcode": "PLZ",
|
||||||
|
"country": "Land",
|
||||||
|
"vat_code": "USt-IdNr.",
|
||||||
|
"function": "Funktion",
|
||||||
|
"department": "Abteilung",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_CONTACT_DOC_EXAMPLES = {
|
||||||
|
"displayname": "Max Mustermann",
|
||||||
|
"firstname": "Max",
|
||||||
|
"surname": "Mustermann",
|
||||||
|
"name": "Muster GmbH",
|
||||||
|
"email": "max@example.com",
|
||||||
|
"email_1": "max@example.com",
|
||||||
|
"email_2": "buero@example.com",
|
||||||
|
"phone": "+49 30 123456",
|
||||||
|
"phone_1": "+49 30 123456",
|
||||||
|
"phone_2": "+49 171 1234567",
|
||||||
|
"mobile": "+49 171 1234567",
|
||||||
|
"website": "https://example.com",
|
||||||
|
"industry": "IT",
|
||||||
|
"city": "Berlin",
|
||||||
|
"postalcode": "10115",
|
||||||
|
"country": "Deutschland",
|
||||||
|
"vat_code": "DE123456789",
|
||||||
|
"function": "Geschäftsführer",
|
||||||
|
"department": "Vertrieb",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _placeholders_for(entity_type: str) -> list[dict]:
|
||||||
|
"""Placeholder descriptors for contact/company templates."""
|
||||||
|
if entity_type not in ("contact", "company", "person"):
|
||||||
|
return []
|
||||||
|
fields = _contacts_document_fields()
|
||||||
|
result = []
|
||||||
|
for key, label in fields.items():
|
||||||
|
result.append({
|
||||||
|
"key": key,
|
||||||
|
"label": label,
|
||||||
|
"example": _CONTACT_DOC_EXAMPLES.get(key, "…"),
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""ARQ background jobs for the contacts plugin.
|
||||||
|
|
||||||
|
Registered via ``register_job()`` at import time; the worker discovers this
|
||||||
|
module through ``ContactsPlugin.get_job_modules()`` — the core worker must
|
||||||
|
not import contact models directly (audit P2: hidden core->contacts
|
||||||
|
coupling in the trash cleanup).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import delete as sa_delete
|
||||||
|
from sqlalchemy import text as sa_text
|
||||||
|
|
||||||
|
from app.core.job_registry import register_job
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_TRASH_RETENTION_DAYS = 90
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup_contacts_trash_job(ctx: dict[str, Any]) -> None:
|
||||||
|
"""Permanently delete soft-deleted contacts older than the retention window.
|
||||||
|
|
||||||
|
Runs daily. Iterates per-tenant for RLS compliance.
|
||||||
|
Moved from app.core.worker.cleanup_trash_job (audit P2) so the core
|
||||||
|
worker only handles core-owned entities (entity_attachments).
|
||||||
|
"""
|
||||||
|
from app.core.db import get_worker_session_factory
|
||||||
|
from app.models.contact import Contact
|
||||||
|
|
||||||
|
factory = get_worker_session_factory()
|
||||||
|
async with factory() as db:
|
||||||
|
try:
|
||||||
|
tenant_result = await db.execute(sa_text("SELECT id FROM tenants"))
|
||||||
|
tenant_ids = [row[0] for row in tenant_result]
|
||||||
|
|
||||||
|
cutoff = datetime.now(UTC) - timedelta(days=_TRASH_RETENTION_DAYS)
|
||||||
|
total_deleted = 0
|
||||||
|
for tenant_id in tenant_ids:
|
||||||
|
await db.execute(
|
||||||
|
sa_text("SELECT set_config('app.current_tenant_id', :tid, true)"),
|
||||||
|
{"tid": str(tenant_id)},
|
||||||
|
)
|
||||||
|
result = await db.execute(
|
||||||
|
sa_delete(Contact).where(
|
||||||
|
Contact.deleted_at.is_not(None),
|
||||||
|
Contact.deleted_at < cutoff,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
total_deleted += result.rowcount
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
if total_deleted:
|
||||||
|
logger.info("Contacts trash cleanup: permanently deleted %d old contacts", total_deleted)
|
||||||
|
except Exception:
|
||||||
|
logger.error("Contacts trash cleanup failed", exc_info=True)
|
||||||
|
await db.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
register_job("cleanup_contacts_trash", cleanup_contacts_trash_job)
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
"""Unified Contact model - company or person, with inline addresses.
|
||||||
|
|
||||||
|
Plugin-owned since Paket 6 (#357): this module is the physical home of the
|
||||||
|
Contact/ContactPerson ORM models. app/models/contact.py re-exports them
|
||||||
|
for backwards compatibility (Alembic env.py, Core services, tests).
|
||||||
|
|
||||||
|
Based on Rentman's contact model: a single table with type field
|
||||||
|
('company' or 'person'). ContactPerson is a 1:N child for
|
||||||
|
ansprechpartner (company employees / contact persons).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Computed,
|
||||||
|
DateTime,
|
||||||
|
Float,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Numeric,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.db import Base, TenantMixin
|
||||||
|
from app.models.owned_mixin import OwnedMixin
|
||||||
|
|
||||||
|
|
||||||
|
class Contact(Base, TenantMixin, OwnedMixin):
|
||||||
|
"""Unified contact entity — can be a company or a person.
|
||||||
|
|
||||||
|
type='company': name is the company name, firstname/surname empty.
|
||||||
|
type='person': firstname/surname are the person's name, name empty.
|
||||||
|
Both types can have contactpersons (1:N) and inline addresses
|
||||||
|
(mailing, visit, invoice).
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "contacts"
|
||||||
|
indexed_at: Mapped[Any] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "code", name="uq_contacts_tenant_code"),
|
||||||
|
UniqueConstraint("tenant_id", "accounting_code", name="uq_contacts_tenant_accounting_code"),
|
||||||
|
Index("ix_contacts_tenant_deleted", "tenant_id", "deleted_at"),
|
||||||
|
Index("ix_contacts_tenant_type", "tenant_id", "type"),
|
||||||
|
Index("ix_contacts_tenant_name", "tenant_id", "name"),
|
||||||
|
Index("ix_contacts_tenant_displayname", "tenant_id", "displayname"),
|
||||||
|
Index("ix_contacts_email", "email_1"),
|
||||||
|
Index("ix_contacts_code", "code"),
|
||||||
|
Index("ix_contacts_search_vec", "search_tsv", postgresql_using="gin"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Identity & Type ──
|
||||||
|
type: Mapped[str] = mapped_column(String(20), nullable=False, default="company") # 'company' or 'person'
|
||||||
|
displayname: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||||
|
|
||||||
|
# ── Lifecycle Status (state machine: lead → qualified → customer → inactive) ──
|
||||||
|
status: Mapped[str] = mapped_column(String(30), nullable=False, default="lead", index=True)
|
||||||
|
name: Mapped[str | None] = mapped_column(String(255), nullable=True) # company name
|
||||||
|
firstname: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
surname: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
suffix: Mapped[str | None] = mapped_column(String(50), nullable=True) # name prefix (Dr., Prof.)
|
||||||
|
ext_name_line: Mapped[str | None] = mapped_column(String(255), nullable=True) # additional name line / subtitle
|
||||||
|
gender: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||||
|
|
||||||
|
# ── Customer / Accounting ──
|
||||||
|
code: Mapped[str | None] = mapped_column(String(100), nullable=True) # customer number
|
||||||
|
accounting_code: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
vendor_accounting_code: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
|
||||||
|
# ── Mailing Address (inline) ──
|
||||||
|
mailing_street: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
mailing_number: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||||
|
mailing_unit_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
mailing_district: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
mailing_extra_address_line: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
mailing_postalcode: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||||
|
mailing_city: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
mailing_state: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
mailing_country: Mapped[str | None] = mapped_column(String(2), nullable=True)
|
||||||
|
|
||||||
|
# ── Visit Address (inline) ──
|
||||||
|
visit_street: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
visit_number: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||||
|
visit_unit_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
visit_district: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
visit_extra_address_line: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
visit_postalcode: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||||
|
visit_city: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
visit_state: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
|
||||||
|
# ── Invoice Address (inline) ──
|
||||||
|
invoice_street: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
invoice_number: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||||
|
invoice_unit_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
invoice_district: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
invoice_extra_address_line: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
invoice_postalcode: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||||
|
invoice_city: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
invoice_state: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
invoice_country: Mapped[str | None] = mapped_column(String(2), nullable=True)
|
||||||
|
|
||||||
|
# ── General country ──
|
||||||
|
country: Mapped[str | None] = mapped_column(String(2), nullable=True)
|
||||||
|
|
||||||
|
# ── Communication ──
|
||||||
|
phone_1: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
phone_2: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
email_1: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
email_2: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
website: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
|
||||||
|
# ── Financial & Tax ──
|
||||||
|
vat_code: Mapped[str | None] = mapped_column(String(50), nullable=True) # USt-IdNr.
|
||||||
|
fiscal_code: Mapped[str | None] = mapped_column(String(50), nullable=True) # Steuernummer
|
||||||
|
commerce_code: Mapped[str | None] = mapped_column(String(100), nullable=True) # Handelsregister
|
||||||
|
purchase_number: Mapped[str | None] = mapped_column(String(100), nullable=True) # Bestellnummer
|
||||||
|
bic: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
bank_account: Mapped[str | None] = mapped_column(String(50), nullable=True) # IBAN
|
||||||
|
|
||||||
|
# ── Discounts ──
|
||||||
|
discount_crew: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
|
||||||
|
discount_transport: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
|
||||||
|
discount_rental: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
|
||||||
|
discount_sale: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
|
||||||
|
discount_subrent: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
|
||||||
|
discount_total: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
|
||||||
|
|
||||||
|
# ── Geo ──
|
||||||
|
latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
|
||||||
|
# ── Notes & Warnings ──
|
||||||
|
projectnote: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
projectnote_title: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
contact_warning: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
tags: Mapped[str | None] = mapped_column(String(500), nullable=True) # comma-separated
|
||||||
|
image: Mapped[str | None] = mapped_column(Text, nullable=True) # logo/image URL or base64
|
||||||
|
|
||||||
|
# ── Default contact persons (self-referential via contactpersons table) ──
|
||||||
|
default_person_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), ForeignKey("contactpersons.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
admin_contactperson_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), ForeignKey("contactpersons.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Folder assignment ──
|
||||||
|
folder_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True),
|
||||||
|
ForeignKey("contact_folders.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Custom fields ──
|
||||||
|
custom: Mapped[dict | None] = mapped_column(JSONB, nullable=True, default=dict)
|
||||||
|
|
||||||
|
# ── FTS ──
|
||||||
|
search_tsv: Mapped[Any] = mapped_column(
|
||||||
|
TSVECTOR,
|
||||||
|
Computed(
|
||||||
|
"to_tsvector('german', coalesce(name, '') || ' ' || coalesce(displayname, '') || ' ' || coalesce(firstname, '') || ' ' || coalesce(surname, '') || ' ' || coalesce(email_1, '') || ' ' || coalesce(email_2, '') || ' ' || coalesce(code, '') || ' ' || coalesce(phone_1, '') || ' ' || coalesce(phone_2, '') || ' ' || coalesce(mailing_city, '') || ' ' || coalesce(mailing_postalcode, '') || ' ' || coalesce(tags, ''))",
|
||||||
|
persisted=True,
|
||||||
|
),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Embedding (pgvector, 768-dim) ──
|
||||||
|
from pgvector.sqlalchemy import Vector
|
||||||
|
embedding: Mapped[Any | None] = mapped_column(
|
||||||
|
Vector(768), nullable=True, default=None
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Audit ──
|
||||||
|
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
updated_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Relationships ──
|
||||||
|
contact_persons: Mapped[list[ContactPerson]] = relationship(
|
||||||
|
back_populates="contact", cascade="all, delete-orphan", foreign_keys="ContactPerson.contact_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ContactPerson(Base, TenantMixin, OwnedMixin):
|
||||||
|
"""Ansprechpartner — 1:N child of a Contact.
|
||||||
|
|
||||||
|
Represents a person working at / associated with a company contact.
|
||||||
|
Has its own address and communication fields.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "contactpersons"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_contactpersons_tenant_deleted", "tenant_id", "deleted_at"),
|
||||||
|
Index("ix_contactpersons_contact", "contact_id"),
|
||||||
|
Index("ix_contactpersons_email", "email"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Parent contact ──
|
||||||
|
contact_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), ForeignKey("contacts.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Name ──
|
||||||
|
displayname: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||||
|
firstname: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
middle_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
lastname: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
function: Mapped[str | None] = mapped_column(String(255), nullable=True) # position/role
|
||||||
|
|
||||||
|
# ── Communication ──
|
||||||
|
phone: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
mobilephone: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
|
||||||
|
# ── Own address ──
|
||||||
|
street: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
number: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||||
|
postalcode: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||||
|
city: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
state: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
country: Mapped[str | None] = mapped_column(String(2), nullable=True)
|
||||||
|
|
||||||
|
# ── Other ──
|
||||||
|
tags: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
custom: Mapped[dict | None] = mapped_column(JSONB, nullable=True, default=dict)
|
||||||
|
|
||||||
|
# ── Audit ──
|
||||||
|
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
updated_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Relationship ──
|
||||||
|
contact: Mapped[Contact] = relationship(
|
||||||
|
back_populates="contact_persons", foreign_keys=[contact_id]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Keep old names for backward compat during migration
|
||||||
|
|
||||||
@@ -10,7 +10,11 @@ import logging
|
|||||||
|
|
||||||
from app.plugins.base import BasePlugin
|
from app.plugins.base import BasePlugin
|
||||||
from app.plugins.manifest import (
|
from app.plugins.manifest import (
|
||||||
|
FieldDefinition,
|
||||||
FrontendDashboardWidget,
|
FrontendDashboardWidget,
|
||||||
|
FrontendMenuItem,
|
||||||
|
FrontendPageRoute,
|
||||||
|
MiniAppContribution,
|
||||||
PluginManifest,
|
PluginManifest,
|
||||||
PluginRouteDef,
|
PluginRouteDef,
|
||||||
)
|
)
|
||||||
@@ -58,6 +62,26 @@ class ContactsPlugin(BasePlugin):
|
|||||||
],
|
],
|
||||||
events=[],
|
events=[],
|
||||||
migrations=[],
|
migrations=[],
|
||||||
|
miniapps=[
|
||||||
|
MiniAppContribution(
|
||||||
|
app_id="contacts_stats",
|
||||||
|
name="Kontakt-Zähler",
|
||||||
|
icon="Building2",
|
||||||
|
description="Firmen- und Kontakt-Zähler (persönliche StatCards).",
|
||||||
|
permission="contacts:read",
|
||||||
|
settings_schema={
|
||||||
|
"fields": [
|
||||||
|
{"name": "show_companies", "label": "Firmen anzeigen", "type": "boolean", "default": True},
|
||||||
|
{"name": "show_persons", "label": "Personen anzeigen", "type": "boolean", "default": True},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
col_span=2,
|
||||||
|
row_span=1,
|
||||||
|
hosts=["chat", "dashboard", "window"],
|
||||||
|
component="@/components/dashboard/ContactsStatsWidget",
|
||||||
|
order=5,
|
||||||
|
),
|
||||||
|
],
|
||||||
dashboard_widgets=[
|
dashboard_widgets=[
|
||||||
FrontendDashboardWidget(
|
FrontendDashboardWidget(
|
||||||
id="recent_contacts",
|
id="recent_contacts",
|
||||||
@@ -70,23 +94,85 @@ class ContactsPlugin(BasePlugin):
|
|||||||
permission="contacts:read",
|
permission="contacts:read",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
menu_items=[
|
||||||
|
FrontendMenuItem(label_key='nav.contacts', label='Kontakte', path='/contacts', icon='Users', order=10, permission='contacts:read'),
|
||||||
|
FrontendMenuItem(label_key='nav.companies', label='Firmen', path='/companies', icon='Building2', order=11, permission='contacts:read'),
|
||||||
|
],
|
||||||
|
page_routes=[
|
||||||
|
FrontendPageRoute(path='/contacts', component='@/pages/ContactsList', protected=True, permission='contacts:read'),
|
||||||
|
FrontendPageRoute(path='/contacts/:id', component='@/pages/ContactDetailPage', protected=True, permission='contacts:read'),
|
||||||
|
FrontendPageRoute(path='/contacts/dedup', component='@/pages/DedupMerge', protected=True, permission='contacts:read'),
|
||||||
|
FrontendPageRoute(path='/companies', component='@/pages/Companies', protected=True, permission='contacts:read'),
|
||||||
|
],
|
||||||
permissions=[
|
permissions=[
|
||||||
"contacts:read",
|
"contacts:read",
|
||||||
"contacts:write",
|
"contacts:write",
|
||||||
"contacts:delete",
|
"contacts:delete",
|
||||||
],
|
],
|
||||||
|
# Audit P1/P2: contact field definitions are plugin-owned (moved
|
||||||
|
# from CORE_FIELD_DEFINITIONS) — registered at activation time via
|
||||||
|
# register_field_definitions() and removed on deactivation.
|
||||||
|
field_definitions=[
|
||||||
|
FieldDefinition(module="contacts", field="firstname", label="First Name", sensitivity="normal"),
|
||||||
|
FieldDefinition(module="contacts", field="surname", label="Last Name", sensitivity="normal"),
|
||||||
|
FieldDefinition(module="contacts", field="displayname", label="Display Name", sensitivity="normal"),
|
||||||
|
FieldDefinition(module="contacts", field="name", label="Name", sensitivity="normal"),
|
||||||
|
FieldDefinition(module="contacts", field="email_1", label="Email 1", sensitivity="normal"),
|
||||||
|
FieldDefinition(module="contacts", field="email_2", label="Email 2", sensitivity="normal"),
|
||||||
|
FieldDefinition(module="contacts", field="phone_1", label="Phone 1", sensitivity="normal"),
|
||||||
|
FieldDefinition(module="contacts", field="phone_2", label="Phone 2", sensitivity="normal"),
|
||||||
|
FieldDefinition(module="contacts", field="mobilephone", label="Mobile", sensitivity="sensitive"),
|
||||||
|
FieldDefinition(module="contacts", field="function", label="Position", sensitivity="normal"),
|
||||||
|
FieldDefinition(module="contacts", field="website", label="Website", sensitivity="normal"),
|
||||||
|
FieldDefinition(module="contacts", field="status", label="Status", sensitivity="normal"),
|
||||||
|
FieldDefinition(module="contacts", field="type", label="Type", sensitivity="normal"),
|
||||||
|
FieldDefinition(module="contacts", field="gender", label="Gender", sensitivity="normal"),
|
||||||
|
FieldDefinition(module="contacts", field="suffix", label="Suffix", sensitivity="normal"),
|
||||||
|
FieldDefinition(module="contacts", field="ext_name_line", label="Extra Name Line", sensitivity="normal"),
|
||||||
|
FieldDefinition(module="contacts", field="country", label="Country", sensitivity="normal"),
|
||||||
|
FieldDefinition(module="contacts", field="code", label="Code", sensitivity="sensitive"),
|
||||||
|
FieldDefinition(module="contacts", field="accounting_code", label="Accounting Code", sensitivity="sensitive"),
|
||||||
|
FieldDefinition(module="contacts", field="vendor_accounting_code", label="Vendor Accounting Code", sensitivity="sensitive"),
|
||||||
|
FieldDefinition(module="contacts", field="vat_code", label="VAT Code", sensitivity="sensitive"),
|
||||||
|
FieldDefinition(module="contacts", field="fiscal_code", label="Fiscal Code", sensitivity="sensitive"),
|
||||||
|
FieldDefinition(module="contacts", field="commerce_code", label="Commerce Code", sensitivity="sensitive"),
|
||||||
|
FieldDefinition(module="contacts", field="purchase_number", label="Purchase Number", sensitivity="sensitive"),
|
||||||
|
FieldDefinition(module="contacts", field="bic", label="BIC", sensitivity="sensitive"),
|
||||||
|
FieldDefinition(module="contacts", field="mailing_street", label="Mailing Street", sensitivity="normal"),
|
||||||
|
FieldDefinition(module="contacts", field="mailing_city", label="Mailing City", sensitivity="normal"),
|
||||||
|
FieldDefinition(module="contacts", field="mailing_postalcode", label="Mailing Postal Code", sensitivity="normal"),
|
||||||
|
FieldDefinition(module="contacts", field="mailing_country", label="Mailing Country", sensitivity="normal"),
|
||||||
|
FieldDefinition(module="contacts", field="visit_street", label="Visit Street", sensitivity="normal"),
|
||||||
|
FieldDefinition(module="contacts", field="visit_city", label="Visit City", sensitivity="normal"),
|
||||||
|
FieldDefinition(module="contacts", field="visit_postalcode", label="Visit Postal Code", sensitivity="normal"),
|
||||||
|
FieldDefinition(module="contacts", field="visit_country", label="Visit Country", sensitivity="normal"),
|
||||||
|
FieldDefinition(module="contacts", field="invoice_street", label="Invoice Street", sensitivity="normal"),
|
||||||
|
FieldDefinition(module="contacts", field="invoice_city", label="Invoice City", sensitivity="normal"),
|
||||||
|
FieldDefinition(module="contacts", field="invoice_postalcode", label="Invoice Postal Code", sensitivity="normal"),
|
||||||
|
FieldDefinition(module="contacts", field="invoice_country", label="Invoice Country", sensitivity="normal"),
|
||||||
|
FieldDefinition(module="contacts", field="notes", label="Notes", sensitivity="sensitive"),
|
||||||
|
FieldDefinition(module="contacts", field="tags", label="Tags", sensitivity="sensitive"),
|
||||||
|
],
|
||||||
is_core=True,
|
is_core=True,
|
||||||
author="LeoCRM Team",
|
author="LeoCRM Team",
|
||||||
min_app_version="1.0.0",
|
min_app_version="1.0.0",
|
||||||
contract_version="1.0.0",
|
contract_version="1.0.0",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_job_modules(self) -> list[str]:
|
||||||
|
"""Worker discovers the contacts trash-cleanup job here (audit P2)."""
|
||||||
|
return ["app.plugins.builtins.contacts.jobs"]
|
||||||
|
|
||||||
def get_entity_models(self) -> dict[str, type]:
|
def get_entity_models(self) -> dict[str, type]:
|
||||||
from app.models.contact import Contact
|
from app.models.contact import Contact
|
||||||
|
from app.models.contact_folder import ContactFolder
|
||||||
return {
|
return {
|
||||||
"contact": Contact,
|
"contact": Contact,
|
||||||
"contacts": Contact,
|
"contacts": Contact,
|
||||||
"company": Contact,
|
"company": Contact,
|
||||||
|
# Audit P2: contact_folder is contacts-plugin-owned domain data
|
||||||
|
# (moved from the static core ENTITY_MODELS map).
|
||||||
|
"contact_folder": ContactFolder,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def on_activate(self, db, service_container, event_bus) -> None:
|
async def on_activate(self, db, service_container, event_bus) -> None:
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from typing import Any
|
|||||||
import redis.asyncio as aioredis
|
import redis.asyncio as aioredis
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.commands.contact_commands import (
|
from app.commands.contact_commands import (
|
||||||
@@ -23,7 +24,10 @@ from app.commands.contact_commands import (
|
|||||||
)
|
)
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.core.visibility import check_single_entity_access
|
from app.core.visibility import check_single_entity_access
|
||||||
from app.deps import get_redis_dep, require_permission
|
from app.deps import get_current_user, get_redis_dep, require_permission, require_workspace_scope
|
||||||
|
from app.models.contact import Contact
|
||||||
|
from app.models.custom_field_definition import CustomFieldDefinition
|
||||||
|
from app.plugins.registry import get_registry
|
||||||
from app.schemas.contact import (
|
from app.schemas.contact import (
|
||||||
ContactCreate,
|
ContactCreate,
|
||||||
ContactPersonCreate,
|
ContactPersonCreate,
|
||||||
@@ -31,7 +35,6 @@ from app.schemas.contact import (
|
|||||||
ContactUpdate,
|
ContactUpdate,
|
||||||
)
|
)
|
||||||
from app.services import contact_service, dedup_service
|
from app.services import contact_service, dedup_service
|
||||||
from app.services.export_service import export_service
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/contacts", tags=["contacts"])
|
router = APIRouter(prefix="/api/v1/contacts", tags=["contacts"])
|
||||||
|
|
||||||
@@ -67,10 +70,13 @@ async def list_contacts(
|
|||||||
cursor: str | None = Query(None, description="Keyset pagination cursor (contact UUID)"),
|
cursor: str | None = Query(None, description="Keyset pagination cursor (contact UUID)"),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(require_permission("contacts:read")),
|
current_user: dict = Depends(require_permission("contacts:read")),
|
||||||
|
workspace_scope: dict | None = Depends(require_workspace_scope("contacts")),
|
||||||
):
|
):
|
||||||
"""List contacts with pagination, FTS search, type/folder filter, sorting.
|
"""List contacts with pagination, FTS search, type/folder filter, sorting.
|
||||||
|
|
||||||
Supports keyset pagination via ``cursor`` parameter for large datasets.
|
Supports keyset pagination via ``cursor`` parameter for large datasets.
|
||||||
|
Phase N3: applies the active workspace scope (X-Workspace-ID) as a pure
|
||||||
|
AND-restriction (folder subtree + contact types) — never a grant.
|
||||||
"""
|
"""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
user_id = uuid.UUID(current_user["user_id"])
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
@@ -84,6 +90,7 @@ async def list_contacts(
|
|||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
is_system_admin=is_admin,
|
is_system_admin=is_admin,
|
||||||
cursor=cursor,
|
cursor=cursor,
|
||||||
|
workspace_scope=workspace_scope,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -95,14 +102,20 @@ async def export_contacts(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(require_permission("contacts:read")),
|
current_user: dict = Depends(require_permission("contacts:read")),
|
||||||
):
|
):
|
||||||
"""Stream contacts as CSV."""
|
"""Stream contacts as CSV (W4c: via ContactsContract, export_service.py removed)."""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
user_id = uuid.UUID(current_user["user_id"])
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
is_admin = current_user.get("is_system_admin", False)
|
is_admin = current_user.get("is_system_admin", False)
|
||||||
csv_data = await export_service.export_contacts_csv(
|
from app.plugins.builtins.contacts.contracts import ContactsContract
|
||||||
db, tenant_id, contact_type=type, search=search,
|
|
||||||
|
headers, rows = await ContactsContract.ie_fetch_rows(
|
||||||
|
db, tenant_id, "contacts",
|
||||||
user_id=user_id, is_system_admin=is_admin,
|
user_id=user_id, is_system_admin=is_admin,
|
||||||
|
contact_type=type, search=search,
|
||||||
)
|
)
|
||||||
|
from app.services.import_export_helpers import write_csv
|
||||||
|
|
||||||
|
csv_data = write_csv(rows, headers).decode("utf-8")
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
io.StringIO(csv_data),
|
io.StringIO(csv_data),
|
||||||
media_type="text/csv",
|
media_type="text/csv",
|
||||||
@@ -316,3 +329,189 @@ async def merge_duplicate_contacts(
|
|||||||
if not result.success:
|
if not result.success:
|
||||||
raise HTTPException(status_code=400, detail=result.error)
|
raise HTTPException(status_code=400, detail=result.error)
|
||||||
return result.data
|
return result.data
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Custom Fields (W4c: migrated from app/routes/custom_fields.py) ─────────
|
||||||
|
|
||||||
|
|
||||||
|
class CustomFieldUpdateRequest(BaseModel):
|
||||||
|
"""Request body for updating custom field values."""
|
||||||
|
|
||||||
|
values: dict[str, Any] = {}
|
||||||
|
|
||||||
|
|
||||||
|
async def _collect_custom_field_definitions(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
entity: str = "contact",
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Collect all custom field definitions from plugin manifests and DB.
|
||||||
|
|
||||||
|
DB-stored definitions override plugin definitions with the same name.
|
||||||
|
"""
|
||||||
|
definitions: list[dict[str, Any]] = []
|
||||||
|
seen_names: set[str] = set()
|
||||||
|
|
||||||
|
# 1. Collect from active plugin manifests
|
||||||
|
registry = get_registry()
|
||||||
|
for name in registry.list_discovered():
|
||||||
|
plugin = registry.get_plugin(name)
|
||||||
|
if plugin is None:
|
||||||
|
continue
|
||||||
|
manifest = plugin.manifest
|
||||||
|
for cf in manifest.custom_fields:
|
||||||
|
if cf.entity != entity:
|
||||||
|
continue
|
||||||
|
if cf.name in seen_names:
|
||||||
|
continue
|
||||||
|
seen_names.add(cf.name)
|
||||||
|
definitions.append(
|
||||||
|
{
|
||||||
|
"name": cf.name,
|
||||||
|
"label": cf.label,
|
||||||
|
"label_key": cf.label_key,
|
||||||
|
"field_type": cf.field_type,
|
||||||
|
"options": cf.options,
|
||||||
|
"default_value": cf.default_value,
|
||||||
|
"required": cf.required,
|
||||||
|
"entity": cf.entity,
|
||||||
|
"plugin": manifest.name,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Collect from DB (user-defined custom field definitions)
|
||||||
|
stmt = select(CustomFieldDefinition).where(
|
||||||
|
CustomFieldDefinition.tenant_id == tenant_id,
|
||||||
|
CustomFieldDefinition.entity == entity,
|
||||||
|
CustomFieldDefinition.is_active == True, # noqa: E712
|
||||||
|
).order_by(CustomFieldDefinition.sort_order, CustomFieldDefinition.name)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
db_definitions = result.scalars().all()
|
||||||
|
|
||||||
|
for d in db_definitions:
|
||||||
|
if d.name in seen_names:
|
||||||
|
# DB definition overrides plugin definition — replace it
|
||||||
|
definitions = [x for x in definitions if x["name"] != d.name]
|
||||||
|
else:
|
||||||
|
seen_names.add(d.name)
|
||||||
|
definitions.append(
|
||||||
|
{
|
||||||
|
"name": d.name,
|
||||||
|
"label": d.label,
|
||||||
|
"label_key": "",
|
||||||
|
"field_type": d.field_type,
|
||||||
|
"options": d.options or [],
|
||||||
|
"default_value": d.default_value,
|
||||||
|
"required": d.required,
|
||||||
|
"entity": d.entity,
|
||||||
|
"plugin": "user_defined",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return definitions
|
||||||
|
|
||||||
|
|
||||||
|
async def _merge_definitions_with_values(
|
||||||
|
definitions: list[dict[str, Any]], stored: dict[str, Any] | None
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Merge field definitions with stored values, applying defaults."""
|
||||||
|
stored = stored or {}
|
||||||
|
result: list[dict[str, Any]] = []
|
||||||
|
for d in definitions:
|
||||||
|
name = d["name"]
|
||||||
|
value = stored.get(name, d.get("default_value"))
|
||||||
|
entry = {**d, "value": value}
|
||||||
|
result.append(entry)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{contact_id}/custom-fields", dependencies=[Depends(require_permission("contacts:read"))])
|
||||||
|
async def get_custom_fields(
|
||||||
|
contact_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Get all custom fields for a contact (merged definitions + stored values)."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
try:
|
||||||
|
cid = uuid.UUID(contact_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise HTTPException(400, detail={"detail": "Invalid contact_id", "code": "invalid_id"}) from None
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(Contact).where(Contact.id == cid, Contact.tenant_id == tenant_id)
|
||||||
|
)
|
||||||
|
contact = result.scalar_one_or_none()
|
||||||
|
if contact is None:
|
||||||
|
raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"})
|
||||||
|
|
||||||
|
definitions = await _collect_custom_field_definitions(db, tenant_id, "contact")
|
||||||
|
merged = await _merge_definitions_with_values(definitions, contact.custom)
|
||||||
|
return {"fields": merged}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{contact_id}/custom-fields", dependencies=[Depends(require_permission("contacts:write"))])
|
||||||
|
async def update_custom_fields(
|
||||||
|
contact_id: str,
|
||||||
|
body: CustomFieldUpdateRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Update custom field values for a contact (stored in contacts.custom JSONB)."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
try:
|
||||||
|
cid = uuid.UUID(contact_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise HTTPException(400, detail={"detail": "Invalid contact_id", "code": "invalid_id"}) from None
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(Contact).where(Contact.id == cid, Contact.tenant_id == tenant_id)
|
||||||
|
)
|
||||||
|
contact = result.scalar_one_or_none()
|
||||||
|
if contact is None:
|
||||||
|
raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"})
|
||||||
|
|
||||||
|
# Validate against definitions
|
||||||
|
definitions = await _collect_custom_field_definitions(db, tenant_id, "contact")
|
||||||
|
def_map = {d["name"]: d for d in definitions}
|
||||||
|
|
||||||
|
current_custom = dict(contact.custom or {})
|
||||||
|
for name, value in body.values.items():
|
||||||
|
if name not in def_map:
|
||||||
|
raise HTTPException(
|
||||||
|
400,
|
||||||
|
detail={"detail": f"Unknown custom field: {name}", "code": "unknown_field"},
|
||||||
|
)
|
||||||
|
field_def = def_map[name]
|
||||||
|
# Validate required
|
||||||
|
if field_def["required"] and (value is None or value == ""):
|
||||||
|
raise HTTPException(
|
||||||
|
400,
|
||||||
|
detail={"detail": f"Field '{name}' is required", "code": "required_field"},
|
||||||
|
)
|
||||||
|
# Validate select/multiselect options
|
||||||
|
if field_def["field_type"] == "select" and value is not None:
|
||||||
|
if value not in field_def["options"]:
|
||||||
|
raise HTTPException(
|
||||||
|
400,
|
||||||
|
detail={"detail": f"Invalid option for field '{name}'", "code": "invalid_option"},
|
||||||
|
)
|
||||||
|
if field_def["field_type"] == "multiselect" and value is not None:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
raise HTTPException(
|
||||||
|
400,
|
||||||
|
detail={"detail": f"Field '{name}' must be a list", "code": "invalid_type"},
|
||||||
|
)
|
||||||
|
for v in value:
|
||||||
|
if v not in field_def["options"]:
|
||||||
|
raise HTTPException(
|
||||||
|
400,
|
||||||
|
detail={"detail": f"Invalid option '{v}' for field '{name}'", "code": "invalid_option"},
|
||||||
|
)
|
||||||
|
current_custom[name] = value
|
||||||
|
|
||||||
|
contact.custom = current_custom
|
||||||
|
await db.flush()
|
||||||
|
merged = await _merge_definitions_with_values(definitions, contact.custom)
|
||||||
|
return {"fields": merged}
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ class ContractRegistry:
|
|||||||
cls._instance._contracts: dict[str, Any] = {}
|
cls._instance._contracts: dict[str, Any] = {}
|
||||||
cls._instance._loaded: set[str] = set()
|
cls._instance._loaded: set[str] = set()
|
||||||
cls._instance._unregistered: set[str] = set()
|
cls._instance._unregistered: set[str] = set()
|
||||||
|
# Plugins whose DB record says active=False (audit restart edge
|
||||||
|
# case) — marked once at API startup, see main.py lifespan.
|
||||||
|
cls._instance._db_inactive: set[str] = set()
|
||||||
return cls._instance
|
return cls._instance
|
||||||
|
|
||||||
# ─── registration ───
|
# ─── registration ───
|
||||||
@@ -92,20 +95,51 @@ class ContractRegistry:
|
|||||||
|
|
||||||
On first access the registry attempts to lazy-load the plugin's
|
On first access the registry attempts to lazy-load the plugin's
|
||||||
``contracts`` module, which will register itself on import.
|
``contracts`` module, which will register itself on import.
|
||||||
"""
|
|
||||||
if plugin_name in self._contracts:
|
|
||||||
return self._contracts[plugin_name]
|
|
||||||
|
|
||||||
|
Audit P1 (contract lazy loading): the DB activation state is checked
|
||||||
|
BEFORE serving or lazy-loading. A plugin that was already inactive
|
||||||
|
when the process started never lands in ``_unregistered`` (it was
|
||||||
|
never deactivated at runtime), so the old guard alone let the lazy
|
||||||
|
loader import its contracts module and resurrect the contract.
|
||||||
|
The permission registry mirrors ``PluginModel.active`` at startup,
|
||||||
|
so an inactive plugin fails closed here. When the permission
|
||||||
|
registry is NOT initialized (worker process, early bootstrap)
|
||||||
|
the legacy lazy-load behaviour is kept.
|
||||||
|
"""
|
||||||
# Explicitly unregistered (deactivated): never resurrect via
|
# Explicitly unregistered (deactivated): never resurrect via
|
||||||
# lazy-loading (ARCH-014) — the deactivated contract must stay gone.
|
# lazy-loading (ARCH-014) — the deactivated contract must stay gone.
|
||||||
if plugin_name in self._unregistered:
|
if plugin_name in self._unregistered:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# DB activation guard (audit restart edge case): plugins whose DB
|
||||||
|
# record was already inactive when the process started never land in
|
||||||
|
# _unregistered (they were never deactivated at runtime), so lazy
|
||||||
|
# loading could resurrect their contracts. main.py marks them once
|
||||||
|
# at startup; activation clears the marker again.
|
||||||
|
if plugin_name in self._db_inactive:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if plugin_name in self._contracts:
|
||||||
|
return self._contracts[plugin_name]
|
||||||
|
|
||||||
if plugin_name not in self._loaded:
|
if plugin_name not in self._loaded:
|
||||||
self._try_lazy_load(plugin_name)
|
self._try_lazy_load(plugin_name)
|
||||||
|
|
||||||
return self._contracts.get(plugin_name)
|
return self._contracts.get(plugin_name)
|
||||||
|
|
||||||
|
def mark_db_inactive(self, plugin_names: set[str]) -> None:
|
||||||
|
"""Mark plugins as DB-inactive (startup, audit restart edge case).
|
||||||
|
|
||||||
|
Called once from main.py lifespan with the names of plugins whose DB
|
||||||
|
record has active=False. get_contract() fails closed for these.
|
||||||
|
"""
|
||||||
|
self._db_inactive.update(plugin_names)
|
||||||
|
|
||||||
|
def mark_plugin_active(self, plugin_name: str) -> None:
|
||||||
|
"""Clear inactive markers (plugin activated/reinstalled at runtime)."""
|
||||||
|
self._db_inactive.discard(plugin_name)
|
||||||
|
self._unregistered.discard(plugin_name)
|
||||||
|
|
||||||
def require_contract(self, plugin_name: str) -> Any:
|
def require_contract(self, plugin_name: str) -> Any:
|
||||||
"""Like :meth:`get_contract` but raise if unavailable."""
|
"""Like :meth:`get_contract` but raise if unavailable."""
|
||||||
contract = self.get_contract(plugin_name)
|
contract = self.get_contract(plugin_name)
|
||||||
@@ -148,6 +182,7 @@ class ContractRegistry:
|
|||||||
"""Clear all state — for unit tests only."""
|
"""Clear all state — for unit tests only."""
|
||||||
self._contracts.clear()
|
self._contracts.clear()
|
||||||
self._loaded.clear()
|
self._loaded.clear()
|
||||||
|
self._db_inactive.clear()
|
||||||
|
|
||||||
|
|
||||||
# ─── module-level helpers ───
|
# ─── module-level helpers ───
|
||||||
|
|||||||
@@ -15,6 +15,40 @@ class DmsContract:
|
|||||||
DmsFile = DmsFile
|
DmsFile = DmsFile
|
||||||
Folder = Folder
|
Folder = Folder
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def workspace_scopes() -> list[dict]:
|
||||||
|
"""Scope-Dimensionen des dms-Moduls für den Workspace-Editor (N1)."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"module_key": "dms",
|
||||||
|
"dimensions": [
|
||||||
|
{
|
||||||
|
"key": "folder_ids",
|
||||||
|
"label": "DMS-Ordner",
|
||||||
|
"control": "multiselect",
|
||||||
|
"value_source": {
|
||||||
|
"endpoint": "/api/v1/dms/folders",
|
||||||
|
"items_path": "",
|
||||||
|
"value_key": "id",
|
||||||
|
"label_key": "name",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "file_types",
|
||||||
|
"label": "Datei-Typen",
|
||||||
|
"control": "multiselect",
|
||||||
|
"options": [
|
||||||
|
{"value": "application/pdf", "label": "PDF"},
|
||||||
|
{"value": "image/", "label": "Bilder"},
|
||||||
|
{"value": "spreadsheet", "label": "Tabellen"},
|
||||||
|
{"value": "word", "label": "Dokumente"},
|
||||||
|
{"value": "other", "label": "Sonstige"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_function(cls, name: str):
|
def get_function(cls, name: str):
|
||||||
"""Return a callable exposed by this contract, or None if absent."""
|
"""Return a callable exposed by this contract, or None if absent."""
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.core.visibility import apply_visibility_filter, check_single_entity_access
|
from app.core.visibility import apply_visibility_filter, check_single_entity_access
|
||||||
from app.deps import get_current_user, require_permission
|
from app.deps import get_current_user, require_permission, require_workspace_scope
|
||||||
from app.plugins.builtins.dms.common import (
|
from app.plugins.builtins.dms.common import (
|
||||||
_parse_uuid,
|
_parse_uuid,
|
||||||
)
|
)
|
||||||
@@ -35,8 +35,13 @@ async def list_folders(
|
|||||||
parent_id: str | None = None,
|
parent_id: str | None = None,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
|
workspace_scope: dict | None = Depends(require_workspace_scope("dms")),
|
||||||
):
|
):
|
||||||
"""AC1: GET /api/v1/dms/folders → 200 + folder tree (recursive)."""
|
"""AC1: GET /api/v1/dms/folders → 200 + folder tree (recursive).
|
||||||
|
|
||||||
|
Phase N3: an active workspace scope (X-Workspace-ID) reduces the tree to
|
||||||
|
the folder subtree — pure AND-restriction, never a grant.
|
||||||
|
"""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
|
||||||
# Fetch all non-deleted folders for tenant with visibility filter
|
# Fetch all non-deleted folders for tenant with visibility filter
|
||||||
@@ -52,6 +57,16 @@ async def list_folders(
|
|||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
all_folders = result.scalars().all()
|
all_folders = result.scalars().all()
|
||||||
|
|
||||||
|
# Phase N3: reduce to the scope subtree (folder_ids dimension)
|
||||||
|
if workspace_scope:
|
||||||
|
from app.services.workspace_scope_service import expand_folder_scope
|
||||||
|
|
||||||
|
scope_folder_ids = workspace_scope.get("folder_ids")
|
||||||
|
if isinstance(scope_folder_ids, list) and scope_folder_ids:
|
||||||
|
subtree = await expand_folder_scope(db, Folder, scope_folder_ids)
|
||||||
|
allowed = subtree or set()
|
||||||
|
all_folders = [f for f in all_folders if f.id in allowed]
|
||||||
|
|
||||||
# Build lookup map
|
# Build lookup map
|
||||||
folder_map: dict[uuid.UUID, dict] = {}
|
folder_map: dict[uuid.UUID, dict] = {}
|
||||||
for f in all_folders:
|
for f in all_folders:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from app.plugins.base import BasePlugin
|
|||||||
from app.plugins.manifest import (
|
from app.plugins.manifest import (
|
||||||
FrontendMenuItem,
|
FrontendMenuItem,
|
||||||
FrontendPageRoute,
|
FrontendPageRoute,
|
||||||
|
MiniAppContribution,
|
||||||
PluginManifest,
|
PluginManifest,
|
||||||
PluginRouteDef,
|
PluginRouteDef,
|
||||||
)
|
)
|
||||||
@@ -19,6 +20,11 @@ class DmsPlugin(BasePlugin):
|
|||||||
version="1.0.0",
|
version="1.0.0",
|
||||||
display_name="DMS",
|
display_name="DMS",
|
||||||
description="Document management: folder hierarchy, file upload, PDF preview, Collabora edit sessions, internal sharing, search, bulk ops.",
|
description="Document management: folder hierarchy, file upload, PDF preview, Collabora edit sessions, internal sharing, search, bulk ops.",
|
||||||
|
# Audit P1/P2 (ADR-020): DMS is a platform core plugin — the core schema
|
||||||
|
# (entity_attachments.files-FK) builds on the DMS files table, so DMS
|
||||||
|
# cannot be deactivated. Declared is_core=True so the registry enforces
|
||||||
|
# this instead of the FK being silently invalid.
|
||||||
|
is_core=True,
|
||||||
dependencies=["permissions"],
|
dependencies=["permissions"],
|
||||||
routes=[
|
routes=[
|
||||||
PluginRouteDef(
|
PluginRouteDef(
|
||||||
@@ -29,6 +35,25 @@ class DmsPlugin(BasePlugin):
|
|||||||
],
|
],
|
||||||
events=[],
|
events=[],
|
||||||
migrations=["0001_initial.sql"],
|
migrations=["0001_initial.sql"],
|
||||||
|
miniapps=[
|
||||||
|
MiniAppContribution(
|
||||||
|
app_id="dms_folders",
|
||||||
|
name="DMS-Ordner",
|
||||||
|
icon="FolderOpen",
|
||||||
|
description="Ordnerübersicht des Dokumentenmanagements mit Dateizählern.",
|
||||||
|
permission="dms:read",
|
||||||
|
settings_schema={
|
||||||
|
"fields": [
|
||||||
|
{"name": "max_items", "label": "Max. Ordner", "type": "number", "default": 6},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
col_span=2,
|
||||||
|
row_span=1,
|
||||||
|
hosts=["chat", "dashboard", "window"],
|
||||||
|
component="@/components/dashboard/DmsFoldersWidget",
|
||||||
|
order=60,
|
||||||
|
),
|
||||||
|
],
|
||||||
permissions=[
|
permissions=[
|
||||||
"dms:read",
|
"dms:read",
|
||||||
"dms:write",
|
"dms:write",
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.core.storage import LocalStorage, get_storage_backend
|
from app.core.storage import LocalStorage, get_storage_backend
|
||||||
from app.core.visibility import apply_visibility_filter, check_single_entity_access
|
from app.core.visibility import apply_visibility_filter, check_single_entity_access
|
||||||
from app.deps import get_current_user, require_permission
|
from app.deps import get_current_user, require_permission, require_workspace_scope
|
||||||
|
|
||||||
# BUG-018 God-Object-Split: Helper/Konstanten leben jetzt in common.py;
|
# BUG-018 God-Object-Split: Helper/Konstanten leben jetzt in common.py;
|
||||||
# Re-Exports sichern Import- und Patch-Kompatibilitaet
|
# Re-Exports sichern Import- und Patch-Kompatibilitaet
|
||||||
@@ -245,8 +245,13 @@ async def get_file(
|
|||||||
async def list_all_files(
|
async def list_all_files(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
|
workspace_scope: dict | None = Depends(require_workspace_scope("dms")),
|
||||||
):
|
):
|
||||||
"""List all non-deleted files for the current tenant."""
|
"""List all non-deleted files for the current tenant.
|
||||||
|
|
||||||
|
Phase N3: applies the active workspace scope (X-Workspace-ID) as a pure
|
||||||
|
AND-restriction — folder subtree + file types. Never a grant.
|
||||||
|
"""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
user_id = uuid.UUID(current_user["user_id"])
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
is_system_admin = current_user.get("role") == "admin"
|
is_system_admin = current_user.get("role") == "admin"
|
||||||
@@ -258,9 +263,32 @@ async def list_all_files(
|
|||||||
query = await apply_visibility_filter(
|
query = await apply_visibility_filter(
|
||||||
db, query, "dms_file", DmsFile, user_id, tenant_id, is_system_admin
|
db, query, "dms_file", DmsFile, user_id, tenant_id, is_system_admin
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Phase N3: workspace scope filters (folder subtree + file types)
|
||||||
|
if workspace_scope:
|
||||||
|
from app.services.workspace_scope_service import (
|
||||||
|
DMS_FILE_TYPE_MATCHERS,
|
||||||
|
expand_folder_scope,
|
||||||
|
)
|
||||||
|
|
||||||
|
scope_folder_ids = workspace_scope.get("folder_ids")
|
||||||
|
if isinstance(scope_folder_ids, list) and scope_folder_ids:
|
||||||
|
subtree = await expand_folder_scope(db, Folder, scope_folder_ids)
|
||||||
|
query = query.where(DmsFile.folder_id.in_(subtree or set()))
|
||||||
|
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
files = result.scalars().all()
|
files = result.scalars().all()
|
||||||
|
|
||||||
|
# file_types needs Python-side matching (semantic matchers, not SQL-LIKE)
|
||||||
|
if workspace_scope:
|
||||||
|
from app.services.workspace_scope_service import DMS_FILE_TYPE_MATCHERS
|
||||||
|
|
||||||
|
scope_file_types = workspace_scope.get("file_types")
|
||||||
|
if isinstance(scope_file_types, list) and scope_file_types:
|
||||||
|
matchers = [DMS_FILE_TYPE_MATCHERS[t] for t in scope_file_types if t in DMS_FILE_TYPE_MATCHERS]
|
||||||
|
if matchers:
|
||||||
|
files = [f for f in files if any(m(f.mime_type) for m in matchers)]
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"id": str(f.id),
|
"id": str(f.id),
|
||||||
|
|||||||
@@ -23,11 +23,15 @@ class ForgejoErrorReporterPlugin(BasePlugin):
|
|||||||
version="1.0.0",
|
version="1.0.0",
|
||||||
display_name="Forgejo Error Reporter",
|
display_name="Forgejo Error Reporter",
|
||||||
description="Automatically reports errors to Forgejo as issues. Test environment only.",
|
description="Automatically reports errors to Forgejo as issues. Test environment only.",
|
||||||
is_core=True,
|
# Audit P2 (classification): a test/staging-only plugin must be
|
||||||
|
# deactivatable — is_core=True contradicts its own production guard.
|
||||||
|
is_core=False,
|
||||||
dependencies=[],
|
dependencies=[],
|
||||||
events=[],
|
events=[],
|
||||||
migrations=[],
|
migrations=[],
|
||||||
permissions=[],
|
# Audit P1 (permission catalog): /status route requires system:read —
|
||||||
|
# the key must be grantable via the manifest.
|
||||||
|
permissions=["system:read"],
|
||||||
routes=[
|
routes=[
|
||||||
PluginRouteDef(
|
PluginRouteDef(
|
||||||
path="/api/v1/forgejo-error-reporter",
|
path="/api/v1/forgejo-error-reporter",
|
||||||
@@ -38,7 +42,8 @@ class ForgejoErrorReporterPlugin(BasePlugin):
|
|||||||
|
|
||||||
author="LeoCRM Team",
|
author="LeoCRM Team",
|
||||||
min_app_version="1.0.0",
|
min_app_version="1.0.0",
|
||||||
contract_version="1.0.0")
|
contract_version="1.0.0",
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|||||||
@@ -3,7 +3,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from app.plugins.base import BasePlugin
|
from app.plugins.base import BasePlugin
|
||||||
from app.plugins.manifest import PluginManifest, PluginRouteDef
|
from app.plugins.manifest import (
|
||||||
|
FrontendMenuItem,
|
||||||
|
FrontendPageRoute,
|
||||||
|
MiniAppContribution,
|
||||||
|
PluginManifest,
|
||||||
|
PluginRouteDef,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class GraphRAGPlugin(BasePlugin):
|
class GraphRAGPlugin(BasePlugin):
|
||||||
@@ -24,6 +30,43 @@ class GraphRAGPlugin(BasePlugin):
|
|||||||
],
|
],
|
||||||
events=[],
|
events=[],
|
||||||
migrations=["0001_initial.sql"],
|
migrations=["0001_initial.sql"],
|
||||||
|
miniapps=[
|
||||||
|
MiniAppContribution(
|
||||||
|
app_id="graph_overview",
|
||||||
|
name="Wissens-Graph",
|
||||||
|
icon="Share2",
|
||||||
|
description="Beziehungsübersicht des Knowledge-Graphs.",
|
||||||
|
permission="graph:read",
|
||||||
|
settings_schema={
|
||||||
|
"fields": [
|
||||||
|
{"name": "max_items", "label": "Max. Beziehungen", "type": "number", "default": 6},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
col_span=2,
|
||||||
|
row_span=1,
|
||||||
|
hosts=["chat", "dashboard", "window"],
|
||||||
|
component="@/components/dashboard/GraphOverviewWidget",
|
||||||
|
order=90,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
menu_items=[
|
||||||
|
FrontendMenuItem(
|
||||||
|
label_key="nav.graphRag",
|
||||||
|
label="Wissens-Graph",
|
||||||
|
path="/graph-rag",
|
||||||
|
icon="Share2",
|
||||||
|
order=87,
|
||||||
|
permission="graph:read",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
page_routes=[
|
||||||
|
FrontendPageRoute(
|
||||||
|
path="/graph-rag",
|
||||||
|
component="@/pages/GraphRag",
|
||||||
|
protected=True,
|
||||||
|
permission="graph:read",
|
||||||
|
),
|
||||||
|
],
|
||||||
permissions=[
|
permissions=[
|
||||||
"graph:read",
|
"graph:read",
|
||||||
"graph:write",
|
"graph:write",
|
||||||
|
|||||||
@@ -105,6 +105,33 @@ class KommunikationContract:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Workspace Scopes contribution (Phase N4) ───
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def workspace_scopes() -> list[dict]:
|
||||||
|
"""Scope-Dimensionen des communication-Moduls: Räume-Teilmengen (N4)."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"module_key": "communication",
|
||||||
|
"dimensions": [
|
||||||
|
{
|
||||||
|
"key": "conversation_ids",
|
||||||
|
"label": "Räume",
|
||||||
|
"control": "multiselect",
|
||||||
|
"options": [],
|
||||||
|
"value_source": {
|
||||||
|
"endpoint": "/api/v1/comm/conversations",
|
||||||
|
"items_path": "items",
|
||||||
|
"value_key": "id",
|
||||||
|
"label_key": "title",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
# ─── self-registration ───
|
# ─── self-registration ───
|
||||||
|
|
||||||
_contract = KommunikationContract()
|
_contract = KommunikationContract()
|
||||||
|
|||||||
@@ -1,92 +1,25 @@
|
|||||||
"""Mini-App registry for plugin-provided interactive chat components."""
|
"""Compatibility bridge — the MiniApp registry moved to the plugin layer.
|
||||||
|
|
||||||
|
The registry is a platform concept now (Phase M1): MiniApps are universal
|
||||||
|
building blocks for Chat, Dashboard and Windows. This module re-exports the
|
||||||
|
universal registry so every existing importer (kommunikation contracts,
|
||||||
|
automation routes, tests) keeps working unchanged.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
from app.plugins.miniapp_registry import ( # noqa: F401
|
||||||
from typing import Any
|
DEFAULT_HOSTS,
|
||||||
|
MiniAppDef,
|
||||||
|
MiniAppRegistry,
|
||||||
|
get_miniapp_registry,
|
||||||
|
reset_miniapp_registry,
|
||||||
|
)
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
__all__ = [
|
||||||
|
"MiniAppDef",
|
||||||
logger = logging.getLogger(__name__)
|
"MiniAppRegistry",
|
||||||
|
"get_miniapp_registry",
|
||||||
|
"reset_miniapp_registry",
|
||||||
class MiniAppDef(BaseModel):
|
"DEFAULT_HOSTS",
|
||||||
"""Definition of a mini-app that plugins can register."""
|
]
|
||||||
|
|
||||||
app_id: str = Field(..., description="Unique app identifier")
|
|
||||||
name: str = Field(..., description="Display name")
|
|
||||||
icon: str = Field(default="app", description="Icon name")
|
|
||||||
description: str = Field(default="", description="App description")
|
|
||||||
plugin_name: str = Field(..., description="Plugin that registered this app")
|
|
||||||
render_schema: dict[str, Any] = Field(
|
|
||||||
default_factory=dict, description="JSON schema for frontend rendering"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class MiniAppRegistry:
|
|
||||||
"""Registry for mini-apps that plugins provide for chat embedding."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._apps: dict[str, MiniAppDef] = {}
|
|
||||||
|
|
||||||
def register(
|
|
||||||
self,
|
|
||||||
app_id: str,
|
|
||||||
name: str,
|
|
||||||
icon: str,
|
|
||||||
description: str,
|
|
||||||
plugin_name: str,
|
|
||||||
render_schema: dict[str, Any] | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Register a mini-app."""
|
|
||||||
app = MiniAppDef(
|
|
||||||
app_id=app_id,
|
|
||||||
name=name,
|
|
||||||
icon=icon,
|
|
||||||
description=description,
|
|
||||||
plugin_name=plugin_name,
|
|
||||||
render_schema=render_schema or {},
|
|
||||||
)
|
|
||||||
self._apps[app_id] = app
|
|
||||||
logger.info(f"Mini-app registered: {app_id} by {plugin_name}")
|
|
||||||
|
|
||||||
def unregister(self, app_id: str) -> None:
|
|
||||||
"""Unregister a mini-app."""
|
|
||||||
app = self._apps.pop(app_id, None)
|
|
||||||
if app:
|
|
||||||
logger.info(f"Mini-app unregistered: {app_id}")
|
|
||||||
|
|
||||||
def unregister_plugin(self, plugin_name: str) -> None:
|
|
||||||
"""Unregister all mini-apps from a specific plugin."""
|
|
||||||
to_remove = [app_id for app_id, app in self._apps.items() if app.plugin_name == plugin_name]
|
|
||||||
for app_id in to_remove:
|
|
||||||
self._apps.pop(app_id, None)
|
|
||||||
if to_remove:
|
|
||||||
logger.info(f"Unregistered {len(to_remove)} mini-apps from plugin {plugin_name}")
|
|
||||||
|
|
||||||
def list_apps(self) -> list[dict[str, Any]]:
|
|
||||||
"""List all available mini-apps for frontend."""
|
|
||||||
return [app.model_dump() for app in self._apps.values()]
|
|
||||||
|
|
||||||
def get_app(self, app_id: str) -> MiniAppDef | None:
|
|
||||||
"""Get a specific mini-app definition."""
|
|
||||||
return self._apps.get(app_id)
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Singleton helpers ───
|
|
||||||
|
|
||||||
_registry: MiniAppRegistry | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_miniapp_registry() -> MiniAppRegistry:
|
|
||||||
"""Return the shared singleton MiniAppRegistry instance."""
|
|
||||||
global _registry
|
|
||||||
if _registry is None:
|
|
||||||
_registry = MiniAppRegistry()
|
|
||||||
return _registry
|
|
||||||
|
|
||||||
|
|
||||||
def reset_miniapp_registry() -> None:
|
|
||||||
"""Reset the singleton instance (useful for tests)."""
|
|
||||||
global _registry
|
|
||||||
_registry = None
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from fastapi import (
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.deps import get_current_user, require_permission
|
from app.deps import get_current_user, require_permission, require_workspace_scope
|
||||||
from app.plugins.builtins.kommunikation.content_types import list_block_types
|
from app.plugins.builtins.kommunikation.content_types import list_block_types
|
||||||
from app.plugins.builtins.kommunikation.dms_bridge import DmsBridge
|
from app.plugins.builtins.kommunikation.dms_bridge import DmsBridge
|
||||||
from app.plugins.builtins.kommunikation.rbac import CommRBAC
|
from app.plugins.builtins.kommunikation.rbac import CommRBAC
|
||||||
@@ -74,11 +74,23 @@ async def list_user_conversations(
|
|||||||
archived: bool = Query(False, description="Include archived conversations"),
|
archived: bool = Query(False, description="Include archived conversations"),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
workspace_scope: dict | None = Depends(require_workspace_scope("communication")),
|
||||||
):
|
):
|
||||||
"""List all conversations for the current user."""
|
"""List all conversations for the current user.
|
||||||
|
|
||||||
|
Phase N4: an active workspace scope (X-Workspace-ID) restricts the list
|
||||||
|
to the configured conversation subset (pure AND — never a grant).
|
||||||
|
"""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
user_id = uuid.UUID(current_user["user_id"])
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
convs = await list_conversations(db, tenant_id, user_id, include_archived=archived)
|
convs = await list_conversations(db, tenant_id, user_id, include_archived=archived)
|
||||||
|
# Phase N4: conversation_ids scope — keep only scoped rooms
|
||||||
|
if workspace_scope:
|
||||||
|
from app.services.workspace_scope_service import scope_uuid_set
|
||||||
|
|
||||||
|
conv_scope = scope_uuid_set(workspace_scope.get("conversation_ids"))
|
||||||
|
if conv_scope is not None:
|
||||||
|
convs = [c for c in convs if uuid.UUID(c["id"]) in conv_scope]
|
||||||
return {"items": convs, "total": len(convs)}
|
return {"items": convs, "total": len(convs)}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,30 @@ class MailContract:
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ─── Workspace Scopes contribution (Phase N1, #359 pattern) ───
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def workspace_scopes() -> list[dict]:
|
||||||
|
"""Scope-Dimensionen des mail-Moduls für den Workspace-Editor (N1)."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"module_key": "mail",
|
||||||
|
"dimensions": [
|
||||||
|
{
|
||||||
|
"key": "account_ids",
|
||||||
|
"label": "Postfächer",
|
||||||
|
"control": "multiselect",
|
||||||
|
"value_source": {
|
||||||
|
"endpoint": "/api/v1/mail/accounts",
|
||||||
|
"items_path": "",
|
||||||
|
"value_key": "id",
|
||||||
|
"label_key": "email",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_function(cls, name: str):
|
def get_function(cls, name: str):
|
||||||
"""Return a callable exposed by this contract, or None if absent."""
|
"""Return a callable exposed by this contract, or None if absent."""
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from app.plugins.manifest import (
|
|||||||
FrontendMenuItem,
|
FrontendMenuItem,
|
||||||
FrontendPageRoute,
|
FrontendPageRoute,
|
||||||
FrontendSettingsPage,
|
FrontendSettingsPage,
|
||||||
|
MiniAppContribution,
|
||||||
PluginManifest,
|
PluginManifest,
|
||||||
PluginRouteDef,
|
PluginRouteDef,
|
||||||
)
|
)
|
||||||
@@ -138,6 +139,25 @@ class MailPlugin(BasePlugin):
|
|||||||
],
|
],
|
||||||
events=[],
|
events=[],
|
||||||
migrations=["0001_initial.sql", "0006_flag_type.sql", "0007_sync_queue.sql", "0008_sync_queue_deleted_at.sql", "0009_remove_mail_soft_delete.sql", "0010_add_deleted_at.sql"],
|
migrations=["0001_initial.sql", "0006_flag_type.sql", "0007_sync_queue.sql", "0008_sync_queue_deleted_at.sql", "0009_remove_mail_soft_delete.sql", "0010_add_deleted_at.sql"],
|
||||||
|
miniapps=[
|
||||||
|
MiniAppContribution(
|
||||||
|
app_id="mail_unread",
|
||||||
|
name="Postfach-Status",
|
||||||
|
icon="Mail",
|
||||||
|
description="Ungelesene E-Mails je Konto und Ordner.",
|
||||||
|
permission="mail:read",
|
||||||
|
settings_schema={
|
||||||
|
"fields": [
|
||||||
|
{"name": "max_items", "label": "Max. Ordner", "type": "number", "default": 6},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
col_span=2,
|
||||||
|
row_span=1,
|
||||||
|
hosts=["chat", "dashboard", "window"],
|
||||||
|
component="@/components/dashboard/MailUnreadWidget",
|
||||||
|
order=70,
|
||||||
|
),
|
||||||
|
],
|
||||||
permissions=["mail:read", "mail:send", "mail:config", "mail:share", "mail:write", "mail:delete"],
|
permissions=["mail:read", "mail:send", "mail:config", "mail:share", "mail:write", "mail:delete"],
|
||||||
menu_items=[
|
menu_items=[
|
||||||
FrontendMenuItem(label_key='nav.mail', label='E-Mail', path='/mail', icon='Mail', order=30, permission='mail:read'),
|
FrontendMenuItem(label_key='nav.mail', label='E-Mail', path='/mail', icon='Mail', order=30, permission='mail:read'),
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import app.plugins.builtins.mail.services as mail_services
|
|||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.core.storage import get_storage_backend
|
from app.core.storage import get_storage_backend
|
||||||
from app.core.visibility import apply_visibility_filter, check_single_entity_access
|
from app.core.visibility import apply_visibility_filter, check_single_entity_access
|
||||||
from app.deps import require_permission
|
from app.deps import require_permission, require_workspace_scope
|
||||||
from app.plugins.builtins.mail.models import (
|
from app.plugins.builtins.mail.models import (
|
||||||
ContactPgpKey,
|
ContactPgpKey,
|
||||||
Mail,
|
Mail,
|
||||||
@@ -214,7 +214,8 @@ async def _check_delegate_access(
|
|||||||
|
|
||||||
@router.get("/accounts")
|
@router.get("/accounts")
|
||||||
async def list_accounts(
|
async def list_accounts(
|
||||||
db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("mail:read"))
|
db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("mail:read")),
|
||||||
|
workspace_scope: dict | None = Depends(require_workspace_scope("mail")),
|
||||||
):
|
):
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
user_id = uuid.UUID(current_user["user_id"])
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
@@ -223,6 +224,13 @@ async def list_accounts(
|
|||||||
query = await apply_visibility_filter(
|
query = await apply_visibility_filter(
|
||||||
db, query, "mail_account", MailAccount, user_id, tenant_id, is_system_admin
|
db, query, "mail_account", MailAccount, user_id, tenant_id, is_system_admin
|
||||||
)
|
)
|
||||||
|
# Phase N3: workspace scope (X-Workspace-ID) — account picker restriction.
|
||||||
|
if workspace_scope:
|
||||||
|
from app.services.workspace_scope_service import scope_uuid_set
|
||||||
|
|
||||||
|
account_scope = scope_uuid_set(workspace_scope.get("account_ids"))
|
||||||
|
if account_scope is not None:
|
||||||
|
query = query.where(MailAccount.id.in_(account_scope))
|
||||||
accounts = (await db.execute(query)).scalars().all()
|
accounts = (await db.execute(query)).scalars().all()
|
||||||
return [account_to_response(a) for a in accounts]
|
return [account_to_response(a) for a in accounts]
|
||||||
|
|
||||||
@@ -878,12 +886,20 @@ async def list_threads(
|
|||||||
account_id: str | None = None,
|
account_id: str | None = None,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(require_permission("mail:read")),
|
current_user: dict = Depends(require_permission("mail:read")),
|
||||||
|
workspace_scope: dict | None = Depends(require_workspace_scope("mail")),
|
||||||
):
|
):
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
stmt = select(Mail).where(Mail.tenant_id == tenant_id)
|
stmt = select(Mail).where(Mail.tenant_id == tenant_id)
|
||||||
if account_id:
|
if account_id:
|
||||||
a_id = _parse_uuid(account_id, "account_id")
|
a_id = _parse_uuid(account_id, "account_id")
|
||||||
stmt = stmt.where(Mail.account_id == a_id)
|
stmt = stmt.where(Mail.account_id == a_id)
|
||||||
|
# Phase N3: workspace scope (X-Workspace-ID) — account subsets, pure AND.
|
||||||
|
if workspace_scope:
|
||||||
|
from app.services.workspace_scope_service import scope_uuid_set
|
||||||
|
|
||||||
|
account_scope = scope_uuid_set(workspace_scope.get("account_ids"))
|
||||||
|
if account_scope is not None:
|
||||||
|
stmt = stmt.where(Mail.account_id.in_(account_scope))
|
||||||
mails = (await db.execute(stmt.order_by(desc(Mail.received_at)))).scalars().all()
|
mails = (await db.execute(stmt.order_by(desc(Mail.received_at)))).scalars().all()
|
||||||
threads: dict[str, dict] = {}
|
threads: dict[str, dict] = {}
|
||||||
for mail in mails:
|
for mail in mails:
|
||||||
@@ -1887,6 +1903,7 @@ async def list_mails(
|
|||||||
sort_order: str = Query("desc", pattern="^(asc|desc)$"),
|
sort_order: str = Query("desc", pattern="^(asc|desc)$"),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(require_permission("mail:read")),
|
current_user: dict = Depends(require_permission("mail:read")),
|
||||||
|
workspace_scope: dict | None = Depends(require_workspace_scope("mail")),
|
||||||
):
|
):
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
stmt = select(Mail).where(Mail.tenant_id == tenant_id)
|
stmt = select(Mail).where(Mail.tenant_id == tenant_id)
|
||||||
@@ -1896,6 +1913,13 @@ async def list_mails(
|
|||||||
if account_id:
|
if account_id:
|
||||||
a_id = _parse_uuid(account_id, "account_id")
|
a_id = _parse_uuid(account_id, "account_id")
|
||||||
stmt = stmt.where(Mail.account_id == a_id)
|
stmt = stmt.where(Mail.account_id == a_id)
|
||||||
|
# Phase N3: workspace scope (X-Workspace-ID) — account subsets, pure AND.
|
||||||
|
if workspace_scope:
|
||||||
|
from app.services.workspace_scope_service import scope_uuid_set
|
||||||
|
|
||||||
|
account_scope = scope_uuid_set(workspace_scope.get("account_ids"))
|
||||||
|
if account_scope is not None:
|
||||||
|
stmt = stmt.where(Mail.account_id.in_(account_scope))
|
||||||
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar()
|
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar()
|
||||||
# Dynamic sorting
|
# Dynamic sorting
|
||||||
sort_columns = {
|
sort_columns = {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from app.plugins.base import BasePlugin
|
from app.plugins.base import BasePlugin
|
||||||
from app.plugins.manifest import PluginManifest, PluginRouteDef
|
from app.plugins.manifest import FrontendMenuItem, FrontendPageRoute, PluginManifest, PluginRouteDef
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -29,8 +29,26 @@ class MarketplacePlugin(BasePlugin):
|
|||||||
events=[],
|
events=[],
|
||||||
migrations=["0001_initial.sql"],
|
migrations=["0001_initial.sql"],
|
||||||
permissions=["marketplace:read", "marketplace:admin"],
|
permissions=["marketplace:read", "marketplace:admin"],
|
||||||
menu_items=[],
|
# UI-Backlog Modul 5 (2026-09-13): marketplace browse/install page,
|
||||||
page_routes=[],
|
# registered via the manifest (Phase Q pattern).
|
||||||
|
menu_items=[
|
||||||
|
FrontendMenuItem(
|
||||||
|
label_key="nav.marketplace",
|
||||||
|
label="Marketplace",
|
||||||
|
path="/marketplace",
|
||||||
|
icon="Store",
|
||||||
|
order=85,
|
||||||
|
permission="marketplace:read",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
page_routes=[
|
||||||
|
FrontendPageRoute(
|
||||||
|
path="/marketplace",
|
||||||
|
component="@/pages/Marketplace",
|
||||||
|
protected=True,
|
||||||
|
permission="marketplace:read",
|
||||||
|
),
|
||||||
|
],
|
||||||
settings_pages=[],
|
settings_pages=[],
|
||||||
detail_tabs=[],
|
detail_tabs=[],
|
||||||
author="LeoCRM",
|
author="LeoCRM",
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import os
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.plugins.builtins.mcp_server.schemas import McpToolDefinition, McpToolParameter
|
from app.plugins.builtins.mcp_server.schemas import McpToolDefinition, McpToolParameter
|
||||||
@@ -95,30 +94,16 @@ async def _handler_call_crm_api(db: AsyncSession, arguments: dict[str, Any], con
|
|||||||
path = "/" + path
|
path = "/" + path
|
||||||
|
|
||||||
try:
|
try:
|
||||||
base_url = _get_base_url()
|
|
||||||
tenant_id = context.get("tenant_id", "")
|
tenant_id = context.get("tenant_id", "")
|
||||||
user_id = context.get("user_id", "")
|
user_id = context.get("user_id", "")
|
||||||
|
|
||||||
headers = {
|
# F09 (Astra P1): authenticated request via short-lived delegation
|
||||||
"Content-Type": "application/json",
|
# token - the MCP session user's real permissions apply.
|
||||||
"X-Internal-Call": "true",
|
from app.plugins.builtins.ai_assistant.crm_api_tool import _make_internal_api_request
|
||||||
"X-Tenant-Id": str(tenant_id),
|
|
||||||
"X-User-Id": str(user_id),
|
|
||||||
}
|
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
resp = await _make_internal_api_request(
|
||||||
if method == "GET":
|
method, path, tenant_id=str(tenant_id), user_id=str(user_id), body=body
|
||||||
resp = await client.get(f"{base_url}{path}", headers=headers, timeout=30.0)
|
)
|
||||||
elif method == "POST":
|
|
||||||
resp = await client.post(f"{base_url}{path}", headers=headers, json=body, timeout=30.0)
|
|
||||||
elif method == "PATCH":
|
|
||||||
resp = await client.patch(f"{base_url}{path}", headers=headers, json=body, timeout=30.0)
|
|
||||||
elif method == "PUT":
|
|
||||||
resp = await client.put(f"{base_url}{path}", headers=headers, json=body, timeout=30.0)
|
|
||||||
elif method == "DELETE":
|
|
||||||
resp = await client.delete(f"{base_url}{path}", headers=headers, timeout=30.0)
|
|
||||||
else:
|
|
||||||
return {"error": f"Unsupported method: {method}"}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp_data = resp.json()
|
resp_data = resp.json()
|
||||||
|
|||||||
@@ -30,7 +30,12 @@ class PermissionsPlugin(BasePlugin):
|
|||||||
],
|
],
|
||||||
events=[],
|
events=[],
|
||||||
migrations=["0001_initial.sql"],
|
migrations=["0001_initial.sql"],
|
||||||
permissions=[],
|
# Audit P1 (permission catalog): routes and settings pages use
|
||||||
|
# permissions:admin / permissions:read — they must be grantable.
|
||||||
|
permissions=[
|
||||||
|
"permissions:read",
|
||||||
|
"permissions:admin",
|
||||||
|
],
|
||||||
is_core=True,
|
is_core=True,
|
||||||
settings_pages=[
|
settings_pages=[
|
||||||
FrontendSettingsPage(path='roles', label_key='settings.roles', label='Roles', component='@/pages/SettingsRoles', icon='Shield', order=10, permission='permissions:read'),
|
FrontendSettingsPage(path='roles', label_key='settings.roles', label='Roles', component='@/pages/SettingsRoles', icon='Shield', order=10, permission='permissions:read'),
|
||||||
|
|||||||
@@ -43,6 +43,31 @@ class ReportGeneratorContract:
|
|||||||
PRESET_META = PRESET_META
|
PRESET_META = PRESET_META
|
||||||
PRESET_TEMPLATES = PRESET_TEMPLATES
|
PRESET_TEMPLATES = PRESET_TEMPLATES
|
||||||
|
|
||||||
|
# ─── Workspace Scopes contribution (Phase N4) ───
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def workspace_scopes() -> list[dict]:
|
||||||
|
"""Scope-Dimensionen des reports-Moduls: Vorlagen-Teilmengen (N4)."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"module_key": "reports",
|
||||||
|
"dimensions": [
|
||||||
|
{
|
||||||
|
"key": "template_ids",
|
||||||
|
"label": "Vorlagen",
|
||||||
|
"control": "multiselect",
|
||||||
|
"options": [],
|
||||||
|
"value_source": {
|
||||||
|
"endpoint": "/api/v1/reports/print-templates",
|
||||||
|
"items_path": "items",
|
||||||
|
"value_key": "id",
|
||||||
|
"label_key": "name",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
# ─── self-registration ───
|
# ─── self-registration ───
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
"""Document block registry — builtin block types, validation, contributions.
|
||||||
|
|
||||||
|
Phase L1: the central registry every drag/drop editor and the renderer use.
|
||||||
|
Builtin blocks cover text, image, simple graphics (shapes), tables, spacers
|
||||||
|
and placeholders. Modules can contribute additional palette blocks via the
|
||||||
|
contract hook ``document_blocks()`` (same philosophy as
|
||||||
|
``importexport_entities()``, #359). Contributed blocks declare ``fields``
|
||||||
|
(data keys) and render as a key-value table; report_generator owns the
|
||||||
|
generic rendering so modules never inject executable code.
|
||||||
|
|
||||||
|
A block is a plain dict: ``{"id": str, "type": str, "config": dict}``.
|
||||||
|
The ``id`` is editor-local (stable within one template/letterhead).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# ─── Builtin block metadata (palette + validation) ───────────────────────────
|
||||||
|
|
||||||
|
BUILTIN_BLOCKS: dict[str, dict[str, Any]] = {
|
||||||
|
"text": {
|
||||||
|
"label": "Text",
|
||||||
|
"category": "basis",
|
||||||
|
"description": "Absatz mit Jinja2-Platzhaltern ({{firstname}})",
|
||||||
|
"fields": {
|
||||||
|
"content": "str (Pflicht)",
|
||||||
|
"style": "dict? (fontSize, align, bold, italic, color)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"label": "Bild",
|
||||||
|
"category": "basis",
|
||||||
|
"description": "Bild aus Briefpapier-Assets (wird als data:-URI ins PDF eingebettet)",
|
||||||
|
"fields": {
|
||||||
|
"asset_id": "uuid?",
|
||||||
|
"url": "str? (data:-URI)",
|
||||||
|
"width": "int? (px)",
|
||||||
|
"height": "int? (px)",
|
||||||
|
"alt": "str?",
|
||||||
|
"align": "left|center|right?",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"shape": {
|
||||||
|
"label": "Grafik / Form",
|
||||||
|
"category": "grafik",
|
||||||
|
"description": "Einfache Grafik: Linie, Rechteck, Kreis",
|
||||||
|
"fields": {
|
||||||
|
"shape": "line|rect|circle (Pflicht)",
|
||||||
|
"width": "str? (CSS, z.B. 100% oder 120px)",
|
||||||
|
"height": "int? (px)",
|
||||||
|
"color": "str? (CSS-Farbe)",
|
||||||
|
"background": "str? (CSS-Farbe, rect/circle)",
|
||||||
|
"radius": "int? (%)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"table": {
|
||||||
|
"label": "Tabelle",
|
||||||
|
"category": "basis",
|
||||||
|
"description": "Statische oder datengetriebene Tabelle",
|
||||||
|
"fields": {
|
||||||
|
"columns": "list[str]?",
|
||||||
|
"rows": "list[list]?",
|
||||||
|
"striped": "bool?",
|
||||||
|
"width": "str? (CSS)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"spacer": {
|
||||||
|
"label": "Abstand",
|
||||||
|
"category": "layout",
|
||||||
|
"description": "Vertikaler Abstand",
|
||||||
|
"fields": {"height": "int? (px, Standard 24)"},
|
||||||
|
},
|
||||||
|
"divider": {
|
||||||
|
"label": "Trennlinie",
|
||||||
|
"category": "layout",
|
||||||
|
"description": "Horizontale Trennlinie",
|
||||||
|
"fields": {"color": "str?", "thickness": "int? (px)"},
|
||||||
|
},
|
||||||
|
"placeholder": {
|
||||||
|
"label": "Platzhalter",
|
||||||
|
"category": "daten",
|
||||||
|
"description": "Einzelner Daten-Platzhalter mit Label",
|
||||||
|
"fields": {"key": "str (Pflicht)", "label": "str?"},
|
||||||
|
},
|
||||||
|
"pagebreak": {
|
||||||
|
"label": "Seitenwechsel",
|
||||||
|
"category": "layout",
|
||||||
|
"description": "Erzwingt einen Seitenumbruch im PDF",
|
||||||
|
"fields": {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
VALID_SHAPES = {"line", "rect", "circle"}
|
||||||
|
|
||||||
|
|
||||||
|
class BlockValidationError(ValueError):
|
||||||
|
"""Raised when a block composition is invalid (→ HTTP 422)."""
|
||||||
|
|
||||||
|
|
||||||
|
def _module_contributions() -> list[tuple[str, dict[str, Any]]]:
|
||||||
|
"""Collect ``document_blocks()`` contributions from plugin contracts."""
|
||||||
|
from app.plugins.builtins.contracts import get_contract_registry
|
||||||
|
from app.plugins.registry import get_registry
|
||||||
|
|
||||||
|
contributions: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
for plugin_name in get_registry().list_discovered():
|
||||||
|
contract = get_contract_registry().get_contract(plugin_name)
|
||||||
|
blocks_fn = getattr(contract, "document_blocks", None)
|
||||||
|
if blocks_fn is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
blocks = blocks_fn() or []
|
||||||
|
except Exception: # noqa: BLE001 — a broken contribution must not break the registry
|
||||||
|
continue
|
||||||
|
for block in blocks:
|
||||||
|
btype = block.get("type") if isinstance(block, dict) else None
|
||||||
|
if btype and btype not in BUILTIN_BLOCKS:
|
||||||
|
meta = dict(block)
|
||||||
|
meta.setdefault("category", "modul")
|
||||||
|
meta["contributed_by"] = plugin_name
|
||||||
|
contributions.append((btype, meta))
|
||||||
|
return contributions
|
||||||
|
|
||||||
|
|
||||||
|
def get_document_blocks() -> list[dict[str, Any]]:
|
||||||
|
"""Return all block types (builtin + module contributions) for the palette."""
|
||||||
|
result = [
|
||||||
|
{"type": btype, "label": meta["label"], "category": meta.get("category", "basis"), "description": meta.get("description", ""), "fields": meta.get("fields", {}), "builtin": True}
|
||||||
|
for btype, meta in BUILTIN_BLOCKS.items()
|
||||||
|
]
|
||||||
|
for btype, meta in _module_contributions():
|
||||||
|
result.append({
|
||||||
|
"type": btype,
|
||||||
|
"label": meta.get("label", btype),
|
||||||
|
"category": meta.get("category", "modul"),
|
||||||
|
"description": meta.get("description", ""),
|
||||||
|
"fields": meta.get("fields", {}),
|
||||||
|
"builtin": False,
|
||||||
|
"contributed_by": meta.get("contributed_by"),
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _known_types() -> set[str]:
|
||||||
|
types = set(BUILTIN_BLOCKS.keys())
|
||||||
|
for btype, _meta in _module_contributions():
|
||||||
|
types.add(btype)
|
||||||
|
return types
|
||||||
|
|
||||||
|
|
||||||
|
def _contribution_meta(btype: str) -> dict[str, Any] | None:
|
||||||
|
for ctype, meta in _module_contributions():
|
||||||
|
if ctype == btype:
|
||||||
|
return meta
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def validate_block(block: Any, *, index: int = 0, known_types: set[str] | None = None) -> None:
|
||||||
|
"""Validate a single block dict. Raises BlockValidationError."""
|
||||||
|
if not isinstance(block, dict):
|
||||||
|
raise BlockValidationError(f"Block {index} ist kein Objekt")
|
||||||
|
btype = block.get("type")
|
||||||
|
if not btype or not isinstance(btype, str):
|
||||||
|
raise BlockValidationError(f"Block {index}: 'type' fehlt")
|
||||||
|
if known_types is None:
|
||||||
|
known_types = _known_types()
|
||||||
|
if btype not in known_types:
|
||||||
|
raise BlockValidationError(
|
||||||
|
f"Unbekannter Block-Typ '{btype}' (Block {index})"
|
||||||
|
)
|
||||||
|
config = block.get("config") or {}
|
||||||
|
if not isinstance(config, dict):
|
||||||
|
raise BlockValidationError(f"Block {index} ({btype}): 'config' muss ein Objekt sein")
|
||||||
|
|
||||||
|
if btype == "text":
|
||||||
|
content = config.get("content")
|
||||||
|
if not isinstance(content, str) or not content.strip():
|
||||||
|
raise BlockValidationError("text-Block benötigt ein nicht-leeres 'content'")
|
||||||
|
elif btype == "shape":
|
||||||
|
shape = config.get("shape")
|
||||||
|
if shape not in VALID_SHAPES:
|
||||||
|
raise BlockValidationError(
|
||||||
|
f"shape-Block: 'shape' muss eine von {sorted(VALID_SHAPES)} sein"
|
||||||
|
)
|
||||||
|
elif btype == "table":
|
||||||
|
columns = config.get("columns")
|
||||||
|
rows = config.get("rows")
|
||||||
|
if columns is not None and not isinstance(columns, list):
|
||||||
|
raise BlockValidationError("table-Block: 'columns' muss eine Liste sein")
|
||||||
|
if rows is not None and not isinstance(rows, list):
|
||||||
|
raise BlockValidationError("table-Block: 'rows' muss eine Liste sein")
|
||||||
|
elif btype == "placeholder":
|
||||||
|
key = config.get("key")
|
||||||
|
if not isinstance(key, str) or not key.strip():
|
||||||
|
raise BlockValidationError("placeholder-Block benötigt ein 'key'")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_blocks(blocks: Any) -> None:
|
||||||
|
"""Validate a full block list. Raises BlockValidationError (→ 422)."""
|
||||||
|
if not isinstance(blocks, list):
|
||||||
|
raise BlockValidationError("'blocks' muss eine Liste sein")
|
||||||
|
known = _known_types()
|
||||||
|
for i, block in enumerate(blocks):
|
||||||
|
validate_block(block, index=i, known_types=known)
|
||||||
|
|
||||||
|
|
||||||
|
def contribution_fields(btype: str) -> list[str] | None:
|
||||||
|
"""Data keys a contributed block renders (generic key-value table)."""
|
||||||
|
meta = _contribution_meta(btype)
|
||||||
|
if meta is None:
|
||||||
|
return None
|
||||||
|
return list(meta.get("fields") or [])
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
"""Document renderer — block composition → HTML → PDF (Phase L1-L3).
|
||||||
|
|
||||||
|
Owns the generic rendering for all block types. Modules contribute data and
|
||||||
|
metadata (placeholders, block descriptors) but never markup — the renderer
|
||||||
|
turns every block into HTML itself, which keeps the PDF surface sandboxed
|
||||||
|
(WeasyPrint URL fetcher allows data: URIs only).
|
||||||
|
|
||||||
|
Pipeline:
|
||||||
|
blocks + letterhead config + data
|
||||||
|
→ ``collect_placeholder_defaults`` fills missing data keys with the
|
||||||
|
module's example values (editor preview without entity)
|
||||||
|
→ ``render_blocks_html`` renders each block (Jinja2 for text content,
|
||||||
|
escaped; shapes/dividers as styled divs; images as data:-URI img)
|
||||||
|
→ ``render_document_html`` wraps content in the letterhead page frame
|
||||||
|
(@page geometry + running header/footer elements)
|
||||||
|
→ ``generate_pdf`` (pdf_generator) → bytes
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import html as _html
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from jinja2.sandbox import SandboxedEnvironment
|
||||||
|
|
||||||
|
# Page sizes in mm (CSS @page)
|
||||||
|
PAGE_SIZES = {
|
||||||
|
"A4": "210mm 297mm",
|
||||||
|
"A5": "148mm 210mm",
|
||||||
|
"letter": "8.5in 11in",
|
||||||
|
}
|
||||||
|
|
||||||
|
_PLACEHOLDER_RE = re.compile(r"\{\{\s*([a-zA-Z_][a-zA-Z0-9_.]*)\s*\}\}")
|
||||||
|
|
||||||
|
|
||||||
|
def _jinja_env() -> SandboxedEnvironment:
|
||||||
|
env = SandboxedEnvironment(autoescape=True, trim_blocks=True, lstrip_blocks=True)
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Placeholder defaults ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def collect_placeholder_defaults(entity_type: str | None) -> dict[str, Any]:
|
||||||
|
"""Collect placeholder example values for an entity type.
|
||||||
|
|
||||||
|
Aggregates ``document_placeholders(entity_type)`` contributions from
|
||||||
|
all plugin contracts. Returns ``{key: example}`` for the editor preview
|
||||||
|
(rendering without live entity data must not raise).
|
||||||
|
"""
|
||||||
|
if not entity_type:
|
||||||
|
return {}
|
||||||
|
defaults: dict[str, Any] = {}
|
||||||
|
from app.plugins.builtins.contracts import get_contract_registry
|
||||||
|
from app.plugins.registry import get_registry
|
||||||
|
|
||||||
|
for plugin_name in get_registry().list_discovered():
|
||||||
|
contract = get_contract_registry().get_contract(plugin_name)
|
||||||
|
fn = getattr(contract, "document_placeholders", None)
|
||||||
|
if fn is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
placeholders = fn(entity_type) or []
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
continue
|
||||||
|
for p in placeholders:
|
||||||
|
if isinstance(p, dict) and p.get("key"):
|
||||||
|
defaults[p["key"]] = p.get("example", "")
|
||||||
|
return defaults
|
||||||
|
|
||||||
|
|
||||||
|
def merge_placeholder_defaults(data: dict | None, entity_type: str | None) -> dict[str, Any]:
|
||||||
|
"""Overlay missing keys with placeholder examples (preview-safe data)."""
|
||||||
|
merged: dict[str, Any] = dict(data or {})
|
||||||
|
for key, example in collect_placeholder_defaults(entity_type).items():
|
||||||
|
if key not in merged or merged[key] in (None, ""):
|
||||||
|
merged[key] = example
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Block → HTML ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _style_attr(style: dict | None) -> str:
|
||||||
|
"""Convert a small style dict into an inline style attribute."""
|
||||||
|
if not isinstance(style, dict):
|
||||||
|
return ""
|
||||||
|
allowed = {
|
||||||
|
"fontSize": "font-size",
|
||||||
|
"font-size": "font-size",
|
||||||
|
"color": "color",
|
||||||
|
"textAlign": "text-align",
|
||||||
|
"text-align": "text-align",
|
||||||
|
}
|
||||||
|
parts = []
|
||||||
|
if style.get("bold"):
|
||||||
|
parts.append("font-weight: bold")
|
||||||
|
if style.get("italic"):
|
||||||
|
parts.append("font-style: italic")
|
||||||
|
for k, v in style.items():
|
||||||
|
css = allowed.get(k)
|
||||||
|
if css and isinstance(v, (str, int, float)):
|
||||||
|
parts.append(f"{css}: {_html.escape(str(v))}")
|
||||||
|
return f' style="{"; ".join(parts)}"' if parts else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _render_text(content: str, data: dict[str, Any]) -> str:
|
||||||
|
"""Render Jinja2 placeholders inside a text block (autoescaped)."""
|
||||||
|
try:
|
||||||
|
template = _jinja_env().from_string(content)
|
||||||
|
return template.render(**data)
|
||||||
|
except Exception: # noqa: BLE001 — a broken expression renders literally
|
||||||
|
return _html.escape(content)
|
||||||
|
|
||||||
|
|
||||||
|
def _img_url(config: dict, assets_map: dict[str, str]) -> str | None:
|
||||||
|
"""Resolve an image block to a data:-URI (sandbox policy for WeasyPrint)."""
|
||||||
|
url = config.get("url")
|
||||||
|
if isinstance(url, str) and url.startswith("data:"):
|
||||||
|
return url
|
||||||
|
asset_id = config.get("asset_id")
|
||||||
|
if asset_id:
|
||||||
|
data_url = assets_map.get(str(asset_id))
|
||||||
|
if data_url:
|
||||||
|
return data_url
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def render_block_html(block: dict, data: dict[str, Any], assets_map: dict[str, str] | None = None) -> str:
|
||||||
|
"""Render one block dict to HTML. Unknown types render nothing."""
|
||||||
|
assets_map = assets_map or {}
|
||||||
|
btype = block.get("type")
|
||||||
|
config = block.get("config") or {}
|
||||||
|
|
||||||
|
if btype == "text":
|
||||||
|
rendered = _render_text(str(config.get("content", "")), data)
|
||||||
|
return f'<p class="doc-block doc-text"{_style_attr(config.get("style"))}>{rendered}</p>'
|
||||||
|
|
||||||
|
if btype == "image":
|
||||||
|
url = _img_url(config, assets_map)
|
||||||
|
if not url:
|
||||||
|
return '<div class="doc-block doc-image-missing" data-missing="true"></div>'
|
||||||
|
dims = ""
|
||||||
|
if isinstance(config.get("width"), (int, float)):
|
||||||
|
dims += f' width="{int(config["width"])}"'
|
||||||
|
if isinstance(config.get("height"), (int, float)):
|
||||||
|
dims += f' height="{int(config["height"])}"'
|
||||||
|
alt = _html.escape(str(config.get("alt", "")))
|
||||||
|
align = config.get("align", "left")
|
||||||
|
return f'<div class="doc-block doc-image" style="text-align: {_html.escape(str(align))}"><img src="{url}" alt="{alt}"{dims} /></div>'
|
||||||
|
|
||||||
|
if btype == "shape":
|
||||||
|
shape = config.get("shape")
|
||||||
|
color = _html.escape(str(config.get("color", "#111827")))
|
||||||
|
background = _html.escape(str(config.get("background", "#e5e7eb")))
|
||||||
|
width = config.get("width", "100%")
|
||||||
|
height = int(config.get("height") or 2)
|
||||||
|
radius = int(config.get("radius") or 50)
|
||||||
|
if shape == "line":
|
||||||
|
return (f'<hr class="doc-block doc-shape" style="border: none; '
|
||||||
|
f'border-top: {height}px solid {color}; width: {_html.escape(str(width))}; margin: 8px 0;" />')
|
||||||
|
if shape == "rect":
|
||||||
|
return (f'<div class="doc-block doc-shape" style="width: {_html.escape(str(width))}; '
|
||||||
|
f'height: {height}px; background: {background}; border: 1px solid {color}; margin: 8px 0;"></div>')
|
||||||
|
if shape == "circle":
|
||||||
|
size = height if height > 4 else 40
|
||||||
|
return (f'<div class="doc-block doc-shape" style="width: {size}px; height: {size}px; '
|
||||||
|
f'background: {background}; border: 1px solid {color}; border-radius: {radius}%; margin: 8px 0;"></div>')
|
||||||
|
return ""
|
||||||
|
|
||||||
|
if btype == "divider":
|
||||||
|
color = _html.escape(str(config.get("color", "#d1d5db")))
|
||||||
|
thickness = int(config.get("thickness") or 1)
|
||||||
|
return f'<hr class="doc-block doc-divider" style="border: none; border-top: {thickness}px solid {color}; margin: 12px 0;" />'
|
||||||
|
|
||||||
|
if btype == "spacer":
|
||||||
|
height = int(config.get("height") or 24)
|
||||||
|
return f'<div class="doc-block doc-spacer" style="height: {height}px;"></div>'
|
||||||
|
|
||||||
|
if btype == "table":
|
||||||
|
columns = config.get("columns") or []
|
||||||
|
rows = config.get("rows") or []
|
||||||
|
striped = " doc-table-striped" if config.get("striped") else ""
|
||||||
|
head = ""
|
||||||
|
if columns:
|
||||||
|
head = "<thead><tr>" + "".join(f"<th>{_html.escape(str(c))}</th>" for c in columns) + "</tr></thead>"
|
||||||
|
body_rows = []
|
||||||
|
for row in rows:
|
||||||
|
if not isinstance(row, (list, tuple)):
|
||||||
|
row = [row]
|
||||||
|
cells = "".join(f"<td>{_render_text(str(c), data) if isinstance(c, str) else _html.escape(str(c))}</td>" for c in row)
|
||||||
|
body_rows.append(f"<tr>{cells}</tr>")
|
||||||
|
body = "<tbody>" + "".join(body_rows) + "</tbody>" if body_rows else ""
|
||||||
|
width_style = f' style="width: {_html.escape(str(config["width"]))}"' if config.get("width") else ""
|
||||||
|
return f'<table class="doc-block doc-table{striped}"{width_style}>{head}{body}</table>'
|
||||||
|
|
||||||
|
if btype == "placeholder":
|
||||||
|
key = str(config.get("key", ""))
|
||||||
|
label = config.get("label") or key
|
||||||
|
value = data.get(key, "")
|
||||||
|
return (f'<div class="doc-block doc-placeholder"><span class="doc-placeholder-label">'
|
||||||
|
f'{_html.escape(str(label))}:</span> <span class="doc-placeholder-value">'
|
||||||
|
f'{_html.escape(str(value if value is not None else ""))}</span></div>')
|
||||||
|
|
||||||
|
if btype == "pagebreak":
|
||||||
|
return '<div class="doc-block doc-pagebreak" style="break-after: page;"></div>'
|
||||||
|
|
||||||
|
# Module-contributed block: generic key-value table over declared fields
|
||||||
|
from app.plugins.builtins.report_generator.document_blocks import contribution_fields
|
||||||
|
fields = contribution_fields(btype) if btype else None
|
||||||
|
if fields:
|
||||||
|
rows = "".join(
|
||||||
|
f"<tr><th>{_html.escape(str(f))}</th><td>{_html.escape(str(data.get(f, '')))}</td></tr>"
|
||||||
|
for f in fields
|
||||||
|
)
|
||||||
|
return f'<table class="doc-block doc-contribution"><tbody>{rows}</tbody></table>'
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def render_blocks_html(blocks: list[dict], data: dict[str, Any], assets_map: dict[str, str] | None = None) -> str:
|
||||||
|
"""Render a block list to a HTML fragment."""
|
||||||
|
return "\n".join(render_block_html(b, data, assets_map) for b in blocks if isinstance(b, dict))
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Letterhead frame ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _esc(value: Any) -> str:
|
||||||
|
return _html.escape(str(value))
|
||||||
|
|
||||||
|
|
||||||
|
def render_document_html(
|
||||||
|
blocks: list[dict],
|
||||||
|
data: dict[str, Any],
|
||||||
|
letterhead_config: dict | None = None,
|
||||||
|
assets_map: dict[str, str] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Wrap rendered blocks in the letterhead page frame (full HTML doc)."""
|
||||||
|
config = letterhead_config or {}
|
||||||
|
page = config.get("page") or {}
|
||||||
|
size = page.get("size", "A4")
|
||||||
|
orientation = page.get("orientation", "portrait")
|
||||||
|
margins = page.get("margins") or {}
|
||||||
|
m_top = margins.get("top", 25)
|
||||||
|
m_right = margins.get("right", 20)
|
||||||
|
m_bottom = margins.get("bottom", 25)
|
||||||
|
m_left = margins.get("left", 20)
|
||||||
|
|
||||||
|
page_css = PAGE_SIZES.get(size, PAGE_SIZES["A4"])
|
||||||
|
if orientation == "landscape":
|
||||||
|
# swap width/height for landscape
|
||||||
|
w, h = page_css.split()
|
||||||
|
page_css = f"{h} {w}"
|
||||||
|
|
||||||
|
header = config.get("header") or {}
|
||||||
|
footer = config.get("footer") or {}
|
||||||
|
header_html = ""
|
||||||
|
footer_html = ""
|
||||||
|
extra_top = 0
|
||||||
|
extra_bottom = 0
|
||||||
|
if header.get("enabled"):
|
||||||
|
header_html = render_blocks_html(header.get("blocks") or [], data, assets_map)
|
||||||
|
extra_top = 20 # reserve space for the running header
|
||||||
|
if footer.get("enabled"):
|
||||||
|
footer_html = render_blocks_html(footer.get("blocks") or [], data, assets_map)
|
||||||
|
extra_bottom = 18
|
||||||
|
|
||||||
|
watermark = config.get("watermark") or {}
|
||||||
|
watermark_html = ""
|
||||||
|
if watermark.get("enabled"):
|
||||||
|
text = _esc(watermark.get("text", ""))
|
||||||
|
watermark_html = (
|
||||||
|
f'<div class="doc-watermark">{text}</div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
content = render_blocks_html(blocks, data, assets_map)
|
||||||
|
|
||||||
|
header_css = ""
|
||||||
|
if header_html:
|
||||||
|
header_css = (
|
||||||
|
"#doc-header { position: running(header); }\n"
|
||||||
|
"@page { @top-center { content: element(header); } }\n"
|
||||||
|
)
|
||||||
|
footer_css = ""
|
||||||
|
if footer_html:
|
||||||
|
footer_css = (
|
||||||
|
"#doc-footer { position: running(footer); }\n"
|
||||||
|
"@page { @bottom-center { content: element(footer); } }\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
return f"""<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<style>
|
||||||
|
@page {{
|
||||||
|
size: {page_css};
|
||||||
|
margin: {int(m_top) + extra_top}mm {int(m_right)}mm {int(m_bottom) + extra_bottom}mm {int(m_left)}mm;
|
||||||
|
}}
|
||||||
|
body {{ font-family: 'Helvetica', 'Arial', sans-serif; font-size: 11pt; color: #111827; line-height: 1.5; }}
|
||||||
|
.doc-text {{ margin: 0 0 10px 0; white-space: pre-wrap; }}
|
||||||
|
.doc-table {{ border-collapse: collapse; width: 100%; margin: 10px 0; }}
|
||||||
|
.doc-table th, .doc-table td {{ border: 1px solid #d1d5db; padding: 6px 10px; text-align: left; }}
|
||||||
|
.doc-table-striped tbody tr:nth-child(even) {{ background: #f9fafb; }}
|
||||||
|
.doc-placeholder-label {{ font-weight: 600; color: #374151; }}
|
||||||
|
.doc-placeholder {{ margin: 4px 0; }}
|
||||||
|
.doc-watermark {{ position: fixed; top: 45%; left: 0; right: 0; text-align: center; font-size: 48pt; color: rgba(107, 114, 128, 0.25); transform: rotate(-30deg); }}
|
||||||
|
{header_css}{footer_css}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{f'<div id="doc-header">{header_html}</div>' if header_html else ''}
|
||||||
|
{f'<div id="doc-footer">{footer_html}</div>' if footer_html else ''}
|
||||||
|
{watermark_html}
|
||||||
|
<div id="doc-content">{content}</div>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Assets ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def load_assets_data_urls(
|
||||||
|
db,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
asset_ids: list[str] | None = None,
|
||||||
|
letterhead_id: str | None = None,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Load DocumentAssets and return ``{asset_id: data_url}``.
|
||||||
|
|
||||||
|
Images are embedded as data:-URIs because the WeasyPrint URL fetcher
|
||||||
|
blocks external resources (SSRF policy). Missing assets are skipped.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.plugins.builtins.report_generator.models import DocumentAsset
|
||||||
|
|
||||||
|
if not asset_ids and not letterhead_id:
|
||||||
|
return {}
|
||||||
|
q = select(DocumentAsset).where(
|
||||||
|
DocumentAsset.tenant_id == tenant_id,
|
||||||
|
DocumentAsset.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
if asset_ids:
|
||||||
|
try:
|
||||||
|
ids = [uuid.UUID(a) for a in asset_ids if a]
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
ids = []
|
||||||
|
if not ids:
|
||||||
|
return {}
|
||||||
|
q = q.where(DocumentAsset.id.in_(ids))
|
||||||
|
elif letterhead_id:
|
||||||
|
try:
|
||||||
|
lh = uuid.UUID(letterhead_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return {}
|
||||||
|
q = q.where(DocumentAsset.letterhead_id == lh)
|
||||||
|
|
||||||
|
from app.core.storage import get_storage_backend
|
||||||
|
|
||||||
|
assets = (await db.execute(q)).scalars().all()
|
||||||
|
storage = get_storage_backend()
|
||||||
|
result: dict[str, str] = {}
|
||||||
|
for asset in assets:
|
||||||
|
try:
|
||||||
|
content = await storage.read(asset.storage_path)
|
||||||
|
except Exception: # noqa: BLE001 — missing blob renders as empty
|
||||||
|
continue
|
||||||
|
b64 = base64.b64encode(content).decode("ascii")
|
||||||
|
result[str(asset.id)] = f"data:{asset.mime_type};base64,{b64}"
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def collect_block_asset_ids(blocks: list[dict]) -> list[str]:
|
||||||
|
"""Extract asset_id references from image blocks."""
|
||||||
|
ids: list[str] = []
|
||||||
|
for b in blocks or []:
|
||||||
|
if not isinstance(b, dict) or b.get("type") != "image":
|
||||||
|
continue
|
||||||
|
asset_id = (b.get("config") or {}).get("asset_id")
|
||||||
|
if isinstance(asset_id, str) and asset_id:
|
||||||
|
ids.append(asset_id)
|
||||||
|
return ids
|
||||||
@@ -0,0 +1,966 @@
|
|||||||
|
"""Documents Generator routes (Phase L1-L3) — letterheads, print templates,
|
||||||
|
block registry, preview, render, assets.
|
||||||
|
|
||||||
|
Mounted under /api/v1/reports via the report_generator manifest (documents
|
||||||
|
endpoints live in their own module; the manifest registers it as a second
|
||||||
|
PluginRouteDef).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import uuid as uuid_mod
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Response, UploadFile, status
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.ai.llm_client import llm_complete
|
||||||
|
from app.core.audit import log_audit
|
||||||
|
from app.core.db import get_db, set_tenant_context
|
||||||
|
from app.core.storage import get_storage_backend
|
||||||
|
from app.deps import require_permission, require_workspace_scope
|
||||||
|
from app.plugins.builtins.report_generator.document_blocks import (
|
||||||
|
BlockValidationError,
|
||||||
|
get_document_blocks,
|
||||||
|
validate_blocks,
|
||||||
|
)
|
||||||
|
from app.plugins.builtins.report_generator.document_renderer import (
|
||||||
|
collect_block_asset_ids,
|
||||||
|
collect_placeholder_defaults,
|
||||||
|
load_assets_data_urls,
|
||||||
|
merge_placeholder_defaults,
|
||||||
|
render_document_html,
|
||||||
|
)
|
||||||
|
from app.plugins.builtins.report_generator.einvoice import (
|
||||||
|
EInvoiceValidationError,
|
||||||
|
render_einvoice_xml,
|
||||||
|
resolve_einvoice_data,
|
||||||
|
validate_einvoice_data,
|
||||||
|
)
|
||||||
|
from app.plugins.builtins.report_generator.models import (
|
||||||
|
DocumentAsset,
|
||||||
|
Letterhead,
|
||||||
|
PrintTemplate,
|
||||||
|
)
|
||||||
|
from app.plugins.builtins.report_generator.pdf_generator import generate_pdf
|
||||||
|
from app.plugins.builtins.report_generator.schemas import (
|
||||||
|
DocumentAssetResponse,
|
||||||
|
DocumentPreviewRequest,
|
||||||
|
DocumentPreviewResponse,
|
||||||
|
DocumentRenderRequest,
|
||||||
|
DocumentSuggestRequest,
|
||||||
|
DocumentSuggestResponse,
|
||||||
|
EInvoiceRenderForRequest,
|
||||||
|
EInvoiceRenderRequest,
|
||||||
|
EInvoiceValidationResponse,
|
||||||
|
LetterheadCreate,
|
||||||
|
LetterheadResponse,
|
||||||
|
LetterheadUpdate,
|
||||||
|
PrintTemplateCreate,
|
||||||
|
PrintTemplateResponse,
|
||||||
|
PrintTemplateUpdate,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/reports", tags=["documents"])
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_uuid(val: str, field: str) -> uuid_mod.UUID:
|
||||||
|
try:
|
||||||
|
return uuid_mod.UUID(val)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise HTTPException(
|
||||||
|
400, detail={"detail": f"Invalid {field}", "code": "invalid_id"}
|
||||||
|
) from None
|
||||||
|
|
||||||
|
|
||||||
|
def _letterhead_to_response(lh: Letterhead) -> LetterheadResponse:
|
||||||
|
return LetterheadResponse(
|
||||||
|
id=str(lh.id),
|
||||||
|
name=lh.name,
|
||||||
|
description=lh.description,
|
||||||
|
config=lh.config or {},
|
||||||
|
is_default=lh.is_default,
|
||||||
|
created_by=str(lh.created_by),
|
||||||
|
created_at=lh.created_at,
|
||||||
|
updated_at=lh.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _template_to_response(t: PrintTemplate) -> PrintTemplateResponse:
|
||||||
|
return PrintTemplateResponse(
|
||||||
|
id=str(t.id),
|
||||||
|
name=t.name,
|
||||||
|
description=t.description,
|
||||||
|
letterhead_id=str(t.letterhead_id) if t.letterhead_id else None,
|
||||||
|
entity_type=t.entity_type,
|
||||||
|
blocks=t.blocks or [],
|
||||||
|
output_format=t.output_format,
|
||||||
|
created_by=str(t.created_by),
|
||||||
|
created_at=t.created_at,
|
||||||
|
updated_at=t.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_entity_data(db, tenant_id, entity_type: str, entity_id):
|
||||||
|
"""Fetch document data for an entity via plugin contracts (L1).
|
||||||
|
|
||||||
|
Iterates contracts exposing ``document_data(db, tenant_id, entity_id,
|
||||||
|
entity_type)``; the first non-empty dict wins. Unknown entities → None
|
||||||
|
(→ 404); a known entity with no data → {} (renders empty placeholders).
|
||||||
|
"""
|
||||||
|
from app.plugins.builtins.contracts import get_contract_registry
|
||||||
|
from app.plugins.registry import get_registry
|
||||||
|
|
||||||
|
for plugin_name in get_registry().list_discovered():
|
||||||
|
contract = get_contract_registry().get_contract(plugin_name)
|
||||||
|
fn = getattr(contract, "document_data", None)
|
||||||
|
if fn is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
data = await fn(db, tenant_id, entity_id, entity_type)
|
||||||
|
except Exception: # noqa: BLE001 — broken contribution must not 500
|
||||||
|
continue
|
||||||
|
if data:
|
||||||
|
return data
|
||||||
|
# No contribution produced data: unknown entity type or entity not
|
||||||
|
# found — both are 404 for the caller (never render an empty document
|
||||||
|
# silently).
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_letterhead(db, tenant_id, lh_id) -> Letterhead | None:
|
||||||
|
return (
|
||||||
|
await db.execute(
|
||||||
|
select(Letterhead).where(
|
||||||
|
Letterhead.id == lh_id,
|
||||||
|
Letterhead.tenant_id == tenant_id,
|
||||||
|
Letterhead.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_template(db, tenant_id, tid) -> PrintTemplate | None:
|
||||||
|
return (
|
||||||
|
await db.execute(
|
||||||
|
select(PrintTemplate).where(
|
||||||
|
PrintTemplate.id == tid,
|
||||||
|
PrintTemplate.tenant_id == tenant_id,
|
||||||
|
PrintTemplate.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Letterheads ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/letterheads")
|
||||||
|
async def list_letterheads(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_permission("reports:read")),
|
||||||
|
):
|
||||||
|
"""List letterheads for the current tenant."""
|
||||||
|
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
|
||||||
|
q = (
|
||||||
|
select(Letterhead)
|
||||||
|
.where(
|
||||||
|
Letterhead.tenant_id == tenant_id,
|
||||||
|
Letterhead.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(Letterhead.name)
|
||||||
|
)
|
||||||
|
items = (await db.execute(q)).scalars().all()
|
||||||
|
return {
|
||||||
|
"items": [_letterhead_to_response(item).model_dump() for item in items],
|
||||||
|
"total": len(items),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/letterheads", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_letterhead(
|
||||||
|
body: LetterheadCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_permission("reports:manage_templates")),
|
||||||
|
):
|
||||||
|
"""Create a letterhead."""
|
||||||
|
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid_mod.UUID(current_user["user_id"])
|
||||||
|
await set_tenant_context(db, tenant_id)
|
||||||
|
|
||||||
|
config = body.config or {}
|
||||||
|
for section in ("header", "footer"):
|
||||||
|
section_cfg = config.get(section) or {}
|
||||||
|
blocks = section_cfg.get("blocks")
|
||||||
|
if blocks is not None:
|
||||||
|
try:
|
||||||
|
validate_blocks(blocks)
|
||||||
|
except BlockValidationError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
422,
|
||||||
|
detail={"detail": str(exc), "code": "invalid_block"},
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
lh = Letterhead(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
name=body.name,
|
||||||
|
description=body.description,
|
||||||
|
config=config,
|
||||||
|
is_default=body.is_default,
|
||||||
|
created_by=user_id,
|
||||||
|
owner_id=user_id,
|
||||||
|
)
|
||||||
|
db.add(lh)
|
||||||
|
await db.flush()
|
||||||
|
await log_audit(
|
||||||
|
db, tenant_id, user_id, "create", "letterhead", lh.id,
|
||||||
|
changes={"name": lh.name},
|
||||||
|
)
|
||||||
|
return _letterhead_to_response(lh).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/letterheads/{letterhead_id}")
|
||||||
|
async def get_letterhead(
|
||||||
|
letterhead_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_permission("reports:read")),
|
||||||
|
):
|
||||||
|
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
|
||||||
|
lh_id = _parse_uuid(letterhead_id, "letterhead_id")
|
||||||
|
lh = await _get_letterhead(db, tenant_id, lh_id)
|
||||||
|
if lh is None:
|
||||||
|
raise HTTPException(404, detail={"detail": "Letterhead not found", "code": "not_found"})
|
||||||
|
return _letterhead_to_response(lh).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/letterheads/{letterhead_id}")
|
||||||
|
async def update_letterhead(
|
||||||
|
letterhead_id: str,
|
||||||
|
body: LetterheadUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_permission("reports:manage_templates")),
|
||||||
|
):
|
||||||
|
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid_mod.UUID(current_user["user_id"])
|
||||||
|
lh_id = _parse_uuid(letterhead_id, "letterhead_id")
|
||||||
|
lh = await _get_letterhead(db, tenant_id, lh_id)
|
||||||
|
if lh is None:
|
||||||
|
raise HTTPException(404, detail={"detail": "Letterhead not found", "code": "not_found"})
|
||||||
|
|
||||||
|
if body.config is not None:
|
||||||
|
for section in ("header", "footer"):
|
||||||
|
section_cfg = (body.config or {}).get(section) or {}
|
||||||
|
blocks = section_cfg.get("blocks")
|
||||||
|
if blocks is not None:
|
||||||
|
try:
|
||||||
|
validate_blocks(blocks)
|
||||||
|
except BlockValidationError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
422,
|
||||||
|
detail={"detail": str(exc), "code": "invalid_block"},
|
||||||
|
) from exc
|
||||||
|
lh.config = body.config
|
||||||
|
if body.name is not None:
|
||||||
|
lh.name = body.name
|
||||||
|
if body.description is not None:
|
||||||
|
lh.description = body.description
|
||||||
|
if body.is_default is not None:
|
||||||
|
lh.is_default = body.is_default
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(lh) # onupdate columns expire — refresh async-safe
|
||||||
|
await log_audit(
|
||||||
|
db, tenant_id, user_id, "update", "letterhead", lh.id,
|
||||||
|
changes={"name": lh.name},
|
||||||
|
)
|
||||||
|
return _letterhead_to_response(lh).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/letterheads/{letterhead_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_letterhead(
|
||||||
|
letterhead_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_permission("reports:manage_templates")),
|
||||||
|
):
|
||||||
|
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid_mod.UUID(current_user["user_id"])
|
||||||
|
lh_id = _parse_uuid(letterhead_id, "letterhead_id")
|
||||||
|
lh = await _get_letterhead(db, tenant_id, lh_id)
|
||||||
|
if lh is None:
|
||||||
|
raise HTTPException(404, detail={"detail": "Letterhead not found", "code": "not_found"})
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
lh.deleted_at = datetime.now(UTC)
|
||||||
|
await db.flush()
|
||||||
|
await log_audit(
|
||||||
|
db, tenant_id, user_id, "delete", "letterhead", lh.id,
|
||||||
|
changes={"name": lh.name},
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Letterhead Assets (logo/image upload) ──────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
ALLOWED_IMAGE_MIMES = {"image/png", "image/jpeg", "image/gif", "image/svg+xml", "image/webp"}
|
||||||
|
MAX_ASSET_SIZE = 5 * 1024 * 1024 # 5 MB
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/letterheads/{letterhead_id}/assets",
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def upload_letterhead_asset(
|
||||||
|
letterhead_id: str,
|
||||||
|
file: UploadFile,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_permission("reports:manage_templates")),
|
||||||
|
):
|
||||||
|
"""Upload an image asset for a letterhead (logo, header graphic)."""
|
||||||
|
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid_mod.UUID(current_user["user_id"])
|
||||||
|
lh_id = _parse_uuid(letterhead_id, "letterhead_id")
|
||||||
|
lh = await _get_letterhead(db, tenant_id, lh_id)
|
||||||
|
if lh is None:
|
||||||
|
raise HTTPException(404, detail={"detail": "Letterhead not found", "code": "not_found"})
|
||||||
|
|
||||||
|
mime = (file.content_type or "").lower()
|
||||||
|
if mime not in ALLOWED_IMAGE_MIMES:
|
||||||
|
raise HTTPException(
|
||||||
|
422,
|
||||||
|
detail={
|
||||||
|
"detail": f"Nur Bild-Dateien sind erlaubt (erhalten: {mime})",
|
||||||
|
"code": "invalid_mime_type",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
content = await file.read()
|
||||||
|
if len(content) > MAX_ASSET_SIZE:
|
||||||
|
raise HTTPException(
|
||||||
|
413,
|
||||||
|
detail={"detail": "Bild ist größer als 5 MB", "code": "asset_too_large"},
|
||||||
|
)
|
||||||
|
|
||||||
|
asset_id = uuid_mod.uuid4()
|
||||||
|
storage = get_storage_backend()
|
||||||
|
storage_path = f"documents/{tenant_id}/{asset_id}"
|
||||||
|
await storage.save(storage_path, content)
|
||||||
|
|
||||||
|
asset = DocumentAsset(
|
||||||
|
id=asset_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
letterhead_id=lh_id,
|
||||||
|
filename=file.filename or "asset",
|
||||||
|
mime_type=mime,
|
||||||
|
size_bytes=len(content),
|
||||||
|
storage_path=storage_path,
|
||||||
|
created_by=user_id,
|
||||||
|
owner_id=user_id,
|
||||||
|
)
|
||||||
|
db.add(asset)
|
||||||
|
await db.flush()
|
||||||
|
await log_audit(
|
||||||
|
db, tenant_id, user_id, "create", "document_asset", asset.id,
|
||||||
|
changes={"filename": asset.filename, "letterhead_id": str(lh_id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
import base64 as _b64
|
||||||
|
|
||||||
|
data_url = f"data:{mime};base64,{_b64.b64encode(content).decode('ascii')}"
|
||||||
|
return DocumentAssetResponse(
|
||||||
|
id=str(asset.id),
|
||||||
|
letterhead_id=str(asset.letterhead_id),
|
||||||
|
filename=asset.filename,
|
||||||
|
mime_type=asset.mime_type,
|
||||||
|
size_bytes=asset.size_bytes,
|
||||||
|
data_url=data_url,
|
||||||
|
created_at=asset.created_at,
|
||||||
|
).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/letterheads/{letterhead_id}/assets")
|
||||||
|
async def list_letterhead_assets(
|
||||||
|
letterhead_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_permission("reports:read")),
|
||||||
|
):
|
||||||
|
"""List assets for a letterhead (metadata + data_url)."""
|
||||||
|
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
|
||||||
|
lh_id = _parse_uuid(letterhead_id, "letterhead_id")
|
||||||
|
lh = await _get_letterhead(db, tenant_id, lh_id)
|
||||||
|
if lh is None:
|
||||||
|
raise HTTPException(404, detail={"detail": "Letterhead not found", "code": "not_found"})
|
||||||
|
|
||||||
|
assets_map = await load_assets_data_urls(db, tenant_id, letterhead_id=letterhead_id)
|
||||||
|
q = select(DocumentAsset).where(
|
||||||
|
DocumentAsset.tenant_id == tenant_id,
|
||||||
|
DocumentAsset.letterhead_id == lh_id,
|
||||||
|
DocumentAsset.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
assets = (await db.execute(q)).scalars().all()
|
||||||
|
return [
|
||||||
|
DocumentAssetResponse(
|
||||||
|
id=str(a.id),
|
||||||
|
letterhead_id=str(a.letterhead_id) if a.letterhead_id else None,
|
||||||
|
filename=a.filename,
|
||||||
|
mime_type=a.mime_type,
|
||||||
|
size_bytes=a.size_bytes,
|
||||||
|
data_url=assets_map.get(str(a.id)),
|
||||||
|
created_at=a.created_at,
|
||||||
|
).model_dump()
|
||||||
|
for a in assets
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Print Templates ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/print-templates")
|
||||||
|
async def list_print_templates(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_permission("reports:read")),
|
||||||
|
workspace_scope: dict | None = Depends(require_workspace_scope("reports")),
|
||||||
|
):
|
||||||
|
"""List print templates for the current tenant.
|
||||||
|
|
||||||
|
Phase N4: an active workspace scope restricts the template list to the
|
||||||
|
configured subset (pure AND — never a grant).
|
||||||
|
"""
|
||||||
|
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
|
||||||
|
q = (
|
||||||
|
select(PrintTemplate)
|
||||||
|
.where(
|
||||||
|
PrintTemplate.tenant_id == tenant_id,
|
||||||
|
PrintTemplate.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(PrintTemplate.name)
|
||||||
|
)
|
||||||
|
items = (await db.execute(q)).scalars().all()
|
||||||
|
if workspace_scope:
|
||||||
|
from app.services.workspace_scope_service import scope_uuid_set
|
||||||
|
|
||||||
|
template_scope = scope_uuid_set(workspace_scope.get("template_ids"))
|
||||||
|
if template_scope is not None:
|
||||||
|
items = [t for t in items if t.id in template_scope]
|
||||||
|
return {
|
||||||
|
"items": [_template_to_response(t).model_dump() for t in items],
|
||||||
|
"total": len(items),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/print-templates", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_print_template(
|
||||||
|
body: PrintTemplateCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_permission("reports:manage_templates")),
|
||||||
|
):
|
||||||
|
"""Create a print template (drag/drop block composition)."""
|
||||||
|
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid_mod.UUID(current_user["user_id"])
|
||||||
|
await set_tenant_context(db, tenant_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
validate_blocks(body.blocks)
|
||||||
|
except BlockValidationError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
422, detail={"detail": str(exc), "code": "invalid_block"}
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
letterhead_id = None
|
||||||
|
if body.letterhead_id:
|
||||||
|
letterhead_id = _parse_uuid(body.letterhead_id, "letterhead_id")
|
||||||
|
if await _get_letterhead(db, tenant_id, letterhead_id) is None:
|
||||||
|
raise HTTPException(
|
||||||
|
404, detail={"detail": "Letterhead not found", "code": "not_found"}
|
||||||
|
)
|
||||||
|
|
||||||
|
template = PrintTemplate(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
name=body.name,
|
||||||
|
description=body.description,
|
||||||
|
letterhead_id=letterhead_id,
|
||||||
|
entity_type=body.entity_type,
|
||||||
|
blocks=body.blocks,
|
||||||
|
output_format=body.output_format,
|
||||||
|
created_by=user_id,
|
||||||
|
owner_id=user_id,
|
||||||
|
)
|
||||||
|
db.add(template)
|
||||||
|
await db.flush()
|
||||||
|
await log_audit(
|
||||||
|
db, tenant_id, user_id, "create", "print_template", template.id,
|
||||||
|
changes={"name": template.name},
|
||||||
|
)
|
||||||
|
return _template_to_response(template).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/print-templates/{template_id}")
|
||||||
|
async def get_print_template(
|
||||||
|
template_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_permission("reports:read")),
|
||||||
|
):
|
||||||
|
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
|
||||||
|
tid = _parse_uuid(template_id, "template_id")
|
||||||
|
template = await _get_template(db, tenant_id, tid)
|
||||||
|
if template is None:
|
||||||
|
raise HTTPException(404, detail={"detail": "Template not found", "code": "not_found"})
|
||||||
|
return _template_to_response(template).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/print-templates/{template_id}")
|
||||||
|
async def update_print_template(
|
||||||
|
template_id: str,
|
||||||
|
body: PrintTemplateUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_permission("reports:manage_templates")),
|
||||||
|
):
|
||||||
|
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid_mod.UUID(current_user["user_id"])
|
||||||
|
tid = _parse_uuid(template_id, "template_id")
|
||||||
|
template = await _get_template(db, tenant_id, tid)
|
||||||
|
if template is None:
|
||||||
|
raise HTTPException(404, detail={"detail": "Template not found", "code": "not_found"})
|
||||||
|
|
||||||
|
if body.blocks is not None:
|
||||||
|
try:
|
||||||
|
validate_blocks(body.blocks)
|
||||||
|
except BlockValidationError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
422, detail={"detail": str(exc), "code": "invalid_block"}
|
||||||
|
) from exc
|
||||||
|
template.blocks = body.blocks
|
||||||
|
if body.name is not None:
|
||||||
|
template.name = body.name
|
||||||
|
if body.description is not None:
|
||||||
|
template.description = body.description
|
||||||
|
if body.entity_type is not None:
|
||||||
|
template.entity_type = body.entity_type
|
||||||
|
if body.output_format is not None:
|
||||||
|
template.output_format = body.output_format
|
||||||
|
if body.letterhead_id is not None:
|
||||||
|
if body.letterhead_id:
|
||||||
|
lh_id = _parse_uuid(body.letterhead_id, "letterhead_id")
|
||||||
|
if await _get_letterhead(db, tenant_id, lh_id) is None:
|
||||||
|
raise HTTPException(
|
||||||
|
404, detail={"detail": "Letterhead not found", "code": "not_found"}
|
||||||
|
)
|
||||||
|
template.letterhead_id = lh_id
|
||||||
|
else:
|
||||||
|
template.letterhead_id = None
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(template) # onupdate columns expire — refresh async-safe
|
||||||
|
await log_audit(
|
||||||
|
db, tenant_id, user_id, "update", "print_template", template.id,
|
||||||
|
changes={"name": template.name},
|
||||||
|
)
|
||||||
|
return _template_to_response(template).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/print-templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_print_template(
|
||||||
|
template_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_permission("reports:manage_templates")),
|
||||||
|
):
|
||||||
|
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid_mod.UUID(current_user["user_id"])
|
||||||
|
tid = _parse_uuid(template_id, "template_id")
|
||||||
|
template = await _get_template(db, tenant_id, tid)
|
||||||
|
if template is None:
|
||||||
|
raise HTTPException(404, detail={"detail": "Template not found", "code": "not_found"})
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
template.deleted_at = datetime.now(UTC)
|
||||||
|
await db.flush()
|
||||||
|
await log_audit(
|
||||||
|
db, tenant_id, user_id, "delete", "print_template", template.id,
|
||||||
|
changes={"name": template.name},
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Block Registry / Placeholders ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/document-blocks")
|
||||||
|
async def list_document_blocks(
|
||||||
|
current_user: dict = Depends(require_permission("reports:read")),
|
||||||
|
):
|
||||||
|
"""List all available block types (builtin + module contributions)."""
|
||||||
|
return get_document_blocks()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/document-placeholders")
|
||||||
|
async def list_document_placeholders(
|
||||||
|
entity_type: str | None = None,
|
||||||
|
current_user: dict = Depends(require_permission("reports:read")),
|
||||||
|
):
|
||||||
|
"""List available placeholders per entity type (module contributions)."""
|
||||||
|
from app.plugins.builtins.contracts import get_contract_registry
|
||||||
|
from app.plugins.registry import get_registry
|
||||||
|
|
||||||
|
result: dict[str, list[dict]] = {}
|
||||||
|
for plugin_name in get_registry().list_discovered():
|
||||||
|
contract = get_contract_registry().get_contract(plugin_name)
|
||||||
|
fn = getattr(contract, "document_placeholders", None)
|
||||||
|
if fn is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
# contracts declare which entity types they serve
|
||||||
|
types_fn = getattr(contract, "document_entity_types", None)
|
||||||
|
entity_types = types_fn() if types_fn else ["contact"]
|
||||||
|
for etype in entity_types:
|
||||||
|
placeholders = fn(etype) or []
|
||||||
|
if placeholders:
|
||||||
|
result.setdefault(etype, []).extend(placeholders)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
continue
|
||||||
|
if entity_type:
|
||||||
|
return {entity_type: result.get(entity_type, [])}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Preview (HTML) ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/documents/preview")
|
||||||
|
async def preview_document(
|
||||||
|
body: DocumentPreviewRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_permission("reports:read")),
|
||||||
|
):
|
||||||
|
"""Render blocks to HTML for the live editor preview (no PDF)."""
|
||||||
|
try:
|
||||||
|
validate_blocks(body.blocks)
|
||||||
|
except BlockValidationError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
422, detail={"detail": str(exc), "code": "invalid_block"}
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
|
||||||
|
|
||||||
|
# load assets referenced by image blocks (editor preview shows images)
|
||||||
|
asset_ids = collect_block_asset_ids(body.blocks)
|
||||||
|
header_blocks = ((body.letterhead_config or {}).get("header") or {}).get("blocks") or []
|
||||||
|
footer_blocks = ((body.letterhead_config or {}).get("footer") or {}).get("blocks") or []
|
||||||
|
asset_ids += collect_block_asset_ids(header_blocks)
|
||||||
|
asset_ids += collect_block_asset_ids(footer_blocks)
|
||||||
|
assets_map = await load_assets_data_urls(db, tenant_id, asset_ids=asset_ids) if asset_ids else {}
|
||||||
|
|
||||||
|
data = merge_placeholder_defaults(body.data, body.entity_type)
|
||||||
|
html = render_document_html(
|
||||||
|
body.blocks,
|
||||||
|
data,
|
||||||
|
letterhead_config=body.letterhead_config,
|
||||||
|
assets_map=assets_map,
|
||||||
|
)
|
||||||
|
return DocumentPreviewResponse(html=html).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Render (PDF) ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/print-templates/{template_id}/render")
|
||||||
|
async def render_print_template(
|
||||||
|
template_id: str,
|
||||||
|
body: DocumentRenderRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_permission("reports:generate")),
|
||||||
|
):
|
||||||
|
"""Render a stored print template with entity data to PDF."""
|
||||||
|
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
|
||||||
|
tid = _parse_uuid(template_id, "template_id")
|
||||||
|
template = await _get_template(db, tenant_id, tid)
|
||||||
|
if template is None:
|
||||||
|
raise HTTPException(404, detail={"detail": "Template not found", "code": "not_found"})
|
||||||
|
|
||||||
|
entity_id = _parse_uuid(body.entity_id, "entity_id")
|
||||||
|
entity_data = await _load_entity_data(db, tenant_id, body.entity_type, entity_id)
|
||||||
|
if entity_data is None:
|
||||||
|
raise HTTPException(
|
||||||
|
404,
|
||||||
|
detail={
|
||||||
|
"detail": f"Kein Daten-Beitrag für entity_type '{body.entity_type}' — Modul nicht aktiv oder Entität unbekannt",
|
||||||
|
"code": "no_data_source",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# resolve letterhead
|
||||||
|
letterhead_config = None
|
||||||
|
letterhead_id = template.letterhead_id
|
||||||
|
if letterhead_id:
|
||||||
|
lh = await _get_letterhead(db, tenant_id, letterhead_id)
|
||||||
|
if lh:
|
||||||
|
letterhead_config = lh.config or {}
|
||||||
|
|
||||||
|
# load image assets from template blocks + letterhead blocks
|
||||||
|
asset_ids = collect_block_asset_ids(template.blocks or [])
|
||||||
|
if letterhead_config:
|
||||||
|
asset_ids += collect_block_asset_ids((letterhead_config.get("header") or {}).get("blocks") or [])
|
||||||
|
asset_ids += collect_block_asset_ids((letterhead_config.get("footer") or {}).get("blocks") or [])
|
||||||
|
assets_map = await load_assets_data_urls(db, tenant_id, asset_ids=asset_ids) if asset_ids else {}
|
||||||
|
|
||||||
|
data = merge_placeholder_defaults(entity_data, template.entity_type)
|
||||||
|
html = render_document_html(
|
||||||
|
template.blocks or [],
|
||||||
|
data,
|
||||||
|
letterhead_config=letterhead_config,
|
||||||
|
assets_map=assets_map,
|
||||||
|
)
|
||||||
|
|
||||||
|
# sync PDF generation — close DB before CPU-bound work (existing pattern)
|
||||||
|
await db.close()
|
||||||
|
try:
|
||||||
|
pdf_bytes = generate_pdf(html)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
500,
|
||||||
|
detail={"detail": f"PDF-Generierung fehlgeschlagen: {exc}", "code": "generation_failed"},
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
filename = f"{template.name.replace(' ', '_')}_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}.pdf"
|
||||||
|
return StreamingResponse(
|
||||||
|
io.BytesIO(pdf_bytes),
|
||||||
|
media_type="application/pdf",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── E-Invoice (EN16931/XRechnung format layer, Phase L5) ────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/einvoice/render")
|
||||||
|
async def render_einvoice(
|
||||||
|
body: EInvoiceRenderRequest,
|
||||||
|
current_user: dict = Depends(require_permission("reports:generate")),
|
||||||
|
):
|
||||||
|
"""Render inline invoice data to EN16931/XRechnung CII XML.
|
||||||
|
|
||||||
|
Pure format endpoint - the sales module later uses render-for with
|
||||||
|
its einvoice_data() contract hook.
|
||||||
|
"""
|
||||||
|
data = body.model_dump(exclude_none=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
validate_einvoice_data(data)
|
||||||
|
except EInvoiceValidationError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
422,
|
||||||
|
detail={"detail": "; ".join(exc.missing), "code": "invalid_einvoice", "missing": exc.missing},
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
xml = render_einvoice_xml(data)
|
||||||
|
filename = f"{data['invoice_number'].replace(' ', '_')}.xml"
|
||||||
|
return Response(
|
||||||
|
content=xml,
|
||||||
|
media_type="application/xml",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/einvoice/validate")
|
||||||
|
async def validate_einvoice(
|
||||||
|
body: EInvoiceRenderRequest,
|
||||||
|
current_user: dict = Depends(require_permission("reports:read")),
|
||||||
|
):
|
||||||
|
"""Validate invoice data without rendering (missing BT/BG terms -> 422)."""
|
||||||
|
data = body.model_dump(exclude_none=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
validate_einvoice_data(data)
|
||||||
|
except EInvoiceValidationError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
422,
|
||||||
|
detail={"detail": "; ".join(exc.missing), "code": "invalid_einvoice", "missing": exc.missing},
|
||||||
|
) from exc
|
||||||
|
return EInvoiceValidationResponse(valid=True).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/einvoice/render-for")
|
||||||
|
async def render_einvoice_for_entity(
|
||||||
|
body: EInvoiceRenderForRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_permission("reports:generate")),
|
||||||
|
):
|
||||||
|
"""Render an e-invoice for an entity via the einvoice_data() contract.
|
||||||
|
|
||||||
|
Docking point for the future sales module: it contributes
|
||||||
|
einvoice_data(db, tenant_id, entity_id, entity_type) and this endpoint
|
||||||
|
handles validation + XML generation. No contribution -> 404.
|
||||||
|
"""
|
||||||
|
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
|
||||||
|
entity_id = _parse_uuid(body.entity_id, "entity_id")
|
||||||
|
|
||||||
|
data = await resolve_einvoice_data(db, tenant_id, body.entity_type, entity_id)
|
||||||
|
if data is None:
|
||||||
|
raise HTTPException(
|
||||||
|
404,
|
||||||
|
detail={
|
||||||
|
"detail": (
|
||||||
|
f"Kein E-Invoice-Datenbeitrag fuer entity_type '{body.entity_type}' - "
|
||||||
|
"Modul nicht aktiv oder Entitaet unbekannt"
|
||||||
|
),
|
||||||
|
"code": "no_data_source",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
validate_einvoice_data(data)
|
||||||
|
except EInvoiceValidationError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
422,
|
||||||
|
detail={"detail": "; ".join(exc.missing), "code": "invalid_einvoice", "missing": exc.missing},
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
xml = render_einvoice_xml(data)
|
||||||
|
invoice_number = str(data.get("invoice_number") or "einvoice")
|
||||||
|
filename = f"{invoice_number.replace(' ', '_')}.xml"
|
||||||
|
return Response(
|
||||||
|
content=xml,
|
||||||
|
media_type="application/xml",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── AI block suggestion (Phase L4) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
_SUGGEST_HEADER = (
|
||||||
|
"Du bist ein Assistent fuer einen Drag&Drop-Dokumenteditor fuer deutsche Geschaeftsdokumente.\n"
|
||||||
|
"Erstelle aus der Nutzeranfrage eine Block-Komposition fuer eine Druckvorlage.\n\n"
|
||||||
|
"Antworte AUSSCHLIESSLICH mit JSON in dieser Struktur:\n"
|
||||||
|
'{"blocks": [{"id": "b1", "type": "<block-type>", "config": {}}], "notes": "kurze Erklaerung"}\n\n'
|
||||||
|
"Verfuegbare Block-Typen mit ihren config-Feldern:\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
_SUGGEST_MIDDLE = "\n\nVerfuegbare Platzhalter (in Text-Bloecken in der Form {key} verwendbar):\n"
|
||||||
|
|
||||||
|
_SUGGEST_FOOTER = (
|
||||||
|
"\n\nRegeln:\n"
|
||||||
|
"- Nutze nur gelistete Block-Typen.\n"
|
||||||
|
"- Jeder Block braucht eine eindeutige id (b1, b2, ...).\n"
|
||||||
|
"- Text-Inhalte koennen Jinja2-Platzhalter wie {firstname} enthalten.\n"
|
||||||
|
'- shape-Bloecke benoetigen "shape": "line", "rect" oder "circle".\n'
|
||||||
|
"- Antworte nur mit dem JSON-Objekt, kein Markdown, keine Code-Fences."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_code_fences(text: str) -> str:
|
||||||
|
"""Strip ```json ...``` fences LLMs like to add."""
|
||||||
|
stripped = text.strip()
|
||||||
|
if stripped.startswith("```"):
|
||||||
|
first_newline = stripped.find("\n")
|
||||||
|
if first_newline != -1:
|
||||||
|
stripped = stripped[first_newline + 1 :]
|
||||||
|
if stripped.rstrip().endswith("```"):
|
||||||
|
stripped = stripped.rstrip()[:-3]
|
||||||
|
return stripped.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_suggested_blocks(blocks: Any) -> list[dict]:
|
||||||
|
"""Filter AI blocks down to registry-valid entries with server ids."""
|
||||||
|
if not isinstance(blocks, list):
|
||||||
|
return []
|
||||||
|
from app.plugins.builtins.report_generator.document_blocks import (
|
||||||
|
BlockValidationError,
|
||||||
|
_known_types,
|
||||||
|
validate_block,
|
||||||
|
)
|
||||||
|
|
||||||
|
known = _known_types()
|
||||||
|
result: list[dict] = []
|
||||||
|
for i, block in enumerate(blocks):
|
||||||
|
if not isinstance(block, dict):
|
||||||
|
continue
|
||||||
|
candidate = {
|
||||||
|
"id": str(block.get("id") or f"ai_{uuid_mod.uuid4().hex[:12]}"),
|
||||||
|
"type": block.get("type"),
|
||||||
|
"config": block.get("config") or {},
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
validate_block(candidate, index=i, known_types=known)
|
||||||
|
except BlockValidationError:
|
||||||
|
continue # drop invalid AI output instead of failing the request
|
||||||
|
result.append(candidate)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/documents/suggest")
|
||||||
|
async def suggest_document_blocks(
|
||||||
|
body: DocumentSuggestRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_permission("reports:manage_templates")),
|
||||||
|
):
|
||||||
|
"""KI-Steuerung (L4): natural language -> block composition suggestion.
|
||||||
|
|
||||||
|
Goes through the central llm_complete (cost tracking, tenant budget).
|
||||||
|
The AI response is sanitized against the block registry - invalid
|
||||||
|
blocks are dropped, ids are assigned server-side. LLM failures -> 502.
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
tenant_id = uuid_mod.UUID(current_user["tenant_id"])
|
||||||
|
|
||||||
|
block_types = get_document_blocks()
|
||||||
|
block_types_desc = _json.dumps(
|
||||||
|
[
|
||||||
|
{"type": b["type"], "label": b["label"], "fields": b.get("fields", {})}
|
||||||
|
for b in block_types
|
||||||
|
],
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
placeholders = collect_placeholder_defaults(body.entity_type) if body.entity_type else {}
|
||||||
|
placeholders_desc = _json.dumps(placeholders, ensure_ascii=False)
|
||||||
|
|
||||||
|
system_prompt = (
|
||||||
|
_SUGGEST_HEADER + block_types_desc + _SUGGEST_MIDDLE + placeholders_desc + _SUGGEST_FOOTER
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await llm_complete(
|
||||||
|
model="gpt-4o-mini",
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": body.prompt},
|
||||||
|
],
|
||||||
|
temperature=0.3,
|
||||||
|
max_tokens=2000,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
502,
|
||||||
|
detail={"detail": f"KI-Antwort fehlgeschlagen: {exc}", "code": "ai_unavailable"},
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
raw_content = result.get("content") or ""
|
||||||
|
try:
|
||||||
|
parsed = _json.loads(_strip_code_fences(raw_content))
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
502,
|
||||||
|
detail={
|
||||||
|
"detail": "KI-Antwort war kein valides JSON",
|
||||||
|
"code": "invalid_ai_response",
|
||||||
|
},
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
raise HTTPException(
|
||||||
|
502,
|
||||||
|
detail={"detail": "KI-Antwort-Struktur ungueltig", "code": "invalid_ai_response"},
|
||||||
|
)
|
||||||
|
|
||||||
|
blocks = _sanitize_suggested_blocks(parsed.get("blocks"))
|
||||||
|
notes = str(parsed.get("notes") or "")
|
||||||
|
return DocumentSuggestResponse(blocks=blocks, notes=notes).model_dump()
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
"""E-Invoice format layer — EN16931 / XRechnung CII XML generation (Phase L5).
|
||||||
|
|
||||||
|
This module is deliberately a pure FORMAT layer: it turns validated invoice
|
||||||
|
data (plain dicts) into Cross Industry Invoice XML. It knows nothing about
|
||||||
|
how invoices are stored — the future sales module will own the entities
|
||||||
|
and dock via the ``einvoice_data()`` contract hook
|
||||||
|
(``resolve_einvoice_data``).
|
||||||
|
|
||||||
|
Profiles:
|
||||||
|
- ``en16931``: GuidelineID ``urn:cen.eu:en16931:2017``
|
||||||
|
- ``xrechnung``: ``...#compliant#urn:xoev-de:kosit:standard:xrechnung_3.0``
|
||||||
|
|
||||||
|
All monetary math uses ``Decimal`` quantized to 2 places (commercial
|
||||||
|
rounding) so header sums are consistent with line tax calculations.
|
||||||
|
XML escaping is delegated to ElementTree — no manual string concatenation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import date
|
||||||
|
from decimal import ROUND_HALF_UP, Decimal
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
|
# ─── CII namespaces (XRechnung 3.0 / D16B) ──────────────────────────────────
|
||||||
|
|
||||||
|
NS_RSM = "urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"
|
||||||
|
NS_RAM = "urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100"
|
||||||
|
NS_UDT = "urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100"
|
||||||
|
|
||||||
|
GUIDELINE_EN16931 = "urn:cen.eu:en16931:2017"
|
||||||
|
GUIDELINE_XRECHNUNG = (
|
||||||
|
"urn:cen.eu:en16931:2017#compliant#urn:xoev-de:kosit:standard:xrechnung_3.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
_CENT = Decimal("0.01")
|
||||||
|
|
||||||
|
|
||||||
|
def _money(value) -> Decimal:
|
||||||
|
"""Quantize to 2 decimal places (commercial rounding, BR-CL-16)."""
|
||||||
|
return Decimal(str(value)).quantize(_CENT, rounding=ROUND_HALF_UP)
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_date(value: str) -> str:
|
||||||
|
"""ISO date (YYYY-MM-DD) → CII format 102 (YYYYMMDD). Raises ValueError."""
|
||||||
|
parsed = date.fromisoformat(str(value).strip())
|
||||||
|
return parsed.strftime("%Y%m%d")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Validation ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EInvoiceValidationError(ValueError):
|
||||||
|
"""Raised when mandatory EN16931 fields are missing or invalid.
|
||||||
|
|
||||||
|
``missing`` carries human-readable entries including BT/BG field codes
|
||||||
|
so the API can surface exactly which business terms fail.
|
||||||
|
"""
|
||||||
|
|
||||||
|
missing: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def _non_empty(data: dict, key: str) -> str | None:
|
||||||
|
value = data.get(key)
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
return value.strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def validate_einvoice_data(data: dict) -> None:
|
||||||
|
"""Validate mandatory EN16931 business terms (subset enforced today).
|
||||||
|
|
||||||
|
Raises EInvoiceValidationError with the collected BT/BG entries.
|
||||||
|
"""
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise EInvoiceValidationError(missing=["payload: Objekt erwartet"])
|
||||||
|
|
||||||
|
missing: list[str] = []
|
||||||
|
|
||||||
|
if not _non_empty(data, "invoice_number"):
|
||||||
|
missing.append("BT-1: Rechnungsnummer fehlt")
|
||||||
|
if not _non_empty(data, "issue_date"):
|
||||||
|
missing.append("BT-2: Rechnungsdatum fehlt")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
_fmt_date(data["issue_date"])
|
||||||
|
except ValueError:
|
||||||
|
missing.append("BT-2: Rechnungsdatum muss YYYY-MM-DD sein")
|
||||||
|
if not _non_empty(data, "type_code"):
|
||||||
|
missing.append("BT-3: Rechnungsart fehlt")
|
||||||
|
if not _non_empty(data, "currency"):
|
||||||
|
missing.append("BT-5: Währung fehlt")
|
||||||
|
|
||||||
|
# Seller: name + at least one tax registration (BT-31 USt-IdNr or
|
||||||
|
# BT-32 Steuernummer — BR-DE-16 for German invoices).
|
||||||
|
if not _non_empty(data, "seller_name"):
|
||||||
|
missing.append("BT-27: Verkäufername fehlt")
|
||||||
|
seller_vat = _non_empty(data, "seller_vat_id")
|
||||||
|
seller_tax = _non_empty(data, "seller_tax_id")
|
||||||
|
if not seller_vat and not seller_tax:
|
||||||
|
missing.append("BT-31: USt-IdNr. oder BT-32: Steuernummer des Verkäufers fehlt")
|
||||||
|
|
||||||
|
if not _non_empty(data, "buyer_name"):
|
||||||
|
missing.append("BT-10: Empfängername fehlt")
|
||||||
|
|
||||||
|
if data.get("due_date"):
|
||||||
|
try:
|
||||||
|
_fmt_date(str(data["due_date"]))
|
||||||
|
except ValueError:
|
||||||
|
missing.append("BT-9: Fälligkeitsdatum muss YYYY-MM-DD sein")
|
||||||
|
|
||||||
|
lines = data.get("line_items")
|
||||||
|
if not isinstance(lines, list) or len(lines) == 0:
|
||||||
|
missing.append("BG-25: mindestens eine Rechnungsposition erforderlich")
|
||||||
|
else:
|
||||||
|
for i, line in enumerate(lines, start=1):
|
||||||
|
if not isinstance(line, dict):
|
||||||
|
missing.append(f"Position {i}: Objekt erwartet")
|
||||||
|
continue
|
||||||
|
if not _non_empty(line, "name"):
|
||||||
|
missing.append(f"BT-126: Positionsname fehlt (Position {i})")
|
||||||
|
try:
|
||||||
|
Decimal(str(line.get("unit_net_price", "0")))
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
missing.append(f"BT-146: Einzelpreis ungültig (Position {i})")
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
raise EInvoiceValidationError(missing=missing)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── XML rendering ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _sub(parent: ET.Element, tag: str, text: str | None = None, **attrib) -> ET.Element:
|
||||||
|
el = ET.SubElement(parent, f"{{{NS_RAM}}}{tag}", {k: str(v) for k, v in attrib.items()})
|
||||||
|
if text is not None:
|
||||||
|
el.text = str(text)
|
||||||
|
return el
|
||||||
|
|
||||||
|
|
||||||
|
def _date_el(parent: ET.Element, tag: str, iso_date: str) -> None:
|
||||||
|
wrapper = _sub(parent, tag)
|
||||||
|
dt = ET.SubElement(wrapper, f"{{{NS_UDT}}}DateTimeString")
|
||||||
|
dt.set("format", "102")
|
||||||
|
dt.text = _fmt_date(iso_date)
|
||||||
|
|
||||||
|
|
||||||
|
def _address(parent: ET.Element, addr: dict | None) -> None:
|
||||||
|
addr = addr or {}
|
||||||
|
postal = _sub(parent, "PostalTradeAddress")
|
||||||
|
if addr.get("street"):
|
||||||
|
_sub(postal, "LineOne", str(addr["street"]))
|
||||||
|
if addr.get("postal_code"):
|
||||||
|
_sub(postal, "PostcodeCode", str(addr["postal_code"]))
|
||||||
|
if addr.get("city"):
|
||||||
|
_sub(postal, "CityName", str(addr["city"]))
|
||||||
|
_sub(postal, "CountryID", str(addr.get("country") or "DE"))
|
||||||
|
|
||||||
|
|
||||||
|
def _line_sums(data: dict) -> tuple[list[dict], Decimal, dict[str, list[Decimal]]]:
|
||||||
|
"""Compute per-line and total sums.
|
||||||
|
|
||||||
|
Returns (line_amounts, line_total, taxes) where line_amounts[i] is the
|
||||||
|
net amount of line i (supplied ``line_net_amount`` wins over
|
||||||
|
quantity × unit price — discounts already applied), and taxes maps
|
||||||
|
vat_rate → [basis, tax_amount] aggregated for the header breakdown.
|
||||||
|
"""
|
||||||
|
line_amounts: list[Decimal] = []
|
||||||
|
line_total = Decimal("0.00")
|
||||||
|
taxes: dict[str, list[Decimal]] = {}
|
||||||
|
|
||||||
|
for line in data.get("line_items") or []:
|
||||||
|
qty = Decimal(str(line.get("quantity", 1)))
|
||||||
|
price = _money(line.get("unit_net_price", 0))
|
||||||
|
if line.get("line_net_amount") is not None:
|
||||||
|
net = _money(line["line_net_amount"])
|
||||||
|
else:
|
||||||
|
net = _money(qty * price)
|
||||||
|
line_amounts.append(net)
|
||||||
|
line_total = _money(line_total + net)
|
||||||
|
|
||||||
|
rate = str(line.get("vat_rate", 0))
|
||||||
|
basis, tax = taxes.get(rate, [Decimal("0.00"), Decimal("0.00")])
|
||||||
|
tax_amount = _money(net * Decimal(rate) / Decimal(100))
|
||||||
|
taxes[rate] = [_money(basis + net), _money(tax + tax_amount)]
|
||||||
|
|
||||||
|
return line_amounts, _money(line_total), taxes
|
||||||
|
|
||||||
|
|
||||||
|
def render_einvoice_xml(data: dict) -> str:
|
||||||
|
"""Render validated invoice data to CII XML (UTF-8, declaration header)."""
|
||||||
|
validate_einvoice_data(data)
|
||||||
|
|
||||||
|
profile = str(data.get("profile") or "en16931").lower()
|
||||||
|
guideline = GUIDELINE_XRECHNUNG if profile == "xrechnung" else GUIDELINE_EN16931
|
||||||
|
|
||||||
|
ET.register_namespace("rsm", NS_RSM)
|
||||||
|
ET.register_namespace("ram", NS_RAM)
|
||||||
|
ET.register_namespace("udt", NS_UDT)
|
||||||
|
root = ET.Element(f"{{{NS_RSM}}}CrossIndustryInvoice")
|
||||||
|
|
||||||
|
# ── ExchangedDocumentContext (BT-24) ──
|
||||||
|
ctx = ET.SubElement(root, f"{{{NS_RSM}}}ExchangedDocumentContext")
|
||||||
|
guideline_param = _sub(ctx, "GuidelineSpecifiedDocumentContextParameter")
|
||||||
|
_sub(guideline_param, "ID", guideline)
|
||||||
|
|
||||||
|
# ── ExchangedDocument (BT-1..BT-22) ──
|
||||||
|
doc = ET.SubElement(root, f"{{{NS_RSM}}}ExchangedDocument")
|
||||||
|
_sub(doc, "ID", data["invoice_number"])
|
||||||
|
_sub(doc, "TypeCode", data["type_code"])
|
||||||
|
_date_el(doc, "IssueDateTime", data["issue_date"])
|
||||||
|
if data.get("note"):
|
||||||
|
note = _sub(doc, "IncludedNote")
|
||||||
|
_sub(note, "Content", str(data["note"]))
|
||||||
|
|
||||||
|
# ── SupplyChainTradeTransaction ──
|
||||||
|
txn = ET.SubElement(root, f"{{{NS_RSM}}}SupplyChainTradeTransaction")
|
||||||
|
|
||||||
|
line_amounts, line_total, taxes = _line_sums(data)
|
||||||
|
for idx, (line, net) in enumerate(zip(data["line_items"], line_amounts, strict=True), start=1):
|
||||||
|
item = ET.SubElement(txn, f"{{{NS_RSM}}}IncludedSupplyChainTradeLineItem")
|
||||||
|
line_doc = _sub(item, "AssociatedDocumentLineDocument")
|
||||||
|
_sub(line_doc, "LineID", str(idx))
|
||||||
|
|
||||||
|
product = _sub(item, "SpecifiedTradeProduct")
|
||||||
|
_sub(product, "Name", line["name"])
|
||||||
|
|
||||||
|
agreement = _sub(item, "SpecifiedLineTradeAgreement")
|
||||||
|
net_price = _sub(agreement, "NetPriceProductTradePrice")
|
||||||
|
_sub(net_price, "ChargeAmount", _money(line.get("unit_net_price", 0)))
|
||||||
|
|
||||||
|
delivery = _sub(item, "SpecifiedLineTradeDelivery")
|
||||||
|
_sub(delivery, "BilledQuantity", Decimal(str(line.get("quantity", 1))), unitCode=line.get("unit") or "HUR")
|
||||||
|
|
||||||
|
settlement = _sub(item, "SpecifiedLineTradeSettlement")
|
||||||
|
line_tax = _sub(settlement, "ApplicableTradeTax")
|
||||||
|
_sub(line_tax, "TypeCode", "VAT")
|
||||||
|
rate = Decimal(str(line.get("vat_rate", 0)))
|
||||||
|
_sub(line_tax, "RateApplicablePercent", rate)
|
||||||
|
_sub(line_tax, "BasisAmount", net)
|
||||||
|
_sub(line_tax, "CalculatedAmount", _money(net * rate / Decimal(100)))
|
||||||
|
line_sum = _sub(settlement, "SpecifiedTradeSettlementLineMonetarySummation")
|
||||||
|
_sub(line_sum, "LineTotalAmount", net)
|
||||||
|
|
||||||
|
# ── ApplicableHeaderTradeAgreement ──
|
||||||
|
agreement_h = ET.SubElement(txn, f"{{{NS_RAM}}}ApplicableHeaderTradeAgreement")
|
||||||
|
if data.get("buyer_reference"):
|
||||||
|
_sub(agreement_h, "BuyerReference", str(data["buyer_reference"]))
|
||||||
|
|
||||||
|
seller = _sub(agreement_h, "SellerTradeParty")
|
||||||
|
_sub(seller, "Name", data["seller_name"])
|
||||||
|
_address(seller, data.get("seller_address"))
|
||||||
|
tax_reg = _sub(seller, "SpecifiedTaxRegistration")
|
||||||
|
if data.get("seller_vat_id"):
|
||||||
|
_sub(tax_reg, "ID", str(data["seller_vat_id"]), schemeID="VA")
|
||||||
|
elif data.get("seller_tax_id"):
|
||||||
|
_sub(tax_reg, "ID", str(data["seller_tax_id"]), schemeID="FC")
|
||||||
|
|
||||||
|
buyer = _sub(agreement_h, "BuyerTradeParty")
|
||||||
|
_sub(buyer, "Name", data["buyer_name"])
|
||||||
|
_address(buyer, data.get("buyer_address"))
|
||||||
|
|
||||||
|
# ── ApplicableHeaderTradeDelivery (BT-72) ──
|
||||||
|
delivery_h = ET.SubElement(txn, f"{{{NS_RAM}}}ApplicableHeaderTradeDelivery")
|
||||||
|
event = _sub(delivery_h, "ActualDeliverySupplyChainEvent")
|
||||||
|
_date_el(event, "OccurrenceDateTime", data.get("delivery_date") or data["issue_date"])
|
||||||
|
|
||||||
|
# ── ApplicableHeaderTradeSettlement ──
|
||||||
|
settlement_h = ET.SubElement(txn, f"{{{NS_RAM}}}ApplicableHeaderTradeSettlement")
|
||||||
|
currency = str(data["currency"])
|
||||||
|
_sub(settlement_h, "InvoiceCurrencyCode", currency)
|
||||||
|
|
||||||
|
if data.get("payment_means_code"):
|
||||||
|
means = _sub(settlement_h, "SpecifiedTradeSettlementPaymentMeans")
|
||||||
|
_sub(means, "TypeCode", str(data["payment_means_code"]))
|
||||||
|
|
||||||
|
# header tax breakdown per VAT rate (BG-23)
|
||||||
|
tax_total = Decimal("0.00")
|
||||||
|
for rate in sorted(taxes, key=Decimal):
|
||||||
|
basis, amount = taxes[rate]
|
||||||
|
header_tax = _sub(settlement_h, "ApplicableTradeTax")
|
||||||
|
_sub(header_tax, "TypeCode", "VAT")
|
||||||
|
_sub(header_tax, "BasisAmount", basis)
|
||||||
|
_sub(header_tax, "CalculatedAmount", amount)
|
||||||
|
_sub(header_tax, "RateApplicablePercent", Decimal(rate))
|
||||||
|
tax_total = _money(tax_total + amount)
|
||||||
|
|
||||||
|
grand_total = _money(line_total + tax_total)
|
||||||
|
|
||||||
|
terms = _sub(settlement_h, "SpecifiedTradePaymentTerms")
|
||||||
|
if data.get("payment_terms_text"):
|
||||||
|
_sub(terms, "Description", str(data["payment_terms_text"]))
|
||||||
|
if data.get("due_date"):
|
||||||
|
_date_el(terms, "DueDateDateTime", str(data["due_date"]))
|
||||||
|
|
||||||
|
sums = _sub(settlement_h, "SpecifiedTradeSettlementHeaderMonetarySummation")
|
||||||
|
_sub(sums, "LineTotalAmount", line_total)
|
||||||
|
_sub(sums, "TaxTotalAmount", tax_total, currencyID=currency)
|
||||||
|
_sub(sums, "GrandTotalAmount", grand_total)
|
||||||
|
_sub(sums, "DuePayableAmount", grand_total)
|
||||||
|
|
||||||
|
return ET.tostring(root, encoding="unicode", xml_declaration=False)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Contract resolution (sales module docking point) ───────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_einvoice_data(
|
||||||
|
db,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
entity_type: str,
|
||||||
|
entity_id: uuid.UUID,
|
||||||
|
) -> dict | None:
|
||||||
|
"""Resolve invoice data for an entity via the ``einvoice_data()`` contract.
|
||||||
|
|
||||||
|
The future sales module will expose::
|
||||||
|
|
||||||
|
async def einvoice_data(db, tenant_id, entity_id, entity_type) -> dict
|
||||||
|
|
||||||
|
Returning validated-shaped invoice data (same fields as the inline
|
||||||
|
render endpoint). No contribution → None (caller answers 404).
|
||||||
|
"""
|
||||||
|
from app.plugins.builtins.contracts import get_contract_registry
|
||||||
|
from app.plugins.registry import get_registry
|
||||||
|
|
||||||
|
for plugin_name in get_registry().list_discovered():
|
||||||
|
contract = get_contract_registry().get_contract(plugin_name)
|
||||||
|
fn = getattr(contract, "einvoice_data", None)
|
||||||
|
if fn is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
result = await fn(db, tenant_id, entity_id, entity_type)
|
||||||
|
except Exception: # noqa: BLE001 — broken contribution must not 500
|
||||||
|
continue
|
||||||
|
if result:
|
||||||
|
return result
|
||||||
|
return None
|
||||||
@@ -77,7 +77,7 @@ async def generate_report_job(
|
|||||||
import hashlib
|
import hashlib
|
||||||
|
|
||||||
from app.plugins.builtins.contracts import get_contract_registry
|
from app.plugins.builtins.contracts import get_contract_registry
|
||||||
_dms_contract = get_contract_registry().get("dms")
|
_dms_contract = get_contract_registry().get_contract("dms")
|
||||||
dms_file = _dms_contract.dms_file
|
dms_file = _dms_contract.dms_file
|
||||||
|
|
||||||
async with create_db_session() as db:
|
async with create_db_session() as db:
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
-- Documents Generator (Phase L1-L3): letterheads, print_templates, document_assets
|
||||||
|
-- Dual-path safe: idempotent (IF NOT EXISTS); Alembic 0143 converges core installs.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS letterheads (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
config JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
is_default BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
owner_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
created_by UUID NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_letterheads_tenant ON letterheads(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_letterheads_name ON letterheads(name);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS print_templates (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
letterhead_id UUID REFERENCES letterheads(id) ON DELETE SET NULL,
|
||||||
|
entity_type VARCHAR(100) NOT NULL DEFAULT 'contact',
|
||||||
|
blocks JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
output_format VARCHAR(20) NOT NULL DEFAULT 'pdf',
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
owner_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
created_by UUID NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_print_templates_tenant ON print_templates(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_print_templates_name ON print_templates(name);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS document_assets (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
letterhead_id UUID REFERENCES letterheads(id) ON DELETE CASCADE,
|
||||||
|
filename VARCHAR(255) NOT NULL,
|
||||||
|
mime_type VARCHAR(100) NOT NULL,
|
||||||
|
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
storage_path VARCHAR(1024) NOT NULL,
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
owner_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
created_by UUID NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_document_assets_tenant ON document_assets(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_document_assets_letterhead ON document_assets(letterhead_id);
|
||||||
|
|
||||||
|
-- RLS fail-closed (matches migration 0084 pattern: FORCE + crm_api + USING/WITH CHECK)
|
||||||
|
DO $do$
|
||||||
|
DECLARE
|
||||||
|
t text;
|
||||||
|
BEGIN
|
||||||
|
FOREACH t IN ARRAY ARRAY['letterheads', 'print_templates', 'document_assets'] LOOP
|
||||||
|
BEGIN
|
||||||
|
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', t);
|
||||||
|
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', t);
|
||||||
|
EXECUTE format('DROP POLICY IF EXISTS %I ON %I', t || '_tenant_isolation', t);
|
||||||
|
EXECUTE format(
|
||||||
|
'CREATE POLICY %I ON %I AS PERMISSIVE FOR ALL TO crm_api, crm_worker USING (tenant_id = NULLIF(current_setting(''app.current_tenant_id'', true), '''')::uuid) WITH CHECK (tenant_id = NULLIF(current_setting(''app.current_tenant_id'', true), '''')::uuid)',
|
||||||
|
t || '_tenant_isolation', t
|
||||||
|
);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RAISE NOTICE 'RLS setup skipped for %', t;
|
||||||
|
END;
|
||||||
|
END LOOP;
|
||||||
|
END
|
||||||
|
$do$;
|
||||||
@@ -4,7 +4,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy import ForeignKey, Index, String, Text
|
from sqlalchemy import Boolean, ForeignKey, Index, Integer, String, Text
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
@@ -63,3 +64,87 @@ class ReportInstance(Base, TenantMixin, OwnedMixin):
|
|||||||
)
|
)
|
||||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
created_by: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
|
created_by: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Letterhead(Base, TenantMixin, OwnedMixin):
|
||||||
|
"""Letterhead (Briefpapier) — page setup + header/footer block composition.
|
||||||
|
|
||||||
|
Phase L1: per-tenant letterhead. ``config`` stores the page geometry
|
||||||
|
(size/orientation/margins) plus header/footer/watermark block lists.
|
||||||
|
Blocks use the same ``{id, type, config}`` shape as print templates so
|
||||||
|
the drag/drop editor can edit both with one component set.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "letterheads"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_letterheads_tenant", "tenant_id"),
|
||||||
|
Index("ix_letterheads_name", "name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||||
|
config: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||||
|
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
created_by: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class PrintTemplate(Base, TenantMixin, OwnedMixin):
|
||||||
|
"""Print template — drag/drop block composition bound to a letterhead.
|
||||||
|
|
||||||
|
Phase L1: ``blocks`` is an ordered JSONB array of
|
||||||
|
``{id, type, config}`` entries validated against the document block
|
||||||
|
registry. ``entity_type`` selects the module placeholder contribution
|
||||||
|
(e.g. "company" → contacts placeholders).
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "print_templates"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_print_templates_tenant", "tenant_id"),
|
||||||
|
Index("ix_print_templates_name", "name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||||
|
letterhead_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True),
|
||||||
|
ForeignKey("letterheads.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
entity_type: Mapped[str] = mapped_column(String(100), nullable=False, default="contact")
|
||||||
|
blocks: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
|
||||||
|
output_format: Mapped[str] = mapped_column(String(20), nullable=False, default="pdf")
|
||||||
|
created_by: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentAsset(Base, TenantMixin, OwnedMixin):
|
||||||
|
"""Image asset for letterheads/print templates (logos, pictures).
|
||||||
|
|
||||||
|
Stored via the central storage backend; ``data_url`` is rendered into
|
||||||
|
PDFs inline (WeasyPrint URL fetcher allows data: URIs only).
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "document_assets"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_document_assets_tenant", "tenant_id"),
|
||||||
|
Index("ix_document_assets_letterhead", "letterhead_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
letterhead_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True),
|
||||||
|
ForeignKey("letterheads.id", ondelete="CASCADE"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
filename: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
mime_type: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
storage_path: Mapped[str] = mapped_column(String(1024), nullable=False)
|
||||||
|
created_by: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
|
||||||
|
|||||||
@@ -3,7 +3,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from app.plugins.base import BasePlugin
|
from app.plugins.base import BasePlugin
|
||||||
from app.plugins.manifest import FrontendMenuItem, FrontendPageRoute, PluginManifest, PluginRouteDef
|
from app.plugins.manifest import (
|
||||||
|
FrontendMenuItem,
|
||||||
|
FrontendPageRoute,
|
||||||
|
FrontendSettingsPage,
|
||||||
|
PluginManifest,
|
||||||
|
PluginRouteDef,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ReportGeneratorPlugin(BasePlugin):
|
class ReportGeneratorPlugin(BasePlugin):
|
||||||
@@ -17,12 +23,31 @@ class ReportGeneratorPlugin(BasePlugin):
|
|||||||
is_core=True,
|
is_core=True,
|
||||||
dependencies=["permissions"],
|
dependencies=["permissions"],
|
||||||
routes=[
|
routes=[
|
||||||
|
# documents router MUST be registered before routes: its fixed
|
||||||
|
# single-segment paths (/letterheads, /print-templates, ...) would
|
||||||
|
# otherwise be shadowed by the /{report_id} catch-all in routes.
|
||||||
|
PluginRouteDef(
|
||||||
|
path="/api/v1/reports",
|
||||||
|
module="app.plugins.builtins.report_generator.documents",
|
||||||
|
router_attr="router",
|
||||||
|
),
|
||||||
PluginRouteDef(
|
PluginRouteDef(
|
||||||
path="/api/v1/reports",
|
path="/api/v1/reports",
|
||||||
module="app.plugins.builtins.report_generator.routes",
|
module="app.plugins.builtins.report_generator.routes",
|
||||||
router_attr="router",
|
router_attr="router",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
settings_pages=[
|
||||||
|
FrontendSettingsPage(
|
||||||
|
path="documents",
|
||||||
|
label_key="settings.documents",
|
||||||
|
label="Dokumente",
|
||||||
|
component="@/pages/DocumentSettings",
|
||||||
|
icon="FileText",
|
||||||
|
order=75,
|
||||||
|
permission="reports:read",
|
||||||
|
),
|
||||||
|
],
|
||||||
events=["report.requested", "report.generated"],
|
events=["report.requested", "report.generated"],
|
||||||
migrations=["0001_initial.sql", "0002_reports_folder_id.sql"],
|
migrations=["0001_initial.sql", "0002_reports_folder_id.sql"],
|
||||||
permissions=["reports:read", "reports:generate", "reports:manage_templates"],
|
permissions=["reports:read", "reports:generate", "reports:manage_templates"],
|
||||||
@@ -38,8 +63,20 @@ class ReportGeneratorPlugin(BasePlugin):
|
|||||||
contract_version="1.0.0")
|
contract_version="1.0.0")
|
||||||
|
|
||||||
def get_entity_models(self) -> dict[str, type]:
|
def get_entity_models(self) -> dict[str, type]:
|
||||||
from app.plugins.builtins.report_generator.models import ReportInstance, ReportTemplate
|
from app.plugins.builtins.report_generator.models import (
|
||||||
return {"report_template": ReportTemplate, "report_instance": ReportInstance}
|
DocumentAsset,
|
||||||
|
Letterhead,
|
||||||
|
PrintTemplate,
|
||||||
|
ReportInstance,
|
||||||
|
ReportTemplate,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"report_template": ReportTemplate,
|
||||||
|
"report_instance": ReportInstance,
|
||||||
|
"letterhead": Letterhead,
|
||||||
|
"print_template": PrintTemplate,
|
||||||
|
"document_asset": DocumentAsset,
|
||||||
|
}
|
||||||
|
|
||||||
async def on_activate(
|
async def on_activate(
|
||||||
self, db, service_container, event_bus
|
self, db, service_container, event_bus
|
||||||
|
|||||||
@@ -72,3 +72,168 @@ class ReportResponse(BaseModel):
|
|||||||
created_by: str
|
created_by: str
|
||||||
created_at: datetime | None = None
|
created_at: datetime | None = None
|
||||||
updated_at: datetime | None = None
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Documents Generator (Phase L1-L3) ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class LetterheadCreate(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=255)
|
||||||
|
description: str = Field("", max_length=2000)
|
||||||
|
config: dict = Field(default_factory=dict)
|
||||||
|
is_default: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class LetterheadUpdate(BaseModel):
|
||||||
|
name: str | None = Field(None, min_length=1, max_length=255)
|
||||||
|
description: str | None = Field(None, max_length=2000)
|
||||||
|
config: dict | None = None
|
||||||
|
is_default: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class LetterheadResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
config: dict
|
||||||
|
is_default: bool
|
||||||
|
created_by: str
|
||||||
|
created_at: datetime | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PrintTemplateCreate(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=255)
|
||||||
|
description: str = Field("", max_length=2000)
|
||||||
|
letterhead_id: str | None = None
|
||||||
|
entity_type: str = Field("contact", max_length=100)
|
||||||
|
blocks: list[dict] = Field(default_factory=list)
|
||||||
|
output_format: str = Field("pdf", pattern="^(pdf|print)$")
|
||||||
|
|
||||||
|
|
||||||
|
class PrintTemplateUpdate(BaseModel):
|
||||||
|
name: str | None = Field(None, min_length=1, max_length=255)
|
||||||
|
description: str | None = Field(None, max_length=2000)
|
||||||
|
letterhead_id: str | None = None
|
||||||
|
entity_type: str | None = Field(None, max_length=100)
|
||||||
|
blocks: list[dict] | None = None
|
||||||
|
output_format: str | None = Field(None, pattern="^(pdf|print)$")
|
||||||
|
|
||||||
|
|
||||||
|
class PrintTemplateResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
letterhead_id: str | None = None
|
||||||
|
entity_type: str
|
||||||
|
blocks: list[dict]
|
||||||
|
output_format: str
|
||||||
|
created_by: str
|
||||||
|
created_at: datetime | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentPreviewRequest(BaseModel):
|
||||||
|
"""Preview: render blocks to HTML (live preview in the editor)."""
|
||||||
|
|
||||||
|
blocks: list[dict]
|
||||||
|
letterhead_config: dict | None = None
|
||||||
|
entity_type: str | None = None
|
||||||
|
data: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentPreviewResponse(BaseModel):
|
||||||
|
html: str
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentRenderRequest(BaseModel):
|
||||||
|
"""Render a stored print template with entity data to PDF."""
|
||||||
|
|
||||||
|
entity_type: str
|
||||||
|
entity_id: str
|
||||||
|
output_format: str = Field("pdf", pattern="^(pdf|print)$")
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentAssetResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
letterhead_id: str | None = None
|
||||||
|
filename: str
|
||||||
|
mime_type: str
|
||||||
|
size_bytes: int
|
||||||
|
data_url: str | None = None
|
||||||
|
created_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── E-Invoice (EN16931/XRechnung, Phase L5) ────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class EInvoiceAddress(BaseModel):
|
||||||
|
"""Postal address (BT-50..53 seller, BT-65..68 buyer)."""
|
||||||
|
|
||||||
|
street: str | None = None
|
||||||
|
postal_code: str | None = None
|
||||||
|
city: str | None = None
|
||||||
|
country: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class EInvoiceLineItem(BaseModel):
|
||||||
|
"""Invoice line (BG-25). ``line_net_amount`` wins over qty*price."""
|
||||||
|
|
||||||
|
name: str = ""
|
||||||
|
quantity: float = 1.0
|
||||||
|
unit: str = "HUR"
|
||||||
|
unit_net_price: float = 0.0
|
||||||
|
vat_rate: float = 0.0
|
||||||
|
line_net_amount: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class EInvoiceRenderRequest(BaseModel):
|
||||||
|
"""Inline invoice data — field-level semantics validated by
|
||||||
|
``einvoice.validate_einvoice_data`` (BT/BG business terms, 422)."""
|
||||||
|
|
||||||
|
invoice_number: str = ""
|
||||||
|
issue_date: str = ""
|
||||||
|
type_code: str = "380"
|
||||||
|
currency: str = "EUR"
|
||||||
|
due_date: str | None = None
|
||||||
|
delivery_date: str | None = None
|
||||||
|
buyer_name: str = ""
|
||||||
|
buyer_reference: str | None = None
|
||||||
|
buyer_address: EInvoiceAddress | None = None
|
||||||
|
seller_name: str = ""
|
||||||
|
seller_vat_id: str | None = None
|
||||||
|
seller_tax_id: str | None = None
|
||||||
|
seller_address: EInvoiceAddress | None = None
|
||||||
|
payment_means_code: str | None = None
|
||||||
|
payment_terms_text: str | None = None
|
||||||
|
note: str | None = None
|
||||||
|
line_items: list[EInvoiceLineItem] = Field(default_factory=list)
|
||||||
|
profile: str = Field("en16931", pattern="^(en16931|xrechnung)$")
|
||||||
|
|
||||||
|
|
||||||
|
class EInvoiceRenderForRequest(BaseModel):
|
||||||
|
"""Render an e-invoice for an entity via the ``einvoice_data()``
|
||||||
|
contract hook (future sales module docking point)."""
|
||||||
|
|
||||||
|
entity_type: str = Field(..., min_length=1, max_length=100)
|
||||||
|
entity_id: str = Field(..., min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class EInvoiceValidationResponse(BaseModel):
|
||||||
|
valid: bool
|
||||||
|
missing: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── AI block suggestion (Phase L4) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentSuggestRequest(BaseModel):
|
||||||
|
"""Natural-language block composition request for the template editor."""
|
||||||
|
|
||||||
|
prompt: str = Field(..., min_length=1, max_length=2000)
|
||||||
|
entity_type: str | None = Field(None, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentSuggestResponse(BaseModel):
|
||||||
|
blocks: list[dict]
|
||||||
|
notes: str = ""
|
||||||
|
|||||||
@@ -26,6 +26,31 @@ class TagsContract:
|
|||||||
Tag = Tag
|
Tag = Tag
|
||||||
TagAssignment = TagAssignment
|
TagAssignment = TagAssignment
|
||||||
|
|
||||||
|
# ─── Workspace Scopes contribution (Phase N4) ───
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def workspace_scopes() -> list[dict]:
|
||||||
|
"""Scope-Dimensionen des tags-Moduls: Tag-Teilmengen (N4)."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"module_key": "tags",
|
||||||
|
"dimensions": [
|
||||||
|
{
|
||||||
|
"key": "tag_ids",
|
||||||
|
"label": "Tags",
|
||||||
|
"control": "multiselect",
|
||||||
|
"options": [],
|
||||||
|
"value_source": {
|
||||||
|
"endpoint": "/api/v1/tags",
|
||||||
|
"items_path": "",
|
||||||
|
"value_key": "id",
|
||||||
|
"label_key": "name",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
# ─── self-registration ───
|
# ─── self-registration ───
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from app.plugins.base import BasePlugin
|
from app.plugins.base import BasePlugin
|
||||||
from app.plugins.manifest import PluginManifest, PluginRouteDef
|
from app.plugins.manifest import FrontendMenuItem, FrontendPageRoute, PluginManifest, PluginRouteDef
|
||||||
|
|
||||||
|
|
||||||
class TagsPlugin(BasePlugin):
|
class TagsPlugin(BasePlugin):
|
||||||
@@ -30,6 +30,26 @@ class TagsPlugin(BasePlugin):
|
|||||||
"tags:delete",
|
"tags:delete",
|
||||||
"tags:admin",
|
"tags:admin",
|
||||||
],
|
],
|
||||||
|
# Q1: the /tags page was a static-only route before - now
|
||||||
|
# manifest-declared (route + sidebar menu item).
|
||||||
|
menu_items=[
|
||||||
|
FrontendMenuItem(
|
||||||
|
label_key="nav.tags",
|
||||||
|
label="Tags",
|
||||||
|
path="/tags",
|
||||||
|
icon="Tags",
|
||||||
|
order=80,
|
||||||
|
permission="tags:read",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
page_routes=[
|
||||||
|
FrontendPageRoute(
|
||||||
|
path="/tags",
|
||||||
|
component="@/pages/Tags",
|
||||||
|
protected=True,
|
||||||
|
permission="tags:read",
|
||||||
|
),
|
||||||
|
],
|
||||||
is_core=True,
|
is_core=True,
|
||||||
# BUG (ghost component): ContactTagsTab does not exist in the
|
# BUG (ghost component): ContactTagsTab does not exist in the
|
||||||
# frontend — tab removed until implemented (Block I-D).
|
# frontend — tab removed until implemented (Block I-D).
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.core.audit import log_audit
|
from app.core.audit import log_audit
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.core.visibility import apply_visibility_filter
|
from app.core.visibility import apply_visibility_filter
|
||||||
from app.deps import get_current_user, require_permission
|
from app.deps import get_current_user, require_permission, require_workspace_scope
|
||||||
from app.plugins.builtins.tags.models import Tag, TagAssignment
|
from app.plugins.builtins.tags.models import Tag, TagAssignment
|
||||||
from app.plugins.builtins.tags.schemas import (
|
from app.plugins.builtins.tags.schemas import (
|
||||||
TagAssignRequest,
|
TagAssignRequest,
|
||||||
@@ -44,6 +44,7 @@ def _parse_uuid(val: str, field: str) -> uuid.UUID:
|
|||||||
async def list_tags(
|
async def list_tags(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
|
workspace_scope: dict | None = Depends(require_workspace_scope("tags")),
|
||||||
):
|
):
|
||||||
"""List all tags with entity counts."""
|
"""List all tags with entity counts."""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
@@ -73,6 +74,14 @@ async def list_tags(
|
|||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
rows = result.all()
|
rows = result.all()
|
||||||
|
|
||||||
|
# Phase N4: workspace scope — tag subset (pure AND, never a grant)
|
||||||
|
if workspace_scope:
|
||||||
|
from app.services.workspace_scope_service import scope_uuid_set
|
||||||
|
|
||||||
|
tag_scope = scope_uuid_set(workspace_scope.get("tag_ids"))
|
||||||
|
if tag_scope is not None:
|
||||||
|
rows = [(tag, count) for tag, count in rows if tag.id in tag_scope]
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"id": str(tag.id),
|
"id": str(tag.id),
|
||||||
|
|||||||
@@ -47,6 +47,27 @@ class TasksContract:
|
|||||||
update_task_status = staticmethod(update_task_status)
|
update_task_status = staticmethod(update_task_status)
|
||||||
get_due_tasks = staticmethod(get_due_tasks)
|
get_due_tasks = staticmethod(get_due_tasks)
|
||||||
|
|
||||||
|
# ─── Workspace Scopes contribution (Phase N4) ───
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def workspace_scopes() -> list[dict]:
|
||||||
|
"""Scope-Dimensionen des tasks-Moduls: „nur meine" (Roadmap N4)."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"module_key": "tasks",
|
||||||
|
"dimensions": [
|
||||||
|
{
|
||||||
|
"key": "only_mine",
|
||||||
|
"label": "Nur meine Aufgaben",
|
||||||
|
"control": "toggle",
|
||||||
|
"options": [],
|
||||||
|
"value_source": None,
|
||||||
|
"default": False,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
# ─── models (read-only for queries) ───
|
# ─── models (read-only for queries) ───
|
||||||
Task = Task
|
Task = Task
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.core.audit import log_audit
|
from app.core.audit import log_audit
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.deps import get_current_user, require_permission
|
from app.deps import get_current_user, require_permission, require_workspace_scope
|
||||||
from app.plugins.builtins.tasks import services
|
from app.plugins.builtins.tasks import services
|
||||||
from app.plugins.builtins.tasks.schemas import (
|
from app.plugins.builtins.tasks.schemas import (
|
||||||
TaskAssignRequest,
|
TaskAssignRequest,
|
||||||
@@ -53,8 +53,13 @@ async def list_tasks(
|
|||||||
search: str | None = Query(None),
|
search: str | None = Query(None),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
|
workspace_scope: dict | None = Depends(require_workspace_scope("tasks")),
|
||||||
):
|
):
|
||||||
"""List tasks with filtering and pagination."""
|
"""List tasks with filtering and pagination.
|
||||||
|
|
||||||
|
Phase N4: an active workspace scope with only_mine=true restricts the
|
||||||
|
list to tasks assigned to or created by the current user (pure AND).
|
||||||
|
"""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
user_id = uuid.UUID(current_user["user_id"])
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
is_system_admin = current_user.get("is_system_admin", False)
|
is_system_admin = current_user.get("is_system_admin", False)
|
||||||
@@ -68,6 +73,7 @@ async def list_tasks(
|
|||||||
parent_task_id=parent_task_id, task_type=task_type,
|
parent_task_id=parent_task_id, task_type=task_type,
|
||||||
search=search,
|
search=search,
|
||||||
user_id=user_id, is_system_admin=is_system_admin,
|
user_id=user_id, is_system_admin=is_system_admin,
|
||||||
|
workspace_scope=workspace_scope,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ def _to_uuid(val: str | UUID | None) -> UUID | None:
|
|||||||
return val
|
return val
|
||||||
return uuid.UUID(str(val))
|
return uuid.UUID(str(val))
|
||||||
|
|
||||||
from sqlalchemy import func, select # noqa: E402 — after helper defs by design
|
from sqlalchemy import func, or_, select # noqa: E402 — after helper defs by design
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession # noqa: E402
|
from sqlalchemy.ext.asyncio import AsyncSession # noqa: E402
|
||||||
|
|
||||||
from app.core.visibility import apply_visibility_filter # noqa: E402
|
from app.core.visibility import apply_visibility_filter # noqa: E402
|
||||||
@@ -215,10 +215,26 @@ async def list_tasks(
|
|||||||
task_type: str | None = None,
|
task_type: str | None = None,
|
||||||
user_id: uuid.UUID | None = None,
|
user_id: uuid.UUID | None = None,
|
||||||
is_system_admin: bool = False,
|
is_system_admin: bool = False,
|
||||||
|
workspace_scope: dict | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""List tasks with filtering and pagination."""
|
"""List tasks with filtering and pagination.
|
||||||
|
|
||||||
|
Phase N4: ``workspace_scope`` with only_mine=true restricts the list to
|
||||||
|
tasks assigned to or created by the current user (pure AND on top of
|
||||||
|
all other filters — never a grant).
|
||||||
|
"""
|
||||||
query = select(Task).where(Task.tenant_id == tenant_id, Task.deleted_at.is_(None))
|
query = select(Task).where(Task.tenant_id == tenant_id, Task.deleted_at.is_(None))
|
||||||
|
|
||||||
|
# Phase N4: workspace scope — only_mine restricts to own tasks (assigned
|
||||||
|
# to OR created by the current user). Pure AND, never a grant.
|
||||||
|
if workspace_scope and workspace_scope.get("only_mine") is True and user_id:
|
||||||
|
query = query.where(
|
||||||
|
or_(
|
||||||
|
Task.assigned_to == user_id,
|
||||||
|
Task.created_by == user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if user_id and not is_system_admin:
|
if user_id and not is_system_admin:
|
||||||
query = await apply_visibility_filter(
|
query = await apply_visibility_filter(
|
||||||
db, query, "task", Task, user_id, tenant_id, is_system_admin
|
db, query, "task", Task, user_id, tenant_id, is_system_admin
|
||||||
|
|||||||
@@ -61,6 +61,68 @@ class UnifiedSearchContract:
|
|||||||
|
|
||||||
await _auto_register(db)
|
await _auto_register(db)
|
||||||
|
|
||||||
|
# ─── Workspace Scopes contribution (Phase N4) ───
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def workspace_scopes() -> list[dict]:
|
||||||
|
"""Scope-Dimensionen des search-Moduls: Suchbereiche (N4).
|
||||||
|
|
||||||
|
Options come from the live search provider registry; when it has
|
||||||
|
not been initialized yet (sync context before activation), the
|
||||||
|
built-in provider classes are the deterministic fallback source
|
||||||
|
(same classes auto_register_providers registers at activation).
|
||||||
|
"""
|
||||||
|
entity_types = list(get_search_registry().get_entity_types())
|
||||||
|
if not entity_types:
|
||||||
|
from app.plugins.builtins.unified_search.providers import (
|
||||||
|
agent_memory_provider,
|
||||||
|
ai_chat_provider,
|
||||||
|
company_provider,
|
||||||
|
contact_provider,
|
||||||
|
contactperson_provider,
|
||||||
|
conversation_provider,
|
||||||
|
event_provider,
|
||||||
|
file_provider,
|
||||||
|
mail_provider,
|
||||||
|
tag_provider,
|
||||||
|
task_provider,
|
||||||
|
user_provider,
|
||||||
|
workflow_provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
for module in (
|
||||||
|
agent_memory_provider, ai_chat_provider, company_provider,
|
||||||
|
contact_provider, contactperson_provider, conversation_provider,
|
||||||
|
event_provider, file_provider, mail_provider, tag_provider,
|
||||||
|
task_provider, user_provider, workflow_provider,
|
||||||
|
):
|
||||||
|
for attr in dir(module):
|
||||||
|
obj = getattr(module, attr)
|
||||||
|
if (
|
||||||
|
isinstance(obj, type)
|
||||||
|
and attr.endswith("Provider")
|
||||||
|
and attr != "BaseSearchProvider"
|
||||||
|
and getattr(obj, "entity_type", "")
|
||||||
|
):
|
||||||
|
entity_types.append(obj.entity_type)
|
||||||
|
entity_types = sorted(set(entity_types))
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"module_key": "search",
|
||||||
|
"dimensions": [
|
||||||
|
{
|
||||||
|
"key": "entity_types",
|
||||||
|
"label": "Suchbereiche",
|
||||||
|
"control": "multiselect",
|
||||||
|
"options": [
|
||||||
|
{"value": et, "label": et.replace("_", " ").title()}
|
||||||
|
for et in entity_types
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_function(cls, name: str):
|
def get_function(cls, name: str):
|
||||||
"""Return a callable exposed by this contract, or None if absent."""
|
"""Return a callable exposed by this contract, or None if absent."""
|
||||||
|
|||||||
@@ -13,11 +13,15 @@ logger = logging.getLogger(__name__)
|
|||||||
BATCH_SIZE = 100
|
BATCH_SIZE = 100
|
||||||
|
|
||||||
# Entity type -> table name mapping (shared by multiple jobs)
|
# Entity type -> table name mapping (shared by multiple jobs)
|
||||||
|
# F31 (Astra P2): derived from the SINGLE shared source (search_engine)
|
||||||
|
# instead of a diverging fixed copy — a new provider with a tsv/embedding
|
||||||
|
# table is now automatically covered by reindex and similarity search.
|
||||||
|
from app.plugins.builtins.unified_search.search_engine import ( # noqa: E402
|
||||||
|
SEARCHABLE_ENTITIES,
|
||||||
|
)
|
||||||
|
|
||||||
_TABLE_MAP: dict[str, str] = {
|
_TABLE_MAP: dict[str, str] = {
|
||||||
"contact": "contacts",
|
entity_type: info[0] for entity_type, info in SEARCHABLE_ENTITIES.items()
|
||||||
"mail": "mails",
|
|
||||||
"file": "files",
|
|
||||||
"event": "calendar_entries",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -243,8 +247,19 @@ async def reindex(ctx: dict[str, Any], entity_type: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def reindex_all(ctx: dict[str, Any]) -> None:
|
async def reindex_all(ctx: dict[str, Any]) -> None:
|
||||||
"""Reindex all entity types in sequence, including file chunks."""
|
"""Reindex all searchable entity types in sequence, including file chunks.
|
||||||
entity_types = ["contact", "mail", "file", "event"]
|
|
||||||
|
F31 (Astra P2): iterates DYNAMICALLY over the registered search
|
||||||
|
providers ∩ SEARCHABLE_ENTITIES instead of a fixed 4-type list — a new
|
||||||
|
provider with a tsv/embedding table is now automatically reindexed.
|
||||||
|
"""
|
||||||
|
from app.plugins.builtins.unified_search.provider_registry import get_search_registry
|
||||||
|
|
||||||
|
try:
|
||||||
|
registered = set(get_search_registry().get_entity_types())
|
||||||
|
except Exception:
|
||||||
|
registered = set()
|
||||||
|
entity_types = [et for et in _TABLE_MAP if not registered or et in registered]
|
||||||
for etype in entity_types:
|
for etype in entity_types:
|
||||||
try:
|
try:
|
||||||
await reindex(ctx, etype)
|
await reindex(ctx, etype)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.core.jobs import enqueue_job
|
from app.core.jobs import enqueue_job
|
||||||
from app.core.permissions import filter_fields_by_permission, resolve_permissions
|
from app.core.permissions import filter_fields_by_permission, resolve_permissions
|
||||||
from app.deps import get_current_user, require_permission
|
from app.deps import get_current_user, require_permission, require_workspace_scope
|
||||||
from app.plugins.builtins.unified_search.provider_registry import get_search_registry
|
from app.plugins.builtins.unified_search.provider_registry import get_search_registry
|
||||||
from app.plugins.builtins.unified_search.query_understanding import (
|
from app.plugins.builtins.unified_search.query_understanding import (
|
||||||
llm_aggregate_results,
|
llm_aggregate_results,
|
||||||
@@ -56,8 +56,13 @@ async def search_get(
|
|||||||
sort: str = Query(default="relevance", description="Sort order: relevance, date, name"),
|
sort: str = Query(default="relevance", description="Sort order: relevance, date, name"),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
workspace_scope: dict | None = Depends(require_workspace_scope("search")),
|
||||||
) -> SearchResponse:
|
) -> SearchResponse:
|
||||||
"""Perform hybrid search via GET (same as POST but with query params)."""
|
"""Perform hybrid search via GET (same as POST but with query params).
|
||||||
|
|
||||||
|
Phase N4: an active workspace scope intersects the requested entity
|
||||||
|
types with the configured search areas (pure AND — never a grant).
|
||||||
|
"""
|
||||||
types_list = entity_types.split(",") if entity_types else None
|
types_list = entity_types.split(",") if entity_types else None
|
||||||
tags_list = tags.split(",") if tags else None
|
tags_list = tags.split(",") if tags else None
|
||||||
req = SearchRequest(
|
req = SearchRequest(
|
||||||
@@ -70,6 +75,12 @@ async def search_get(
|
|||||||
tags=tags_list,
|
tags=tags_list,
|
||||||
sort=sort,
|
sort=sort,
|
||||||
)
|
)
|
||||||
|
if workspace_scope:
|
||||||
|
from app.services.workspace_scope_service import apply_entity_type_scope
|
||||||
|
|
||||||
|
req.entity_types = apply_entity_type_scope(
|
||||||
|
req.entity_types, workspace_scope.get("entity_types")
|
||||||
|
)
|
||||||
return await _do_search(req, current_user, db)
|
return await _do_search(req, current_user, db)
|
||||||
|
|
||||||
|
|
||||||
@@ -219,8 +230,19 @@ async def search(
|
|||||||
req: SearchRequest,
|
req: SearchRequest,
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
workspace_scope: dict | None = Depends(require_workspace_scope("search")),
|
||||||
) -> SearchResponse:
|
) -> SearchResponse:
|
||||||
"""Perform hybrid search with KI query understanding."""
|
"""Perform hybrid search with KI query understanding.
|
||||||
|
|
||||||
|
Phase N4: an active workspace scope intersects the requested entity
|
||||||
|
types with the configured search areas (pure AND — never a grant).
|
||||||
|
"""
|
||||||
|
if workspace_scope:
|
||||||
|
from app.services.workspace_scope_service import apply_entity_type_scope
|
||||||
|
|
||||||
|
req.entity_types = apply_entity_type_scope(
|
||||||
|
req.entity_types, workspace_scope.get("entity_types")
|
||||||
|
)
|
||||||
return await _do_search(req, current_user, db)
|
return await _do_search(req, current_user, db)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Wiki plugin contract — public interface for cross-plugin access (N4).
|
||||||
|
|
||||||
|
Created for the Phase N workspace_scopes contribution (the wiki previously
|
||||||
|
had no contract module — N4 needs one for the scope registry, mirroring the
|
||||||
|
contacts/dms/mail/calendar pattern from N1).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.plugins.builtins.contracts import get_contract_registry
|
||||||
|
|
||||||
|
|
||||||
|
class WikiContract:
|
||||||
|
"""Public contract for the wiki plugin."""
|
||||||
|
|
||||||
|
contract_name = "wiki"
|
||||||
|
|
||||||
|
# ─── Workspace Scopes contribution (Phase N4) ───
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def workspace_scopes() -> list[dict]:
|
||||||
|
"""Scope-Dimensionen des wiki-Moduls: Kategorien-Teilmengen (N4)."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"module_key": "wiki",
|
||||||
|
"dimensions": [
|
||||||
|
{
|
||||||
|
"key": "category_ids",
|
||||||
|
"label": "Wiki-Kategorien",
|
||||||
|
"control": "multiselect",
|
||||||
|
"options": [],
|
||||||
|
"value_source": {
|
||||||
|
"endpoint": "/api/v1/wiki/categories",
|
||||||
|
"items_path": "items",
|
||||||
|
"value_key": "id",
|
||||||
|
"label_key": "name",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_function(cls, name: str):
|
||||||
|
"""Return a callable exposed by this contract, or None if absent."""
|
||||||
|
return getattr(cls, name, None)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── self-registration ───
|
||||||
|
|
||||||
|
_contract = WikiContract()
|
||||||
|
get_contract_registry().register("wiki", _contract)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["WikiContract"]
|
||||||
@@ -4,7 +4,13 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from app.plugins.base import BasePlugin
|
from app.plugins.base import BasePlugin
|
||||||
from app.plugins.manifest import FrontendMenuItem, FrontendPageRoute, PluginManifest, PluginRouteDef
|
from app.plugins.manifest import (
|
||||||
|
FrontendMenuItem,
|
||||||
|
FrontendPageRoute,
|
||||||
|
MiniAppContribution,
|
||||||
|
PluginManifest,
|
||||||
|
PluginRouteDef,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -19,6 +25,25 @@ class WikiPlugin(BasePlugin):
|
|||||||
routes=[
|
routes=[
|
||||||
PluginRouteDef(path="/api/v1/wiki", module="app.plugins.builtins.wiki.routes", router_attr="router"),
|
PluginRouteDef(path="/api/v1/wiki", module="app.plugins.builtins.wiki.routes", router_attr="router"),
|
||||||
],
|
],
|
||||||
|
miniapps=[
|
||||||
|
MiniAppContribution(
|
||||||
|
app_id="wiki_recent",
|
||||||
|
name="Wiki-Neuigkeiten",
|
||||||
|
icon="BookOpen",
|
||||||
|
description="Zuletzt aktualisierte Wiki-Artikel.",
|
||||||
|
permission="wiki:read",
|
||||||
|
settings_schema={
|
||||||
|
"fields": [
|
||||||
|
{"name": "max_items", "label": "Max. Artikel", "type": "number", "default": 5},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
col_span=2,
|
||||||
|
row_span=1,
|
||||||
|
hosts=["chat", "dashboard", "window"],
|
||||||
|
component="@/components/dashboard/WikiRecentWidget",
|
||||||
|
order=80,
|
||||||
|
),
|
||||||
|
],
|
||||||
permissions=["wiki:read", "wiki:write", "wiki:delete", "wiki:admin"],
|
permissions=["wiki:read", "wiki:write", "wiki:delete", "wiki:admin"],
|
||||||
menu_items=[FrontendMenuItem(label_key="wiki.menu.wiki", label="Wiki", path="/wiki", icon="BookOpen", permission="wiki:read")],
|
menu_items=[FrontendMenuItem(label_key="wiki.menu.wiki", label="Wiki", path="/wiki", icon="BookOpen", permission="wiki:read")],
|
||||||
page_routes=[FrontendPageRoute(path="/wiki", component="@/pages/Wiki", permission="wiki:read")],
|
page_routes=[FrontendPageRoute(path="/wiki", component="@/pages/Wiki", permission="wiki:read")],
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.core.audit import log_audit
|
from app.core.audit import log_audit
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.deps import require_permission
|
from app.deps import require_permission, require_workspace_scope
|
||||||
from app.plugins.builtins.wiki import services
|
from app.plugins.builtins.wiki import services
|
||||||
from app.plugins.builtins.wiki.schemas import (
|
from app.plugins.builtins.wiki.schemas import (
|
||||||
ArticleCreate,
|
ArticleCreate,
|
||||||
@@ -28,11 +28,35 @@ async def list_articles(
|
|||||||
search: str | None = None,
|
search: str | None = None,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(require_permission("wiki:read")),
|
current_user: dict = Depends(require_permission("wiki:read")),
|
||||||
|
workspace_scope: dict | None = Depends(require_workspace_scope("wiki")),
|
||||||
):
|
):
|
||||||
return await services.list_articles(
|
"""List wiki articles.
|
||||||
|
|
||||||
|
Phase N4: an active workspace scope (X-Workspace-ID) restricts articles
|
||||||
|
to the category subtree (category_ids incl. children — pure AND).
|
||||||
|
"""
|
||||||
|
scoped_category_ids: set | None = None
|
||||||
|
if workspace_scope:
|
||||||
|
from app.plugins.builtins.wiki.models import WikiCategory
|
||||||
|
from app.services.workspace_scope_service import expand_folder_scope
|
||||||
|
|
||||||
|
raw_ids = workspace_scope.get("category_ids")
|
||||||
|
if isinstance(raw_ids, list) and raw_ids:
|
||||||
|
scoped_category_ids = await expand_folder_scope(db, WikiCategory, raw_ids)
|
||||||
|
|
||||||
|
result = await services.list_articles(
|
||||||
db, uuid.UUID(current_user["tenant_id"]),
|
db, uuid.UUID(current_user["tenant_id"]),
|
||||||
page=page, page_size=page_size, category_id=category_id, status=status, search=search,
|
page=page, page_size=page_size, category_id=category_id, status=status, search=search,
|
||||||
)
|
)
|
||||||
|
# Phase N4: filter to the scoped category subtree (post-fetch AND filter)
|
||||||
|
if scoped_category_ids is not None:
|
||||||
|
items = [
|
||||||
|
a for a in result["items"]
|
||||||
|
if a.get("category_id") and uuid.UUID(a["category_id"]) in scoped_category_ids
|
||||||
|
]
|
||||||
|
result["items"] = items
|
||||||
|
result["total"] = len(items)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.post("/articles", status_code=status.HTTP_201_CREATED)
|
@router.post("/articles", status_code=status.HTTP_201_CREATED)
|
||||||
@@ -120,8 +144,20 @@ async def restore_version(
|
|||||||
async def list_categories(
|
async def list_categories(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(require_permission("wiki:read")),
|
current_user: dict = Depends(require_permission("wiki:read")),
|
||||||
|
workspace_scope: dict | None = Depends(require_workspace_scope("wiki")),
|
||||||
):
|
):
|
||||||
return {"items": await services.list_categories(db, uuid.UUID(current_user["tenant_id"]))}
|
"""List wiki categories (Phase N4: scope reduces to the category subtree)."""
|
||||||
|
items = await services.list_categories(db, uuid.UUID(current_user["tenant_id"]))
|
||||||
|
if workspace_scope:
|
||||||
|
from app.plugins.builtins.wiki.models import WikiCategory
|
||||||
|
from app.services.workspace_scope_service import expand_folder_scope
|
||||||
|
|
||||||
|
raw_ids = workspace_scope.get("category_ids")
|
||||||
|
if isinstance(raw_ids, list) and raw_ids:
|
||||||
|
subtree = await expand_folder_scope(db, WikiCategory, raw_ids)
|
||||||
|
allowed = subtree or set()
|
||||||
|
items = [c for c in items if uuid.UUID(c["id"]) in allowed]
|
||||||
|
return {"items": items}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/categories", status_code=status.HTTP_201_CREATED)
|
@router.post("/categories", status_code=status.HTTP_201_CREATED)
|
||||||
|
|||||||
@@ -148,6 +148,10 @@ class FrontendDashboardWidget(BaseModel):
|
|||||||
col_span: int = Field(default=1, description="Grid column span (1-4)")
|
col_span: int = Field(default=1, description="Grid column span (1-4)")
|
||||||
row_span: int = Field(default=1, description="Grid row span")
|
row_span: int = Field(default=1, description="Grid row span")
|
||||||
permission: str = Field(default="", description="Optional permission required")
|
permission: str = Field(default="", description="Optional permission required")
|
||||||
|
settings_schema: dict[str, Any] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description="JSON schema for per-instance settings (Phase M1)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CustomFieldDefinition(BaseModel):
|
class CustomFieldDefinition(BaseModel):
|
||||||
@@ -173,6 +177,19 @@ class MiniAppContribution(BaseModel):
|
|||||||
icon: str = Field(default="AppWindow")
|
icon: str = Field(default="AppWindow")
|
||||||
description: str = Field(default="")
|
description: str = Field(default="")
|
||||||
render_schema: dict[str, Any] = Field(default_factory=dict)
|
render_schema: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
# Phase M1: universal MiniApp fields
|
||||||
|
permission: str = Field(default="", description="Required permission (fail-closed)")
|
||||||
|
settings_schema: dict[str, Any] = Field(
|
||||||
|
default_factory=dict, description="JSON schema for per-instance settings"
|
||||||
|
)
|
||||||
|
col_span: int = Field(default=1, ge=1, le=12, description="Grid column span")
|
||||||
|
row_span: int = Field(default=1, ge=1, le=12, description="Grid row span")
|
||||||
|
hosts: list[str] = Field(
|
||||||
|
default_factory=lambda: ["chat", "dashboard", "window"],
|
||||||
|
description="Hosts this MiniApp may appear in",
|
||||||
|
)
|
||||||
|
component: str = Field(default="", description="Frontend component path")
|
||||||
|
order: int = Field(default=100, description="Sort order in palettes")
|
||||||
|
|
||||||
|
|
||||||
class PluginManifest(BaseModel):
|
class PluginManifest(BaseModel):
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -11,6 +12,8 @@ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
|
|||||||
|
|
||||||
from app.models.plugin import PluginMigration
|
from app.models.plugin import PluginMigration
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class MigrationValidationError(Exception):
|
class MigrationValidationError(Exception):
|
||||||
"""Raised when a plugin migration creates a table without tenant_id column."""
|
"""Raised when a plugin migration creates a table without tenant_id column."""
|
||||||
@@ -124,12 +127,20 @@ class MigrationRunner:
|
|||||||
plugin_name=plugin_name,
|
plugin_name=plugin_name,
|
||||||
migration_file=migration_filename,
|
migration_file=migration_filename,
|
||||||
status="applied",
|
status="applied",
|
||||||
|
content_hash=self._hash_sql(sql_content), # F40 (Astra): record applied content
|
||||||
)
|
)
|
||||||
db.add(migration_record)
|
db.add(migration_record)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
return migration_record
|
return migration_record
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _hash_sql(sql_content: str) -> str:
|
||||||
|
"""F40: SHA-256 of migration SQL content for drift detection."""
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
return hashlib.sha256(sql_content.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
async def run_all_migrations(
|
async def run_all_migrations(
|
||||||
self,
|
self,
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
@@ -150,11 +161,34 @@ class MigrationRunner:
|
|||||||
PluginMigration.status == "applied",
|
PluginMigration.status == "applied",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
applied_files = {row.migration_file for row in result.scalars().all()}
|
applied_rows = {row.migration_file: row for row in result.scalars().all()}
|
||||||
|
applied_files = set(applied_rows.keys())
|
||||||
|
|
||||||
records: list[PluginMigration] = []
|
records: list[PluginMigration] = []
|
||||||
for filename in migration_files:
|
for filename in migration_files:
|
||||||
if filename in applied_files:
|
if filename in applied_files:
|
||||||
|
# F40 (Astra): hash check — a modified already-applied
|
||||||
|
# migration must be VISIBLE, not silently skipped. This is
|
||||||
|
# exactly the #389 bug class: a broken migration was fixed
|
||||||
|
# on disk, but the runner skipped it by filename forever.
|
||||||
|
record = applied_rows[filename]
|
||||||
|
try:
|
||||||
|
path = self._resolve_migration_path(filename, plugin_name)
|
||||||
|
current_hash = self._hash_sql(path.read_text(encoding="utf-8"))
|
||||||
|
except FileNotFoundError:
|
||||||
|
continue # file gone (plugin removed it) — nothing to compare
|
||||||
|
if record.content_hash is not None and record.content_hash != current_hash:
|
||||||
|
logger.warning(
|
||||||
|
"F40 DRIFT: plugin '%s' migration '%s' was MODIFIED after being "
|
||||||
|
"applied (recorded=%s current=%s). The runner keeps the skip "
|
||||||
|
"(idempotent), but the schema may no longer match the file. "
|
||||||
|
"If the change is intentional, verify the schema and consider "
|
||||||
|
"a follow-up migration instead of editing an applied one.",
|
||||||
|
plugin_name,
|
||||||
|
filename,
|
||||||
|
(record.content_hash or "")[:12],
|
||||||
|
current_hash[:12],
|
||||||
|
)
|
||||||
continue # Already applied — idempotent skip
|
continue # Already applied — idempotent skip
|
||||||
record = await self.run_migration(db, plugin_name, filename, tenant_id)
|
record = await self.run_migration(db, plugin_name, filename, tenant_id)
|
||||||
records.append(record)
|
records.append(record)
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""Universal MiniApp registry — platform-level (Phase M1).
|
||||||
|
|
||||||
|
MiniApps are the platform's universal UI building blocks, hostable in Chat,
|
||||||
|
Dashboard, Windows and AI agent output. Every plugin registers its MiniApps
|
||||||
|
here (manifest-driven via BasePlugin lifecycle or programmatically at
|
||||||
|
runtime). The registry is deliberately host-agnostic: hosts declare where
|
||||||
|
an app may appear, the registry itself never renders anything.
|
||||||
|
|
||||||
|
History: grew out of kommunikation/miniapp_registry.py (chat-only, 92
|
||||||
|
lines). That module is now a compatibility bridge re-exporting this one —
|
||||||
|
all existing importers (kommunikation contracts, automation routes) keep
|
||||||
|
working unchanged.
|
||||||
|
|
||||||
|
Security model (fail-closed):
|
||||||
|
- ``MiniAppDef.permission`` — apps requiring a permission the user lacks are
|
||||||
|
filtered out server-side by the /api/v1/miniapps listing and answered
|
||||||
|
with 403 on single-app lookup. Empty permission = visible to everyone
|
||||||
|
(backward compatible with the old chat miniapps).
|
||||||
|
- Hosts additionally restrict where an app may appear (?host= filter).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_HOSTS: list[str] = ["chat", "dashboard", "window"]
|
||||||
|
|
||||||
|
|
||||||
|
class MiniAppDef(BaseModel):
|
||||||
|
"""Definition of a MiniApp that plugins can register."""
|
||||||
|
|
||||||
|
app_id: str = Field(..., description="Unique app identifier")
|
||||||
|
name: str = Field(..., description="Display name")
|
||||||
|
icon: str = Field(default="AppWindow", description="Icon name (lucide or emoji)")
|
||||||
|
description: str = Field(default="", description="App description")
|
||||||
|
plugin_name: str = Field(..., description="Plugin that registered this app")
|
||||||
|
render_schema: dict[str, Any] = Field(
|
||||||
|
default_factory=dict, description="JSON schema for frontend rendering"
|
||||||
|
)
|
||||||
|
# ── Phase M1 extensions ──
|
||||||
|
permission: str = Field(
|
||||||
|
default="",
|
||||||
|
description="Required permission (fail-closed). Empty = everyone.",
|
||||||
|
)
|
||||||
|
settings_schema: dict[str, Any] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description="JSON schema for the per-instance settings form",
|
||||||
|
)
|
||||||
|
col_span: int = Field(default=1, ge=1, le=12, description="Grid column span")
|
||||||
|
row_span: int = Field(default=1, ge=1, le=12, description="Grid row span")
|
||||||
|
hosts: list[str] = Field(
|
||||||
|
default_factory=lambda: list(DEFAULT_HOSTS),
|
||||||
|
description="Hosts this app may appear in (chat, dashboard, window)",
|
||||||
|
)
|
||||||
|
component: str = Field(
|
||||||
|
default="", description="Frontend component path (for manifest-contributed apps)"
|
||||||
|
)
|
||||||
|
order: int = Field(default=100, description="Sort order in palettes")
|
||||||
|
builtin: bool = Field(
|
||||||
|
default=True, description="Registered in-process (vs. future remote apps)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MiniAppRegistry:
|
||||||
|
"""Registry for MiniApps provided by system and plugins."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._apps: dict[str, MiniAppDef] = {}
|
||||||
|
|
||||||
|
def register(
|
||||||
|
self,
|
||||||
|
app_id: str,
|
||||||
|
name: str,
|
||||||
|
icon: str = "AppWindow",
|
||||||
|
description: str = "",
|
||||||
|
plugin_name: str = "system",
|
||||||
|
render_schema: dict[str, Any] | None = None,
|
||||||
|
permission: str = "",
|
||||||
|
settings_schema: dict[str, Any] | None = None,
|
||||||
|
col_span: int = 1,
|
||||||
|
row_span: int = 1,
|
||||||
|
hosts: list[str] | None = None,
|
||||||
|
component: str = "",
|
||||||
|
order: int = 100,
|
||||||
|
) -> None:
|
||||||
|
"""Register or replace a MiniApp."""
|
||||||
|
app = MiniAppDef(
|
||||||
|
app_id=app_id,
|
||||||
|
name=name,
|
||||||
|
icon=icon,
|
||||||
|
description=description,
|
||||||
|
plugin_name=plugin_name,
|
||||||
|
render_schema=render_schema or {},
|
||||||
|
permission=permission,
|
||||||
|
settings_schema=settings_schema or {},
|
||||||
|
col_span=col_span,
|
||||||
|
row_span=row_span,
|
||||||
|
hosts=hosts if hosts is not None else list(DEFAULT_HOSTS),
|
||||||
|
component=component,
|
||||||
|
order=order,
|
||||||
|
)
|
||||||
|
self._apps[app_id] = app
|
||||||
|
logger.info("MiniApp registered: %s by %s", app_id, plugin_name)
|
||||||
|
|
||||||
|
def unregister(self, app_id: str) -> None:
|
||||||
|
"""Unregister a MiniApp."""
|
||||||
|
app = self._apps.pop(app_id, None)
|
||||||
|
if app:
|
||||||
|
logger.info("MiniApp unregistered: %s", app_id)
|
||||||
|
|
||||||
|
def unregister_plugin(self, plugin_name: str) -> None:
|
||||||
|
"""Unregister all MiniApps from a specific plugin."""
|
||||||
|
to_remove = [
|
||||||
|
app_id for app_id, app in self._apps.items() if app.plugin_name == plugin_name
|
||||||
|
]
|
||||||
|
for app_id in to_remove:
|
||||||
|
self._apps.pop(app_id, None)
|
||||||
|
if to_remove:
|
||||||
|
logger.info("Unregistered %d MiniApps from plugin %s", len(to_remove), plugin_name)
|
||||||
|
|
||||||
|
def list_apps(self, host: str | None = None) -> list[dict[str, Any]]:
|
||||||
|
"""List MiniApp definitions, optionally filtered by host."""
|
||||||
|
apps = self._apps.values()
|
||||||
|
if host:
|
||||||
|
apps = [a for a in apps if host in a.hosts]
|
||||||
|
return [app.model_dump() for app in apps]
|
||||||
|
|
||||||
|
def get_app(self, app_id: str) -> MiniAppDef | None:
|
||||||
|
"""Get a specific MiniApp definition."""
|
||||||
|
return self._apps.get(app_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Singleton helpers ──
|
||||||
|
|
||||||
|
_registry: MiniAppRegistry | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_miniapp_registry() -> MiniAppRegistry:
|
||||||
|
"""Return the shared singleton MiniAppRegistry instance."""
|
||||||
|
global _registry
|
||||||
|
if _registry is None:
|
||||||
|
_registry = MiniAppRegistry()
|
||||||
|
return _registry
|
||||||
|
|
||||||
|
|
||||||
|
def reset_miniapp_registry() -> None:
|
||||||
|
"""Reset the singleton instance (useful for tests)."""
|
||||||
|
global _registry
|
||||||
|
_registry = None
|
||||||
|
|
||||||
|
|
||||||
|
def user_permits(current_user: dict[str, Any], app: dict[str, Any]) -> bool:
|
||||||
|
"""Check whether *current_user* may see/use the MiniApp *app*.
|
||||||
|
|
||||||
|
Empty permission = visible to everyone; otherwise fail-closed check
|
||||||
|
(system admins always pass). Shared by /api/v1/miniapps and the
|
||||||
|
personal dashboard seed (Phase M2) so both apply identical rules.
|
||||||
|
"""
|
||||||
|
from app.core.permissions import check_permission
|
||||||
|
|
||||||
|
required = app.get("permission") or ""
|
||||||
|
if not required:
|
||||||
|
return True
|
||||||
|
if current_user.get("is_system_admin"):
|
||||||
|
return True
|
||||||
|
return check_permission(current_user, required)
|
||||||
+26
-1
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
import logging
|
import logging
|
||||||
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -852,19 +853,43 @@ class PluginRegistry:
|
|||||||
|
|
||||||
# ── Active UI Manifests (Phase 3) ──
|
# ── Active UI Manifests (Phase 3) ──
|
||||||
|
|
||||||
async def get_active_manifests(self, db: AsyncSession) -> list[dict[str, Any]]:
|
async def get_active_manifests(
|
||||||
|
self, db: AsyncSession, tenant_id: uuid.UUID | None = None
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
"""Return UI manifests for all active plugins.
|
"""Return UI manifests for all active plugins.
|
||||||
|
|
||||||
Each entry contains the plugin name and its frontend UI contributions
|
Each entry contains the plugin name and its frontend UI contributions
|
||||||
(menu_items, page_routes, detail_tabs, settings_pages, dashboard_widgets).
|
(menu_items, page_routes, detail_tabs, settings_pages, dashboard_widgets).
|
||||||
|
|
||||||
|
Audit P1 (tenant manifests): when *tenant_id* is given, plugins that are
|
||||||
|
deactivated for that tenant (tenant_plugin_activation.is_active=False)
|
||||||
|
are excluded — the manifest output must mirror require_active_plugin()
|
||||||
|
semantics so the UI never offers menus/routes the backend then blocks
|
||||||
|
with 403. No tenant row = default active (same as the API gate).
|
||||||
"""
|
"""
|
||||||
result = await db.execute(select(PluginModel).where(PluginModel.active.is_(True)))
|
result = await db.execute(select(PluginModel).where(PluginModel.active.is_(True)))
|
||||||
active_records = {row.name: row for row in result.scalars().all()}
|
active_records = {row.name: row for row in result.scalars().all()}
|
||||||
|
|
||||||
|
# Per-tenant deactivations (same table/semantics as deps.require_active_plugin)
|
||||||
|
tenant_disabled: set[str] = set()
|
||||||
|
if tenant_id is not None:
|
||||||
|
from sqlalchemy import text as sa_text
|
||||||
|
|
||||||
|
rows = await db.execute(
|
||||||
|
sa_text(
|
||||||
|
"SELECT plugin_name FROM tenant_plugin_activation "
|
||||||
|
"WHERE tenant_id = :tid AND is_active = false"
|
||||||
|
),
|
||||||
|
{"tid": tenant_id},
|
||||||
|
)
|
||||||
|
tenant_disabled = {row[0] for row in rows}
|
||||||
|
|
||||||
manifests: list[dict[str, Any]] = []
|
manifests: list[dict[str, Any]] = []
|
||||||
for name, plugin in self._plugins.items():
|
for name, plugin in self._plugins.items():
|
||||||
if name not in active_records:
|
if name not in active_records:
|
||||||
continue
|
continue
|
||||||
|
if name in tenant_disabled:
|
||||||
|
continue
|
||||||
m = plugin.manifest
|
m = plugin.manifest
|
||||||
manifests.append(
|
manifests.append(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from app.routes import (
|
|||||||
compliance, # noqa: F401
|
compliance, # noqa: F401
|
||||||
currencies, # noqa: F401
|
currencies, # noqa: F401
|
||||||
dashboard, # noqa: F401
|
dashboard, # noqa: F401
|
||||||
|
dashboards, # noqa: F401
|
||||||
entity_history, # noqa: F401
|
entity_history, # noqa: F401
|
||||||
guests, # noqa: F401 # ⚠️ Guest-System umgebaut — Guests sind jetzt reguläre User mit role=guest
|
guests, # noqa: F401 # ⚠️ Guest-System umgebaut — Guests sind jetzt reguläre User mit role=guest
|
||||||
health, # noqa: F401
|
health, # noqa: F401
|
||||||
|
|||||||
+22
-2
@@ -64,6 +64,7 @@ class ApprovalResponse(BaseModel):
|
|||||||
requested_by_type: str
|
requested_by_type: str
|
||||||
approver_id: str | None = None
|
approver_id: str | None = None
|
||||||
approver_group: str | None = None
|
approver_group: str | None = None
|
||||||
|
resolved_by: str | None = None
|
||||||
status: str
|
status: str
|
||||||
comment: str | None = None
|
comment: str | None = None
|
||||||
created_at: str | None = None
|
created_at: str | None = None
|
||||||
@@ -93,6 +94,7 @@ def _to_response(r: ApprovalRequest) -> ApprovalResponse:
|
|||||||
requested_by_type=r.requested_by_type,
|
requested_by_type=r.requested_by_type,
|
||||||
approver_id=str(r.approver_id) if r.approver_id else None,
|
approver_id=str(r.approver_id) if r.approver_id else None,
|
||||||
approver_group=r.approver_group,
|
approver_group=r.approver_group,
|
||||||
|
resolved_by=str(r.resolved_by) if r.resolved_by else None,
|
||||||
status=r.status,
|
status=r.status,
|
||||||
comment=r.comment,
|
comment=r.comment,
|
||||||
created_at=r.created_at.isoformat() if r.created_at else None,
|
created_at=r.created_at.isoformat() if r.created_at else None,
|
||||||
@@ -242,6 +244,9 @@ async def approve_approval(
|
|||||||
"""Approve a pending approval request."""
|
"""Approve a pending approval request."""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
rid = _parse_uuid(request_id, "request_id")
|
rid = _parse_uuid(request_id, "request_id")
|
||||||
|
from app.core.approval import ApprovalDecisionError
|
||||||
|
|
||||||
|
try:
|
||||||
req = await resolve_approval_request(
|
req = await resolve_approval_request(
|
||||||
db,
|
db,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
@@ -249,9 +254,15 @@ async def approve_approval(
|
|||||||
decision="approved",
|
decision="approved",
|
||||||
approver_id=uuid.UUID(current_user["user_id"]),
|
approver_id=uuid.UUID(current_user["user_id"]),
|
||||||
comment=body.comment,
|
comment=body.comment,
|
||||||
|
is_system_admin=bool(current_user.get("is_system_admin", False)),
|
||||||
)
|
)
|
||||||
|
except ApprovalDecisionError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=exc.http_status,
|
||||||
|
detail={"detail": str(exc), "code": exc.code},
|
||||||
|
) from exc
|
||||||
if req is None:
|
if req is None:
|
||||||
raise HTTPException(status_code=404, detail="Approval request not found or not pending")
|
raise HTTPException(status_code=404, detail="Approval request not found")
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return _to_response(req)
|
return _to_response(req)
|
||||||
|
|
||||||
@@ -270,6 +281,9 @@ async def reject_approval(
|
|||||||
"""Reject a pending approval request."""
|
"""Reject a pending approval request."""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
rid = _parse_uuid(request_id, "request_id")
|
rid = _parse_uuid(request_id, "request_id")
|
||||||
|
from app.core.approval import ApprovalDecisionError
|
||||||
|
|
||||||
|
try:
|
||||||
req = await resolve_approval_request(
|
req = await resolve_approval_request(
|
||||||
db,
|
db,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
@@ -277,9 +291,15 @@ async def reject_approval(
|
|||||||
decision="rejected",
|
decision="rejected",
|
||||||
approver_id=uuid.UUID(current_user["user_id"]),
|
approver_id=uuid.UUID(current_user["user_id"]),
|
||||||
comment=body.comment,
|
comment=body.comment,
|
||||||
|
is_system_admin=bool(current_user.get("is_system_admin", False)),
|
||||||
)
|
)
|
||||||
|
except ApprovalDecisionError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=exc.http_status,
|
||||||
|
detail={"detail": str(exc), "code": exc.code},
|
||||||
|
) from exc
|
||||||
if req is None:
|
if req is None:
|
||||||
raise HTTPException(status_code=404, detail="Approval request not found or not pending")
|
raise HTTPException(status_code=404, detail="Approval request not found")
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return _to_response(req)
|
return _to_response(req)
|
||||||
|
|
||||||
|
|||||||
+11
-2
@@ -165,15 +165,24 @@ async def audit_retention_cleanup(
|
|||||||
"""Delete audit log entries older than retention_days. Admin only.
|
"""Delete audit log entries older than retention_days. Admin only.
|
||||||
|
|
||||||
Default retention: 365 days.
|
Default retention: 365 days.
|
||||||
|
|
||||||
|
F20 (Astra): runtime roles (crm_api/crm_worker) must NOT be able to
|
||||||
|
delete audit data. The delete runs via the migration session factory
|
||||||
|
(table owner) instead of the request ``db`` — a documented maintenance
|
||||||
|
operation, same pattern as plugin uninstall.
|
||||||
"""
|
"""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
cutoff = datetime.now(UTC) - timedelta(days=retention_days)
|
cutoff = datetime.now(UTC) - timedelta(days=retention_days)
|
||||||
|
|
||||||
|
from app.core.db import get_migration_session_factory
|
||||||
|
|
||||||
|
factory = get_migration_session_factory()
|
||||||
|
async with factory() as mig_db:
|
||||||
q = delete(AuditLog).where(
|
q = delete(AuditLog).where(
|
||||||
AuditLog.tenant_id == tenant_id,
|
AuditLog.tenant_id == tenant_id,
|
||||||
AuditLog.timestamp < cutoff,
|
AuditLog.timestamp < cutoff,
|
||||||
)
|
)
|
||||||
result = await db.execute(q)
|
result = await mig_db.execute(q)
|
||||||
await db.commit()
|
await mig_db.commit()
|
||||||
|
|
||||||
return {"deleted": result.rowcount, "retention_days": retention_days, "cutoff": cutoff.isoformat()}
|
return {"deleted": result.rowcount, "retention_days": retention_days, "cutoff": cutoff.isoformat()}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.deps import get_current_user, require_permission
|
from app.deps import get_current_user, require_admin, require_permission
|
||||||
from app.schemas.backup import BackupListResponse, BackupResponse
|
from app.schemas.backup import BackupListResponse, BackupResponse
|
||||||
from app.services import backup_service
|
from app.services import backup_service
|
||||||
|
|
||||||
@@ -53,16 +53,22 @@ async def create_backup(
|
|||||||
@router.post(
|
@router.post(
|
||||||
"/{backup_id}/restore",
|
"/{backup_id}/restore",
|
||||||
response_model=BackupResponse,
|
response_model=BackupResponse,
|
||||||
dependencies=[Depends(require_permission("automation:admin"))],
|
dependencies=[Depends(require_admin)],
|
||||||
)
|
)
|
||||||
async def restore_backup(
|
async def restore_backup(
|
||||||
backup_id: str,
|
backup_id: str,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(require_admin),
|
||||||
):
|
):
|
||||||
"""Restore a database backup.
|
"""Restore a database backup.
|
||||||
|
|
||||||
WARNING: This is a destructive operation. It drops and recreates the database.
|
WARNING: This is a destructive operation. It drops and recreates the database.
|
||||||
|
|
||||||
|
F23 (Astra P1): a full-database restore is a GLOBAL operations action —
|
||||||
|
it affects every tenant. A tenant admin (automation:admin) must not be
|
||||||
|
able to trigger it: the restore replaces the shared database, not just
|
||||||
|
this tenant's rows. Requires a real system admin (is_system_admin or
|
||||||
|
*:* via the RBAC system).
|
||||||
"""
|
"""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
try:
|
try:
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user