abbe7a18fc
- P0: hooks.py 3-tuple fix, trigger_dispatcher Contract, contacts/plugin unregister_actions_by_owner - P0: 5 test files — check_permission mocks removed, hardcoded DB credential → env var - P1: attachment_service DmsFile via Contract helper, restore_registry/history_hooks dedup - P1: mail/plugin restore unregister, mcp_client datetime.now(UTC), saved_views/filters patterns - P1: ProtectedRoute fail-closed, 13 test assertion fixes (bcrypt, DB-URLs, SECRET_KEYs) - P2: deprecated notifications → post_system_message (3 files), forgejo Base, report_generator lazy import - P2: webhooks permissions, deps.py/roles.py plugin perms removed, import_export default - P2: address/tags/entity_links patterns removed, worker.py Contract-Umgehungen fixed - P2: 28 frontend TODOs (hardcoded constants, deprecated notification API) - P3: dead code, duplicates, deprecated imports, private attr, __import__ inline - P3: 8 frontend TODOs (LucideIcons, inline styles, XSS, i18n) - ruff: 838 → 0 (612 auto-fix + 246 manual + 27 F821 regression fix) - F821: 30 → 0 (AutomationDefinition, DmsFile, user_id, Path, Any, String) - Contract-Umgehungen: 2 neue gefunden (worker.py:169, worker.py:280) und gefixt
317 lines
11 KiB
Python
317 lines
11 KiB
Python
"""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:
|
|
record = await self._registry.activate(db, name)
|
|
was_already_active = record.active and record.status == "active"
|
|
|
|
# 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)
|
|
|
|
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:
|
|
record = await self._registry.deactivate(db, name)
|
|
was_already_inactive = not record.active and record.status == "inactive"
|
|
|
|
# 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)
|
|
|
|
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:
|
|
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) -> list[dict[str, Any]]:
|
|
"""Return UI manifests for all active plugins."""
|
|
return await self._registry.get_active_manifests(db)
|
|
|
|
|
|
# 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
|