"""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}