feat: Plugin-System Umbau — 6 Phasen komplett abgeschlossen
Check Cross-Plugin Imports / check (push) Has been cancelled

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
This commit is contained in:
Agent Zero
2026-07-26 23:15:34 +02:00
parent 744d595cae
commit 98eb1d0d89
62 changed files with 3284 additions and 18 deletions
+80
View File
@@ -104,6 +104,67 @@ class PluginRegistry:
return discovered
def discover_external(self) -> list[str]:
"""Discover plugins from an external plugins/ directory.
Scans the directory specified by EXTERNAL_PLUGINS_PATH env var
(default: 'plugins/') for plugin packages.
Returns list of discovered plugin names.
"""
import os
import sys
discovered: list[str] = []
external_dir = Path(os.environ.get("EXTERNAL_PLUGINS_PATH", "plugins"))
if not external_dir.exists():
return discovered
for plugin_dir in sorted(external_dir.iterdir()):
if not plugin_dir.is_dir() or plugin_dir.name.startswith("_"):
continue
# Look for plugin.py or __init__.py
plugin_file = plugin_dir / "plugin.py"
init_file = plugin_dir / "__init__.py"
if not plugin_file.exists() and not init_file.exists():
continue
try:
# Add to sys.path temporarily
str_dir = str(external_dir)
if str_dir not in sys.path:
sys.path.insert(0, str_dir)
module_name = f"{plugin_dir.name}.plugin" if plugin_file.exists() else plugin_dir.name
module = importlib.import_module(module_name)
# Look for BasePlugin subclass
for attr_name in dir(module):
attr = getattr(module, attr_name)
if (
isinstance(attr, type)
and issubclass(attr, BasePlugin)
and attr is not BasePlugin
):
instance = attr()
if instance.name not in self._plugins:
self._plugins[instance.name] = instance
discovered.append(instance.name)
logger.info(f"Discovered external plugin: {instance.name} v{instance.version}")
except Exception as exc:
logger.warning(f"Failed to load external plugin {plugin_dir.name}: {exc}")
return discovered
def discover_all(self) -> list[str]:
"""Discover built-in AND external plugins."""
discovered = self.discover_builtins()
discovered.extend(self.discover_external())
return discovered
def register_plugin(self, plugin: BasePlugin) -> None:
"""Manually register a plugin instance."""
self._plugins[plugin.name] = plugin
@@ -465,6 +526,25 @@ class PluginRegistry:
await self._check_and_run_version_migrations(db, name, existing)
return existing
# Check app version compatibility
from app.plugins.semver import SemVer
from app.config import get_settings
settings = get_settings()
app_version = getattr(settings, "app_version", "0.0.0")
min_version = plugin.manifest.min_app_version
if min_version and min_version != "0.0.0":
try:
if not SemVer.parse(app_version).is_compatible_with(SemVer.parse(min_version)):
raise ValueError(
f"Plugin '{name}' requires LeoCRM >= {min_version}, "
f"but current version is {app_version}"
)
except ValueError as e:
if "Invalid semver" in str(e):
pass # Skip check if version is not valid SemVer
else:
raise
# Check dependencies are installed
await self._check_dependencies_installed(db, name)