From 061972e91a079622df781d3dc8046902cab0fff0 Mon Sep 17 00:00:00 2001 From: bernatsampera Date: Sun, 9 Aug 2026 02:17:41 +0200 Subject: [PATCH] Add admin CRUD endpoints for the marketplace dashboard (task 11) Co-Authored-By: Claude Opus 4.6 (1M context) --- api/app/main.py | 14 ++++++ api/app/routes_admin.py | 67 +++++++++++++++++++++++++++++ api/app/schemas.py | 27 +++++++++++- api/tests/test_api.py | 94 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 api/app/routes_admin.py diff --git a/api/app/main.py b/api/app/main.py index 10f403e..c9935b9 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -1,8 +1,10 @@ from contextlib import asynccontextmanager from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware from .db import init_db +from .routes_admin import router as admin_router from .routes_moderation import router as moderation_router from .routes_public import router as public_router @@ -14,8 +16,20 @@ async def lifespan(app: FastAPI): app = FastAPI(title="gcontext workflows API", lifespan=lifespan) +app.add_middleware( + CORSMiddleware, + allow_origins=[ + "https://gcontext.ai", + "https://www.gcontext.ai", + "http://localhost:3000", + "http://localhost:3001", + ], + allow_methods=["*"], + allow_headers=["*"], +) app.include_router(public_router) app.include_router(moderation_router) +app.include_router(admin_router) @app.get("/health") diff --git a/api/app/routes_admin.py b/api/app/routes_admin.py new file mode 100644 index 0000000..b0b9db6 --- /dev/null +++ b/api/app/routes_admin.py @@ -0,0 +1,67 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.orm import Session, selectinload + +from .db import get_session +from .models import Template +from .routes_moderation import require_admin +from .schemas import AdminUpdateIn, AdminWorkflowOut + +router = APIRouter( + prefix="/api/admin", tags=["admin"], dependencies=[Depends(require_admin)] +) + + +def _to_out(t: Template) -> AdminWorkflowOut: + return AdminWorkflowOut( + id=t.id, + name=t.name, + description=t.description, + tags=t.tags, + status=t.status, + submitted_at=t.submitted_at, + reviewed_at=t.reviewed_at, + file_count=len(t.files), + ) + + +@router.get("/workflows", response_model=list[AdminWorkflowOut]) +def list_all(session: Session = Depends(get_session)): + rows = session.scalars( + select(Template).options(selectinload(Template.files)).order_by(Template.id) + ).all() + return [_to_out(t) for t in rows] + + +@router.patch("/workflows/{workflow_id}", response_model=AdminWorkflowOut) +def update_metadata( + workflow_id: str, body: AdminUpdateIn, session: Session = Depends(get_session) +): + template = session.scalars( + select(Template) + .where(Template.id == workflow_id) + .options(selectinload(Template.files)) + ).first() + if template is None: + raise HTTPException(status_code=404, detail="workflow not found") + if body.name is not None: + template.name = body.name + if body.description is not None: + template.description = body.description + if body.tags is not None: + template.tags = body.tags + session.commit() + session.refresh(template) + return _to_out(template) + + +@router.delete("/workflows/{workflow_id}") +def delete_workflow(workflow_id: str, session: Session = Depends(get_session)): + template = session.scalars( + select(Template).where(Template.id == workflow_id) + ).first() + if template is None: + raise HTTPException(status_code=404, detail="workflow not found") + session.delete(template) + session.commit() + return {"deleted": workflow_id} diff --git a/api/app/schemas.py b/api/app/schemas.py index 9e8ecc2..75aa585 100644 --- a/api/app/schemas.py +++ b/api/app/schemas.py @@ -1,4 +1,6 @@ -from pydantic import BaseModel +from datetime import datetime + +from pydantic import BaseModel, model_validator class FileIn(BaseModel): @@ -23,3 +25,26 @@ class SubmitOut(ManifestOut): class TemplateOut(ManifestOut): files: list[FileIn] + + +class AdminWorkflowOut(BaseModel): + id: str + name: str + description: str + tags: list[str] + status: str + submitted_at: datetime + reviewed_at: datetime | None + file_count: int + + +class AdminUpdateIn(BaseModel): + name: str | None = None + description: str | None = None + tags: list[str] | None = None + + @model_validator(mode="after") + def at_least_one_field(self): + if self.name is None and self.description is None and self.tags is None: + raise ValueError("at least one field must be provided") + return self diff --git a/api/tests/test_api.py b/api/tests/test_api.py index bbc21ad..b90a6a0 100644 --- a/api/tests/test_api.py +++ b/api/tests/test_api.py @@ -141,6 +141,100 @@ def test_invalid_bundles_rejected(client): assert bad_slug.status_code == 422 +def test_admin_list_all_statuses(client, admin): + submit(client) + client.post("/api/moderation/workflows/demo-flow/approve", headers=admin) + alt = INDEX_MD.replace("demo-flow", "alt-flow").replace("Demo Flow", "Alt Flow") + client.post("/api/workflows", json=bundle(index_content=alt)) + client.post("/api/moderation/workflows/alt-flow/reject", headers=admin) + + resp = client.get("/api/admin/workflows", headers=admin) + assert resp.status_code == 200 + items = resp.json() + by_id = {w["id"]: w for w in items} + assert by_id["demo-flow"]["status"] == "approved" + assert by_id["demo-flow"]["file_count"] == 5 + assert by_id["alt-flow"]["status"] == "rejected" + + +def test_admin_list_requires_token(client): + assert client.get("/api/admin/workflows").status_code == 401 + bad = {"Authorization": "Bearer wrong"} + assert client.get("/api/admin/workflows", headers=bad).status_code == 401 + + +def test_admin_update_metadata(client, admin): + submit(client) + client.post("/api/moderation/workflows/demo-flow/approve", headers=admin) + + resp = client.patch( + "/api/admin/workflows/demo-flow", + json={"name": "Updated Name", "tags": ["new"]}, + headers=admin, + ) + assert resp.status_code == 200 + assert resp.json()["name"] == "Updated Name" + assert resp.json()["tags"] == ["new"] + + public = client.get("/api/workflows/demo-flow").json() + assert public["name"] == "Updated Name" + assert public["tags"] == ["new"] + + +def test_admin_update_partial(client, admin): + submit(client) + client.post("/api/moderation/workflows/demo-flow/approve", headers=admin) + + resp = client.patch( + "/api/admin/workflows/demo-flow", + json={"name": "New Name"}, + headers=admin, + ) + assert resp.status_code == 200 + assert resp.json()["name"] == "New Name" + assert resp.json()["description"] == "A demo workflow used by the tests." + assert resp.json()["tags"] == ["demo", "testing"] + + +def test_admin_update_not_found(client, admin): + resp = client.patch( + "/api/admin/workflows/nonexistent", + json={"name": "X"}, + headers=admin, + ) + assert resp.status_code == 404 + + +def test_admin_delete(client, admin): + submit(client) + client.post("/api/moderation/workflows/demo-flow/approve", headers=admin) + + resp = client.delete("/api/admin/workflows/demo-flow", headers=admin) + assert resp.status_code == 200 + assert resp.json()["deleted"] == "demo-flow" + + assert client.get("/api/workflows/demo-flow").status_code == 404 + assert client.get("/api/admin/workflows", headers=admin).json() == [] + + +def test_admin_delete_rejected(client, admin): + submit(client) + client.post("/api/moderation/workflows/demo-flow/reject", headers=admin) + + resp = client.delete("/api/admin/workflows/demo-flow", headers=admin) + assert resp.status_code == 200 + assert client.get("/api/admin/workflows", headers=admin).json() == [] + + +def test_admin_delete_not_found(client, admin): + resp = client.delete("/api/admin/workflows/nonexistent", headers=admin) + assert resp.status_code == 404 + + +def test_admin_delete_requires_token(client): + assert client.delete("/api/admin/workflows/x").status_code == 401 + + def test_path_traversal_rejected(client): evil = bundle(extra=[{"path": "../outside.md", "content": "x"}]) assert client.post("/api/workflows", json=evil).status_code == 422