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:
@@ -1,7 +1,8 @@
|
||||
"""Plugins routes: browse and install plugins from the marketplace.
|
||||
|
||||
The catalog and installation records are kept in-memory as stubs.
|
||||
Step 10 replaces these with PluginRegistry, RevenueShare, and the plugins DB table.
|
||||
Backed by ``PluginRegistry`` and ``RevenueShare`` service classes introduced
|
||||
in Step 10. Step 12 will swap those services' in-memory stores for
|
||||
PostgreSQL persistence.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -12,49 +13,12 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.config.settings import settings
|
||||
from app.marketplace.plugin_registry import registry
|
||||
from app.marketplace.revenue_share import revenue_share
|
||||
from app.schemas import PluginInstallRequest, PluginListResponse, PluginManifest, UserProfile
|
||||
|
||||
router = APIRouter(prefix="/plugins", tags=["plugins"])
|
||||
|
||||
# ── In-memory catalog (Step 10 replaces with PluginRegistry + DB) ─────
|
||||
|
||||
_plugin_catalog: list[PluginManifest] = [
|
||||
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,
|
||||
),
|
||||
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,
|
||||
),
|
||||
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,
|
||||
),
|
||||
]
|
||||
|
||||
# plugin_id → set of user_ids who have installed it
|
||||
_installations: dict[str, set[str]] = {}
|
||||
|
||||
|
||||
# ── Tier gate ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -67,43 +31,12 @@ def _require_plugin_tier(user: UserProfile) -> None:
|
||||
)
|
||||
|
||||
|
||||
# ── Filter + sort helpers ──────────────────────────────────────────────
|
||||
|
||||
def _apply_filters(
|
||||
plugins: list[PluginManifest],
|
||||
category: str | None,
|
||||
q: str | None,
|
||||
) -> list[PluginManifest]:
|
||||
result = plugins
|
||||
if category:
|
||||
result = [p for p in result if p.category == category]
|
||||
if q:
|
||||
q_lower = q.lower()
|
||||
result = [
|
||||
p for p in result
|
||||
if q_lower in p.name.lower() or q_lower in p.description.lower()
|
||||
]
|
||||
return result
|
||||
|
||||
|
||||
def _apply_sort(
|
||||
plugins: list[PluginManifest],
|
||||
sort: str,
|
||||
) -> list[PluginManifest]:
|
||||
if sort == "installs":
|
||||
return sorted(plugins, key=lambda p: len(_installations.get(p.id, set())), reverse=True)
|
||||
if sort == "rating":
|
||||
# Placeholder until Step 10 introduces avg_rating from DB
|
||||
return sorted(plugins, key=lambda p: -p.price_cents)
|
||||
return plugins # "newest" = catalog insertion order
|
||||
|
||||
|
||||
# ── Local detail schema ────────────────────────────────────────────────
|
||||
|
||||
class _PluginDetail(BaseModel):
|
||||
plugin: PluginManifest
|
||||
install_count: int
|
||||
ratings: list[Any] # Step 10 populates from plugin_reviews table
|
||||
ratings: list[Any] # Step 12 populates from plugin_reviews table
|
||||
|
||||
|
||||
# ── Routes ────────────────────────────────────────────────────────────
|
||||
@@ -118,9 +51,7 @@ async def list_plugins(
|
||||
) -> PluginListResponse:
|
||||
"""Browse the plugin marketplace. Requires Power tier or above."""
|
||||
_require_plugin_tier(current_user)
|
||||
filtered = _apply_filters(_plugin_catalog, category, q)
|
||||
sorted_plugins = _apply_sort(filtered, sort)
|
||||
return PluginListResponse(plugins=sorted_plugins, total=len(sorted_plugins), page=page)
|
||||
return await registry.list_plugins(category=category, query=q, page=page, sort=sort)
|
||||
|
||||
|
||||
@router.get("/{plugin_id}", response_model=_PluginDetail)
|
||||
@@ -130,13 +61,13 @@ async def get_plugin(
|
||||
) -> _PluginDetail:
|
||||
"""Get full plugin details including install count. Requires Power tier or above."""
|
||||
_require_plugin_tier(current_user)
|
||||
plugin = next((p for p in _plugin_catalog if p.id == plugin_id), None)
|
||||
if plugin is None:
|
||||
entry = await registry.get_plugin(plugin_id)
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Plugin not found")
|
||||
return _PluginDetail(
|
||||
plugin=plugin,
|
||||
install_count=len(_installations.get(plugin_id, set())),
|
||||
ratings=[], # Step 10 populates from plugin_reviews table
|
||||
plugin=entry["manifest"],
|
||||
install_count=entry["install_count"],
|
||||
ratings=[], # Step 12 populates from plugin_reviews table
|
||||
)
|
||||
|
||||
|
||||
@@ -146,20 +77,21 @@ async def install_plugin(
|
||||
body: PluginInstallRequest, # noqa: ARG001 — reserved for future fields
|
||||
current_user: UserProfile = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
"""Install a plugin. Triggers Stripe Connect for paid plugins when configured.
|
||||
"""Install a plugin. Triggers Stripe Connect revenue split for paid plugins.
|
||||
|
||||
Requires Power tier or above.
|
||||
"""
|
||||
_require_plugin_tier(current_user)
|
||||
plugin = next((p for p in _plugin_catalog if p.id == plugin_id), None)
|
||||
if plugin is None:
|
||||
entry = await registry.get_plugin(plugin_id)
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Plugin not found")
|
||||
|
||||
if plugin.price_cents > 0 and settings.STRIPE_SECRET_KEY:
|
||||
# TODO(Step10): stripe.PaymentIntent.create with destination charge (70/30 split)
|
||||
pass
|
||||
await revenue_share.record_install(
|
||||
plugin_id=plugin_id,
|
||||
user_id=current_user.id,
|
||||
amount_cents=entry["manifest"].price_cents,
|
||||
)
|
||||
|
||||
_installations.setdefault(plugin_id, set()).add(current_user.id)
|
||||
download_url = f"https://cdn.adiuva.app/plugins/{plugin_id}/package.zip"
|
||||
return {"ok": True, "download_url": download_url}
|
||||
|
||||
@@ -170,5 +102,5 @@ async def uninstall_plugin(
|
||||
current_user: UserProfile = Depends(get_current_user),
|
||||
) -> dict[str, bool]:
|
||||
"""Unregister a plugin installation."""
|
||||
_installations.get(plugin_id, set()).discard(current_user.id)
|
||||
await registry.record_uninstall(plugin_id)
|
||||
return {"ok": True}
|
||||
|
||||
Reference in New Issue
Block a user