2026-07-23 08:42:26 +02:00
# LeoCRM Plugin Development Guide
2026-07-23 19:01:18 +02:00
> **Version:** 2.0
> **Date:** 2026-07-23
> **Applies to:** All plugin developers
2026-07-23 08:42:26 +02:00
---
2026-07-23 19:01:18 +02:00
## 1. Overview
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
Plugins are self-contained modules that extend LeoCRM's functionality. Each plugin lives in its own directory under `app/plugins/builtins/` and declares its capabilities via a `PluginManifest` . The plugin system supports:
- **API Routes** — Register FastAPI route handlers
- **Event Subscriptions** — React to domain events (contact.created, etc.)
- **Database Migrations** — Versioned SQL migrations run on install
- **RBAC Permissions** — Declare and enforce permissions
- **Frontend UI Contributions** — Menu items, page routes, detail tabs, settings pages, dashboard widgets
- **AI Agent Capabilities** — Register tools for the AI assistant
- **Lifecycle Hooks** — on_install, on_activate, on_deactivate, on_uninstall
---
## 2. Plugin Structure
Every plugin lives under `app/plugins/builtins/<plugin_name>/` :
2026-07-23 08:42:26 +02:00
```
app/plugins/builtins/my_plugin/
2026-07-23 19:01:18 +02:00
├── __init__.py # Package init (can be empty)
├── plugin.py # Plugin class with manifest (required)
├── routes.py # FastAPI route definitions
├── models.py # SQLAlchemy models (optional)
├── schemas.py # Pydantic schemas (optional)
├── services.py # Business logic (optional)
├── migrations/ # SQL migration files
2026-07-23 08:42:26 +02:00
│ └── 0001_initial.sql
2026-07-23 19:01:18 +02:00
└── tests/ # Plugin tests (optional)
├── __init__.py
└── test_plugin.py
```
---
## 3. Manifest Format
The `PluginManifest` is a Pydantic v2 model that declares all metadata and capabilities of a plugin. It is defined in `app/plugins/manifest.py` .
### 3.1 Core Fields
| Field | Type | Required | Description |
|---|---|---|---|
| `name` | `str` | Yes | Unique plugin identifier (snake_case, max 80 chars, alphanumeric with underscores only) |
| `version` | `str` | Yes | Semantic version string (e.g. "1.0.0") |
| `display_name` | `str` | Yes | Human-readable plugin name (max 120 chars) |
| `description` | `str` | No | Plugin description (max 500 chars) |
| `dependencies` | `list[str]` | No | Other plugin names this plugin depends on |
| `is_core` | `bool` | No | Whether this is a core plugin that cannot be deactivated (default: `false` ) |
### 3.2 Route Definitions
``` python
from app . plugins . manifest import PluginRouteDef
routes = [
PluginRouteDef (
path = " /api/v1/my-plugin " , # URL path prefix
module = " app.plugins.builtins.my_plugin.routes " , # Dotted module path
router_attr = " router " , # Attribute name of the APIRouter in the module
) ,
]
```
### 3.3 Event Subscriptions
``` python
events = [
" contact.created " ,
" contact.updated " ,
" contact.deleted " ,
]
```
Event naming convention: `<entity>.<action>` (e.g. `company.created` , `task.assigned` ).
### 3.4 Migrations
``` python
migrations = [
" 0001_initial.sql " ,
" 0002_add_indexes.sql " ,
]
```
Migration files are stored in the plugin's `migrations/` directory and run in order on install.
### 3.5 Permissions
``` python
permissions = [
" my_plugin:read " ,
" my_plugin:write " ,
" my_plugin:delete " ,
" my_plugin:admin " ,
]
```
Permission naming convention: `<plugin_name>:<action>` .
### 3.6 Field Definitions (Field-Level Permissions)
``` python
from app . plugins . manifest import FieldDefinition
field_definitions = [
FieldDefinition (
module = " companies " , # Module name (e.g. 'companies', 'contacts')
field = " annual_revenue " , # Field name
label = " Annual Revenue " , # Human-readable label
sensitivity = " sensitive " , # normal|sensitive|critical
) ,
]
```
### 3.7 Agent Capabilities
``` python
agent_capabilities = [
" contact_search " , # Contact search capability
" email_draft " , # Email draft generation
" calendar_scheduling " , # Calendar scheduling
]
```
### 3.8 Frontend UI Fields (Phase 3)
#### FrontendMenuItem — Sidebar Navigation
``` python
from app . plugins . manifest import FrontendMenuItem
menu_items = [
FrontendMenuItem (
label_key = " nav.myPlugin " , # i18n key for the menu label
label = " My Plugin " , # Fallback label if i18n key is missing
path = " /my-plugin " , # Frontend route path
icon = " Sparkles " , # lucide-react icon name
group = " " , # Optional group label_key for tree-style nesting
order = 100 , # Sort order within the sidebar
badge_key = " " , # Optional store key for badge count
) ,
]
```
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `label_key` | `str` | Yes | — | i18n key for the menu label |
| `label` | `str` | No | `""` | Fallback label if i18n key is missing |
| `path` | `str` | Yes | — | Frontend route path, e.g. `/mail` |
| `icon` | `str` | No | `"FileText"` | lucide-react icon name |
| `group` | `str` | No | `""` | Optional group label_key for tree-style nesting |
| `order` | `int` | No | `100` | Sort order within the sidebar |
| `badge_key` | `str` | No | `""` | Optional store key for badge count |
#### FrontendPageRoute — Page Routes
``` python
from app . plugins . manifest import FrontendPageRoute
page_routes = [
FrontendPageRoute (
path = " /my-plugin " , # Frontend route path
component = " @/pages/MyPlugin " , # Dotted path to the React component
parent = " " , # Parent route path for nested routes
protected = True , # Whether the route requires authentication
order = 100 , # Sort order
) ,
]
```
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `path` | `str` | Yes | — | Frontend route path, e.g. `/mail` or `/mail/settings` |
| `component` | `str` | Yes | — | Dotted path to the React component, e.g. `@/pages/Mail` |
| `parent` | `str` | No | `""` | Parent route path for nested routes (e.g. `/settings` for a settings sub-page) |
| `protected` | `bool` | No | `True` | Whether the route requires authentication |
| `order` | `int` | No | `100` | Sort order |
#### FrontendDetailTab — Entity Detail Tabs
``` python
from app . plugins . manifest import FrontendDetailTab
detail_tabs = [
FrontendDetailTab (
entity_type = " contact " , # Entity type this tab applies to
label_key = " tabs.myPlugin " , # i18n key for the tab label
label = " My Tab " , # Fallback label
component = " @/components/MyTab " , # Dotted path to the React component
icon = " FileText " , # lucide-react icon name
order = 50 , # Sort order within the detail view
permission = " my_plugin:read " , # Optional permission required to see this tab
) ,
]
```
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `entity_type` | `str` | Yes | — | Entity type this tab applies to, e.g. `'contact'` |
| `label_key` | `str` | Yes | — | i18n key for the tab label |
| `label` | `str` | No | `""` | Fallback label |
| `component` | `str` | Yes | — | Dotted path to the React component |
| `icon` | `str` | No | `"FileText"` | lucide-react icon name |
| `order` | `int` | No | `100` | Sort order within the detail view |
| `permission` | `str` | No | `""` | Optional permission required to see this tab |
#### FrontendSettingsPage — Settings Sub-Pages
``` python
from app . plugins . manifest import FrontendSettingsPage
settings_pages = [
FrontendSettingsPage (
path = " my-plugin " , # Settings sub-route path
label_key = " settings.myPlugin " , # i18n key for the settings nav label
label = " My Plugin " , # Fallback label
component = " @/pages/MyPluginSettings " , # Dotted path to the React component
icon = " Sparkles " , # lucide-react icon name
order = 100 , # Sort order within settings nav
permission = " my_plugin:admin " , # Optional permission required
) ,
]
2026-07-23 08:42:26 +02:00
```
2026-07-23 19:01:18 +02:00
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `path` | `str` | Yes | — | Settings sub-route path, e.g. `mail` or `notifications` |
| `label_key` | `str` | Yes | — | i18n key for the settings nav label |
| `label` | `str` | No | `""` | Fallback label |
| `component` | `str` | Yes | — | Dotted path to the React component |
| `icon` | `str` | No | `"Settings"` | lucide-react icon name |
| `order` | `int` | No | `100` | Sort order within settings nav |
| `permission` | `str` | No | `""` | Optional permission required |
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
#### FrontendDashboardWidget — Dashboard Widgets
``` python
from app . plugins . manifest import FrontendDashboardWidget
dashboard_widgets = [
FrontendDashboardWidget (
id = " my_plugin_stats " , # Unique widget identifier
label_key = " widgets.myPlugin " , # i18n key for the widget title
label = " My Plugin Stats " , # Fallback label
component = " @/components/MyWidget " , # Dotted path to the React component
icon = " LayoutDashboard " , # lucide-react icon name
order = 100 , # Sort order on the dashboard
col_span = 1 , # Grid column span (1-4)
row_span = 1 , # Grid row span
permission = " my_plugin:read " , # Optional permission required
) ,
]
```
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `id` | `str` | Yes | — | Unique widget identifier |
| `label_key` | `str` | Yes | — | i18n key for the widget title |
| `label` | `str` | No | `""` | Fallback label |
| `component` | `str` | Yes | — | Dotted path to the React component |
| `icon` | `str` | No | `"LayoutDashboard"` | lucide-react icon name |
| `order` | `int` | No | `100` | Sort order on the dashboard |
| `col_span` | `int` | No | `1` | Grid column span (1-4) |
| `row_span` | `int` | No | `1` | Grid row span |
| `permission` | `str` | No | `""` | Optional permission required |
### 3.9 Complete Manifest Example
2026-07-23 08:42:26 +02:00
``` python
from app . plugins . base import BasePlugin
2026-07-23 19:01:18 +02:00
from app . plugins . manifest import (
PluginManifest , PluginRouteDef , FieldDefinition ,
FrontendMenuItem , FrontendPageRoute , FrontendDetailTab ,
FrontendSettingsPage , FrontendDashboardWidget ,
)
2026-07-23 08:42:26 +02:00
class MyPlugin ( BasePlugin ) :
manifest = PluginManifest (
name = " my_plugin " ,
version = " 1.0.0 " ,
display_name = " My Plugin " ,
2026-07-23 19:01:18 +02:00
description = " A comprehensive example plugin. " ,
dependencies = [ " permissions " ] ,
is_core = False ,
2026-07-23 08:42:26 +02:00
routes = [
PluginRouteDef (
path = " /api/v1/my-plugin " ,
module = " app.plugins.builtins.my_plugin.routes " ,
router_attr = " router " ,
) ,
] ,
events = [ " contact.created " , " contact.updated " ] ,
migrations = [ " 0001_initial.sql " ] ,
2026-07-23 19:01:18 +02:00
permissions = [ " my_plugin:read " , " my_plugin:write " , " my_plugin:admin " ] ,
field_definitions = [
FieldDefinition (
module = " contacts " ,
field = " custom_field " ,
label = " Custom Field " ,
sensitivity = " normal " ,
) ,
] ,
agent_capabilities = [ " my_plugin:search " ] ,
menu_items = [
FrontendMenuItem (
label_key = " nav.myPlugin " ,
label = " My Plugin " ,
path = " /my-plugin " ,
icon = " Sparkles " ,
order = 100 ,
) ,
] ,
page_routes = [
FrontendPageRoute (
path = " /my-plugin " ,
component = " @/pages/MyPlugin " ,
protected = True ,
) ,
] ,
detail_tabs = [
FrontendDetailTab (
entity_type = " contact " ,
label_key = " tabs.myPlugin " ,
label = " My Tab " ,
component = " @/components/MyTab " ,
icon = " FileText " ,
order = 50 ,
permission = " my_plugin:read " ,
) ,
] ,
settings_pages = [
FrontendSettingsPage (
path = " my-plugin " ,
label_key = " settings.myPlugin " ,
label = " My Plugin " ,
component = " @/pages/MyPluginSettings " ,
icon = " Sparkles " ,
order = 100 ,
permission = " my_plugin:admin " ,
) ,
2026-07-23 08:42:26 +02:00
] ,
2026-07-23 19:01:18 +02:00
dashboard_widgets = [
FrontendDashboardWidget (
id = " my_plugin_stats " ,
label_key = " widgets.myPlugin " ,
label = " My Plugin Stats " ,
component = " @/components/MyWidget " ,
icon = " LayoutDashboard " ,
order = 100 ,
col_span = 2 ,
row_span = 1 ,
permission = " my_plugin:read " ,
) ,
2026-07-23 08:42:26 +02:00
] ,
)
```
2026-07-23 19:01:18 +02:00
---
## 4. Plugin Lifecycle
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
### 4.1 Installation (`on_install`)
Called when the plugin is installed, after migrations are run. Override to perform seed data or initial setup.
``` python
async def on_install ( self , db : AsyncSession , service_container : ServiceContainer ) - > None :
""" Perform initial setup after migrations. """
# Create default settings
settings_service = service_container . settings
await settings_service . create_defaults ( db , plugin_name = self . name )
```
### 4.2 Activation (`on_activate`)
Called when the plugin is activated. Override to register event listeners and prepare runtime state. The default implementation subscribes to events listed in the manifest.
``` python
async def on_activate (
self , db : AsyncSession , service_container : ServiceContainer , event_bus : EventBus
) - > None :
""" Register event listeners and prepare runtime state. """
# Default: subscribes to manifest events
for event_name in self . manifest . events :
handler = self . _make_event_handler ( event_name )
self . _event_handlers [ event_name ] = handler
event_bus . subscribe ( event_name , handler )
self . _container = service_container
```
### 4.3 Deactivation (`on_deactivate`)
Called when the plugin is deactivated. Override to clean up runtime state. The default implementation unsubscribes all event listeners.
``` python
async def on_deactivate (
self , db : AsyncSession , service_container : ServiceContainer , event_bus : EventBus
) - > None :
""" Clean up runtime state. """
for event_name , handler in self . _event_handlers . items ( ) :
event_bus . unsubscribe ( event_name , handler )
self . _event_handlers . clear ( )
```
### 4.4 Uninstallation (`on_uninstall`)
Called when the plugin is uninstalled (before data tables are dropped). Override to clean up external resources.
``` python
async def on_uninstall ( self , db : AsyncSession , service_container : ServiceContainer ) - > None :
""" Clean up external resources before tables are dropped. """
# Remove external API webhooks, etc.
pass
```
---
## 5. UI Registration
Plugins contribute frontend UI elements through their manifest. The frontend `PluginRegistry` component fetches active manifests and populates the `pluginStore` (Zustand), which is then consumed by:
### 5.1 Sidebar Menu Items
Menu items from all active plugins are merged and sorted by `order` via `getAllMenuItems()` . The sidebar renders them alongside built-in navigation items.
### 5.2 Page Routes
Page routes are registered via `PluginRouteRenderer` , a catch-all route handler that checks the current URL against all plugin `page_routes` . When a match is found, it renders the plugin's page component using `PluginPage` (React.lazy + Suspense + ErrorBoundary).
### 5.3 Detail Tabs
Detail tabs are filtered by `entity_type` via `getDetailTabsForEntity(entityType)` . Entity detail views (contacts, companies, etc.) render these tabs alongside built-in tabs.
### 5.4 Settings Pages
Settings pages are merged and sorted via `getAllSettingsPages()` . The settings navigation renders them alongside built-in settings pages.
### 5.5 Dashboard Widgets
Dashboard widgets are merged and sorted via `getAllDashboardWidgets()` . The dashboard grid renders them with their specified `col_span` and `row_span` .
### 5.6 Frontend Component Resolution
Component paths use the `@/` alias (resolved to `src/` by Vite). The `PluginPage` component converts `@/pages/MyPlugin` to `../pages/MyPlugin` for dynamic import:
``` typescript
// PluginLoader.tsx
const importPath = componentPath . replace ( /^@\// , '../' ) ;
const LazyComp = lazy ( ( ) = >
import ( /* @vite-ignore */ importPath ) . then ( ( m ) = > ( {
default : m . default || m [ Object . keys ( m ) [ 0 ] ] ,
} ) )
) ;
```
---
## 6. Event Bus
### 6.1 Subscribing to Events
Events are declared in the manifest and handled by methods named `on_<event_name>` with dots replaced by underscores:
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
``` python
# Manifest
events = [ " contact.created " , " contact.updated " ]
# Handler methods
async def on_contact_created ( self , event_data : dict ) :
contact_id = event_data . get ( " contact_id " )
# React to new contact
async def on_contact_updated ( self , event_data : dict ) :
contact_id = event_data . get ( " contact_id " )
# React to contact update
```
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
### 6.2 Publishing Events
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
Events are published via the EventBus service:
``` python
from app . core . event_bus import get_event_bus
event_bus = get_event_bus ( )
await event_bus . publish ( " contact.created " , { " contact_id " : str ( contact . id ) } )
```
### 6.3 Event Naming Conventions
- Format: `<entity>.<action>`
- Standard actions: `created` , `updated` , `deleted` , `assigned` , `completed`
- Examples: `company.created` , `task.assigned` , `email.sent`
- Use past tense for actions
---
## 7. Migration Runner
### 7.1 Writing Migrations
SQL migration files are stored in the plugin's `migrations/` directory and referenced in the manifest:
``` sql
-- migrations/0001_initial.sql
CREATE TABLE my_plugin_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid ( ) ,
tenant_id UUID NOT NULL REFERENCES tenants ( id ) ,
name VARCHAR ( 200 ) NOT NULL ,
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 idx_my_plugin_items_tenant ON my_plugin_items ( tenant_id ) ;
```
### 7.2 Migration Naming
- Files are named with a zero-padded sequence number and a descriptive slug: `0001_initial.sql` , `0002_add_indexes.sql`
- Migrations run in alphanumeric order
- Each migration runs exactly once per plugin installation
### 7.3 Migration Guidelines
- Always include `tenant_id` for multi-tenant tables
- Use `UUID` primary keys with `gen_random_uuid()`
- Include `created_at` and `updated_at` timestamps
- Add appropriate indexes for foreign keys and frequently queried columns
- Use `IF NOT EXISTS` / `IF EXISTS` for idempotent operations
---
## 8. Service Container
After activation, plugins can access shared services via `self.services` :
``` python
class MyPlugin ( BasePlugin ) :
async def on_activate ( self , db , service_container , event_bus ) :
self . _container = service_container
async def do_something ( self ) :
# Access services after activation
settings = self . services . settings
audit = self . services . audit
cache = self . services . cache
```
Available services (defined in `app/core/service_container.py` ):
| Service | Accessor | Description |
|---|---|---|
| Settings | `self.services.settings` | Global and per-tenant settings |
| Audit | `self.services.audit` | Audit logging |
| Cache | `self.services.cache` | Redis/In-memory cache |
| EventBus | `self.services.event_bus` | Event publishing/subscribing |
| Notifications | `self.services.notifications` | User notifications |
---
## 9. RBAC / Permissions
### 9.1 Declaring Permissions
Permissions are declared in the manifest:
2026-07-23 08:42:26 +02:00
``` python
permissions = [
" my_plugin:read " ,
" my_plugin:write " ,
" my_plugin:delete " ,
" my_plugin:admin " ,
2026-07-23 19:01:18 +02:00
]
2026-07-23 08:42:26 +02:00
```
2026-07-23 19:01:18 +02:00
### 9.2 Securing Routes
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
Use the `require_permission` dependency:
2026-07-23 08:42:26 +02:00
``` python
from app . deps import get_current_user , require_permission
from fastapi import Depends
@router.get ( " " , dependencies = [ Depends ( require_permission ( " my_plugin:read " ) ) ] )
async def list_items ( current_user : dict = Depends ( get_current_user ) ) :
. . .
@router.post ( " " , status_code = 201 , dependencies = [ Depends ( require_permission ( " my_plugin:write " ) ) ] )
async def create_item ( data : ItemCreate , current_user : dict = Depends ( get_current_user ) ) :
. . .
```
2026-07-23 19:01:18 +02:00
### 9.3 Permission Naming Convention
2026-07-23 08:42:26 +02:00
- Format: `<plugin_name>:<action>`
2026-07-23 19:01:18 +02:00
- Standard actions: `read` , `write` , `delete` , `share` , `admin`
- Examples: `calendar:read` , `dms:write` , `tags:delete`
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
### 9.4 Field-Level Permissions
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
Field definitions in the manifest enable field-level access control:
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
``` python
field_definitions = [
FieldDefinition (
module = " companies " ,
field = " annual_revenue " ,
label = " Annual Revenue " ,
sensitivity = " sensitive " , # normal|sensitive|critical
) ,
]
2026-07-23 08:42:26 +02:00
```
2026-07-23 19:01:18 +02:00
---
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
## 10. AI Agent Integration
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
### 10.1 Agent Capabilities
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
Declare AI capabilities in the manifest:
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
``` python
agent_capabilities = [
" contact_search " ,
" email_draft " ,
" calendar_scheduling " ,
]
```
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
### 10.2 Tool Registry
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
Plugins can register tools for the AI assistant:
2026-07-23 08:42:26 +02:00
``` python
from app . plugins . builtins . ai_assistant . tool_registry import get_tool_registry
registry = get_tool_registry ( )
registry . register (
name = " search_contacts " ,
description = " Search contacts by name, email, or phone number " ,
parameters = {
" type " : " object " ,
" properties " : {
" query " : { " type " : " string " , " description " : " Search query " } ,
" limit " : { " type " : " integer " , " description " : " Max results " , " default " : 10 } ,
} ,
" required " : [ " query " ] ,
} ,
handler = my_search_handler ,
plugin_name = " my_plugin " ,
required_permission = " contacts:read " ,
category = " search " ,
)
```
2026-07-23 19:01:18 +02:00
### 10.3 Tool Handler
2026-07-23 08:42:26 +02:00
``` python
async def my_search_handler ( arguments : dict , context : dict ) - > str :
query = arguments . get ( " query " , " " )
limit = arguments . get ( " limit " , 10 )
2026-07-23 19:01:18 +02:00
# Perform search...
2026-07-23 08:42:26 +02:00
return json . dumps ( { " results " : results } )
```
2026-07-23 19:01:18 +02:00
### 10.4 Cleanup on Deactivation
2026-07-23 08:42:26 +02:00
``` python
2026-07-23 19:01:18 +02:00
async def on_deactivate ( self , db , service_container , event_bus ) :
2026-07-23 08:42:26 +02:00
registry = get_tool_registry ( )
registry . unregister_plugin ( " my_plugin " )
```
2026-07-23 19:01:18 +02:00
---
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
## 11. Testing Guide
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
### 11.1 Backend Tests
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
Tests live in the plugin's `tests/` directory or in the central `tests/` folder:
2026-07-23 08:42:26 +02:00
``` python
# tests/test_my_plugin.py
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
async def test_list_items_requires_permission ( client : AsyncClient , auth_headers ) :
response = await client . get ( " /api/v1/my-plugin " , headers = auth_headers )
assert response . status_code == 200
@pytest.mark.asyncio
async def test_list_items_without_permission_returns_403 ( client : AsyncClient , no_perm_headers ) :
response = await client . get ( " /api/v1/my-plugin " , headers = no_perm_headers )
assert response . status_code == 403
```
2026-07-23 19:01:18 +02:00
### 11.2 Frontend Tests
Frontend tests use vitest, @testing -library/react, and jsdom:
``` typescript
// frontend/src/components/plugins/__tests__/PluginRegistry.test.tsx
import React from 'react' ;
import { describe , it , expect , vi , beforeEach } from 'vitest' ;
import { render } from '@testing-library/react' ;
import { PluginRegistry } from '../PluginRegistry' ;
import { usePluginStore } from '@/store/pluginStore' ;
// Mock the API hook
vi . mock ( '@/api/pluginManifests' , ( ) = > ( {
useActivePluginManifests : ( ) = > ( {
data : { plugins : [ /* ... */ ] , total : 1 } ,
isLoading : false ,
error : null ,
} ) ,
} ) ) ;
describe ( 'PluginRegistry' , ( ) = > {
beforeEach ( ( ) = > {
usePluginStore . getState ( ) . reset ( ) ;
} ) ;
it ( 'populates store with manifests on mount' , ( ) = > {
render ( < PluginRegistry / > ) ;
const manifests = usePluginStore . getState ( ) . manifests ;
expect ( manifests ) . toHaveLength ( 1 ) ;
} ) ;
} ) ;
```
---
## 12. Do's and Don'ts
### Do's
- **Do** use snake_case for plugin names
- **Do** declare all permissions in the manifest
- **Do** secure every API route with `require_permission`
- **Do** include `tenant_id` in all multi-tenant tables
- **Do** use UUID primary keys
- **Do** prefix i18n keys with the plugin name
- **Do** clean up resources in `on_deactivate` and `on_uninstall`
- **Do** write tests for both backend and frontend
- **Do** follow the event naming convention `<entity>.<action>`
- **Do** use semantic versioning for plugin versions
### Don'ts
- **Don't** hardcode tenant IDs or user IDs
- **Don't** use synchronous database operations
- **Don't** store secrets in the database without encryption
- **Don't** modify other plugins' data directly (use events instead)
- **Don't** create circular dependencies between plugins
- **Don't** use `is_core=True` unless the plugin is essential for system operation
- **Don't** skip error handling in event handlers (they run asynchronously)
- **Don't** register the same route path in multiple plugins
---
## 13. Examples
2026-07-23 08:42:26 +02:00
2026-07-23 19:01:18 +02:00
### 13.1 Minimal Plugin
2026-07-23 08:42:26 +02:00
``` python
2026-07-23 19:01:18 +02:00
# app/plugins/builtins/minimal_example/plugin.py
2026-07-23 08:42:26 +02:00
from app . plugins . base import BasePlugin
from app . plugins . manifest import PluginManifest , PluginRouteDef
2026-07-23 19:01:18 +02:00
class MinimalExamplePlugin ( BasePlugin ) :
2026-07-23 08:42:26 +02:00
manifest = PluginManifest (
2026-07-23 19:01:18 +02:00
name = " minimal_example " ,
2026-07-23 08:42:26 +02:00
version = " 1.0.0 " ,
2026-07-23 19:01:18 +02:00
display_name = " Minimal Example " ,
2026-07-23 08:42:26 +02:00
description = " A minimal example plugin. " ,
dependencies = [ ] ,
routes = [
PluginRouteDef (
2026-07-23 19:01:18 +02:00
path = " /api/v1/minimal-example " ,
module = " app.plugins.builtins.minimal_example.routes " ,
2026-07-23 08:42:26 +02:00
router_attr = " router " ,
) ,
] ,
events = [ ] ,
migrations = [ ] ,
2026-07-23 19:01:18 +02:00
permissions = [ " minimal_example:read " ] ,
2026-07-23 08:42:26 +02:00
)
```
``` python
2026-07-23 19:01:18 +02:00
# app/plugins/builtins/minimal_example/routes.py
2026-07-23 08:42:26 +02:00
from fastapi import APIRouter , Depends
from app . deps import get_current_user , require_permission
router = APIRouter ( )
2026-07-23 19:01:18 +02:00
@router.get ( " " , dependencies = [ Depends ( require_permission ( " minimal_example:read " ) ) ] )
2026-07-23 08:42:26 +02:00
async def list_items ( current_user : dict = Depends ( get_current_user ) ) :
return { " items " : [ ] }
```
2026-07-23 19:01:18 +02:00
### 13.2 Plugin with UI
See the complete manifest example in Section 3.9 for a plugin with full UI contributions (menu items, page routes, detail tabs, settings pages, dashboard widgets).
### 13.3 Plugin with Events
``` python
# app/plugins/builtins/event_example/plugin.py
from app . plugins . base import BasePlugin
from app . plugins . manifest import PluginManifest
class EventExamplePlugin ( BasePlugin ) :
manifest = PluginManifest (
name = " event_example " ,
version = " 1.0.0 " ,
display_name = " Event Example " ,
description = " Demonstrates event handling. " ,
dependencies = [ ] ,
events = [ " contact.created " , " contact.updated " , " contact.deleted " ] ,
migrations = [ ] ,
permissions = [ ] ,
)
async def on_contact_created ( self , event_data : dict ) :
contact_id = event_data . get ( " contact_id " )
print ( f " Contact created: { contact_id } " )
async def on_contact_updated ( self , event_data : dict ) :
contact_id = event_data . get ( " contact_id " )
print ( f " Contact updated: { contact_id } " )
async def on_contact_deleted ( self , event_data : dict ) :
contact_id = event_data . get ( " contact_id " )
print ( f " Contact deleted: { contact_id } " )
```
2026-07-23 08:42:26 +02:00
---
2026-07-23 19:01:18 +02:00
* This document is authoritative for all plugin development at LeoCRM. *