"""Plugin catalog registry backed by PostgreSQL. Maintains the authoritative list of plugins, their review status, and aggregate install counts. All data is persisted in the ``plugins`` table. Module-level singleton:: from app.marketplace.plugin_registry import registry """ from __future__ import annotations import json from typing import Any, Literal from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession from app.models import Plugin from app.schemas import PluginListResponse, PluginManifest _PAGE_SIZE = 20 def _plugin_to_manifest(p: Plugin) -> PluginManifest: """Convert an ORM ``Plugin`` row to a Pydantic ``PluginManifest``.""" try: permissions = json.loads(p.permissions) if p.permissions else [] except (json.JSONDecodeError, TypeError): permissions = [] return PluginManifest( id=p.id, name=p.name, description=p.description, version=p.version, author=p.author_name, permissions=permissions, category=p.category, price_cents=p.price_cents, ) class PluginRegistry: """PostgreSQL-backed plugin catalog. All methods accept an ``AsyncSession`` parameter so the calling route controls the session lifecycle. """ # ── Queries ────────────────────────────────────────────────────── async def list_plugins( self, db: AsyncSession, category: str | None = None, query: str | None = None, page: int = 1, sort: Literal["rating", "installs", "newest"] = "newest", ) -> PluginListResponse: """Return a page of approved plugins, optionally filtered and sorted.""" base = select(Plugin).where(Plugin.status == "approved") if category: base = base.where(Plugin.category == category) if query: pattern = f"%{query}%" base = base.where( Plugin.name.ilike(pattern) | Plugin.description.ilike(pattern) ) # Count count_q = select(func.count()).select_from(base.subquery()) total = (await db.execute(count_q)).scalar_one() # Sort if sort == "installs": base = base.order_by(Plugin.install_count.desc()) elif sort == "rating": base = base.order_by(Plugin.avg_rating.desc()) else: # newest base = base.order_by(Plugin.created_at.desc()) base = base.offset((page - 1) * _PAGE_SIZE).limit(_PAGE_SIZE) rows = (await db.execute(base)).scalars().all() return PluginListResponse( plugins=[_plugin_to_manifest(r) for r in rows], total=total, page=page, ) async def get_plugin(self, db: AsyncSession, plugin_id: str) -> dict[str, Any] | None: """Return ``{manifest, status, install_count, avg_rating}`` or ``None``.""" result = await db.execute(select(Plugin).where(Plugin.id == plugin_id)) p = result.scalar_one_or_none() if p is None: return None return { "manifest": _plugin_to_manifest(p), "status": p.status, "install_count": p.install_count, "avg_rating": p.avg_rating, } # ── Mutations ──────────────────────────────────────────────────── async def submit_plugin( self, db: AsyncSession, manifest: PluginManifest, package_s3_key: str, ) -> str: """Add *manifest* to the catalog with ``status='pending_review'``. Returns the plugin_id. If a plugin with the same id already exists it is overwritten (re-submission after rejection). """ plugin_id = manifest.id existing = await db.execute(select(Plugin).where(Plugin.id == plugin_id)) row = existing.scalar_one_or_none() if row is not None: row.name = manifest.name row.description = manifest.description row.version = manifest.version row.author_name = manifest.author row.category = manifest.category row.price_cents = manifest.price_cents row.permissions = json.dumps(manifest.permissions) row.status = "pending_review" row.s3_package_key = package_s3_key row.rejection_reason = None else: row = Plugin( id=plugin_id, name=manifest.name, description=manifest.description, version=manifest.version, author_name=manifest.author, category=manifest.category, price_cents=manifest.price_cents, permissions=json.dumps(manifest.permissions), status="pending_review", s3_package_key=package_s3_key, install_count=0, avg_rating=0.0, ) db.add(row) await db.commit() return plugin_id async def approve_plugin(self, db: AsyncSession, plugin_id: str) -> None: """Set *plugin_id* status to ``'approved'``. Raises ``KeyError`` if the plugin is not found. """ result = await db.execute(select(Plugin).where(Plugin.id == plugin_id)) row = result.scalar_one_or_none() if row is None: raise KeyError(f"Plugin not found: {plugin_id}") row.status = "approved" row.rejection_reason = None await db.commit() async def reject_plugin(self, db: AsyncSession, plugin_id: str, reason: str) -> None: """Set *plugin_id* status to ``'rejected'`` and record the reason. Raises ``KeyError`` if the plugin is not found. """ result = await db.execute(select(Plugin).where(Plugin.id == plugin_id)) row = result.scalar_one_or_none() if row is None: raise KeyError(f"Plugin not found: {plugin_id}") row.status = "rejected" row.rejection_reason = reason await db.commit() async def record_install(self, db: AsyncSession, plugin_id: str) -> None: """Increment the install count for *plugin_id* (no-op if not found).""" result = await db.execute(select(Plugin).where(Plugin.id == plugin_id)) row = result.scalar_one_or_none() if row is not None: row.install_count = row.install_count + 1 await db.commit() async def record_uninstall(self, db: AsyncSession, plugin_id: str) -> None: """Decrement the install count for *plugin_id*, floored at 0.""" result = await db.execute(select(Plugin).where(Plugin.id == plugin_id)) row = result.scalar_one_or_none() if row is not None: row.install_count = max(0, row.install_count - 1) await db.commit() # ── Internal helpers used by ReviewQueue ───────────────────────── async def get_pending_entries(self, db: AsyncSession) -> list[dict[str, Any]]: """Return all entries with status='pending_review'.""" result = await db.execute( select(Plugin).where(Plugin.status == "pending_review") ) rows = result.scalars().all() return [ { "manifest": _plugin_to_manifest(r), "submitted_at": int(r.submitted_at.timestamp()) if r.submitted_at else 0, } for r in rows ] # Module-level singleton registry = PluginRegistry()