chrysopedia/backend/routers/creators.py
jlightner 10cd175333 feat: Added personality_profile JSONB column to Creator model with migr…
- "backend/models.py"
- "backend/schemas.py"
- "backend/routers/creators.py"
- "alembic/versions/023_add_personality_profile.py"

GSD-Task: S06/T01
2026-04-04 08:24:44 +00:00

197 lines
6.3 KiB
Python

"""Creator endpoints for Chrysopedia API.
Enhanced with sort (random default per R014), genre filter, and
technique/video counts for browse pages.
"""
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 database import get_session
from models import Creator, CreatorFollow, KeyMoment, SourceVideo, TechniquePage
from schemas import CreatorBrowseItem, CreatorDetail, CreatorRead, CreatorTechniqueItem
logger = logging.getLogger("chrysopedia.creators")
router = APIRouter(prefix="/creators", tags=["creators"])
@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,
)