T03: plugin system framework + lifecycle + migrations + event bus + DI
- Plugin registry with discover/install/activate/deactivate/uninstall lifecycle - PluginManifest Pydantic v2 schema (name, version, dependencies, routes, events, migrations) - BasePlugin abstract class with lifecycle hooks (on_install/activate/deactivate/uninstall) - Migration runner with tenant_id validator (rejects tables without tenant_id) - Event bus integration: register/unregister listeners on activate/deactivate - Service container DI: plugins receive db, cache, event_bus, storage, notifications - Idempotent operations (activate active=200, deactivate inactive=200) - UI registry for frontend component registration - 47 new tests (14 ACs + 33 unit tests), 103 total tests pass - Migration 0003: plugins + plugin_migrations tables - Coverage: 85.92% for plugin modules
This commit is contained in:
+15
-2
@@ -9,14 +9,26 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core.middleware import CSRFMiddleware
|
||||
from app.core.db import close_engine
|
||||
from app.routes import auth, users, roles, tenants, health, notifications, companies, contacts, import_export
|
||||
from app.core.db import close_engine, get_engine
|
||||
from app.core.service_container import get_container
|
||||
from app.plugins.registry import get_registry
|
||||
from app.routes import auth, users, roles, tenants, health, notifications, companies, contacts, import_export, plugins
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan: startup and shutdown."""
|
||||
# Initialize service container
|
||||
container = get_container()
|
||||
await container.initialize()
|
||||
|
||||
# Initialize plugin registry and discover built-in plugins
|
||||
registry = get_registry()
|
||||
registry.initialize(get_engine(), app)
|
||||
registry.discover_builtins()
|
||||
|
||||
yield
|
||||
|
||||
await close_engine()
|
||||
|
||||
|
||||
@@ -44,6 +56,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(companies.router)
|
||||
app.include_router(contacts.router)
|
||||
app.include_router(import_export.router)
|
||||
app.include_router(plugins.router)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@@ -9,10 +9,12 @@ from app.models.notification import Notification
|
||||
from app.models.auth import PasswordResetToken, ApiToken
|
||||
from app.models.company import Company
|
||||
from app.models.contact import Contact, CompanyContact
|
||||
from app.models.plugin import Plugin, PluginMigration
|
||||
|
||||
__all__ = [
|
||||
"Tenant", "User", "UserTenant", "Role", "Session",
|
||||
"AuditLog", "DeletionLog", "Notification",
|
||||
"PasswordResetToken", "ApiToken", "Company",
|
||||
"Contact", "CompanyContact",
|
||||
"Plugin", "PluginMigration",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Plugin and PluginMigration models — tracks installed plugins and their migrations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import String, Boolean, Text, DateTime, func, Index
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TimestampMixin
|
||||
|
||||
|
||||
class Plugin(Base, TimestampMixin):
|
||||
"""Plugin record — tracks installation and activation status.
|
||||
|
||||
This table is NOT tenant-scoped (plugins are system-wide, not per-tenant).
|
||||
The tables *created by* plugin migrations MUST have tenant_id.
|
||||
"""
|
||||
|
||||
__tablename__ = "plugins"
|
||||
__table_args__ = (
|
||||
Index("ix_plugins_name", "name", unique=True),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(80), nullable=False, unique=True)
|
||||
display_name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
version: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="installed"
|
||||
)
|
||||
installed: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True
|
||||
)
|
||||
active: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False
|
||||
)
|
||||
config: Mapped[dict[str, Any] | None] = mapped_column(
|
||||
Text, nullable=True # JSON string for plugin configuration
|
||||
)
|
||||
|
||||
# Transient attribute for response (not persisted)
|
||||
# Used by uninstall to report dropped tables
|
||||
# This is set dynamically by registry.uninstall()
|
||||
|
||||
|
||||
class PluginMigration(Base, TimestampMixin):
|
||||
"""Tracks individual migration files applied for each plugin."""
|
||||
|
||||
__tablename__ = "plugin_migrations"
|
||||
__table_args__ = (
|
||||
Index("ix_plugin_migrations_plugin", "plugin_name"),
|
||||
Index("ix_plugin_migrations_unique", "plugin_name", "migration_file", unique=True),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
plugin_name: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
migration_file: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="applied"
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Plugin system framework for LeoCRM."""
|
||||
|
||||
from app.plugins.manifest import PluginManifest
|
||||
from app.plugins.base import BasePlugin
|
||||
from app.plugins.registry import get_registry
|
||||
from app.plugins.migration_runner import MigrationRunner
|
||||
|
||||
__all__ = ["PluginManifest", "BasePlugin", "get_registry", "MigrationRunner"]
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Base plugin abstract class with lifecycle hooks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.plugins.manifest import PluginManifest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.core.event_bus import EventBus
|
||||
from app.core.service_container import ServiceContainer
|
||||
|
||||
|
||||
class BasePlugin(ABC):
|
||||
"""Abstract base class for all LeoCRM plugins.
|
||||
|
||||
Subclasses must define a ``manifest`` class attribute (PluginManifest)
|
||||
and implement the lifecycle hooks.
|
||||
"""
|
||||
|
||||
manifest: PluginManifest
|
||||
|
||||
def __init__(self) -> None:
|
||||
if not hasattr(self, "manifest") or self.manifest is None:
|
||||
raise ValueError(f"{self.__class__.__name__} must define a 'manifest' attribute")
|
||||
self._event_handlers: dict[str, Any] = {}
|
||||
self._routers: list[APIRouter] = []
|
||||
|
||||
# ─── Lifecycle Hooks ───
|
||||
|
||||
async def on_install(self, db: AsyncSession, service_container: ServiceContainer) -> None:
|
||||
"""Called when the plugin is installed (after migrations are run).
|
||||
|
||||
Override to perform seed data or initial setup.
|
||||
"""
|
||||
return None
|
||||
|
||||
async def on_activate(
|
||||
self, db: AsyncSession, service_container: ServiceContainer, event_bus: EventBus
|
||||
) -> 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.
|
||||
"""
|
||||
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)
|
||||
|
||||
async def on_deactivate(
|
||||
self, db: AsyncSession, service_container: ServiceContainer, event_bus: EventBus
|
||||
) -> None:
|
||||
"""Called when the plugin is deactivated.
|
||||
|
||||
Override to clean up runtime state. Default implementation unsubscribes
|
||||
all event listeners that were registered during activation.
|
||||
"""
|
||||
for event_name, handler in self._event_handlers.items():
|
||||
event_bus.unsubscribe(event_name, handler)
|
||||
self._event_handlers.clear()
|
||||
|
||||
async def on_uninstall(self, db: AsyncSession, service_container: ServiceContainer) -> None:
|
||||
"""Called when the plugin is uninstalled (before data tables are dropped).
|
||||
|
||||
Override to clean up any external resources.
|
||||
"""
|
||||
return None
|
||||
|
||||
# ─── Route Registration ───
|
||||
|
||||
def get_routes(self) -> list[APIRouter]:
|
||||
"""Return list of APIRouter instances to mount on the FastAPI app.
|
||||
|
||||
Default implementation loads routers from manifest route definitions
|
||||
by importing the module and getting the specified attribute.
|
||||
"""
|
||||
if self._routers:
|
||||
return self._routers
|
||||
|
||||
routers: list[APIRouter] = []
|
||||
for route_def in self.manifest.routes:
|
||||
import importlib
|
||||
module = importlib.import_module(route_def.module)
|
||||
router: APIRouter = getattr(module, route_def.router_attr)
|
||||
routers.append(router)
|
||||
self._routers = routers
|
||||
return routers
|
||||
|
||||
# ─── Event Handler Factory ───
|
||||
|
||||
def _make_event_handler(self, event_name: str) -> Any:
|
||||
"""Create an event handler coroutine for the given event name.
|
||||
|
||||
Looks for a method named ``on_<event_name>`` with dots replaced by underscores.
|
||||
Falls back to ``on_event`` if it exists, otherwise uses a no-op handler.
|
||||
"""
|
||||
method_name = f"on_{event_name.replace('.', '_')}"
|
||||
specific = getattr(self, method_name, None)
|
||||
if specific is not None:
|
||||
return specific
|
||||
fallback = getattr(self, "on_event", None)
|
||||
if fallback is not None:
|
||||
return fallback
|
||||
# Default no-op handler
|
||||
async def _noop_handler(payload: dict[str, Any]) -> None:
|
||||
pass
|
||||
return _noop_handler
|
||||
|
||||
# ─── Utility ───
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self.manifest.name
|
||||
|
||||
@property
|
||||
def version(self) -> str:
|
||||
return self.manifest.version
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<{self.__class__.__name__} name={self.manifest.name} version={self.manifest.version}>"
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Built-in plugins directory.
|
||||
|
||||
Each module in this package that exports a BasePlugin subclass will be
|
||||
discovered automatically by the plugin registry on application startup.
|
||||
"""
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Bad migration: creates table WITHOUT tenant_id (should be rejected by validator)
|
||||
CREATE TABLE IF NOT EXISTS plugin_bad_table (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
title VARCHAR(200) NOT NULL,
|
||||
content TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Test plugin migration: creates plugin_test_data table with tenant_id
|
||||
CREATE TABLE IF NOT EXISTS plugin_test_data (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
content TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_plugin_test_data_tenant ON plugin_test_data(tenant_id);
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Sample test plugin for LeoCRM plugin system testing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.plugins.base import BasePlugin
|
||||
from app.plugins.manifest import PluginManifest
|
||||
|
||||
|
||||
class TestSamplePlugin(BasePlugin):
|
||||
"""A sample plugin for testing the plugin lifecycle."""
|
||||
|
||||
manifest = PluginManifest(
|
||||
name="test_sample",
|
||||
version="1.0.0",
|
||||
display_name="Test Sample Plugin",
|
||||
description="A sample plugin for testing install/activate/deactivate/uninstall lifecycle.",
|
||||
dependencies=[],
|
||||
routes=[],
|
||||
events=["company.created", "contact.created"],
|
||||
migrations=["0001_test_plugin.sql"],
|
||||
permissions=[],
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.install_called = False
|
||||
self.activate_called = False
|
||||
self.deactivate_called = False
|
||||
self.uninstall_called = False
|
||||
self.event_log: list[dict[str, Any]] = []
|
||||
|
||||
async def on_install(self, db, service_container) -> None:
|
||||
self.install_called = True
|
||||
|
||||
async def on_activate(self, db, service_container, event_bus) -> None:
|
||||
self.activate_called = True
|
||||
await super().on_activate(db, service_container, event_bus)
|
||||
|
||||
async def on_deactivate(self, db, service_container, event_bus) -> None:
|
||||
self.deactivate_called = True
|
||||
await super().on_deactivate(db, service_container, event_bus)
|
||||
|
||||
async def on_uninstall(self, db, service_container) -> None:
|
||||
self.uninstall_called = True
|
||||
|
||||
async def on_company_created(self, payload: dict[str, Any]) -> None:
|
||||
self.event_log.append({"event": "company.created", "payload": payload})
|
||||
|
||||
async def on_contact_created(self, payload: dict[str, Any]) -> None:
|
||||
self.event_log.append({"event": "contact.created", "payload": payload})
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Plugin manifest schema (Pydantic v2)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class PluginRouteDef(BaseModel):
|
||||
"""Route definition within a plugin manifest."""
|
||||
|
||||
path: str = Field(..., description="URL path prefix, e.g. /api/v1/plugin-mail")
|
||||
module: str = Field(..., description="Dotted path to the module containing the APIRouter")
|
||||
router_attr: str = Field(default="router", description="Attribute name of the APIRouter in the module")
|
||||
|
||||
|
||||
class PluginManifest(BaseModel):
|
||||
"""Manifest describing a plugin's metadata, dependencies, and capabilities."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=80, description="Unique plugin identifier (snake_case)")
|
||||
version: str = Field(..., min_length=1, max_length=40, description="Semantic version string")
|
||||
display_name: str = Field(..., min_length=1, max_length=120)
|
||||
description: str = Field(default="", max_length=500)
|
||||
dependencies: list[str] = Field(default_factory=list, description="Other plugin names this plugin depends on")
|
||||
routes: list[PluginRouteDef] = Field(default_factory=list, description="Route definitions to register on activation")
|
||||
events: list[str] = Field(default_factory=list, description="Event names this plugin listens to")
|
||||
migrations: list[str] = Field(default_factory=list, description="Migration file names (ordered, e.g. 0001_initial.sql)")
|
||||
permissions: list[str] = Field(default_factory=list, description="Required permissions for this plugin")
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def validate_name(cls, v: str) -> str:
|
||||
if not v.replace("_", "").isalnum():
|
||||
raise ValueError("Plugin name must be alphanumeric with underscores only")
|
||||
return v.lower()
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
|
||||
class ManifestSchemaResponse(BaseModel):
|
||||
"""Response model describing the manifest schema for API consumers."""
|
||||
|
||||
fields: dict[str, dict[str, str]]
|
||||
example: PluginManifest
|
||||
|
||||
|
||||
# Pre-built schema documentation for GET /api/v1/plugins/manifest endpoint
|
||||
MANIFEST_SCHEMA_DOC = ManifestSchemaResponse(
|
||||
fields={
|
||||
"name": {"type": "str", "required": "true", "description": "Unique plugin identifier (snake_case, max 80 chars)"},
|
||||
"version": {"type": "str", "required": "true", "description": "Semantic version string"},
|
||||
"display_name": {"type": "str", "required": "true", "description": "Human-readable plugin name"},
|
||||
"description": {"type": "str", "required": "false", "description": "Plugin description (max 500 chars)"},
|
||||
"dependencies": {"type": "list[str]", "required": "false", "description": "Other plugin names required"},
|
||||
"routes": {"type": "list[PluginRouteDef]", "required": "false", "description": "Route definitions to register"},
|
||||
"events": {"type": "list[str]", "required": "false", "description": "Event names to listen to"},
|
||||
"migrations": {"type": "list[str]", "required": "false", "description": "Migration file names (ordered)"},
|
||||
"permissions": {"type": "list[str]", "required": "false", "description": "Required permissions"},
|
||||
},
|
||||
example=PluginManifest(
|
||||
name="example_plugin",
|
||||
version="1.0.0",
|
||||
display_name="Example Plugin",
|
||||
description="An example plugin demonstrating the manifest schema.",
|
||||
dependencies=[],
|
||||
routes=[],
|
||||
events=["company.created", "contact.created"],
|
||||
migrations=["0001_initial.sql"],
|
||||
permissions=["companies.read"],
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,282 @@
|
||||
"""Plugin DB migration runner with tenant_id column validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text, inspect
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
|
||||
|
||||
from app.models.plugin import PluginMigration
|
||||
|
||||
|
||||
class MigrationValidationError(Exception):
|
||||
"""Raised when a plugin migration creates a table without tenant_id column."""
|
||||
|
||||
|
||||
class MigrationRunner:
|
||||
"""Runs plugin SQL migrations and validates that all created tables have tenant_id."""
|
||||
|
||||
def __init__(self, engine: AsyncEngine, migrations_dir: str | None = None) -> None:
|
||||
self._engine = engine
|
||||
self._migrations_dir = Path(migrations_dir or os.path.join(os.path.dirname(__file__), "migrations"))
|
||||
|
||||
def _resolve_migration_path(self, filename: str) -> Path:
|
||||
"""Resolve a migration filename to an absolute path."""
|
||||
# Try migrations_dir first, then app/plugins/migrations/
|
||||
candidate = self._migrations_dir / filename
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
# Fallback: builtins directory
|
||||
builtins_dir = Path(__file__).parent / "builtins" / "migrations"
|
||||
candidate2 = builtins_dir / filename
|
||||
if candidate2.exists():
|
||||
return candidate2
|
||||
raise FileNotFoundError(f"Migration file not found: {filename}")
|
||||
|
||||
async def run_migration(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
plugin_name: str,
|
||||
migration_filename: str,
|
||||
tenant_id: Any | None = None,
|
||||
) -> PluginMigration:
|
||||
"""Run a single SQL migration file for a plugin.
|
||||
|
||||
Reads the SQL file, executes it, validates created tables have tenant_id,
|
||||
and records the migration in plugin_migrations table.
|
||||
|
||||
Args:
|
||||
db: Async database session.
|
||||
plugin_name: Name of the plugin being migrated.
|
||||
migration_filename: Filename of the migration SQL file.
|
||||
tenant_id: Optional tenant UUID for tenant-scoped migrations.
|
||||
|
||||
Returns:
|
||||
PluginMigration record.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If migration file doesn't exist.
|
||||
MigrationValidationError: If a created table lacks tenant_id column.
|
||||
"""
|
||||
migration_path = self._resolve_migration_path(migration_filename)
|
||||
sql_content = migration_path.read_text(encoding="utf-8")
|
||||
|
||||
# Capture existing table names before migration (static analysis of SQL)
|
||||
existing_tables = await self._get_table_names_via_session(db)
|
||||
|
||||
# Execute the migration SQL
|
||||
# Split on semicolons but handle potential function definitions
|
||||
statements = self._split_sql(sql_content)
|
||||
for stmt in statements:
|
||||
stmt = stmt.strip()
|
||||
if stmt:
|
||||
await db.execute(text(stmt))
|
||||
await db.flush()
|
||||
|
||||
# Capture table names after migration (using same session connection)
|
||||
new_tables = await self._get_table_names_via_session(db)
|
||||
created_tables = new_tables - existing_tables
|
||||
|
||||
# Validate: all newly created tables must have tenant_id column
|
||||
tables_without_tenant = []
|
||||
for table_name in created_tables:
|
||||
if not await self._table_has_column_via_session(db, table_name, "tenant_id"):
|
||||
tables_without_tenant.append(table_name)
|
||||
|
||||
if tables_without_tenant:
|
||||
# Rollback the migration
|
||||
await db.rollback()
|
||||
raise MigrationValidationError(
|
||||
f"Plugin '{plugin_name}' migration '{migration_filename}' created tables without tenant_id column: "
|
||||
f"{', '.join(tables_without_tenant)}. All plugin tables must have tenant_id for multi-tenant isolation."
|
||||
)
|
||||
|
||||
# Record the migration in plugin_migrations table
|
||||
migration_record = PluginMigration(
|
||||
plugin_name=plugin_name,
|
||||
migration_file=migration_filename,
|
||||
status="applied",
|
||||
)
|
||||
db.add(migration_record)
|
||||
await db.flush()
|
||||
|
||||
return migration_record
|
||||
|
||||
async def run_all_migrations(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
plugin_name: str,
|
||||
migration_files: list[str],
|
||||
tenant_id: Any | None = None,
|
||||
) -> list[PluginMigration]:
|
||||
"""Run all migration files for a plugin in order.
|
||||
|
||||
Skips already-applied migrations (idempotent).
|
||||
"""
|
||||
# Check which migrations are already applied
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await db.execute(
|
||||
select(PluginMigration).where(
|
||||
PluginMigration.plugin_name == plugin_name,
|
||||
PluginMigration.status == "applied",
|
||||
)
|
||||
)
|
||||
applied_files = {row.migration_file for row in result.scalars().all()}
|
||||
|
||||
records: list[PluginMigration] = []
|
||||
for filename in migration_files:
|
||||
if filename in applied_files:
|
||||
continue # Already applied — idempotent skip
|
||||
record = await self.run_migration(db, plugin_name, filename, tenant_id)
|
||||
records.append(record)
|
||||
|
||||
return records
|
||||
|
||||
async def drop_plugin_tables(self, db: AsyncSession, plugin_name: str) -> list[str]:
|
||||
"""Drop all tables that were created by a plugin's migrations.
|
||||
|
||||
Args:
|
||||
db: Async database session.
|
||||
plugin_name: Name of the plugin to remove tables for.
|
||||
|
||||
Returns:
|
||||
List of dropped table names.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
# Get all migration records for this plugin
|
||||
result = await db.execute(
|
||||
select(PluginMigration).where(PluginMigration.plugin_name == plugin_name)
|
||||
)
|
||||
migration_records = result.scalars().all()
|
||||
|
||||
dropped_tables: list[str] = []
|
||||
for record in migration_records:
|
||||
migration_path = self._resolve_migration_path(record.migration_file)
|
||||
sql_content = migration_path.read_text(encoding="utf-8")
|
||||
|
||||
# Parse table names from CREATE TABLE statements
|
||||
table_names = self._extract_table_names(sql_content)
|
||||
for table_name in table_names:
|
||||
try:
|
||||
await db.execute(text(f'DROP TABLE IF EXISTS "{table_name}" CASCADE'))
|
||||
dropped_tables.append(table_name)
|
||||
except Exception:
|
||||
pass # Table may already be gone
|
||||
await db.flush()
|
||||
|
||||
# Remove migration records
|
||||
for record in migration_records:
|
||||
await db.delete(record)
|
||||
await db.flush()
|
||||
|
||||
return dropped_tables
|
||||
|
||||
async def _get_table_names_via_session(self, db: AsyncSession) -> set[str]:
|
||||
"""Get current table names using the session's own connection.
|
||||
|
||||
This ensures we see uncommitted DDL changes within the same transaction.
|
||||
"""
|
||||
result = await db.execute(
|
||||
text("SELECT tablename FROM pg_tables WHERE schemaname = 'public'")
|
||||
)
|
||||
return {row[0] for row in result.fetchall()}
|
||||
|
||||
async def _table_has_column_via_session(
|
||||
self, db: AsyncSession, table_name: str, column_name: str
|
||||
) -> bool:
|
||||
"""Check if a table has a specific column using the session's connection."""
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT column_name FROM information_schema.columns "
|
||||
"WHERE table_schema = 'public' AND table_name = :table_name AND column_name = :column_name"
|
||||
),
|
||||
{"table_name": table_name, "column_name": column_name},
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
|
||||
async def _get_table_names(self) -> set[str]:
|
||||
"""Get current table names from the database (separate connection)."""
|
||||
def _get_names(sync_conn):
|
||||
insp = inspect(sync_conn)
|
||||
return set(insp.get_table_names())
|
||||
|
||||
async with self._engine.connect() as conn:
|
||||
return await conn.run_sync(_get_names)
|
||||
|
||||
async def _table_has_column(self, table_name: str, column_name: str) -> bool:
|
||||
"""Check if a table has a specific column (separate connection)."""
|
||||
def _has_col(sync_conn):
|
||||
insp = inspect(sync_conn)
|
||||
if table_name not in insp.get_table_names():
|
||||
return False
|
||||
columns = [col["name"] for col in insp.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
async with self._engine.connect() as conn:
|
||||
return await conn.run_sync(_has_col)
|
||||
|
||||
@staticmethod
|
||||
def _split_sql(sql: str) -> list[str]:
|
||||
"""Split SQL into individual statements, respecting basic semicolon boundaries."""
|
||||
statements: list[str] = []
|
||||
current: list[str] = []
|
||||
in_dollar_quote = False
|
||||
dollar_tag = ""
|
||||
|
||||
for line in sql.splitlines():
|
||||
stripped = line.strip()
|
||||
|
||||
# Handle dollar-quoted blocks (PostgreSQL function bodies)
|
||||
if "$$" in stripped and not in_dollar_quote:
|
||||
parts = stripped.split("$$", 1)
|
||||
if len(parts) > 1:
|
||||
dollar_tag = parts[0].strip()
|
||||
if dollar_tag:
|
||||
in_dollar_quote = True
|
||||
elif stripped.count("$$") >= 2:
|
||||
# Single-line $$ block
|
||||
current.append(line)
|
||||
if stripped.rstrip().endswith(";"):
|
||||
statements.append("\n".join(current))
|
||||
current = []
|
||||
continue
|
||||
else:
|
||||
in_dollar_quote = True
|
||||
current.append(line)
|
||||
continue
|
||||
|
||||
if in_dollar_quote:
|
||||
current.append(line)
|
||||
if "$$" in stripped:
|
||||
in_dollar_quote = False
|
||||
# After closing $$, check if the line ends with ; to flush statement
|
||||
after_dollar = stripped.split("$$", 1)[-1] if "$$" in stripped else ""
|
||||
if after_dollar.rstrip().endswith(";"):
|
||||
statements.append("\n".join(current))
|
||||
current = []
|
||||
continue
|
||||
|
||||
current.append(line)
|
||||
if stripped.endswith(";"):
|
||||
statements.append("\n".join(current))
|
||||
current = []
|
||||
|
||||
if current:
|
||||
remaining = "\n".join(current).strip()
|
||||
if remaining:
|
||||
statements.append(remaining)
|
||||
|
||||
return statements
|
||||
|
||||
@staticmethod
|
||||
def _extract_table_names(sql: str) -> list[str]:
|
||||
"""Extract table names from CREATE TABLE statements in SQL."""
|
||||
import re
|
||||
# Match CREATE TABLE [IF NOT EXISTS] "table_name" or CREATE TABLE table_name
|
||||
pattern = r'CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["\']?(\w+)["\']?'
|
||||
return re.findall(pattern, sql, re.IGNORECASE)
|
||||
@@ -0,0 +1,385 @@
|
||||
"""Plugin registry — manages discovered, installed, and active plugins."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, AsyncEngine
|
||||
|
||||
from app.core.event_bus import EventBus, get_event_bus
|
||||
from app.core.service_container import ServiceContainer, get_container
|
||||
from app.models.plugin import Plugin as PluginModel
|
||||
from app.plugins.base import BasePlugin
|
||||
from app.plugins.migration_runner import MigrationRunner
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PluginRegistry:
|
||||
"""Registry for managing plugin lifecycle.
|
||||
|
||||
Maintains in-memory state for discovered plugins and their runtime instances,
|
||||
backed by database records for persistent status tracking.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# name -> BasePlugin instance (discovered/loaded)
|
||||
self._plugins: dict[str, BasePlugin] = {}
|
||||
# name -> PluginModel DB record (installed status)
|
||||
self._db_status: dict[str, PluginModel] = {}
|
||||
# name -> list of mounted APIRouter references (for unregistration)
|
||||
self._mounted_routers: dict[str, list[Any]] = {}
|
||||
self._engine: AsyncEngine | None = None
|
||||
self._event_bus: EventBus = get_event_bus()
|
||||
self._container: ServiceContainer = get_container()
|
||||
self._migration_runner: MigrationRunner | None = None
|
||||
self._app: FastAPI | None = None
|
||||
self._initialized = False
|
||||
|
||||
def initialize(self, engine: AsyncEngine, app: FastAPI | None = None) -> None:
|
||||
"""Initialize the registry with the DB engine and optional FastAPI app."""
|
||||
self._engine = engine
|
||||
self._app = app
|
||||
self._migration_runner = MigrationRunner(engine)
|
||||
self._initialized = True
|
||||
|
||||
@property
|
||||
def migration_runner(self) -> MigrationRunner:
|
||||
if self._migration_runner is None:
|
||||
raise RuntimeError("Registry not initialized — call initialize() first")
|
||||
return self._migration_runner
|
||||
|
||||
@property
|
||||
def engine(self) -> AsyncEngine:
|
||||
if self._engine is None:
|
||||
raise RuntimeError("Registry not initialized — call initialize() first")
|
||||
return self._engine
|
||||
|
||||
# ─── Discovery ───
|
||||
|
||||
def discover_builtins(self) -> list[str]:
|
||||
"""Discover and load built-in plugins from app.plugins.builtins.
|
||||
|
||||
Scans the builtins package for modules that export a BasePlugin subclass.
|
||||
Returns list of discovered plugin names.
|
||||
"""
|
||||
discovered: list[str] = []
|
||||
try:
|
||||
builtins_pkg = importlib.import_module("app.plugins.builtins")
|
||||
except ImportError:
|
||||
return discovered
|
||||
|
||||
pkg_path = getattr(builtins_pkg, "__path__", None)
|
||||
if pkg_path is None:
|
||||
return discovered
|
||||
|
||||
import pkgutil
|
||||
for importer, modname, ispkg in pkgutil.iter_modules(pkg_path):
|
||||
if modname.startswith("_"):
|
||||
continue
|
||||
try:
|
||||
full_name = f"app.plugins.builtins.{modname}"
|
||||
module = importlib.import_module(full_name)
|
||||
# Look for BasePlugin subclass in module
|
||||
for attr_name in dir(module):
|
||||
attr = getattr(module, attr_name)
|
||||
if (
|
||||
isinstance(attr, type)
|
||||
and issubclass(attr, BasePlugin)
|
||||
and attr is not BasePlugin
|
||||
):
|
||||
instance = attr()
|
||||
if instance.name not in self._plugins:
|
||||
self._plugins[instance.name] = instance
|
||||
discovered.append(instance.name)
|
||||
logger.info(f"Discovered plugin: {instance.name} v{instance.version}")
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to load builtin plugin module {modname}: {exc}")
|
||||
|
||||
return discovered
|
||||
|
||||
def register_plugin(self, plugin: BasePlugin) -> None:
|
||||
"""Manually register a plugin instance."""
|
||||
self._plugins[plugin.name] = plugin
|
||||
logger.info(f"Registered plugin: {plugin.name} v{plugin.version}")
|
||||
|
||||
def get_plugin(self, name: str) -> BasePlugin | None:
|
||||
"""Get a registered plugin instance by name."""
|
||||
return self._plugins.get(name)
|
||||
|
||||
def list_discovered(self) -> list[str]:
|
||||
"""List all discovered plugin names."""
|
||||
return list(self._plugins.keys())
|
||||
|
||||
# ─── DB Status Sync ───
|
||||
|
||||
async def sync_db_status(self, db: AsyncSession) -> None:
|
||||
"""Load plugin status records from the database."""
|
||||
result = await db.execute(select(PluginModel))
|
||||
self._db_status = {row.name: row for row in result.scalars().all()}
|
||||
|
||||
def get_db_status(self, name: str) -> PluginModel | None:
|
||||
"""Get the DB status record for a plugin."""
|
||||
return self._db_status.get(name)
|
||||
|
||||
# ─── Install / Activate / Deactivate / Uninstall ───
|
||||
|
||||
async def install(self, db: AsyncSession, name: str) -> PluginModel:
|
||||
"""Install a plugin: run migrations and create DB record.
|
||||
|
||||
Idempotent: if already installed, returns existing record.
|
||||
"""
|
||||
plugin = self.get_plugin(name)
|
||||
if plugin is None:
|
||||
raise ValueError(f"Plugin '{name}' not found in registry")
|
||||
|
||||
# Check if already installed in DB
|
||||
existing = await self._get_plugin_record(db, name)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
# Run migrations
|
||||
if plugin.manifest.migrations:
|
||||
await self.migration_runner.run_all_migrations(
|
||||
db, name, plugin.manifest.migrations
|
||||
)
|
||||
|
||||
# Call on_install hook
|
||||
await plugin.on_install(db, self._container)
|
||||
|
||||
# Create DB record
|
||||
record = PluginModel(
|
||||
name=name,
|
||||
display_name=plugin.manifest.display_name,
|
||||
version=plugin.manifest.version,
|
||||
status="installed",
|
||||
installed=True,
|
||||
active=False,
|
||||
)
|
||||
db.add(record)
|
||||
await db.flush()
|
||||
self._db_status[name] = record
|
||||
|
||||
return record
|
||||
|
||||
async def activate(self, db: AsyncSession, name: str) -> PluginModel:
|
||||
"""Activate a plugin: register routes, event listeners, set status=active.
|
||||
|
||||
Idempotent: if already active, returns existing record without error.
|
||||
"""
|
||||
plugin = self.get_plugin(name)
|
||||
if plugin is None:
|
||||
raise ValueError(f"Plugin '{name}' not found in registry")
|
||||
|
||||
record = await self._get_plugin_record(db, name)
|
||||
if record is None:
|
||||
raise ValueError(f"Plugin '{name}' is not installed — install first")
|
||||
|
||||
# Idempotent: already active
|
||||
if record.active and record.status == "active":
|
||||
return record
|
||||
|
||||
# Call on_activate hook (registers event listeners)
|
||||
await plugin.on_activate(db, self._container, self._event_bus)
|
||||
|
||||
# Register routes on FastAPI app if available
|
||||
if self._app is not None:
|
||||
routers = plugin.get_routes()
|
||||
mounted = []
|
||||
for router in routers:
|
||||
self._app.include_router(router)
|
||||
mounted.append(router)
|
||||
self._mounted_routers[name] = mounted
|
||||
|
||||
# Update DB record
|
||||
record.status = "active"
|
||||
record.active = True
|
||||
await db.flush()
|
||||
self._db_status[name] = record
|
||||
|
||||
return record
|
||||
|
||||
async def deactivate(self, db: AsyncSession, name: str) -> PluginModel:
|
||||
"""Deactivate a plugin: unregister event listeners, set status=inactive.
|
||||
|
||||
Idempotent: if already inactive, returns existing record without error.
|
||||
"""
|
||||
plugin = self.get_plugin(name)
|
||||
if plugin is None:
|
||||
raise ValueError(f"Plugin '{name}' not found in registry")
|
||||
|
||||
record = await self._get_plugin_record(db, name)
|
||||
if record is None:
|
||||
raise ValueError(f"Plugin '{name}' is not installed")
|
||||
|
||||
# Idempotent: already inactive
|
||||
if not record.active and record.status == "inactive":
|
||||
return record
|
||||
|
||||
# Call on_deactivate hook (unregisters event listeners)
|
||||
await plugin.on_deactivate(db, self._container, self._event_bus)
|
||||
|
||||
# Unregister routes from FastAPI app if available
|
||||
if self._app is not None and name in self._mounted_routers:
|
||||
mounted = self._mounted_routers.pop(name, [])
|
||||
# Collect all route paths from mounted routers for removal
|
||||
paths_to_remove: set[str] = set()
|
||||
for router in mounted:
|
||||
for route in router.routes:
|
||||
if hasattr(route, "path"):
|
||||
paths_to_remove.add(route.path)
|
||||
# Remove matching routes from app
|
||||
self._app.router.routes = [
|
||||
r for r in self._app.router.routes
|
||||
if not (hasattr(r, "path") and r.path in paths_to_remove)
|
||||
]
|
||||
|
||||
# Update DB record
|
||||
record.status = "inactive"
|
||||
record.active = False
|
||||
await db.flush()
|
||||
self._db_status[name] = record
|
||||
|
||||
return record
|
||||
|
||||
async def uninstall(
|
||||
self, db: AsyncSession, name: str, remove_data: bool = False
|
||||
) -> PluginModel:
|
||||
"""Uninstall a plugin: deactivate, optionally drop tables, remove DB record.
|
||||
|
||||
Args:
|
||||
db: Async database session.
|
||||
name: Plugin name to uninstall.
|
||||
remove_data: If True, drop all plugin-created tables.
|
||||
|
||||
Returns:
|
||||
The plugin record before deletion (for response).
|
||||
|
||||
Raises:
|
||||
ValueError if plugin not installed.
|
||||
"""
|
||||
plugin = self.get_plugin(name)
|
||||
if plugin is None:
|
||||
raise ValueError(f"Plugin '{name}' not found in registry")
|
||||
|
||||
record = await self._get_plugin_record(db, name)
|
||||
if record is None:
|
||||
raise ValueError(f"Plugin '{name}' is not installed")
|
||||
|
||||
# Deactivate first if active
|
||||
if record.active:
|
||||
await self.deactivate(db, name)
|
||||
# Refetch record after deactivate
|
||||
record = await self._get_plugin_record(db, name)
|
||||
if record is None:
|
||||
raise ValueError(f"Plugin '{name}' disappeared during uninstall")
|
||||
|
||||
# Call on_uninstall hook
|
||||
await plugin.on_uninstall(db, self._container)
|
||||
|
||||
# Optionally drop plugin tables
|
||||
dropped_tables: list[str] = []
|
||||
if remove_data:
|
||||
dropped_tables = await self.migration_runner.drop_plugin_tables(db, name)
|
||||
|
||||
# Remove DB record
|
||||
await db.delete(record)
|
||||
await db.flush()
|
||||
self._db_status.pop(name, None)
|
||||
|
||||
# Return a detached copy for response
|
||||
record_dropped_tables = dropped_tables
|
||||
record.status = "uninstalled"
|
||||
record.dropped_tables = record_dropped_tables
|
||||
return record
|
||||
|
||||
async def list_plugins(self, db: AsyncSession) -> list[dict[str, Any]]:
|
||||
"""List all plugins with their current status.
|
||||
|
||||
Merges discovered (in-memory) plugins with installed (DB) records.
|
||||
"""
|
||||
result = await db.execute(select(PluginModel))
|
||||
db_records = {row.name: row for row in result.scalars().all()}
|
||||
|
||||
plugins_list: list[dict[str, Any]] = []
|
||||
for name, plugin in self._plugins.items():
|
||||
record = db_records.get(name)
|
||||
if record is not None:
|
||||
plugins_list.append({
|
||||
"name": name,
|
||||
"display_name": record.display_name,
|
||||
"version": record.version,
|
||||
"status": record.status,
|
||||
"installed": record.installed,
|
||||
"active": record.active,
|
||||
"description": plugin.manifest.description,
|
||||
"dependencies": plugin.manifest.dependencies,
|
||||
"events": plugin.manifest.events,
|
||||
"migrations": plugin.manifest.migrations,
|
||||
"permissions": plugin.manifest.permissions,
|
||||
})
|
||||
else:
|
||||
plugins_list.append({
|
||||
"name": name,
|
||||
"display_name": plugin.manifest.display_name,
|
||||
"version": plugin.version,
|
||||
"status": "discovered",
|
||||
"installed": False,
|
||||
"active": False,
|
||||
"description": plugin.manifest.description,
|
||||
"dependencies": plugin.manifest.dependencies,
|
||||
"events": plugin.manifest.events,
|
||||
"migrations": plugin.manifest.migrations,
|
||||
"permissions": plugin.manifest.permissions,
|
||||
})
|
||||
|
||||
# Also include DB-only records (plugins that were installed but no longer discovered)
|
||||
for name, record in db_records.items():
|
||||
if name not in self._plugins:
|
||||
plugins_list.append({
|
||||
"name": name,
|
||||
"display_name": record.display_name,
|
||||
"version": record.version,
|
||||
"status": record.status,
|
||||
"installed": record.installed,
|
||||
"active": record.active,
|
||||
"description": "",
|
||||
"dependencies": [],
|
||||
"events": [],
|
||||
"migrations": [],
|
||||
"permissions": [],
|
||||
})
|
||||
|
||||
return plugins_list
|
||||
|
||||
# ─── Internal Helpers ───
|
||||
|
||||
async def _get_plugin_record(self, db: AsyncSession, name: str) -> PluginModel | None:
|
||||
"""Fetch a plugin record from DB by name."""
|
||||
result = await db.execute(
|
||||
select(PluginModel).where(PluginModel.name == name)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
# Global registry instance
|
||||
_registry: PluginRegistry | None = None
|
||||
|
||||
|
||||
def get_registry() -> PluginRegistry:
|
||||
"""Get the global plugin registry."""
|
||||
global _registry
|
||||
if _registry is None:
|
||||
_registry = PluginRegistry()
|
||||
return _registry
|
||||
|
||||
|
||||
def reset_registry_for_testing() -> PluginRegistry:
|
||||
"""Create a fresh registry for testing."""
|
||||
global _registry
|
||||
_registry = PluginRegistry()
|
||||
return _registry
|
||||
@@ -1 +1,3 @@
|
||||
"""Routes package."""
|
||||
|
||||
from app.routes import auth, users, roles, tenants, health, notifications, companies, contacts, import_export, plugins
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Plugin routes — list, install, activate, deactivate, uninstall, manifest schema."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.deps import require_admin
|
||||
from app.plugins.migration_runner import MigrationValidationError
|
||||
from app.services.plugin_service import get_plugin_service
|
||||
|
||||
router = APIRouter(prefix="/api/v1/plugins", tags=["plugins"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_plugins(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_admin),
|
||||
):
|
||||
"""List all plugins with their current status (discovered, installed, active, inactive)."""
|
||||
service = get_plugin_service()
|
||||
plugins = await service.list_plugins(db)
|
||||
return {"plugins": plugins, "total": len(plugins)}
|
||||
|
||||
|
||||
@router.get("/manifest")
|
||||
async def get_manifest_schema(
|
||||
current_user: dict = Depends(require_admin),
|
||||
):
|
||||
"""Get the plugin manifest schema documentation."""
|
||||
service = get_plugin_service()
|
||||
return service.get_manifest_schema()
|
||||
|
||||
|
||||
@router.post("/{name}/install")
|
||||
async def install_plugin(
|
||||
name: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_admin),
|
||||
):
|
||||
"""Install a plugin by name. Runs migrations and creates DB record.
|
||||
|
||||
Idempotent: returns 200 if already installed.
|
||||
"""
|
||||
import uuid as uuid_mod
|
||||
service = get_plugin_service()
|
||||
try:
|
||||
result = await service.install_plugin(
|
||||
db, name,
|
||||
tenant_id=uuid_mod.UUID(current_user["tenant_id"]),
|
||||
user_id=uuid_mod.UUID(current_user["user_id"]),
|
||||
)
|
||||
return result
|
||||
except ValueError as exc:
|
||||
if "not found" in str(exc).lower():
|
||||
raise HTTPException(404, detail={"detail": str(exc), "code": "plugin_not_found"})
|
||||
raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_error"})
|
||||
except MigrationValidationError as exc:
|
||||
raise HTTPException(422, detail={"detail": str(exc), "code": "migration_validation_error"})
|
||||
|
||||
|
||||
@router.post("/{name}/activate")
|
||||
async def activate_plugin(
|
||||
name: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_admin),
|
||||
):
|
||||
"""Activate a plugin by name. Registers event listeners and routes.
|
||||
|
||||
Idempotent: returns 200 if already active.
|
||||
"""
|
||||
import uuid as uuid_mod
|
||||
service = get_plugin_service()
|
||||
try:
|
||||
result = await service.activate_plugin(
|
||||
db, name,
|
||||
tenant_id=uuid_mod.UUID(current_user["tenant_id"]),
|
||||
user_id=uuid_mod.UUID(current_user["user_id"]),
|
||||
)
|
||||
return result
|
||||
except ValueError as exc:
|
||||
if "not installed" in str(exc).lower():
|
||||
raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_not_installed"})
|
||||
if "not found" in str(exc).lower():
|
||||
raise HTTPException(404, detail={"detail": str(exc), "code": "plugin_not_found"})
|
||||
raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_error"})
|
||||
|
||||
|
||||
@router.post("/{name}/deactivate")
|
||||
async def deactivate_plugin(
|
||||
name: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_admin),
|
||||
):
|
||||
"""Deactivate a plugin by name. Unregisters event listeners and routes.
|
||||
|
||||
Idempotent: returns 200 if already inactive.
|
||||
"""
|
||||
import uuid as uuid_mod
|
||||
service = get_plugin_service()
|
||||
try:
|
||||
result = await service.deactivate_plugin(
|
||||
db, name,
|
||||
tenant_id=uuid_mod.UUID(current_user["tenant_id"]),
|
||||
user_id=uuid_mod.UUID(current_user["user_id"]),
|
||||
)
|
||||
return result
|
||||
except ValueError as exc:
|
||||
if "not found" in str(exc).lower():
|
||||
raise HTTPException(404, detail={"detail": str(exc), "code": "plugin_not_found"})
|
||||
raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_error"})
|
||||
|
||||
|
||||
@router.delete("/{name}")
|
||||
async def uninstall_plugin(
|
||||
name: str,
|
||||
remove_data: bool = Query(False),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_admin),
|
||||
):
|
||||
"""Uninstall a plugin. Optionally drop plugin-created tables with remove_data=true.
|
||||
|
||||
Returns 200 with uninstalled status. Idempotent for already-uninstalled plugins.
|
||||
"""
|
||||
import uuid as uuid_mod
|
||||
service = get_plugin_service()
|
||||
try:
|
||||
result = await service.uninstall_plugin(
|
||||
db, name,
|
||||
remove_data=remove_data,
|
||||
tenant_id=uuid_mod.UUID(current_user["tenant_id"]),
|
||||
user_id=uuid_mod.UUID(current_user["user_id"]),
|
||||
)
|
||||
return result
|
||||
except ValueError as exc:
|
||||
if "not found" in str(exc).lower():
|
||||
raise HTTPException(404, detail={"detail": str(exc), "code": "plugin_not_found"})
|
||||
raise HTTPException(400, detail={"detail": str(exc), "code": "plugin_error"})
|
||||
@@ -1 +1,3 @@
|
||||
"""Pydantic schemas package."""
|
||||
|
||||
from app.schemas.plugin import PluginInfo, PluginListResponse, PluginActionResponse, PluginUninstallResponse
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Pydantic schemas for plugin API responses."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PluginInfo(BaseModel):
|
||||
"""Plugin status information returned in list endpoint."""
|
||||
|
||||
name: str
|
||||
display_name: str
|
||||
version: str
|
||||
status: str = Field(description="discovered | installed | active | inactive")
|
||||
installed: bool = False
|
||||
active: bool = False
|
||||
description: str = ""
|
||||
dependencies: list[str] = Field(default_factory=list)
|
||||
events: list[str] = Field(default_factory=list)
|
||||
migrations: list[str] = Field(default_factory=list)
|
||||
permissions: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PluginListResponse(BaseModel):
|
||||
"""Response for GET /api/v1/plugins."""
|
||||
|
||||
plugins: list[PluginInfo]
|
||||
total: int
|
||||
|
||||
|
||||
class PluginActionResponse(BaseModel):
|
||||
"""Response for install/activate/deactivate/uninstall actions."""
|
||||
|
||||
name: str
|
||||
display_name: str
|
||||
version: str
|
||||
status: str
|
||||
installed: bool = False
|
||||
active: bool = False
|
||||
dropped_tables: list[str] = Field(default_factory=list, description="Tables dropped (uninstall with remove_data=true)")
|
||||
message: str = ""
|
||||
|
||||
|
||||
class PluginUninstallResponse(PluginActionResponse):
|
||||
"""Response for DELETE /api/v1/plugins/{name}."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ManifestFieldDoc(BaseModel):
|
||||
"""Documentation for a single manifest field."""
|
||||
type: str
|
||||
required: str
|
||||
description: str
|
||||
|
||||
|
||||
class ManifestSchemaResponse(BaseModel):
|
||||
"""Response for GET /api/v1/plugins/manifest."""
|
||||
|
||||
fields: dict[str, ManifestFieldDoc]
|
||||
example: dict
|
||||
@@ -1 +1,3 @@
|
||||
"""Service layer package."""
|
||||
|
||||
from app.services.plugin_service import PluginService, get_plugin_service
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Plugin service layer — business logic for plugin lifecycle operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.audit import log_audit
|
||||
from app.plugins.registry import PluginRegistry, get_registry
|
||||
from app.plugins.migration_runner import MigrationValidationError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PluginService:
|
||||
"""Service layer for plugin lifecycle management.
|
||||
|
||||
Wraps the PluginRegistry with audit logging and error handling.
|
||||
"""
|
||||
|
||||
def __init__(self, registry: PluginRegistry | None = None) -> None:
|
||||
self._registry = registry or get_registry()
|
||||
|
||||
@property
|
||||
def registry(self) -> PluginRegistry:
|
||||
return self._registry
|
||||
|
||||
async def list_plugins(self, db: AsyncSession) -> list[dict[str, Any]]:
|
||||
"""List all discovered and installed plugins with their status."""
|
||||
return await self._registry.list_plugins(db)
|
||||
|
||||
async def install_plugin(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
name: str,
|
||||
tenant_id: uuid.UUID | None = None,
|
||||
user_id: uuid.UUID | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Install a plugin by name.
|
||||
|
||||
Runs migrations, calls on_install hook, creates DB record.
|
||||
Idempotent: returns existing record if already installed.
|
||||
"""
|
||||
try:
|
||||
record = await self._registry.install(db, name)
|
||||
if tenant_id and user_id:
|
||||
await log_audit(
|
||||
db, tenant_id, user_id,
|
||||
action="plugin.install",
|
||||
entity_type="plugin",
|
||||
entity_id=getattr(record, "id", None),
|
||||
changes={"name": name, "version": record.version},
|
||||
)
|
||||
return {
|
||||
"name": record.name,
|
||||
"display_name": record.display_name,
|
||||
"version": record.version,
|
||||
"status": record.status,
|
||||
"installed": record.installed,
|
||||
"active": record.active,
|
||||
"message": "Plugin installed successfully",
|
||||
}
|
||||
except ValueError as exc:
|
||||
raise ValueError(str(exc))
|
||||
except MigrationValidationError as exc:
|
||||
raise MigrationValidationError(str(exc))
|
||||
|
||||
async def activate_plugin(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
name: str,
|
||||
tenant_id: uuid.UUID | None = None,
|
||||
user_id: uuid.UUID | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Activate a plugin by name.
|
||||
|
||||
Registers event listeners and routes. Idempotent.
|
||||
"""
|
||||
try:
|
||||
record = await self._registry.activate(db, name)
|
||||
was_already_active = record.active and record.status == "active"
|
||||
if tenant_id and user_id:
|
||||
await log_audit(
|
||||
db, tenant_id, user_id,
|
||||
action="plugin.activate",
|
||||
entity_type="plugin",
|
||||
entity_id=getattr(record, "id", None),
|
||||
changes={"name": name, "was_already_active": was_already_active},
|
||||
)
|
||||
return {
|
||||
"name": record.name,
|
||||
"display_name": record.display_name,
|
||||
"version": record.version,
|
||||
"status": record.status,
|
||||
"installed": record.installed,
|
||||
"active": record.active,
|
||||
"message": "Plugin is already active" if was_already_active else "Plugin activated successfully",
|
||||
}
|
||||
except ValueError as exc:
|
||||
raise ValueError(str(exc))
|
||||
|
||||
async def deactivate_plugin(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
name: str,
|
||||
tenant_id: uuid.UUID | None = None,
|
||||
user_id: uuid.UUID | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Deactivate a plugin by name.
|
||||
|
||||
Unregisters event listeners and routes. Idempotent.
|
||||
"""
|
||||
try:
|
||||
record = await self._registry.deactivate(db, name)
|
||||
was_already_inactive = not record.active and record.status == "inactive"
|
||||
if tenant_id and user_id:
|
||||
await log_audit(
|
||||
db, tenant_id, user_id,
|
||||
action="plugin.deactivate",
|
||||
entity_type="plugin",
|
||||
entity_id=getattr(record, "id", None),
|
||||
changes={"name": name, "was_already_inactive": was_already_inactive},
|
||||
)
|
||||
return {
|
||||
"name": record.name,
|
||||
"display_name": record.display_name,
|
||||
"version": record.version,
|
||||
"status": record.status,
|
||||
"installed": record.installed,
|
||||
"active": record.active,
|
||||
"message": "Plugin is already inactive" if was_already_inactive else "Plugin deactivated successfully",
|
||||
}
|
||||
except ValueError as exc:
|
||||
raise ValueError(str(exc))
|
||||
|
||||
async def uninstall_plugin(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
name: str,
|
||||
remove_data: bool = False,
|
||||
tenant_id: uuid.UUID | None = None,
|
||||
user_id: uuid.UUID | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Uninstall a plugin by name.
|
||||
|
||||
Deactivates, calls on_uninstall hook, optionally drops tables, removes DB record.
|
||||
"""
|
||||
try:
|
||||
record = await self._registry.uninstall(db, name, remove_data=remove_data)
|
||||
dropped_tables = getattr(record, "dropped_tables", [])
|
||||
if tenant_id and user_id:
|
||||
await log_audit(
|
||||
db, tenant_id, user_id,
|
||||
action="plugin.uninstall",
|
||||
entity_type="plugin",
|
||||
changes={"name": name, "remove_data": remove_data, "dropped_tables": dropped_tables},
|
||||
)
|
||||
return {
|
||||
"name": name,
|
||||
"display_name": record.display_name,
|
||||
"version": record.version,
|
||||
"status": "uninstalled",
|
||||
"installed": False,
|
||||
"active": False,
|
||||
"dropped_tables": dropped_tables,
|
||||
"message": f"Plugin uninstalled{' and data tables dropped' if remove_data else ''}",
|
||||
}
|
||||
except ValueError as exc:
|
||||
raise ValueError(str(exc))
|
||||
|
||||
def get_manifest_schema(self) -> dict[str, Any]:
|
||||
"""Return the manifest schema documentation."""
|
||||
from app.plugins.manifest import MANIFEST_SCHEMA_DOC
|
||||
return MANIFEST_SCHEMA_DOC.model_dump()
|
||||
|
||||
|
||||
# Global service instance
|
||||
_service: PluginService | None = None
|
||||
|
||||
|
||||
def get_plugin_service() -> PluginService:
|
||||
"""Get the global plugin service instance."""
|
||||
global _service
|
||||
if _service is None:
|
||||
_service = PluginService()
|
||||
return _service
|
||||
|
||||
|
||||
def reset_plugin_service_for_testing(registry: PluginRegistry | None = None) -> PluginService:
|
||||
"""Create a fresh plugin service for testing."""
|
||||
global _service
|
||||
_service = PluginService(registry=registry)
|
||||
return _service
|
||||
Reference in New Issue
Block a user