2026-06-29 01:18:46 +02:00
|
|
|
"""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.migration_runner import MigrationValidationError
|
2026-06-29 17:43:56 +02:00
|
|
|
from app.plugins.registry import PluginRegistry, get_registry
|
2026-06-29 01:18:46 +02:00
|
|
|
|
|
|
|
|
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(
|
2026-06-29 17:43:56 +02:00
|
|
|
db,
|
|
|
|
|
tenant_id,
|
|
|
|
|
user_id,
|
2026-06-29 01:18:46 +02:00
|
|
|
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:
|
2026-06-29 17:43:56 +02:00
|
|
|
raise ValueError(str(exc)) from None
|
2026-06-29 01:18:46 +02:00
|
|
|
except MigrationValidationError as exc:
|
2026-06-29 17:43:56 +02:00
|
|
|
raise MigrationValidationError(str(exc)) from None
|
2026-06-29 01:18:46 +02:00
|
|
|
|
|
|
|
|
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(
|
2026-06-29 17:43:56 +02:00
|
|
|
db,
|
|
|
|
|
tenant_id,
|
|
|
|
|
user_id,
|
2026-06-29 01:18:46 +02:00
|
|
|
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,
|
2026-06-29 17:43:56 +02:00
|
|
|
"message": "Plugin is already active"
|
|
|
|
|
if was_already_active
|
|
|
|
|
else "Plugin activated successfully",
|
2026-06-29 01:18:46 +02:00
|
|
|
}
|
|
|
|
|
except ValueError as exc:
|
2026-06-29 17:43:56 +02:00
|
|
|
raise ValueError(str(exc)) from None
|
2026-06-29 01:18:46 +02:00
|
|
|
|
|
|
|
|
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(
|
2026-06-29 17:43:56 +02:00
|
|
|
db,
|
|
|
|
|
tenant_id,
|
|
|
|
|
user_id,
|
2026-06-29 01:18:46 +02:00
|
|
|
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,
|
2026-06-29 17:43:56 +02:00
|
|
|
"message": "Plugin is already inactive"
|
|
|
|
|
if was_already_inactive
|
|
|
|
|
else "Plugin deactivated successfully",
|
2026-06-29 01:18:46 +02:00
|
|
|
}
|
|
|
|
|
except ValueError as exc:
|
2026-06-29 17:43:56 +02:00
|
|
|
raise ValueError(str(exc)) from None
|
2026-06-29 01:18:46 +02:00
|
|
|
|
|
|
|
|
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(
|
2026-06-29 17:43:56 +02:00
|
|
|
db,
|
|
|
|
|
tenant_id,
|
|
|
|
|
user_id,
|
2026-06-29 01:18:46 +02:00
|
|
|
action="plugin.uninstall",
|
|
|
|
|
entity_type="plugin",
|
2026-06-29 17:43:56 +02:00
|
|
|
changes={
|
|
|
|
|
"name": name,
|
|
|
|
|
"remove_data": remove_data,
|
|
|
|
|
"dropped_tables": dropped_tables,
|
|
|
|
|
},
|
2026-06-29 01:18:46 +02:00
|
|
|
)
|
|
|
|
|
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:
|
2026-06-29 17:43:56 +02:00
|
|
|
raise ValueError(str(exc)) from None
|
2026-06-29 01:18:46 +02:00
|
|
|
|
|
|
|
|
def get_manifest_schema(self) -> dict[str, Any]:
|
|
|
|
|
"""Return the manifest schema documentation."""
|
|
|
|
|
from app.plugins.manifest import MANIFEST_SCHEMA_DOC
|
2026-06-29 17:43:56 +02:00
|
|
|
|
2026-06-29 01:18:46 +02:00
|
|
|
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
|