213 lines
7.6 KiB
Python
213 lines
7.6 KiB
Python
"""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()
|