chore: fix all ruff lint errors + format — 0 errors, 306 tests pass

This commit is contained in:
leocrm-bot
2026-06-29 17:43:56 +02:00
parent 316f323ff4
commit a2452cc04b
81 changed files with 2317 additions and 1128 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
"""Plugin system framework for LeoCRM."""
from app.plugins.manifest import PluginManifest
from app.plugins.base import BasePlugin
from app.plugins.registry import get_registry
from app.plugins.manifest import PluginManifest
from app.plugins.migration_runner import MigrationRunner
from app.plugins.registry import get_registry
__all__ = ["PluginManifest", "BasePlugin", "get_registry", "MigrationRunner"]
+7 -1
View File
@@ -11,6 +11,7 @@ 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
@@ -85,6 +86,7 @@ class BasePlugin(ABC):
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)
@@ -106,9 +108,11 @@ class BasePlugin(ABC):
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 ───
@@ -122,4 +126,6 @@ class BasePlugin(ABC):
return self.manifest.version
def __repr__(self) -> str:
return f"<{self.__class__.__name__} name={self.manifest.name} version={self.manifest.version}>"
return (
f"<{self.__class__.__name__} name={self.manifest.name} version={self.manifest.version}>"
)
+2 -2
View File
@@ -7,8 +7,8 @@ Subdirectory plugins (tags, permissions, entity_links) export their plugin
class via __init__.py so the registry can discover them as packages.
"""
from app.plugins.builtins.tags import TagsPlugin
from app.plugins.builtins.permissions import PermissionsPlugin
from app.plugins.builtins.entity_links import EntityLinksPlugin
from app.plugins.builtins.permissions import PermissionsPlugin
from app.plugins.builtins.tags import TagsPlugin
__all__ = ["TagsPlugin", "PermissionsPlugin", "EntityLinksPlugin"]
+6 -5
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import uuid
from sqlalchemy import String, ForeignKey, Index, UniqueConstraint
from sqlalchemy import Index, String, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -17,7 +17,10 @@ class EntityLink(Base, TenantMixin):
__tablename__ = "entity_links"
__table_args__ = (
UniqueConstraint(
"tenant_id", "file_id", "entity_type", "entity_id",
"tenant_id",
"file_id",
"entity_type",
"entity_id",
name="uq_entity_links_file_entity",
),
Index("ix_entity_links_file", "tenant_id", "file_id"),
@@ -30,6 +33,4 @@ class EntityLink(Base, TenantMixin):
file_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
entity_type: Mapped[str] = mapped_column(String(20), nullable=False)
entity_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
created_by: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), nullable=True
)
created_by: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
@@ -42,6 +42,7 @@ class EntityLinksPlugin(BasePlugin):
async def on_company_deleted(self, payload: dict[str, Any]) -> None:
"""Handle company.deleted event — remove all EntityLink rows for that company."""
from sqlalchemy import delete
from app.core.db import get_session_factory
from app.plugins.builtins.entity_links.models import EntityLink
@@ -51,6 +52,7 @@ class EntityLinksPlugin(BasePlugin):
return
import uuid as _uuid
factory = get_session_factory()
async with factory() as session:
await session.execute(
@@ -65,6 +67,7 @@ class EntityLinksPlugin(BasePlugin):
async def on_contact_deleted(self, payload: dict[str, Any]) -> None:
"""Handle contact.deleted event — remove all EntityLink rows for that contact."""
from sqlalchemy import delete
from app.core.db import get_session_factory
from app.plugins.builtins.entity_links.models import EntityLink
@@ -74,6 +77,7 @@ class EntityLinksPlugin(BasePlugin):
return
import uuid as _uuid
factory = get_session_factory()
async with factory() as session:
await session.execute(
+7 -3
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import uuid
from fastapi import APIRouter, Body, Depends, HTTPException, Response, status
from sqlalchemy import select, delete
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
@@ -24,7 +24,9 @@ def _parse_uuid(val: str, field: str) -> uuid.UUID:
try:
return uuid.UUID(val)
except (ValueError, TypeError):
raise HTTPException(400, detail={"detail": f"Invalid {field}", "code": "invalid_id"})
raise HTTPException(
400, detail={"detail": f"Invalid {field}", "code": "invalid_id"}
) from None
@router.post("/files/{file_id}/link")
@@ -41,7 +43,9 @@ async def link_file_to_entity(
entity_id = _parse_uuid(body.entity_id, "entity_id")
if body.entity_type not in VALID_ENTITY_TYPES:
raise HTTPException(400, detail={"detail": "Invalid entity_type", "code": "invalid_entity_type"})
raise HTTPException(
400, detail={"detail": "Invalid entity_type", "code": "invalid_entity_type"}
)
# Check if link already exists
existing = await db.execute(
+8 -11
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, ForeignKey, Index, UniqueConstraint
from sqlalchemy import DateTime, Index, String, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -18,7 +18,10 @@ class Permission(Base, TenantMixin):
__tablename__ = "permissions"
__table_args__ = (
UniqueConstraint(
"tenant_id", "file_id", "user_id", "access_level",
"tenant_id",
"file_id",
"user_id",
"access_level",
name="uq_permissions_file_user_level",
),
Index("ix_permissions_file", "tenant_id", "file_id"),
@@ -30,9 +33,7 @@ class Permission(Base, TenantMixin):
)
file_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
user_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
group_id: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), nullable=True
)
group_id: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
access_level: Mapped[str] = mapped_column(String(10), nullable=False, default="read")
@@ -51,10 +52,6 @@ class ShareLink(Base, TenantMixin):
file_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
token: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
access_level: Mapped[str] = mapped_column(String(10), nullable=False, default="download")
created_by: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), nullable=True
)
created_by: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
@@ -2,10 +2,6 @@
from __future__ import annotations
from typing import Any
from fastapi import APIRouter
from app.plugins.base import BasePlugin
from app.plugins.manifest import PluginManifest, PluginRouteDef
+27 -16
View File
@@ -4,10 +4,10 @@ from __future__ import annotations
import secrets
import uuid
from datetime import datetime, timezone
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy import select, delete
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.auth import hash_password, verify_password
@@ -28,11 +28,14 @@ def _parse_uuid(val: str, field: str) -> uuid.UUID:
try:
return uuid.UUID(val)
except (ValueError, TypeError):
raise HTTPException(400, detail={"detail": f"Invalid {field}", "code": "invalid_id"})
raise HTTPException(
400, detail={"detail": f"Invalid {field}", "code": "invalid_id"}
) from None
# ─── Permissions CRUD ───
@router.get("/files/{file_id}/permissions")
async def list_permissions(
file_id: str,
@@ -85,7 +88,9 @@ async def grant_permission(
)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(409, detail={"detail": "Permission already exists", "code": "duplicate"})
raise HTTPException(
409, detail={"detail": "Permission already exists", "code": "duplicate"}
)
perm = Permission(
tenant_id=tenant_id,
@@ -135,8 +140,13 @@ async def revoke_permission(
# ─── Permission Check Helper ───
async def check_user_file_permission(
db: AsyncSession, tenant_id: uuid.UUID, file_id: uuid.UUID, user_id: uuid.UUID, required: str = "read"
db: AsyncSession,
tenant_id: uuid.UUID,
file_id: uuid.UUID,
user_id: uuid.UUID,
required: str = "read",
) -> bool:
"""Check if user has required permission on a file."""
result = await db.execute(
@@ -158,6 +168,7 @@ async def check_user_file_permission(
# ─── Share Links ───
@router.post("/files/{file_id}/share-link")
async def create_share_link(
file_id: str,
@@ -219,6 +230,7 @@ async def revoke_share_link(
# ─── Public Share Access (NO AUTH) ───
@public_router.get("/share/{token}")
async def public_access(
token: str,
@@ -229,23 +241,20 @@ async def public_access(
Returns 410 Gone if link is expired.
Returns 403 if password is required but not provided/invalid.
"""
result = await db.execute(
select(ShareLink).where(ShareLink.token == token)
)
result = await db.execute(select(ShareLink).where(ShareLink.token == token))
link = result.scalar_one_or_none()
if link is None:
raise HTTPException(404, detail={"detail": "Share link not found", "code": "not_found"})
# Check expiry
if link.expires_at is not None:
now = datetime.now(timezone.utc)
now = datetime.now(UTC)
if now > link.expires_at:
raise HTTPException(410, detail={"detail": "Share link expired", "code": "expired"})
# Check password
if link.password_hash is not None:
# Password must be provided via query param or header — check query param
from fastapi import Request
# We need the request to get the password param
# For simplicity, return that password is required
raise HTTPException(
@@ -267,25 +276,27 @@ async def public_access_with_password(
db: AsyncSession = Depends(get_db),
):
"""Public share access with password verification — no auth required."""
result = await db.execute(
select(ShareLink).where(ShareLink.token == token)
)
result = await db.execute(select(ShareLink).where(ShareLink.token == token))
link = result.scalar_one_or_none()
if link is None:
raise HTTPException(404, detail={"detail": "Share link not found", "code": "not_found"})
# Check expiry
if link.expires_at is not None:
now = datetime.now(timezone.utc)
now = datetime.now(UTC)
if now > link.expires_at:
raise HTTPException(410, detail={"detail": "Share link expired", "code": "expired"})
# Verify password if required
if link.password_hash is not None:
if body.password is None:
raise HTTPException(401, detail={"detail": "Password required", "code": "password_required"})
raise HTTPException(
401, detail={"detail": "Password required", "code": "password_required"}
)
if not verify_password(body.password, link.password_hash):
raise HTTPException(403, detail={"detail": "Invalid password", "code": "invalid_password"})
raise HTTPException(
403, detail={"detail": "Invalid password", "code": "invalid_password"}
)
return {
"file_id": str(link.file_id),
+5 -2
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import uuid
from sqlalchemy import String, ForeignKey, Index, UniqueConstraint
from sqlalchemy import ForeignKey, Index, String, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -33,7 +33,10 @@ class TagAssignment(Base, TenantMixin):
__tablename__ = "tag_assignments"
__table_args__ = (
UniqueConstraint(
"tenant_id", "tag_id", "entity_type", "entity_id",
"tenant_id",
"tag_id",
"entity_type",
"entity_id",
name="uq_tag_assignments_entity",
),
Index("ix_tag_assignments_tag", "tenant_id", "tag_id"),
+41 -28
View File
@@ -5,18 +5,18 @@ from __future__ import annotations
import uuid
from fastapi import APIRouter, Body, Depends, HTTPException, Response, status
from sqlalchemy import select, func, delete
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.deps import get_current_user
from app.plugins.builtins.tags.models import Tag, TagAssignment
from app.plugins.builtins.tags.schemas import (
TagCreate,
TagUpdate,
TagAssignRequest,
TagUnassignRequest,
TagBulkAssignRequest,
TagCreate,
TagUnassignRequest,
TagUpdate,
)
router = APIRouter(prefix="/api/v1/tags", tags=["tags"])
@@ -28,7 +28,9 @@ def _parse_uuid(val: str, field: str) -> uuid.UUID:
try:
return uuid.UUID(val)
except (ValueError, TypeError):
raise HTTPException(400, detail={"detail": f"Invalid {field}", "code": "invalid_id"})
raise HTTPException(
400, detail={"detail": f"Invalid {field}", "code": "invalid_id"}
) from None
@router.get("")
@@ -107,9 +109,7 @@ async def update_tag(
tenant_id = uuid.UUID(current_user["tenant_id"])
tid = _parse_uuid(tag_id, "tag_id")
result = await db.execute(
select(Tag).where(Tag.id == tid, Tag.tenant_id == tenant_id)
)
result = await db.execute(select(Tag).where(Tag.id == tid, Tag.tenant_id == tenant_id))
tag = result.scalar_one_or_none()
if tag is None:
raise HTTPException(404, detail={"detail": "Tag not found", "code": "not_found"})
@@ -122,7 +122,9 @@ async def update_tag(
select(Tag).where(Tag.tenant_id == tenant_id, Tag.name == data["name"])
)
if dup.scalar_one_or_none() is not None:
raise HTTPException(409, detail={"detail": "Tag name already exists", "code": "duplicate"})
raise HTTPException(
409, detail={"detail": "Tag name already exists", "code": "duplicate"}
)
tag.name = data["name"]
if "color" in data:
tag.color = data["color"]
@@ -148,12 +150,12 @@ async def assign_tag(
entity_id = _parse_uuid(body.entity_id, "entity_id")
if body.entity_type not in VALID_ENTITY_TYPES:
raise HTTPException(400, detail={"detail": "Invalid entity_type", "code": "invalid_entity_type"})
raise HTTPException(
400, detail={"detail": "Invalid entity_type", "code": "invalid_entity_type"}
)
# Verify tag exists
tag_result = await db.execute(
select(Tag).where(Tag.id == tag_id, Tag.tenant_id == tenant_id)
)
tag_result = await db.execute(select(Tag).where(Tag.id == tag_id, Tag.tenant_id == tenant_id))
if tag_result.scalar_one_or_none() is None:
raise HTTPException(404, detail={"detail": "Tag not found", "code": "tag_not_found"})
@@ -167,7 +169,13 @@ async def assign_tag(
)
)
if existing.scalar_one_or_none() is not None:
return {"id": str(tag_id), "tag_id": str(tag_id), "entity_type": body.entity_type, "entity_id": str(entity_id), "already_assigned": True}
return {
"id": str(tag_id),
"tag_id": str(tag_id),
"entity_type": body.entity_type,
"entity_id": str(entity_id),
"already_assigned": True,
}
assignment = TagAssignment(
tenant_id=tenant_id,
@@ -177,7 +185,13 @@ async def assign_tag(
)
db.add(assignment)
await db.flush()
return {"id": str(assignment.id), "tag_id": str(tag_id), "entity_type": body.entity_type, "entity_id": str(entity_id), "already_assigned": False}
return {
"id": str(assignment.id),
"tag_id": str(tag_id),
"entity_type": body.entity_type,
"entity_id": str(entity_id),
"already_assigned": False,
}
@router.delete("/assign", status_code=status.HTTP_204_NO_CONTENT)
@@ -217,22 +231,21 @@ async def delete_tag(
tenant_id = uuid.UUID(current_user["tenant_id"])
tid = _parse_uuid(tag_id, "tag_id")
result = await db.execute(
select(Tag).where(Tag.id == tid, Tag.tenant_id == tenant_id)
)
result = await db.execute(select(Tag).where(Tag.id == tid, Tag.tenant_id == tenant_id))
tag = result.scalar_one_or_none()
if tag is None:
raise HTTPException(404, detail={"detail": "Tag not found", "code": "not_found"})
# Cascade delete assignments
await db.execute(
delete(TagAssignment).where(TagAssignment.tag_id == tid, TagAssignment.tenant_id == tenant_id)
delete(TagAssignment).where(
TagAssignment.tag_id == tid, TagAssignment.tenant_id == tenant_id
)
)
await db.delete(tag)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post("/bulk-assign")
async def bulk_assign_tags(
body: TagBulkAssignRequest,
@@ -244,17 +257,19 @@ async def bulk_assign_tags(
entity_id = _parse_uuid(body.entity_id, "entity_id")
if body.entity_type not in VALID_ENTITY_TYPES:
raise HTTPException(400, detail={"detail": "Invalid entity_type", "code": "invalid_entity_type"})
raise HTTPException(
400, detail={"detail": "Invalid entity_type", "code": "invalid_entity_type"}
)
tag_ids = [_parse_uuid(tid, "tag_id") for tid in body.tag_ids]
# Verify all tags exist
for tid in tag_ids:
result = await db.execute(
select(Tag).where(Tag.id == tid, Tag.tenant_id == tenant_id)
)
result = await db.execute(select(Tag).where(Tag.id == tid, Tag.tenant_id == tenant_id))
if result.scalar_one_or_none() is None:
raise HTTPException(404, detail={"detail": f"Tag {tid} not found", "code": "tag_not_found"})
raise HTTPException(
404, detail={"detail": f"Tag {tid} not found", "code": "tag_not_found"}
)
assigned = []
already = []
@@ -299,9 +314,7 @@ async def list_tag_entities(
tid = _parse_uuid(tag_id, "tag_id")
# Verify tag exists
tag_result = await db.execute(
select(Tag).where(Tag.id == tid, Tag.tenant_id == tenant_id)
)
tag_result = await db.execute(select(Tag).where(Tag.id == tid, Tag.tenant_id == tenant_id))
if tag_result.scalar_one_or_none() is None:
raise HTTPException(404, detail={"detail": "Tag not found", "code": "not_found"})
-2
View File
@@ -2,8 +2,6 @@
from __future__ import annotations
import uuid
from pydantic import BaseModel, Field
+61 -15
View File
@@ -10,21 +10,35 @@ class PluginRouteDef(BaseModel):
path: str = Field(..., description="URL path prefix, e.g. /api/v1/plugin-mail")
module: str = Field(..., description="Dotted path to the module containing the APIRouter")
router_attr: str = Field(default="router", description="Attribute name of the APIRouter in the module")
router_attr: str = Field(
default="router", description="Attribute name of the APIRouter in the module"
)
class PluginManifest(BaseModel):
"""Manifest describing a plugin's metadata, dependencies, and capabilities."""
name: str = Field(..., min_length=1, max_length=80, description="Unique plugin identifier (snake_case)")
name: str = Field(
..., min_length=1, max_length=80, description="Unique plugin identifier (snake_case)"
)
version: str = Field(..., min_length=1, max_length=40, description="Semantic version string")
display_name: str = Field(..., min_length=1, max_length=120)
description: str = Field(default="", max_length=500)
dependencies: list[str] = Field(default_factory=list, description="Other plugin names this plugin depends on")
routes: list[PluginRouteDef] = Field(default_factory=list, description="Route definitions to register on activation")
events: list[str] = Field(default_factory=list, description="Event names this plugin listens to")
migrations: list[str] = Field(default_factory=list, description="Migration file names (ordered, e.g. 0001_initial.sql)")
permissions: list[str] = Field(default_factory=list, description="Required permissions for this plugin")
dependencies: list[str] = Field(
default_factory=list, description="Other plugin names this plugin depends on"
)
routes: list[PluginRouteDef] = Field(
default_factory=list, description="Route definitions to register on activation"
)
events: list[str] = Field(
default_factory=list, description="Event names this plugin listens to"
)
migrations: list[str] = Field(
default_factory=list, description="Migration file names (ordered, e.g. 0001_initial.sql)"
)
permissions: list[str] = Field(
default_factory=list, description="Required permissions for this plugin"
)
@field_validator("name")
@classmethod
@@ -46,15 +60,47 @@ class ManifestSchemaResponse(BaseModel):
# Pre-built schema documentation for GET /api/v1/plugins/manifest endpoint
MANIFEST_SCHEMA_DOC = ManifestSchemaResponse(
fields={
"name": {"type": "str", "required": "true", "description": "Unique plugin identifier (snake_case, max 80 chars)"},
"name": {
"type": "str",
"required": "true",
"description": "Unique plugin identifier (snake_case, max 80 chars)",
},
"version": {"type": "str", "required": "true", "description": "Semantic version string"},
"display_name": {"type": "str", "required": "true", "description": "Human-readable plugin name"},
"description": {"type": "str", "required": "false", "description": "Plugin description (max 500 chars)"},
"dependencies": {"type": "list[str]", "required": "false", "description": "Other plugin names required"},
"routes": {"type": "list[PluginRouteDef]", "required": "false", "description": "Route definitions to register"},
"events": {"type": "list[str]", "required": "false", "description": "Event names to listen to"},
"migrations": {"type": "list[str]", "required": "false", "description": "Migration file names (ordered)"},
"permissions": {"type": "list[str]", "required": "false", "description": "Required permissions"},
"display_name": {
"type": "str",
"required": "true",
"description": "Human-readable plugin name",
},
"description": {
"type": "str",
"required": "false",
"description": "Plugin description (max 500 chars)",
},
"dependencies": {
"type": "list[str]",
"required": "false",
"description": "Other plugin names required",
},
"routes": {
"type": "list[PluginRouteDef]",
"required": "false",
"description": "Route definitions to register",
},
"events": {
"type": "list[str]",
"required": "false",
"description": "Event names to listen to",
},
"migrations": {
"type": "list[str]",
"required": "false",
"description": "Migration file names (ordered)",
},
"permissions": {
"type": "list[str]",
"required": "false",
"description": "Required permissions",
},
},
example=PluginManifest(
name="example_plugin",
+7 -2
View File
@@ -6,7 +6,7 @@ import os
from pathlib import Path
from typing import Any
from sqlalchemy import text, inspect
from sqlalchemy import inspect, text
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from app.models.plugin import PluginMigration
@@ -21,7 +21,9 @@ class MigrationRunner:
def __init__(self, engine: AsyncEngine, migrations_dir: str | None = None) -> None:
self._engine = engine
self._migrations_dir = Path(migrations_dir or os.path.join(os.path.dirname(__file__), "migrations"))
self._migrations_dir = Path(
migrations_dir or os.path.join(os.path.dirname(__file__), "migrations")
)
def _resolve_migration_path(self, filename: str, plugin_name: str | None = None) -> Path:
"""Resolve a migration filename to an absolute path.
@@ -213,6 +215,7 @@ class MigrationRunner:
async def _get_table_names(self) -> set[str]:
"""Get current table names from the database (separate connection)."""
def _get_names(sync_conn):
insp = inspect(sync_conn)
return set(insp.get_table_names())
@@ -222,6 +225,7 @@ class MigrationRunner:
async def _table_has_column(self, table_name: str, column_name: str) -> bool:
"""Check if a table has a specific column (separate connection)."""
def _has_col(sync_conn):
insp = inspect(sync_conn)
if table_name not in insp.get_table_names():
@@ -289,6 +293,7 @@ class MigrationRunner:
def _extract_table_names(sql: str) -> list[str]:
"""Extract table names from CREATE TABLE statements in SQL."""
import re
# Match CREATE TABLE [IF NOT EXISTS] "table_name" or CREATE TABLE table_name
pattern = r'CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["\']?(\w+)["\']?'
return re.findall(pattern, sql, re.IGNORECASE)
+52 -48
View File
@@ -8,7 +8,7 @@ from typing import Any
from fastapi import FastAPI
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, AsyncEngine
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from app.core.event_bus import EventBus, get_event_bus
from app.core.service_container import ServiceContainer, get_container
@@ -78,7 +78,8 @@ class PluginRegistry:
return discovered
import pkgutil
for importer, modname, ispkg in pkgutil.iter_modules(pkg_path):
for _importer, modname, _ispkg in pkgutil.iter_modules(pkg_path):
if modname.startswith("_"):
continue
try:
@@ -144,9 +145,7 @@ class PluginRegistry:
# Run migrations
if plugin.manifest.migrations:
await self.migration_runner.run_all_migrations(
db, name, plugin.manifest.migrations
)
await self.migration_runner.run_all_migrations(db, name, plugin.manifest.migrations)
# Call on_install hook
await plugin.on_install(db, self._container)
@@ -234,7 +233,8 @@ class PluginRegistry:
paths_to_remove.add(route.path)
# Remove matching routes from app
self._app.router.routes = [
r for r in self._app.router.routes
r
for r in self._app.router.routes
if not (hasattr(r, "path") and r.path in paths_to_remove)
]
@@ -309,50 +309,56 @@ class PluginRegistry:
for name, plugin in self._plugins.items():
record = db_records.get(name)
if record is not None:
plugins_list.append({
"name": name,
"display_name": record.display_name,
"version": record.version,
"status": record.status,
"installed": record.installed,
"active": record.active,
"description": plugin.manifest.description,
"dependencies": plugin.manifest.dependencies,
"events": plugin.manifest.events,
"migrations": plugin.manifest.migrations,
"permissions": plugin.manifest.permissions,
})
plugins_list.append(
{
"name": name,
"display_name": record.display_name,
"version": record.version,
"status": record.status,
"installed": record.installed,
"active": record.active,
"description": plugin.manifest.description,
"dependencies": plugin.manifest.dependencies,
"events": plugin.manifest.events,
"migrations": plugin.manifest.migrations,
"permissions": plugin.manifest.permissions,
}
)
else:
plugins_list.append({
"name": name,
"display_name": plugin.manifest.display_name,
"version": plugin.version,
"status": "discovered",
"installed": False,
"active": False,
"description": plugin.manifest.description,
"dependencies": plugin.manifest.dependencies,
"events": plugin.manifest.events,
"migrations": plugin.manifest.migrations,
"permissions": plugin.manifest.permissions,
})
plugins_list.append(
{
"name": name,
"display_name": plugin.manifest.display_name,
"version": plugin.version,
"status": "discovered",
"installed": False,
"active": False,
"description": plugin.manifest.description,
"dependencies": plugin.manifest.dependencies,
"events": plugin.manifest.events,
"migrations": plugin.manifest.migrations,
"permissions": plugin.manifest.permissions,
}
)
# Also include DB-only records (plugins that were installed but no longer discovered)
for name, record in db_records.items():
if name not in self._plugins:
plugins_list.append({
"name": name,
"display_name": record.display_name,
"version": record.version,
"status": record.status,
"installed": record.installed,
"active": record.active,
"description": "",
"dependencies": [],
"events": [],
"migrations": [],
"permissions": [],
})
plugins_list.append(
{
"name": name,
"display_name": record.display_name,
"version": record.version,
"status": record.status,
"installed": record.installed,
"active": record.active,
"description": "",
"dependencies": [],
"events": [],
"migrations": [],
"permissions": [],
}
)
return plugins_list
@@ -360,9 +366,7 @@ class PluginRegistry:
async def _get_plugin_record(self, db: AsyncSession, name: str) -> PluginModel | None:
"""Fetch a plugin record from DB by name."""
result = await db.execute(
select(PluginModel).where(PluginModel.name == name)
)
result = await db.execute(select(PluginModel).where(PluginModel.name == name))
return result.scalar_one_or_none()