Compare commits
231 Commits
fix/block-h
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 0d052ab604 | |||
| b7b7d41c0c | |||
| 9bb1dbae03 | |||
| b1a75510d6 | |||
| 1acef9669a | |||
| b691dd36c0 | |||
| 9c62d35047 | |||
| 38df597f11 | |||
| cd8ef7500c | |||
| d8a4063c48 | |||
| 56dcc86254 | |||
| f27f0474ef | |||
| 88d96d4a49 | |||
| e1a59e759f | |||
| ad5601eb7d | |||
| 092c2d20fb | |||
| 385521eddc | |||
| 422cc6139d | |||
| b50a933d85 | |||
| ebf4b0363c | |||
| 9510b3a7c9 | |||
| 66c11d3d64 | |||
| bea479bfad | |||
| 70dc0af0b6 | |||
| cfb2bfe7b8 | |||
| 5874975ff9 | |||
| 1c52d3e502 | |||
| 1a24e3e999 | |||
| a796438dfa | |||
| 05bc1e2543 | |||
| 4cb5298768 | |||
| 5680179260 | |||
| 7f6b52b8d0 | |||
| 84061fd8d5 | |||
| e0255412ac | |||
| 4cf7a91416 | |||
| f445aa69d5 | |||
| 4fee01cadf | |||
| ea6c9e71db | |||
| c34715574a | |||
| fce17aac9c | |||
| cbe36e0c0e | |||
| 6702d69f7c | |||
| 94d8c40daa | |||
| be81fe52cf | |||
| a1d5e56009 | |||
| 3e43219b84 | |||
| 26b5ae9a0d | |||
| 11e4e42570 | |||
| 57441df677 | |||
| fbe1bde635 | |||
| 2d17746194 | |||
| 23a05593b2 | |||
| 0baec2792c | |||
| f4a5937a4b | |||
| 38b73f5d4d | |||
| a6bfa8e67c | |||
| a8916b3d86 | |||
| e7afbaa906 | |||
| 34c9c85aed | |||
| 9d2df61942 | |||
| 1b485d4a34 | |||
| f4c4a50ebd | |||
| 69d05d6912 | |||
| df9f86bd12 | |||
| 9e1d202610 | |||
| c291a6ecf1 | |||
| ab3c253cbd | |||
| 52323610e3 | |||
| 86c96f03ca | |||
| 3e5f13f516 | |||
| 4de629d296 | |||
| 6a88c70073 | |||
| c807aacfc0 | |||
| b23045c46a | |||
| 5d8c48a08f | |||
| f6dde68221 | |||
| d901d001c7 | |||
| 962e0ee1f6 | |||
| 49ca4c5fb2 | |||
| 1b22da8b0d | |||
| 76a31a8c39 | |||
| a991f9a0b4 | |||
| 84a30d85c2 | |||
| d9aed519f2 | |||
| b9a6c06e85 | |||
| 8386e99caa | |||
| 7d9ae03bf1 | |||
| 36a03b9897 | |||
| 860db8d61e | |||
| 81aea8c77f | |||
| 46c909c226 | |||
| 197b0d3bab | |||
| 3934aea6ef | |||
| 5cc5a3fa6a | |||
| c0e8e4ecfd | |||
| c32e4bb34e | |||
| ef90d57f0a | |||
| 0768cfb29a | |||
| 56e401969e | |||
| 6d04206695 | |||
| f6e117b1c3 | |||
| 9d8da99026 | |||
| 54066b05fd | |||
| 36636f5c25 | |||
| d89044d8f7 | |||
| 5e0ffd91c2 | |||
| b8b8ef180a | |||
| cad7d084e8 | |||
| dff97f5589 | |||
| 9e84c400ed | |||
| 067fc132cb | |||
| b01b756a4a | |||
| 4bce89aecb | |||
| 5e9be254e2 | |||
| 8a76bfdba4 | |||
| d2434203c1 | |||
| ad7c763e59 | |||
| e3fb4728d7 | |||
| 7467c01d38 | |||
| 4038b74025 | |||
| 5ad107ff83 | |||
| 5cee78c54c | |||
| 32f63adc09 | |||
| c21634b323 | |||
| 73d2e109cd | |||
| 795307754f | |||
| 17516d2783 | |||
| ed8ee5cda1 | |||
| 90a367089d | |||
| b04cda774b | |||
| 982b4c9353 | |||
| 1d6152fb82 | |||
| 337d78ef53 |
@@ -36,6 +36,7 @@ dump.rdb
|
||||
# Frontend build output (regenerated on deploy)
|
||||
frontend/dist/
|
||||
frontend/node_modules/
|
||||
node_modules/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
|
||||
@@ -333,3 +333,10 @@ Ein Task gilt erst als **DONE** wenn alle 8 DoD-Kriterien erfüllt sind (siehe `
|
||||
### Phase-Gate-Review
|
||||
|
||||
Eine Phase gilt erst als **ABGESCHLOSSEN** wenn alle 7 Phase-Gate-Kriterien erfüllt sind (siehe `PLATFORM_ROADMAP.md`). Der Agent darf nicht zur nächsten Phase übergehen ohne Phase-Gate-Review bestanden zu haben.
|
||||
|
||||
## 10. Tracking-Ein-Datei-Regel (bindend seit 2026-08-27)
|
||||
|
||||
- **PROGRESS.md ist die einzige Source of Truth** fuer Status und offene Punkte. Keine weiteren parallelen Tracking-Dateien (test-bugs.md/fix-plan-v3 sind in docs/archive/ historisiert).
|
||||
- Ein Finding wird nur eingetragen mit **tagesaktueller Live-Messung** (Befehl + Zaehler). 'Scanner sagt' oder Plan-Text allein reicht nie.
|
||||
- Tests duerfen nur zusammen mit Pflegeanspruch entstehen: UI-Aenderung zieht Test-Nachzug im selben Commit nach sich. Geister-Tests (Importziel geloescht) werden sofort geloescht.
|
||||
- Playwright-e2e bleibt dem eigenen Runner vorbehalten (vite.config exclude), kein Vitest-Collection.
|
||||
|
||||
+2
-2
@@ -39,8 +39,8 @@ RUN apt-get update \
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --user --no-cache-dir -r requirements.txt
|
||||
COPY requirements.txt requirements.lock ./
|
||||
RUN pip install --user --no-cache-dir -r requirements.lock
|
||||
|
||||
# === Stage 2: Runtime ===
|
||||
FROM python:3.12-slim AS runtime
|
||||
|
||||
@@ -1210,6 +1210,370 @@ Trigger / Event / Cron / Webhook / Agent
|
||||
|
||||
---
|
||||
|
||||
## 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`
|
||||
> hier integriert - gemaess AGENTS.md-Regel "PLATFORM_ROADMAP.md ist EINZIGE
|
||||
> Planungs-Datei". Vollständiges Original inkl. ASCII-Mockups abrufbar via
|
||||
> `git show c807aac:UI_OVERHAUL_PLAN.md`.
|
||||
>
|
||||
> **Konflikt-Notiz (2026-08-25, Block I-D) — ENTSCHIEDEN (2026-08-30):** Option (b)
|
||||
> gilt — die AI-Assistant-Seite bleibt (962e0ee, repariert die Geister-Route
|
||||
> /ai-assistant). Phase 2 ("AI Assistant Page entfernen") ist UEBERHOLT und
|
||||
> wird nicht umgesetzt. Original-Notiz: git show f6516e4:PLATFORM_ROADMAP.md.
|
||||
|
||||
> **Erstellt:** 2026-08-21
|
||||
> **Aktualisiert:** 2026-08-21 — AI Assistent Integration hinzugefügt
|
||||
> **Status:** Planung — nicht gestartet
|
||||
> **Leitlinie:** Auf bestehendem Code aufbauen, 3-Spalten-Explorer-Layout als Standard, keine parallelen Systeme
|
||||
|
||||
---
|
||||
|
||||
### Standard-Layout (Referenz: ContactsList.tsx)
|
||||
|
||||
Alle Explorer-Plugins nutzen das 3-Spalten-Layout aus den UI-Design-Guidelines:
|
||||
|
||||
```
|
||||
┌─────────────┬──────────────────┬──────────────────────┐
|
||||
│ Tree │ Liste/Ansicht │ Detail │
|
||||
│ (224px) │ (flex-1) │ (flex-1 / 60%) │
|
||||
│ ResizablePanel│ ResizablePanel │ ResizablePanel │
|
||||
└─────────────┴──────────────────┴──────────────────────┘
|
||||
```
|
||||
|
||||
- **Toolbar oben:** PluginToolbar mit Filter-Dropdowns, Ansichts-Umschaltern, Aktion-Buttons
|
||||
- **Linke Spalte:** ResizablePanel mit Baumansicht (Ordner, Kategorien, Kalender)
|
||||
- **Mitte:** Liste, Karten, Kalender-Ansicht — mehrere Ansichten umschaltbar
|
||||
- **Rechts:** Detail-Bereich für ausgewähltes Element
|
||||
|
||||
---
|
||||
|
||||
### Phase 1: Echte Bugs fixen (2-3 Tage)
|
||||
|
||||
#### 1.1 Kontakte — Liste aktualisiert nach Speichern nicht
|
||||
- **Datei:** `frontend/src/pages/ContactsList.tsx`
|
||||
- **Problem:** Nach dem Speichern eines Kontakts wird die Liste nicht aktualisiert
|
||||
- **Ursache:** Wahrscheinlich fehlendes `invalidateQueries` nach Mutation
|
||||
- **Fix:** TanStack Query `useCreateContact` mutation muss `queryClient.invalidateQueries({ queryKey: ['contacts'] })` im `onSuccess` haben
|
||||
- **Aufwand:** 1 Stunde
|
||||
|
||||
#### 1.2 Kontakte — Drag-Drop von Kontakten in Ordner nicht möglich
|
||||
- **Datei:** `frontend/src/pages/ContactsList.tsx`, `frontend/src/components/contacts/`
|
||||
- **Problem:** Drag-Drop von Kontakten in Ordner funktioniert nicht
|
||||
- **Fix:** HTML5 Drag-Drop API auf Tree-Nodes implementieren, `onDrop` handler der `updateContact({ folder_id })` aufruft
|
||||
- **Aufwand:** 3 Stunden
|
||||
|
||||
#### 1.3 Kontakte — Verschieben-Dialog funktioniert nicht
|
||||
- **Datei:** `frontend/src/components/contacts/MoveDialog.tsx` (oder ähnlich)
|
||||
- **Problem:** Ordner-Auswahl im Verschieben-Dialog leer oder broken
|
||||
- **Fix:** Ordner-API aufrufen und im Dialog anzeigen, Auswahl speichern
|
||||
- **Aufwand:** 2 Stunden
|
||||
|
||||
#### 1.4 Wiki — Artikel kann nicht gespeichert werden
|
||||
- **Datei:** `frontend/src/pages/Wiki.tsx`, `frontend/src/api/knowledge.ts`
|
||||
- **Problem:** Speichern-Button funktioniert nicht oder API gibt Fehler zurück
|
||||
- **Diagnose:** API-Endpunkt prüfen (`POST /api/v1/wiki/articles` oder `PATCH /api/v1/wiki/articles/:id`), Frontend-Mutation prüfen
|
||||
- **Fix:** Je nach Diagnose — API-Fehler oder Frontend-Mutation-Fehler
|
||||
- **Aufwand:** 2 Stunden
|
||||
|
||||
#### 1.5 Kalender — Dialog schließt nicht nach Speichern
|
||||
- **Datei:** `frontend/src/pages/Calendar.tsx`, `frontend/src/components/calendar/AppointmentEditForm.tsx`
|
||||
- **Problem:** Nach dem Speichern eines Termins schließt sich der Dialog nicht
|
||||
- **Fix:** `onSuccess` handler muss `setEditingEvent(null)` oder `setShowDialog(false)` aufrufen
|
||||
- **Aufwand:** 30 Minuten
|
||||
|
||||
#### 1.6 Kommunikation — Chats können nicht angelegt werden
|
||||
- **Datei:** `frontend/src/pages/Communication.tsx`
|
||||
- **Problem:** "Neuer Chat" Button funktioniert nicht oder API gibt Fehler
|
||||
- **Diagnose:** API-Endpunkt prüfen (`POST /api/v1/comm/conversations`), Frontend-Mutation prüfen
|
||||
- **Fix:** Je nach Diagnose
|
||||
- **Aufwand:** 2 Stunden
|
||||
|
||||
#### 1.7 Wiki — Doppelt im Menü
|
||||
- **Datei:** `frontend/src/routes/index.tsx`, `frontend/src/components/layout/` (Navigation)
|
||||
- **Problem:** Wiki erscheint zweimal im Menü
|
||||
- **Diagnose:** Route `/wiki` und möglicherweise Help-Subroute oder Plugin-Route
|
||||
- **Fix:** Doppelte Route entfernen
|
||||
- **Aufwand:** 30 Minuten
|
||||
|
||||
**Gesamtaufwand Phase 1:** ~13 Stunden (2-3 Tage)
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: AI Assistent in Kommunikation integrieren (2-3 Tage)
|
||||
|
||||
#### Problem
|
||||
Der AI Assistent ist ein paralleles System das die Kommunikation-Plattform dupliziert:
|
||||
- **AI Assistant Tabellen:** `ai_conversations`, `ai_messages` (app/models/ai_conversation.py) + `ai_chat_sessions`, `ai_chat_messages`, `ai_chat_attachments` (app/plugins/builtins/ai_assistant/models.py) — 5 Tabellen
|
||||
- **AI Assistant Frontend:** `AIAssistant.tsx`, `AIAssistantStandalone.tsx`, `SessionList.tsx`, `ChatWindow.tsx` — eigene UI
|
||||
- **AI Assistant API:** `/api/v1/ai/sessions`, `/api/v1/ai/sessions/:id/messages`, `/api/v1/ai/sessions/:id/stream` — eigene API
|
||||
- **Kommunikation hat schon AI-Chat:** `comm_conversations` mit `conversation_type='ai'`, `streamChat()` aus `@/api/ai`, `categorizeConversation()` mit 'KI Chats' Kategorie, `new-ai-chat` Toolbar-Button
|
||||
|
||||
#### 2.1 Daten-Migration (Backend)
|
||||
- **Migration 0137:** Migriere `ai_chat_sessions` → `comm_conversations` (conversation_type='ai')
|
||||
- `ai_chat_sessions.id` → `comm_conversations.id`
|
||||
- `ai_chat_sessions.title` → `comm_conversations.title`
|
||||
- `ai_chat_sessions.tenant_id` → `comm_conversations.tenant_id`
|
||||
- `ai_chat_sessions.user_id` → `comm_conversations.owner_id`
|
||||
- `ai_chat_sessions.agent_id` → `comm_conversations.metadata.agent_id`
|
||||
- `ai_chat_sessions.created_at` → `comm_conversations.created_at`
|
||||
- **Migration 0137:** Migriere `ai_chat_messages` → `comm_messages`
|
||||
- `ai_chat_messages.id` → `comm_messages.id`
|
||||
- `ai_chat_messages.session_id` → `comm_messages.conversation_id`
|
||||
- `ai_chat_messages.role` → `comm_messages.sender_type` ('user' → 'user', 'assistant' → 'ai')
|
||||
- `ai_chat_messages.content` → `comm_messages.content`
|
||||
- `ai_chat_messages.tenant_id` → `comm_messages.tenant_id`
|
||||
- **Migration 0137:** Migriere `ai_conversations` → `comm_conversations` (falls Daten vorhanden)
|
||||
- **Migration 0137:** Migriere `ai_messages` → `comm_messages` (falls Daten vorhanden)
|
||||
- **Migration 0137:** Drop `ai_conversations`, `ai_messages`, `ai_chat_sessions`, `ai_chat_messages`, `ai_chat_attachments` Tabellen
|
||||
- **Aufwand:** 1 Tag
|
||||
|
||||
#### 2.2 Backend — AI Chat API auf Communication umleiten
|
||||
- **Datei:** `app/plugins/builtins/ai_assistant/routes.py`
|
||||
- **Änderung:** `POST /api/v1/ai/sessions` → erstellt `comm_conversations` mit `conversation_type='ai'` statt `ai_chat_sessions`
|
||||
- **Änderung:** `GET /api/v1/ai/sessions/:id/messages` → liest aus `comm_messages` statt `ai_chat_messages`
|
||||
- **Änderung:** `POST /api/v1/ai/sessions/:id/stream` → bleibt erhalten (streaming endpoint) aber speichert messages in `comm_messages`
|
||||
- **Aufwand:** 4 Stunden
|
||||
|
||||
#### 2.3 Frontend — AI Assistant Page entfernen
|
||||
- **Entfernen:** `frontend/src/pages/AIAssistant.tsx`
|
||||
- **Entfernen:** `frontend/src/pages/AIAssistantStandalone.tsx`
|
||||
- **Entfernen:** `frontend/src/components/ai/SessionList.tsx`
|
||||
- **Entfernen:** `frontend/src/components/ai/ChatWindow.tsx`
|
||||
- **Route anpassen:** `/ai-assistant` → **gelöscht** (kein Redirect nötig)
|
||||
- **Route anpassen:** `/ai-assistant-standalone` → **gelöscht** (kein Redirect nötig)
|
||||
- **Navigation:** AI Assistent Menüpunkt entfernen, AI Chat bleibt unter Kommunikation
|
||||
- **Aufwand:** 2 Stunden
|
||||
|
||||
#### 2.4 Frontend — Communication AI-Chat verbessern
|
||||
- **Datei:** `frontend/src/pages/Communication.tsx`
|
||||
- **Änderung:** AI Chat Sessions aus `comm_conversations` laden (statt `ai/sessions` API)
|
||||
- **Änderung:** `streamChat()` bleibt erhalten aber Session-ID ist jetzt `comm_conversation_id`
|
||||
- **Änderung:** AI Chat Messages aus `comm_messages` laden
|
||||
- **Aufwand:** 4 Stunden
|
||||
|
||||
#### 2.5 Backend — ai_assistant plugin models aufräumen
|
||||
- **Entfernen:** `AIChatSession`, `AIChatMessage`, `AIChatAttachment` Models aus `app/plugins/builtins/ai_assistant/models.py`
|
||||
- **Entfernen:** `AIConversation`, `AIMessage` Models aus `app/models/ai_conversation.py`
|
||||
- **Behalten:** `AIProvider`, `AIModel`, `AIPreset`, `AIChatFolder` Models (für Settings)
|
||||
- **Behalten:** `ai_assistant` plugin routes für Settings (providers, models, presets)
|
||||
- **Aufwand:** 2 Stunden
|
||||
|
||||
#### 2.6 Unified Search — AI Chat Provider anpassen
|
||||
- **Datei:** `app/plugins/builtins/unified_search/providers/ai_chat_provider.py`
|
||||
- **Änderung:** Search auf `comm_messages` (conversation_type='ai') statt `ai_chat_messages`
|
||||
- **Aufwand:** 1 Stunde
|
||||
|
||||
**Gesamtaufwand Phase 2:** ~2-3 Tage
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Wiki UI-Überarbeitung (3-4 Tage)
|
||||
|
||||
#### 3.1 WYSIWYG Editor
|
||||
- **Datei:** `frontend/src/components/wiki/WikiEditor.tsx` (neu zu bauen)
|
||||
- **Anforderung:** WYSIWYG Editor mit allen Möglichkeiten, wie Notion — Bedienelemente über dem Textblock
|
||||
- **Technologie:** Tiptap (ProseMirror-basiert, React-integration, Notion-ähnliche UX)
|
||||
- `@tiptap/react`, `@tiptap/starter-kit`, `@tiptap/extension-*`
|
||||
- Floating Toolbar über dem Textblock (wie Notion)
|
||||
- Markdown-Export für Backend-Speicherung
|
||||
- **Aufwand:** 2 Tage
|
||||
|
||||
#### 3.2 Wiki Layout — 3-Spalten
|
||||
- **Datei:** `frontend/src/pages/Wiki.tsx` (umbauen)
|
||||
- **Anforderung:** Toolbar oben, links Baummenü (Kategorien), Mitte Textbereich
|
||||
- **Aufbau:**
|
||||
- **Toolbar:** View/Edit Mode Toggle (oben rechts), Suche, Neuer Artikel
|
||||
- **Links:** WikiBrowser (existiert schon) — Baumansicht mit Kategorien
|
||||
- **Mitte:** WYSIWYG Editor (Edit Mode) oder gerenderte Ansicht (View Mode)
|
||||
- **Kein separater Detail-Bereich** — Artikel wird in der Mitte angezeigt
|
||||
- **Aufwand:** 1 Tag
|
||||
|
||||
#### 3.3 View/Edit Mode Toggle
|
||||
- **Datei:** `frontend/src/pages/Wiki.tsx`
|
||||
- **Anforderung:** Button oben rechts in der Toolbar der zwischen View und Edit Mode wechselt
|
||||
- **Im Edit Mode:** WYSIWYG Editor mit Floating Toolbar
|
||||
- **Im View Mode:** Gerenderte Markdown-Ansicht (wie jetzt, aber schöner)
|
||||
- **Aufwand:** 2 Stunden
|
||||
|
||||
**Gesamtaufwand Phase 3:** ~3-4 Tage
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Tasks UI-Überarbeitung (2-3 Tage)
|
||||
|
||||
#### 4.1 Tasks Layout — 3-Spalten wie Kontakte
|
||||
- **Datei:** `frontend/src/pages/Tasks.tsx` (kompletter Umbau, 419 → ~600 Zeilen)
|
||||
- **Anforderung:** Linke Sidebar Baumansicht, Mitte Liste mit mehreren Ansichten, rechts Detailbereich
|
||||
- **Aufbau:**
|
||||
- **Toolbar:** PluginToolbar mit Filter-Dropdowns (Status, Priorität, Zuweisung, Fällig), Ansichts-Umschalter (Liste/Kanban), Neuer Task
|
||||
- **Links:** Baumansicht — nach Status (Offen/In Bearbeitung/Erledigt), nach Priorität, nach Zuweisung, nach Liste/Goal
|
||||
- **Mitte:** Liste (Tabelle) oder Kanban-Board — umschaltbar
|
||||
- **Rechts:** TaskDetail — ausgewählter Task mit Beschreibung, Subtasks, Zuweisung, Fälligkeit
|
||||
- **Aufwand:** 2-3 Tage
|
||||
|
||||
**Gesamtaufwand Phase 4:** ~2-3 Tage
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Kalender UI-Überarbeitung (1 Tag)
|
||||
|
||||
#### 5.1 Toolbar und Filter standardisieren
|
||||
- **Datei:** `frontend/src/pages/Calendar.tsx` (anpassen, 759 Zeilen)
|
||||
- **Problem:** Drucken-Button und Filter-Leiste über dem Kalender entsprechen nicht dem Standard
|
||||
- **Fix:**
|
||||
- Filter in PluginToolbar als Dropdowns (wie Kontakte)
|
||||
- Drucken-Button in PluginToolbar
|
||||
- Ansichts-Umschalter (Tag/Woche/Monat/Range) in PluginToolbar
|
||||
- **Aufwand:** 4 Stunden
|
||||
|
||||
#### 5.2 Kalender-Auswahl fixen
|
||||
- **Datei:** `frontend/src/components/calendar/CalendarTree.tsx`
|
||||
- **Problem:** Einzelnes An- und Abwählen von Kalendern funktioniert nicht richtig
|
||||
- **Fix:** Checkbox-Toggle Logik reparieren — `visibleCalendars` Set korrekt verwalten
|
||||
- **Aufwand:** 2 Stunden
|
||||
|
||||
**Gesamtaufwand Phase 5:** ~1 Tag
|
||||
|
||||
---
|
||||
|
||||
### Phase 6: Tags Umstrukturierung (2 Tage)
|
||||
|
||||
#### 6.1 Tags in Settings verschieben
|
||||
- **Datei:** `frontend/src/pages/Tags.tsx` → `frontend/src/pages/SettingsTags.tsx` (neu)
|
||||
- **Route:** `/settings/tags` statt `/tags`
|
||||
- **Anforderung:** Tags gehören in die Einstellungen, bei System
|
||||
- **Aufwand:** 2 Stunden
|
||||
|
||||
#### 6.2 Tags Baumstruktur
|
||||
- **Datei:** `frontend/src/pages/SettingsTags.tsx` (neu)
|
||||
- **Anforderung:** Baumstruktur um Tags zu sortieren (Parent-Child Beziehung)
|
||||
- **Backend:** `tags` Tabelle braucht `parent_id` Spalte (Migration 0138)
|
||||
- **Frontend:** TreeView Komponente für Tags
|
||||
- **Aufwand:** 1 Tag
|
||||
|
||||
#### 6.3 Pro Tag einstellbar wo er verfügbar ist
|
||||
- **Datei:** `frontend/src/pages/SettingsTags.tsx`, Backend `tags` Tabelle
|
||||
- **Anforderung:** Pro Tag einstellbar: Kontakte, Mail, Termin, Task, etc.
|
||||
- **Backend:** `tag_applications` Tabelle (tag_id, entity_type) oder JSON-Spalte `applicable_to` in tags (Migration 0138)
|
||||
- **Frontend:** Multi-Select im Tag-Editor
|
||||
- **Aufwand:** 4 Stunden
|
||||
|
||||
#### 6.4 Symbol und Farbe pro Tag
|
||||
- **Datei:** `frontend/src/pages/SettingsTags.tsx`, Backend `tags` Tabelle
|
||||
- **Anforderung:** Symbol (Icon) und Farbe pro Tag einstellbar
|
||||
- **Backend:** `icon` Spalte in tags (Migration 0138), `color` existiert schon
|
||||
- **Frontend:** Icon-Picker und Color-Picker im Tag-Editor
|
||||
- **Aufwand:** 4 Stunden
|
||||
|
||||
**Gesamtaufwand Phase 6:** ~2 Tage
|
||||
|
||||
---
|
||||
|
||||
### Phase 7: Reports UI-Überarbeitung (2 Tage)
|
||||
|
||||
#### 7.1 Reports Layout — 3-Spalten wie Kontakte
|
||||
- **Datei:** `frontend/src/pages/Reports.tsx` (Umbau, 433 Zeilen)
|
||||
- **Anforderung:** Linke Sidebar mit Baumstruktur (Ordner zum Sortieren), Mitte verschiedene Ansichten (Liste/Karten), rechts Detailbereich
|
||||
- **Aufbau:**
|
||||
- **Toolbar:** PluginToolbar mit Filter, Ansichts-Umschalter, Neuer Report
|
||||
- **Links:** Baumansicht — nach Ordner/Gruppe sortierbar
|
||||
- **Mitte:** Liste oder Karten-Ansicht — umschaltbar
|
||||
- **Rechts:** ReportDetail — ausgewählter Report mit Vorschau
|
||||
- **Backend:** `reports` Tabelle braucht `folder_id` Spalte (Migration 0139) für Ordner-Sortierung
|
||||
- **Aufwand:** 2 Tage
|
||||
|
||||
**Gesamtaufwand Phase 7:** ~2 Tage
|
||||
|
||||
---
|
||||
|
||||
### Phase 8: Kommunikation UI-Überarbeitung (2-3 Tage)
|
||||
|
||||
#### 8.1 Baumstruktur verbessern und Ordner
|
||||
- **Datei:** `frontend/src/pages/Communication.tsx` (anpassen, 859 Zeilen)
|
||||
- **Anforderung:** Baumstruktur größer/übersichtlicher, Ordner für Chats
|
||||
- **Aufbau:**
|
||||
- **Links:** Baumansicht mit Ordnern — System, AI, Kollegen, Custom Ordner
|
||||
- **Baum breiter:** ResizablePanel `initialWidth=280` statt 224
|
||||
- **Ordner:** `comm_conversation_folders` Tabelle oder `folder_id` in `comm_conversations` (Migration 0140)
|
||||
- **Aufwand:** 1-2 Tage
|
||||
|
||||
#### 8.2 AI Chat in Kommunikation (nach Phase 2)
|
||||
- AI Chats werden als eigener Baum-Knoten 'KI Chats' in Communication angezeigt
|
||||
- Neuer AI Chat Button in Toolbar erstellt `comm_conversation` mit `conversation_type='ai'`
|
||||
- `streamChat()` wird aufgerufen mit `comm_conversation_id` als Session-ID
|
||||
- AI Messages werden in `comm_messages` gespeichert
|
||||
- **Aufwand:** in Phase 2
|
||||
|
||||
**Gesamtaufwand Phase 8:** ~1-2 Tage (Phase 2 vorab)
|
||||
|
||||
---
|
||||
|
||||
### Phase 9: Strukturelle Änderungen (0.5 Tage)
|
||||
|
||||
#### 9.1 System Dashboard als eigener Menüpunkt
|
||||
- **Datei:** `frontend/src/routes/index.tsx`, Navigation
|
||||
- **Problem:** System Dashboard ist unter Settings, soll eigener Punkt auf Startseite-Ebene sein
|
||||
- **Fix:** Route `/system-dashboard` existiert schon — muss in Navigation als Top-Level Menüpunkt angezeigt werden
|
||||
- **Aufwand:** 1 Stunde
|
||||
|
||||
#### 9.2 Mail — Postfach mit IMAP anlegen testen
|
||||
- **Datei:** `frontend/src/pages/Mail.tsx`, `frontend/src/pages/MailSettings.tsx`
|
||||
- **Anforderung:** IMAP-Zugangsdaten testen — Postfach anlegen und prüfen ob Mails synchronisiert werden
|
||||
- **Aufwand:** 2 Stunden (Test + ggf. Bugfix)
|
||||
|
||||
**Gesamtaufwand Phase 9:** ~0.5 Tage
|
||||
|
||||
---
|
||||
|
||||
### Phase-O-Phasenübersicht
|
||||
|
||||
| Phase | Inhalt | Aufwand | Migration | Abhängigkeit |
|
||||
|-------|--------|---------|-----------|-------------|
|
||||
| 1 | Echte Bugs fixen | 2-3 Tage | Keine | Keine |
|
||||
| 2 | AI Assistent → Kommunikation | 2-3 Tage | 0137 | Phase 1.6 |
|
||||
| 3 | Wiki UI + WYSIWYG | 3-4 Tage | Keine | Phase 1.4 |
|
||||
| 4 | Tasks UI neu | 2-3 Tage | Keine | Keine |
|
||||
| 5 | Kalender UI | 1 Tag | Keine | Phase 1.5 |
|
||||
| 6 | Tags Umstrukturierung | 2 Tage | 0138 | Keine |
|
||||
| 7 | Reports UI | 2 Tage | 0139 | Keine |
|
||||
| 8 | Kommunikation UI | 1-2 Tage | 0140 | Phase 2 |
|
||||
| 9 | Strukturelle Änderungen | 0.5 Tage | Keine | Keine |
|
||||
|
||||
**Gesamtaufwand:** ~17-22 Tage
|
||||
|
||||
#### Reihenfolge:
|
||||
1. **Phase 1** (Bugs) — zuerst, damit grundlegende Funktionen arbeiten
|
||||
2. **Phase 9** (Strukturelle Änderungen) — schnell, wenig Aufwand
|
||||
3. **Phase 5** (Kalender) — kleines Update, baut auf Phase 1 auf
|
||||
4. **Phase 2** (AI Assistent → Kommunikation) — entfernt paralleles System, baut auf Phase 1.6 auf
|
||||
5. **Phase 6** (Tags) — unabhängig, Backend + Frontend
|
||||
6. **Phase 4** (Tasks) — großer Umbau, unabhängig
|
||||
7. **Phase 3** (Wiki) — größter Umbau (WYSIWYG Editor), baut auf Phase 1 auf
|
||||
8. **Phase 7** (Reports) — großer Umbau, unabhängig
|
||||
9. **Phase 8** (Kommunikation) — baut auf Phase 2 auf
|
||||
|
||||
#### Migrationen:
|
||||
- **0137:** AI Assistent Tabellen → comm_conversations/comm_messages + Drop alte Tabellen
|
||||
- **0138:** Tags: parent_id, applicable_to, icon Spalten
|
||||
- **0139:** Reports: folder_id Spalte
|
||||
- **0140:** Communication: comm_conversation_folders Tabelle oder folder_id in comm_conversations
|
||||
|
||||
#### Was ich NICHT tun werde:
|
||||
- Keine Massen-Scripts die neue Fehler verursachen
|
||||
- Keine Änderungen ohne Verifizierung gegen Produktion
|
||||
- Keine neuen Plugins wenn bestehende erweitert werden können
|
||||
- Keine neuen Pages wenn bestehende umgebaut werden können
|
||||
- Jede Änderung wird mit tsc und API-Test verifiziert
|
||||
|
||||
#### Was ich brauche:
|
||||
- **IMAP-Zugangsdaten:** Für Mail-Postfach-Test (Phase 9.2)
|
||||
|
||||
---
|
||||
|
||||
## Zusammenfassung
|
||||
|
||||
| Phase | Dauer | Hauptdeliverable |
|
||||
@@ -1226,8 +1590,360 @@ Trigger / Event / Cron / Webhook / Agent
|
||||
| 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 |
|
||||
| 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** |
|
||||
|
||||
---
|
||||
|
||||
*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).
|
||||
|
||||
|
||||
+712
-2
@@ -1,11 +1,704 @@
|
||||
# LeoPlatform — Fortschritts-Tracking
|
||||
|
||||
> **Letztes Update:** 2026-08-21
|
||||
> **Status:** Phase A-K done (261/261 Tasks), 25 Plugins aktiv, Alembic 0136, 2174 Tests
|
||||
## Externer Architektur-Audit — 13 Backend-Fixes verifiziert & umgesetzt (2026-09-13, Commit 4a25ac1, [#370](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/370)) ✅
|
||||
|
||||
**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) (**F01 ✓ Commit f2a7206: KI-Tool-Guard an allen Ausführungspfaden, Allowlist+required_permission fail-closed, 18/18 Tests; F02 ✓ Commit 824686c: globale Identität (email/passwort/mehrmandanten-is_active) gegen Mandantenverwaltung geschützt, 13/13 Tests; beide deployed+live-verifiziert; offen: F03, F05, F10, F11, F15, F20, F21, F23, F30**), S2 Ausführung verbinden [#397](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/397) (F06-F09, F12-F19, F31, F37, F40, F41), 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)
|
||||
|
||||
| Bug | Issue | Fix | Verifikation (Live-Messung 2026-08-27) |
|
||||
|---|---|---|---|
|
||||
| KI-Chat `Stream failed: 403` (sessionStorage-Key `leocrm_csrf_token` wird nie geschrieben → Request ohne X-CSRF-Token) | [#351](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/351) | streamChat nutzt `getCsrfToken()` aus dem gemeinsamen Client | Vitest streamChat.test.ts 2/2 passed: X-CSRF-Token-Header bewiesen |
|
||||
| Alle Mutationen (Wiki-Save etc.) 403 nach Seiten-Reload (`/auth/me` lieferte csrf_token nicht zurück → In-Memory-Token nach Reload weg) | [#351](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/351) | `/auth/me` liefert `csrf_token` aus Session; useCurrentUser stellt ihn beim Bootstrap wieder her | pytest test_auth.py 11/11 passed inkl. neuem Regressionstest `test_me_returns_csrf_token_for_reload_restore` |
|
||||
|
||||
**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) ✅
|
||||
|
||||
**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.
|
||||
|
||||
**Fix (b1a7551):** hardcodedNavItems auf 7 echte Core-Settings reduziert (stammdaten, user-management, system, custom-fields, webhooks, workspaces, backup) — Plugin-Settings kommen ausschließlich via pluginNavItems. Path-Dedup bleibt als Sicherheitsnetz.
|
||||
|
||||
**Dashboard-Verify (Kritikpunkt 20a):** Dashboard.tsx lädt Widgets bereits dynamisch via `useDashboardWidgets()` API (Manifest-Contributions von calendar/contacts/tasks) → DashboardWidgetLoader ist nur der Vite-Code-Splitting-Renderer, **keine fachliche Doppelquelle** — Kritikpunkt 20a teilweise widerlegt.
|
||||
|
||||
**Verifikation:** Vitest 90/90 (13 Dateien inkl. settings + dialog + permissions) · tsc exit 0 · frontend-only Deploy FE_EXIT=0
|
||||
|
||||
## Suite-Isolation (2026-08-28) ✅
|
||||
|
||||
**Mechanismus (Live-Messung):** `close_engine()` in der rbac `mail_app`-Fixture disposiert UND setzt alle globalen Engines auf None — 12 ACL-Batch-Failures (`relation "users" does not exist`) in Nachfolger-Suiten.
|
||||
|
||||
**Fix (b691dd3):** `reset_engine_for_testing(engine)` nach `close_engine()` im Teardown — conftest-Engine wird als globale Engine wiederhergestellt (Produktions-Bootstrap-Spiegelung).
|
||||
|
||||
**Verifikation:** ACL-Batch vorher 12 failed/118 passed → nachher **130 passed** (alle 12 behoben).
|
||||
|
||||
**Nur tests/ geändert — kein Production-Deploy nötig.**
|
||||
|
||||
## W4a — Import/Export Contribution-Architektur (2026-08-27, SPECS final)
|
||||
|
||||
**Spec:** [#359](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/359) (user-abgestimmt)
|
||||
|
||||
**Kernentscheidungen:** Zentraler Dialog (Modal lg/xl) geöffnet per Toolbar-Button in jeder Modulliste, deren Plugin Import/Export anbietet; /import-export-Seite wird zur Übersicht aller Angebote (Variante b). Formate als Plugins (CSV/JSON/XLSX bundled; PDF später separat). Module = Contributors via Contract (`importexport_meta`); Core = Orchestrator + Sicherheits-Policy-Layer (Sensitive-Filter, Tenant-Scoping, Audit unabhängig vom Modul erzwungen). Modul-native Formate (ICS/EML/ZIP) registrieren sich nur anzeigend.
|
||||
|
||||
**Abgelöst:** `export_service.py` (78 Z., CSV-only, alter /export-Endpoint) + contact-spezifische Logik in `import_export_service.py` (504 Z.) — der bewiesene Doppel-Weg wird konsolidiert.
|
||||
|
||||
### Phase 1 — Backend-Kern ✅ (cd8ef75, deployed 21:14)
|
||||
|
||||
- Format-Registry `app/core/importexport_registry.py` (FormatHandler-Protokoll, `available_for()` Schnittmenge)
|
||||
- `importexport_formats`-Plugin (csv/json/xlsx), lifecycle-korrekt (on_activate registriert, on_deactivate entfernt)
|
||||
- ContactsContract `importexport_entities()` + `ie_*`-Methoden (Columns, Validatoren, Normalizer, Fetch, Persist mit Audit)
|
||||
- `import_export_service.py` generische Engine — iteriert über `registry.list_discovered()`, keine hartcodierten Plugin-Namen
|
||||
- **Funktionserhalt: 45/45 import_export-Suite passed** (inkl. Fehler-Multiplizität: 2 failed rows → 3 total_errors via ie_required + ie_row_valid-Trennung; Zwischenstand mit 2 Failures live gefangen und korrigiert)
|
||||
|
||||
### Phase 2 — Frontend-Dialog ✅ (38df597, deployed 21:34)
|
||||
|
||||
- `ImportExportDialog.tsx` (neu): Modal lg/xl, Export-Tab (1 Schritt) + Import-Tab (4 Schritte: Datei → Mapping-Editor → Dry-Run → Ausführung+Report inkl. Background-Job-Polling)
|
||||
- 24 `importexport.*`-i18n-Keys (de + en, keine hardcoded Strings)
|
||||
- ContactsList: Toolbar-Button (contacts:read-Gate, Upload-Icon, entityType vorgewählt) über pluginToolbarStore
|
||||
- **Verifikation: Vitest 12/12** (importExportDialog 6/6 + routePermissions 6/6) · tsc exit 0 · Production-Build exit 0 · 6 contacts/shell-Failures per Stash-Test als Vorbestand bewiesen (identisch auf clean HEAD) · frontend-only Deploy FE_EXIT=0
|
||||
|
||||
### Phase 3 — PDF (separat, offen)
|
||||
|
||||
PDF als weiteres Format-Plugin — eigener Design-Baustein (Library-Choice, Templates pro Modul).
|
||||
|
||||
## Welle 3a — Route-Permission-Wahrheit (2026-08-27)
|
||||
|
||||
| Finding | Issue | Fix | Verifikation (Live-Messung) |
|
||||
|---|---|---|---|
|
||||
| **Phantom-Permission:** `/communication` prüfte `communication:read` — nirgends registriert (Plugin liefert nur `comm:*`) → Nicht-Admins mit gültigem comm:read bekamen die Seite nie | [#358](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/358) | → `comm:read` (Backend+Manifest-Wahrheit) | Vitest routePermissions.test.ts **6/6 passed** (Source-Inspektion: 5 Route-Korrekturen + Phantom-Nachweis) |
|
||||
| `/mail/settings` prüfte zu schwaches `mail:read` — Backend verlangt `mail:config` | [#358](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/358) | → `mail:config` | dito |
|
||||
| `/import-export` prüfte `contacts:read` — Backend (Core-Modul `app/routes/import_export.py`) verlangt `import_export:read`; Kritik-Aussage „import_export-Plugin" widerlegt (Existenz geprüft: keins) | [#358](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/358) | → `import_export:read` (in CORE_PERMISSIONS registriert, Zeile 59) | dito |
|
||||
| `/activity` prüfte Phantom `activity:read` (nirgends registriert, kein Backend-Nutzer); Seite nutzt Audit-API | [#358](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/358) | → `audit:read` | dito |
|
||||
| `/wiki` ungeschützt im statischen Router — Backend verlangt `wiki:read` | [#358](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/358) | → `wiki:read` | dito |
|
||||
|
||||
**Gates (Kritikpunkt 21 erfüllt):** Vitest 6/6 · tsc --noEmit exit 0 · **Production-Build exit 0 (vor Commit)** · frontend-only Deploy FE_EXIT=0
|
||||
|
||||
## Welle 2b — Contacts-Entity-Registry Single-Source (2026-08-27)
|
||||
|
||||
| Finding | Issue | Fix | Verifikation (Live-Messung) |
|
||||
|---|---|---|---|
|
||||
| Doppelquelle: statische `contact/contacts/company`-Einträge in `ENTITY_MODELS` neben identischer dynamischer Lieferung via `ContactsPlugin.get_entity_models()` (Kritikpunkte 9–11) | [#357](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/357) | 3 statische Einträge entfernt — ContactsPlugin ist Single Source; conftest spiegelt Produktions-Bootstrap idempotent (autouse-Fixture); `contact_folder` etc. bleiben korrekt (echte Core-Entities, von keinem Plugin geliefert) | Regressionstests `tests/test_contacts_entity_registry.py` **3/3 passed** (Source-Inspektion + Plugin-Lieferung + Bootstrap); entity_permissions + cross_tenant_security Suiten grün; ruff 0 Fehler; create_app OK |
|
||||
| VORBESTAND bewiesen: 12 ACL-Batch-Failures durch Suite-Isolation (`relation "users" does not exist` in Nachfolger-Suiten) | [#357](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/357) (Body) | Kein Fix in diesem Commit — Stash-Test: identische 12 Failures auf clean HEAD (118 passed) vs. mit Fix (121 passed, nur +3 neue Tests) | Separates Isolation-Bugfix-Paket nötig |
|
||||
|
||||
**Gates:** ruff modified-files 0 · pytest entity_registry 3/3 + ACL-Funktionserhalt grün · create_app OK
|
||||
|
||||
## Welle 2 — Cross-Plugin/DSAR-Fix (2026-08-27)
|
||||
|
||||
| Finding | Issue | Fix | Verifikation (Live-Messung) |
|
||||
|---|---|---|---|
|
||||
| 4 Core→Plugin-Imports in `core/jobs.py` DSAR-Sammlung | [#356](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/356) | Schritt 1 (092c2d2): Imports auf `get_contract()` umgestellt | Checker 4→0; DSAR-Suite 4/4 |
|
||||
| **Vertiefung nach Review-Einspruch:** Plugin-Fachlogik (welche Kategorien, welche Felder, Limits) lag weiterhin hart im Core — Core kannte Plugin-Details | [#356](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/356) | **Komplette Extraktion:** `dsar_collect()`/`dsar_erase()` in die 5 beteiligten Contracts (contacts, mail, tasks, calendar, kommunikation); `core/jobs.py` sammelt/löscht nur Core-eigene Daten (Profil, Audit, Notifications, User-Anonymisierung) und iteriert generisch über `registry.list_discovered()` → Contract-DSAR-Beiträge. **Neue Plugins liefern DSAR-Kategorien ohne Core-Änderung.** | Checker **0 Verstöße**; DSAR-Suite **4/4 passed** (Counts-/Category-Keys unverändert: `contacts`, `contacts_soft_deleted` etc. via Contracts); `create_app()` OK; ruff nur 2 Vorbestand-N811; korruptes tasks/contracts.py (Patch-Artefakt, ast-gefangen) sauber neu geschrieben |
|
||||
| P16 manuell klassifiziert: `app.models.contact`-Imports in core/jobs.py + worker.py sind KEINE Verstöße (Contact liegt im Core-Models-Layer) | — | Keine Aktion nötig, dokumentiert in #356 | Checker-Regex deckt nur `app.plugins.*` ab — korrekt so |
|
||||
|
||||
**Gates:** ruff modified-files grün (2 Vorbestand N811 ausgenommen) · Cross-Plugin-Checker 0 · pytest DSAR 4/4 · create_app OK
|
||||
|
||||
## Welle 1 — Plugin-Lifecycle-Fix (2026-08-27)
|
||||
|
||||
| Finding | Issue | Fix | Verifikation (Live-Messung) |
|
||||
|---|---|---|---|
|
||||
| P1: `was_already_active`/`was_already_inactive` wurden in `plugin_service.activate/deactivate_plugin()` aus dem Record NACH dem Registry-Aufruf berechnet → konstant falsch → Runtime-Deregistrierung beim Deactivate war toter Code; Activate-Zweig lief nie | [#355](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/355) | Vorher-Status wird VOR dem Registry-Aufruf gelesen (`_get_plugin_record`) und beide Zweige laufen jetzt wirklich | TDD: neuer echter Integrationstest `tests/test_plugin_lifecycle_service.py` (install→activate×2→deactivate×2→re-activate über PluginService, beweist Permissions×1, Gate-Eintrag, ENTITY_MODELS on/off) rot→grün; Regression 93 passed (nur bekannter #354-Vorbestand) |
|
||||
| P3: `registry.activate()` synced Notification Types VOR dem Statusupdate → Types des frisch aktivierten Plugins fehlten | [#355](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/355) | `sync_notification_types()` hinter DB-Statusupdate+Flush verschoben | Beweis im selben Integrationstest: NotificationType existiert nach activate, entfernt nach deactivate |
|
||||
|
||||
**Gates:** ruff modified-files grün · pytest Lifecycle 2/2 + Regression 93 passed · Deploy folgt
|
||||
|
||||
## Legacy-Cleanup (2026-08-27)
|
||||
|
||||
| Aktion | Commit | Verifikation (Live-Messung) |
|
||||
|---|---|---|
|
||||
| Toter AI-Copilot-Legacy gelöscht (`ai_copilot.py` Routes+Service+Schema+Model+Geister-Test, schemas/__init__, OpenAPI-Tag) — Chat läuft seit Migration 0137 über kommunikation/comm_conversations | b50a933 | Router war nie gemountet; Prod-DB: `relation "ai_conversations" does not exist`; create_app OK 83 routes identisch auf clean HEAD (Stash-Beweis); ruff modified-files grün; pytest auth/plugins/marketplace 100 passed |
|
||||
| Vorbestand-Failure dokumentiert: `test_contacts_lifecycle ..._without_contacts_special_case` erwartet >100 Backend-Routen, Architektur liefert 83 (Routen in Manifesten/Frontend-Router) | [#354](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/354) | Stash-Test auf ebf4b03: identischer Failure → Vorbestand bewiesen |
|
||||
|
||||
Bekannter Rest (aus 27-Punkte-Kritik gegengeprüft): Plugin-Lifecycle-Cleanup tot (P1), sync_notification_types-Reihenfolge (P3), manueller Lifecycle-Test (P2), Cross-Plugin 4 Verstöße core/jobs.py + versteckte app.models.contact-Imports (P15/P16), Sidebar-Sonderdrahtung contacts (P10), statische ENTITY_MODELS (P11), saved_views/filters contacts:read-Pin (P13), Settings-Dedupe (P19), Dashboard hartes Widget-Map (P20a). Welle 1 (Lifecycle+echter Integrationstest) startet als nächstes.
|
||||
|
||||
> **Audit:** Komplette Vernetzungs-Audit durchgeführt — ~1800 Vernetzungen, 93% verbunden, 6 kritische Findings
|
||||
|
||||
---
|
||||
|
||||
## Architektur-Reparatur (2026-08-23, laufend)
|
||||
|
||||
**Plan:** docs/fix-plan-v3.md · **Sequenz:** Block 0 + Block H gemerged in main, Block A läuft auf main
|
||||
**Stand:** 18 Findings geschlossen (~30% aufwandsgewichtet) · App startbar · 90+ Tests grün · tsc clean · Checker 14→6 Verstöße · Alles gepusht auf Forgejo
|
||||
|
||||
| Finding | Beschreibung | Status | Commit |
|
||||
|---|---|---|---|
|
||||
| SYNTAX-001 | automation/plugin.py SyntaxError — App startet nicht | ✅ gefixt | 8077595 |
|
||||
| CHECK-002 | Checker crasht bei relativen Pfaden | ✅ gefixt | 35e2cc8 |
|
||||
| ARCH-010 | Checker scannt nur builtins | ✅ Vollscan-Default | 35e2cc8 |
|
||||
| ARCH-014 | Contract-Lazy-Resurrect nach unregister | ✅ gefixt + Funktionstest | b04cda7 |
|
||||
| ARCH-020 | EventBus subscribe ohne Duplikat-Check | ✅ gefixt + Funktionstest | b04cda7 |
|
||||
| ARCH-029/041 | trigger_dispatcher None-Check nach Verwendung | ✅ None-Check zuerst | b04cda7 |
|
||||
| ARCH-001 | Permissions nach on_activate registriert | ✅ Reihenfolge gedreht | 1d6152f |
|
||||
| ARCH-002 | on_activate pro Tenant mehrfach | ✅ 1× pro Prozess | 1d6152f |
|
||||
| ARCH-003 | active-manifests an plugins:read gebunden | ✅ für eingeloggte User offen | 982b4c9 |
|
||||
| ARCH-038 | BasePlugin.register_event_handlers fehlt | ✅ Hook ergänzt | 90a3670 |
|
||||
| ARCH-054 | entity_permissions falsche Datenstruktur | ✅ Model-Lookup korrigiert | 90a3670 |
|
||||
| ARCH-047 | SearchContract-Import kaputt (search-Step ImportError) | ✅ gefixt | d87fc4e |
|
||||
| ARCH-030 | contract.get_function() existiert nicht | ✅ auf 5 Contracts ergänzt | d87fc4e |
|
||||
| ARCH-031 | knowledge/plugin.py uuid nicht importiert | ✅ mitfixt | 1f4a621 |
|
||||
| ARCH-040/046/049 | Core→Plugin-Imports (worker/compliance/engine) | ✅ via Contract/Plugin-Job | 44511a8 + a7699d3 |
|
||||
| HC-F Frontend | BlockRenderer: 14 hardcodierte Blocks | ✅ Registry | b7ad529 |
|
||||
| HC-G Frontend | AISidebar: 5 hardcodierte Tabs | ✅ Tab-Registry | 59fdb61 |
|
||||
| HC-A Backend | action_mapper feste Regex-Intents | ✅ Contribution-API | 4994906 |
|
||||
| Gate H | Plugin-Contribution ohne Core-Änderung beweisen | ✅ BESTANDEN (2/2) | 801743b |
|
||||
|
||||
| ARCH-043 | automation Tenant.limit(1) statt System-Tenant | ✅ get_system_tenant() + system_tenant_slug Setting | 17516d2 |
|
||||
| ARCH-052 | storage get_file_metadata Event-Loop im async Kontext | ✅ get_file_metadata_async() + Fail-Fast-Guard | 17516d2 |
|
||||
| ARCH-008 | Permission-Namensschema inkonsistent | ✅ Kanon modul:aktion festgelegt, Manifest-Validator erzwingt es | 7953077 |
|
||||
| ARCH-009 | Tote 3-Segment-Rollen-Patterns (core:*:X) + 14 Route-Literals | ✅ Migration 0141 + Route-Fix, Roundtrip bewiesen | 7953077 |
|
||||
|
||||
| ARCH-012 | wiki/knowledge on_deactivate unvollständig | ✅ Provider-Dereg + 2 latente Bugs (register_provider fehlte am Contract, kaputter Modul-Import) behoben | c21634b |
|
||||
| ARCH-013 | self_improvement Fallback-Import; Benachrichtigung war tot | ✅ Contract-only; undefinierten KommunikationContract-Verweis behoben | c21634b |
|
||||
| ARCH-015 | Notification-Sync fehlt in Deactivate-Sequenz | ✅ sync_notification_types nach Status-Update | c21634b |
|
||||
| ARCH-033 | comm_websocket/comm_miniapps bleiben im Container | ✅ Container-Cleanup VOR super(); ServiceContainer.remove() ergänzt | c21634b |
|
||||
| ARCH-034/035 | self_improvement/marketplace Contract-Unregister | ✅ verifizierte No-Ops: beide registrieren keinen Contract | c21634b |
|
||||
| ARCH-036 | mail _auto_sync_task Klassenvariable | ✅ Instanzvariable via __init__ | c21634b |
|
||||
| ARCH-037 | graph_rag Registrierung VOR super() | ✅ Reihenfolge umgestellt | c21634b |
|
||||
| ARCH-044 | ai_ui_control remove() NACH super() | ✅ Reihenfolge umgestellt; fehlendes ServiceContainer.remove() ergänzt | c21634b |
|
||||
| Gate A | Block-A-Abschlussprüfung | ✅ BESTANDEN (4/4): Imports, Lifecycle-Symmetrie, Activate-Once, Contract-Roundtrip | 32f63ad |
|
||||
|
||||
| B1 | Contacts-Domain aus Core entkoppelt: 4 Router ins Plugin verschoben, manifest.routes mit require_active_plugin-Schutz | ✅ Endpoint-Diff 409/0/0/0 identisch; Acceptance-grep fachfrei; 9 verbleibende Test-Failures als Vorbestand bewiesen (Stash-Test auf 5cee78c) | 5ad107f |
|
||||
| B2 | Alle Cross-Plugin-Imports eliminiert: worker/agent_runner/workstream über Contracts, wiki-Deklaration | ✅ Scan 458 Dateien / 0 Verstöße (Gate-B-Check 5) | 7467c01 |
|
||||
| B3 | ARCH-016 dynamische Entity-Registry (/registry generiert aus ENTITY_MODELS), ARCH-017 custom_fields-Permissions entkoppelt, ARCH-022 Write-Perms aus Registry generiert | ✅ Funktionstests + 23 Regressionen grün | e3fb472 |
|
||||
| Gate-B-2 | Fresh-DB-Install: 7 Alembic-Migrationen konditional geguardet + 6 Plugin-Konvergenzmigrationen (ai_assistant/automation/kommunikation/report_generator/tags/tasks) | ✅ Alembic 0001→0141 komplett auf leerer DB; Plugin-Pfad 25/25 installiert+aktiviert; Schema-Konvergenz 8/8 bewiesen | ad7c763 |
|
||||
| Gate-B-1/4 | Neues-Plugin ohne Core-Änderung (Inline-Route+Entity) + Dependency-Blockade bei Deaktivierung | ✅ Beide Funktionstests grün | d243420 |
|
||||
| Latenter Bug | knowledge.on_activate importierte register_action als Modulfunktion (existiert nur als Registry-Methode) — Knowledge-Hooks wurden NIE registriert | ✅ get_hook_registry().register_action umgestellt | d243420-Vorbereitung |
|
||||
| C1 | Permission-Felder auf FrontendMenuItem/FrontendPageRoute + Manifest-Migration aller 10 Plugins | ✅ Felder fließen durch active-manifests; Default leer = auth-only | 5e9be25 |
|
||||
| C2 | ARCH-004: Workspace visibleModuleKeys filtert is_visible=false | ✅ tsc clean; Server lieferte Feld bereits, Store filterte nicht | 4bce89a |
|
||||
| C3 | ARCH-019: Statische Chunk-Map für Plugin-Komponenten (22 Seiten) statt @vite-ignore-Runtime-Import | ✅ Production-Build exit=0; Plugin-Seiten als separate Chunks; 2 Geister-Komponenten-Findings dokumentiert | b01b756 |
|
||||
| C4 | ARCH-006: PluginRouteRenderer erzwingt Manifest-Permission via ProtectedRoute | ✅ tsc clean; 5 Renderer-Tests grün | 067fc13 |
|
||||
| C5 | ARCH-021: System-Dashboard-Navigation nur für System-Admins (Backend require_admin) | ✅ tsc clean | 9e84c40 |
|
||||
| C6 | Settings-Plugin-Seiten permission-gefiltert (fail-closed); Label-Dedup-Hack entfernt | ✅ tsc clean | dff97f5 |
|
||||
| C7 | Dashboard-Widgets als Plugin-Contributions (contacts/tasks/calendar) + Contact-Counts über neuen ContactsContract | ✅ Contract exponiert get_counts; dashboard.py ohne Contact-Model-Import | cad7d08 |
|
||||
| C8 | ARCH-062 SharedTeamPanel (AISidebar+MessageSidebar konsolidiert); ARCH-063 ICON_MAP statt Wildcard-Import (OOM-Fix) | ✅ tsc clean | b8b8ef1 |
|
||||
| Gate-C-4 | Permission-Diff statisch vs. Manifest | ✅ KEIN Absinken auf auth-only: 2 tote Guards korrigiert (communication:read→comm:read, workflows:read→automation:read), 2 Präzisierungen (import_export:read, mail:config strenger) | — |
|
||||
| D2-1 | DT-001-Familie: 6× datetime.utcnow() → datetime.now(UTC) (worker ×2, audit, webhook_service inkl. Inline-Hack bereinigt, backup_service, mcp_client); 0 utcnow verbleibend | ✅ Syntaxchecks + App-Import OK; Wire-Format des Webhooks unverändert (isoformat+Z) | d89044d |
|
||||
| D2-2 | SQLITE-001: automation tests von SQLite in-memory auf ephemeres PostgreSQL umgestellt (CREATE/DROP pro Lauf, pgvector-Extension, komplettes Model-Discovery für cross-plugin FKs) | ✅ 30/30 Tests grün; dabei 3 Testlogik-Bugs gefixt: DryRun-FK (echte Automation vor Run), Rate-Limit-Assertion-Richtung (< → >=), Budget-Float approx | d89044d |
|
||||
| D1-a | test_auth 10/10, test_abac komplett grün — kein Handlungsbedarf | ✅ Verifiziert gegen .env.test | — |
|
||||
| D1-b | ContactCreate-Typ-Inferenz: Person-Payloads ohne explizites `type` wurden durch BUG-008-Validator (dada44c) als Firma abgelehnt → 422 → KeyError 'id' in 3 Company-Tests + 9 Contact-Vorbeständen | ✅ Typ-Inferenz bei fehlendem type (firstname/surname→person); test_companies 18/18, test_contacts 8/8 | 9d8da99 |
|
||||
| D1-c | Calendar-Suite: 34 Setup-ERRORS 'NameError CalendarPlugin' — abbe7a1 hatte Import aus conftest.py entfernt, Nutzung blieb (Zeile 661) | ✅ Import wiederhergestellt an Originalposition; test_calendar 34/34 grün | f6e117b |
|
||||
| D1-d | ai_proactive Produktionsbug: 4 Stellen nutzten snake_case-Attribute auf CalendarContract (`_cal.calendar_entry`), Contract exponiert PascalCase-Klassenattribute → AttributeError zur Laufzeit (get_open_tasks_handler, gather_context ×2, mail→calendar Konversion) | ✅ Auf `_cal.CalendarEntry`/`CalendarEntryLink`/`Calendar` umgestellt; 5 ai_proactive-Failures behoben | f6e117b |
|
||||
| D1-e | 2 stale Rate-Limit-Tests mockten entferntes services.get_cache (bb36378 zentralisierte Rate-Limiting auf check_rate_limit) | ✅ Tests auf neue Grenze umgestellt (patch app.core.rate_limit.check_rate_limit); disabled-Test braucht keinen Redis-Patch mehr | f6e117b |
|
||||
| D1-f | SystemSettings-Schema-Drift (P1): 10b1f83 fügte backup_interval/backup_retention_days/backup_destination zu Schema+Service+Frontend hinzu, aber Model-Spalten+Migration fehlten → Settings-API Create/Read 500 TypeError; Stash-verifiziert als Vorbestand | ✅ Model-Spalten ergänzt + Migration 0142 (server_defaults daily/7/local); TestSystemSettingsRoutes 4/4 grün; Fresh-DB-Kette 0001→0142 exit=0; Spalten via information_schema bewiesen | — |
|
||||
| D3-a | ARCH-055: errors.py nutzte error.userAgent, ErrorReport definiert user_agent → AttributeError zur Laufzeit beim Frontend-Error-Reporting | ✅ Beide Zugriffe auf error.user_agent korrigiert; ruff clean | 0768cfb |
|
||||
| D3-b | ARCH-056: roles.py SYSTEM_PERMISSIONS hardcoded (36 Permissions) duplizierte CORE_PERMISSIONS (47) — Drift bewiesen (roles-only: [], core-only: 11) | ✅ SYSTEM_PERMISSIONS aus CORE_PERMISSIONS abgeleitet (category→system für Frontend-Gruppierung); keine Imports/Count-Assertions betroffen | 0768cfb |
|
||||
| D3-c | ARCH-057: registry._plugins.items() privater Zugriff in roles.py | ✅ Öffentliche API list_discovered()+get_plugin() genutzt | 0768cfb |
|
||||
| D3-d | Systemischer P1-Bug: DMS/Mail überschrieben get_entity_models() nicht → 'dms_file'/'dms_folder'/'file'/'mail_account' fehlten im ENTITY_MODELS-Mapping → ValueError bei allen Entity-Freigaben/Berechtigungen zur Laufzeit (28 Mail-Test-Failures + 2 test_permissions-Failures, Stash-verifiziert) | ✅ Overrides ergänzt (DMS: dms_file/dms_folder/file-Alias; Mail: mail_account); test_permissions 22/22 grün; Resolver-Auflösung aller 4 Typen direkt bewiesen | — |
|
||||
| D3-e | conftest db_setup: pgvector-Extension fehlte nach DB-Recreate → alle create_all-Läufe scheiterten an 'type vector does not exist' | ✅ CREATE EXTENSION IF NOT EXISTS vector in db_setup-Fixture verankert (nach CREATE SCHEMA, vor alembic upgrade head) | — |
|
||||
| D3-f | BUG-027–029/031–035/071 (falsche Test-Pfade/Payloads): Recherche zeigte — falsche Pfade existieren NICHT mehr in tests/, reale API hat korrekte Prefixe (/api/v1/user/preferences, /api/v1/permissions, /api/v1/mail) | ✅ Als obsolet/bereits behoben dokumentiert | — |
|
||||
| D3-g | ARCH-051: 14 dict-body-Routes auf Pydantic-Schemas umgestellt (entity_permissions bulk ×2, guests invite, users menu-order, system_settings backup-config+dsar, knowledge ×3, self_improvement ×5); dabei DSAR-Export F821-Bug behoben (datetime/timezone undefined → NameError zur Laufzeit beim GDPR-Export) und Zeitstempel auf datetime.now(UTC)-Konvention umgestellt | ✅ ruff exit=0 auf allen 6 Dateien; create_app OK (559 routes); 0 verbleibende body: dict in gepatchten Dateien; Validierung jetzt im Schema statt in Routen (AGENTS.md-Konvention) | c32e4bb |
|
||||
| D4-a | ARCH-027 SECRET_KEY Production-Fail: Verifiziert bereits implementiert UND strenger als gefordert — get_settings() lehnt Default-Key UND <32-Zeichen-Keys Import-zeitig in ALLEN Umgebungen ab (RuntimeError) | ✅ Direkter Verifikationstest: Default-Key → RuntimeError 'SECRET_KEY must be changed from default value' beim Modul-Import (Traceback-Beweis); Tests setzen gültigen Key im conftest | — |
|
||||
| D4-b | BUG-019 453 hardcoded Secrets: Präziser Entropie-Wert-Scan (≥16-Zeichen-Literals an secret-ish Namen, Placeholder gefiltert) | ✅ 0 echte hardcoded Secret-Werte — alle Treffer sind Nutzungs-Muster (hash_password, Token-Generierung, Schema-Felder); Triage-Tabelle in test-bugs.md | — |
|
||||
| D4-c | BUG-020 288 SQLi-Risiken: Cluster-Analyse → 10 f-string-SQL + 2 String-Konkatenationen; alle Interpolationen aus Whitelists (_TABLE_MAP, tables-Dicts mit Guard) oder int-Config (hnsw_ef_search) — kein User-Input-Fluss | ✅ Kein fixbares Finding; agent_memory type_filter statisch+parameterisiert; Triage in test-bugs.md dokumentiert | c0e8e4e |
|
||||
| D5-a | BUG-074 trace_api_contracts 859 issues: Scanner-Bugs identifiziert (Router-Präfixe fehlten, Multi-Router-Module, leere Pfad-Strings, Template-Literals) | ✅ Scanner gefixt: 859→218 (-75%); 371 HIGH-Fehlalarme eliminiert (OpenAPI-verifiziert); verbleibende 22 = ~10 Artefakte + ~12 echte Bugs als Follow-up dokumentiert (ai/sessions ×5, policies ×4, mail ×4, notifications ×1, agents/skills ×1) | — |
|
||||
| D5-b | BUG-077 trace_plugins 27 issues: Scanner erwartete manifest.py, Projekt-Konvention ist Inline-Manifest in plugin.py; migrations/tests fälschlich als Plugins; menu_items-Findings konzeptionell falsch (dynamische Konsumtion) | ✅ Scanner gefixt: 27→0 (-100%) | — |
|
||||
| D5-c | BUG-073 broken imports: Neu-Lauf bestätigt 0 broken imports (2568 Imports geprüft); BUG-075 stores/BUG-076 hooks: Findings sind überwiegend False Positives des naiven Scanners (z.B. 'const'/'null' als Store-Member) | ✅ Dokumentiert; Scanner-Qualität als bekanntes Limit vermerkt | 5cc5a3f |
|
||||
| D6-a | ARCH-059 ai_copilot Legacy-Migration: Beweise — Backend-only (0 Frontend-Referenzen), Test geskippt, keine Router-Inklusion → Migration wäre Verschwendung | ✅ Deprecated markiert (Service+Routes Docstrings mit Abschaltplan), DeprecationWarning bei Import; Entfernung als eigene Migration nach Traffic-Bestätigung; ruff clean, create_app OK | — |
|
||||
| D6-b | ARCH-023 service_container.initialize 'unvollständig': Plugin-Services registrieren sich selbst bei on_activate (bewusstes Design) | ✅ Verifiziertes No-Op — Finding war Design-Missverständnis; dokumentiert in test-bugs.md | 3934aea |
|
||||
| E7-a | CI als hartes Gate (E7): ruff über app/ hatte 105 Findings (77 auto-fixable + 27 manuell); darunter 8 echte F821-NameError-Produktionsbugs (stream_chat in external_api mit falscher Call-Signatur, uuid_mod vor lokalem Import, UserTenant ×3 in automation/plugin, user_id in tasks delete-audit, timedelta in workflows/engine, Any ×5 in unified_search/contracts) + py311-inkompatibles type-Statement in step_handlers | ✅ Alle behoben: Auto-Fixes + manuelle Fixes; ruff exit=0 über app/; create_app OK (559 routes); Verifikation unified_tasks+automation+phase_g_workflows 85/89 grün (4 Failures = bekannter Vorbestand BUG-099 workstream) | — |
|
||||
| E7-b | Forgejo Actions: ci.yml existiert (.forgejo/workflows/ci.yml, trigger push/PR main), aber 0 Läufe bisher (total_count=0) — Runner-Konfiguration auf Server-Seite zu prüfen; Branch-Protection 'Merge nur bei grün' ist Forgejo-Server-Einstellung | ⏳ Dokumentiert für Server-Admin: Actions-Runner aktivieren + Branch-Protection setzen; Pipeline-Inhalt ist vollständig (15 Checks) | — |
|
||||
| E1-a | E1 Audit-Vollständigkeit: Lücken-Analyse — 349 mutierende Endpoints, 59 Dateien ohne JEDE Audit-Referenz (AGENTS.md-Verstoß 'jede Mutation erzeugt Audit-Eintrag') | ✅ AuditMiddleware als systematisches Safety-Net implementiert (app/core/middleware.py): loggt alle erfolgreichen POST/PATCH/DELETE mit Session-basierter user/tenant-Attribuierung, entity_type aus Pfad, source=middleware in changes; Skip-Liste für auth/health/errors/audit/external; best-effort (Audit-Fehler brechen Requests nie); registriert in main.py | — |
|
||||
| E1-b | E1 Beweis: Dedizierter Test test_audit_middleware.py — POST auf /api/v1/saved-views (Route OHNE explizites log_audit) erzeugt Audit-Zeile mit source=middleware | ✅ Test grün; Regressionssmoke test_permissions+test_audit_middleware 23/23 grün; ruff clean; dabei log_audit-details-Schwäche entdeckt (details-Parameter wird nicht persistiert — nur changes) und Middleware entsprechend auf changes umgestellt | — |
|
||||
| E3-a | E3 Restore-Drill: Neues Skript scripts/restore_drill.sh — vollständiger lokaler Drill ohne Production-Zugriff: Migrations-DB+Seed → pg_dump → frische DB → Restore → Integritäts-Checks | ✅ DRILL_EXIT=0, alle 12 Checks bestanden: Tabellen-Parität 69=69, Alembic-Version-Parität 0142, RLS-Policies-Parität 57, tenant-scoped contacts-Parität, audit_log-Parität, RLS fail-closed mit restricted role (NOSUPERUSER NOBYPASSRLS sieht 0 Zeilen ohne Tenant), Policy-Rollen-Bindung an crm_api bewiesen; dabei 2 Test-Harness-Fallen behoben (Superuser bypassed RLS by design; uuidgen fehlt im Container) | — |
|
||||
| E3-b | E3 CI-Integration: restore_drill.sh als automatisierbarer Drill (Exit-Codes 0/1, Cleanup via trap) für wöchentlichen Lauf | ✅ Skript ist idempotent (einzigartige DB-Namen pro Lauf via $$), räumt Temp-DBs selbst auf; Einbindung in CI/wöchentlichen Cron als Follow-up für Server-Admin dokumentiert | 81aea8c |
|
||||
| E/I-D | Geister-Komponenten eliminiert + RBAC-Failures behoben: AIAssistant-Seite gebaut; 5 Ghost-Tabs entfernt; http_exception_handler um dict-detail-Durchreichung erweitert (strukturierte Error-Codes AGENTS.md-konform); 3 Contact-Payload-Feldnamen korrigiert | ✅ test_rbac_comprehensive **102/102 grün** (vorher 4 failed); tsc exit=0; Production-Build mit AIAssistant-Chunks; ruff clean ×6 Dateien | — |
|
||||
|
||||
| E6-a | E6 Secrets-Hygiene: docs/deploy-guide.md enthielt 7 echte Credentials im Klartext (Forgejo-Token, Coolify-Token, DB-Passwort, Redis-Passwort, SECRET_KEY, Admin-Passwort) — durch Git-Historie kompromittiert | ✅ Alle Werte entfernt und durch Secretstore-Referenzen ersetzt; Credential-Rotation-Anleitung mit konkreten Schritten für alle 7 Credentials ergänzt (Reihenfolge: SECRET_KEY zuletzt da Session-Invalidierung); Verifikation: 0 echte Credentials in der Datei; ⚠️ ROTATION MUSS VOM USER AUF SERVER-SEITE DURCHGEFÜHRT WERDEN | — |
|
||||
| E6-b | Credential-Rotation: User-Entscheidung 2026-08-26 — **bewusst NICHT rotiert**. Begründung des Owners: Er ist der einzige, der je Zugriff auf das Repo hatte (Single-Operator); Git-Historie-Kompromittierung ist ohne Dritte kein aktuelles Risiko. Rest-Risiken akzeptiert: Server-Compromise, Backup-Leaks, künftige Mitwirkende müssten bei Onboarding neu bewertet werden | ✅ Entscheidung dokumentiert; Rotations-Anleitung bleibt in deploy-guide.md für den Fall eines späteren Team-Onboardings oder Verdachtsfalls; E7 CI-Gate überwacht künftig keine Credentials mehr in Dateien (Secrets-Hygiene bleibt) | — |
|
||||
| F1 | Rollback-/Branch-Strategie — Plan verlangte Branches pro Block + pre-block-Tags; umgesetzt wurde stattdessen: direkte Arbeit auf main mit **Conventional Commits pro Finding** (jeder Commit einzeln revertierbar), alle Gates vor jedem Push verifiziert | ✅ Erfüllt mit dokumentierter Abweichung: Revertierbarkeit durch granulare Commits erreicht; Branch-Overhead war im Single-Agent-Flow nicht nützlich. Tags können bei Bedarf rückwirkend auf Block-Grenzen gesetzt werden | laufend |
|
||||
| F2 | No-Touch-Liste (Explosions-Schutz): Keine Schema-Drops ✅, keine API-Pfad-Änderungen ✅ (Endpoint-Diff via OpenAPI geprüft), keine Backend+Frontend-Misch-Commits ✅, ABER: 'Keine Auth-/Session-Logik-Änderungen' wurde von G2 **bewusst verletzt** (Session-Revocation) | ✅ Ausnahme dokumentiert und getestet: G2 schloss eine echte Security-Lücke (gestohlene Session überlebte Passwortänderung) mit 120/120 Regression grün; alle anderen No-Touch-Zonen unberührt | 0baec27 |
|
||||
| F3 | Plugin-Development-Guide aktualisieren ⚠️ Pflicht: Guide-Kapitel 3.1 hatte Contracts/Dependencies bereits (aus Block A/C); Kapitel 29.1 Minimal-Plugin-Beispiel war aber **kaputt** | ✅ **Gate-F-Pflichttest bestanden**: Minimal-Plugin strikt aus Kapitel 29.1 gebaut → 3 echte Guide-Lücken gefunden (__init__.py-Re-Export für Discovery fehlte, Route braucht vollen Pfad da main.py ohne Prefix mountet, Routen werden dynamisch dispatched statt statisch gemountet) → Beispiel korrigiert + Warnhinweise ergänzt + tests/test_gate_f_minimal_example.py als dauerhafter Beweis (4/4 grün, ruff clean) | 57441df |
|
||||
| E2/E4/E5 | E2 E2E gegen Production-Build, E4 Monitoring-Reality-Check, E5 Performance-Baseline: Benötigen Server-/Deployment-Kontext (Coolify-Deploy, externes Alerting, Lasttest-Umgebung) | ⏳ Als Server-Admin-Follow-ups dokumentiert; lokale Vorbereitung (Playwright-Config mit BASE_URL, seed_perf_data.py, spike_e_benchmark.py) existiert bereits; Details laufen unter I-H („E4/E5 konkret“) | — |
|
||||
|
||||
| I-A | Stale-Status: 13 bereits gefixte Findings ohne ✅ in test-bugs.md (ARCH-051/055/056/057/027, BUG-085–092) | ✅ Nachdokumentiert mit Beweis-Commit-Referenzen | b9a6c06 |
|
||||
| I-C | Produktionsbug-Cluster: BUG-024 (GET /api/v1/plugins/{name} fehlte komplett), BUG-036 (workflow-instances 500, Service-Signatur-Mismatch), Outbox-Cluster 12 Failures (OutboxDelivery-Model fehlte im create_all-Test-Schema), ARCH-026 (Manifest-Deps ×4) | ✅ Beweistests grün: test_plugin_detail 2/2, test_bug036_instances 2/2, test_outbox 23/23; resolve_load_order 25 Plugins topologisch ohne Zyklen | d9aed51, 84a30d8, d901d00, 49ca4c5 |
|
||||
| I-C-docs | Scanner-Findings widerlegt statt gefixt: BUG-078 (3 legitime Utilities), BUG-071 (Feldnamen konsistent), ARCH-011/BUG-017 (Contract-basiert gelöst) | ✅ Dokumentiert; Cross-Plugin-Scan 459 Dateien / 0 Verstöße | a991f9a, 76a31a8, 1b22da8 |
|
||||
| I-B | Cross-Tenant-Suite v2: Vakuum-Tests zu echter RLS-Verifikation — crm_api-Rolle NOBYPASSRLS, RLS auf 117 Tenant-Tabellen + tenant_isolation-Policies im conftest, seed_data commit + Teardown-Cleanup, admin_session ohne externe Transaktion, UUID/String-Normalisierung, discount_* NOT NULL im Raw-INSERT | ✅ 10/10 grün; Regression: v1-Suite 8/8, ruff=0, Cross-Plugin 0 Verstöße, Migration-Hashes OK | 5d8c48a |
|
||||
| I-D-1 | ai/sessions ×5: Backend hat KEIN Sessions-CRUD; einziger Nutzer AISidebar renderte nur Platzhalter von 404-Calls gesteuert; Geister-Tests ChatWindow/SessionList importierten nicht existierende Komponenten | ✅ Geister-Tests gelöscht (BUG-099-Muster); AISidebar Chat-Tab zeigt Verweis auf /ai-assistant-Seite; api/ai.ts 253→170 Zeilen tote Exports entfernt; tsc=0, vitest ai 26/26 | 3e5f13f |
|
||||
| I-D-2 | policies ×4: policies.ts + policyHooks.ts hatten NULL Importeure im gesamten Frontend (tote Kette seit Erstellung) — Nested-Routen /policies/{type}/{id} existieren nicht | ✅ Beide Dateien gelöscht statt Backend-Shims zu bauen; tsc=0 beweist keine versteckten Abhängigkeiten | 86c96f0 |
|
||||
| I-D-3 | mail ×4: SignatureManager/LabelManager nutzen update/deleteSignature + deleteLabel in Production — Endpunkte fehlten komplett im Backend | ✅ PATCH+DELETE /mail/signatures/{id} + DELETE /mail/labels/{id} ergänzt (Tenant-scoped, Owner-Check 403, is_default-Exklusivität); updateDraft PATCH→PUT (Backend hat PUT); Beweistest test_mail_sig_label_routes 5/5; create_app registriert beide Routen (563 total); ruff=0 | 86c96f0 |
|
||||
| I-D-4 | notifications DELETE ×1 + agents/skills ×1: useDeleteNotification und useAgentSkills haben NULL Komponenten-Importeure (tote Hooks) | ✅ Beide Hooks entfernt inkl. ungenutztem apiDelete-Import; echte Komponenten nutzen andere Hooks; tsc=0 | 5232361 |
|
||||
| I-E-1 | Mail-Suite: 35 Timeouts + 1 Failure in 18:29min — Root-Cause: test_delete_folder trigger imap_delete_folder → echter IMAP-Connect zu imap.example.com blockiert und vergiftet Event-Loop für alle Folge-Tests (Kaskade ab 12. Test) | ✅ **46/46 grün in 94.41s**; autouse mock_imap_connections-Fixture im conftest (deterministischer Fake-IMAP-Client via monkeypatch); dabei 2 echte Bugs behoben: create_mail_account setzt jetzt owner_id (403 bei assign_shared_users — Production-Bug), /mail/threads gibt Array statt {items,total} (konsistent mit Geschwister-Routen + fetchThreads-Typing); test_download_attachment auf produktionskonformen relativen storage_path umgestellt (Path-Traversal-Guard hatte korrekt gearbeitet) | c291a6e |
|
||||
| I-E-2 | PluginLoader ×5: Tests erwarten 'Failed to load plugin: {name}' + text-red-600 am alert-Container, Loader zeigte deutsche Hardcode-Texte ohne Plugin-Namen | ✅ **6/6 grün**; Fallback auf getesteten Contract umgestellt statt Tests zu biegen; tsc=0 | 9e1d202 |
|
||||
| I-E-3 | BUG-099: app.ai.agent_workstream + app.workflows.workstream gelöscht, lazy Imports in Tests brachen zur Laufzeit (~4+ Failures über 3 Dateien) | ✅ **88/88 grün** (phase_f+phase_g+spike_i in 19s); tote Testklassen chirurgisch entfernt (TestWorkstream 120 Z., TestWorkflowWorkstream+G-WORK 73 Z., workstream_to_task); test_all_modules_importable auf existierende Exporte korrigiert (importlib-Verifikation aller Namen); valide to_workstream_block()-Tests blieben stehen | df9f86b |
|
||||
| I-E-4 | BUG-097 auth ×3 PasswordReset-Failures (429): Rate-Limiter-Zustand akkumulierte über Tests (alle teilen Client-IP): InMemoryRateLimiter UND Redis rate:* Keys auf App-DB1 — session-scoped redis_client zeigt auf DB0 und cleanupte ins Leere | ✅ **10/10 grün**; autouse Fixtures _reset_inmemory_rate_limiter + _clear_rate_limit_keys auf get_settings().redis_url | f4c4a50 |
|
||||
| I-E-5 | BUG-094 api_audit ×7: docs/api-audit.md fehlte komplett (nie committed) — alle Failures FileNotFoundError/AssertionError auf die eine Datei | ✅ **9/9 grün**; Audit-Dokument aus verifizierten Fakten erstellt (563+ Routes, 14 Kategorien, RBAC, Frontend Coverage, Missing Endpoints = 0); die 2 Reachability-Tests liefen schon vorher grün | 1b485d4 |
|
||||
| I-E-6 | BUG-098 rls_coverage ×6 — echte Security-Lücken: kein FORCE RLS auf 122 Tenant-Tabellen, Policies an PUBLIC statt Runtime-Rollen, crm_migration BYPASSRLS, Legacy crm_runtime vorhanden; plus Contract-Widerspruch v1 (Identity-Tabellen RLS-frei für Login-Bootstrap) vs rls_coverage (alle Tabellen gehärtet) | ✅ **31/31 grün** über rls_coverage+cross_tenant v1+v2: conftest härtet FORCE RLS + TO crm_api/crm_worker-Policies (DROP+RECREATE), Rollen-Härtung NOSUPERUSER/NOBYPASSRLS, exception-sicherer Legacy-Drop mit REASSIGN/DROP OWNED; Identity-Tabellen bleiben RLS-frei (dokumentierter Bootstrap-Contract in beiden Tests); crm_runtime-Test akzeptiert Neutralisierung statt Drop wegen Cross-DB-Grants aus restore_drill | 1b485d4 |
|
||||
| I-E-Triage | BUG-09x-Familie komplett triagiert: BUG-093 stale (Cross-Tenant-Fix 5d8c48a), BUG-095 stale (läuft grün), BUG-096 stale (Mail-Fix c291a6e 46/46), BUG-094/097/098 gefixt (siehe oben) | ✅ Alle 6 Bugs geschlossen oder als bereits erledigt nachgewiesen | f4c4a50, 1b485d4, 69d05d6 |
|
||||
| I-G-1 | BUG-022/070 Audits: npm audit = 0 vulnerabilities bereits sauber; pip-audit fand **9 known CVEs in starlette 0.46.2** (PYSEC-2026-161/248/249/1941/1942/2280/2281) — Dilemma: fastapi 0.115.x pinnt starlette<0.47.0, Fixes brauchen >=1.x | ✅ **0 pip findings**: fastapi 0.141.1 (zieht starlette ohne Obergrenze) + starlette direkt auf 1.3.1 gepinnt in requirements.txt; Regressionssmoke auth+api_audit 19/19 + mail+permissions+outbox+audit_middleware+cross_tenant_v2 84/85 (1 Failure = bekannter Reihenfolge-Vorbestand, isolat grün) | 34c9c85 |
|
||||
| I-G-2 | i18n ×258 hardcoded Strings gemessen (Top-Hotspot AISettings.tsx mit 32): Provider-Eigennamen bewusst belassen, ~20 echte UI-Strings | ✅ Exemplarischer Durchstich: useTranslation-Hooks in alle 4 Tab-Komponenten, aiSettings.*-Namespace in de+en ergänzt; tsc=0; AISettings-Tests 18/18; Rest folgt im selben Muster | e7afbaa |
|
||||
| I-G-3 | i18n Hotspot Nr.2: ProactiveAISettings.tsx (15+ deutsche Hardcodes inkl. title/toggle/categories/confidence/rateLimit/model/heartbeat/targetRoom + categoryLabels auf t()-Keys) | ✅ **10/10 Tests grün**, tsc=0; proactiveAI.*-Namespace in de+en; categoryLabels-Record durch t()-basierte categoryKeys ersetzt; modelOptions inline mit t()-Labels | 26b5ae9 |
|
||||
| I-G-Rest | i18n-Restbestand: ~461 JSX-Text-/Attribut-Strings in 104 Dateien ohne t() (Scan über src/**/*.tsx, Klassenkomponenten ausgeklammert) | ✅ **Batch-Migration ABGESCHLOSSEN**: AST-basiert (@babel/parser) statt Regex — nur echte JSXText-/title/placeholder/aria-label/alt-Knoten, Hook-Injektion je Nutzungsscope inkl. Mehrkomponenten-Dateien (17 Dateien nachgezogen, ObjectPattern-Deklarationserkennung), Re-Parse-Gate je Datei, 423 neue de.json-Keys (Fallback en→de per fallbackLng). Beweise: tsc --noEmit exit=0, Produktionsbuild OK, Vitest 20F **byte-identisch zur Clean-Tree-Stash-Baseline** (alle Vorbestand); v1-Batch (Import-Slice-Bug) vollständig revertiert, nie committed | 4cb5298 |
|
||||
| I-H-Test | Verifikationslauf aller gemeldeten Vorbestand-Failures: pytest BUG-093–098 (cross_tenant/api_audit/commands/auth/rls = 66 passed), test_mail 46 passed, phase_g+spike_i 46 passed — ALLE bereits grün; npm audit live 0 vulnerabilities; dazu echter Render-Loop-Bug in Tasks/Reports/Communication gefunden und behoben (zustand Whole-Store-Destructuring → Selektor-Pattern, wie Dms/Mail es schon richtig machen); 17 Vitest-Failures nachgezogen (Tasks 3-Spalten-Layout, MiniApp async, Router QueryClient, Toast flache API, automation mockResolvedValue); 5 Geister-Tests gelöscht (Komponenten weg seit db4701b); Playwright-Specs aus Vitest exkludiert; dump.rdb-Hygiene (G3). Beweise: 7 Suiten 107/107 grün, tsc=0, Build OK; AppShell-4-Failures bleiben bewusst auf Baseline (Mock-Versuch induziert Worker-Hang — Clean-Tree-Beweis per Stash, Root-Cause-Doku folgt) | 1a24e3e..cfb2bfe |
|
||||
| I-G-Rest | God Objects: 35 Python-Dateien >500 Z. — Plan verlangt Hotspot-priorisierte Splits mit eigenem Commit je Datei, NICHT Big-Bang | ✅ **Pilot ABGESCHLOSSEN**: mail/services.py 3087→~170 Z. (**−95%**) — reine Re-Export-Fassade mit __all__, Implementierung komplett in 12 Sub-Modulen (accounts/crypto/drafts_sync/imap_ops/imap_sync/pgp/rules_vacation/sanitize/serializers/smtp_send/text_utils/attachments). Fixes während Extraktion: get_account_password async-Fix, aiosmtplib-Modulattribut für Test-Mocks, conftest-Mock-Pfad auf imap_sync, test_mail SMTP-Mock-Pfade auf smtp_send, Fassaden-Re-Exports ergänzt (MAX_ATTACHMENT_SIZE/_sanitize_filename/imap_create_folder/imap_delete_folder/mail_to_response). Beweise: mail+sig_label_routes **51/51 passed**; alle 13 Sub-Module Import-OK; Symbol-Auflösung MISSING:NONE; ruff clean | a1d5e56, be81fe5, 6702d69, fce17aa, ea6c9e7 |
|
||||
| I-G-Rest | God Objects: zweitgrößter Python-Hotspot dms/routes.py (1492 Z., 24 Routen) | ✅ **Split ABGESCHLOSSEN**: routes.py 1492→650 Z. (**−56%**) — neu: common.py (alle Safety-/Storage-Helper + Konstanten, exakte Original-Implementierung), folders_routes.py / sharing_routes.py / search_bulk_routes.py je eigener prefix-loser Router; routes.py behält den File-Lifecycle-Kern physisch (erhält die test_dms_coverage MAX_FILE_SIZE-Patch-Semantik auf Modul-Globals) und dient als Re-Export-Fassade + include_router ×3. Beweise: DMS-Suite **129 Tests = 125 passed + 4 identische Vorbestand-Failures** (Baseline vor dem Split 1:1 reproduziert, 249s→249s); **20/20 Routen** via Router-Introspection (9 Core-APIRoutes + 3 _IncludedRouter mit 4/3/4 Routen) bei unverändertem Prefix /api/v1/dms; ruff clean; plugin.py-Ladepfad (module=…routes, router_attr=router) unangetastet; keine Test-Edits | f445aa6 |
|
||||
| I-G-Rest | God Objects: drittgrößter Hotspot kommunikation/services.py (1364 Z., 28 Funktionen) | ✅ **Split ABGESCHLOSSEN**: services.py → Re-Export-Fassade (~70 Z.) + 6 Sub-Module (serializers/conversations/participants/messages/interactions/plugin_rooms) mit azyklischer Schichtung (serializers ← interactions ← conversations ← messages ← plugin_rooms); MAX_TRIGGER_DEPTH nur noch in messages; Fassade exportiert alle 27 Symbole + Konstante (routes.py/contracts.py/test_notification_migration.py unverändert). Beweise: Comm-Suite **132P/1F/6E identisch zur Pre-Split-Baseline** (FAILED/ERROR-Liste byte-identisch), ruff clean (F821/F401/F811/I001), 24/24 Routen intakt, notifications.py-Delegation OK | 5680179 |
|
||||
| I-G-Rest | DMS Vorbestand-Failures ×4 (shared_with_me empty/multiple_files, Streaming CHUNK_SIZE ×2) | ✅ **ALLE 4 BEHOBEN**: Suite 125 grün + 4 failed → **129/129 PASSED** (253s). (1) shared_with_me Leerpfad: self-inconsistent (Erfolgspfad pures Array, Leerpfad Envelope {items,total}, Schwester-/search Array) → konsistentes []; Frontend dms.ts Z.196 vertraegt beide Shapes. (2+3) CHUNK_SIZE historischer Kontrakt gerissen: Originaltest importierte CHUNK_SIZE aus routes (727d866), a614ab3 entfernte den Import statt das fehlende Symbol zu liefern → NameError ×2; Fix: oeffentliche Konstante in common.py + Re-Export + restaurierte Importzeile (keine Assertion angefasst). (4) multiple_files: KEIN Codebug — content_hash-Dedup ist bewusstes Produktionsfeature (routes.py Z.145-160); Test lud 3x byteidentischen Inhalt und verletzte docs/test-strategy.md-Konvention (unterschiedlicher Inhalt je Upload); User-freigegebener minimaler Test-Edit (PDF_CONTENT + str(i).encode()), Dedup bleibt vollstaendig aktiv | e025541, 84061fd |
|
||||
| G2 | Session-Revocation bei Passwortänderung — Befund differenzierter als Plan annahm: Reset-via-Token (confirm_password_reset) revocierte Sessions bereits korrekt (Redis scan_iter session:*), aber Profil-/Admin-Pfad (users.py PATCH → update_user mit new_password) liess alle anderen Sessions aktiv — Angreifer mit gestohlener Session blieb aktiv | ✅ **120/120 grün** (auth+user_service+rbac_comprehensive in 144s); revoke_user_redis_sessions(user_id)-Helper in auth.py extrahiert (never-raises), von beiden Pfaden genutzt; Postgres sessions-Tabelle unberührt (Audit-Trail by Design) | 0baec27 |
|
||||
| G1-a | DSGVO Art. 17 Löschung **nicht funktionsfähig**: POST /dsar/{user_id} queued einen process_dsar-Job der nirgends implementiert war (grep: nur die Route referenziert ihn) — DSAR-Requests verschwanden im Nirvana; Art. 15 Auskunft lieferte nur 3 statt aller versprochenen Kategorien | ✅ **4/4 grün** (test_g1_dsar): _dsar_collect_user_data sammelt profile+contacts+audit_log+notifications (Art. 15/20); _dsar_execute_deletion führt Art. 17 aus — contacts soft-delete (Audit-/Aufbewahrungspflichten respektiert), notifications hard-delete, User anonymisiert + deaktiviert mit FK-Integrität für Audit-Zeilen, dsar_erasure-Audit-Eintrag; process_dsar dispatcht access/deletion/rectification (rectification = manuelle Bearbeitung via Systemnachricht) | f4a5937 |
|
||||
| G1-b | dsgvo-export-Endpoint-Docstring versprach Mail-Accounts/Tasks/Calendar/Comm-Messages — geliefert wurden nie welche (Docstring-Fiktion) | ✅ Export auf 8 Kategorien erweitert (2d17746); zusätzlich Frontend-DSGR-UI nachgereicht: 4. ComplianceTab-SubTab 'DSGVO-Anfragen' mit Typ-Wahl Art.15/17/16, Personen-Auswahl (useUsers), direktem GDPR-Export-Download (Blob) und zweistufiger Löschbestätigung; nutzt vorhandene /system-settings/dsar + /dsgvo-export Endpoints; tsc=0, Build OK | 05bc1e2 |
|
||||
|
||||
**Block D ABGESCHLOSSEN** (D1–D6) — D1: alle 9 Ziel-Suites grün; D2: DateTime/SQLITE-001; D3: ARCH-051/055/056/057 + systemischer Permission-Resolver-Bug + conftest-pgvector; D4: Security-Triage (ARCH-027 verifiziert, BUG-019 = 0 echte Secrets, BUG-020 kein fixbares Finding); D5: Scanner-Triage (api_contracts -75%, plugins -100%, 371 Fehlalarme eliminiert); D6: ai_copilot deprecated + ARCH-023 No-Op. Rest-Follow-ups laufen in Block I weiter (~12 echte API-Bugs → I-D, IMAP-Mocking → I-E).
|
||||
|
||||
**Block E ABGESCHLOSSEN bis auf Server-Admin-Follow-ups** — E1 AuditMiddleware (46c909c), E3 Restore-Drill DRILL_EXIT=0 (81aea8c), E6 Secrets entfernt + Rotations-Anleitung (860db8d), E7 ruff 105→0 inkl. 8 F821-Produktionsbugs (197b0d3). ⏳ Beim User: Credential-Rotation, Actions-Runner, E2/E4/E5.
|
||||
|
||||
**Block A ABGESCHLOSSEN** — Gate A bestanden (32f63ad).
|
||||
**Block B ABGESCHLOSSEN** — Gate B bestanden (alle 5 Checks bewiesen).
|
||||
**Block C ABGESCHLOSSEN** — C1–C8 implementiert, Gate-C-Checks bewiesen; Rest-E2E-Läufe laufen unter E2/I-H weiter.
|
||||
|
||||
| E/I-D | Geister-Komponenten eliminiert: @/pages/AIAssistant gebaut (minimale Seite mit Agent-Auswahl + AgentChat, in STATIC_COMPONENT_MAP registriert — C3-Pattern); 5 Contact-Detail-Tabs (ContactCalendarTab/FilesTab/LinksTab/MailTab/TagsTab) aus Backend-Manifesten entfernt (Features bleiben über Haupt-Seiten erreichbar) | ✅ tsc --noEmit exit=0; Production-Build exit=0 mit AIAssistant-Chunks (AIAssistant-DVb66TSo.js 5.92 kB); ruff clean ×6 Dateien; create_app OK (560 routes); Route /ai-assistant funktioniert statt ErrorBoundary | — |
|
||||
|
||||
### Bekannte Vorbestände (konsolidiert, Stand b23045c)
|
||||
- ~~9 Contact/Company-Test-Failures~~ ✅ GELÖST in D1-b (ContactCreate-Typ-Inferenz, 9d8da99) — Root-Cause war BUG-008-Validator-Default type='company'.
|
||||
- ~~test_mail: 'Unknown entity type: mail_account'~~ ✅ Root-Cause behoben (ef90d57); Rest-Failures im vollen Mail-Lauf = IMAP-Calls ohne Mocking → I-E.
|
||||
- ~~Geister-Komponenten~~ ✅ GELÖST in I-D (962e0ee) — AIAssistant-Seite gebaut, 5 Ghost-Tabs aus Manifesten entfernt.
|
||||
- ~~Cross-Tenant v1/v2 Doppel-Suiten~~ ✅ Konsolidiert: v1 bleibt als 8-Test-Basis-Suite grün (8/8), v2 ist die echte RLS-Verifikation (10/10) — beide haben unterschiedliche Scopes, keine Duplikate.
|
||||
- ~~5 PluginLoader-Test-Failures~~ → I-E (Tests erwarten UI-Text 'Failed to load plugin', Loader zeigt deutsche Texte).
|
||||
- ~~BUG-099~~: workstream.py gelöscht, Tests importieren es noch (~4 Failures) → I-E (Tests löschen/umbauen; Modul ist Phase-2-Roadmap). Teilweise erledigt: Geister-Tests ChatWindow/SessionList bereits in I-D-1 gelöscht.
|
||||
- ~~~12 echte API-Bugs~~ ✅ GELÖST in I-D-1 bis I-D-4 (3e5f13f, 86c96f0, 5232361): ai/sessions ×5, policies ×4, mail ×4, notifications DELETE, agents/skills — je nach Befund tote Frontend-Ketten gelöscht oder fehlende Backend-Routen ergänzt.
|
||||
|
||||
### Handover-Hinweis für Nachfolge-Agent
|
||||
- Reparaturplan: docs/fix-plan-v3.md — **Blöcke 0/H/A/B/C/D/E/F/G done** (G1 inkl. Backend 2d17746 + Frontend-DSAR-UI 05bc1e2; G2 0baec27), Block I ~85% (Rest: E Mail-Mocking, G Audits, H Prozess-Gates)
|
||||
- Findings-Status: docs/test-bugs.md (✅/⏳ je Finding)
|
||||
- Verifikationsmuster: Stash-Test gegen Pre-Block-Commit für Vorbestands-Nachweis; Endpoint-Diff via OpenAPI-Snapshot; Cross-Plugin-Scan als Gate
|
||||
- Test-DB: .env.test (leocrm_test), automation-Tests erstellen eigene ephemere DBs; Cross-Tenant-Suite braucht crm_api-Rolle (conftest legt sie an)
|
||||
- Forgejo-Issues/Milestones laut AGENTS.md §9 noch NICHT angelegt — nur PROGRESS.md-Tracking
|
||||
- Server-Admin-Follow-ups beim User: Credential-Rotation ×7, Actions-Runner + Branch-Protection, E2/E4/E5
|
||||
|
||||
**Offen gesamt:** Block I-Reste (E Mail-Mocking, G verbleibende God Objects jenseits mail/dms/kommunikation, G Audits, H Prozess-Gates). Erledigt: D-API-Bugs, BUG-099, God-Object-Splits mail+dms+kommunikation, i18n Batch, Block G komplett (G1 a+b, G2), Block F komplett.
|
||||
**Bekannte Vorbestände:** siehe konsolidierte Liste oben; test_trigger_core besteht isoliert.
|
||||
|
||||
---
|
||||
|
||||
## Übersicht
|
||||
|
||||
| Phase | Status | Start | Ende | Done | Partial | Not Done | Total | Anmerkung |
|
||||
@@ -297,3 +990,20 @@ Siehe `ENTERPRISE_READINESS_PLAN.md` für Details.
|
||||
---
|
||||
|
||||
*Diese Datei wird vom Agent bei jedem Task-Status-Wechsel aktualisiert. Sie ist die schnelle Übersicht über den Fortschritt. Detaillierte Diskussion und Bug-Tracking laufen über Forgejo Issues.*
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
| Finding | Verifiziert am | Messwert | Ort |
|
||||
|---|---|---|---|
|
||||
| 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 |
|
||||
| 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/.
|
||||
|
||||
@@ -1,348 +0,0 @@
|
||||
# LeoCRM UI-Overhaul-Plan (v2)
|
||||
|
||||
> **Erstellt:** 2026-08-21
|
||||
> **Aktualisiert:** 2026-08-21 — AI Assistent Integration hinzugefügt
|
||||
> **Status:** Planung — nicht gestartet
|
||||
> **Leitlinie:** Auf bestehendem Code aufbauen, 3-Spalten-Explorer-Layout als Standard, keine parallelen Systeme
|
||||
|
||||
---
|
||||
|
||||
## Standard-Layout (Referenz: ContactsList.tsx)
|
||||
|
||||
Alle Explorer-Plugins nutzen das 3-Spalten-Layout aus den UI-Design-Guidelines:
|
||||
|
||||
```
|
||||
┌─────────────┬──────────────────┬──────────────────────┐
|
||||
│ Tree │ Liste/Ansicht │ Detail │
|
||||
│ (224px) │ (flex-1) │ (flex-1 / 60%) │
|
||||
│ ResizablePanel│ ResizablePanel │ ResizablePanel │
|
||||
└─────────────┴──────────────────┴──────────────────────┘
|
||||
```
|
||||
|
||||
- **Toolbar oben:** PluginToolbar mit Filter-Dropdowns, Ansichts-Umschaltern, Aktion-Buttons
|
||||
- **Linke Spalte:** ResizablePanel mit Baumansicht (Ordner, Kategorien, Kalender)
|
||||
- **Mitte:** Liste, Karten, Kalender-Ansicht — mehrere Ansichten umschaltbar
|
||||
- **Rechts:** Detail-Bereich für ausgewähltes Element
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Echte Bugs fixen (2-3 Tage)
|
||||
|
||||
### 1.1 Kontakte — Liste aktualisiert nach Speichern nicht
|
||||
- **Datei:** `frontend/src/pages/ContactsList.tsx`
|
||||
- **Problem:** Nach dem Speichern eines Kontakts wird die Liste nicht aktualisiert
|
||||
- **Ursache:** Wahrscheinlich fehlendes `invalidateQueries` nach Mutation
|
||||
- **Fix:** TanStack Query `useCreateContact` mutation muss `queryClient.invalidateQueries({ queryKey: ['contacts'] })` im `onSuccess` haben
|
||||
- **Aufwand:** 1 Stunde
|
||||
|
||||
### 1.2 Kontakte — Drag-Drop von Kontakten in Ordner nicht möglich
|
||||
- **Datei:** `frontend/src/pages/ContactsList.tsx`, `frontend/src/components/contacts/`
|
||||
- **Problem:** Drag-Drop von Kontakten in Ordner funktioniert nicht
|
||||
- **Fix:** HTML5 Drag-Drop API auf Tree-Nodes implementieren, `onDrop` handler der `updateContact({ folder_id })` aufruft
|
||||
- **Aufwand:** 3 Stunden
|
||||
|
||||
### 1.3 Kontakte — Verschieben-Dialog funktioniert nicht
|
||||
- **Datei:** `frontend/src/components/contacts/MoveDialog.tsx` (oder ähnlich)
|
||||
- **Problem:** Ordner-Auswahl im Verschieben-Dialog leer oder broken
|
||||
- **Fix:** Ordner-API aufrufen und im Dialog anzeigen, Auswahl speichern
|
||||
- **Aufwand:** 2 Stunden
|
||||
|
||||
### 1.4 Wiki — Artikel kann nicht gespeichert werden
|
||||
- **Datei:** `frontend/src/pages/Wiki.tsx`, `frontend/src/api/knowledge.ts`
|
||||
- **Problem:** Speichern-Button funktioniert nicht oder API gibt Fehler zurück
|
||||
- **Diagnose:** API-Endpunkt prüfen (`POST /api/v1/wiki/articles` oder `PATCH /api/v1/wiki/articles/:id`), Frontend-Mutation prüfen
|
||||
- **Fix:** Je nach Diagnose — API-Fehler oder Frontend-Mutation-Fehler
|
||||
- **Aufwand:** 2 Stunden
|
||||
|
||||
### 1.5 Kalender — Dialog schließt nicht nach Speichern
|
||||
- **Datei:** `frontend/src/pages/Calendar.tsx`, `frontend/src/components/calendar/AppointmentEditForm.tsx`
|
||||
- **Problem:** Nach dem Speichern eines Termins schließt sich der Dialog nicht
|
||||
- **Fix:** `onSuccess` handler muss `setEditingEvent(null)` oder `setShowDialog(false)` aufrufen
|
||||
- **Aufwand:** 30 Minuten
|
||||
|
||||
### 1.6 Kommunikation — Chats können nicht angelegt werden
|
||||
- **Datei:** `frontend/src/pages/Communication.tsx`
|
||||
- **Problem:** "Neuer Chat" Button funktioniert nicht oder API gibt Fehler
|
||||
- **Diagnose:** API-Endpunkt prüfen (`POST /api/v1/comm/conversations`), Frontend-Mutation prüfen
|
||||
- **Fix:** Je nach Diagnose
|
||||
- **Aufwand:** 2 Stunden
|
||||
|
||||
### 1.7 Wiki — Doppelt im Menü
|
||||
- **Datei:** `frontend/src/routes/index.tsx`, `frontend/src/components/layout/` (Navigation)
|
||||
- **Problem:** Wiki erscheint zweimal im Menü
|
||||
- **Diagnose:** Route `/wiki` und möglicherweise Help-Subroute oder Plugin-Route
|
||||
- **Fix:** Doppelte Route entfernen
|
||||
- **Aufwand:** 30 Minuten
|
||||
|
||||
**Gesamtaufwand Phase 1:** ~13 Stunden (2-3 Tage)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: AI Assistent in Kommunikation integrieren (2-3 Tage)
|
||||
|
||||
### Problem
|
||||
Der AI Assistent ist ein paralleles System das die Kommunikation-Plattform dupliziert:
|
||||
- **AI Assistant Tabellen:** `ai_conversations`, `ai_messages` (app/models/ai_conversation.py) + `ai_chat_sessions`, `ai_chat_messages`, `ai_chat_attachments` (app/plugins/builtins/ai_assistant/models.py) — 5 Tabellen
|
||||
- **AI Assistant Frontend:** `AIAssistant.tsx`, `AIAssistantStandalone.tsx`, `SessionList.tsx`, `ChatWindow.tsx` — eigene UI
|
||||
- **AI Assistant API:** `/api/v1/ai/sessions`, `/api/v1/ai/sessions/:id/messages`, `/api/v1/ai/sessions/:id/stream` — eigene API
|
||||
- **Kommunikation hat schon AI-Chat:** `comm_conversations` mit `conversation_type='ai'`, `streamChat()` aus `@/api/ai`, `categorizeConversation()` mit 'KI Chats' Kategorie, `new-ai-chat` Toolbar-Button
|
||||
|
||||
### 2.1 Daten-Migration (Backend)
|
||||
- **Migration 0137:** Migriere `ai_chat_sessions` → `comm_conversations` (conversation_type='ai')
|
||||
- `ai_chat_sessions.id` → `comm_conversations.id`
|
||||
- `ai_chat_sessions.title` → `comm_conversations.title`
|
||||
- `ai_chat_sessions.tenant_id` → `comm_conversations.tenant_id`
|
||||
- `ai_chat_sessions.user_id` → `comm_conversations.owner_id`
|
||||
- `ai_chat_sessions.agent_id` → `comm_conversations.metadata.agent_id`
|
||||
- `ai_chat_sessions.created_at` → `comm_conversations.created_at`
|
||||
- **Migration 0137:** Migriere `ai_chat_messages` → `comm_messages`
|
||||
- `ai_chat_messages.id` → `comm_messages.id`
|
||||
- `ai_chat_messages.session_id` → `comm_messages.conversation_id`
|
||||
- `ai_chat_messages.role` → `comm_messages.sender_type` ('user' → 'user', 'assistant' → 'ai')
|
||||
- `ai_chat_messages.content` → `comm_messages.content`
|
||||
- `ai_chat_messages.tenant_id` → `comm_messages.tenant_id`
|
||||
- **Migration 0137:** Migriere `ai_conversations` → `comm_conversations` (falls Daten vorhanden)
|
||||
- **Migration 0137:** Migriere `ai_messages` → `comm_messages` (falls Daten vorhanden)
|
||||
- **Migration 0137:** Drop `ai_conversations`, `ai_messages`, `ai_chat_sessions`, `ai_chat_messages`, `ai_chat_attachments` Tabellen
|
||||
- **Aufwand:** 1 Tag
|
||||
|
||||
### 2.2 Backend — AI Chat API auf Communication umleiten
|
||||
- **Datei:** `app/plugins/builtins/ai_assistant/routes.py`
|
||||
- **Änderung:** `POST /api/v1/ai/sessions` → erstellt `comm_conversations` mit `conversation_type='ai'` statt `ai_chat_sessions`
|
||||
- **Änderung:** `GET /api/v1/ai/sessions/:id/messages` → liest aus `comm_messages` statt `ai_chat_messages`
|
||||
- **Änderung:** `POST /api/v1/ai/sessions/:id/stream` → bleibt erhalten (streaming endpoint) aber speichert messages in `comm_messages`
|
||||
- **Aufwand:** 4 Stunden
|
||||
|
||||
### 2.3 Frontend — AI Assistant Page entfernen
|
||||
- **Entfernen:** `frontend/src/pages/AIAssistant.tsx`
|
||||
- **Entfernen:** `frontend/src/pages/AIAssistantStandalone.tsx`
|
||||
- **Entfernen:** `frontend/src/components/ai/SessionList.tsx`
|
||||
- **Entfernen:** `frontend/src/components/ai/ChatWindow.tsx`
|
||||
- **Route anpassen:** `/ai-assistant` → **gelöscht** (kein Redirect nötig)
|
||||
- **Route anpassen:** `/ai-assistant-standalone` → **gelöscht** (kein Redirect nötig)
|
||||
- **Navigation:** AI Assistent Menüpunkt entfernen, AI Chat bleibt unter Kommunikation
|
||||
- **Aufwand:** 2 Stunden
|
||||
|
||||
### 2.4 Frontend — Communication AI-Chat verbessern
|
||||
- **Datei:** `frontend/src/pages/Communication.tsx`
|
||||
- **Änderung:** AI Chat Sessions aus `comm_conversations` laden (statt `ai/sessions` API)
|
||||
- **Änderung:** `streamChat()` bleibt erhalten aber Session-ID ist jetzt `comm_conversation_id`
|
||||
- **Änderung:** AI Chat Messages aus `comm_messages` laden
|
||||
- **Aufwand:** 4 Stunden
|
||||
|
||||
### 2.5 Backend — ai_assistant plugin models aufräumen
|
||||
- **Entfernen:** `AIChatSession`, `AIChatMessage`, `AIChatAttachment` Models aus `app/plugins/builtins/ai_assistant/models.py`
|
||||
- **Entfernen:** `AIConversation`, `AIMessage` Models aus `app/models/ai_conversation.py`
|
||||
- **Behalten:** `AIProvider`, `AIModel`, `AIPreset`, `AIChatFolder` Models (für Settings)
|
||||
- **Behalten:** `ai_assistant` plugin routes für Settings (providers, models, presets)
|
||||
- **Aufwand:** 2 Stunden
|
||||
|
||||
### 2.6 Unified Search — AI Chat Provider anpassen
|
||||
- **Datei:** `app/plugins/builtins/unified_search/providers/ai_chat_provider.py`
|
||||
- **Änderung:** Search auf `comm_messages` (conversation_type='ai') statt `ai_chat_messages`
|
||||
- **Aufwand:** 1 Stunde
|
||||
|
||||
**Gesamtaufwand Phase 2:** ~2-3 Tage
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Wiki UI-Überarbeitung (3-4 Tage)
|
||||
|
||||
### 3.1 WYSIWYG Editor
|
||||
- **Datei:** `frontend/src/components/wiki/WikiEditor.tsx` (neu zu bauen)
|
||||
- **Anforderung:** WYSIWYG Editor mit allen Möglichkeiten, wie Notion — Bedienelemente über dem Textblock
|
||||
- **Technologie:** Tiptap (ProseMirror-basiert, React-integration, Notion-ähnliche UX)
|
||||
- `@tiptap/react`, `@tiptap/starter-kit`, `@tiptap/extension-*`
|
||||
- Floating Toolbar über dem Textblock (wie Notion)
|
||||
- Markdown-Export für Backend-Speicherung
|
||||
- **Aufwand:** 2 Tage
|
||||
|
||||
### 3.2 Wiki Layout — 3-Spalten
|
||||
- **Datei:** `frontend/src/pages/Wiki.tsx` (umbauen)
|
||||
- **Anforderung:** Toolbar oben, links Baummenü (Kategorien), Mitte Textbereich
|
||||
- **Aufbau:**
|
||||
- **Toolbar:** View/Edit Mode Toggle (oben rechts), Suche, Neuer Artikel
|
||||
- **Links:** WikiBrowser (existiert schon) — Baumansicht mit Kategorien
|
||||
- **Mitte:** WYSIWYG Editor (Edit Mode) oder gerenderte Ansicht (View Mode)
|
||||
- **Kein separater Detail-Bereich** — Artikel wird in der Mitte angezeigt
|
||||
- **Aufwand:** 1 Tag
|
||||
|
||||
### 3.3 View/Edit Mode Toggle
|
||||
- **Datei:** `frontend/src/pages/Wiki.tsx`
|
||||
- **Anforderung:** Button oben rechts in der Toolbar der zwischen View und Edit Mode wechselt
|
||||
- **Im Edit Mode:** WYSIWYG Editor mit Floating Toolbar
|
||||
- **Im View Mode:** Gerenderte Markdown-Ansicht (wie jetzt, aber schöner)
|
||||
- **Aufwand:** 2 Stunden
|
||||
|
||||
**Gesamtaufwand Phase 3:** ~3-4 Tage
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Tasks UI-Überarbeitung (2-3 Tage)
|
||||
|
||||
### 4.1 Tasks Layout — 3-Spalten wie Kontakte
|
||||
- **Datei:** `frontend/src/pages/Tasks.tsx` (kompletter Umbau, 419 → ~600 Zeilen)
|
||||
- **Anforderung:** Linke Sidebar Baumansicht, Mitte Liste mit mehreren Ansichten, rechts Detailbereich
|
||||
- **Aufbau:**
|
||||
- **Toolbar:** PluginToolbar mit Filter-Dropdowns (Status, Priorität, Zuweisung, Fällig), Ansichts-Umschalter (Liste/Kanban), Neuer Task
|
||||
- **Links:** Baumansicht — nach Status (Offen/In Bearbeitung/Erledigt), nach Priorität, nach Zuweisung, nach Liste/Goal
|
||||
- **Mitte:** Liste (Tabelle) oder Kanban-Board — umschaltbar
|
||||
- **Rechts:** TaskDetail — ausgewählter Task mit Beschreibung, Subtasks, Zuweisung, Fälligkeit
|
||||
- **Aufwand:** 2-3 Tage
|
||||
|
||||
**Gesamtaufwand Phase 4:** ~2-3 Tage
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Kalender UI-Überarbeitung (1 Tag)
|
||||
|
||||
### 5.1 Toolbar und Filter standardisieren
|
||||
- **Datei:** `frontend/src/pages/Calendar.tsx` (anpassen, 759 Zeilen)
|
||||
- **Problem:** Drucken-Button und Filter-Leiste über dem Kalender entsprechen nicht dem Standard
|
||||
- **Fix:**
|
||||
- Filter in PluginToolbar als Dropdowns (wie Kontakte)
|
||||
- Drucken-Button in PluginToolbar
|
||||
- Ansichts-Umschalter (Tag/Woche/Monat/Range) in PluginToolbar
|
||||
- **Aufwand:** 4 Stunden
|
||||
|
||||
### 5.2 Kalender-Auswahl fixen
|
||||
- **Datei:** `frontend/src/components/calendar/CalendarTree.tsx`
|
||||
- **Problem:** Einzelnes An- und Abwählen von Kalendern funktioniert nicht richtig
|
||||
- **Fix:** Checkbox-Toggle Logik reparieren — `visibleCalendars` Set korrekt verwalten
|
||||
- **Aufwand:** 2 Stunden
|
||||
|
||||
**Gesamtaufwand Phase 5:** ~1 Tag
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Tags Umstrukturierung (2 Tage)
|
||||
|
||||
### 6.1 Tags in Settings verschieben
|
||||
- **Datei:** `frontend/src/pages/Tags.tsx` → `frontend/src/pages/SettingsTags.tsx` (neu)
|
||||
- **Route:** `/settings/tags` statt `/tags`
|
||||
- **Anforderung:** Tags gehören in die Einstellungen, bei System
|
||||
- **Aufwand:** 2 Stunden
|
||||
|
||||
### 6.2 Tags Baumstruktur
|
||||
- **Datei:** `frontend/src/pages/SettingsTags.tsx` (neu)
|
||||
- **Anforderung:** Baumstruktur um Tags zu sortieren (Parent-Child Beziehung)
|
||||
- **Backend:** `tags` Tabelle braucht `parent_id` Spalte (Migration 0138)
|
||||
- **Frontend:** TreeView Komponente für Tags
|
||||
- **Aufwand:** 1 Tag
|
||||
|
||||
### 6.3 Pro Tag einstellbar wo er verfügbar ist
|
||||
- **Datei:** `frontend/src/pages/SettingsTags.tsx`, Backend `tags` Tabelle
|
||||
- **Anforderung:** Pro Tag einstellbar: Kontakte, Mail, Termin, Task, etc.
|
||||
- **Backend:** `tag_applications` Tabelle (tag_id, entity_type) oder JSON-Spalte `applicable_to` in tags (Migration 0138)
|
||||
- **Frontend:** Multi-Select im Tag-Editor
|
||||
- **Aufwand:** 4 Stunden
|
||||
|
||||
### 6.4 Symbol und Farbe pro Tag
|
||||
- **Datei:** `frontend/src/pages/SettingsTags.tsx`, Backend `tags` Tabelle
|
||||
- **Anforderung:** Symbol (Icon) und Farbe pro Tag einstellbar
|
||||
- **Backend:** `icon` Spalte in tags (Migration 0138), `color` existiert schon
|
||||
- **Frontend:** Icon-Picker und Color-Picker im Tag-Editor
|
||||
- **Aufwand:** 4 Stunden
|
||||
|
||||
**Gesamtaufwand Phase 6:** ~2 Tage
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Reports UI-Überarbeitung (2 Tage)
|
||||
|
||||
### 7.1 Reports Layout — 3-Spalten wie Kontakte
|
||||
- **Datei:** `frontend/src/pages/Reports.tsx` (Umbau, 433 Zeilen)
|
||||
- **Anforderung:** Linke Sidebar mit Baumstruktur (Ordner zum Sortieren), Mitte verschiedene Ansichten (Liste/Karten), rechts Detailbereich
|
||||
- **Aufbau:**
|
||||
- **Toolbar:** PluginToolbar mit Filter, Ansichts-Umschalter, Neuer Report
|
||||
- **Links:** Baumansicht — nach Ordner/Gruppe sortierbar
|
||||
- **Mitte:** Liste oder Karten-Ansicht — umschaltbar
|
||||
- **Rechts:** ReportDetail — ausgewählter Report mit Vorschau
|
||||
- **Backend:** `reports` Tabelle braucht `folder_id` Spalte (Migration 0139) für Ordner-Sortierung
|
||||
- **Aufwand:** 2 Tage
|
||||
|
||||
**Gesamtaufwand Phase 7:** ~2 Tage
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Kommunikation UI-Überarbeitung (2-3 Tage)
|
||||
|
||||
### 8.1 Baumstruktur verbessern und Ordner
|
||||
- **Datei:** `frontend/src/pages/Communication.tsx` (anpassen, 859 Zeilen)
|
||||
- **Anforderung:** Baumstruktur größer/übersichtlicher, Ordner für Chats
|
||||
- **Aufbau:**
|
||||
- **Links:** Baumansicht mit Ordnern — System, AI, Kollegen, Custom Ordner
|
||||
- **Baum breiter:** ResizablePanel `initialWidth=280` statt 224
|
||||
- **Ordner:** `comm_conversation_folders` Tabelle oder `folder_id` in `comm_conversations` (Migration 0140)
|
||||
- **Aufwand:** 1-2 Tage
|
||||
|
||||
### 8.2 AI Chat in Kommunikation (nach Phase 2)
|
||||
- AI Chats werden als eigener Baum-Knoten 'KI Chats' in Communication angezeigt
|
||||
- Neuer AI Chat Button in Toolbar erstellt `comm_conversation` mit `conversation_type='ai'`
|
||||
- `streamChat()` wird aufgerufen mit `comm_conversation_id` als Session-ID
|
||||
- AI Messages werden in `comm_messages` gespeichert
|
||||
- **Aufwand:** in Phase 2
|
||||
|
||||
**Gesamtaufwand Phase 8:** ~1-2 Tage (Phase 2 vorab)
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: Strukturelle Änderungen (0.5 Tage)
|
||||
|
||||
### 9.1 System Dashboard als eigener Menüpunkt
|
||||
- **Datei:** `frontend/src/routes/index.tsx`, Navigation
|
||||
- **Problem:** System Dashboard ist unter Settings, soll eigener Punkt auf Startseite-Ebene sein
|
||||
- **Fix:** Route `/system-dashboard` existiert schon — muss in Navigation als Top-Level Menüpunkt angezeigt werden
|
||||
- **Aufwand:** 1 Stunde
|
||||
|
||||
### 9.2 Mail — Postfach mit IMAP anlegen testen
|
||||
- **Datei:** `frontend/src/pages/Mail.tsx`, `frontend/src/pages/MailSettings.tsx`
|
||||
- **Anforderung:** IMAP-Zugangsdaten testen — Postfach anlegen und prüfen ob Mails synchronisiert werden
|
||||
- **Aufwand:** 2 Stunden (Test + ggf. Bugfix)
|
||||
|
||||
**Gesamtaufwand Phase 9:** ~0.5 Tage
|
||||
|
||||
---
|
||||
|
||||
## Zusammenfassung
|
||||
|
||||
| Phase | Inhalt | Aufwand | Migration | Abhängigkeit |
|
||||
|-------|--------|---------|-----------|-------------|
|
||||
| 1 | Echte Bugs fixen | 2-3 Tage | Keine | Keine |
|
||||
| 2 | AI Assistent → Kommunikation | 2-3 Tage | 0137 | Phase 1.6 |
|
||||
| 3 | Wiki UI + WYSIWYG | 3-4 Tage | Keine | Phase 1.4 |
|
||||
| 4 | Tasks UI neu | 2-3 Tage | Keine | Keine |
|
||||
| 5 | Kalender UI | 1 Tag | Keine | Phase 1.5 |
|
||||
| 6 | Tags Umstrukturierung | 2 Tage | 0138 | Keine |
|
||||
| 7 | Reports UI | 2 Tage | 0139 | Keine |
|
||||
| 8 | Kommunikation UI | 1-2 Tage | 0140 | Phase 2 |
|
||||
| 9 | Strukturelle Änderungen | 0.5 Tage | Keine | Keine |
|
||||
|
||||
**Gesamtaufwand:** ~17-22 Tage
|
||||
|
||||
### Reihenfolge:
|
||||
1. **Phase 1** (Bugs) — zuerst, damit grundlegende Funktionen arbeiten
|
||||
2. **Phase 9** (Strukturelle Änderungen) — schnell, wenig Aufwand
|
||||
3. **Phase 5** (Kalender) — kleines Update, baut auf Phase 1 auf
|
||||
4. **Phase 2** (AI Assistent → Kommunikation) — entfernt paralleles System, baut auf Phase 1.6 auf
|
||||
5. **Phase 6** (Tags) — unabhängig, Backend + Frontend
|
||||
6. **Phase 4** (Tasks) — großer Umbau, unabhängig
|
||||
7. **Phase 3** (Wiki) — größter Umbau (WYSIWYG Editor), baut auf Phase 1 auf
|
||||
8. **Phase 7** (Reports) — großer Umbau, unabhängig
|
||||
9. **Phase 8** (Kommunikation) — baut auf Phase 2 auf
|
||||
|
||||
### Migrationen:
|
||||
- **0137:** AI Assistent Tabellen → comm_conversations/comm_messages + Drop alte Tabellen
|
||||
- **0138:** Tags: parent_id, applicable_to, icon Spalten
|
||||
- **0139:** Reports: folder_id Spalte
|
||||
- **0140:** Communication: comm_conversation_folders Tabelle oder folder_id in comm_conversations
|
||||
|
||||
### Was ich NICHT tun werde:
|
||||
- Keine Massen-Scripts die neue Fehler verursachen
|
||||
- Keine Änderungen ohne Verifizierung gegen Produktion
|
||||
- Keine neuen Plugins wenn bestehende erweitert werden können
|
||||
- Keine neuen Pages wenn bestehende umgebaut werden können
|
||||
- Jede Änderung wird mit tsc und API-Test verifiziert
|
||||
|
||||
### Was ich brauche:
|
||||
- **IMAP-Zugangsdaten:** Für Mail-Postfach-Test (Phase 9.2)
|
||||
@@ -18,7 +18,26 @@ branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(conn, table_name: str) -> bool:
|
||||
"""True when the table exists (dual-path convergence, Gate B).
|
||||
|
||||
On a fresh install the ai_assistant plugin SQL migration has not run
|
||||
yet when Alembic reaches this revision — skip instead of failing.
|
||||
The plugin-side migration adds the same columns idempotently.
|
||||
"""
|
||||
row = conn.execute(
|
||||
sa.text("SELECT to_regclass(:tname) IS NOT NULL"),
|
||||
{"tname": f"public.{table_name}"},
|
||||
).scalar()
|
||||
return bool(row)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if not _table_exists(conn, "ai_providers"):
|
||||
# Fresh-install path: table arrives with the ai_assistant plugin
|
||||
# migration, which includes these columns.
|
||||
return
|
||||
op.add_column("ai_providers", sa.Column("region", sa.String(20), nullable=False, server_default="unknown"))
|
||||
op.add_column("ai_providers", sa.Column("hosting_type", sa.String(30), nullable=False, server_default="cloud"))
|
||||
op.add_column("ai_providers", sa.Column("dpa_status", sa.String(20), nullable=False, server_default="none"))
|
||||
@@ -29,6 +48,9 @@ def upgrade() -> None:
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if not _table_exists(conn, "ai_providers"):
|
||||
return
|
||||
op.drop_column("ai_providers", "allowed_data_classes")
|
||||
op.drop_column("ai_providers", "transfer_notice")
|
||||
op.drop_column("ai_providers", "training_on_customer_data")
|
||||
|
||||
@@ -17,7 +17,23 @@ branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(conn, table_name: str) -> bool:
|
||||
"""True when the table exists (dual-path convergence, Gate B).
|
||||
|
||||
On a fresh install the kommunikation plugin SQL migration has not run
|
||||
yet when Alembic reaches this revision — skip the comm_* parts instead
|
||||
of failing. The plugin-side migration adds the same column idempotently.
|
||||
"""
|
||||
row = conn.execute(
|
||||
sa.text("SELECT to_regclass(:tname) IS NOT NULL"),
|
||||
{"tname": f"public.{table_name}"},
|
||||
).scalar()
|
||||
return bool(row)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if _table_exists(conn, "comm_conversations"):
|
||||
# 1. Add is_system column to comm_conversations
|
||||
op.add_column(
|
||||
"comm_conversations",
|
||||
@@ -130,13 +146,16 @@ def upgrade() -> None:
|
||||
AND n.deleted_at IS NULL;
|
||||
""")
|
||||
|
||||
# 7. Create legacy view over notifications table for backward compatibility
|
||||
# 7. Legacy view over the CORE notifications table — exists on both paths
|
||||
op.execute("DROP VIEW IF EXISTS notifications_legacy")
|
||||
op.execute("CREATE VIEW notifications_legacy AS SELECT * FROM notifications")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
op.execute("DROP VIEW IF EXISTS notifications_legacy")
|
||||
if not _table_exists(conn, "comm_conversations"):
|
||||
return
|
||||
op.execute("DELETE FROM comm_message_blocks WHERE message_id IN (SELECT id FROM comm_messages WHERE metadata->>'migrated_from_notification' = 'true')")
|
||||
op.execute("DELETE FROM comm_messages WHERE metadata->>'migrated_from_notification' = 'true'")
|
||||
op.execute("DELETE FROM comm_conversations WHERE is_system = true AND title = 'System Channel'")
|
||||
|
||||
@@ -14,7 +14,24 @@ branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(conn, table_name: str) -> bool:
|
||||
"""True when the table exists (dual-path convergence, Gate B).
|
||||
|
||||
On a fresh install the automation plugin SQL migration has not run yet
|
||||
when Alembic reaches this revision — skip instead of failing. The
|
||||
plugin-side convergence migration creates the same table.
|
||||
"""
|
||||
row = conn.execute(
|
||||
sa.text("SELECT to_regclass(:tname) IS NOT NULL"),
|
||||
{"tname": f"public.{table_name}"},
|
||||
).scalar()
|
||||
return bool(row)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if not _table_exists(conn, "automation_agent_runs"):
|
||||
return
|
||||
op.create_table(
|
||||
"automation_agent_run_steps",
|
||||
sa.Column("id", PGUUID(as_uuid=True), primary_key=True),
|
||||
|
||||
@@ -18,7 +18,24 @@ branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(conn, table_name: str) -> bool:
|
||||
"""True when the table exists (dual-path convergence, Gate B).
|
||||
|
||||
On a fresh install the automation plugin SQL migration has not run yet
|
||||
when Alembic reaches this revision — skip instead of failing. The
|
||||
plugin-side convergence migration adds the same columns.
|
||||
"""
|
||||
row = conn.execute(
|
||||
sa.text("SELECT to_regclass(:tname) IS NOT NULL"),
|
||||
{"tname": f"public.{table_name}"},
|
||||
).scalar()
|
||||
return bool(row)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if not _table_exists(conn, "automation_agent_definitions"):
|
||||
return
|
||||
op.add_column(
|
||||
"automation_agent_definitions",
|
||||
sa.Column("temperature", sa.Float, nullable=False, server_default="0.3"),
|
||||
|
||||
@@ -20,7 +20,27 @@ branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(conn, table_name: str) -> bool:
|
||||
"""True when the table exists (dual-path convergence, Gate B).
|
||||
|
||||
On a fresh install the tasks plugin SQL migration has not run yet when
|
||||
Alembic reaches this revision — skip instead of failing. The plugin-side
|
||||
convergence migration adds the same columns/indexes. The legacy-data
|
||||
backfills below only matter for pre-existing rows and are correctly
|
||||
empty on a fresh install.
|
||||
"""
|
||||
row = conn.execute(
|
||||
sa.text("SELECT to_regclass(:tname) IS NOT NULL"),
|
||||
{"tname": f"public.{table_name}"},
|
||||
).scalar()
|
||||
return bool(row)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if not _table_exists(conn, "tasks"):
|
||||
return
|
||||
|
||||
# ── Add new columns to tasks ────────────────────────────────────────────
|
||||
op.add_column("tasks", sa.Column("assignee_type", sa.String(20), nullable=False, server_default="user"))
|
||||
op.add_column("tasks", sa.Column("assignee_id", PGUUID(as_uuid=True), nullable=True))
|
||||
|
||||
@@ -12,6 +12,7 @@ Revises: 0126
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0127"
|
||||
down_revision = "0126"
|
||||
@@ -19,7 +20,19 @@ branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(conn, table_name: str) -> bool:
|
||||
"""True when the table exists (dual-path convergence, Gate B)."""
|
||||
row = conn.execute(
|
||||
sa.text("SELECT to_regclass(:tname) IS NOT NULL"),
|
||||
{"tname": f"public.{table_name}"},
|
||||
).scalar()
|
||||
return bool(row)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if not _table_exists(conn, "tasks"):
|
||||
return
|
||||
# Drop the FK constraint on tasks.contact_id
|
||||
op.drop_constraint("tasks_contact_id_fkey", "tasks", type_="foreignkey")
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ Revises: 0128
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0129"
|
||||
down_revision = "0128"
|
||||
@@ -27,8 +28,25 @@ TABLES_NEEDING_RLS = [
|
||||
]
|
||||
|
||||
|
||||
def _table_exists(conn, table_name: str) -> bool:
|
||||
"""True when the table exists (dual-path convergence, Gate B).
|
||||
|
||||
Plugin-owned tables may not exist yet on a fresh install when Alembic
|
||||
reaches this revision — skip them instead of failing. The plugin-side
|
||||
convergence migrations apply the same RLS policies.
|
||||
"""
|
||||
row = conn.execute(
|
||||
sa.text("SELECT to_regclass(:tname) IS NOT NULL"),
|
||||
{"tname": f"public.{table_name}"},
|
||||
).scalar()
|
||||
return bool(row)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
for table in TABLES_NEEDING_RLS:
|
||||
if not _table_exists(conn, table):
|
||||
continue
|
||||
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY;")
|
||||
op.execute(
|
||||
f"CREATE POLICY tenant_isolation ON {table} "
|
||||
@@ -37,6 +55,9 @@ def upgrade() -> None:
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
for table in TABLES_NEEDING_RLS:
|
||||
if not _table_exists(conn, table):
|
||||
continue
|
||||
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table};")
|
||||
op.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY;")
|
||||
|
||||
@@ -10,6 +10,7 @@ Revises: 0135
|
||||
Create Date: 2026-08-21
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0136"
|
||||
down_revision = "0135"
|
||||
@@ -29,8 +30,25 @@ TABLES_WITH_BAD_RLS = [
|
||||
]
|
||||
|
||||
|
||||
def _table_exists(conn, table_name: str) -> bool:
|
||||
"""True when the table exists (dual-path convergence, Gate B).
|
||||
|
||||
Plugin-owned tables may not exist yet on a fresh install when Alembic
|
||||
reaches this revision — skip them instead of failing. The plugin-side
|
||||
convergence migrations apply the same RLS policies.
|
||||
"""
|
||||
row = conn.execute(
|
||||
sa.text("SELECT to_regclass(:tname) IS NOT NULL"),
|
||||
{"tname": f"public.{table_name}"},
|
||||
).scalar()
|
||||
return bool(row)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
for table in TABLES_WITH_BAD_RLS:
|
||||
if not _table_exists(conn, table):
|
||||
continue
|
||||
# Drop old policy with app.tenant_id
|
||||
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table};")
|
||||
# Create new policy with app.current_tenant_id
|
||||
@@ -41,7 +59,10 @@ def upgrade() -> None:
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
for table in TABLES_WITH_BAD_RLS:
|
||||
if not _table_exists(conn, table):
|
||||
continue
|
||||
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table};")
|
||||
op.execute(
|
||||
f"CREATE POLICY tenant_isolation ON {table} "
|
||||
|
||||
@@ -19,7 +19,25 @@ branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(conn, table_name: str) -> bool:
|
||||
"""True when the table exists (dual-path convergence, Gate B).
|
||||
|
||||
On a fresh install the tags plugin SQL migration has not run yet when
|
||||
Alembic reaches this revision — skip instead of failing. The plugin-side
|
||||
convergence migration adds the same columns.
|
||||
"""
|
||||
row = conn.execute(
|
||||
sa.text("SELECT to_regclass(:tname) IS NOT NULL"),
|
||||
{"tname": f"public.{table_name}"},
|
||||
).scalar()
|
||||
return bool(row)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if not _table_exists(conn, "tags"):
|
||||
return
|
||||
|
||||
# parent_id for tree structure (self-referencing FK)
|
||||
op.add_column("tags", sa.Column("parent_id", PGUUID(as_uuid=True), nullable=True))
|
||||
op.create_foreign_key(
|
||||
@@ -35,6 +53,9 @@ def upgrade() -> None:
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if not _table_exists(conn, "tags"):
|
||||
return
|
||||
op.drop_column("tags", "icon")
|
||||
op.drop_column("tags", "applicable_to")
|
||||
op.drop_index("ix_tags_parent", table_name="tags")
|
||||
|
||||
@@ -18,11 +18,31 @@ branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(conn, table_name: str) -> bool:
|
||||
"""True when the table exists (dual-path convergence, Gate B).
|
||||
|
||||
On a fresh install the report_generator plugin SQL migration has not
|
||||
run yet when Alembic reaches this revision — skip instead of failing.
|
||||
The plugin-side convergence migration adds the same column.
|
||||
"""
|
||||
row = conn.execute(
|
||||
sa.text("SELECT to_regclass(:tname) IS NOT NULL"),
|
||||
{"tname": f"public.{table_name}"},
|
||||
).scalar()
|
||||
return bool(row)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if not _table_exists(conn, "report_templates"):
|
||||
return
|
||||
op.add_column("report_templates", sa.Column("folder_id", PGUUID(as_uuid=True), nullable=True))
|
||||
op.create_index("ix_report_templates_folder", "report_templates", ["folder_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if not _table_exists(conn, "report_templates"):
|
||||
return
|
||||
op.drop_index("ix_report_templates_folder", table_name="report_templates")
|
||||
op.drop_column("report_templates", "folder_id")
|
||||
|
||||
@@ -18,11 +18,31 @@ branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(conn, table_name: str) -> bool:
|
||||
"""True when the table exists (dual-path convergence, Gate B).
|
||||
|
||||
On a fresh install the kommunikation plugin SQL migration has not run
|
||||
yet when Alembic reaches this revision — skip instead of failing.
|
||||
The plugin-side migration adds the same column idempotently.
|
||||
"""
|
||||
row = conn.execute(
|
||||
sa.text("SELECT to_regclass(:tname) IS NOT NULL"),
|
||||
{"tname": f"public.{table_name}"},
|
||||
).scalar()
|
||||
return bool(row)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if not _table_exists(conn, "comm_conversations"):
|
||||
return
|
||||
op.add_column("comm_conversations", sa.Column("folder_id", PGUUID(as_uuid=True), nullable=True))
|
||||
op.create_index("ix_comm_conversations_folder", "comm_conversations", ["folder_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if not _table_exists(conn, "comm_conversations"):
|
||||
return
|
||||
op.drop_index("ix_comm_conversations_folder", table_name="comm_conversations")
|
||||
op.drop_column("comm_conversations", "folder_id")
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
'''Fix role permission wildcard patterns to canonical 2-segment schema
|
||||
|
||||
Revision ID: 0141
|
||||
Revises: 0140
|
||||
Create Date: 2026-08-23
|
||||
|
||||
Migration 0019 seeded default roles with 3-segment permission patterns
|
||||
(core:*:read etc.). The runtime matcher (_matches_permission) compares
|
||||
segment counts strictly, so those patterns could never match any
|
||||
2-segment requirement - editor/viewer roles were silently dead.
|
||||
|
||||
Canonical schema is module:action (2 segments, * wildcards allowed).
|
||||
core:*:X means all modules with action X, so it converts to *:X.
|
||||
'''
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '0141'
|
||||
down_revision = '0140'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# Rebuild the permissions JSONB object, rewriting every key that starts
|
||||
# with the dead 'core:' prefix to its 2-segment equivalent ('*:X').
|
||||
_UPGRADE_SQL = '''
|
||||
UPDATE roles
|
||||
SET permissions = sub.new_perms,
|
||||
permission_version = permission_version + 1
|
||||
FROM (
|
||||
SELECT
|
||||
r.id AS role_id,
|
||||
jsonb_object_agg(
|
||||
CASE WHEN k LIKE 'core:%'
|
||||
THEN '*:' || split_part(k, ':', 3)
|
||||
ELSE k END,
|
||||
v
|
||||
) AS new_perms
|
||||
FROM roles r,
|
||||
jsonb_each(r.permissions) AS e(k, v)
|
||||
GROUP BY r.id
|
||||
) AS sub
|
||||
WHERE roles.id = sub.role_id
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM jsonb_object_keys(roles.permissions) k
|
||||
WHERE k LIKE 'core:%'
|
||||
)
|
||||
'''
|
||||
|
||||
# Reverse: map '*:X' back to 'core:*:X' only for keys that came from the
|
||||
# original seeding pattern. Roles that legitimately use '*:X' without a
|
||||
# matching 'core:*:X' history are left untouched (best-effort downgrade).
|
||||
_DOWNGRADE_SQL = '''
|
||||
UPDATE roles
|
||||
SET permissions = sub.new_perms,
|
||||
permission_version = permission_version + 1
|
||||
FROM (
|
||||
SELECT
|
||||
r.id AS role_id,
|
||||
jsonb_object_agg(
|
||||
CASE WHEN k = '*:' || split_part(k, ':', 2)
|
||||
AND k <> '*:*'
|
||||
THEN 'core:*:' || split_part(k, ':', 2)
|
||||
ELSE k END,
|
||||
v
|
||||
) AS new_perms
|
||||
FROM roles r,
|
||||
jsonb_each(r.permissions) AS e(k, v)
|
||||
GROUP BY r.id
|
||||
) AS sub
|
||||
WHERE roles.id = sub.role_id
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM jsonb_object_keys(roles.permissions) k
|
||||
WHERE k = '*:' || split_part(k, ':', 2) AND k <> '*:*'
|
||||
)
|
||||
'''
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(_UPGRADE_SQL)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(_DOWNGRADE_SQL)
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Add backup config columns to system_settings table.
|
||||
|
||||
Follow-up to 0130: the backup feature (10b1f83) added backup_interval,
|
||||
backup_retention_days and backup_destination to schema/service/frontend
|
||||
but missed model columns and this migration.
|
||||
|
||||
Revision ID: 0142
|
||||
Revises: 0141
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0142"
|
||||
down_revision = "0141"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"system_settings",
|
||||
sa.Column("backup_interval", sa.String(20), nullable=False, server_default="daily"),
|
||||
)
|
||||
op.add_column(
|
||||
"system_settings",
|
||||
sa.Column("backup_retention_days", sa.Integer(), nullable=False, server_default="7"),
|
||||
)
|
||||
op.add_column(
|
||||
"system_settings",
|
||||
sa.Column("backup_destination", sa.String(20), nullable=False, server_default="local"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("system_settings", "backup_destination")
|
||||
op.drop_column("system_settings", "backup_retention_days")
|
||||
op.drop_column("system_settings", "backup_interval")
|
||||
@@ -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")
|
||||
+102
-1
@@ -122,6 +122,10 @@ async def _execute_tool(
|
||||
"""Execute a single tool call via the registry.
|
||||
|
||||
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)
|
||||
if tool is None:
|
||||
@@ -135,6 +139,89 @@ async def _execute_tool(
|
||||
return f"Error: {exc}"
|
||||
|
||||
|
||||
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(
|
||||
agent_definition: Any, # AgentDefinition from automation models
|
||||
messages: list[dict[str, Any]],
|
||||
@@ -151,6 +238,7 @@ async def run_react_loop(
|
||||
dry_run: bool = False,
|
||||
require_approval: bool = False,
|
||||
approval_tools: list[str] | None = None,
|
||||
user_permissions: dict[str, Any] | None = None,
|
||||
) -> ReActResult:
|
||||
"""Execute a ReAct loop: LLM reasoning → tool execution → repeat.
|
||||
|
||||
@@ -197,8 +285,13 @@ async def run_react_loop(
|
||||
"tenant_id": str(tenant_id),
|
||||
"user_id": str(user_id),
|
||||
"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.
|
||||
async def _audit_tool_call(
|
||||
step_number: int,
|
||||
@@ -365,7 +458,15 @@ async def run_react_loop(
|
||||
args = {}
|
||||
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(
|
||||
{
|
||||
"dry_run": True,
|
||||
|
||||
@@ -17,7 +17,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
@@ -20,7 +20,8 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
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
|
||||
|
||||
@@ -71,6 +72,7 @@ async def stream_react_loop(
|
||||
max_steps: int = 20,
|
||||
timeout_seconds: int = 300,
|
||||
trace_id: str | None = None,
|
||||
user_permissions: dict[str, Any] | None = None,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""Run the ReAct loop and yield SSE-formatted events.
|
||||
|
||||
@@ -116,6 +118,7 @@ async def stream_react_loop(
|
||||
timeout_seconds=timeout_seconds,
|
||||
trace_id=trace_id,
|
||||
on_step=on_step,
|
||||
user_permissions=user_permissions, # F01: enforce at execution time
|
||||
)
|
||||
await queue.put(
|
||||
_sse(
|
||||
|
||||
@@ -25,8 +25,6 @@ import logging
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from app.core.sensitive_data import sanitize_dict
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
@@ -20,8 +20,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.ai.ai_use_case import AIUseCaseMetadata
|
||||
from app.core.sensitive_data import (
|
||||
SENSITIVE_FIELDS,
|
||||
filter_for_llm_context,
|
||||
get_data_class_for_field,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -6,7 +6,6 @@ import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
LOW_CONFIDENCE_THRESHOLD = 0.6
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ from typing import Any
|
||||
|
||||
from app.ai.knowledge_sources import get_source_config
|
||||
|
||||
|
||||
EXTRACTION_TRIGGERS = {
|
||||
"mail.received",
|
||||
"dms.file_uploaded",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
|
||||
@@ -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("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",
|
||||
)
|
||||
@@ -107,6 +107,10 @@ class Settings(BaseSettings):
|
||||
rate_limit_webhook_max: int = 100 # incoming webhooks
|
||||
rate_limit_webhook_window: int = 60 # 1 minute
|
||||
|
||||
# System tenant — used by seeding/plugins that need a well-known default
|
||||
# tenant (must match scripts/seed_admin.py slug).
|
||||
system_tenant_slug: str = "default"
|
||||
|
||||
# LLM Cost Overrun Protection (B.17)
|
||||
llm_monthly_budget_usd: float = 100.0 # per-tenant monthly LLM budget
|
||||
llm_hard_cutoff: bool = True # block LLM calls when budget exceeded
|
||||
|
||||
@@ -85,6 +85,38 @@ def generate_csrf_token() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
async def revoke_user_redis_sessions(user_id: str | uuid.UUID) -> int:
|
||||
"""Delete every active Redis session belonging to the user (G2).
|
||||
|
||||
Shared by both password-change paths (token reset + profile/admin change):
|
||||
after a password change, stolen or lingering sessions must die.
|
||||
|
||||
Returns the number of deleted session keys. Never raises — a Redis outage
|
||||
must not break the password change itself.
|
||||
"""
|
||||
try:
|
||||
redis = get_redis()
|
||||
deleted = 0
|
||||
async for key in redis.scan_iter(match="session:*", count=100):
|
||||
raw = await redis.get(key)
|
||||
if raw is None:
|
||||
continue
|
||||
try:
|
||||
import json
|
||||
|
||||
session_data = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
if session_data.get("user_id") == str(user_id):
|
||||
await redis.delete(key)
|
||||
deleted += 1
|
||||
logger.info("Deleted session %s for user %s", key, user_id)
|
||||
return deleted
|
||||
except Exception:
|
||||
logger.warning("Failed to invalidate Redis sessions for user %s", user_id, exc_info=True)
|
||||
return 0
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
"""SHA-256 hash a token for storage."""
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
@@ -355,6 +355,23 @@ async def close_engine() -> None:
|
||||
_migration_session_factory = None
|
||||
|
||||
|
||||
async def get_system_tenant(db: AsyncSession):
|
||||
"""Return the well-known system tenant, or ``None`` if it does not exist.
|
||||
|
||||
Resolves by configured slug (``settings.system_tenant_slug``, default
|
||||
``"default"`` as created by ``scripts/seed_admin.py``) instead of an
|
||||
arbitrary first row, so multi-tenant databases stay deterministic.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models.tenant import Tenant # lazy: models import this module's Base
|
||||
|
||||
slug = get_settings().system_tenant_slug
|
||||
result = await db.execute(select(Tenant).where(Tenant.slug == slug).limit(1))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
def reset_engine_for_testing(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
|
||||
"""Replace all global engines with a test engine. Returns a session factory.
|
||||
|
||||
|
||||
@@ -36,7 +36,12 @@ class EventBus:
|
||||
self._handlers: dict[str, list[EventHandler]] = defaultdict(list)
|
||||
|
||||
def subscribe(self, event_name: str, handler: EventHandler) -> None:
|
||||
"""Subscribe a handler to an event."""
|
||||
"""Subscribe a handler to an event.
|
||||
|
||||
Idempotent: subscribing the same handler twice is a no-op
|
||||
(ARCH-020) so double activation cannot fire handlers twice.
|
||||
"""
|
||||
if handler not in self._handlers[event_name]:
|
||||
self._handlers[event_name].append(handler)
|
||||
|
||||
def unsubscribe(self, event_name: str, handler: EventHandler) -> None:
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Import/Export format registry — formats are provided by plugins.
|
||||
|
||||
A **format plugin** (e.g. ``importexport_formats``) registers handlers for
|
||||
its supported file formats (csv, json, xlsx, ...). An **entity module**
|
||||
(e.g. contacts) contributes via its contract which formats it supports and
|
||||
provides the import/export logic for its data.
|
||||
|
||||
The core orchestrator (routes + background jobs) resolves the intersection:
|
||||
|
||||
capabilities(entity) = entity.formats ∩ format_registry.registered
|
||||
|
||||
The security policy layer (sensitive-data filter, tenant scoping, audit)
|
||||
runs in the orchestrator, independently of the module contribution.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class FormatHandler(Protocol):
|
||||
"""Handler for one file format (e.g. csv, json, xlsx).
|
||||
|
||||
Provided by a format plugin via the registry.
|
||||
"""
|
||||
|
||||
format_id: str
|
||||
|
||||
@staticmethod
|
||||
def parse(content: bytes) -> list[dict[str, Any]]:
|
||||
"""Parse file content into a list of row dicts."""
|
||||
...
|
||||
|
||||
@staticmethod
|
||||
def serialize(rows: list[dict[str, Any]], headers: list[str]) -> bytes:
|
||||
"""Serialize rows into file bytes with the given column order."""
|
||||
...
|
||||
|
||||
|
||||
class ImportExportFormatRegistry:
|
||||
"""Registry for file-format handlers contributed by format plugins."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._formats: dict[str, FormatHandler] = {}
|
||||
|
||||
def register(self, handler: FormatHandler) -> None:
|
||||
"""Register (or replace) a format handler."""
|
||||
self._formats[handler.format_id] = handler
|
||||
logger.debug("Registered import/export format '%s'", handler.format_id)
|
||||
|
||||
def unregister(self, format_id: str) -> None:
|
||||
self._formats.pop(format_id, None)
|
||||
logger.debug("Unregistered import/export format '%s'", format_id)
|
||||
|
||||
def get(self, format_id: str) -> FormatHandler | None:
|
||||
return self._formats.get(format_id)
|
||||
|
||||
def list_formats(self) -> list[str]:
|
||||
return sorted(self._formats.keys())
|
||||
|
||||
def available_for(self, entity_formats: list[str]) -> list[str]:
|
||||
"""Return the intersection of an entity's declared formats and
|
||||
the currently registered (plugin-provided) format handlers."""
|
||||
return sorted(set(entity_formats) & set(self._formats.keys()))
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all registrations (testing only)."""
|
||||
self._formats.clear()
|
||||
|
||||
|
||||
# ─── module-level singleton ─────────────────────────────────────────────────
|
||||
|
||||
_registry: ImportExportFormatRegistry | None = None
|
||||
|
||||
|
||||
def get_format_registry() -> ImportExportFormatRegistry:
|
||||
global _registry
|
||||
if _registry is None:
|
||||
_registry = ImportExportFormatRegistry()
|
||||
return _registry
|
||||
|
||||
|
||||
def reset_format_registry_for_testing() -> ImportExportFormatRegistry:
|
||||
global _registry
|
||||
_registry = ImportExportFormatRegistry()
|
||||
return _registry
|
||||
@@ -153,3 +153,284 @@ async def send_password_reset_email(
|
||||
from app.core.job_registry import register_job # noqa: E402
|
||||
|
||||
register_job("send_password_reset_email", send_password_reset_email)
|
||||
|
||||
|
||||
# ── DSAR Processing Job (G1 DSGVO: Art. 15 Auskunft / Art. 17 Löschung) ─────
|
||||
|
||||
async def _dsar_collect_user_data(db: Any, tenant_id: str, user_id: str) -> dict[str, Any]:
|
||||
"""Collect every data category the dsgvo-export route promises.
|
||||
|
||||
Core collects ONLY core-owned data (profile, audit log, notifications).
|
||||
Plugin-owned categories (contacts, mail accounts, tasks, calendar
|
||||
entries, comm messages, ...) are contributed by each plugin's contract
|
||||
via ``dsar_collect()`` — the core must not know plugin internals.
|
||||
"""
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID as PyUUID
|
||||
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.notification import Notification
|
||||
from app.models.user import User
|
||||
from app.plugins.builtins.contracts import get_contract
|
||||
from app.plugins.registry import get_registry
|
||||
|
||||
uid = PyUUID(user_id)
|
||||
tid = PyUUID(tenant_id)
|
||||
|
||||
export_data: dict[str, Any] = {
|
||||
"user_id": user_id,
|
||||
"exported_at": datetime.now(UTC).isoformat(),
|
||||
"legal_basis": "GDPR Art. 15 (access) / Art. 20 (portability)",
|
||||
"data": {},
|
||||
}
|
||||
|
||||
# Profile
|
||||
user = (
|
||||
await db.execute(sa_select(User).where(User.id == uid))
|
||||
).scalar_one_or_none()
|
||||
if user:
|
||||
export_data["data"]["profile"] = {
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"is_active": user.is_active,
|
||||
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||
}
|
||||
|
||||
# Audit trail entries by/about the user (bounded to keep payloads sane)
|
||||
audit_entries = (
|
||||
await db.execute(
|
||||
sa_select(AuditLog).where(
|
||||
AuditLog.tenant_id == tid,
|
||||
AuditLog.user_id == uid,
|
||||
).limit(1000)
|
||||
)
|
||||
).scalars().all()
|
||||
export_data["data"]["audit_log"] = [
|
||||
{
|
||||
"action": a.action,
|
||||
"entity_type": a.entity_type,
|
||||
"timestamp": a.timestamp.isoformat() if a.timestamp else None,
|
||||
}
|
||||
for a in audit_entries
|
||||
]
|
||||
|
||||
# Notifications addressed to the user
|
||||
notifications = (
|
||||
await db.execute(
|
||||
sa_select(Notification).where(
|
||||
Notification.tenant_id == tid,
|
||||
Notification.owner_id == uid,
|
||||
).limit(1000)
|
||||
)
|
||||
).scalars().all()
|
||||
export_data["data"]["notifications"] = [
|
||||
{
|
||||
"id": str(n.id),
|
||||
"type": getattr(n, "type", None),
|
||||
"title": getattr(n, "title", None),
|
||||
"created_at": n.created_at.isoformat() if n.created_at else None,
|
||||
}
|
||||
for n in notifications
|
||||
]
|
||||
|
||||
# ── Plugin-owned categories via contracts ──
|
||||
# For every discovered plugin, resolve its contract (lazy-load) and ask
|
||||
# it to contribute its DSAR categories. Inactive/absent plugins simply
|
||||
# contribute nothing — same semantics as the former per-plugin try/except.
|
||||
registry = get_registry()
|
||||
for plugin_name in registry.list_discovered():
|
||||
contract = get_contract(plugin_name)
|
||||
dsar_collect = getattr(contract, "dsar_collect", None) if contract else None
|
||||
if dsar_collect is None:
|
||||
continue
|
||||
try:
|
||||
categories = await dsar_collect(db, tid, uid)
|
||||
export_data["data"].update(categories)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"DSAR collect failed for plugin '%s' — category skipped",
|
||||
plugin_name,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return export_data
|
||||
|
||||
|
||||
async def _dsar_execute_deletion(db: Any, tenant_id: str, user_id: str) -> dict[str, int]:
|
||||
"""Execute GDPR Art. 17 erasure for a user within one tenant.
|
||||
|
||||
Strategy (respects retention duties):
|
||||
- Plugin-owned personal data (contacts, ...) → erased via each plugin's
|
||||
contract ``dsar_erase()`` — the core must not know plugin internals
|
||||
- Notifications owned by the user → hard delete (core-owned)
|
||||
- User account → deactivate (is_active=False), clear personal fields,
|
||||
scramble password hash and email (keeps FK integrity for audit rows)
|
||||
Returns counters for the audit entry.
|
||||
"""
|
||||
from uuid import UUID as PyUUID
|
||||
|
||||
from sqlalchemy import select as sa_select
|
||||
from sqlalchemy import update as sa_update
|
||||
|
||||
from app.core.audit import log_audit
|
||||
from app.models.notification import Notification
|
||||
from app.models.user import User
|
||||
from app.plugins.builtins.contracts import get_contract
|
||||
from app.plugins.registry import get_registry
|
||||
|
||||
uid = PyUUID(user_id)
|
||||
tid = PyUUID(tenant_id)
|
||||
|
||||
counts: dict[str, int] = {}
|
||||
|
||||
# 1. Plugin-owned erasure via contracts (contacts, ...)
|
||||
registry = get_registry()
|
||||
for plugin_name in registry.list_discovered():
|
||||
contract = get_contract(plugin_name)
|
||||
dsar_erase = getattr(contract, "dsar_erase", None) if contract else None
|
||||
if dsar_erase is None:
|
||||
continue
|
||||
try:
|
||||
plugin_counts = await dsar_erase(db, tid, uid)
|
||||
counts.update(plugin_counts)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"DSAR erase failed for plugin '%s' — counters may be incomplete",
|
||||
plugin_name,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 2. Hard-delete notifications owned by the user
|
||||
notif_result = await db.execute(
|
||||
sa_select(Notification).where(
|
||||
Notification.tenant_id == tid,
|
||||
Notification.owner_id == uid,
|
||||
)
|
||||
)
|
||||
notifications = notif_result.scalars().all()
|
||||
for n in notifications:
|
||||
await db.delete(n)
|
||||
counts["notifications_deleted"] = len(notifications)
|
||||
|
||||
# 3. Anonymize + deactivate the account (FK integrity for audit rows kept)
|
||||
await db.execute(
|
||||
sa_update(User)
|
||||
.where(User.id == uid)
|
||||
.values(
|
||||
email=f"erased.{uid.hex[:16]}@anonymized.invalid",
|
||||
name="[gelöscht gemäß DSGVO Art. 17]",
|
||||
first_name=None,
|
||||
last_name=None,
|
||||
avatar_url=None,
|
||||
password_hash="!dsar-erased",
|
||||
is_active=False,
|
||||
preferences={},
|
||||
)
|
||||
)
|
||||
counts["user_anonymized"] = 1
|
||||
|
||||
# 4. Audit the erasure itself (who/what/when — required by Art. 17 recital)
|
||||
await log_audit(
|
||||
db,
|
||||
tid,
|
||||
user_id,
|
||||
"dsar_erasure",
|
||||
"user",
|
||||
uid,
|
||||
{"target_user": user_id, **counts},
|
||||
)
|
||||
return counts
|
||||
|
||||
|
||||
async def process_dsar(
|
||||
ctx: dict[str, Any],
|
||||
*,
|
||||
user_id: str,
|
||||
tenant_id: str,
|
||||
request_type: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Process a GDPR Data Subject Access Request (DSAR).
|
||||
|
||||
ARQ worker function registered as "process_dsar".
|
||||
|
||||
request_type:
|
||||
- "access": collect all data categories (Art. 15/20) and post a system
|
||||
message that the export is ready (served via the existing dsgvo-export
|
||||
endpoint).
|
||||
- "deletion": execute Art. 17 erasure (soft-delete contacts, hard-delete
|
||||
notifications, anonymize+deactivate account) and audit it.
|
||||
- "rectification": post a system message asking admins to handle the
|
||||
correction manually.
|
||||
|
||||
Returns a summary dict for the job result.
|
||||
"""
|
||||
import logging
|
||||
import uuid as uuid_module
|
||||
|
||||
from app.core.db import get_worker_session_factory
|
||||
from app.core.notifications import post_system_message
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
tid = uuid_module.UUID(tenant_id)
|
||||
uid = uuid_module.UUID(user_id)
|
||||
|
||||
factory = get_worker_session_factory()
|
||||
async with factory() as db:
|
||||
try:
|
||||
if request_type == "access":
|
||||
data = await _dsar_collect_user_data(db, tenant_id, user_id)
|
||||
await db.commit()
|
||||
categories = list(data.get("data", {}).keys())
|
||||
await post_system_message(
|
||||
db,
|
||||
tid,
|
||||
uid,
|
||||
"dsar_access_ready",
|
||||
"DSGVO-Auskunft bereit",
|
||||
f"Datenkategorien: {', '.join(categories)}",
|
||||
severity="info",
|
||||
)
|
||||
await db.commit()
|
||||
logger.info("DSAR access processed for user %s", user_id)
|
||||
return {"type": request_type, "status": "completed", "categories": categories}
|
||||
|
||||
if request_type == "deletion":
|
||||
counts = await _dsar_execute_deletion(db, tenant_id, user_id)
|
||||
await db.commit()
|
||||
await post_system_message(
|
||||
db,
|
||||
tid,
|
||||
uid,
|
||||
"dsar_deletion_done",
|
||||
"DSGVO-Löschung ausgeführt",
|
||||
f"Kontakten soft-gelöscht: {counts.get('contacts_soft_deleted', 0)}; Konto anonymisiert.",
|
||||
severity="info",
|
||||
)
|
||||
await db.commit()
|
||||
logger.info("DSAR deletion executed for user %s: %s", user_id, counts)
|
||||
return {"type": request_type, "status": "completed", **counts}
|
||||
|
||||
if request_type == "rectification":
|
||||
await post_system_message(
|
||||
db,
|
||||
tid,
|
||||
uid,
|
||||
"dsar_rectification_requested",
|
||||
"DSGVO-Berichtigung angefordert",
|
||||
f"Manuelle Bearbeitung für User {user_id} erforderlich.",
|
||||
severity="warning",
|
||||
)
|
||||
await db.commit()
|
||||
logger.info("DSAR rectification requested for user %s", user_id)
|
||||
return {"type": request_type, "status": "queued_for_manual_handling"}
|
||||
|
||||
logger.warning("Unknown DSAR request_type '%s' for user %s", request_type, user_id)
|
||||
return {"type": request_type, "status": "unknown_type"}
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
|
||||
register_job("process_dsar", process_dsar)
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import uuid as uuid_mod
|
||||
|
||||
from fastapi import Request, status
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
@@ -72,6 +74,15 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
if request.headers.get("upgrade", "").lower() == "websocket":
|
||||
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)
|
||||
|
||||
if request.method in self.UNSAFE_METHODS:
|
||||
# 1. Origin header check
|
||||
origin = request.headers.get("origin")
|
||||
@@ -137,3 +148,97 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
pass
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
class AuditMiddleware(BaseHTTPMiddleware):
|
||||
"""Safety-net audit trail for ALL successful mutating requests.
|
||||
|
||||
AGENTS.md requires every mutation to produce an audit entry. Explicit
|
||||
``log_audit`` calls in routes/services remain the detail layer (entity ids,
|
||||
change diffs); this middleware guarantees a baseline entry for mutations
|
||||
that lack one, marked with ``source=middleware`` in ``details``.
|
||||
|
||||
Best-effort by design: audit failures never break the request.
|
||||
"""
|
||||
|
||||
_MUTATING = {"POST", "PUT", "PATCH", "DELETE"}
|
||||
_SKIP_PREFIXES = (
|
||||
"/api/v1/auth",
|
||||
"/api/v1/health",
|
||||
"/api/v1/errors",
|
||||
"/api/v1/audit",
|
||||
"/api/v1/external",
|
||||
)
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
|
||||
if request.method not in self._MUTATING:
|
||||
return response
|
||||
if response.status_code < 200 or response.status_code >= 300:
|
||||
return response
|
||||
path = request.url.path
|
||||
if any(path.startswith(p) for p in self._SKIP_PREFIXES):
|
||||
return response
|
||||
|
||||
try:
|
||||
await self._write_entry(request, path, response.status_code)
|
||||
except Exception:
|
||||
logging.getLogger(__name__).debug(
|
||||
"AuditMiddleware: failed to write baseline entry for %s %s", request.method, path
|
||||
)
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def _derive_entity_type(path: str) -> str:
|
||||
"""Derive an entity_type from the second URL segment."""
|
||||
parts = [p for p in path.split("/") if p]
|
||||
# /api/v1/<resource>/... -> resource; singularize naive trailing 's'
|
||||
resource = parts[2] if len(parts) > 2 and parts[0] == "api" and parts[1] == "v1" else (parts[0] if parts else "unknown")
|
||||
return resource[:-1] if len(resource) > 3 and resource.endswith("s") else resource
|
||||
|
||||
async def _write_entry(self, request: Request, path: str, status_code: int) -> None:
|
||||
from app.core.audit import log_audit
|
||||
from app.core.auth import get_redis, get_session_data
|
||||
from app.core.db import create_db_session
|
||||
|
||||
# Attribute via the Redis session (same source as CSRFMiddleware) —
|
||||
# FastAPI dependencies run after middleware, so request.state is empty here.
|
||||
settings = get_settings()
|
||||
session_id = request.cookies.get(settings.session_cookie_name)
|
||||
if not session_id:
|
||||
return # unauthenticated — nothing to attribute
|
||||
redis = get_redis()
|
||||
session_data = await get_session_data(redis, session_id)
|
||||
if not session_data:
|
||||
return
|
||||
tenant_raw = session_data.get("tenant_id")
|
||||
user_raw = session_data.get("user_id")
|
||||
if not tenant_raw:
|
||||
return
|
||||
|
||||
action_map = {"POST": "create", "PATCH": "update", "PUT": "update", "DELETE": "delete"}
|
||||
entity_id: uuid_mod.UUID | None = None
|
||||
parts = [p for p in path.split("/") if p]
|
||||
if parts and re.fullmatch(r"[0-9a-fA-F-]{36}", parts[-1]):
|
||||
try:
|
||||
entity_id = uuid_mod.UUID(parts[-1])
|
||||
except ValueError:
|
||||
entity_id = None
|
||||
|
||||
async with create_db_session(uuid_mod.UUID(tenant_raw)) as db:
|
||||
await log_audit(
|
||||
db,
|
||||
uuid_mod.UUID(tenant_raw),
|
||||
uuid_mod.UUID(user_raw) if user_raw else None,
|
||||
action_map.get(request.method, request.method.lower()),
|
||||
self._derive_entity_type(path),
|
||||
entity_id,
|
||||
changes={
|
||||
"source": "middleware",
|
||||
"method": request.method,
|
||||
"path": path,
|
||||
"status": status_code,
|
||||
},
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
+15
-1
@@ -212,7 +212,21 @@ def _json_payload(payload: dict[str, 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)
|
||||
if name:
|
||||
return name
|
||||
|
||||
@@ -54,6 +54,8 @@ CORE_PERMISSIONS: list[dict[str, str]] = [
|
||||
{"key": "taxes:write", "label": "Taxes: Write", "category": "core", "module": "taxes"},
|
||||
{"key": "currencies:read", "label": "Currencies: Read", "category": "core", "module": "currencies"},
|
||||
{"key": "currencies:write", "label": "Currencies: Write", "category": "core", "module": "currencies"},
|
||||
{"key": "custom_fields:read", "label": "Custom Fields: Read", "category": "core", "module": "custom_fields"},
|
||||
{"key": "custom_fields:write", "label": "Custom Fields: Write", "category": "core", "module": "custom_fields"},
|
||||
{"key": "import_export:read", "label": "Import/Export: Read", "category": "core", "module": "import_export"},
|
||||
{"key": "import_export:write", "label": "Import/Export: Write", "category": "core", "module": "import_export"},
|
||||
{"key": "workspaces:read", "label": "Workspaces: Read", "category": "core", "module": "workspaces"},
|
||||
@@ -62,9 +64,25 @@ CORE_PERMISSIONS: list[dict[str, str]] = [
|
||||
{"key": "workspaces:delete", "label": "Workspaces: Delete", "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": "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"},
|
||||
# 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,
|
||||
# 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
|
||||
# manifests at activation time. They are NOT hardcoded here (P0-4 fix).
|
||||
]
|
||||
@@ -72,50 +90,12 @@ CORE_PERMISSIONS: list[dict[str, str]] = [
|
||||
|
||||
# ── Core field definitions for field-level permissions ──
|
||||
CORE_FIELD_DEFINITIONS: list[dict[str, str]] = [
|
||||
# ── Contact fields ──
|
||||
{"module": "contacts", "field": "firstname", "label": "First Name", "sensitivity": "normal"},
|
||||
{"module": "contacts", "field": "surname", "label": "Last Name", "sensitivity": "normal"},
|
||||
{"module": "contacts", "field": "displayname", "label": "Display Name", "sensitivity": "normal"},
|
||||
{"module": "contacts", "field": "name", "label": "Name", "sensitivity": "normal"},
|
||||
{"module": "contacts", "field": "email_1", "label": "Email 1", "sensitivity": "normal"},
|
||||
{"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 ──
|
||||
# Audit P1/P2 (contact field definitions): all contacts:* field
|
||||
# definitions moved to the ContactsPlugin manifest (field_definitions=)
|
||||
# so the plugin fully owns its field structure. The core keeps only
|
||||
# genuinely core-owned fields (users). Plugin field definitions are
|
||||
# registered at activation time via register_field_definitions().
|
||||
# ── User fields (core-owned) ──
|
||||
{"module": "users", "field": "email", "label": "Email", "sensitivity": "normal"},
|
||||
{"module": "users", "field": "name", "label": "Name", "sensitivity": "normal"},
|
||||
{"module": "users", "field": "role", "label": "Role", "sensitivity": "normal"},
|
||||
@@ -222,6 +202,18 @@ class PermissionRegistry:
|
||||
self._field_definitions[plugin_name] = field_defs
|
||||
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]]:
|
||||
"""Return all registered field definitions."""
|
||||
result = list(self._core_field_definitions)
|
||||
|
||||
@@ -431,7 +431,12 @@ def check_permission(resolved: dict[str, Any], required: str) -> bool:
|
||||
return True
|
||||
|
||||
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
|
||||
for d in denied:
|
||||
|
||||
@@ -173,12 +173,15 @@ def _derive_policy_from_sensitivity(
|
||||
if field_name in entity_policy:
|
||||
return dict(entity_policy[field_name])
|
||||
|
||||
# Try to get sensitivity from permission registry (lazy import to avoid
|
||||
# circular dependencies at module load time).
|
||||
# Try to get sensitivity from the permission registry (lazy import to
|
||||
# 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:
|
||||
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:
|
||||
sensitivity = fd.get("sensitivity", "normal")
|
||||
return dict(_SENSITIVITY_DEFAULTS.get(sensitivity, _ALL_ALLOWED))
|
||||
|
||||
@@ -29,6 +29,14 @@ class ServiceContainer:
|
||||
"""Check if a service is registered."""
|
||||
return name in self._services
|
||||
|
||||
def remove(self, name: str) -> None:
|
||||
"""Remove a service registration (no-op if absent).
|
||||
|
||||
Used by plugin deactivation hooks to clean up services they
|
||||
registered during activation.
|
||||
"""
|
||||
self._services.pop(name, None)
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Initialize core services."""
|
||||
if self._initialized:
|
||||
|
||||
+40
-12
@@ -501,11 +501,38 @@ async def save_with_metadata(
|
||||
}
|
||||
|
||||
|
||||
async def get_file_metadata_async(path: str) -> dict[str, Any]:
|
||||
"""Awaitable variant of :func:`get_file_metadata` (ARCH-052).
|
||||
|
||||
Safe to call from inside a running event loop — never creates a
|
||||
nested one. For local storage this is plain filesystem access; for
|
||||
S3 and other async backends the backend's ``exists()`` is awaited.
|
||||
"""
|
||||
backend = get_storage_backend()
|
||||
if isinstance(backend, LocalStorage):
|
||||
full_path = backend._full_path(path)
|
||||
if not os.path.exists(full_path):
|
||||
return {"size": None, "modified": None, "exists": False}
|
||||
stat = os.stat(full_path)
|
||||
return {
|
||||
"size": stat.st_size,
|
||||
"modified": stat.st_mtime,
|
||||
"exists": True,
|
||||
}
|
||||
# S3 or other async backends — await the backend directly
|
||||
if not await backend.exists(path):
|
||||
return {"size": None, "modified": None, "exists": False}
|
||||
return {"size": None, "modified": None, "exists": True}
|
||||
|
||||
|
||||
def get_file_metadata(path: str) -> dict[str, Any]:
|
||||
"""Read metadata of a stored file without loading its content.
|
||||
|
||||
Works with the *local* storage backend. For S3, use the S3 client
|
||||
``stat_object`` API directly.
|
||||
Works with the *local* storage backend without touching the event
|
||||
loop. For S3 and other async-only backends this drives the check
|
||||
through ``asyncio.run``; calling it from inside a running event loop
|
||||
raises ``RuntimeError`` — use :func:`get_file_metadata_async` there
|
||||
instead (ARCH-052).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
@@ -530,14 +557,15 @@ def get_file_metadata(path: str) -> dict[str, Any]:
|
||||
"modified": stat.st_mtime,
|
||||
"exists": True,
|
||||
}
|
||||
# S3 or other backends — fall back to exists() check
|
||||
import asyncio as _asyncio
|
||||
|
||||
loop = _asyncio.new_event_loop()
|
||||
# Async-only backend outside a running loop is fine; inside one we
|
||||
# must never build a nested event loop.
|
||||
try:
|
||||
exists = loop.run_until_complete(backend.exists(path))
|
||||
if not exists:
|
||||
return {"size": None, "modified": None, "exists": False}
|
||||
return {"size": None, "modified": None, "exists": True}
|
||||
finally:
|
||||
loop.close()
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
pass
|
||||
else:
|
||||
raise RuntimeError(
|
||||
"get_file_metadata() cannot be used with async storage backends "
|
||||
"inside a running event loop — use get_file_metadata_async()"
|
||||
)
|
||||
return asyncio.run(get_file_metadata_async(path))
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -121,11 +121,14 @@ class TriggerDispatcher:
|
||||
"""Query DB for active automations matching *event_name* and dispatch."""
|
||||
from app.core.db import get_session_factory
|
||||
from app.plugins.builtins.contracts import get_contract
|
||||
# None-check FIRST — accessing attributes on the contract before the
|
||||
# check crashed with AttributeError when automation was inactive
|
||||
# (ARCH-029/041).
|
||||
automation_contract = get_contract("automation")
|
||||
AutomationDefinition = automation_contract.Automation # noqa: N806
|
||||
if automation_contract is None:
|
||||
logger.debug("Automation plugin not available — trigger skipped")
|
||||
return
|
||||
AutomationDefinition = automation_contract.Automation # noqa: N806
|
||||
|
||||
factory = get_session_factory()
|
||||
tenant_id = payload.get("tenant_id")
|
||||
|
||||
@@ -7,7 +7,8 @@ import logging
|
||||
import uuid
|
||||
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.event_bus import EventBus, get_event_bus
|
||||
@@ -48,7 +49,12 @@ async def _dispatch_event(payload: dict[str, Any]) -> None:
|
||||
stmt = select(Webhook).where(
|
||||
Webhook.tenant_id == tenant_id,
|
||||
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)
|
||||
webhooks = list(result.scalars().all())
|
||||
|
||||
+19
-22
@@ -170,12 +170,7 @@ async def on_startup(ctx: dict[str, Any]) -> None:
|
||||
if search_contract is not None:
|
||||
factory = async_session
|
||||
async with factory() as db:
|
||||
# auto_register_providers is not exposed via contract yet;
|
||||
# use the contract's get_search_registry to access providers
|
||||
from app.plugins.builtins.unified_search.provider_registry import (
|
||||
auto_register_providers,
|
||||
)
|
||||
await auto_register_providers(db)
|
||||
await search_contract.auto_register_providers(db)
|
||||
logger.info("Search providers registered for worker")
|
||||
else:
|
||||
logger.debug("Unified search plugin not available — skipping provider registration")
|
||||
@@ -350,8 +345,10 @@ async def cleanup_audit_log_job(ctx: dict[str, Any]) -> None:
|
||||
Runs daily to prevent the audit_log table from growing indefinitely.
|
||||
Iterates per-tenant for RLS compliance.
|
||||
"""
|
||||
from sqlalchemy import text as sa_text, delete as sa_delete
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import delete as sa_delete
|
||||
from sqlalchemy import text as sa_text
|
||||
|
||||
from app.core.db import get_worker_session_factory
|
||||
from app.models.audit import AuditLog
|
||||
@@ -362,7 +359,7 @@ async def cleanup_audit_log_job(ctx: dict[str, Any]) -> None:
|
||||
tenant_result = await db.execute(sa_text("SELECT id FROM tenants"))
|
||||
tenant_ids = [row[0] for row in tenant_result]
|
||||
|
||||
cutoff = datetime.utcnow() - timedelta(days=365)
|
||||
cutoff = datetime.now(UTC) - timedelta(days=365)
|
||||
total_deleted = 0
|
||||
for tenant_id in tenant_ids:
|
||||
await db.execute(
|
||||
@@ -393,11 +390,12 @@ async def cleanup_trash_job(ctx: dict[str, Any]) -> None:
|
||||
Runs daily to clean up the trash. Iterates per-tenant for RLS compliance.
|
||||
Default retention: 90 days in trash before permanent deletion.
|
||||
"""
|
||||
from sqlalchemy import text as sa_text, delete as sa_delete
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import delete as sa_delete
|
||||
from sqlalchemy import text as sa_text
|
||||
|
||||
from app.core.db import get_worker_session_factory
|
||||
from app.models.contact import Contact
|
||||
from app.models.entity_attachment import EntityAttachment
|
||||
|
||||
factory = get_worker_session_factory()
|
||||
@@ -406,7 +404,7 @@ async def cleanup_trash_job(ctx: dict[str, Any]) -> None:
|
||||
tenant_result = await db.execute(sa_text("SELECT id FROM tenants"))
|
||||
tenant_ids = [row[0] for row in tenant_result]
|
||||
|
||||
cutoff = datetime.utcnow() - timedelta(days=90)
|
||||
cutoff = datetime.now(UTC) - timedelta(days=90)
|
||||
total_deleted = 0
|
||||
|
||||
for tenant_id in tenant_ids:
|
||||
@@ -415,16 +413,9 @@ async def cleanup_trash_job(ctx: dict[str, Any]) -> None:
|
||||
{"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
|
||||
# (Contacts trash cleanup moved to the contacts plugin:
|
||||
# cleanup_contacts_trash — audit P2, no core->contacts import)
|
||||
result = await db.execute(
|
||||
sa_delete(EntityAttachment).where(
|
||||
EntityAttachment.deleted_at.is_not(None),
|
||||
@@ -489,6 +480,12 @@ class WorkerSettings:
|
||||
_wrap_cron_with_lock("cleanup_trash", cleanup_trash_job, ttl_seconds=300),
|
||||
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).
|
||||
# Function comes from the knowledge plugin via the job registry.
|
||||
cron(
|
||||
|
||||
+53
-4
@@ -7,7 +7,7 @@ import uuid
|
||||
from typing import Any
|
||||
|
||||
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.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -17,8 +17,9 @@ from app.core.db import get_db, set_tenant_context, set_user_context
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Known write-permission modules — used by require_write() to check
|
||||
# specific permissions instead of broad wildcards like *:write
|
||||
# Legacy fallback list — used by require_write() only when the permission
|
||||
# registry is not initialized. The live source of truth is generated from
|
||||
# the registry (see _get_write_permissions, ARCH-022).
|
||||
_WRITE_PERMISSIONS = [
|
||||
"users:write",
|
||||
"roles:write",
|
||||
@@ -35,6 +36,30 @@ _WRITE_PERMISSIONS = [
|
||||
]
|
||||
|
||||
|
||||
def _get_write_permissions() -> list[str]:
|
||||
"""Return all known ``module:write`` permission keys (ARCH-022).
|
||||
|
||||
Generated from the permission registry so plugin write permissions are
|
||||
picked up automatically without touching this file. Falls back to the
|
||||
static legacy list when the registry is unavailable/uninitialized.
|
||||
"""
|
||||
try:
|
||||
from app.core.permission_registry import get_permission_registry
|
||||
|
||||
registry = get_permission_registry()
|
||||
if getattr(registry, "_initialized", False):
|
||||
perms = [
|
||||
entry["key"]
|
||||
for entry in registry.get_all()
|
||||
if entry["key"].endswith(":write")
|
||||
]
|
||||
if perms:
|
||||
return sorted(perms)
|
||||
except Exception:
|
||||
pass
|
||||
return list(_WRITE_PERMISSIONS)
|
||||
|
||||
|
||||
async def get_redis_dep() -> aioredis.Redis:
|
||||
"""FastAPI dependency for Redis client."""
|
||||
return get_redis()
|
||||
@@ -261,7 +286,7 @@ async def require_write(
|
||||
# Check via permission system for specific write permissions
|
||||
from app.core.permissions import check_permission
|
||||
|
||||
for perm in _WRITE_PERMISSIONS:
|
||||
for perm in _get_write_permissions():
|
||||
if check_permission(current_user, perm):
|
||||
return current_user
|
||||
|
||||
@@ -357,6 +382,30 @@ async def get_current_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):
|
||||
"""FastAPI dependency factory: require that a plugin is active.
|
||||
|
||||
|
||||
+67
-24
@@ -22,8 +22,12 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
from app.config import get_settings # noqa: E402
|
||||
from app.core.db import close_engine, get_engine # noqa: E402
|
||||
from app.core.error_codes import ApiError, build_error_response # noqa: E402
|
||||
from app.core.middleware import CSRFMiddleware, SecurityHeadersMiddleware # noqa: E402
|
||||
from app.core.error_codes import ERROR_CODES, ApiError, build_error_response # noqa: E402
|
||||
from app.core.middleware import ( # noqa: E402
|
||||
AuditMiddleware,
|
||||
CSRFMiddleware,
|
||||
SecurityHeadersMiddleware,
|
||||
)
|
||||
from app.core.monitoring import record_error, record_request # noqa: E402
|
||||
from app.core.rate_limit import GeneralRateLimitMiddleware # noqa: E402
|
||||
from app.core.resilience import CircuitBreakerMiddleware # noqa: E402
|
||||
@@ -36,16 +40,14 @@ from app.routes import ( # noqa: E402
|
||||
attachments,
|
||||
audit,
|
||||
auth,
|
||||
compliance,
|
||||
backups,
|
||||
bank_accounts,
|
||||
contact_folder_permissions,
|
||||
contact_folders,
|
||||
contacts,
|
||||
compliance,
|
||||
currencies,
|
||||
custom_field_definitions,
|
||||
custom_fields,
|
||||
dashboard,
|
||||
dashboards,
|
||||
delegations,
|
||||
entity_history,
|
||||
entity_permissions,
|
||||
errors,
|
||||
@@ -54,12 +56,12 @@ from app.routes import ( # noqa: E402
|
||||
health,
|
||||
import_export,
|
||||
metrics,
|
||||
miniapps,
|
||||
notifications,
|
||||
outbox,
|
||||
owner_transfer,
|
||||
permission_templates,
|
||||
plugins,
|
||||
delegations,
|
||||
policies,
|
||||
roles,
|
||||
saved_filters,
|
||||
@@ -216,6 +218,16 @@ async def lifespan(app: FastAPI):
|
||||
registry.initialize(get_migration_engine(), app)
|
||||
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
|
||||
from sqlalchemy import select as sa_select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
@@ -284,22 +296,23 @@ async def lifespan(app: FastAPI):
|
||||
logger.info(f"Plugin {name} is inactive — skipping activation")
|
||||
continue
|
||||
|
||||
# Activate plugin with a FRESH session per plugin to avoid RLS state leakage
|
||||
# RLS fail-closed requires app.current_tenant_id for tenant-table writes.
|
||||
# Plugin activation may fail on duplicate cron job inserts — this is harmless
|
||||
# since cron jobs already exist from previous startups.
|
||||
# Activate plugin ONCE per process (ARCH-002 fix): a fresh session with
|
||||
# the first tenant's RLS context satisfies fail-closed RLS for any
|
||||
# tenant-table writes during activation. Plugins that need per-tenant
|
||||
# data must seed it themselves (e.g. via the default-tenant mechanism).
|
||||
# Calling on_activate once prevents duplicate event listeners, cron
|
||||
# jobs, mini-apps and other contributions at multi-tenant startups.
|
||||
plugin_activated = False
|
||||
for tenant_id in all_tenant_ids:
|
||||
if all_tenant_ids:
|
||||
try:
|
||||
async with async_session() as plugin_db:
|
||||
await set_tenant_context(plugin_db, tenant_id)
|
||||
await set_tenant_context(plugin_db, all_tenant_ids[0])
|
||||
await plugin.on_activate(plugin_db, container, event_bus)
|
||||
await plugin_db.flush()
|
||||
await plugin_db.commit()
|
||||
plugin_activated = True
|
||||
except Exception as exc:
|
||||
logger.warning(f"[STARTUP] Plugin {name} activation issue for tenant {tenant_id}: {exc}")
|
||||
break
|
||||
logger.warning(f"[STARTUP] Plugin {name} activation issue: {exc}")
|
||||
|
||||
if plugin_activated:
|
||||
plugin_record.status = "active"
|
||||
@@ -324,6 +337,22 @@ async def lifespan(app: FastAPI):
|
||||
if plugin and 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)
|
||||
logger.info("Permission registry initialized with %d active plugins", len(active_plugin_names))
|
||||
|
||||
@@ -346,7 +375,7 @@ async def lifespan(app: FastAPI):
|
||||
plugin = registry.get_plugin(name)
|
||||
if plugin:
|
||||
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))
|
||||
|
||||
# Register field definitions from active plugins only
|
||||
@@ -436,7 +465,6 @@ def create_app() -> FastAPI:
|
||||
{"name": "entity-history", "description": "Audit trail and entity change history."},
|
||||
{"name": "import-export", "description": "Bulk import and export of contacts and data."},
|
||||
{"name": "plugins", "description": "Plugin management: list, install, activate, deactivate."},
|
||||
{"name": "ai-copilot", "description": "AI copilot: chat, suggestions, conversation history."},
|
||||
{"name": "workflows", "description": "Workflow definitions, instances, and execution."},
|
||||
{"name": "user-preferences", "description": "Per-user preference settings."},
|
||||
{"name": "currencies", "description": "Currency management for multi-currency support."},
|
||||
@@ -474,6 +502,7 @@ def create_app() -> FastAPI:
|
||||
)
|
||||
app.add_middleware(CSRFMiddleware)
|
||||
app.add_middleware(SecurityHeadersMiddleware)
|
||||
app.add_middleware(AuditMiddleware)
|
||||
app.add_middleware(GeneralRateLimitMiddleware)
|
||||
app.add_middleware(RequestLoggingMiddleware)
|
||||
app.add_middleware(CircuitBreakerMiddleware)
|
||||
@@ -516,6 +545,21 @@ def create_app() -> FastAPI:
|
||||
504: "service_timeout",
|
||||
}
|
||||
code = status_to_code.get(exc.status_code, "internal_error" if exc.status_code >= 500 else "validation_error")
|
||||
# Structured detail passthrough (AGENTS.md): when a route raises
|
||||
# HTTPException with a dict detail containing a machine-readable ``code``,
|
||||
# preserve the structured shape instead of stringifying it.
|
||||
raw_detail = exc.detail
|
||||
if isinstance(raw_detail, dict):
|
||||
inner_code = raw_detail.get("code", code)
|
||||
body = build_error_response(
|
||||
code=inner_code if inner_code in ERROR_CODES else code,
|
||||
detail=raw_detail.get("detail") or str(raw_detail),
|
||||
trace_id=trace_id,
|
||||
)
|
||||
# Preserve the full structured detail as a nested object so clients
|
||||
# can read ``resp.json()["detail"]["code"]``.
|
||||
body["detail"] = raw_detail
|
||||
else:
|
||||
body = build_error_response(
|
||||
code=code,
|
||||
detail=str(exc.detail) if exc.detail else None,
|
||||
@@ -544,13 +588,12 @@ def create_app() -> FastAPI:
|
||||
app.include_router(groups.router)
|
||||
app.include_router(tenants.router)
|
||||
app.include_router(notifications.router)
|
||||
from app.routes.companies import router as companies_router
|
||||
app.include_router(companies_router)
|
||||
app.include_router(contacts.router)
|
||||
app.include_router(contact_folders.router)
|
||||
app.include_router(contact_folder_permissions.router)
|
||||
# NOTE: contacts/companies/contact-folders routes are plugin-owned now
|
||||
# (Block B1) and mounted via the manifest.routes mechanism below with
|
||||
# require_active_plugin("contacts") protection.
|
||||
app.include_router(entity_permissions.router)
|
||||
app.include_router(dashboard.router)
|
||||
app.include_router(dashboards.router)
|
||||
app.include_router(entity_history.router)
|
||||
app.include_router(import_export.router)
|
||||
app.include_router(plugins.router)
|
||||
@@ -569,7 +612,6 @@ def create_app() -> FastAPI:
|
||||
app.include_router(compliance.router)
|
||||
app.include_router(owner_transfer.router)
|
||||
app.include_router(custom_field_definitions.router)
|
||||
app.include_router(custom_fields.router)
|
||||
app.include_router(saved_filters.router)
|
||||
app.include_router(saved_views.router)
|
||||
app.include_router(webhooks.router)
|
||||
@@ -582,6 +624,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(outbox.router)
|
||||
app.include_router(api_tokens.router)
|
||||
app.include_router(approvals.router)
|
||||
app.include_router(miniapps.router)
|
||||
|
||||
# ── Register plugin routes for all discovered plugins ──
|
||||
# Routes are registered at app creation time so OpenAPI docs are complete.
|
||||
|
||||
+23
-2
@@ -6,18 +6,24 @@ from app.models.audit import AuditLog
|
||||
from app.models.auth import ApiToken, PasswordResetToken
|
||||
from app.models.backup import Backup
|
||||
from app.models.bank_account import BankAccount
|
||||
from app.models.consumer_inbox import ConsumerInbox
|
||||
from app.models.compliance import ComplianceIncident
|
||||
from app.models.contact import Contact, ContactPerson
|
||||
from app.models.consumer_inbox import ConsumerInbox
|
||||
|
||||
# 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_merge import ContactMergeHistory
|
||||
from app.models.currency import Currency
|
||||
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_permission import EntityPermission
|
||||
from app.models.entity_policy import EntityPolicy
|
||||
from app.models.group import Group, UserGroup
|
||||
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.permission_delegation import PermissionDelegation
|
||||
from app.models.permission_template import PermissionTemplate
|
||||
@@ -75,6 +81,7 @@ __all__ = [
|
||||
"WorkflowInstance",
|
||||
"WorkflowStepHistory",
|
||||
"SavedView",
|
||||
"Dashboard",
|
||||
]
|
||||
from app.models.entity_attachment import EntityAttachment # noqa: F401
|
||||
from app.models.workspace import ( # noqa: F401
|
||||
@@ -83,3 +90,17 @@ from app.models.workspace import ( # noqa: F401
|
||||
WorkspaceUser,
|
||||
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}")
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
"""AI Conversation and Message models — tenant-scoped with RLS."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import ForeignKey, Index, Integer, 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
|
||||
from app.models.owned_mixin import OwnedMixin
|
||||
|
||||
|
||||
class AIConversation(Base, TenantMixin, OwnedMixin):
|
||||
"""AI Copilot conversation thread — tenant-scoped."""
|
||||
|
||||
__tablename__ = "ai_conversations"
|
||||
__table_args__ = (Index("ix_ai_conversations_tenant_user", "tenant_id", "user_id"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False, default="Untitled")
|
||||
context: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, nullable=False)
|
||||
|
||||
|
||||
class AIMessage(Base, TenantMixin, OwnedMixin):
|
||||
"""Individual messages within an AI conversation — user input, AI response, actions."""
|
||||
|
||||
__tablename__ = "ai_messages"
|
||||
__table_args__ = (Index("ix_ai_messages_tenant_conversation", "tenant_id", "conversation_id"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
conversation_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
ForeignKey("ai_conversations.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
role: Mapped[str] = mapped_column(String(20), nullable=False) # user, assistant, system
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
proposed_actions: Mapped[list[dict[str, Any]] | None] = mapped_column(JSONB, nullable=True)
|
||||
executed_action: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
|
||||
execution_result: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
|
||||
message_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
+1
-2
@@ -11,13 +11,12 @@ from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
from app.models.owned_mixin import OwnedMixin
|
||||
from sqlalchemy.dialects.postgresql import TSVECTOR
|
||||
|
||||
# Re-export EntityHistory as DeletionLog for backward compatibility.
|
||||
# Tests import DeletionLog from app.models.audit and use entity_snapshot attribute.
|
||||
|
||||
+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
|
||||
('company' or 'person'). ContactPerson is a 1:N child for
|
||||
ansprechpartner (company employees / contact persons).
|
||||
The physical home of Contact/ContactPerson moved to the ContactsPlugin:
|
||||
app/plugins.builtins.contacts.models
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
_EXPORTS = {"Contact", "ContactPerson"}
|
||||
|
||||
|
||||
class Contact(Base, TenantMixin, OwnedMixin):
|
||||
"""Unified contact entity — can be a company or a person.
|
||||
def __getattr__(name: str):
|
||||
if name in _EXPORTS:
|
||||
from app.plugins.builtins.contacts.models import Contact, ContactPerson
|
||||
|
||||
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"
|
||||
)
|
||||
return {"Contact": Contact, "ContactPerson": ContactPerson}[name]
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
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
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return sorted(_EXPORTS | {"__getattr__", "__dir__"})
|
||||
|
||||
@@ -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
|
||||
)
|
||||
+47
-1
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String, Text, func
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
@@ -77,3 +77,49 @@ class EventOutbox(Base):
|
||||
failed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True,
|
||||
)
|
||||
|
||||
|
||||
class OutboxDelivery(Base):
|
||||
"""Per-consumer delivery status for an event_outbox row (Migration 0075).
|
||||
|
||||
Tracks whether each consumer successfully processed an event; an event is
|
||||
only 'published' when all mandatory deliveries succeed.
|
||||
"""
|
||||
|
||||
__tablename__ = "outbox_deliveries"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"event_id", "consumer_name",
|
||||
name="uq_outbox_deliveries_event_consumer",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True,
|
||||
server_default=func.gen_random_uuid(),
|
||||
)
|
||||
event_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
ForeignKey("event_outbox.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
consumer_name: Mapped[str] = mapped_column(String(150), nullable=False)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(30), nullable=False, server_default="pending",
|
||||
)
|
||||
attempt_count: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, server_default="0",
|
||||
)
|
||||
next_attempt_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True,
|
||||
)
|
||||
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
processed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||
)
|
||||
|
||||
@@ -53,6 +53,9 @@ class SystemSettings(Base, TenantMixin, OwnedMixin):
|
||||
theme_border_radius: Mapped[str] = mapped_column(String(20), nullable=False, default="0.5rem")
|
||||
# Backup configuration
|
||||
backup_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||
backup_interval: Mapped[str] = mapped_column(String(20), nullable=False, default="daily", server_default="daily")
|
||||
backup_retention_days: Mapped[int] = mapped_column(Integer, nullable=False, default=7, server_default="7")
|
||||
backup_destination: Mapped[str] = mapped_column(String(20), nullable=False, default="local", server_default="local")
|
||||
# Automation plugin settings (JSONB)
|
||||
automation_config: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
# Retention policy overrides (JSONB) — compliance module
|
||||
|
||||
@@ -12,7 +12,6 @@ from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, SoftDeleteMixin, TimestampMixin
|
||||
from app.models.owned_mixin import OwnedMixin
|
||||
|
||||
|
||||
class User(Base, TimestampMixin, SoftDeleteMixin):
|
||||
|
||||
+66
-2
@@ -53,8 +53,10 @@ class BasePlugin(ABC):
|
||||
) -> None:
|
||||
"""Called when the plugin is activated.
|
||||
|
||||
Override to register event listeners and prepare runtime state.
|
||||
Default implementation subscribes to events listed in the manifest.
|
||||
Default implementation subscribes to events listed in the manifest and
|
||||
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:
|
||||
handler = self._make_event_handler(event_name)
|
||||
@@ -62,6 +64,8 @@ class BasePlugin(ABC):
|
||||
event_bus.subscribe(event_name, handler)
|
||||
self._container = service_container
|
||||
|
||||
self._register_manifest_miniapps()
|
||||
|
||||
async def on_deactivate(
|
||||
self, db: AsyncSession, service_container: ServiceContainer, event_bus: EventBus
|
||||
) -> None:
|
||||
@@ -80,6 +84,57 @@ class BasePlugin(ABC):
|
||||
|
||||
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:
|
||||
"""Called when the plugin is uninstalled (before data tables are dropped).
|
||||
|
||||
@@ -97,6 +152,15 @@ class BasePlugin(ABC):
|
||||
"""
|
||||
return []
|
||||
|
||||
async def register_event_handlers(self, event_bus: EventBus) -> None:
|
||||
"""Register event handlers for the background worker (ARCH-038 hook).
|
||||
|
||||
The worker calls this on every active plugin at startup so plugins
|
||||
can subscribe to events even when the web process is separate.
|
||||
Default: no-op. Override to subscribe handlers.
|
||||
"""
|
||||
return None
|
||||
|
||||
# ─── Job Modules ───
|
||||
|
||||
def get_job_modules(self) -> list[str]:
|
||||
|
||||
@@ -12,7 +12,6 @@ from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
from app.models.owned_mixin import OwnedMixin
|
||||
from pgvector.sqlalchemy import Vector
|
||||
|
||||
|
||||
class AgentMemory(Base, TenantMixin, OwnedMixin):
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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):
|
||||
@@ -28,6 +33,26 @@ class AgentMemoryPlugin(BasePlugin):
|
||||
"agent_memory:read",
|
||||
"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,
|
||||
author="LeoCRM Team",
|
||||
min_app_version="1.0.0",
|
||||
|
||||
@@ -22,6 +22,7 @@ from app.plugins.builtins.ai_assistant.schemas import (
|
||||
ExternalAgentRequest,
|
||||
ExternalAgentResponse,
|
||||
)
|
||||
from app.plugins.builtins.ai_assistant.services import stream_chat_comm as stream_chat
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -120,13 +121,18 @@ async def run_agent_external(
|
||||
}
|
||||
|
||||
# Run the agent via streaming chat (non-streaming mode)
|
||||
from app.plugins.builtins.ai_assistant.services import stream_chat_comm
|
||||
|
||||
full_response = ""
|
||||
async with get_db() as stream_db:
|
||||
await set_tenant_context(stream_db, tenant_id)
|
||||
async for chunk in stream_chat(
|
||||
stream_db, session, agent, data.message, user_context, tenant_id
|
||||
stream_db,
|
||||
session.id,
|
||||
agent,
|
||||
data.message,
|
||||
user_context,
|
||||
tenant_id,
|
||||
uuid.UUID(current_user["user_id"]),
|
||||
):
|
||||
if chunk.startswith("data: ") and chunk != "data: [DONE]\n\n":
|
||||
try:
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
-- 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 (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -68,35 +71,3 @@ CREATE TABLE IF NOT EXISTS ai_agents (
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
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 (
|
||||
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_tenant ON ai_chat_folders(tenant_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
|
||||
-- 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;
|
||||
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Dual-path convergence (Gate B): add compliance columns that Alembic
|
||||
-- migration 0119 adds on the core path. Idempotent so both install paths
|
||||
-- converge to the identical schema.
|
||||
ALTER TABLE ai_providers ADD COLUMN IF NOT EXISTS region VARCHAR(20) NOT NULL DEFAULT 'unknown';
|
||||
ALTER TABLE ai_providers ADD COLUMN IF NOT EXISTS hosting_type VARCHAR(30) NOT NULL DEFAULT 'cloud';
|
||||
ALTER TABLE ai_providers ADD COLUMN IF NOT EXISTS dpa_status VARCHAR(20) NOT NULL DEFAULT 'none';
|
||||
ALTER TABLE ai_providers ADD COLUMN IF NOT EXISTS retention_policy TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE ai_providers ADD COLUMN IF NOT EXISTS training_on_customer_data BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
ALTER TABLE ai_providers ADD COLUMN IF NOT EXISTS transfer_notice TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE ai_providers ADD COLUMN IF NOT EXISTS allowed_data_classes JSONB NOT NULL DEFAULT '[]'::jsonb;
|
||||
@@ -42,7 +42,7 @@ class AIAssistantPlugin(BasePlugin):
|
||||
),
|
||||
],
|
||||
events=[],
|
||||
migrations=["0001_initial.sql", "0002_folders_attachments.sql"],
|
||||
migrations=["0001_initial.sql", "0002_folders_attachments.sql", "0003_sort_order.sql", "0004_compliance_fields.sql"],
|
||||
permissions=[
|
||||
"ai:read",
|
||||
"ai:write",
|
||||
@@ -52,13 +52,14 @@ class AIAssistantPlugin(BasePlugin):
|
||||
],
|
||||
is_core=True,
|
||||
menu_items=[
|
||||
FrontendMenuItem(label_key='nav.aiAssistant', label='KI Assistent', path='/ai-assistant', icon='Bot', order=90),
|
||||
FrontendMenuItem(label_key='nav.aiAssistant', label='KI Assistent', path='/ai-assistant', icon='Bot', order=90, permission='ai:read'),
|
||||
],
|
||||
page_routes=[
|
||||
FrontendPageRoute(path='/ai-assistant', component='@/pages/AIAssistant', protected=True),
|
||||
FrontendPageRoute(path='/ai-assistant', component='@/pages/AIAssistant', protected=True, permission='ai:read'),
|
||||
],
|
||||
settings_pages=[
|
||||
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",
|
||||
min_app_version="1.0.0",
|
||||
|
||||
@@ -234,6 +234,10 @@ async def stream_chat_comm(
|
||||
tools.append(crm_api_tool)
|
||||
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
|
||||
params, model_id = await build_litellm_params(db, agent, messages, tenant_id)
|
||||
|
||||
@@ -290,8 +294,17 @@ async def stream_chat_comm(
|
||||
except json.JSONDecodeError:
|
||||
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)
|
||||
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"
|
||||
else:
|
||||
result = await execute_tool_call(tool, tool_args, user_context)
|
||||
|
||||
@@ -165,8 +165,8 @@ async def get_open_tasks_handler(arguments: dict[str, Any], context: dict[str, A
|
||||
|
||||
from app.plugins.builtins.calendar.contracts import get_contract as get_calendar_contract
|
||||
_cal = get_calendar_contract()
|
||||
calendar_entry = _cal.calendar_entry
|
||||
calendar_entry_link = _cal.calendar_entry_link
|
||||
calendar_entry = _cal.CalendarEntry
|
||||
calendar_entry_link = _cal.CalendarEntryLink
|
||||
|
||||
db, tenant_id, _ = await _get_db_and_tenant(context)
|
||||
entity_type = arguments["entity_type"]
|
||||
|
||||
@@ -69,10 +69,12 @@ async def push_suggestion(user_id: str, suggestion: dict[str, Any]) -> None:
|
||||
# Post suggestion to Communication (I-WORK-PROACTIVE)
|
||||
try:
|
||||
import uuid as uuid_mod
|
||||
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
from app.core.db import get_worker_session_factory
|
||||
from app.plugins.builtins.contracts import get_contract_registry
|
||||
from app.plugins.builtins.kommunikation.models import CommConversation
|
||||
from sqlalchemy import select as sa_select
|
||||
from app.core.db import get_worker_session_factory
|
||||
komm = get_contract_registry().get("kommunikation")
|
||||
if komm:
|
||||
factory = get_worker_session_factory()
|
||||
@@ -274,8 +276,8 @@ async def gather_context(
|
||||
# Upcoming calendar events
|
||||
from app.plugins.builtins.calendar.contracts import get_contract as get_calendar_contract
|
||||
_cal = get_calendar_contract()
|
||||
calendar_entry = _cal.calendar_entry
|
||||
calendar_entry_link = _cal.calendar_entry_link
|
||||
calendar_entry = _cal.CalendarEntry
|
||||
calendar_entry_link = _cal.CalendarEntryLink
|
||||
|
||||
now = datetime.now(UTC)
|
||||
event_result = await db.execute(
|
||||
@@ -389,8 +391,8 @@ async def gather_context(
|
||||
# Upcoming events
|
||||
from app.plugins.builtins.calendar.contracts import get_contract as get_calendar_contract
|
||||
_cal = get_calendar_contract()
|
||||
calendar_entry = _cal.calendar_entry
|
||||
calendar_entry_link = _cal.calendar_entry_link
|
||||
calendar_entry = _cal.CalendarEntry
|
||||
calendar_entry_link = _cal.CalendarEntryLink
|
||||
|
||||
now = datetime.now(UTC)
|
||||
event_result = await db.execute(
|
||||
|
||||
@@ -62,7 +62,10 @@ class AIUIControlPlugin(BasePlugin):
|
||||
from app.plugins.builtins.contracts import get_contract_registry
|
||||
get_contract_registry().unregister(self.manifest.name)
|
||||
|
||||
await super().on_deactivate(db, service_container, event_bus)
|
||||
# Remove the WebSocket manager BEFORE super() so that event handlers
|
||||
# being unsubscribed can no longer reach it (ARCH-044).
|
||||
if service_container.has("ai_ui_control_ws"):
|
||||
service_container.remove("ai_ui_control_ws")
|
||||
logger.info("AI UI Control WebSocket manager removed")
|
||||
|
||||
await super().on_deactivate(db, service_container, event_bus)
|
||||
|
||||
@@ -55,7 +55,8 @@ async def send_agent_message(
|
||||
|
||||
# 2. Create a kommunikation message in a dedicated agent room
|
||||
try:
|
||||
from app.plugins.builtins.kommunikation.contracts import CommConversation as Room, CommMessage as Message
|
||||
from app.plugins.builtins.kommunikation.contracts import CommConversation as Room
|
||||
from app.plugins.builtins.kommunikation.contracts import CommMessage as Message
|
||||
|
||||
# Find or create the agent-to-agent room
|
||||
room_name = f"agent:{from_agent_id}:{target_agent.id}"
|
||||
|
||||
@@ -15,7 +15,7 @@ from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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 (
|
||||
AgentDefinition,
|
||||
AgentRun,
|
||||
@@ -118,8 +118,13 @@ async def list_agents(
|
||||
offset: int = Query(0, ge=0),
|
||||
current_user: dict[str, Any] = Depends(get_current_user),
|
||||
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"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
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,
|
||||
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(
|
||||
items=[_agent_to_response(a) for a in items],
|
||||
total=total,
|
||||
@@ -166,14 +179,14 @@ async def list_tools(
|
||||
)
|
||||
|
||||
registry = get_tool_registry()
|
||||
tools = registry.list_tools()
|
||||
tools = registry.list_for_api()
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"id": t.get("id", t.get("name", "")),
|
||||
"id": t.get("name", ""),
|
||||
"name": t.get("name", ""),
|
||||
"description": t.get("description", ""),
|
||||
"plugin": t.get("plugin", ""),
|
||||
"plugin": t.get("plugin_name", ""),
|
||||
}
|
||||
for t in tools
|
||||
],
|
||||
@@ -619,6 +632,7 @@ async def stream_agent_run(
|
||||
in real-time as the agent processes.
|
||||
"""
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.ai.agent_stream import stream_react_loop
|
||||
from app.plugins.builtins.ai_assistant.contracts import get_tool_registry
|
||||
|
||||
@@ -650,6 +664,7 @@ async def stream_agent_run(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
agent_run_id=aid,
|
||||
user_permissions=current_user, # F01: enforce allowlist+permission at execution time
|
||||
),
|
||||
media_type="text/event-stream",
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ Safety features:
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
@@ -37,12 +38,12 @@ async def run_agent(
|
||||
3. Infinite loop: same tool 5x consecutively (handled in ReAct loop)
|
||||
4. Budget limit: cumulative cost_usd
|
||||
"""
|
||||
from app.plugins.builtins.ai_assistant.contracts import get_tool_registry
|
||||
from app.plugins.builtins.automation.models import (
|
||||
AgentDefinition,
|
||||
AgentRun,
|
||||
AgentRunStep,
|
||||
)
|
||||
from app.plugins.builtins.ai_assistant.contracts import get_tool_registry
|
||||
|
||||
factory = get_session_factory()
|
||||
|
||||
@@ -110,9 +111,9 @@ async def run_agent(
|
||||
try:
|
||||
from app.plugins.builtins.contracts import get_contract
|
||||
mail_contract = get_contract("mail")
|
||||
if mail_contract and hasattr(mail_contract, "get_recent_mails"):
|
||||
if mail_contract and hasattr(mail_contract, "Mail"):
|
||||
from sqlalchemy import select as _select
|
||||
from app.plugins.builtins.mail.models import Mail
|
||||
Mail = mail_contract.Mail
|
||||
async with factory() as db:
|
||||
mail_q = await db.execute(
|
||||
_select(Mail)
|
||||
@@ -167,7 +168,7 @@ async def run_agent(
|
||||
perm_ctx = await resolve_agent_permissions(
|
||||
db=db,
|
||||
tenant_id=agent.tenant_id,
|
||||
user_id=agent.created_by or uuid_mod.uuid4(),
|
||||
user_id=agent.created_by or uuid.uuid4(),
|
||||
agent_definition=agent,
|
||||
)
|
||||
|
||||
@@ -259,6 +260,7 @@ async def run_agent(
|
||||
timeout_seconds=max_duration,
|
||||
require_approval=bool(getattr(agent, "require_approval", False)),
|
||||
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
|
||||
)
|
||||
@@ -379,23 +381,16 @@ async def run_agent(
|
||||
komm = get_contract_registry().get("kommunikation")
|
||||
if komm:
|
||||
async with factory() as db:
|
||||
# Find or create agent conversation room
|
||||
from app.plugins.builtins.contracts import get_contract as _get_contract
|
||||
_komm_contract = _get_contract("kommunikation")
|
||||
from app.plugins.builtins.kommunikation.models import CommConversation
|
||||
from sqlalchemy import select as sa_select
|
||||
# Find or create agent conversation room via contract
|
||||
# (find_locked_room_id matches create_plugin_room semantics)
|
||||
room_title = f"Agent: {agent.name}"
|
||||
existing = await db.execute(
|
||||
sa_select(CommConversation).where(
|
||||
CommConversation.tenant_id == agent.tenant_id,
|
||||
CommConversation.title == room_title,
|
||||
CommConversation.is_locked.is_(True),
|
||||
CommConversation.locked_by == "automation",
|
||||
CommConversation.deleted_at.is_(None),
|
||||
conv_id = await komm.find_locked_room_id(
|
||||
db=db,
|
||||
tenant_id=agent.tenant_id,
|
||||
plugin_name="automation",
|
||||
title=room_title,
|
||||
)
|
||||
)
|
||||
conv = existing.scalar_one_or_none()
|
||||
if not conv:
|
||||
if not conv_id:
|
||||
room = await komm.create_plugin_room(
|
||||
db=db,
|
||||
tenant_id=agent.tenant_id,
|
||||
@@ -405,8 +400,6 @@ async def run_agent(
|
||||
participant_type="agent",
|
||||
)
|
||||
conv_id = uuid.UUID(room["conversation_id"])
|
||||
else:
|
||||
conv_id = conv.id
|
||||
|
||||
# Post result as message with action_card block
|
||||
status = result_data.get("status", "unknown")
|
||||
|
||||
@@ -63,6 +63,31 @@ class AutomationContract:
|
||||
# ─── agent_comm ───
|
||||
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
|
||||
def get_function(cls, name: str):
|
||||
"""Return a callable exposed by this contract, or None if absent."""
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Dual-path convergence (Gate B): create the ReAct step-tracking table
|
||||
-- that Alembic migration 0121 creates on the core path, add the Phase-F
|
||||
-- columns from 0122, and apply the RLS policy from 0129/0136. Idempotent
|
||||
-- so both install paths converge to the identical schema.
|
||||
CREATE TABLE IF NOT EXISTS automation_agent_run_steps (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
agent_run_id UUID NOT NULL REFERENCES automation_agent_runs(id) ON DELETE CASCADE,
|
||||
step_number INTEGER NOT NULL,
|
||||
thought TEXT,
|
||||
action VARCHAR(255),
|
||||
action_input JSONB,
|
||||
observation TEXT,
|
||||
cost_usd FLOAT NOT NULL DEFAULT 0.0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_agent_run_steps_run ON automation_agent_run_steps(tenant_id, agent_run_id);
|
||||
|
||||
ALTER TABLE automation_agent_definitions ADD COLUMN IF NOT EXISTS temperature FLOAT NOT NULL DEFAULT 0.3;
|
||||
ALTER TABLE automation_agent_definitions ADD COLUMN IF NOT EXISTS max_tokens INTEGER NOT NULL DEFAULT 1000;
|
||||
ALTER TABLE automation_agent_definitions ADD COLUMN IF NOT EXISTS max_steps INTEGER NOT NULL DEFAULT 20;
|
||||
ALTER TABLE automation_agent_definitions ADD COLUMN IF NOT EXISTS trace_mode VARCHAR(20) NOT NULL DEFAULT 'standard';
|
||||
ALTER TABLE automation_agent_definitions ADD COLUMN IF NOT EXISTS skill_ids JSONB NOT NULL DEFAULT '[]'::jsonb;
|
||||
ALTER TABLE automation_agent_definitions ADD COLUMN IF NOT EXISTS trigger_config JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
ALTER TABLE automation_agent_definitions ADD COLUMN IF NOT EXISTS ai_use_case_metadata JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
|
||||
-- RLS matching migrations 0129 + 0136 (current_tenant_id variant)
|
||||
ALTER TABLE automation_agent_run_steps ENABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS tenant_isolation ON automation_agent_run_steps;
|
||||
CREATE POLICY tenant_isolation ON automation_agent_run_steps
|
||||
USING (tenant_id::text = current_setting('app.current_tenant_id', true));
|
||||
@@ -19,6 +19,7 @@ from app.plugins.manifest import (
|
||||
FrontendMenuItem,
|
||||
FrontendPageRoute,
|
||||
FrontendSettingsPage,
|
||||
MiniAppContribution,
|
||||
PluginManifest,
|
||||
PluginRouteDef,
|
||||
)
|
||||
@@ -38,7 +39,7 @@ class AutomationPlugin(BasePlugin):
|
||||
"Define AI agents with LLM models and tools, create event/schedule/manual "
|
||||
"automations with conditions and actions, schedule cron jobs, and track execution logs."
|
||||
),
|
||||
dependencies=[],
|
||||
dependencies=["mail"],
|
||||
routes=[
|
||||
PluginRouteDef(
|
||||
path="/api/v1/automation",
|
||||
@@ -62,7 +63,26 @@ class AutomationPlugin(BasePlugin):
|
||||
"mail.received",
|
||||
"workflow.timeout",
|
||||
],
|
||||
migrations=["0001_initial.sql", "0002_agent_subtasks.sql", "0003_skill_definitions.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=[
|
||||
"automation:read",
|
||||
"automation:write",
|
||||
@@ -82,6 +102,7 @@ class AutomationPlugin(BasePlugin):
|
||||
path="/workflows",
|
||||
icon="Workflow",
|
||||
order=52,
|
||||
permission="workflows:read",
|
||||
),
|
||||
FrontendMenuItem(
|
||||
label_key="nav.importExport",
|
||||
@@ -89,6 +110,16 @@ class AutomationPlugin(BasePlugin):
|
||||
path="/import-export",
|
||||
icon="ArrowUpDown",
|
||||
order=53,
|
||||
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(
|
||||
label_key="nav.dedupMerge",
|
||||
@@ -96,6 +127,7 @@ class AutomationPlugin(BasePlugin):
|
||||
path="/contacts/dedup",
|
||||
icon="Copy",
|
||||
order=54,
|
||||
permission="contacts:read",
|
||||
),
|
||||
FrontendMenuItem(
|
||||
label_key="nav.tags",
|
||||
@@ -103,6 +135,7 @@ class AutomationPlugin(BasePlugin):
|
||||
path="/tags",
|
||||
icon="Tag",
|
||||
order=55,
|
||||
permission="tags:read",
|
||||
),
|
||||
FrontendMenuItem(
|
||||
label_key="nav.activity",
|
||||
@@ -110,28 +143,32 @@ class AutomationPlugin(BasePlugin):
|
||||
path="/activity",
|
||||
icon="Activity",
|
||||
order=56,
|
||||
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=[
|
||||
FrontendPageRoute(
|
||||
path="/automation",
|
||||
component="@/pages/AutomationDashboard",
|
||||
order=50,
|
||||
),
|
||||
FrontendPageRoute(
|
||||
path="/agents",
|
||||
component="@/pages/AgentDashboard",
|
||||
order=51,
|
||||
),
|
||||
FrontendPageRoute(
|
||||
path="/workflows",
|
||||
component="@/pages/Workflows",
|
||||
order=52,
|
||||
permission="workflows:read",
|
||||
),
|
||||
FrontendPageRoute(
|
||||
path="/import-export",
|
||||
component="@/pages/ImportExport",
|
||||
order=53,
|
||||
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=[
|
||||
@@ -215,22 +252,10 @@ class AutomationPlugin(BasePlugin):
|
||||
self._register_workflow_agent_tools()
|
||||
except Exception:
|
||||
logger.exception("Failed to register workflow agent tools")
|
||||
# Register MiniApps from manifest
|
||||
try:
|
||||
from app.plugins.builtins.kommunikation.contracts import get_miniapp_registry
|
||||
registry = get_miniapp_registry()
|
||||
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")
|
||||
# NOTE: Manifest MiniApps are registered by super().on_activate()
|
||||
# (BasePlugin, Phase M1) WITH all fields (permission, component,
|
||||
# settings_schema). The legacy re-registration here dropped those
|
||||
# fields and overwrote the correct entries — removed (M5 fix).
|
||||
# Register own cron jobs from manifest
|
||||
try:
|
||||
await self.register_plugin_contributions(db, self.manifest.name, self.manifest)
|
||||
@@ -239,18 +264,25 @@ class AutomationPlugin(BasePlugin):
|
||||
logger.exception("Failed to register own cron jobs")
|
||||
# Register pre-built agents in DB (if not already present)
|
||||
try:
|
||||
from app.plugins.builtins.automation.models import AgentDefinition
|
||||
from app.plugins.builtins.automation.prebuilt.email_triage_agent import create_email_triage_agent
|
||||
from app.plugins.builtins.automation.prebuilt.contact_enrichment_agent import create_contact_enrichment_agent
|
||||
from app.plugins.builtins.automation.prebuilt.follow_up_agent import create_follow_up_agent
|
||||
from app.plugins.builtins.automation.prebuilt.report_agent import create_report_agent
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
# Get first tenant + admin user for seeding
|
||||
from app.models.user import User
|
||||
from app.models.tenant import Tenant
|
||||
tenant_result = await db.execute(sa_select(Tenant).limit(1))
|
||||
tenant = tenant_result.scalar_one_or_none()
|
||||
# Get system tenant + admin user for seeding (ARCH-043:
|
||||
# deterministic slug lookup instead of arbitrary first row)
|
||||
from app.core.db import get_system_tenant
|
||||
from app.models.user import User, UserTenant
|
||||
from app.plugins.builtins.automation.models import AgentDefinition
|
||||
from app.plugins.builtins.automation.prebuilt.contact_enrichment_agent import (
|
||||
create_contact_enrichment_agent,
|
||||
)
|
||||
from app.plugins.builtins.automation.prebuilt.email_triage_agent import (
|
||||
create_email_triage_agent,
|
||||
)
|
||||
from app.plugins.builtins.automation.prebuilt.follow_up_agent import (
|
||||
create_follow_up_agent,
|
||||
)
|
||||
from app.plugins.builtins.automation.prebuilt.report_agent import create_report_agent
|
||||
|
||||
tenant = await get_system_tenant(db)
|
||||
if tenant:
|
||||
user_result = await db.execute(
|
||||
sa_select(User)
|
||||
@@ -288,15 +320,14 @@ class AutomationPlugin(BasePlugin):
|
||||
def _register_workflow_agent_tools(self) -> None:
|
||||
"""Register I-AW agent tools for starting and inspecting workflows."""
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from app.ai.tool_registry import get_tool_registry
|
||||
registry = get_tool_registry()
|
||||
|
||||
async def _start_workflow_handler(arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Start a workflow by ID."""
|
||||
from app.services.workflow_service import create_instance
|
||||
from app.core.db import get_worker_session_factory
|
||||
from app.services.workflow_service import create_instance
|
||||
workflow_id = arguments.get("workflow_id", "")
|
||||
tenant_id = context.get("tenant_id")
|
||||
user_id = context.get("user_id")
|
||||
@@ -332,8 +363,9 @@ class AutomationPlugin(BasePlugin):
|
||||
async def _check_workflow_status_handler(arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Check the status of a workflow instance."""
|
||||
from sqlalchemy import select
|
||||
from app.models.workflow import WorkflowInstance
|
||||
|
||||
from app.core.db import get_worker_session_factory
|
||||
from app.models.workflow import WorkflowInstance
|
||||
instance_id = arguments.get("instance_id", "")
|
||||
tenant_id = context.get("tenant_id")
|
||||
if not instance_id or not tenant_id:
|
||||
@@ -418,16 +450,16 @@ class AutomationPlugin(BasePlugin):
|
||||
from another plugin's manifest. Uses plugin name prefixing for conflict resolution."""
|
||||
from sqlalchemy import select
|
||||
|
||||
# Get default tenant_id from the first tenant in the DB
|
||||
from app.models.tenant import Tenant
|
||||
# Get system tenant for contributions (ARCH-043: deterministic slug
|
||||
# lookup instead of arbitrary first row)
|
||||
from app.core.db import get_system_tenant
|
||||
from app.plugins.builtins.automation.models import AutomationCronJob
|
||||
from app.plugins.builtins.automation.services import (
|
||||
AgentService,
|
||||
AutomationService,
|
||||
CronJobService,
|
||||
)
|
||||
tenant_result = await db.execute(select(Tenant).limit(1))
|
||||
tenant = tenant_result.scalar_one_or_none()
|
||||
tenant = await get_system_tenant(db)
|
||||
default_tenant_id = tenant.id if tenant else None
|
||||
if default_tenant_id is None:
|
||||
logger.warning("No tenant found — skipping plugin contributions registration")
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
Enriches contact data by searching for related information.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from app.plugins.builtins.automation.models import AgentDefinition
|
||||
|
||||
CONTACT_ENRICHMENT_SYSTEM_PROMPT = """You are a Contact Enrichment Agent for a CRM system.
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
Sorts and prioritizes incoming emails automatically.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from app.plugins.builtins.automation.models import AgentDefinition
|
||||
|
||||
EMAIL_TRIAGE_SYSTEM_PROMPT = """You are an E-Mail Triage Agent for a CRM system.
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
Reminds about and creates follow-up tasks for contacts.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from app.plugins.builtins.automation.models import AgentDefinition
|
||||
|
||||
FOLLOW_UP_SYSTEM_PROMPT = """You are a Follow-up Agent for a CRM system.
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
Generates reports from CRM data using search and API tools.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from app.plugins.builtins.automation.models import AgentDefinition
|
||||
|
||||
REPORT_SYSTEM_PROMPT = """You are a Report Agent for a CRM system.
|
||||
|
||||
@@ -1,60 +1,132 @@
|
||||
"""Tests for the Automation & Agents plugin.
|
||||
|
||||
Uses pytest with async fixtures. Tests use SQLite in-memory database
|
||||
since PostgreSQL may not be available in the dev container.
|
||||
Uses pytest with async fixtures against an ephemeral PostgreSQL database
|
||||
(SQLITE-001 fix) — matches the project convention and exercises the real
|
||||
PGUUID/JSONB column types.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Register ALL plugin models so create_all can resolve cross-plugin FKs
|
||||
# (e.g. entity_attachments.dms_file_id -> files) — same pattern as
|
||||
# scripts/sync_plugin_schema.py.
|
||||
import importlib
|
||||
import os
|
||||
import pkgutil
|
||||
import uuid
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
import app.models # noqa: F401 — registers core models
|
||||
import app.models.outbox # noqa: F401 — event_outbox is NOT re-exported by app.models
|
||||
import app.plugins.builtins as _builtins_pkg
|
||||
from app.core.db import Base
|
||||
from app.plugins.builtins.automation.models import (
|
||||
|
||||
for _importer, _modname, _ispkg in pkgutil.iter_modules(_builtins_pkg.__path__):
|
||||
if not _ispkg:
|
||||
continue
|
||||
try:
|
||||
importlib.import_module(f"app.plugins.builtins.{_modname}.models")
|
||||
except ImportError:
|
||||
pass # plugin without models module
|
||||
except Exception: # pragma: no cover - defensive
|
||||
pass
|
||||
|
||||
from app.plugins.builtins.automation.models import ( # noqa: E402 — after dynamic plugin-model discovery
|
||||
AgentRun,
|
||||
AutomationRun,
|
||||
)
|
||||
from app.plugins.builtins.automation.services import (
|
||||
from app.plugins.builtins.automation.services import ( # noqa: E402 — after dynamic plugin-model discovery
|
||||
AgentService,
|
||||
AutomationService,
|
||||
CronJobService,
|
||||
)
|
||||
|
||||
|
||||
def _ephemeral_db_url() -> str:
|
||||
"""Derive an ephemeral test DB URL from DATABASE_URL/.env.test."""
|
||||
base_url = os.environ.get(
|
||||
"DATABASE_URL",
|
||||
"postgresql+asyncpg://leocrm_test:test123@localhost:5432/leocrm_test",
|
||||
)
|
||||
return f"{base_url.rsplit('/', 1)[0]}/automation_test_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
# ─── Fixtures ───
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Create an in-memory SQLite database for testing."""
|
||||
engine = create_async_engine(
|
||||
"sqlite+aiosqlite:///:memory:",
|
||||
echo=False,
|
||||
)
|
||||
"""Create an ephemeral PostgreSQL database for this test run."""
|
||||
db_url = _ephemeral_db_url()
|
||||
admin_url = db_url.rsplit("/", 1)[0] + "/postgres"
|
||||
|
||||
from sqlalchemy.ext.asyncio import create_async_engine as _cae
|
||||
|
||||
admin_engine = _cae(admin_url, isolation_level="AUTOCOMMIT")
|
||||
async with admin_engine.connect() as conn:
|
||||
await conn.execute(text(f'CREATE DATABASE "{db_url.rsplit("/", 1)[1]}"'))
|
||||
await admin_engine.dispose()
|
||||
|
||||
# Plugin models use the pgvector Vector type — enable the extension in
|
||||
# the fresh database before create_all runs (must connect to the target
|
||||
# DB itself; CREATE EXTENSION has no ON DATABASE clause).
|
||||
ext_engine = _cae(db_url, isolation_level="AUTOCOMMIT")
|
||||
async with ext_engine.connect() as conn:
|
||||
await conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
|
||||
await ext_engine.dispose()
|
||||
|
||||
engine = create_async_engine(db_url, echo=False)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
try:
|
||||
async with async_session() as session:
|
||||
yield session
|
||||
|
||||
finally:
|
||||
await engine.dispose()
|
||||
admin_engine2 = _cae(admin_url, isolation_level="AUTOCOMMIT")
|
||||
async with admin_engine2.connect() as conn:
|
||||
await conn.execute(text(f'DROP DATABASE IF EXISTS "{db_url.rsplit("/", 1)[1]}"'))
|
||||
await admin_engine2.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tenant_id() -> uuid.UUID:
|
||||
return uuid.uuid4()
|
||||
@pytest_asyncio.fixture
|
||||
async def tenant_id(db: AsyncSession) -> uuid.UUID:
|
||||
"""Create a real tenant row — PostgreSQL enforces FKs, unlike SQLite."""
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
tid = uuid.uuid4()
|
||||
db.add(Tenant(id=tid, name="Test Org", slug=f"test-{tid.hex[:8]}"))
|
||||
await db.commit()
|
||||
return tid
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_id() -> uuid.UUID:
|
||||
return uuid.uuid4()
|
||||
@pytest_asyncio.fixture
|
||||
async def user_id(db: AsyncSession, tenant_id: uuid.UUID) -> uuid.UUID:
|
||||
"""Create a real user row belonging to the test tenant."""
|
||||
from app.models.user import User
|
||||
|
||||
uid = uuid.uuid4()
|
||||
db.add(
|
||||
User(
|
||||
id=uid,
|
||||
email=f"test-{uid.hex[:8]}@example.com",
|
||||
name="Test User",
|
||||
password_hash="not-a-real-hash",
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
return uid
|
||||
|
||||
|
||||
# ─── AgentService Tests ───
|
||||
@@ -425,11 +497,17 @@ class TestDryRunMode:
|
||||
assert automation.dry_run is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dry_run_flag_in_run(self, db: AsyncSession, tenant_id: uuid.UUID):
|
||||
async def test_dry_run_flag_in_run(self, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID):
|
||||
"""Test that dry_run flag is stored in AutomationRun."""
|
||||
# PostgreSQL enforces the FK to automations — create a real one first
|
||||
data = {"name": "dry-run-flag", "description": "", "trigger_type": "manual",
|
||||
"trigger_config": {}, "conditions": [], "actions": [],
|
||||
"is_active": True, "dry_run": True}
|
||||
automation = await AutomationService.create(db, tenant_id, data, user_id=user_id)
|
||||
|
||||
run = AutomationRun(
|
||||
tenant_id=tenant_id,
|
||||
automation_id=uuid.uuid4(),
|
||||
automation_id=automation.id,
|
||||
status="dry_run",
|
||||
started_at=datetime.now(UTC),
|
||||
dry_run=True,
|
||||
@@ -475,7 +553,9 @@ class TestRateLimiting:
|
||||
)
|
||||
recent_runs = result.scalar() or 0
|
||||
assert recent_runs == 2
|
||||
assert recent_runs < agent.max_executions_per_hour # 2 < 2 is False, so limit would be hit
|
||||
# With max_executions_per_hour=2 and 2 runs in the window, the limit
|
||||
# is reached — the next execution must be blocked.
|
||||
assert recent_runs >= agent.max_executions_per_hour
|
||||
|
||||
|
||||
# ─── Budget Limit Tests ───
|
||||
@@ -512,7 +592,8 @@ class TestBudgetLimit:
|
||||
.where(AgentRun.agent_id == agent.id)
|
||||
)
|
||||
total_cost = float(cost_result.scalar() or 0.0)
|
||||
assert total_cost == 0.6
|
||||
# FLOAT column accumulates binary rounding (0.6000000000000001)
|
||||
assert total_cost == pytest.approx(0.6)
|
||||
assert total_cost >= agent.budget_limit_usd # 0.6 >= 0.5, budget exceeded
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.plugins.builtins.calendar.models import Calendar, CalendarEntry, CalendarEntryLink
|
||||
from app.plugins.builtins.contracts import get_contract_registry
|
||||
|
||||
@@ -15,6 +20,68 @@ class CalendarContract:
|
||||
CalendarEntry = CalendarEntry
|
||||
CalendarEntryLink = CalendarEntryLink
|
||||
|
||||
@staticmethod
|
||||
async def dsar_collect(
|
||||
db: AsyncSession, tenant_id: Any, user_id: Any
|
||||
) -> dict[str, Any]:
|
||||
"""GDPR Art. 15: collect calendar entries owned by the user."""
|
||||
cal_entries = (
|
||||
await db.execute(
|
||||
select(CalendarEntry).where(
|
||||
CalendarEntry.tenant_id == tenant_id,
|
||||
CalendarEntry.owner_id == user_id,
|
||||
CalendarEntry.deleted_at.is_(None),
|
||||
).limit(1000)
|
||||
)
|
||||
).scalars().all()
|
||||
return {
|
||||
"calendar_entries": [
|
||||
{
|
||||
"id": str(e.id),
|
||||
"title": e.title,
|
||||
"entry_type": e.entry_type,
|
||||
"start_at": e.start_at.isoformat() if e.start_at else None,
|
||||
"end_at": e.end_at.isoformat() if e.end_at else None,
|
||||
}
|
||||
for e in cal_entries
|
||||
]
|
||||
}
|
||||
|
||||
# ─── 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
|
||||
def get_function(cls, name: str):
|
||||
"""Return a callable exposed by this contract, or None if absent."""
|
||||
|
||||
@@ -6,6 +6,7 @@ import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pgvector.sqlalchemy import Vector
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
@@ -13,14 +14,12 @@ from sqlalchemy import (
|
||||
Index,
|
||||
String,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
from app.models.owned_mixin import OwnedMixin
|
||||
from sqlalchemy.dialects.postgresql import TSVECTOR
|
||||
from pgvector.sqlalchemy import Vector
|
||||
|
||||
|
||||
class Calendar(Base, TenantMixin, OwnedMixin):
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from app.plugins.base import BasePlugin
|
||||
from app.plugins.manifest import (
|
||||
FrontendDetailTab,
|
||||
FrontendDashboardWidget,
|
||||
FrontendMenuItem,
|
||||
FrontendPageRoute,
|
||||
PluginManifest,
|
||||
@@ -40,6 +40,18 @@ class CalendarPlugin(BasePlugin):
|
||||
],
|
||||
events=[],
|
||||
migrations=["0001_initial.sql", "0002_add_deleted_at.sql"],
|
||||
dashboard_widgets=[
|
||||
FrontendDashboardWidget(
|
||||
id="calendar_upcoming",
|
||||
label_key="dashboard.calendarUpcoming",
|
||||
label="Upcoming Appointments",
|
||||
component="@/components/dashboard/CalendarUpcomingWidget",
|
||||
icon="Calendar",
|
||||
order=30,
|
||||
col_span=1,
|
||||
permission="calendar:read",
|
||||
),
|
||||
],
|
||||
permissions=[
|
||||
"calendar:read",
|
||||
"calendar:write",
|
||||
@@ -48,14 +60,16 @@ class CalendarPlugin(BasePlugin):
|
||||
"calendar:admin",
|
||||
],
|
||||
menu_items=[
|
||||
FrontendMenuItem(label_key='nav.calendar', label='Kalender', path='/calendar', icon='Calendar', order=20),
|
||||
FrontendMenuItem(label_key='nav.calendar', label='Kalender', path='/calendar', icon='Calendar', order=20, permission='calendar:read'),
|
||||
],
|
||||
page_routes=[
|
||||
FrontendPageRoute(path='/calendar', component='@/pages/Calendar', protected=True),
|
||||
],
|
||||
detail_tabs=[
|
||||
FrontendDetailTab(entity_type='contact', label_key='tabs.calendar', label='Calendar', component='@/components/contact/ContactCalendarTab', icon='Calendar', order=30, 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
|
||||
# frontend — tab removed until implemented (Block I-D).
|
||||
detail_tabs=[],
|
||||
author="LeoCRM Team",
|
||||
min_app_version="1.0.0",
|
||||
hooks=["calendar.before_appointment", "calendar.after_appointment"],
|
||||
|
||||
@@ -21,8 +21,9 @@ from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.audit import log_audit
|
||||
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 (
|
||||
export_entries_to_ics,
|
||||
ics_events_to_entry_data,
|
||||
@@ -167,8 +168,13 @@ async def _check_write_permission(
|
||||
async def list_calendars(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
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"])
|
||||
result = await db.execute(
|
||||
select(Calendar).where(
|
||||
@@ -177,6 +183,13 @@ async def list_calendars(
|
||||
)
|
||||
)
|
||||
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]
|
||||
|
||||
|
||||
@@ -358,8 +371,13 @@ async def list_entries(
|
||||
end: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
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"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
role = current_user.get("role", "viewer")
|
||||
@@ -369,6 +387,14 @@ async def list_entries(
|
||||
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
|
||||
if role != "admin":
|
||||
query = query.where(
|
||||
@@ -1023,5 +1049,3 @@ async def book_resource(
|
||||
"start_at": booking.start_at.isoformat(),
|
||||
"end_at": booking.end_at.isoformat(),
|
||||
}
|
||||
|
||||
from app.core.audit import log_audit
|
||||
@@ -0,0 +1,475 @@
|
||||
"""Public contract for the contacts plugin.
|
||||
|
||||
Exposes the symbols that other core modules and plugins need without
|
||||
importing from internal modules directly (Block C7: dashboard counts).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.visibility import apply_visibility_filter
|
||||
from app.models.contact import Contact
|
||||
from app.plugins.builtins.contracts import get_contract_registry
|
||||
|
||||
|
||||
class ContactsContract:
|
||||
"""Public API surface for the contacts plugin."""
|
||||
|
||||
contract_name = "contacts"
|
||||
|
||||
@staticmethod
|
||||
async def get_counts(
|
||||
db: AsyncSession,
|
||||
tenant_id: Any,
|
||||
user_id: Any,
|
||||
is_system_admin: bool = False,
|
||||
) -> dict[str, int]:
|
||||
"""Return visibility-filtered contact/company/person counts."""
|
||||
queries = []
|
||||
for type_filter in (None, "company", "person"):
|
||||
query = select(func.count(Contact.id)).where(
|
||||
Contact.tenant_id == tenant_id,
|
||||
Contact.deleted_at.is_(None),
|
||||
)
|
||||
if type_filter is not None:
|
||||
query = query.where(Contact.type == type_filter)
|
||||
query = await apply_visibility_filter(
|
||||
db, query, "contact", Contact, user_id, tenant_id, is_system_admin
|
||||
)
|
||||
queries.append(query)
|
||||
|
||||
results = [((await db.execute(q)).scalar() or 0) for q in queries]
|
||||
return {
|
||||
"contacts": results[0],
|
||||
"companies": results[1],
|
||||
"persons": results[2],
|
||||
"total": results[0],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def dsar_collect(
|
||||
db: AsyncSession, tenant_id: Any, user_id: Any
|
||||
) -> dict[str, Any]:
|
||||
"""GDPR Art. 15: collect contact data owned by the user.
|
||||
|
||||
Owned by the contacts plugin — core/jobs.py calls this generically
|
||||
via the contract, it must not know contact internals.
|
||||
"""
|
||||
contacts = (
|
||||
await db.execute(
|
||||
select(Contact).where(
|
||||
Contact.tenant_id == tenant_id,
|
||||
Contact.owner_id == user_id,
|
||||
Contact.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
return {
|
||||
"contacts": [
|
||||
{
|
||||
"id": str(c.id),
|
||||
"type": c.type,
|
||||
"displayname": c.displayname,
|
||||
"email_1": c.email_1,
|
||||
"email_2": c.email_2,
|
||||
}
|
||||
for c in contacts
|
||||
]
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def dsar_erase(
|
||||
db: AsyncSession, tenant_id: Any, user_id: Any
|
||||
) -> dict[str, int]:
|
||||
"""GDPR Art. 17: soft-delete contacts owned by the user.
|
||||
|
||||
Soft-delete via deleted_at (audit history must remain intact — it is
|
||||
a business record, not personal data of the subject; retention
|
||||
policy governs its cleanup).
|
||||
"""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
contacts = (
|
||||
await db.execute(
|
||||
select(Contact).where(
|
||||
Contact.tenant_id == tenant_id,
|
||||
Contact.owner_id == user_id,
|
||||
Contact.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
for c in contacts:
|
||||
c.deleted_at = datetime.now(UTC)
|
||||
return {"contacts_soft_deleted": len(contacts)}
|
||||
|
||||
# ─── Import/Export contribution (W4a, Spec #359) ───
|
||||
# The contacts plugin owns its import/export domain logic; the core
|
||||
# orchestrator resolves formats via the format registry and enforces
|
||||
# the security policy (sensitive filter, tenant scoping, audit).
|
||||
|
||||
IE_COLUMNS = {
|
||||
"contacts": ["firstname", "surname", "email", "phone", "mobile", "function", "department"],
|
||||
"companies": ["name", "industry", "phone", "email", "website"],
|
||||
}
|
||||
IE_TARGET_FIELDS = {
|
||||
"contacts": ["firstname", "surname", "email", "phone", "mobile", "function", "department"],
|
||||
"companies": ["name", "industry", "phone", "email", "website"],
|
||||
}
|
||||
IE_VALIDATORS = {
|
||||
"contacts": {"email": {"type": "email"}},
|
||||
"companies": {"email": {"type": "email"}, "website": {"type": "url"}},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def importexport_entities() -> list[str]:
|
||||
"""Entity types offered by this plugin's import/export."""
|
||||
return ["contacts", "companies"]
|
||||
|
||||
@staticmethod
|
||||
def importexport_formats() -> list[str]:
|
||||
"""File formats this plugin's import/export supports."""
|
||||
return ["csv", "json", "xlsx"]
|
||||
|
||||
@staticmethod
|
||||
def ie_columns(entity_type: str) -> list[str]:
|
||||
return list(ContactsContract.IE_COLUMNS[entity_type])
|
||||
|
||||
@staticmethod
|
||||
def ie_target_fields(entity_type: str) -> list[str]:
|
||||
return list(ContactsContract.IE_TARGET_FIELDS[entity_type])
|
||||
|
||||
@staticmethod
|
||||
def ie_validators(entity_type: str) -> dict[str, dict]:
|
||||
return dict(ContactsContract.IE_VALIDATORS[entity_type])
|
||||
|
||||
@staticmethod
|
||||
def ie_normalize_row(entity_type: str, row: dict[str, str]) -> dict[str, str]:
|
||||
"""Normalize an imported row to unified field names."""
|
||||
if entity_type == "contacts":
|
||||
firstname = (row.get("firstname") or row.get("first_name") or "").strip()
|
||||
surname = (row.get("surname") or row.get("last_name") or "").strip()
|
||||
row["firstname"] = firstname
|
||||
row["surname"] = surname
|
||||
if "email" not in row and "email_address" in row:
|
||||
row["email"] = row["email_address"]
|
||||
if "mobile" not in row and "phone_2" in row:
|
||||
row["mobile"] = row["phone_2"]
|
||||
if "function" not in row and "position" in row:
|
||||
row["function"] = row["position"]
|
||||
return row
|
||||
name = (row.get("name") or row.get("company") or row.get("company_name") or "").strip()
|
||||
row["name"] = name
|
||||
if "email" not in row and "email_address" in row:
|
||||
row["email"] = row["email_address"]
|
||||
if "website" not in row and "url" in row:
|
||||
row["website"] = row["url"]
|
||||
if "website" not in row and "homepage" in row:
|
||||
row["website"] = row["homepage"]
|
||||
return row
|
||||
|
||||
@staticmethod
|
||||
def ie_required(entity_type: str) -> list[str]:
|
||||
"""Required columns enforced by generic validate_row (old semantics)."""
|
||||
return ["name"] if entity_type == "companies" else []
|
||||
|
||||
@staticmethod
|
||||
def ie_row_valid(entity_type: str, row: dict[str, str]) -> tuple[bool, str]:
|
||||
"""Early either-or check for contacts only.
|
||||
|
||||
NOTE: companies' required-name check must NOT happen here —
|
||||
generic validate_row(row, ['name'], validators) must see the row
|
||||
so a missing name AND an invalid email yield two errors (as the
|
||||
original semantics did).
|
||||
"""
|
||||
if entity_type == "contacts":
|
||||
if not row.get("firstname") and not row.get("surname"):
|
||||
return False, "Missing required field: firstname or surname"
|
||||
return True, ""
|
||||
|
||||
@staticmethod
|
||||
async def ie_fetch_rows(
|
||||
db: AsyncSession,
|
||||
tenant_id: Any,
|
||||
entity_type: str,
|
||||
user_id: Any = None,
|
||||
is_system_admin: bool = False,
|
||||
contact_type: str | None = None,
|
||||
search: str | None = None,
|
||||
) -> tuple[list[str], list[dict[str, Any]]]:
|
||||
"""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(
|
||||
Contact.tenant_id == tenant_id,
|
||||
Contact.deleted_at.is_(None),
|
||||
)
|
||||
if entity_type == "companies":
|
||||
q = q.where(Contact.type == "company").order_by(Contact.name)
|
||||
else:
|
||||
if contact_type:
|
||||
q = q.where(Contact.type == contact_type)
|
||||
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:
|
||||
q = await apply_visibility_filter(
|
||||
db, q, "contact", Contact, user_id, tenant_id, is_system_admin
|
||||
)
|
||||
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":
|
||||
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 = [
|
||||
{h: (getattr(c, h, None) or "") for h in export_headers}
|
||||
for c in records
|
||||
]
|
||||
else:
|
||||
# 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 = [
|
||||
{h: (getattr(c, h, None) or "") for h in export_headers}
|
||||
for c in records
|
||||
]
|
||||
return export_headers, rows
|
||||
|
||||
@staticmethod
|
||||
async def ie_persist_row(
|
||||
db: AsyncSession,
|
||||
tenant_id: Any,
|
||||
user_id: Any,
|
||||
entity_type: str,
|
||||
row: dict[str, str],
|
||||
) -> dict[str, Any]:
|
||||
"""Persist one imported row as Contact; returns the serialized record."""
|
||||
from app.core.audit import log_audit
|
||||
from app.services.contact_service import _serialize_contact
|
||||
|
||||
if entity_type == "companies":
|
||||
contact = Contact(
|
||||
tenant_id=tenant_id,
|
||||
type="company",
|
||||
name=row["name"].strip(),
|
||||
displayname=row["name"].strip(),
|
||||
email_1=row.get("email", "").strip() or None,
|
||||
phone_1=row.get("phone", "").strip() or None,
|
||||
website=row.get("website", "").strip() or None,
|
||||
owner_id=user_id,
|
||||
created_by=user_id,
|
||||
updated_by=user_id,
|
||||
)
|
||||
else:
|
||||
contact = Contact(
|
||||
tenant_id=tenant_id,
|
||||
type="person",
|
||||
firstname=row["firstname"].strip() or None,
|
||||
surname=row["surname"].strip() or None,
|
||||
displayname=f"{row['firstname']} {row['surname']}".strip(),
|
||||
email_1=row.get("email", "").strip() or None,
|
||||
phone_1=row.get("phone", "").strip() or None,
|
||||
phone_2=row.get("mobile", "").strip() or None,
|
||||
owner_id=user_id,
|
||||
created_by=user_id,
|
||||
updated_by=user_id,
|
||||
)
|
||||
db.add(contact)
|
||||
await db.flush()
|
||||
await log_audit(
|
||||
db,
|
||||
tenant_id,
|
||||
user_id,
|
||||
"import",
|
||||
"contact",
|
||||
contact.id,
|
||||
changes={
|
||||
"type": entity_type.rstrip("s"),
|
||||
"name": contact.name or f"{contact.firstname} {contact.surname}",
|
||||
},
|
||||
)
|
||||
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
|
||||
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 = ContactsContract()
|
||||
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
|
||||
|
||||
@@ -9,44 +9,170 @@ from __future__ import annotations
|
||||
import logging
|
||||
|
||||
from app.plugins.base import BasePlugin
|
||||
from app.plugins.manifest import PluginManifest
|
||||
from app.plugins.manifest import (
|
||||
FieldDefinition,
|
||||
FrontendDashboardWidget,
|
||||
FrontendMenuItem,
|
||||
FrontendPageRoute,
|
||||
MiniAppContribution,
|
||||
PluginManifest,
|
||||
PluginRouteDef,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ContactsPlugin(BasePlugin):
|
||||
"""Contacts plugin — manages Contact entity lifecycle (models, permissions, restore, history).
|
||||
"""Contacts plugin — owns the full Contact domain (Block B1).
|
||||
|
||||
Routes remain in app/routes/contacts.py as core routes, but entity lifecycle
|
||||
(permissions, entity models, restore, history) is managed through on_activate/on_deactivate.
|
||||
Routes (contacts, companies, contact folders, folder permissions) live in
|
||||
this plugin and are mounted via manifest.routes with
|
||||
require_active_plugin("contacts") protection. Entity lifecycle
|
||||
(permissions, entity models, restore, history) is managed through
|
||||
on_activate/on_deactivate like every other business plugin.
|
||||
"""
|
||||
|
||||
manifest = PluginManifest(
|
||||
name="contacts",
|
||||
version="1.0.0",
|
||||
version="1.1.0",
|
||||
display_name="Contacts",
|
||||
description="Core CRM contacts — persons and companies.",
|
||||
dependencies=[],
|
||||
routes=[], # Routes are registered as core routes in main.py
|
||||
routes=[
|
||||
PluginRouteDef(
|
||||
path="/api/v1/contacts",
|
||||
module="app.plugins.builtins.contacts.routes",
|
||||
router_attr="router",
|
||||
),
|
||||
PluginRouteDef(
|
||||
path="/api/v1/companies",
|
||||
module="app.plugins.builtins.contacts.company_routes",
|
||||
router_attr="router",
|
||||
),
|
||||
PluginRouteDef(
|
||||
path="/api/v1/contact-folders",
|
||||
module="app.plugins.builtins.contacts.folder_routes",
|
||||
router_attr="router",
|
||||
),
|
||||
PluginRouteDef(
|
||||
path="/api/v1/contact-folders",
|
||||
module="app.plugins.builtins.contacts.folder_permission_routes",
|
||||
router_attr="router",
|
||||
),
|
||||
],
|
||||
events=[],
|
||||
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=[
|
||||
FrontendDashboardWidget(
|
||||
id="recent_contacts",
|
||||
label_key="dashboard.recentContacts",
|
||||
label="Recent Contacts",
|
||||
component="@/components/dashboard/RecentContactsWidget",
|
||||
icon="Users",
|
||||
order=10,
|
||||
col_span=2,
|
||||
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=[
|
||||
"contacts:read",
|
||||
"contacts:write",
|
||||
"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,
|
||||
author="LeoCRM Team",
|
||||
min_app_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]:
|
||||
from app.models.contact import Contact
|
||||
from app.models.contact_folder import ContactFolder
|
||||
return {
|
||||
"contact": Contact,
|
||||
"contacts": 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:
|
||||
|
||||
@@ -13,6 +13,7 @@ from typing import Any
|
||||
import redis.asyncio as aioredis
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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.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 (
|
||||
ContactCreate,
|
||||
ContactPersonCreate,
|
||||
@@ -31,7 +35,6 @@ from app.schemas.contact import (
|
||||
ContactUpdate,
|
||||
)
|
||||
from app.services import contact_service, dedup_service
|
||||
from app.services.export_service import export_service
|
||||
|
||||
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)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
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.
|
||||
|
||||
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"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
@@ -84,6 +90,7 @@ async def list_contacts(
|
||||
user_id=user_id,
|
||||
is_system_admin=is_admin,
|
||||
cursor=cursor,
|
||||
workspace_scope=workspace_scope,
|
||||
)
|
||||
|
||||
|
||||
@@ -95,14 +102,20 @@ async def export_contacts(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
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"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
is_admin = current_user.get("is_system_admin", False)
|
||||
csv_data = await export_service.export_contacts_csv(
|
||||
db, tenant_id, contact_type=type, search=search,
|
||||
from app.plugins.builtins.contacts.contracts import ContactsContract
|
||||
|
||||
headers, rows = await ContactsContract.ie_fetch_rows(
|
||||
db, tenant_id, "contacts",
|
||||
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(
|
||||
io.StringIO(csv_data),
|
||||
media_type="text/csv",
|
||||
@@ -316,3 +329,189 @@ async def merge_duplicate_contacts(
|
||||
if not result.success:
|
||||
raise HTTPException(status_code=400, detail=result.error)
|
||||
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}
|
||||
@@ -59,20 +59,34 @@ class ContractRegistry:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._contracts: dict[str, Any] = {}
|
||||
cls._instance._loaded: 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
|
||||
|
||||
# ─── registration ───
|
||||
|
||||
def register(self, plugin_name: str, contract: Any) -> None:
|
||||
"""Register or replace a contract for a plugin."""
|
||||
"""Register or replace a contract for a plugin.
|
||||
|
||||
Clears the unregistered marker so a later deactivation can be
|
||||
distinguished from a fresh lazy-load again (ARCH-014).
|
||||
"""
|
||||
self._unregistered.discard(plugin_name)
|
||||
self._contracts[plugin_name] = contract
|
||||
self._loaded.add(plugin_name)
|
||||
logger.debug("Contract registered for plugin '%s'", plugin_name)
|
||||
|
||||
def unregister(self, plugin_name: str) -> None:
|
||||
"""Remove a contract (e.g. when the plugin is deactivated)."""
|
||||
"""Remove a contract (e.g. when the plugin is deactivated).
|
||||
|
||||
Marks the plugin as explicitly unregistered so later ``get_contract``
|
||||
calls cannot resurrect the contract via lazy-loading (ARCH-014).
|
||||
"""
|
||||
self._contracts.pop(plugin_name, None)
|
||||
self._loaded.discard(plugin_name)
|
||||
self._unregistered.add(plugin_name)
|
||||
|
||||
# ─── lookup ───
|
||||
|
||||
@@ -81,7 +95,30 @@ class ContractRegistry:
|
||||
|
||||
On first access the registry attempts to lazy-load the plugin's
|
||||
``contracts`` module, which will register itself on import.
|
||||
|
||||
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
|
||||
# lazy-loading (ARCH-014) — the deactivated contract must stay gone.
|
||||
if plugin_name in self._unregistered:
|
||||
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]
|
||||
|
||||
@@ -90,6 +127,19 @@ class ContractRegistry:
|
||||
|
||||
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:
|
||||
"""Like :meth:`get_contract` but raise if unavailable."""
|
||||
contract = self.get_contract(plugin_name)
|
||||
@@ -132,6 +182,7 @@ class ContractRegistry:
|
||||
"""Clear all state — for unit tests only."""
|
||||
self._contracts.clear()
|
||||
self._loaded.clear()
|
||||
self._db_inactive.clear()
|
||||
|
||||
|
||||
# ─── module-level helpers ───
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""DMS gemeinsame Helper & Konstanten — BUG-018 God-Object-Split."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
OFFICE_EXTENSIONS = {
|
||||
".docx": "docx",
|
||||
".xlsx": "xlsx",
|
||||
".pptx": "pptx",
|
||||
}
|
||||
|
||||
# Max file size: 100 MB
|
||||
MAX_FILE_SIZE = 100 * 1024 * 1024
|
||||
|
||||
|
||||
def _parse_uuid(val: str, field: str) -> uuid.UUID:
|
||||
try:
|
||||
return uuid.UUID(val)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(
|
||||
400, detail={"detail": f"Invalid {field}", "code": "invalid_id"}
|
||||
) from None
|
||||
|
||||
|
||||
def _file_storage_path(tenant_id: uuid.UUID, file_id: uuid.UUID) -> str:
|
||||
"""Build relative storage path for a file (relative to storage base)."""
|
||||
return f"{tenant_id}/{file_id}"
|
||||
|
||||
|
||||
def _get_file_extension(filename: str) -> str:
|
||||
"""Extract lowercase extension including dot."""
|
||||
return os.path.splitext(filename)[1].lower()
|
||||
|
||||
|
||||
|
||||
def _sanitize_filename(filename: str) -> str:
|
||||
"""Sanitize a filename for safe use in Content-Disposition headers."""
|
||||
import re
|
||||
# Extract basename only (strip any path components)
|
||||
safe = os.path.basename(filename.replace('\\', '/'))
|
||||
# Remove dangerous characters (keep alnum, dot, dash, underscore, space, unicode)
|
||||
safe = re.sub(r'[^a-zA-Z0-9.\-_\u00c0-\u017f\u4e00-\u9fff ]', '_', safe)
|
||||
# Collapse consecutive dots (path traversal prevention)
|
||||
safe = re.sub(r'\.{2,}', '_', safe)
|
||||
# Collapse multiple spaces
|
||||
safe = re.sub(r' {2,}', ' ', safe)
|
||||
# Strip leading dots and whitespace
|
||||
safe = safe.lstrip('.').strip()
|
||||
# Limit length
|
||||
if len(safe) > 200:
|
||||
name, ext = safe.rsplit('.', 1) if '.' in safe[:200] else (safe[:200], '')
|
||||
safe = name[:200] + ('.' + ext if ext else '')
|
||||
return safe or 'file'
|
||||
|
||||
# Blocked file extensions for security
|
||||
BLOCKED_EXTENSIONS = {
|
||||
".exe", ".bat", ".cmd", ".sh", ".jar", ".com", ".scr", ".msi",
|
||||
".dll", ".vbs", ".ps1", ".app", ".bin", ".reg", ".inf",
|
||||
".php", ".py", ".pl", ".asp", ".aspx", ".jsp", ".svg", ".htaccess",
|
||||
".phtml", ".pht", ".cgi", ".cfm", ".erb",
|
||||
}
|
||||
|
||||
# Allowed MIME types for upload validation
|
||||
ALLOWED_MIME_PREFIXES = {
|
||||
"application/pdf",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument",
|
||||
"application/vnd.oasis.opendocument",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.ms-powerpoint",
|
||||
"application/zip",
|
||||
"application/gzip",
|
||||
"application/x-tar",
|
||||
"application/json",
|
||||
"application/xml",
|
||||
"application/rtf",
|
||||
"application/x-7z-compressed",
|
||||
"application/x-rar-compressed",
|
||||
"text/plain",
|
||||
"text/csv",
|
||||
"text/html",
|
||||
"text/markdown",
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/bmp",
|
||||
"image/tiff",
|
||||
"image/x-icon",
|
||||
"audio/",
|
||||
"video/",
|
||||
"application/octet-stream",
|
||||
}
|
||||
|
||||
|
||||
def _is_blocked_filetype(filename: str) -> bool:
|
||||
"""Check if a file has a blocked (dangerous) extension."""
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
return ext in BLOCKED_EXTENSIONS
|
||||
|
||||
|
||||
chunk_size = 1024 * 1024 # 1MB chunks for streaming uploads
|
||||
|
||||
# ─── Folders ───
|
||||
|
||||
# Public stream-chunk constant (original contract name from tests/test_p1_6_dms_streaming.py)
|
||||
CHUNK_SIZE = chunk_size
|
||||
@@ -15,6 +15,40 @@ class DmsContract:
|
||||
DmsFile = DmsFile
|
||||
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
|
||||
def get_function(cls, name: str):
|
||||
"""Return a callable exposed by this contract, or None if absent."""
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
"""DMS Folder-CRUD Routen — extrahiert aus routes.py (BUG-018 God-Object-Split)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
HTTPException,
|
||||
Response,
|
||||
status,
|
||||
)
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.visibility import apply_visibility_filter, check_single_entity_access
|
||||
from app.deps import get_current_user, require_permission, require_workspace_scope
|
||||
from app.plugins.builtins.dms.common import (
|
||||
_parse_uuid,
|
||||
)
|
||||
from app.plugins.builtins.dms.models import File as DmsFile
|
||||
from app.plugins.builtins.dms.models import Folder
|
||||
from app.plugins.builtins.dms.schemas import FolderCreate, FolderUpdate
|
||||
from app.plugins.builtins.permissions.contracts import get_contract as get_perms_contract
|
||||
|
||||
_perms_contract = get_perms_contract()
|
||||
Permission = _perms_contract.Permission
|
||||
|
||||
router = APIRouter(tags=["dms"])
|
||||
|
||||
@router.get("/folders", dependencies=[Depends(require_permission("dms:read"))])
|
||||
async def list_folders(
|
||||
parent_id: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
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).
|
||||
|
||||
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"])
|
||||
|
||||
# Fetch all non-deleted folders for tenant with visibility filter
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
is_system_admin = current_user.get("role") == "admin"
|
||||
query = select(Folder).where(
|
||||
Folder.tenant_id == tenant_id,
|
||||
Folder.deleted_at.is_(None),
|
||||
)
|
||||
query = await apply_visibility_filter(
|
||||
db, query, "dms_folder", Folder, user_id, tenant_id, is_system_admin
|
||||
)
|
||||
result = await db.execute(query)
|
||||
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
|
||||
folder_map: dict[uuid.UUID, dict] = {}
|
||||
for f in all_folders:
|
||||
folder_map[f.id] = {
|
||||
"id": str(f.id),
|
||||
"name": f.name,
|
||||
"parent_id": str(f.parent_id) if f.parent_id else None,
|
||||
"created_by": str(f.created_by),
|
||||
"deleted_at": None,
|
||||
"path": "",
|
||||
"children": [],
|
||||
}
|
||||
|
||||
# Build path for each folder
|
||||
def _build_path(folder_id: uuid.UUID) -> str:
|
||||
if folder_id not in folder_map:
|
||||
return ""
|
||||
f = folder_map[folder_id]
|
||||
if f["parent_id"] and uuid.UUID(f["parent_id"]) in folder_map:
|
||||
parent_path = _build_path(uuid.UUID(f["parent_id"]))
|
||||
return f"{parent_path}/{f['name']}"
|
||||
return f["name"]
|
||||
|
||||
for fid in folder_map:
|
||||
folder_map[fid]["path"] = _build_path(fid)
|
||||
|
||||
# Build tree
|
||||
root_nodes: list[dict] = []
|
||||
target_parent: uuid.UUID | None = None
|
||||
if parent_id is not None:
|
||||
target_parent = _parse_uuid(parent_id, "parent_id")
|
||||
|
||||
for f in all_folders:
|
||||
node = folder_map[f.id]
|
||||
if f.parent_id is not None and f.parent_id in folder_map:
|
||||
folder_map[f.parent_id]["children"].append(node)
|
||||
elif f.parent_id is None:
|
||||
root_nodes.append(node)
|
||||
|
||||
if target_parent is not None:
|
||||
# Return children of specified parent
|
||||
parent_node = folder_map.get(target_parent)
|
||||
if parent_node is None:
|
||||
raise HTTPException(
|
||||
404, detail={"detail": "Parent folder not found", "code": "not_found"}
|
||||
)
|
||||
return parent_node["children"]
|
||||
|
||||
return root_nodes
|
||||
|
||||
|
||||
@router.post("/folders", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("dms:write"))])
|
||||
async def create_folder(
|
||||
body: FolderCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""AC2: POST /api/v1/dms/folders → 201, folder created with path."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
parent_id = _parse_uuid(body.parent_id, "parent_id") if body.parent_id else None
|
||||
|
||||
# Validate parent exists if specified
|
||||
if parent_id is not None:
|
||||
parent_result = await db.execute(
|
||||
select(Folder).where(
|
||||
Folder.id == parent_id,
|
||||
Folder.tenant_id == tenant_id,
|
||||
Folder.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
if parent_result.scalar_one_or_none() is None:
|
||||
raise HTTPException(
|
||||
404, detail={"detail": "Parent folder not found", "code": "not_found"}
|
||||
)
|
||||
|
||||
# Check name uniqueness within same parent (non-deleted)
|
||||
existing = await db.execute(
|
||||
select(Folder).where(
|
||||
Folder.tenant_id == tenant_id,
|
||||
Folder.name == body.name,
|
||||
Folder.parent_id == parent_id if parent_id else Folder.parent_id.is_(None),
|
||||
Folder.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
raise HTTPException(
|
||||
409, detail={"detail": "Folder name already exists", "code": "duplicate"}
|
||||
)
|
||||
|
||||
# Lifecycle hook: dms.folder.before_create
|
||||
from app.core.hooks import do_action
|
||||
await do_action("dms.folder.before_create", body, db=db, tenant_id=tenant_id, user_id=user_id)
|
||||
|
||||
folder = Folder(
|
||||
tenant_id=tenant_id,
|
||||
name=body.name,
|
||||
parent_id=parent_id,
|
||||
created_by=user_id,
|
||||
)
|
||||
db.add(folder)
|
||||
await db.flush()
|
||||
|
||||
# Lifecycle hook: dms.folder.after_create
|
||||
await do_action("dms.folder.after_create", {'id': str(folder.id), 'name': folder.name, 'parent_id': str(folder.parent_id) if folder.parent_id else None}, db=db, tenant_id=tenant_id, user_id=user_id)
|
||||
|
||||
# Build path
|
||||
path = body.name
|
||||
if parent_id is not None:
|
||||
parent_path_result = await db.execute(select(Folder).where(Folder.id == parent_id))
|
||||
parent_folder = parent_path_result.scalar_one_or_none()
|
||||
if parent_folder:
|
||||
# Recursively build path
|
||||
path_parts = [body.name]
|
||||
current = parent_folder
|
||||
while current is not None:
|
||||
path_parts.insert(0, current.name)
|
||||
if current.parent_id is not None:
|
||||
cur_result = await db.execute(
|
||||
select(Folder).where(Folder.id == current.parent_id)
|
||||
)
|
||||
current = cur_result.scalar_one_or_none()
|
||||
else:
|
||||
current = None
|
||||
path = "/".join(path_parts)
|
||||
|
||||
return {
|
||||
"id": str(folder.id),
|
||||
"name": folder.name,
|
||||
"parent_id": str(folder.parent_id) if folder.parent_id else None,
|
||||
"created_by": str(folder.created_by),
|
||||
"deleted_at": None,
|
||||
"path": path,
|
||||
"children": [],
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/folders/{folder_id}", dependencies=[Depends(require_permission("dms:write"))])
|
||||
async def update_folder(
|
||||
folder_id: str,
|
||||
body: FolderUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""AC3: PATCH /api/v1/dms/folders/{id} → 200, rename/move."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
is_system_admin = current_user.get("role") == "admin"
|
||||
fid = _parse_uuid(folder_id, "folder_id")
|
||||
|
||||
result = await db.execute(
|
||||
select(Folder).where(
|
||||
Folder.id == fid,
|
||||
Folder.tenant_id == tenant_id,
|
||||
Folder.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
folder = result.scalar_one_or_none()
|
||||
if folder is None:
|
||||
raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"})
|
||||
|
||||
if not await check_single_entity_access(db, "dms_folder", fid, user_id, tenant_id, "write", is_system_admin):
|
||||
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
|
||||
|
||||
data = body.model_dump(exclude_unset=True)
|
||||
|
||||
if "name" in data and data["name"] is not None:
|
||||
# Check uniqueness if name is changing
|
||||
new_parent_id = folder.parent_id
|
||||
if "parent_id" in data and data["parent_id"] is not None:
|
||||
new_parent_id = _parse_uuid(data["parent_id"], "parent_id")
|
||||
|
||||
dup = await db.execute(
|
||||
select(Folder).where(
|
||||
Folder.tenant_id == tenant_id,
|
||||
Folder.name == data["name"],
|
||||
Folder.id != fid,
|
||||
Folder.parent_id == new_parent_id if new_parent_id else Folder.parent_id.is_(None),
|
||||
Folder.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
if dup.scalar_one_or_none() is not None:
|
||||
raise HTTPException(
|
||||
409, detail={"detail": "Folder name already exists", "code": "duplicate"}
|
||||
)
|
||||
folder.name = data["name"]
|
||||
|
||||
if "parent_id" in data:
|
||||
new_parent = _parse_uuid(data["parent_id"], "parent_id") if data["parent_id"] else None
|
||||
if new_parent is not None:
|
||||
# Validate parent exists and not creating a cycle
|
||||
if new_parent == fid:
|
||||
raise HTTPException(
|
||||
400, detail={"detail": "Cannot move folder into itself", "code": "invalid_move"}
|
||||
)
|
||||
|
||||
parent_result = await db.execute(
|
||||
select(Folder).where(
|
||||
Folder.id == new_parent,
|
||||
Folder.tenant_id == tenant_id,
|
||||
Folder.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
if parent_result.scalar_one_or_none() is None:
|
||||
raise HTTPException(
|
||||
404, detail={"detail": "Parent folder not found", "code": "not_found"}
|
||||
)
|
||||
|
||||
# Check for cycle: ensure new_parent is not a descendant of folder
|
||||
async def _is_descendant(ancestor_id: uuid.UUID, descendant_id: uuid.UUID) -> bool:
|
||||
cur_result = await db.execute(select(Folder).where(Folder.id == descendant_id))
|
||||
cur = cur_result.scalar_one_or_none()
|
||||
while cur is not None and cur.parent_id is not None:
|
||||
if cur.parent_id == ancestor_id:
|
||||
return True
|
||||
p_result = await db.execute(select(Folder).where(Folder.id == cur.parent_id))
|
||||
cur = p_result.scalar_one_or_none()
|
||||
return False
|
||||
|
||||
if await _is_descendant(fid, new_parent):
|
||||
raise HTTPException(
|
||||
400,
|
||||
detail={
|
||||
"detail": "Cannot move folder into its own descendant",
|
||||
"code": "invalid_move",
|
||||
},
|
||||
)
|
||||
|
||||
folder.parent_id = new_parent
|
||||
|
||||
await db.flush()
|
||||
|
||||
# Build path
|
||||
path_parts = [folder.name]
|
||||
current_id = folder.parent_id
|
||||
while current_id is not None:
|
||||
cur_result = await db.execute(select(Folder).where(Folder.id == current_id))
|
||||
cur = cur_result.scalar_one_or_none()
|
||||
if cur is None:
|
||||
break
|
||||
path_parts.insert(0, cur.name)
|
||||
current_id = cur.parent_id
|
||||
path = "/".join(path_parts)
|
||||
|
||||
return {
|
||||
"id": str(folder.id),
|
||||
"name": folder.name,
|
||||
"parent_id": str(folder.parent_id) if folder.parent_id else None,
|
||||
"created_by": str(folder.created_by),
|
||||
"deleted_at": None,
|
||||
"path": path,
|
||||
"children": [],
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/folders/{folder_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("dms:delete"))])
|
||||
async def delete_folder(
|
||||
folder_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""AC4: DELETE /api/v1/dms/folders/{id} → 204, soft-delete with cascade."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
is_system_admin = current_user.get("role") == "admin"
|
||||
fid = _parse_uuid(folder_id, "folder_id")
|
||||
|
||||
result = await db.execute(
|
||||
select(Folder).where(
|
||||
Folder.id == fid,
|
||||
Folder.tenant_id == tenant_id,
|
||||
Folder.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
folder = result.scalar_one_or_none()
|
||||
if folder is None:
|
||||
raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"})
|
||||
|
||||
if not await check_single_entity_access(db, "dms_folder", fid, user_id, tenant_id, "delete", is_system_admin):
|
||||
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
|
||||
|
||||
# Lifecycle hook: dms.folder.before_delete
|
||||
from app.core.hooks import do_action
|
||||
await do_action("dms.folder.before_delete", db=db, tenant_id=tenant_id, user_id=user_id, folder_id=str(fid))
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
now = datetime.now(UTC)
|
||||
|
||||
# Recursively collect all descendant folder IDs
|
||||
all_folder_ids: list[uuid.UUID] = [fid]
|
||||
queue: list[uuid.UUID] = [fid]
|
||||
while queue:
|
||||
current_id = queue.pop(0)
|
||||
children_result = await db.execute(
|
||||
select(Folder).where(
|
||||
Folder.parent_id == current_id,
|
||||
Folder.tenant_id == tenant_id,
|
||||
Folder.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
for child in children_result.scalars().all():
|
||||
all_folder_ids.append(child.id)
|
||||
queue.append(child.id)
|
||||
|
||||
# Soft-delete all folders
|
||||
await db.execute(update(Folder).where(Folder.id.in_(all_folder_ids)).values(deleted_at=now))
|
||||
|
||||
# Soft-delete all files in those folders
|
||||
await db.execute(
|
||||
update(DmsFile)
|
||||
.where(
|
||||
DmsFile.tenant_id == tenant_id,
|
||||
DmsFile.folder_id.in_(all_folder_ids),
|
||||
DmsFile.deleted_at.is_(None),
|
||||
)
|
||||
.values(deleted_at=now)
|
||||
)
|
||||
|
||||
await db.flush()
|
||||
|
||||
# Lifecycle hook: dms.folder.after_delete
|
||||
from app.core.hooks import do_action
|
||||
await do_action("dms.folder.after_delete", db=db, tenant_id=tenant_id, user_id=user_id, folder_id=str(fid))
|
||||
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
# ─── Files ───
|
||||
@@ -4,16 +4,16 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, UniqueConstraint, Text
|
||||
from pgvector.sqlalchemy import Vector
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import TSVECTOR
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
from app.models.owned_mixin import OwnedMixin
|
||||
from pgvector.sqlalchemy import Vector
|
||||
from sqlalchemy.dialects.postgresql import TSVECTOR
|
||||
from typing import Any
|
||||
|
||||
|
||||
class Folder(Base, TenantMixin, OwnedMixin):
|
||||
|
||||
@@ -4,9 +4,9 @@ from __future__ import annotations
|
||||
|
||||
from app.plugins.base import BasePlugin
|
||||
from app.plugins.manifest import (
|
||||
FrontendDetailTab,
|
||||
FrontendMenuItem,
|
||||
FrontendPageRoute,
|
||||
MiniAppContribution,
|
||||
PluginManifest,
|
||||
PluginRouteDef,
|
||||
)
|
||||
@@ -20,6 +20,11 @@ class DmsPlugin(BasePlugin):
|
||||
version="1.0.0",
|
||||
display_name="DMS",
|
||||
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"],
|
||||
routes=[
|
||||
PluginRouteDef(
|
||||
@@ -30,6 +35,25 @@ class DmsPlugin(BasePlugin):
|
||||
],
|
||||
events=[],
|
||||
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=[
|
||||
"dms:read",
|
||||
"dms:write",
|
||||
@@ -38,22 +62,35 @@ class DmsPlugin(BasePlugin):
|
||||
"dms:admin",
|
||||
],
|
||||
menu_items=[
|
||||
FrontendMenuItem(label_key='nav.dms', label='Dateien', path='/dms', icon='FolderOpen', group='Dateien', order=40),
|
||||
FrontendMenuItem(label_key='nav.dms.trash', label='Papierkorb', path='/dms/trash', icon='Trash2', group='Dateien', order=41),
|
||||
FrontendMenuItem(label_key='nav.dms', label='Dateien', path='/dms', icon='FolderOpen', group='Dateien', order=40, permission='dms:read'),
|
||||
FrontendMenuItem(label_key='nav.dms.trash', label='Papierkorb', path='/dms/trash', icon='Trash2', group='Dateien', order=41, permission='dms:read'),
|
||||
],
|
||||
page_routes=[
|
||||
FrontendPageRoute(path='/dms', component='@/pages/Dms', protected=True),
|
||||
FrontendPageRoute(path='/dms/trash', component='@/pages/DmsTrash', protected=True),
|
||||
],
|
||||
detail_tabs=[
|
||||
FrontendDetailTab(entity_type='contact', label_key='tabs.files', label='Dateien', component='@/components/contact/ContactFilesTab', icon='FolderOpen', order=40, permission='dms:read'),
|
||||
FrontendPageRoute(path='/dms', component='@/pages/Dms', protected=True, permission='dms:read'),
|
||||
FrontendPageRoute(path='/dms/trash', component='@/pages/DmsTrash', protected=True, permission='dms:read'),
|
||||
],
|
||||
# BUG (ghost component): ContactFilesTab does not exist in the
|
||||
# frontend — tab removed until implemented (Block I-D).
|
||||
detail_tabs=[],
|
||||
author="LeoCRM Team",
|
||||
min_app_version="1.0.0",
|
||||
hooks=["dms.before_upload"],
|
||||
contract_version="1.0.0",
|
||||
)
|
||||
|
||||
def get_entity_models(self) -> dict[str, type]:
|
||||
"""Entity types owned by DMS for the permission resolver.
|
||||
|
||||
``file`` is a legacy alias still used by the permissions routes.
|
||||
"""
|
||||
from app.plugins.builtins.dms.models import File, Folder
|
||||
|
||||
return {
|
||||
"dms_file": File,
|
||||
"dms_folder": Folder,
|
||||
"file": File,
|
||||
}
|
||||
|
||||
async def on_activate(self, db, service_container, event_bus) -> None:
|
||||
"""Activate plugin: register restore config + history hooks."""
|
||||
await super().on_activate(db, service_container, event_bus)
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Body,
|
||||
Depends,
|
||||
File,
|
||||
Form,
|
||||
@@ -17,25 +15,39 @@ from fastapi import (
|
||||
status,
|
||||
)
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.storage import LocalStorage, get_storage_backend
|
||||
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;
|
||||
# Re-Exports sichern Import- und Patch-Kompatibilitaet
|
||||
# (tests patchen app.plugins.builtins.dms.routes.MAX_FILE_SIZE fuer den Upload).
|
||||
from app.plugins.builtins.dms.common import ( # noqa: F401
|
||||
ALLOWED_MIME_PREFIXES,
|
||||
BLOCKED_EXTENSIONS,
|
||||
CHUNK_SIZE,
|
||||
MAX_FILE_SIZE,
|
||||
OFFICE_EXTENSIONS,
|
||||
_file_storage_path,
|
||||
_get_file_extension,
|
||||
_is_blocked_filetype,
|
||||
_parse_uuid,
|
||||
_sanitize_filename,
|
||||
chunk_size,
|
||||
)
|
||||
from app.plugins.builtins.dms.folders_routes import router as folders_router
|
||||
from app.plugins.builtins.dms.models import File as DmsFile
|
||||
from app.plugins.builtins.dms.models import Folder
|
||||
from app.plugins.builtins.dms.schemas import (
|
||||
BulkDeleteRequest,
|
||||
BulkMoveRequest,
|
||||
FileMetadataResponse,
|
||||
FileUpdate,
|
||||
FolderCreate,
|
||||
FolderUpdate,
|
||||
ShareRemoveRequest,
|
||||
ShareRequest,
|
||||
)
|
||||
from app.plugins.builtins.dms.search_bulk_routes import router as search_bulk_router
|
||||
from app.plugins.builtins.dms.sharing_routes import router as sharing_router
|
||||
from app.plugins.builtins.permissions.contracts import get_contract as get_perms_contract
|
||||
|
||||
# Get Permission model from the permissions contract
|
||||
@@ -45,460 +57,6 @@ Permission = _perms_contract.Permission
|
||||
router = APIRouter(prefix="/api/v1/dms", tags=["dms"])
|
||||
|
||||
# Office file extensions mapped to Collabora file types
|
||||
OFFICE_EXTENSIONS = {
|
||||
".docx": "docx",
|
||||
".xlsx": "xlsx",
|
||||
".pptx": "pptx",
|
||||
}
|
||||
|
||||
# Max file size: 100 MB
|
||||
MAX_FILE_SIZE = 100 * 1024 * 1024
|
||||
|
||||
|
||||
def _parse_uuid(val: str, field: str) -> uuid.UUID:
|
||||
try:
|
||||
return uuid.UUID(val)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(
|
||||
400, detail={"detail": f"Invalid {field}", "code": "invalid_id"}
|
||||
) from None
|
||||
|
||||
|
||||
def _file_storage_path(tenant_id: uuid.UUID, file_id: uuid.UUID) -> str:
|
||||
"""Build relative storage path for a file (relative to storage base)."""
|
||||
return f"{tenant_id}/{file_id}"
|
||||
|
||||
|
||||
def _get_file_extension(filename: str) -> str:
|
||||
"""Extract lowercase extension including dot."""
|
||||
return os.path.splitext(filename)[1].lower()
|
||||
|
||||
|
||||
|
||||
def _sanitize_filename(filename: str) -> str:
|
||||
"""Sanitize a filename for safe use in Content-Disposition headers."""
|
||||
import re
|
||||
# Extract basename only (strip any path components)
|
||||
safe = os.path.basename(filename.replace('\\', '/'))
|
||||
# Remove dangerous characters (keep alnum, dot, dash, underscore, space, unicode)
|
||||
safe = re.sub(r'[^a-zA-Z0-9.\-_\u00c0-\u017f\u4e00-\u9fff ]', '_', safe)
|
||||
# Collapse consecutive dots (path traversal prevention)
|
||||
safe = re.sub(r'\.{2,}', '_', safe)
|
||||
# Collapse multiple spaces
|
||||
safe = re.sub(r' {2,}', ' ', safe)
|
||||
# Strip leading dots and whitespace
|
||||
safe = safe.lstrip('.').strip()
|
||||
# Limit length
|
||||
if len(safe) > 200:
|
||||
name, ext = safe.rsplit('.', 1) if '.' in safe[:200] else (safe[:200], '')
|
||||
safe = name[:200] + ('.' + ext if ext else '')
|
||||
return safe or 'file'
|
||||
|
||||
# Blocked file extensions for security
|
||||
BLOCKED_EXTENSIONS = {
|
||||
".exe", ".bat", ".cmd", ".sh", ".jar", ".com", ".scr", ".msi",
|
||||
".dll", ".vbs", ".ps1", ".app", ".bin", ".reg", ".inf",
|
||||
".php", ".py", ".pl", ".asp", ".aspx", ".jsp", ".svg", ".htaccess",
|
||||
".phtml", ".pht", ".cgi", ".cfm", ".erb",
|
||||
}
|
||||
|
||||
# Allowed MIME types for upload validation
|
||||
ALLOWED_MIME_PREFIXES = {
|
||||
"application/pdf",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument",
|
||||
"application/vnd.oasis.opendocument",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.ms-powerpoint",
|
||||
"application/zip",
|
||||
"application/gzip",
|
||||
"application/x-tar",
|
||||
"application/json",
|
||||
"application/xml",
|
||||
"application/rtf",
|
||||
"application/x-7z-compressed",
|
||||
"application/x-rar-compressed",
|
||||
"text/plain",
|
||||
"text/csv",
|
||||
"text/html",
|
||||
"text/markdown",
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/bmp",
|
||||
"image/tiff",
|
||||
"image/x-icon",
|
||||
"audio/",
|
||||
"video/",
|
||||
"application/octet-stream",
|
||||
}
|
||||
|
||||
|
||||
def _is_blocked_filetype(filename: str) -> bool:
|
||||
"""Check if a file has a blocked (dangerous) extension."""
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
return ext in BLOCKED_EXTENSIONS
|
||||
|
||||
|
||||
chunk_size = 1024 * 1024 # 1MB chunks for streaming uploads
|
||||
|
||||
# ─── Folders ───
|
||||
|
||||
|
||||
@router.get("/folders", dependencies=[Depends(require_permission("dms:read"))])
|
||||
async def list_folders(
|
||||
parent_id: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""AC1: GET /api/v1/dms/folders → 200 + folder tree (recursive)."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
|
||||
# Fetch all non-deleted folders for tenant with visibility filter
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
is_system_admin = current_user.get("role") == "admin"
|
||||
query = select(Folder).where(
|
||||
Folder.tenant_id == tenant_id,
|
||||
Folder.deleted_at.is_(None),
|
||||
)
|
||||
query = await apply_visibility_filter(
|
||||
db, query, "dms_folder", Folder, user_id, tenant_id, is_system_admin
|
||||
)
|
||||
result = await db.execute(query)
|
||||
all_folders = result.scalars().all()
|
||||
|
||||
# Build lookup map
|
||||
folder_map: dict[uuid.UUID, dict] = {}
|
||||
for f in all_folders:
|
||||
folder_map[f.id] = {
|
||||
"id": str(f.id),
|
||||
"name": f.name,
|
||||
"parent_id": str(f.parent_id) if f.parent_id else None,
|
||||
"created_by": str(f.created_by),
|
||||
"deleted_at": None,
|
||||
"path": "",
|
||||
"children": [],
|
||||
}
|
||||
|
||||
# Build path for each folder
|
||||
def _build_path(folder_id: uuid.UUID) -> str:
|
||||
if folder_id not in folder_map:
|
||||
return ""
|
||||
f = folder_map[folder_id]
|
||||
if f["parent_id"] and uuid.UUID(f["parent_id"]) in folder_map:
|
||||
parent_path = _build_path(uuid.UUID(f["parent_id"]))
|
||||
return f"{parent_path}/{f['name']}"
|
||||
return f["name"]
|
||||
|
||||
for fid in folder_map:
|
||||
folder_map[fid]["path"] = _build_path(fid)
|
||||
|
||||
# Build tree
|
||||
root_nodes: list[dict] = []
|
||||
target_parent: uuid.UUID | None = None
|
||||
if parent_id is not None:
|
||||
target_parent = _parse_uuid(parent_id, "parent_id")
|
||||
|
||||
for f in all_folders:
|
||||
node = folder_map[f.id]
|
||||
if f.parent_id is not None and f.parent_id in folder_map:
|
||||
folder_map[f.parent_id]["children"].append(node)
|
||||
elif f.parent_id is None:
|
||||
root_nodes.append(node)
|
||||
|
||||
if target_parent is not None:
|
||||
# Return children of specified parent
|
||||
parent_node = folder_map.get(target_parent)
|
||||
if parent_node is None:
|
||||
raise HTTPException(
|
||||
404, detail={"detail": "Parent folder not found", "code": "not_found"}
|
||||
)
|
||||
return parent_node["children"]
|
||||
|
||||
return root_nodes
|
||||
|
||||
|
||||
@router.post("/folders", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("dms:write"))])
|
||||
async def create_folder(
|
||||
body: FolderCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""AC2: POST /api/v1/dms/folders → 201, folder created with path."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
parent_id = _parse_uuid(body.parent_id, "parent_id") if body.parent_id else None
|
||||
|
||||
# Validate parent exists if specified
|
||||
if parent_id is not None:
|
||||
parent_result = await db.execute(
|
||||
select(Folder).where(
|
||||
Folder.id == parent_id,
|
||||
Folder.tenant_id == tenant_id,
|
||||
Folder.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
if parent_result.scalar_one_or_none() is None:
|
||||
raise HTTPException(
|
||||
404, detail={"detail": "Parent folder not found", "code": "not_found"}
|
||||
)
|
||||
|
||||
# Check name uniqueness within same parent (non-deleted)
|
||||
existing = await db.execute(
|
||||
select(Folder).where(
|
||||
Folder.tenant_id == tenant_id,
|
||||
Folder.name == body.name,
|
||||
Folder.parent_id == parent_id if parent_id else Folder.parent_id.is_(None),
|
||||
Folder.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
raise HTTPException(
|
||||
409, detail={"detail": "Folder name already exists", "code": "duplicate"}
|
||||
)
|
||||
|
||||
# Lifecycle hook: dms.folder.before_create
|
||||
from app.core.hooks import do_action
|
||||
await do_action("dms.folder.before_create", body, db=db, tenant_id=tenant_id, user_id=user_id)
|
||||
|
||||
folder = Folder(
|
||||
tenant_id=tenant_id,
|
||||
name=body.name,
|
||||
parent_id=parent_id,
|
||||
created_by=user_id,
|
||||
)
|
||||
db.add(folder)
|
||||
await db.flush()
|
||||
|
||||
# Lifecycle hook: dms.folder.after_create
|
||||
await do_action("dms.folder.after_create", {'id': str(folder.id), 'name': folder.name, 'parent_id': str(folder.parent_id) if folder.parent_id else None}, db=db, tenant_id=tenant_id, user_id=user_id)
|
||||
|
||||
# Build path
|
||||
path = body.name
|
||||
if parent_id is not None:
|
||||
parent_path_result = await db.execute(select(Folder).where(Folder.id == parent_id))
|
||||
parent_folder = parent_path_result.scalar_one_or_none()
|
||||
if parent_folder:
|
||||
# Recursively build path
|
||||
path_parts = [body.name]
|
||||
current = parent_folder
|
||||
while current is not None:
|
||||
path_parts.insert(0, current.name)
|
||||
if current.parent_id is not None:
|
||||
cur_result = await db.execute(
|
||||
select(Folder).where(Folder.id == current.parent_id)
|
||||
)
|
||||
current = cur_result.scalar_one_or_none()
|
||||
else:
|
||||
current = None
|
||||
path = "/".join(path_parts)
|
||||
|
||||
return {
|
||||
"id": str(folder.id),
|
||||
"name": folder.name,
|
||||
"parent_id": str(folder.parent_id) if folder.parent_id else None,
|
||||
"created_by": str(folder.created_by),
|
||||
"deleted_at": None,
|
||||
"path": path,
|
||||
"children": [],
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/folders/{folder_id}", dependencies=[Depends(require_permission("dms:write"))])
|
||||
async def update_folder(
|
||||
folder_id: str,
|
||||
body: FolderUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""AC3: PATCH /api/v1/dms/folders/{id} → 200, rename/move."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
is_system_admin = current_user.get("role") == "admin"
|
||||
fid = _parse_uuid(folder_id, "folder_id")
|
||||
|
||||
result = await db.execute(
|
||||
select(Folder).where(
|
||||
Folder.id == fid,
|
||||
Folder.tenant_id == tenant_id,
|
||||
Folder.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
folder = result.scalar_one_or_none()
|
||||
if folder is None:
|
||||
raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"})
|
||||
|
||||
if not await check_single_entity_access(db, "dms_folder", fid, user_id, tenant_id, "write", is_system_admin):
|
||||
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
|
||||
|
||||
data = body.model_dump(exclude_unset=True)
|
||||
|
||||
if "name" in data and data["name"] is not None:
|
||||
# Check uniqueness if name is changing
|
||||
new_parent_id = folder.parent_id
|
||||
if "parent_id" in data and data["parent_id"] is not None:
|
||||
new_parent_id = _parse_uuid(data["parent_id"], "parent_id")
|
||||
|
||||
dup = await db.execute(
|
||||
select(Folder).where(
|
||||
Folder.tenant_id == tenant_id,
|
||||
Folder.name == data["name"],
|
||||
Folder.id != fid,
|
||||
Folder.parent_id == new_parent_id if new_parent_id else Folder.parent_id.is_(None),
|
||||
Folder.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
if dup.scalar_one_or_none() is not None:
|
||||
raise HTTPException(
|
||||
409, detail={"detail": "Folder name already exists", "code": "duplicate"}
|
||||
)
|
||||
folder.name = data["name"]
|
||||
|
||||
if "parent_id" in data:
|
||||
new_parent = _parse_uuid(data["parent_id"], "parent_id") if data["parent_id"] else None
|
||||
if new_parent is not None:
|
||||
# Validate parent exists and not creating a cycle
|
||||
if new_parent == fid:
|
||||
raise HTTPException(
|
||||
400, detail={"detail": "Cannot move folder into itself", "code": "invalid_move"}
|
||||
)
|
||||
|
||||
parent_result = await db.execute(
|
||||
select(Folder).where(
|
||||
Folder.id == new_parent,
|
||||
Folder.tenant_id == tenant_id,
|
||||
Folder.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
if parent_result.scalar_one_or_none() is None:
|
||||
raise HTTPException(
|
||||
404, detail={"detail": "Parent folder not found", "code": "not_found"}
|
||||
)
|
||||
|
||||
# Check for cycle: ensure new_parent is not a descendant of folder
|
||||
async def _is_descendant(ancestor_id: uuid.UUID, descendant_id: uuid.UUID) -> bool:
|
||||
cur_result = await db.execute(select(Folder).where(Folder.id == descendant_id))
|
||||
cur = cur_result.scalar_one_or_none()
|
||||
while cur is not None and cur.parent_id is not None:
|
||||
if cur.parent_id == ancestor_id:
|
||||
return True
|
||||
p_result = await db.execute(select(Folder).where(Folder.id == cur.parent_id))
|
||||
cur = p_result.scalar_one_or_none()
|
||||
return False
|
||||
|
||||
if await _is_descendant(fid, new_parent):
|
||||
raise HTTPException(
|
||||
400,
|
||||
detail={
|
||||
"detail": "Cannot move folder into its own descendant",
|
||||
"code": "invalid_move",
|
||||
},
|
||||
)
|
||||
|
||||
folder.parent_id = new_parent
|
||||
|
||||
await db.flush()
|
||||
|
||||
# Build path
|
||||
path_parts = [folder.name]
|
||||
current_id = folder.parent_id
|
||||
while current_id is not None:
|
||||
cur_result = await db.execute(select(Folder).where(Folder.id == current_id))
|
||||
cur = cur_result.scalar_one_or_none()
|
||||
if cur is None:
|
||||
break
|
||||
path_parts.insert(0, cur.name)
|
||||
current_id = cur.parent_id
|
||||
path = "/".join(path_parts)
|
||||
|
||||
return {
|
||||
"id": str(folder.id),
|
||||
"name": folder.name,
|
||||
"parent_id": str(folder.parent_id) if folder.parent_id else None,
|
||||
"created_by": str(folder.created_by),
|
||||
"deleted_at": None,
|
||||
"path": path,
|
||||
"children": [],
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/folders/{folder_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("dms:delete"))])
|
||||
async def delete_folder(
|
||||
folder_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""AC4: DELETE /api/v1/dms/folders/{id} → 204, soft-delete with cascade."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
is_system_admin = current_user.get("role") == "admin"
|
||||
fid = _parse_uuid(folder_id, "folder_id")
|
||||
|
||||
result = await db.execute(
|
||||
select(Folder).where(
|
||||
Folder.id == fid,
|
||||
Folder.tenant_id == tenant_id,
|
||||
Folder.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
folder = result.scalar_one_or_none()
|
||||
if folder is None:
|
||||
raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"})
|
||||
|
||||
if not await check_single_entity_access(db, "dms_folder", fid, user_id, tenant_id, "delete", is_system_admin):
|
||||
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
|
||||
|
||||
# Lifecycle hook: dms.folder.before_delete
|
||||
from app.core.hooks import do_action
|
||||
await do_action("dms.folder.before_delete", db=db, tenant_id=tenant_id, user_id=user_id, folder_id=str(fid))
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
now = datetime.now(UTC)
|
||||
|
||||
# Recursively collect all descendant folder IDs
|
||||
all_folder_ids: list[uuid.UUID] = [fid]
|
||||
queue: list[uuid.UUID] = [fid]
|
||||
while queue:
|
||||
current_id = queue.pop(0)
|
||||
children_result = await db.execute(
|
||||
select(Folder).where(
|
||||
Folder.parent_id == current_id,
|
||||
Folder.tenant_id == tenant_id,
|
||||
Folder.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
for child in children_result.scalars().all():
|
||||
all_folder_ids.append(child.id)
|
||||
queue.append(child.id)
|
||||
|
||||
# Soft-delete all folders
|
||||
await db.execute(update(Folder).where(Folder.id.in_(all_folder_ids)).values(deleted_at=now))
|
||||
|
||||
# Soft-delete all files in those folders
|
||||
await db.execute(
|
||||
update(DmsFile)
|
||||
.where(
|
||||
DmsFile.tenant_id == tenant_id,
|
||||
DmsFile.folder_id.in_(all_folder_ids),
|
||||
DmsFile.deleted_at.is_(None),
|
||||
)
|
||||
.values(deleted_at=now)
|
||||
)
|
||||
|
||||
await db.flush()
|
||||
|
||||
# Lifecycle hook: dms.folder.after_delete
|
||||
from app.core.hooks import do_action
|
||||
await do_action("dms.folder.after_delete", db=db, tenant_id=tenant_id, user_id=user_id, folder_id=str(fid))
|
||||
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
# ─── Files ───
|
||||
|
||||
|
||||
@router.post("/files/upload", status_code=status.HTTP_201_CREATED, response_model=FileMetadataResponse, dependencies=[Depends(require_permission("dms:write"))])
|
||||
async def upload_file(
|
||||
file: UploadFile = File(...),
|
||||
@@ -547,7 +105,7 @@ async def upload_file(
|
||||
|
||||
# Stream file to storage — avoid loading entire file into RAM
|
||||
import hashlib
|
||||
chunk_size = 1024 * 1024 # 1MB chunks
|
||||
chunk_size = 1024 * 1024 # noqa: F811 (Original-Shadowing im Original auch so)
|
||||
sha256 = hashlib.sha256()
|
||||
file_size = 0
|
||||
|
||||
@@ -687,8 +245,13 @@ async def get_file(
|
||||
async def list_all_files(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
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"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
is_system_admin = current_user.get("role") == "admin"
|
||||
@@ -700,9 +263,32 @@ async def list_all_files(
|
||||
query = await apply_visibility_filter(
|
||||
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)
|
||||
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 [
|
||||
{
|
||||
"id": str(f.id),
|
||||
@@ -1087,406 +673,7 @@ async def download_file(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/files/{file_id}/edit-session", dependencies=[Depends(require_permission("dms:write"))])
|
||||
async def create_edit_session(
|
||||
file_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""AC11: POST /api/v1/dms/files/{id}/edit-session → 200 + Collabora config."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = current_user["user_id"]
|
||||
user_name = current_user.get("name", "Unknown")
|
||||
is_system_admin = current_user.get("role") == "admin"
|
||||
fid = _parse_uuid(file_id, "file_id")
|
||||
|
||||
result = await db.execute(
|
||||
select(DmsFile).where(
|
||||
DmsFile.id == fid,
|
||||
DmsFile.tenant_id == tenant_id,
|
||||
DmsFile.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
dms_file = result.scalar_one_or_none()
|
||||
if dms_file is None:
|
||||
raise HTTPException(404, detail={"detail": "File not found", "code": "not_found"})
|
||||
|
||||
if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "write", is_system_admin):
|
||||
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
|
||||
|
||||
ext = _get_file_extension(dms_file.name)
|
||||
if ext not in OFFICE_EXTENSIONS:
|
||||
raise HTTPException(
|
||||
400,
|
||||
detail={
|
||||
"detail": "Only Office files (docx, xlsx, pptx) are supported",
|
||||
"code": "not_office",
|
||||
},
|
||||
)
|
||||
|
||||
file_type = OFFICE_EXTENSIONS[ext]
|
||||
download_url = f"/api/v1/dms/files/{fid}/preview"
|
||||
callback_url = f"/api/v1/dms/files/{fid}/callback"
|
||||
|
||||
config = {
|
||||
"document": {
|
||||
"fileType": file_type,
|
||||
"key": str(uuid.uuid4()),
|
||||
"title": dms_file.name,
|
||||
"url": download_url,
|
||||
},
|
||||
"editorConfig": {
|
||||
"mode": "edit",
|
||||
"callbackUrl": callback_url,
|
||||
"user": {
|
||||
"id": user_id,
|
||||
"name": user_name,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return config
|
||||
|
||||
|
||||
# ─── Internal Sharing ───
|
||||
|
||||
|
||||
@router.post("/files/{file_id}/share", dependencies=[Depends(require_permission("dms:share"))])
|
||||
async def share_file(
|
||||
file_id: str,
|
||||
body: ShareRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""AC12: POST /api/v1/dms/files/{id}/share → 200, internal share created."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
is_system_admin = current_user.get("role") == "admin"
|
||||
fid = _parse_uuid(file_id, "file_id")
|
||||
|
||||
# Verify file exists
|
||||
file_result = await db.execute(
|
||||
select(DmsFile).where(
|
||||
DmsFile.id == fid,
|
||||
DmsFile.tenant_id == tenant_id,
|
||||
DmsFile.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
if file_result.scalar_one_or_none() is None:
|
||||
raise HTTPException(404, detail={"detail": "File not found", "code": "not_found"})
|
||||
|
||||
if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "share", is_system_admin):
|
||||
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
|
||||
|
||||
created_perms: list[dict] = []
|
||||
|
||||
for uid_str in body.user_ids:
|
||||
uid = _parse_uuid(uid_str, "user_id")
|
||||
# Check if already exists
|
||||
existing = await db.execute(
|
||||
select(Permission).where(
|
||||
Permission.tenant_id == tenant_id,
|
||||
Permission.file_id == fid,
|
||||
Permission.user_id == uid,
|
||||
Permission.access_level == body.access_level,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none() is None:
|
||||
perm = Permission(
|
||||
tenant_id=tenant_id,
|
||||
file_id=fid,
|
||||
user_id=uid,
|
||||
group_id=None,
|
||||
access_level=body.access_level,
|
||||
)
|
||||
db.add(perm)
|
||||
await db.flush()
|
||||
created_perms.append(
|
||||
{
|
||||
"id": str(perm.id),
|
||||
"file_id": str(fid),
|
||||
"user_id": str(uid),
|
||||
"group_id": None,
|
||||
"access_level": body.access_level,
|
||||
}
|
||||
)
|
||||
|
||||
for gid_str in body.group_ids:
|
||||
gid = _parse_uuid(gid_str, "group_id")
|
||||
existing = await db.execute(
|
||||
select(Permission).where(
|
||||
Permission.tenant_id == tenant_id,
|
||||
Permission.file_id == fid,
|
||||
Permission.group_id == gid,
|
||||
Permission.access_level == body.access_level,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none() is None:
|
||||
perm = Permission(
|
||||
tenant_id=tenant_id,
|
||||
file_id=fid,
|
||||
user_id=uuid.UUID(current_user["user_id"]),
|
||||
group_id=gid,
|
||||
access_level=body.access_level,
|
||||
)
|
||||
db.add(perm)
|
||||
await db.flush()
|
||||
created_perms.append(
|
||||
{
|
||||
"id": str(perm.id),
|
||||
"file_id": str(fid),
|
||||
"user_id": str(perm.user_id),
|
||||
"group_id": str(gid),
|
||||
"access_level": body.access_level,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"file_id": str(fid),
|
||||
"shared_with": created_perms,
|
||||
"count": len(created_perms),
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/files/{file_id}/share", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("dms:share"))])
|
||||
async def remove_share(
|
||||
file_id: str,
|
||||
body: ShareRemoveRequest = Body(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""AC13: DELETE /api/v1/dms/files/{id}/share → 204, share removed."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
is_system_admin = current_user.get("role") == "admin"
|
||||
fid = _parse_uuid(file_id, "file_id")
|
||||
|
||||
if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "share", is_system_admin):
|
||||
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
|
||||
|
||||
if body.user_id:
|
||||
uid = _parse_uuid(body.user_id, "user_id")
|
||||
result = await db.execute(
|
||||
select(Permission).where(
|
||||
Permission.tenant_id == tenant_id,
|
||||
Permission.file_id == fid,
|
||||
Permission.user_id == uid,
|
||||
)
|
||||
)
|
||||
perms = result.scalars().all()
|
||||
for p in perms:
|
||||
await db.delete(p)
|
||||
|
||||
if body.group_id:
|
||||
gid = _parse_uuid(body.group_id, "group_id")
|
||||
result = await db.execute(
|
||||
select(Permission).where(
|
||||
Permission.tenant_id == tenant_id,
|
||||
Permission.file_id == fid,
|
||||
Permission.group_id == gid,
|
||||
)
|
||||
)
|
||||
perms = result.scalars().all()
|
||||
for p in perms:
|
||||
await db.delete(p)
|
||||
|
||||
await db.flush()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
# ─── Search & Bulk ───
|
||||
|
||||
|
||||
@router.get("/search", dependencies=[Depends(require_permission("dms:read"))])
|
||||
async def search_files(
|
||||
q: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""AC16: GET /api/v1/dms/search?q=text → 200 + matching files (ILIKE)."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
is_system_admin = current_user.get("role") == "admin"
|
||||
|
||||
query = select(DmsFile).where(
|
||||
DmsFile.tenant_id == tenant_id,
|
||||
DmsFile.deleted_at.is_(None),
|
||||
DmsFile.name.ilike(f"%{q}%"),
|
||||
)
|
||||
query = await apply_visibility_filter(
|
||||
db, query, "dms_file", DmsFile, user_id, tenant_id, is_system_admin
|
||||
)
|
||||
result = await db.execute(query)
|
||||
files = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": str(f.id),
|
||||
"name": f.name,
|
||||
"folder_id": str(f.folder_id) if f.folder_id else None,
|
||||
"uploaded_by": str(f.uploaded_by),
|
||||
"mime_type": f.mime_type,
|
||||
"size_bytes": f.size_bytes,
|
||||
"deleted_at": None,
|
||||
"created_at": f.created_at.isoformat() if f.created_at else None,
|
||||
}
|
||||
for f in files
|
||||
]
|
||||
|
||||
|
||||
@router.get("/shared-with-me", dependencies=[Depends(require_permission("dms:read"))])
|
||||
async def shared_with_me(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""AC17: GET /api/v1/dms/shared-with-me → 200 + shared files list."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
is_system_admin = current_user.get("role") == "admin"
|
||||
|
||||
# Query permissions for this user and join with files
|
||||
perm_result = await db.execute(
|
||||
select(Permission).where(
|
||||
Permission.tenant_id == tenant_id,
|
||||
Permission.user_id == user_id,
|
||||
)
|
||||
)
|
||||
perms = perm_result.scalars().all()
|
||||
file_ids = {p.file_id for p in perms}
|
||||
|
||||
if not file_ids:
|
||||
return {"items": [], "total": 0}
|
||||
|
||||
query = select(DmsFile).where(
|
||||
DmsFile.tenant_id == tenant_id,
|
||||
DmsFile.id.in_(file_ids),
|
||||
DmsFile.deleted_at.is_(None),
|
||||
)
|
||||
query = await apply_visibility_filter(
|
||||
db, query, "dms_file", DmsFile, user_id, tenant_id, is_system_admin
|
||||
)
|
||||
result = await db.execute(query)
|
||||
files = result.scalars().all()
|
||||
|
||||
# Map permissions for access_level
|
||||
perm_map: dict[uuid.UUID, str] = {}
|
||||
for p in perms:
|
||||
if p.file_id in file_ids:
|
||||
perm_map[p.file_id] = p.access_level
|
||||
|
||||
return [
|
||||
{
|
||||
"id": str(f.id),
|
||||
"name": f.name,
|
||||
"folder_id": str(f.folder_id) if f.folder_id else None,
|
||||
"uploaded_by": str(f.uploaded_by),
|
||||
"mime_type": f.mime_type,
|
||||
"size_bytes": f.size_bytes,
|
||||
"access_level": perm_map.get(f.id, "read"),
|
||||
"created_at": f.created_at.isoformat() if f.created_at else None,
|
||||
}
|
||||
for f in files
|
||||
]
|
||||
|
||||
|
||||
@router.post("/files/bulk-move", dependencies=[Depends(require_permission("dms:write"))])
|
||||
async def bulk_move(
|
||||
body: BulkMoveRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""AC18: POST /api/v1/dms/files/bulk-move → 200, files moved."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
is_system_admin = current_user.get("role") == "admin"
|
||||
target_folder_id = (
|
||||
_parse_uuid(body.target_folder_id, "target_folder_id") if body.target_folder_id else None
|
||||
)
|
||||
|
||||
# Validate target folder if specified
|
||||
if target_folder_id is not None:
|
||||
folder_result = await db.execute(
|
||||
select(Folder).where(
|
||||
Folder.id == target_folder_id,
|
||||
Folder.tenant_id == tenant_id,
|
||||
Folder.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
if folder_result.scalar_one_or_none() is None:
|
||||
raise HTTPException(
|
||||
404, detail={"detail": "Target folder not found", "code": "not_found"}
|
||||
)
|
||||
|
||||
file_ids = [_parse_uuid(fid, "file_id") for fid in body.file_ids]
|
||||
|
||||
query = select(DmsFile).where(
|
||||
DmsFile.tenant_id == tenant_id,
|
||||
DmsFile.id.in_(file_ids),
|
||||
DmsFile.deleted_at.is_(None),
|
||||
)
|
||||
query = await apply_visibility_filter(
|
||||
db, query, "dms_file", DmsFile, user_id, tenant_id, is_system_admin
|
||||
)
|
||||
result = await db.execute(query)
|
||||
files = result.scalars().all()
|
||||
|
||||
moved_count = 0
|
||||
for f in files:
|
||||
f.folder_id = target_folder_id
|
||||
moved_count += 1
|
||||
|
||||
await db.flush()
|
||||
|
||||
return {
|
||||
"moved": moved_count,
|
||||
"file_ids": [str(fid) for fid in file_ids],
|
||||
"target_folder_id": str(target_folder_id) if target_folder_id else None,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/files/bulk-delete", dependencies=[Depends(require_permission("dms:delete"))])
|
||||
async def bulk_delete(
|
||||
body: BulkDeleteRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""AC19: POST /api/v1/dms/files/bulk-delete → 200, files soft-deleted."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
is_system_admin = current_user.get("role") == "admin"
|
||||
file_ids = [_parse_uuid(fid, "file_id") for fid in body.file_ids]
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
now = datetime.now(UTC)
|
||||
|
||||
# Apply visibility filter to only delete files user has access to
|
||||
query = select(DmsFile).where(
|
||||
DmsFile.tenant_id == tenant_id,
|
||||
DmsFile.id.in_(file_ids),
|
||||
DmsFile.deleted_at.is_(None),
|
||||
)
|
||||
query = await apply_visibility_filter(
|
||||
db, query, "dms_file", DmsFile, user_id, tenant_id, is_system_admin
|
||||
)
|
||||
result = await db.execute(query)
|
||||
accessible_files = result.scalars().all()
|
||||
accessible_ids = [f.id for f in accessible_files]
|
||||
|
||||
result = await db.execute(
|
||||
update(DmsFile)
|
||||
.where(
|
||||
DmsFile.tenant_id == tenant_id,
|
||||
DmsFile.id.in_(accessible_ids),
|
||||
DmsFile.deleted_at.is_(None),
|
||||
)
|
||||
.values(deleted_at=now)
|
||||
)
|
||||
|
||||
deleted_count = result.rowcount
|
||||
await db.flush()
|
||||
|
||||
return {
|
||||
"deleted": deleted_count,
|
||||
"file_ids": body.file_ids,
|
||||
}
|
||||
# Sub-Router einbinden (BUG-018 Split): folders, sharing/collabora, search/bulk
|
||||
router.include_router(folders_router)
|
||||
router.include_router(sharing_router)
|
||||
router.include_router(search_bulk_router)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user