feat: Plugin-System Umbau — 6 Phasen komplett abgeschlossen
Check Cross-Plugin Imports / check (push) Has been cancelled
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:
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user