chrysopedia/backend/routers/creators.py
jlightner a60f4074dc chore: Add GET/PUT shorts-template admin endpoints, collapsible templat…
- "backend/routers/creators.py"
- "backend/schemas.py"
- "frontend/src/api/templates.ts"
- "frontend/src/pages/HighlightQueue.tsx"
- "frontend/src/pages/HighlightQueue.module.css"
- "backend/routers/shorts.py"
- "backend/pipeline/stages.py"
- "frontend/src/api/shorts.ts"

GSD-Task: S04/T03
2026-04-04 11:25:29 +00:00

298 lines
9.6 KiB
Python

"""Creator endpoints for Chrysopedia API.
Enhanced with sort (random default per R014), genre filter, and
technique/video counts for browse pages. Includes admin endpoints
for shorts template configuration.
"""
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from auth import require_role
from database import get_session
from models import Creator, CreatorFollow, KeyMoment, SourceVideo, TechniquePage, User, UserRole
from schemas import (
CreatorBrowseItem,
CreatorDetail,
CreatorRead,
CreatorTechniqueItem,
ShortsTemplateConfig,
ShortsTemplateUpdate,
)
logger = logging.getLogger("chrysopedia.creators")
router = APIRouter(prefix="/creators", tags=["creators"])
_require_admin = require_role(UserRole.admin)
@router.get("")
async def list_creators(
sort: Annotated[str, Query()] = "random",
genre: Annotated[str | None, Query()] = None,
offset: Annotated[int, Query(ge=0)] = 0,
limit: Annotated[int, Query(ge=1, le=100)] = 50,
db: AsyncSession = Depends(get_session),
):
"""List creators with sort, genre filter, and technique/video counts.
- **sort**: ``random`` (default, R014 creator equity), ``alpha``, ``views``
- **genre**: filter by genre (matches against ARRAY column)
"""
# Subqueries for counts
technique_count_sq = (
select(func.count())
.where(TechniquePage.creator_id == Creator.id)
.correlate(Creator)
.scalar_subquery()
)
video_count_sq = (
select(func.count())
.where(SourceVideo.creator_id == Creator.id)
.correlate(Creator)
.scalar_subquery()
)
last_technique_sq = (
select(func.max(TechniquePage.created_at))
.where(TechniquePage.creator_id == Creator.id)
.correlate(Creator)
.scalar_subquery()
)
stmt = select(
Creator,
technique_count_sq.label("technique_count"),
video_count_sq.label("video_count"),
last_technique_sq.label("last_technique_at"),
).where(Creator.hidden != True) # noqa: E712
# Genre filter
if genre:
stmt = stmt.where(Creator.genres.any(genre))
# Sorting
if sort == "alpha":
stmt = stmt.order_by(Creator.name)
elif sort == "views":
stmt = stmt.order_by(Creator.view_count.desc())
else:
# Default: random (small dataset <100, func.random() is fine)
stmt = stmt.order_by(func.random())
stmt = stmt.offset(offset).limit(limit)
result = await db.execute(stmt)
rows = result.all()
items: list[CreatorBrowseItem] = []
for row in rows:
creator = row[0]
tc = row[1] or 0
vc = row[2] or 0
lta = row[3] # None when creator has 0 techniques
base = CreatorRead.model_validate(creator)
items.append(
CreatorBrowseItem(
**base.model_dump(),
technique_count=tc,
video_count=vc,
last_technique_at=lta,
)
)
# Get total count (without offset/limit)
count_stmt = select(func.count()).select_from(Creator).where(Creator.hidden != True) # noqa: E712
if genre:
count_stmt = count_stmt.where(Creator.genres.any(genre))
total = (await db.execute(count_stmt)).scalar() or 0
logger.debug(
"Listed %d creators (sort=%s, genre=%s, offset=%d, limit=%d)",
len(items), sort, genre, offset, limit,
)
return {"items": items, "total": total, "offset": offset, "limit": limit}
@router.get("/{slug}", response_model=CreatorDetail)
async def get_creator(
slug: str,
db: AsyncSession = Depends(get_session),
) -> CreatorDetail:
"""Get a single creator by slug with full profile data.
Returns video/technique counts, full technique list, genre breakdown,
bio, social links, and avatar for the creator landing page.
"""
stmt = select(Creator).where(Creator.slug == slug)
result = await db.execute(stmt)
creator = result.scalar_one_or_none()
if creator is None:
raise HTTPException(status_code=404, detail=f"Creator '{slug}' not found")
# Video count
video_count = (await db.execute(
select(func.count()).select_from(SourceVideo)
.where(SourceVideo.creator_id == creator.id)
)).scalar() or 0
# Moment count (key moments across all creator's videos)
moment_count = (await db.execute(
select(func.count()).select_from(KeyMoment)
.join(SourceVideo, KeyMoment.source_video_id == SourceVideo.id)
.where(SourceVideo.creator_id == creator.id)
)).scalar() or 0
# Technique pages for this creator
key_moment_count_sq = (
select(func.count(KeyMoment.id))
.where(KeyMoment.technique_page_id == TechniquePage.id)
.correlate(TechniquePage)
.scalar_subquery()
.label("key_moment_count")
)
technique_rows = (await db.execute(
select(
TechniquePage.title,
TechniquePage.slug,
TechniquePage.topic_category,
TechniquePage.created_at,
TechniquePage.summary,
TechniquePage.topic_tags,
key_moment_count_sq,
)
.where(TechniquePage.creator_id == creator.id)
.order_by(TechniquePage.created_at.desc())
)).all()
techniques = [
CreatorTechniqueItem(
title=t.title, slug=t.slug,
topic_category=t.topic_category, created_at=t.created_at,
summary=t.summary, topic_tags=t.topic_tags,
key_moment_count=t.key_moment_count,
)
for t in technique_rows
]
# Genre breakdown across this creator's techniques
genre_breakdown: dict[str, int] = {}
for t in technique_rows:
cat = t.topic_category
genre_breakdown[cat] = genre_breakdown.get(cat, 0) + 1
creator_data = CreatorRead.model_validate(creator)
# Follower count
follower_count = (await db.execute(
select(func.count()).select_from(CreatorFollow)
.where(CreatorFollow.creator_id == creator.id)
)).scalar() or 0
return CreatorDetail(
**creator_data.model_dump(),
bio=creator.bio,
social_links=creator.social_links,
personality_profile=creator.personality_profile,
featured=creator.featured,
video_count=video_count,
technique_count=len(techniques),
moment_count=moment_count,
techniques=techniques,
genre_breakdown=genre_breakdown,
follower_count=follower_count,
)
# ── Admin: Shorts Template Config ────────────────────────────────────────────
admin_router = APIRouter(prefix="/admin/creators", tags=["admin-creators"])
@admin_router.get(
"/{creator_id}/shorts-template",
response_model=ShortsTemplateConfig,
)
async def get_shorts_template(
creator_id: str,
_admin: Annotated[User, Depends(_require_admin)],
db: AsyncSession = Depends(get_session),
) -> ShortsTemplateConfig:
"""Return the current shorts template config for a creator.
Returns default values when the creator has no template set.
"""
stmt = select(Creator).where(Creator.id == creator_id)
result = await db.execute(stmt)
creator = result.scalar_one_or_none()
if creator is None:
raise HTTPException(status_code=404, detail=f"Creator not found: {creator_id}")
raw = creator.shorts_template or {}
return ShortsTemplateConfig(
show_intro=bool(raw.get("show_intro", False)),
intro_text=str(raw.get("intro_text", "")),
intro_duration_secs=float(raw.get("intro_duration", 2.0)),
show_outro=bool(raw.get("show_outro", False)),
outro_text=str(raw.get("outro_text", "")),
outro_duration_secs=float(raw.get("outro_duration", 2.0)),
accent_color=str(raw.get("accent_color", "#22d3ee")),
font_family=str(raw.get("font_family", "Inter")),
)
@admin_router.put(
"/{creator_id}/shorts-template",
response_model=ShortsTemplateConfig,
)
async def update_shorts_template(
creator_id: str,
body: ShortsTemplateUpdate,
_admin: Annotated[User, Depends(_require_admin)],
db: AsyncSession = Depends(get_session),
) -> ShortsTemplateConfig:
"""Save shorts template config for a creator.
Validates all fields and stores as JSONB on the Creator row.
"""
stmt = select(Creator).where(Creator.id == creator_id)
result = await db.execute(stmt)
creator = result.scalar_one_or_none()
if creator is None:
raise HTTPException(status_code=404, detail=f"Creator not found: {creator_id}")
# Store using the keys that card_renderer.parse_template_config expects
creator.shorts_template = {
"show_intro": body.show_intro,
"intro_text": body.intro_text,
"intro_duration": body.intro_duration_secs,
"show_outro": body.show_outro,
"outro_text": body.outro_text,
"outro_duration": body.outro_duration_secs,
"accent_color": body.accent_color,
"font_family": body.font_family,
}
await db.commit()
await db.refresh(creator)
logger.info(
"Updated shorts template for creator_id=%s (intro=%s, outro=%s)",
creator_id, body.show_intro, body.show_outro,
)
return ShortsTemplateConfig(
show_intro=body.show_intro,
intro_text=body.intro_text,
intro_duration_secs=body.intro_duration_secs,
show_outro=body.show_outro,
outro_text=body.outro_text,
outro_duration_secs=body.outro_duration_secs,
accent_color=body.accent_color,
font_family=body.font_family,
)