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
+172
View File
@@ -190,6 +190,178 @@ class MigrationRunner:
return dropped_tables
async def get_applied_migrations(
self,
db: AsyncSession,
plugin_name: str,
) -> list[str]:
"""Get all applied migration filenames for a plugin, sorted by application order.
Args:
db: Async database session.
plugin_name: Name of the plugin.
Returns:
List of migration filenames sorted by application order (oldest first).
"""
from sqlalchemy import select
result = await db.execute(
select(PluginMigration)
.where(
PluginMigration.plugin_name == plugin_name,
PluginMigration.status == "applied",
)
.order_by(PluginMigration.id)
)
return [row.migration_file for row in result.scalars().all()]
async def _find_down_sql(
self,
migration_filename: str,
plugin_name: str | None = None,
) -> str | None:
"""Find rollback SQL for a migration.
Search order:
1. A dedicated down file: <migration_filename>_down.sql
2. A `-- DOWN:` block inside the original migration file
Returns the rollback SQL string, or None if no rollback is found.
"""
# 1. Try dedicated down file
base, ext = os.path.splitext(migration_filename)
down_filename = f"{base}_down{ext}"
try:
down_path = self._resolve_migration_path(down_filename, plugin_name)
return down_path.read_text(encoding="utf-8")
except FileNotFoundError:
pass
# 2. Try parsing -- DOWN: block from the original migration file
try:
up_path = self._resolve_migration_path(migration_filename, plugin_name)
content = up_path.read_text(encoding="utf-8")
except FileNotFoundError:
return None
down_marker = "-- DOWN:"
if down_marker in content:
parts = content.split(down_marker, 1)
if len(parts) == 2:
down_sql = parts[1].strip()
return down_sql if down_sql else None
return None
async def run_migration_down(
self,
db: AsyncSession,
plugin_name: str,
migration_filename: str,
) -> None:
"""Roll back a single migration.
Searches for rollback SQL (dedicated _down.sql file or -- DOWN: block
in the original migration), executes it, and removes the migration
record from plugin_migrations.
Args:
db: Async database session.
plugin_name: Name of the plugin.
migration_filename: Filename of the migration to roll back.
Raises:
FileNotFoundError: If no rollback SQL can be found.
"""
down_sql = await self._find_down_sql(migration_filename, plugin_name)
if down_sql is None:
raise FileNotFoundError(
f"No rollback SQL found for migration '{migration_filename}'. "
f"Create a '{migration_filename.replace('.sql', '_down.sql')}' file "
f"or add a '-- DOWN:' section to the migration file."
)
# Execute the rollback SQL
statements = self._split_sql(down_sql)
for stmt in statements:
stmt = stmt.strip()
if stmt:
await db.execute(text(stmt))
await db.flush()
# Remove the migration record
from sqlalchemy import select
result = await db.execute(
select(PluginMigration).where(
PluginMigration.plugin_name == plugin_name,
PluginMigration.migration_file == migration_filename,
PluginMigration.status == "applied",
)
)
record = result.scalar_one_or_none()
if record:
await db.delete(record)
await db.flush()
async def rollback_to_version(
self,
db: AsyncSession,
plugin_name: str,
target_version: str,
) -> list[str]:
"""Roll back all applied migrations after a target version.
Migrations are rolled back in reverse order (newest first) until
the target version is reached. The plugin version in the database
is updated to the target version.
Args:
db: Async database session.
plugin_name: Name of the plugin.
target_version: Target version string (e.g. '0002'). Migrations
with filenames greater than this will be rolled back.
Returns:
List of migration filenames that were rolled back.
"""
applied = await self.get_applied_migrations(db, plugin_name)
# Filter migrations after target_version (by filename sort order)
migrations_to_rollback = [
m for m in applied if m > target_version
]
if not migrations_to_rollback:
return []
# Roll back in reverse order (newest first)
rolled_back: list[str] = []
for migration_filename in reversed(migrations_to_rollback):
await self.run_migration_down(db, plugin_name, migration_filename)
rolled_back.append(migration_filename)
# Update plugin version in DB (if a plugin_versions table exists)
try:
from sqlalchemy import select, update as sa_update
result = await db.execute(
text("SELECT 1 FROM information_schema.tables "
"WHERE table_schema = 'public' AND table_name = 'plugin_versions'")
)
if result.fetchone():
await db.execute(
text("UPDATE plugin_versions SET version = :version "
"WHERE plugin_name = :plugin_name"),
{"version": target_version, "plugin_name": plugin_name},
)
await db.flush()
except Exception:
pass # plugin_versions table may not exist — that's ok
return rolled_back
async def _get_table_names_via_session(self, db: AsyncSession) -> set[str]:
"""Get current table names using the session's own connection.