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:
@@ -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}>"
|
||||
Reference in New Issue
Block a user