Phase 3: Plugin-UI-System (WordPress-Style)
Backend: - PluginManifest um 5 neue UI-Felder erweitert: menu_items, page_routes, detail_tabs, settings_pages, dashboard_widgets (FrontendMenuItem, FrontendPageRoute, FrontendDetailTab, FrontendSettingsPage, FrontendDashboardWidget) - GET /api/v1/plugins/active-manifests Endpoint liefert UI-Manifeste aller aktiven Plugins - Registry.get_active_manifests() + PluginService.get_active_manifests() - 12 Built-in Plugins mit UI-Manifest-Daten gefuellt (menu_items, page_routes, detail_tabs, settings_pages) - Plugin-Install-System: POST /upload (ZIP), POST /install-url (URL) mit Validierung (Manifest, dangerous imports, SQL migrations) Frontend: - pluginStore.ts (Zustand) mit PluginUiManifest Typen + Selektoren - useActivePluginManifests() React Query Hook - PluginRegistry.tsx — fetcht Manifeste beim App-Start - PluginLoader.tsx — dynamisches React.lazy() mit ErrorBoundary - PluginRouteRenderer.tsx — Catch-all fuer Plugin-Routes - routes/index.tsx — Catch-all Routes fuer Plugin-Pages + Settings - Sidebar.tsx — dynamische Plugin Menu-Items mit Grouping + Icons - Settings.tsx — dynamische Plugin Settings-Pages - ContactDetail.tsx — dynamische Plugin Detail-Tabs mit Permissions - AppShell.tsx — PluginRegistry Provider eingebunden - SettingsPlugins.tsx — Install-UI (ZIP Upload + URL Install) - plugins.ts — useUploadPlugin() + useInstallPluginFromUrl() Hooks Docs & Templates: - docs/plugin-development-guide.md — komplette Entwickler-Doku - templates/plugin-template/ — Boilerplate mit allen Manifest-Feldern Tests: - 34 Vitest-Tests (PluginRegistry, PluginLoader, PluginRouteRenderer, pluginStore) — alle bestanden - TSC: keine neuen Errors (nur pre-existing Dms.tsx)
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
# 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
|
||||
@@ -0,0 +1 @@
|
||||
"""Plugin template package."""
|
||||
@@ -0,0 +1,17 @@
|
||||
-- 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);
|
||||
@@ -0,0 +1,38 @@
|
||||
"""
|
||||
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")
|
||||
@@ -0,0 +1,178 @@
|
||||
"""
|
||||
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,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
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"}
|
||||
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
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
|
||||
@@ -0,0 +1,39 @@
|
||||
"""
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the example plugin."""
|
||||
@@ -0,0 +1,63 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user