feat(gallery): add admin gallery curation endpoints and management page (Task 6)

Add super-admin-only backend endpoints for toggling is_gallery_featured and
gallery_sort_order on flows and scripts, plus a frontend GalleryManagementPage
with toggle switches, editable sort order fields, and name/featured filters.
13 integration tests; all pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 20:04:40 +00:00
parent 2b657fc4ac
commit 12a373a2a2
6 changed files with 1036 additions and 0 deletions

View File

@@ -0,0 +1,191 @@
"""Admin gallery curation endpoints.
Allows super admins to toggle is_gallery_featured and update gallery_sort_order
on Tree (flows) and ScriptTemplate (scripts) records.
"""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import require_admin
from app.core.database import get_db
from app.models.script_template import ScriptTemplate
from app.models.tree import Tree
from app.models.user import User
router = APIRouter(prefix="/admin/gallery", tags=["admin-gallery"])
# ---------------------------------------------------------------------------
# Request schemas
# ---------------------------------------------------------------------------
class FeatureToggle(BaseModel):
is_gallery_featured: bool
class SortOrderUpdate(BaseModel):
gallery_sort_order: int
# ---------------------------------------------------------------------------
# Response helpers
# ---------------------------------------------------------------------------
def _flow_summary(tree: Tree) -> dict:
return {
"id": str(tree.id),
"name": tree.name,
"tree_type": tree.tree_type,
"is_gallery_featured": tree.is_gallery_featured,
"gallery_sort_order": tree.gallery_sort_order,
"visibility": tree.visibility,
}
def _script_summary(script: ScriptTemplate) -> dict:
return {
"id": str(script.id),
"name": script.name,
"is_gallery_featured": script.is_gallery_featured,
"gallery_sort_order": script.gallery_sort_order,
"is_active": script.is_active,
}
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("/featured")
async def list_featured(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""List all featured flows and scripts (super admin only)."""
flows_result = await db.execute(
select(Tree)
.where(Tree.is_gallery_featured == True) # noqa: E712
.order_by(Tree.gallery_sort_order.asc(), Tree.name.asc())
)
flows = flows_result.scalars().all()
scripts_result = await db.execute(
select(ScriptTemplate)
.where(ScriptTemplate.is_gallery_featured == True) # noqa: E712
.order_by(ScriptTemplate.gallery_sort_order.asc(), ScriptTemplate.name.asc())
)
scripts = scripts_result.scalars().all()
return {
"flows": [_flow_summary(f) for f in flows],
"scripts": [_script_summary(s) for s in scripts],
}
@router.get("/items")
async def list_all_items(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""List ALL flows and scripts with their gallery status (super admin only)."""
flows_result = await db.execute(
select(Tree)
.where(Tree.visibility == "public")
.order_by(Tree.gallery_sort_order.asc(), Tree.name.asc())
)
flows = flows_result.scalars().all()
scripts_result = await db.execute(
select(ScriptTemplate)
.order_by(ScriptTemplate.gallery_sort_order.asc(), ScriptTemplate.name.asc())
)
scripts = scripts_result.scalars().all()
return {
"flows": [_flow_summary(f) for f in flows],
"scripts": [_script_summary(s) for s in scripts],
}
@router.patch("/flows/{flow_id}/feature")
async def toggle_flow_featured(
flow_id: UUID,
body: FeatureToggle,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Toggle is_gallery_featured on a flow (super admin only)."""
result = await db.execute(select(Tree).where(Tree.id == flow_id))
tree = result.scalar_one_or_none()
if not tree:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Flow not found")
tree.is_gallery_featured = body.is_gallery_featured
await db.commit()
await db.refresh(tree)
return _flow_summary(tree)
@router.patch("/flows/{flow_id}/sort-order")
async def update_flow_sort_order(
flow_id: UUID,
body: SortOrderUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Update gallery_sort_order on a flow (super admin only)."""
result = await db.execute(select(Tree).where(Tree.id == flow_id))
tree = result.scalar_one_or_none()
if not tree:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Flow not found")
tree.gallery_sort_order = body.gallery_sort_order
await db.commit()
await db.refresh(tree)
return _flow_summary(tree)
@router.patch("/scripts/{script_id}/feature")
async def toggle_script_featured(
script_id: UUID,
body: FeatureToggle,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Toggle is_gallery_featured on a script (super admin only)."""
result = await db.execute(select(ScriptTemplate).where(ScriptTemplate.id == script_id))
script = result.scalar_one_or_none()
if not script:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Script not found")
script.is_gallery_featured = body.is_gallery_featured
await db.commit()
await db.refresh(script)
return _script_summary(script)
@router.patch("/scripts/{script_id}/sort-order")
async def update_script_sort_order(
script_id: UUID,
body: SortOrderUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Update gallery_sort_order on a script (super admin only)."""
result = await db.execute(select(ScriptTemplate).where(ScriptTemplate.id == script_id))
script = result.scalar_one_or_none()
if not script:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Script not found")
script.gallery_sort_order = body.gallery_sort_order
await db.commit()
await db.refresh(script)
return _script_summary(script)

View File

@@ -26,6 +26,7 @@ from app.api.endpoints import flow_proposals
from app.api.endpoints import flowpilot_analytics
from app.api.endpoints import notifications
from app.api.endpoints import public_templates
from app.api.endpoints import admin_gallery
api_router = APIRouter()
@@ -77,3 +78,4 @@ api_router.include_router(flow_proposals.router)
api_router.include_router(flowpilot_analytics.router)
api_router.include_router(notifications.router)
api_router.include_router(public_templates.router)
api_router.include_router(admin_gallery.router)