chore: Clean up dump.rdb, templates/, test_report.md; update .a0 status files
This commit is contained in:
+27
-43
@@ -1,47 +1,31 @@
|
|||||||
# LeoCRM — Current Status
|
# LeoCRM — Current Status
|
||||||
**Phase**: Fix Branch — 20/22 FIX-PLAN Items erledigt
|
**Last update**: 2026-08-04
|
||||||
**Last update**: 2026-07-26 16:25
|
**Branch**: main
|
||||||
**Branch**: main (leocrm-fix)
|
**Git HEAD**: 157e454
|
||||||
|
**Alembic-Head**: 0103
|
||||||
|
**Produktion**: https://crm.media-on.de — healthy
|
||||||
|
|
||||||
## FIX-PLAN Überprüfung (2026-07-26)
|
## Security Fix Plan — Alle Phasen abgeschlossen
|
||||||
Alle 22 Items gegen Codebasis verifiziert. 20 erledigt, 2 offen.
|
| Phase | Status |
|
||||||
|
|-------|--------|
|
||||||
|
| 1 — Kritische Sicherheitslücken | ✅ |
|
||||||
|
| 2 — Visibility Filter & Owner ID | ✅ |
|
||||||
|
| 3 — WebSocket CSRF, SameSite, FK CASCADE | ✅ |
|
||||||
|
| 4 — Krisensicherheit (Circuit Breaker, Retry, Fallback) | ✅ |
|
||||||
|
| 5 — Architektur-Lücken (9 Sub-Tasks) | ✅ |
|
||||||
|
|
||||||
### Erledigt (20)
|
## Implementierte Features (Phase 5)
|
||||||
- P0-1: Auth-Bypass entfernt ✅
|
- PWA (VitePWA, manifest.json, service worker)
|
||||||
- P0-2: Migrationen repariert ✅
|
- Public Plugin Endpoints (is_public, share-link routes)
|
||||||
- P0-3: Plugin-Upload deaktiviert ✅
|
- Contacts Embedding (Vector(768), HNSW index)
|
||||||
- P0-4: RLS FORCE + WITH CHECK ✅
|
- 10 Search Providers (contact, company, mail, file, event, task, contactperson, tag, conversation, user)
|
||||||
- P0-5: Plugin-Doppelregistrierung behoben ✅
|
- Plugin-Marketplace (listing, download, Ed25519 verify, install)
|
||||||
- P0-6: Persistent Volume ✅
|
- Agent Memory (persistent, pgvector semantic search)
|
||||||
- P1-1: User/Tenant-Modell bereinigt ✅
|
- GraphRAG (entity relationships, BFS traversal, search provider)
|
||||||
- P1-2: Redis zentralisiert ✅
|
- Subagents/Multi-Agent (AgentCoordinator, subtask management)
|
||||||
- P1-3: Worker ausgelagert ✅
|
- External Agent API (Bearer token, SSE streaming, rate limiting)
|
||||||
- P1-4: Transactional Outbox ✅
|
- Circuit Breaker + DB Retry + Redis Graceful Degradation
|
||||||
- P1-5: XSS-Stellen geschlossen ✅
|
|
||||||
- P1-6: DMS lastfest ✅
|
|
||||||
- P1-7: Permission-System vereinheitlicht ✅
|
|
||||||
- P1-8: Password Reset funktionsfähig ✅
|
|
||||||
- P1-9: Metrics abgesichert ✅
|
|
||||||
- P1-10: Coolify-Doku & Config korrigiert ✅
|
|
||||||
- P1-11: Cross-Tenant FK ✅
|
|
||||||
- P2-1: Contact Model normalisiert ✅
|
|
||||||
- P2-3: Commands & Statusmaschinen ✅
|
|
||||||
- P2-4: SPA Path-Traversal ✅
|
|
||||||
|
|
||||||
### Offen (2)
|
## Offene Items
|
||||||
- P0-7: App von öffentlicher Domain nehmen (operational — 30 Min)
|
- IMPLEMENTATION_PLAN.md: 14 Frontend-Features in 4 Phasen (nicht begonnen)
|
||||||
- P2-2: Plugin-Cross-Imports reduzieren (228 Imports — 1-2 Wochen)
|
- Test-Instanzen CRM2/CRM3 noch aktiv (können gelöscht werden)
|
||||||
|
|
||||||
## Previous: P1-4: Transactional Outbox — COMPLETE
|
|
||||||
- Migration 0040_outbox.py created (down_revision=0039_contact_normalize)
|
|
||||||
- event_outbox table: id, tenant_id, event_name, payload JSONB, status, attempts, max_attempts, next_retry_at, timestamps
|
|
||||||
- app/core/outbox.py: enqueue_outbox_event() + process_outbox_batch() with FOR UPDATE SKIP LOCKED, exponential backoff retry
|
|
||||||
- app/core/event_bus.py: added publish_with_results() for error-aware publishing; docstring note about outbox
|
|
||||||
- app/core/worker.py: process_outbox_job cron (every 5s, Redis distributed lock)
|
|
||||||
- app/services/contact_service.py: contact.created, lead.created, contact.updated → enqueue_outbox_event
|
|
||||||
- app/models/outbox.py: SQLAlchemy ORM model for event_outbox
|
|
||||||
- tests/test_outbox.py: 6 tests, all passing
|
|
||||||
- py_compile: OK, alembic heads: single head 0040_outbox
|
|
||||||
|
|
||||||
## Previous: P2-1: Unified Contact Model normalisieren — COMPLETE
|
|
||||||
- Migration 0039_contact_normalize.py (down_revision=0038_dms_content_hash)
|
|
||||||
|
|||||||
+14
-7
@@ -1,10 +1,17 @@
|
|||||||
# LeoCRM — Next Steps
|
# LeoCRM — Next Steps
|
||||||
|
|
||||||
## FIX-PLAN Offene Items (2026-07-26)
|
## Offene Items (2026-08-04)
|
||||||
1. P0-7: App von öffentlicher Domain nehmen (operational — 30 Min)
|
1. IMPLEMENTATION_PLAN.md — 14 Frontend-Features in 4 Phasen
|
||||||
2. P2-2: Plugin-Cross-Imports reduzieren (228 Imports — 1-2 Wochen)
|
- Phase 1: Workflows UI, Dedup/Merge UI, Import/Export UI, Print/PDF
|
||||||
|
- Phase 2: Tags UI, Custom Fields UI, Notifications Dropdown
|
||||||
|
- Phase 3: Saved Filters, Entity History, Activity Timeline, API Docs
|
||||||
|
- Phase 4: Webhooks, Backup/Restore UI, Onboarding
|
||||||
|
2. Test-Instanzen CRM2/CRM3 können gelöscht werden
|
||||||
|
3. Tests für neue Plugins (agent_memory, graph_rag, marketplace) schreiben
|
||||||
|
|
||||||
## Abgeschlossen
|
## Abgeschlossen (2026-08-04)
|
||||||
- P2-1: Unified Contact Model normalisieren — COMPLETE
|
- Security Fix Plan Phase 1-5 komplett
|
||||||
- P1-4: Transactional Outbox — COMPLETE
|
- Alle alten Plan-Dateien gelöscht (Sanierungsplan, FIX-PLAN, etc.)
|
||||||
- 20/22 FIX-PLAN Items erledigt (siehe .a0/current_status.md)
|
- 3 neue Plugins: agent_memory, graph_rag, marketplace
|
||||||
|
- Resilience-Features: Circuit Breaker, DB Retry, Redis Fallback
|
||||||
|
- PWA, Public Endpoints, Search Coverage (10 providers)
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
# Test Report — Phase 4: Unified Messaging Frontend
|
|
||||||
|
|
||||||
## Task
|
|
||||||
Implement MessageSidebar replacing AISidebar with comm API backend integration.
|
|
||||||
|
|
||||||
## Files Created/Modified
|
|
||||||
|
|
||||||
### New Files
|
|
||||||
1. `src/store/commStore.ts` — Zustand store for conversations, messages, typing, unread counts
|
|
||||||
2. `src/api/comm.ts` — API client covering all `/api/v1/comm/*` endpoints (conversations, messages, reactions, read state, mini-apps, block types)
|
|
||||||
3. `src/hooks/useCommWebSocket.ts` — WebSocket hook with auto-reconnect, ping, streaming, typing indicators
|
|
||||||
4. `src/components/layout/MessageSidebar.tsx` — Main sidebar component (716 lines) replicating AISidebar design exactly
|
|
||||||
|
|
||||||
### Modified Files
|
|
||||||
5. `src/store/uiStore.ts` — Added `messageSidebarCollapsed` + `toggleMessageSidebar`; replaced mock notifications with empty array; kept `aiSidebarCollapsed` for backward compat
|
|
||||||
6. `src/components/layout/AppShell.tsx` — Replaced `AISidebar` import with `MessageSidebar`
|
|
||||||
7. `src/components/layout/TopBar.tsx` — Replaced `openAISidebarProactive` with `toggleMessageSidebar`
|
|
||||||
|
|
||||||
## TypeScript Compilation
|
|
||||||
|
|
||||||
Command: `npx tsc --noEmit`
|
|
||||||
|
|
||||||
```
|
|
||||||
src/pages/Dms.tsx(553,13): error TS2322: ... onRangeSelect does not exist on type FileExplorerProps
|
|
||||||
src/pages/Dms.tsx(647,15): error TS2322: ... onRangeSelect does not exist on type FileExplorerProps
|
|
||||||
```
|
|
||||||
|
|
||||||
**Result:** All new/modified files compile cleanly. Only 2 pre-existing errors in `Dms.tsx` (unrelated to this task — `onRangeSelect` prop missing on `FileExplorerProps` type).
|
|
||||||
|
|
||||||
## Smoke Test Description
|
|
||||||
|
|
||||||
- **Build:** `npx tsc --noEmit` exits with only pre-existing Dms.tsx errors (0 new errors from this task)
|
|
||||||
- **Design fidelity:** MessageSidebar uses identical icons (robotIcon, bellIcon, bulbIcon, teamIcon, chatBubbleIcon), identical ResizablePanel (320px initial, 240-600 range, handleSide=left), identical mobile/desktop structure (collapsed icon strip → expanded resizable panel + mobile overlay)
|
|
||||||
- **API integration:** comm.ts uses same `apiGet/apiPost/apiPatch/apiDelete` from `client.ts` as `ai.ts` — consistent with existing conventions
|
|
||||||
- **WebSocket:** Auto-reconnect with exponential backoff, ping interval, handles message.new, conversation.updated, message.streaming, typing events
|
|
||||||
- **Store:** commStore provides conversations, messages per conversation, typing users, unread counts with all setter methods
|
|
||||||
- **uiStore:** messageSidebarCollapsed defaults to true (same as aiSidebarCollapsed), toggleMessageSidebar works, mock notifications replaced with empty array
|
|
||||||
|
|
||||||
## What Was NOT Tested
|
|
||||||
|
|
||||||
- Live API calls (requires running backend with kommunikation plugin)
|
|
||||||
- WebSocket connection (requires authenticated session)
|
|
||||||
- Visual rendering (requires dev server + browser)
|
|
||||||
- These require runtime verification in a subsequent phase
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>LeoCRM</title>
|
|
||||||
<style>
|
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
body { font-family: Arial, sans-serif; background: #f5f5f5; color: #333; }
|
|
||||||
nav { background: #2c3e50; color: white; padding: 1rem; display: flex; justify-content: space-between; align-items: center; }
|
|
||||||
nav a { color: white; text-decoration: none; margin-left: 1rem; }
|
|
||||||
nav a:hover { text-decoration: underline; }
|
|
||||||
.container { max-width: 900px; margin: 2rem auto; padding: 0 1rem; }
|
|
||||||
.flash { padding: 0.75rem; margin-bottom: 1rem; border-radius: 4px; }
|
|
||||||
.flash.success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
|
|
||||||
.flash.danger { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
|
|
||||||
.flash.warning { background: #fff3cd; color: #856404; border: 1px solid #ffeeba; }
|
|
||||||
.flash.info { background: #d1ecf1; color: #0c5460; border: 1px solid #bee5eb; }
|
|
||||||
table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
|
|
||||||
th, td { padding: 0.5rem; text-align: left; border-bottom: 1px solid #ddd; }
|
|
||||||
th { background: #f8f9fa; }
|
|
||||||
.btn { display: inline-block; padding: 0.4rem 0.8rem; border: none; border-radius: 4px; cursor: pointer; text-decoration: none; font-size: 0.9rem; }
|
|
||||||
.btn-primary { background: #007bff; color: white; }
|
|
||||||
.btn-danger { background: #dc3545; color: white; }
|
|
||||||
.btn-secondary { background: #6c757d; color: white; }
|
|
||||||
.btn-sm { padding: 0.2rem 0.5rem; font-size: 0.8rem; }
|
|
||||||
form label { display: block; margin-top: 0.5rem; font-weight: bold; }
|
|
||||||
form input, form textarea { width: 100%; padding: 0.4rem; margin-top: 0.2rem; border: 1px solid #ccc; border-radius: 4px; }
|
|
||||||
form textarea { resize: vertical; min-height: 60px; }
|
|
||||||
form button { margin-top: 1rem; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<nav>
|
|
||||||
<span><strong>LeoCRM</strong></span>
|
|
||||||
<span>
|
|
||||||
{% if session.username %}
|
|
||||||
<span>{{ session.username }}</span>
|
|
||||||
<a href="{{ url_for('dashboard') }}">Dashboard</a>
|
|
||||||
<a href="{{ url_for('logout') }}">Logout</a>
|
|
||||||
{% else %}
|
|
||||||
<a href="{{ url_for('login') }}">Login</a>
|
|
||||||
<a href="{{ url_for('register') }}">Register</a>
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
</nav>
|
|
||||||
<div class="container">
|
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
|
||||||
{% if messages %}
|
|
||||||
{% for category, message in messages %}
|
|
||||||
<div class="flash {{ category }}">{{ message }}</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
{% block content %}{% endblock %}
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block content %}
|
|
||||||
<h2>{% if company %}Firma bearbeiten{% else %}Neue Firma{% endif %}</h2>
|
|
||||||
<form method="post">
|
|
||||||
<label>Name *</label>
|
|
||||||
<input type="text" name="name" value="{{ company.name if company else '' }}" required>
|
|
||||||
<label>Adresse</label>
|
|
||||||
<input type="text" name="address" value="{{ company.address if company else '' }}">
|
|
||||||
<label>Telefon</label>
|
|
||||||
<input type="text" name="phone" value="{{ company.phone if company else '' }}">
|
|
||||||
<label>Email</label>
|
|
||||||
<input type="email" name="email" value="{{ company.email if company else '' }}">
|
|
||||||
<label>Website</label>
|
|
||||||
<input type="text" name="website" value="{{ company.website if company else '' }}">
|
|
||||||
<label>Notizen</label>
|
|
||||||
<textarea name="notes">{{ company.notes if company else '' }}</textarea>
|
|
||||||
<button type="submit" class="btn btn-primary">Speichern</button>
|
|
||||||
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary">Abbrechen</a>
|
|
||||||
</form>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block content %}
|
|
||||||
<h2>{% if contact %}Kontakt bearbeiten{% else %}Neuer Kontakt{% endif %} – {{ company.name }}</h2>
|
|
||||||
<form method="post">
|
|
||||||
<label>Vorname *</label>
|
|
||||||
<input type="text" name="first_name" value="{{ contact.first_name if contact else '' }}" required>
|
|
||||||
<label>Nachname *</label>
|
|
||||||
<input type="text" name="last_name" value="{{ contact.last_name if contact else '' }}" required>
|
|
||||||
<label>Email</label>
|
|
||||||
<input type="email" name="email" value="{{ contact.email if contact else '' }}">
|
|
||||||
<label>Telefon</label>
|
|
||||||
<input type="text" name="phone" value="{{ contact.phone if contact else '' }}">
|
|
||||||
<label>Position</label>
|
|
||||||
<input type="text" name="position" value="{{ contact.position if contact else '' }}">
|
|
||||||
<label>Notizen</label>
|
|
||||||
<textarea name="notes">{{ contact.notes if contact else '' }}</textarea>
|
|
||||||
<button type="submit" class="btn btn-primary">Speichern</button>
|
|
||||||
<a href="{{ url_for('contact_list', company_id=company.id) }}" class="btn btn-secondary">Abbrechen</a>
|
|
||||||
</form>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block content %}
|
|
||||||
<h2>{{ company.name }} – Kontaktpersonen</h2>
|
|
||||||
<a href="{{ url_for('contact_create', company_id=company.id) }}" class="btn btn-primary">Neuer Kontakt</a>
|
|
||||||
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary">Zurück</a>
|
|
||||||
{% if contacts %}
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Position</th>
|
|
||||||
<th>Email</th>
|
|
||||||
<th>Telefon</th>
|
|
||||||
<th>Aktionen</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for c in contacts %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ c.first_name }} {{ c.last_name }}</td>
|
|
||||||
<td>{{ c.position or '-' }}</td>
|
|
||||||
<td>{{ c.email or '-' }}</td>
|
|
||||||
<td>{{ c.phone or '-' }}</td>
|
|
||||||
<td>
|
|
||||||
<a href="{{ url_for('contact_edit', id=c.id) }}" class="btn btn-sm btn-secondary">Edit</a>
|
|
||||||
<form method="post" action="{{ url_for('contact_delete', id=c.id) }}" style="display:inline;">
|
|
||||||
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Wirklich löschen?')">Del</button>
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{% else %}
|
|
||||||
<p>Keine Kontaktpersonen erfasst.</p>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block content %}
|
|
||||||
<h2>Dashboard</h2>
|
|
||||||
<a href="{{ url_for('company_create') }}" class="btn btn-primary">Neue Firma</a>
|
|
||||||
{% if companies %}
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Telefon</th>
|
|
||||||
<th>Email</th>
|
|
||||||
<th>Aktionen</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for c in companies %}
|
|
||||||
<tr>
|
|
||||||
<td><a href="{{ url_for('contact_list', company_id=c.id) }}">{{ c.name }}</a></td>
|
|
||||||
<td>{{ c.phone or '-' }}</td>
|
|
||||||
<td>{{ c.email or '-' }}</td>
|
|
||||||
<td>
|
|
||||||
<a href="{{ url_for('company_edit', id=c.id) }}" class="btn btn-sm btn-secondary">Edit</a>
|
|
||||||
<form method="post" action="{{ url_for('company_delete', id=c.id) }}" style="display:inline;">
|
|
||||||
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Wirklich löschen?')">Del</button>
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{% else %}
|
|
||||||
<p>Noch keine Firmen erfasst.</p>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block content %}
|
|
||||||
<h2>Login</h2>
|
|
||||||
<form method="post">
|
|
||||||
<label>Benutzername</label>
|
|
||||||
<input type="text" name="username" required>
|
|
||||||
<label>Passwort</label>
|
|
||||||
<input type="password" name="password" required>
|
|
||||||
<button type="submit" class="btn btn-primary">Anmelden</button>
|
|
||||||
</form>
|
|
||||||
<p style="margin-top:1rem;">Noch kein Konto? <a href="{{ url_for('register') }}">Registrieren</a></p>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
# Plugin Template
|
|
||||||
|
|
||||||
A minimal plugin template for LeoCRM. Copy this directory to `app/plugins/builtins/<your_plugin_name>/` and customize.
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
1. **Copy the template:**
|
|
||||||
```bash
|
|
||||||
cp -r templates/plugin-template app/plugins/builtins/my_plugin
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Rename the plugin class:**
|
|
||||||
Edit `plugin.py` and rename `ExamplePlugin` to your plugin name.
|
|
||||||
|
|
||||||
3. **Update the manifest:**
|
|
||||||
- Change `name`, `version`, `display_name`, `description`
|
|
||||||
- Add your routes, events, migrations, permissions
|
|
||||||
- Add UI contributions (menu items, page routes, etc.)
|
|
||||||
|
|
||||||
4. **Implement routes:**
|
|
||||||
Edit `routes.py` with your API endpoints.
|
|
||||||
|
|
||||||
5. **Add database models:**
|
|
||||||
Uncomment and customize `models.py`.
|
|
||||||
|
|
||||||
6. **Write migrations:**
|
|
||||||
Add SQL migration files to `migrations/`.
|
|
||||||
|
|
||||||
7. **Write tests:**
|
|
||||||
Add tests to `tests/test_plugin.py`.
|
|
||||||
|
|
||||||
## Directory Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
templates/plugin-template/
|
|
||||||
├── __init__.py # Package init
|
|
||||||
├── plugin.py # Plugin class with manifest (required)
|
|
||||||
├── routes.py # FastAPI route definitions
|
|
||||||
├── models.py # SQLAlchemy models (optional, commented out)
|
|
||||||
├── schemas.py # Pydantic schemas (optional)
|
|
||||||
├── services.py # Business logic (optional)
|
|
||||||
├── migrations/ # SQL migration files
|
|
||||||
│ └── 0001_initial.sql
|
|
||||||
├── tests/ # Plugin tests
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ └── test_plugin.py
|
|
||||||
└── README.md # This file
|
|
||||||
```
|
|
||||||
|
|
||||||
## Manifest Fields
|
|
||||||
|
|
||||||
See `docs/plugin-development-guide.md` for a complete reference of all manifest fields.
|
|
||||||
|
|
||||||
## Key Points
|
|
||||||
|
|
||||||
- All API routes must be secured with `require_permission`
|
|
||||||
- Event handlers are named `on_<event_name>` with dots replaced by underscores
|
|
||||||
- Migration files run in alphanumeric order
|
|
||||||
- UI component paths use the `@/` alias (resolved to `src/` by Vite)
|
|
||||||
- i18n keys should be prefixed with the plugin name
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Plugin template package."""
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
-- 0001_initial.sql
|
|
||||||
-- Initial migration for the example plugin.
|
|
||||||
-- Creates the example_items table.
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS example_items (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
|
||||||
name VARCHAR(200) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
config JSONB DEFAULT '{}',
|
|
||||||
is_active BOOLEAN DEFAULT true,
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_example_items_tenant_id ON example_items(tenant_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_example_items_name ON example_items(name);
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
"""
|
|
||||||
SQLAlchemy models for the example plugin.
|
|
||||||
|
|
||||||
Uncomment and customize for your plugin's data model.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# from __future__ import annotations
|
|
||||||
#
|
|
||||||
# import uuid
|
|
||||||
# from datetime import datetime
|
|
||||||
#
|
|
||||||
# from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, Text
|
|
||||||
# from sqlalchemy.dialects.postgresql import UUID
|
|
||||||
# from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
||||||
#
|
|
||||||
# from app.database import Base
|
|
||||||
#
|
|
||||||
#
|
|
||||||
# class ExampleItem(Base):
|
|
||||||
# __tablename__ = "example_items"
|
|
||||||
#
|
|
||||||
# id: Mapped[uuid.UUID] = mapped_column(
|
|
||||||
# UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
|
||||||
# )
|
|
||||||
# tenant_id: Mapped[uuid.UUID] = mapped_column(
|
|
||||||
# UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=False
|
|
||||||
# )
|
|
||||||
# name: Mapped[str] = mapped_column(String(200), nullable=False)
|
|
||||||
# description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
# is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
|
||||||
# created_at: Mapped[datetime] = mapped_column(
|
|
||||||
# DateTime(timezone=True), default=datetime.utcnow
|
|
||||||
# )
|
|
||||||
# updated_at: Mapped[datetime] = mapped_column(
|
|
||||||
# DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow
|
|
||||||
# )
|
|
||||||
#
|
|
||||||
# tenant = relationship("Tenant", back_populates="example_items")
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
"""
|
|
||||||
Plugin Template — Example plugin demonstrating all manifest fields.
|
|
||||||
|
|
||||||
Copy this directory to app/plugins/builtins/<your_plugin_name>/ and customize.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from app.plugins.base import BasePlugin
|
|
||||||
from app.plugins.manifest import (
|
|
||||||
PluginManifest,
|
|
||||||
PluginRouteDef,
|
|
||||||
FieldDefinition,
|
|
||||||
FrontendMenuItem,
|
|
||||||
FrontendPageRoute,
|
|
||||||
FrontendDetailTab,
|
|
||||||
FrontendSettingsPage,
|
|
||||||
FrontendDashboardWidget,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ExamplePlugin(BasePlugin):
|
|
||||||
"""
|
|
||||||
Example plugin demonstrating all manifest fields.
|
|
||||||
|
|
||||||
Remove or comment out fields you don't need.
|
|
||||||
"""
|
|
||||||
|
|
||||||
manifest = PluginManifest(
|
|
||||||
# ── Core Metadata ──────────────────────────────────────────────
|
|
||||||
name="example_plugin",
|
|
||||||
version="1.0.0",
|
|
||||||
display_name="Example Plugin",
|
|
||||||
description="A minimal example plugin demonstrating all manifest fields.",
|
|
||||||
dependencies=[],
|
|
||||||
is_core=False,
|
|
||||||
# ── API Routes ─────────────────────────────────────────────────
|
|
||||||
routes=[
|
|
||||||
PluginRouteDef(
|
|
||||||
path="/api/v1/example",
|
|
||||||
module="app.plugins.builtins.example.routes",
|
|
||||||
router_attr="router",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
# ── Event Subscriptions ────────────────────────────────────────
|
|
||||||
events=[
|
|
||||||
"contact.created",
|
|
||||||
"contact.updated",
|
|
||||||
],
|
|
||||||
# ── Database Migrations ────────────────────────────────────────
|
|
||||||
migrations=[
|
|
||||||
"0001_initial.sql",
|
|
||||||
],
|
|
||||||
# ── RBAC Permissions ───────────────────────────────────────────
|
|
||||||
permissions=[
|
|
||||||
"example:read",
|
|
||||||
"example:write",
|
|
||||||
],
|
|
||||||
# ── Field Definitions (Field-Level Permissions) ────────────────
|
|
||||||
field_definitions=[
|
|
||||||
FieldDefinition(
|
|
||||||
module="contacts",
|
|
||||||
field="custom_field",
|
|
||||||
label="Custom Field",
|
|
||||||
sensitivity="normal",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
# ── AI Agent Capabilities ──────────────────────────────────────
|
|
||||||
agent_capabilities=[
|
|
||||||
"example:search",
|
|
||||||
],
|
|
||||||
# ── Frontend UI: Sidebar Menu Items ────────────────────────────
|
|
||||||
menu_items=[
|
|
||||||
FrontendMenuItem(
|
|
||||||
label_key="nav.example",
|
|
||||||
label="Example",
|
|
||||||
path="/example",
|
|
||||||
icon="Sparkles",
|
|
||||||
order=100,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
# ── Frontend UI: Page Routes ───────────────────────────────────
|
|
||||||
page_routes=[
|
|
||||||
FrontendPageRoute(
|
|
||||||
path="/example",
|
|
||||||
component="@/pages/Example",
|
|
||||||
protected=True,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
# ── Frontend UI: Detail Tabs ───────────────────────────────────
|
|
||||||
detail_tabs=[
|
|
||||||
FrontendDetailTab(
|
|
||||||
entity_type="contact",
|
|
||||||
label_key="tabs.example",
|
|
||||||
label="Example",
|
|
||||||
component="@/components/ExampleTab",
|
|
||||||
icon="Sparkles",
|
|
||||||
order=50,
|
|
||||||
permission="example:read",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
# ── Frontend UI: Settings Pages ────────────────────────────────
|
|
||||||
settings_pages=[
|
|
||||||
FrontendSettingsPage(
|
|
||||||
path="example",
|
|
||||||
label_key="settings.example",
|
|
||||||
label="Example",
|
|
||||||
component="@/pages/ExampleSettings",
|
|
||||||
icon="Sparkles",
|
|
||||||
order=100,
|
|
||||||
permission="example:write",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
# ── Frontend UI: Dashboard Widgets ─────────────────────────────
|
|
||||||
dashboard_widgets=[
|
|
||||||
FrontendDashboardWidget(
|
|
||||||
id="example_stats",
|
|
||||||
label_key="widgets.example",
|
|
||||||
label="Example Stats",
|
|
||||||
component="@/components/ExampleWidget",
|
|
||||||
icon="LayoutDashboard",
|
|
||||||
order=100,
|
|
||||||
col_span=2,
|
|
||||||
row_span=1,
|
|
||||||
permission="example:read",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
# ─── Lifecycle Hooks ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def on_install(self, db, service_container):
|
|
||||||
"""Called after migrations are run."""
|
|
||||||
# Perform seed data or initial setup here
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def on_activate(self, db, service_container, event_bus):
|
|
||||||
"""Called when the plugin is activated."""
|
|
||||||
# Default implementation subscribes to manifest events
|
|
||||||
await super().on_activate(db, service_container, event_bus)
|
|
||||||
|
|
||||||
async def on_deactivate(self, db, service_container, event_bus):
|
|
||||||
"""Called when the plugin is deactivated."""
|
|
||||||
# Default implementation unsubscribes all event listeners
|
|
||||||
await super().on_deactivate(db, service_container, event_bus)
|
|
||||||
|
|
||||||
async def on_uninstall(self, db, service_container):
|
|
||||||
"""Called before data tables are dropped."""
|
|
||||||
# Clean up external resources here
|
|
||||||
pass
|
|
||||||
|
|
||||||
# ─── Event Handlers ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def on_contact_created(self, event_data: dict) -> None:
|
|
||||||
"""Handle contact.created event."""
|
|
||||||
contact_id = event_data.get("contact_id")
|
|
||||||
# React to new contact
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def on_contact_updated(self, event_data: dict) -> None:
|
|
||||||
"""Handle contact.updated event."""
|
|
||||||
contact_id = event_data.get("contact_id")
|
|
||||||
# React to contact update
|
|
||||||
pass
|
|
||||||
|
|
||||||
# ─── Notification Types ────────────────────────────────────────────
|
|
||||||
|
|
||||||
def get_notification_types(self) -> list[dict]:
|
|
||||||
"""Return notification types this plugin registers."""
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"type_key": "example.notification",
|
|
||||||
"label": "Example Notification",
|
|
||||||
"category": "general",
|
|
||||||
"description": "Notification from the example plugin",
|
|
||||||
"is_enabled_by_default": True,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
"""
|
|
||||||
API routes for the example plugin.
|
|
||||||
|
|
||||||
Each route must be secured with require_permission.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
|
||||||
|
|
||||||
from app.deps import get_current_user, require_permission
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"",
|
|
||||||
dependencies=[Depends(require_permission("example:read"))],
|
|
||||||
)
|
|
||||||
async def list_items(current_user: dict = Depends(get_current_user)):
|
|
||||||
"""List all items."""
|
|
||||||
return {"items": [], "total": 0}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"",
|
|
||||||
status_code=201,
|
|
||||||
dependencies=[Depends(require_permission("example:write"))],
|
|
||||||
)
|
|
||||||
async def create_item(current_user: dict = Depends(get_current_user)):
|
|
||||||
"""Create a new item."""
|
|
||||||
return {"status": "created"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/{item_id}",
|
|
||||||
dependencies=[Depends(require_permission("example:read"))],
|
|
||||||
)
|
|
||||||
async def get_item(item_id: str, current_user: dict = Depends(get_current_user)):
|
|
||||||
"""Get a single item by ID."""
|
|
||||||
return {"id": item_id}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
|
||||||
"/{item_id}",
|
|
||||||
dependencies=[Depends(require_permission("example:write"))],
|
|
||||||
)
|
|
||||||
async def delete_item(item_id: str, current_user: dict = Depends(get_current_user)):
|
|
||||||
"""Delete an item by ID."""
|
|
||||||
return {"status": "deleted"}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
"""
|
|
||||||
Pydantic schemas for the example plugin.
|
|
||||||
|
|
||||||
Customize these for your plugin's API request/response models.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class ExampleItemBase(BaseModel):
|
|
||||||
"""Base schema for an example item."""
|
|
||||||
|
|
||||||
name: str = Field(..., min_length=1, max_length=200, description="Item name")
|
|
||||||
description: str | None = Field(None, max_length=1000, description="Item description")
|
|
||||||
|
|
||||||
|
|
||||||
class ExampleItemCreate(ExampleItemBase):
|
|
||||||
"""Schema for creating an example item."""
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class ExampleItemUpdate(BaseModel):
|
|
||||||
"""Schema for updating an example item."""
|
|
||||||
|
|
||||||
name: str | None = Field(None, min_length=1, max_length=200)
|
|
||||||
description: str | None = Field(None, max_length=1000)
|
|
||||||
|
|
||||||
|
|
||||||
class ExampleItemResponse(ExampleItemBase):
|
|
||||||
"""Schema for returning an example item."""
|
|
||||||
|
|
||||||
id: UUID
|
|
||||||
tenant_id: UUID
|
|
||||||
is_active: bool
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
|
||||||
|
|
||||||
|
|
||||||
class ExampleItemListResponse(BaseModel):
|
|
||||||
"""Schema for a paginated list of example items."""
|
|
||||||
|
|
||||||
items: list[ExampleItemResponse]
|
|
||||||
total: int
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
"""
|
|
||||||
Business logic services for the example plugin.
|
|
||||||
|
|
||||||
Customize these for your plugin's business logic.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
|
|
||||||
class ExampleService:
|
|
||||||
"""Service class for example plugin business logic."""
|
|
||||||
|
|
||||||
def __init__(self, db: AsyncSession):
|
|
||||||
self.db = db
|
|
||||||
|
|
||||||
async def list_items(self, tenant_id: str, skip: int = 0, limit: int = 100) -> dict:
|
|
||||||
"""List items for a tenant."""
|
|
||||||
# TODO: Implement with actual database queries
|
|
||||||
return {"items": [], "total": 0}
|
|
||||||
|
|
||||||
async def create_item(self, tenant_id: str, data: dict) -> dict:
|
|
||||||
"""Create a new item."""
|
|
||||||
# TODO: Implement with actual database operations
|
|
||||||
return {"id": "new-uuid", **data}
|
|
||||||
|
|
||||||
async def get_item(self, item_id: str, tenant_id: str) -> dict | None:
|
|
||||||
"""Get a single item by ID."""
|
|
||||||
# TODO: Implement with actual database queries
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def delete_item(self, item_id: str, tenant_id: str) -> bool:
|
|
||||||
"""Delete an item by ID."""
|
|
||||||
# TODO: Implement with actual database operations
|
|
||||||
return True
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Tests for the example plugin."""
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
"""Tests for the example plugin."""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from httpx import AsyncClient, ASGITransport
|
|
||||||
|
|
||||||
from app.main import create_app
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
async def client():
|
|
||||||
"""Create a test client."""
|
|
||||||
app = create_app()
|
|
||||||
transport = ASGITransport(app=app)
|
|
||||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
|
||||||
yield ac
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def auth_headers():
|
|
||||||
"""Return headers with valid authentication."""
|
|
||||||
return {
|
|
||||||
"Authorization": "Bearer test-token",
|
|
||||||
"X-Tenant-ID": "test-tenant",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_list_items_requires_auth(client: AsyncClient):
|
|
||||||
"""Test that listing items requires authentication."""
|
|
||||||
response = await client.get("/api/v1/example")
|
|
||||||
assert response.status_code in (401, 403)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_list_items_with_auth(client: AsyncClient, auth_headers: dict):
|
|
||||||
"""Test that listing items works with authentication."""
|
|
||||||
response = await client.get("/api/v1/example", headers=auth_headers)
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert "items" in data
|
|
||||||
assert "total" in data
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_item_requires_write_permission(client: AsyncClient, auth_headers: dict):
|
|
||||||
"""Test that creating items requires write permission."""
|
|
||||||
response = await client.post(
|
|
||||||
"/api/v1/example",
|
|
||||||
headers=auth_headers,
|
|
||||||
json={"name": "Test Item"},
|
|
||||||
)
|
|
||||||
# May return 201 or 403 depending on test permissions
|
|
||||||
assert response.status_code in (201, 403)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_item_returns_404_for_missing(client: AsyncClient, auth_headers: dict):
|
|
||||||
"""Test that getting a non-existent item returns 404."""
|
|
||||||
response = await client.get(
|
|
||||||
"/api/v1/example/non-existent-id",
|
|
||||||
headers=auth_headers,
|
|
||||||
)
|
|
||||||
assert response.status_code == 404
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block content %}
|
|
||||||
<h2>Registrieren</h2>
|
|
||||||
<form method="post">
|
|
||||||
<label>Benutzername</label>
|
|
||||||
<input type="text" name="username" required>
|
|
||||||
<label>Passwort</label>
|
|
||||||
<input type="password" name="password" required>
|
|
||||||
<button type="submit" class="btn btn-primary">Registrieren</button>
|
|
||||||
</form>
|
|
||||||
<p style="margin-top:1rem;">Bereits registriert? <a href="{{ url_for('login') }}">Anmelden</a></p>
|
|
||||||
{% endblock %}
|
|
||||||
Reference in New Issue
Block a user