Files
leocrm/app/plugins/base.py
T
Agent Zero 98eb1d0d89
Check Cross-Plugin Imports / check (push) Has been cancelled
feat: Plugin-System Umbau — 6 Phasen komplett abgeschlossen
Phase 1: Contracts konsequent nutzen
- 12 neue contracts.py erstellt (alle 19 Plugins haben jetzt contracts)
- 4 bestehende contracts.py an zentrale ContractRegistry angepasst
- Alle 19 Plugins haben on_deactivate mit Contract-Unregister
- 0 echte problematische INTER-Plugin Imports

Phase 2: Hooks/Filters-System
- app/core/hooks.py (HookRegistry mit actions + filters)
- 15 Hook-Punkte in Core-Services (contact, auth, mail, calendar, user, dms)
- BasePlugin.on_deactivate meldet alle Hooks ab

Phase 3: Plugin-Isolation
- scripts/check_cross_plugin_imports.py (Linting-Regel)
- .github/workflows/check-cross-plugin-imports.yml (CI/CD)
- .pre-commit-cross-plugin.yaml (Pre-commit Hook)
- 155 Dateien geprueft, 0 Verstoesse

Phase 4: Plugin-Versioning
- app/plugins/semver.py (SemVer mit Parse, Compare, Pre-release)
- migration_runner.py erweitert: run_migration_down, rollback_to_version
- manifest.py: min_app_version Feld
- registry.py: App-Version-Compatibility-Check bei Installation
- GET /api/v1/plugins/updates Endpoint

Phase 5: Marketplace-Vorbereitung
- app/plugins/signature.py (Ed25519 Signatur-Validierung)
- app/plugins/quarantine.py (Plugin-Quarantine mit Validierung)
- app/models/plugin_allowlist.py + Migration 0046
- manifest.py: author, license, homepage, icon, screenshots, changelog, marketplace_tags, price
- registry.py: discover_external(), discover_all()
- POST /api/v1/plugins/install-marketplace (deaktiviert)

Phase 6: Manifest-Anpassung
- manifest.py: 12 neue Felder + SemVer/Hook-Name Validierung
- MANIFEST_SCHEMA_DOC aktualisiert
- Alle 19 Plugin-Manifeste aktualisiert
- Frontend PluginUiManifest Typ erweitert

Zusaetzliche Bug-Fixes:
- test_sample-Modul erstellt
- conftest.py Deadlock-Prevention
- SESSION_COOKIE_SECURE=true
- dump.rdb aus Git entfernt + .gitignore
- backup.py datetime.utcnow -> func.now()
- system_settings.py JSONB-Import nach oben
- tax.py Mapped[float] -> Mapped[Decimal]
- notification.py type_key-Laengen vereinheitlicht

Tests: 91 neue Tests, alle bestanden
2026-07-26 23:15:34 +02:00

166 lines
5.6 KiB
Python

"""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] = []
self._container: Any = None # ServiceContainer, set during on_activate
@property
def services(self) -> Any:
"""Access the ServiceContainer after activation."""
if self._container is None:
raise RuntimeError("Services not available — plugin not activated")
return self._container
# ─── 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)
self._container = service_container
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 and hooks that were registered during activation.
"""
# Unsubscribe event listeners
for event_name, handler in self._event_handlers.items():
event_bus.unsubscribe(event_name, handler)
self._event_handlers.clear()
# Unregister all hooks owned by this plugin
from app.core.hooks import get_hook_registry
get_hook_registry().unregister_all_for_plugin(self.manifest.name)
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
# ─── Notification Types ───
def get_notification_types(self) -> list[dict[str, Any]]:
"""Return notification types this plugin registers.
Override in subclasses to register notification types.
Each dict must have: type_key, category, label, description, is_enabled_by_default
"""
return []
# ─── Field Definitions ───
def get_field_definitions(self) -> list[dict[str, str]]:
"""Return field definitions from manifest."""
return [
{"module": fd.module, "field": fd.field, "label": fd.label, "sensitivity": fd.sensitivity}
for fd in self.manifest.field_definitions
]
# ─── 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}>"
)