Add admin CRUD endpoints for the marketplace dashboard (task 11)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
bernatsampera
2026-08-09 02:17:41 +02:00
co-authored by Claude Opus 4.6
parent a055826477
commit 061972e91a
4 changed files with 201 additions and 1 deletions
+14
View File
@@ -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")
+67
View File
@@ -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}
+26 -1
View File
@@ -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
+94
View File
@@ -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