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:
191
backend/app/api/endpoints/admin_gallery.py
Normal file
191
backend/app/api/endpoints/admin_gallery.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
312
backend/tests/test_admin_gallery.py
Normal file
312
backend/tests/test_admin_gallery.py
Normal file
@@ -0,0 +1,312 @@
|
||||
"""Integration tests for admin gallery curation endpoints."""
|
||||
|
||||
import uuid
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.tree import Tree
|
||||
from app.models.script_template import ScriptTemplate, ScriptCategory
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _create_tree(db: AsyncSession, admin_user_id: str) -> Tree:
|
||||
"""Insert a minimal Tree directly into the DB."""
|
||||
tree = Tree(
|
||||
id=uuid.uuid4(),
|
||||
name="Gallery Test Flow",
|
||||
tree_type="troubleshooting",
|
||||
visibility="public",
|
||||
is_gallery_featured=False,
|
||||
gallery_sort_order=0,
|
||||
tree_structure={
|
||||
"id": "root",
|
||||
"type": "decision",
|
||||
"question": "Test?",
|
||||
"options": [],
|
||||
"children": [],
|
||||
},
|
||||
author_id=uuid.UUID(admin_user_id),
|
||||
)
|
||||
db.add(tree)
|
||||
await db.commit()
|
||||
await db.refresh(tree)
|
||||
return tree
|
||||
|
||||
|
||||
async def _create_script(db: AsyncSession, admin_user_id: str) -> ScriptTemplate:
|
||||
"""Insert a minimal ScriptTemplate directly into the DB."""
|
||||
# Need a category first
|
||||
category = ScriptCategory(
|
||||
id=uuid.uuid4(),
|
||||
name="Test Category",
|
||||
slug=f"test-category-{uuid.uuid4().hex[:6]}",
|
||||
)
|
||||
db.add(category)
|
||||
await db.flush()
|
||||
|
||||
script = ScriptTemplate(
|
||||
id=uuid.uuid4(),
|
||||
category_id=category.id,
|
||||
name="Gallery Test Script",
|
||||
slug=f"gallery-test-script-{uuid.uuid4().hex[:6]}",
|
||||
script_body="Write-Host 'Test'",
|
||||
is_gallery_featured=False,
|
||||
gallery_sort_order=0,
|
||||
created_by=uuid.UUID(admin_user_id) if admin_user_id else None,
|
||||
)
|
||||
db.add(script)
|
||||
await db.commit()
|
||||
await db.refresh(script)
|
||||
return script
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAdminGallery:
|
||||
"""Test suite for admin gallery curation endpoints."""
|
||||
|
||||
# -- Auth guard tests --
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_featured_list_requires_admin(
|
||||
self, client: AsyncClient, auth_headers: dict
|
||||
):
|
||||
"""Non-admin users get 403 on featured list."""
|
||||
response = await client.get("/api/v1/admin/gallery/featured", headers=auth_headers)
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_feature_toggle_flow_requires_admin(
|
||||
self, client: AsyncClient, auth_headers: dict
|
||||
):
|
||||
"""Non-admin users get 403 when trying to toggle flow feature."""
|
||||
fake_id = str(uuid.uuid4())
|
||||
response = await client.patch(
|
||||
f"/api/v1/admin/gallery/flows/{fake_id}/feature",
|
||||
json={"is_gallery_featured": True},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_feature_toggle_script_requires_admin(
|
||||
self, client: AsyncClient, auth_headers: dict
|
||||
):
|
||||
"""Non-admin users get 403 when trying to toggle script feature."""
|
||||
fake_id = str(uuid.uuid4())
|
||||
response = await client.patch(
|
||||
f"/api/v1/admin/gallery/scripts/{fake_id}/feature",
|
||||
json={"is_gallery_featured": True},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
# -- Feature toggle tests --
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_toggle_flow_featured_on(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
admin_auth_headers: dict,
|
||||
test_admin: dict,
|
||||
test_db: AsyncSession,
|
||||
):
|
||||
"""Admin can feature a flow."""
|
||||
tree = await _create_tree(test_db, test_admin["user_data"]["id"])
|
||||
|
||||
response = await client.patch(
|
||||
f"/api/v1/admin/gallery/flows/{tree.id}/feature",
|
||||
json={"is_gallery_featured": True},
|
||||
headers=admin_auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["is_gallery_featured"] is True
|
||||
assert data["id"] == str(tree.id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_toggle_flow_featured_off(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
admin_auth_headers: dict,
|
||||
test_admin: dict,
|
||||
test_db: AsyncSession,
|
||||
):
|
||||
"""Admin can un-feature a previously featured flow."""
|
||||
tree = await _create_tree(test_db, test_admin["user_data"]["id"])
|
||||
# Feature it first
|
||||
tree.is_gallery_featured = True
|
||||
await test_db.commit()
|
||||
|
||||
response = await client.patch(
|
||||
f"/api/v1/admin/gallery/flows/{tree.id}/feature",
|
||||
json={"is_gallery_featured": False},
|
||||
headers=admin_auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["is_gallery_featured"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_toggle_flow_not_found(
|
||||
self, client: AsyncClient, admin_auth_headers: dict
|
||||
):
|
||||
"""Returns 404 when flow ID doesn't exist."""
|
||||
fake_id = str(uuid.uuid4())
|
||||
response = await client.patch(
|
||||
f"/api/v1/admin/gallery/flows/{fake_id}/feature",
|
||||
json={"is_gallery_featured": True},
|
||||
headers=admin_auth_headers,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_toggle_script_featured_on(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
admin_auth_headers: dict,
|
||||
test_admin: dict,
|
||||
test_db: AsyncSession,
|
||||
):
|
||||
"""Admin can feature a script."""
|
||||
script = await _create_script(test_db, test_admin["user_data"]["id"])
|
||||
|
||||
response = await client.patch(
|
||||
f"/api/v1/admin/gallery/scripts/{script.id}/feature",
|
||||
json={"is_gallery_featured": True},
|
||||
headers=admin_auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["is_gallery_featured"] is True
|
||||
assert data["id"] == str(script.id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_toggle_script_not_found(
|
||||
self, client: AsyncClient, admin_auth_headers: dict
|
||||
):
|
||||
"""Returns 404 when script ID doesn't exist."""
|
||||
fake_id = str(uuid.uuid4())
|
||||
response = await client.patch(
|
||||
f"/api/v1/admin/gallery/scripts/{fake_id}/feature",
|
||||
json={"is_gallery_featured": True},
|
||||
headers=admin_auth_headers,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
# -- Sort order tests --
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_flow_sort_order(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
admin_auth_headers: dict,
|
||||
test_admin: dict,
|
||||
test_db: AsyncSession,
|
||||
):
|
||||
"""Admin can update gallery sort order for a flow."""
|
||||
tree = await _create_tree(test_db, test_admin["user_data"]["id"])
|
||||
|
||||
response = await client.patch(
|
||||
f"/api/v1/admin/gallery/flows/{tree.id}/sort-order",
|
||||
json={"gallery_sort_order": 42},
|
||||
headers=admin_auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["gallery_sort_order"] == 42
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_script_sort_order(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
admin_auth_headers: dict,
|
||||
test_admin: dict,
|
||||
test_db: AsyncSession,
|
||||
):
|
||||
"""Admin can update gallery sort order for a script."""
|
||||
script = await _create_script(test_db, test_admin["user_data"]["id"])
|
||||
|
||||
response = await client.patch(
|
||||
f"/api/v1/admin/gallery/scripts/{script.id}/sort-order",
|
||||
json={"gallery_sort_order": 7},
|
||||
headers=admin_auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["gallery_sort_order"] == 7
|
||||
|
||||
# -- Featured list tests --
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_featured_list_returns_only_featured(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
admin_auth_headers: dict,
|
||||
test_admin: dict,
|
||||
test_db: AsyncSession,
|
||||
):
|
||||
"""GET /featured returns only items where is_gallery_featured=True."""
|
||||
# Create two flows: one featured, one not
|
||||
featured_tree = await _create_tree(test_db, test_admin["user_data"]["id"])
|
||||
_unfeatured_tree = await _create_tree(test_db, test_admin["user_data"]["id"])
|
||||
_unfeatured_tree.name = "Unfeatured Flow"
|
||||
|
||||
# Feature only the first
|
||||
featured_tree.is_gallery_featured = True
|
||||
await test_db.commit()
|
||||
|
||||
# Create two scripts: one featured, one not
|
||||
featured_script = await _create_script(test_db, test_admin["user_data"]["id"])
|
||||
_unfeatured_script = await _create_script(test_db, test_admin["user_data"]["id"])
|
||||
featured_script.is_gallery_featured = True
|
||||
await test_db.commit()
|
||||
|
||||
response = await client.get(
|
||||
"/api/v1/admin/gallery/featured", headers=admin_auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
flow_ids = [f["id"] for f in data["flows"]]
|
||||
script_ids = [s["id"] for s in data["scripts"]]
|
||||
|
||||
assert str(featured_tree.id) in flow_ids
|
||||
assert str(_unfeatured_tree.id) not in flow_ids
|
||||
assert str(featured_script.id) in script_ids
|
||||
assert str(_unfeatured_script.id) not in script_ids
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_items_list_requires_admin(
|
||||
self, client: AsyncClient, auth_headers: dict
|
||||
):
|
||||
"""Non-admin users get 403 on all items list."""
|
||||
response = await client.get("/api/v1/admin/gallery/items", headers=auth_headers)
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_items_list_returns_all(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
admin_auth_headers: dict,
|
||||
test_admin: dict,
|
||||
test_db: AsyncSession,
|
||||
):
|
||||
"""GET /items returns all flows and scripts regardless of featured status."""
|
||||
tree = await _create_tree(test_db, test_admin["user_data"]["id"])
|
||||
|
||||
response = await client.get(
|
||||
"/api/v1/admin/gallery/items", headers=admin_auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "flows" in data
|
||||
assert "scripts" in data
|
||||
flow_ids = [f["id"] for f in data["flows"]]
|
||||
assert str(tree.id) in flow_ids
|
||||
Reference in New Issue
Block a user