"""Plugin service layer — business logic for plugin lifecycle operations.""" from __future__ import annotations import logging import uuid from typing import Any from fastapi import HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession from app.core.audit import log_audit from app.plugins.migration_runner import MigrationValidationError from app.plugins.registry import PluginRegistry, get_registry 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. Checks that all declared dependencies are installed first. """ 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: msg = str(exc) if "dependencies" in msg.lower(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=msg, ) from None raise ValueError(msg) from None except MigrationValidationError as exc: raise MigrationValidationError(str(exc)) from None 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. Checks that all declared dependencies are active first. """ try: # Pre-state MUST be captured BEFORE the mutation: the record returned # by registry.activate() always reflects the NEW state, so computing # was_already_active afterwards yielded constant True and silently # skipped all runtime registrations (dead code — proven by # tests/test_plugin_lifecycle_service.py, Welle 1 / Kritikpunkt 1). pre = await self._registry._get_plugin_record(db, name) was_already_active = bool(pre and pre.active and pre.status == "active") record = await self._registry.activate(db, name) # Update permission registry at runtime so newly activated plugins # are immediately usable without app restart (P0-10 fix). if not was_already_active: from app.core.permission_registry import ( get_permission_registry, register_plugin_permissions, ) plugin = self._registry.get_plugin(name) if plugin and plugin.manifest.permissions: register_plugin_permissions(name, plugin.manifest.permissions) # Add to active set so require_active_plugin() returns True get_permission_registry()._active_plugins.add(name) # Register entity models for permission system (P0-3 fix) if plugin: from app.services.entity_permission_service import register_entity_model for entity_type, model_class in plugin.get_entity_models().items(): register_entity_model(entity_type, model_class, plugin_name=name) # Register field definitions for field-level permissions # (audit: contribution type now fully lifecycle-integrated) if plugin: field_defs = plugin.get_field_definitions() if field_defs: get_permission_registry().register_field_definitions(name, field_defs) # Contract registry: clear inactive markers so contracts of # a re-activated plugin are served again (audit P1). from app.plugins.builtins.contracts import get_contract_registry get_contract_registry().mark_plugin_active(name) 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: msg = str(exc) if "dependencies" in msg.lower(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=msg, ) from None raise ValueError(msg) from None 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: # Pre-state MUST be captured BEFORE the mutation (same reasoning as # activate_plugin — the returned record always reflects the NEW # state; computing was_already_inactive afterwards yielded constant # True and skipped permission/entity/gate deregistration entirely). pre = await self._registry._get_plugin_record(db, name) was_already_inactive = bool(pre and not pre.active and pre.status == "inactive") record = await self._registry.deactivate(db, name) # Update permission registry at runtime so deactivated plugins # immediately stop being usable (P0-10 fix). if not was_already_inactive: from app.core.permission_registry import ( get_permission_registry, unregister_plugin_permissions, ) unregister_plugin_permissions(name) get_permission_registry()._active_plugins.discard(name) # Unregister entity models for permission system (P0-3 fix) plugin = self._registry.get_plugin(name) if plugin: from app.services.entity_permission_service import unregister_entity_model for entity_type in plugin.get_entity_models(): unregister_entity_model(entity_type) # Unregister field definitions (audit: full lifecycle) get_permission_registry().unregister_field_definitions(name) # Contract registry: fail closed for the deactivated plugin # (ARCH-014 / audit P1 — central, so every plugin is covered # even if its own on_deactivate forgets the unregister). from app.plugins.builtins.contracts import get_contract_registry get_contract_registry().unregister(name) 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)) from None 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: # Audit P1 (uninstall lifecycle): run the FULL service-level # deactivation first. registry.uninstall()'s internal fallback # (registry.deactivate) does NOT clean PermissionRegistry, # _active_plugins or ENTITY_MODELS — an active plugin uninstalled # directly through the registry left stale registrations behind. pre = await self._registry._get_plugin_record(db, name) if pre is not None and pre.active: await self.deactivate_plugin( db, name, tenant_id=tenant_id, user_id=user_id ) 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)) from None async def get_plugin_config(self, db: AsyncSession, name: str) -> dict[str, Any]: """Get the configuration for a plugin. Returns the plugin's config field (JSONB, already parsed by SQLAlchemy). """ record = await self._registry._get_plugin_record(db, name) if record is None: raise ValueError(f"Plugin '{name}' is not installed") return getattr(record, "config", None) or {} async def update_plugin_config( self, db: AsyncSession, name: str, config: dict[str, Any], tenant_id: uuid.UUID | None = None, user_id: uuid.UUID | None = None, ) -> dict[str, Any]: """Update the configuration for a plugin. Stores the config directly as JSONB in the plugin's config field. """ record = await self._registry._get_plugin_record(db, name) if record is None: raise ValueError(f"Plugin '{name}' is not installed") record.config = config await db.flush() if tenant_id and user_id: await log_audit( db, tenant_id, user_id, action="plugin.update_config", entity_type="plugin", entity_id=getattr(record, "id", None), changes={"name": name, "config": config}, ) return { "name": name, "config": config, "message": "Plugin configuration updated successfully", } 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() async def get_active_manifests( self, db: AsyncSession, tenant_id: uuid.UUID | None = None ) -> list[dict[str, Any]]: """Return UI manifests for all active plugins. Audit P1 (tenant manifests): *tenant_id* filters out plugins that are deactivated for the caller's tenant (tenant_plugin_activation), mirroring require_active_plugin() so UI and backend agree. """ return await self._registry.get_active_manifests(db, tenant_id=tenant_id) # 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