step 10 complete: plugin marketplace with catalog, review workflow, and revenue split
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
211
app/marketplace/plugin_registry.py
Normal file
211
app/marketplace/plugin_registry.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""Plugin catalog registry.
|
||||
|
||||
Maintains the authoritative list of plugins, their review status, and
|
||||
aggregate install counts. Storage is in-memory until Step 12 migrates to
|
||||
the ``plugins`` PostgreSQL table.
|
||||
|
||||
Module-level singleton::
|
||||
|
||||
from app.marketplace.plugin_registry import registry
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Literal
|
||||
|
||||
from app.schemas import PluginListResponse, PluginManifest
|
||||
|
||||
# ── Pre-seeded approved plugins (mirrors the Step 8 stub catalog) ─────
|
||||
|
||||
_SEED_PLUGINS: list[dict[str, Any]] = [
|
||||
{
|
||||
"manifest": PluginManifest(
|
||||
id="plugin-github-sync",
|
||||
name="GitHub Sync",
|
||||
description="Sync tasks with GitHub Issues and pull requests.",
|
||||
version="1.0.0",
|
||||
author="Adiuva",
|
||||
permissions=["read:tasks", "write:tasks"],
|
||||
category="productivity",
|
||||
price_cents=0,
|
||||
),
|
||||
"status": "approved",
|
||||
"s3_package_key": "plugins/plugin-github-sync/1.0.0/package.zip",
|
||||
"install_count": 0,
|
||||
"avg_rating": 0.0,
|
||||
"rejection_reason": None,
|
||||
"submitted_at": int(time.time()),
|
||||
},
|
||||
{
|
||||
"manifest": PluginManifest(
|
||||
id="plugin-slack-notify",
|
||||
name="Slack Notifier",
|
||||
description="Post task and checkpoint updates to Slack channels.",
|
||||
version="1.2.0",
|
||||
author="Adiuva",
|
||||
permissions=["read:tasks", "read:checkpoints"],
|
||||
category="communication",
|
||||
price_cents=499,
|
||||
),
|
||||
"status": "approved",
|
||||
"s3_package_key": "plugins/plugin-slack-notify/1.2.0/package.zip",
|
||||
"install_count": 0,
|
||||
"avg_rating": 0.0,
|
||||
"rejection_reason": None,
|
||||
"submitted_at": int(time.time()),
|
||||
},
|
||||
{
|
||||
"manifest": PluginManifest(
|
||||
id="plugin-time-tracker",
|
||||
name="Time Tracker",
|
||||
description="Track time spent on tasks with automatic reporting.",
|
||||
version="0.9.1",
|
||||
author="Third Party",
|
||||
permissions=["read:tasks", "write:tasks"],
|
||||
category="productivity",
|
||||
price_cents=999,
|
||||
),
|
||||
"status": "approved",
|
||||
"s3_package_key": "plugins/plugin-time-tracker/0.9.1/package.zip",
|
||||
"install_count": 0,
|
||||
"avg_rating": 0.0,
|
||||
"rejection_reason": None,
|
||||
"submitted_at": int(time.time()),
|
||||
},
|
||||
]
|
||||
|
||||
_PAGE_SIZE = 20
|
||||
|
||||
|
||||
class PluginRegistry:
|
||||
"""In-process plugin catalog.
|
||||
|
||||
All mutating methods are ``async`` to make the future DB swap transparent
|
||||
to callers.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# plugin_id → entry dict (deep-copied so each instance is independent)
|
||||
self._catalog: dict[str, dict[str, Any]] = {
|
||||
e["manifest"].id: copy.deepcopy(e) for e in _SEED_PLUGINS
|
||||
}
|
||||
|
||||
# ── Queries ──────────────────────────────────────────────────────
|
||||
|
||||
async def list_plugins(
|
||||
self,
|
||||
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."""
|
||||
entries = [e for e in self._catalog.values() if e["status"] == "approved"]
|
||||
|
||||
if category:
|
||||
entries = [e for e in entries if e["manifest"].category == category]
|
||||
|
||||
if query:
|
||||
q_lower = query.lower()
|
||||
entries = [
|
||||
e
|
||||
for e in entries
|
||||
if q_lower in e["manifest"].name.lower()
|
||||
or q_lower in e["manifest"].description.lower()
|
||||
]
|
||||
|
||||
if sort == "installs":
|
||||
entries = sorted(entries, key=lambda e: e["install_count"], reverse=True)
|
||||
elif sort == "rating":
|
||||
entries = sorted(entries, key=lambda e: e["avg_rating"], reverse=True)
|
||||
# "newest" = catalog insertion order (dict preserves insertion in Python 3.7+)
|
||||
|
||||
total = len(entries)
|
||||
start = (page - 1) * _PAGE_SIZE
|
||||
page_entries = entries[start : start + _PAGE_SIZE]
|
||||
|
||||
return PluginListResponse(
|
||||
plugins=[e["manifest"] for e in page_entries],
|
||||
total=total,
|
||||
page=page,
|
||||
)
|
||||
|
||||
async def get_plugin(self, plugin_id: str) -> dict[str, Any] | None:
|
||||
"""Return ``{manifest, status, install_count, avg_rating}`` or ``None``."""
|
||||
entry = self._catalog.get(plugin_id)
|
||||
if entry is None:
|
||||
return None
|
||||
return {
|
||||
"manifest": entry["manifest"],
|
||||
"status": entry["status"],
|
||||
"install_count": entry["install_count"],
|
||||
"avg_rating": entry["avg_rating"],
|
||||
}
|
||||
|
||||
# ── Mutations ────────────────────────────────────────────────────
|
||||
|
||||
async def submit_plugin(
|
||||
self,
|
||||
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 or str(uuid.uuid4())
|
||||
self._catalog[plugin_id] = {
|
||||
"manifest": manifest,
|
||||
"status": "pending_review",
|
||||
"s3_package_key": package_s3_key,
|
||||
"install_count": 0,
|
||||
"avg_rating": 0.0,
|
||||
"rejection_reason": None,
|
||||
"submitted_at": int(time.time()),
|
||||
}
|
||||
return plugin_id
|
||||
|
||||
async def approve_plugin(self, plugin_id: str) -> None:
|
||||
"""Set *plugin_id* status to ``'approved'``.
|
||||
|
||||
Raises ``KeyError`` if the plugin is not found.
|
||||
"""
|
||||
if plugin_id not in self._catalog:
|
||||
raise KeyError(f"Plugin not found: {plugin_id}")
|
||||
self._catalog[plugin_id]["status"] = "approved"
|
||||
self._catalog[plugin_id]["rejection_reason"] = None
|
||||
|
||||
async def reject_plugin(self, plugin_id: str, reason: str) -> None:
|
||||
"""Set *plugin_id* status to ``'rejected'`` and record the reason.
|
||||
|
||||
Raises ``KeyError`` if the plugin is not found.
|
||||
"""
|
||||
if plugin_id not in self._catalog:
|
||||
raise KeyError(f"Plugin not found: {plugin_id}")
|
||||
self._catalog[plugin_id]["status"] = "rejected"
|
||||
self._catalog[plugin_id]["rejection_reason"] = reason
|
||||
|
||||
async def record_install(self, plugin_id: str) -> None:
|
||||
"""Increment the install count for *plugin_id* (no-op if not found)."""
|
||||
if plugin_id in self._catalog:
|
||||
self._catalog[plugin_id]["install_count"] += 1
|
||||
|
||||
async def record_uninstall(self, plugin_id: str) -> None:
|
||||
"""Decrement the install count for *plugin_id*, floored at 0."""
|
||||
if plugin_id in self._catalog:
|
||||
current = self._catalog[plugin_id]["install_count"]
|
||||
self._catalog[plugin_id]["install_count"] = max(0, current - 1)
|
||||
|
||||
# ── Internal helpers used by ReviewQueue ─────────────────────────
|
||||
|
||||
def _get_pending_entries(self) -> list[dict[str, Any]]:
|
||||
"""Return all entries with status='pending_review' (synchronous helper)."""
|
||||
return [e for e in self._catalog.values() if e["status"] == "pending_review"]
|
||||
|
||||
|
||||
# Module-level singleton
|
||||
registry = PluginRegistry()
|
||||
Reference in New Issue
Block a user