Files
adiuva-api/app/api/routes/plugins.py

175 lines
6.4 KiB
Python

"""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.
"""
from __future__ import annotations
from typing import Any, Literal
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.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 ─────────────────────────────────────────────────────────
def _require_plugin_tier(user: UserProfile) -> None:
"""Raise HTTP 403 for users below Power tier."""
if user.tier not in ("power", "team"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Plugin marketplace requires Power tier or above",
)
# ── 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
# ── Routes ────────────────────────────────────────────────────────────
@router.get("", response_model=PluginListResponse)
async def list_plugins(
category: str | None = Query(default=None),
q: str | None = Query(default=None),
page: int = Query(default=1, ge=1),
sort: Literal["rating", "installs", "newest"] = Query(default="newest"),
current_user: UserProfile = Depends(get_current_user),
) -> 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)
@router.get("/{plugin_id}", response_model=_PluginDetail)
async def get_plugin(
plugin_id: str,
current_user: UserProfile = Depends(get_current_user),
) -> _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:
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
)
@router.post("/{plugin_id}/install", response_model=dict)
async def install_plugin(
plugin_id: str,
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.
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:
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
_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}
@router.delete("/{plugin_id}/install", response_model=dict)
async def uninstall_plugin(
plugin_id: str,
current_user: UserProfile = Depends(get_current_user),
) -> dict[str, bool]:
"""Unregister a plugin installation."""
_installations.get(plugin_id, set()).discard(current_user.id)
return {"ok": True}