chrysopedia/backend/routers/creator_chapters.py
jlightner ed9aa7a83a feat: Added ChapterStatus enum, sort_order column, migration 020, chapt…
- "backend/models.py"
- "backend/schemas.py"
- "alembic/versions/020_add_chapter_status_and_sort_order.py"
- "backend/routers/creator_chapters.py"
- "backend/routers/videos.py"
- "backend/main.py"

GSD-Task: S06/T01
2026-04-04 06:03:49 +00:00

172 lines
5.8 KiB
Python

"""Creator chapter management endpoints — review, edit, reorder, approve chapters.
Auth-guarded endpoints for creators to manage auto-detected chapters for
their videos before publication.
"""
import logging
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from auth import get_current_user
from database import get_session
from models import ChapterStatus, KeyMoment, SourceVideo, User
from schemas import (
ChapterBulkApproveRequest,
ChapterMarkerRead,
ChapterReorderRequest,
ChapterUpdate,
ChaptersResponse,
)
logger = logging.getLogger("chrysopedia.creator_chapters")
router = APIRouter(prefix="/creator", tags=["creator-chapters"])
async def _verify_creator_owns_video(
current_user: User,
video_id: uuid.UUID,
db: AsyncSession,
) -> None:
"""Verify the user is a creator and owns the specified video."""
if current_user.creator_id is None:
raise HTTPException(status_code=403, detail="No creator profile linked")
video = (await db.execute(
select(SourceVideo).where(
SourceVideo.id == video_id,
SourceVideo.creator_id == current_user.creator_id,
)
)).scalar_one_or_none()
if video is None:
raise HTTPException(status_code=404, detail="Video not found or not owned by you")
@router.get("/{video_id}/chapters", response_model=ChaptersResponse)
async def get_creator_chapters(
video_id: uuid.UUID,
current_user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_session),
) -> ChaptersResponse:
"""Return all chapters for a creator's video (all statuses)."""
await _verify_creator_owns_video(current_user, video_id, db)
stmt = (
select(KeyMoment)
.where(KeyMoment.source_video_id == video_id)
.order_by(KeyMoment.sort_order, KeyMoment.start_time)
)
result = await db.execute(stmt)
moments = result.scalars().all()
logger.debug("Creator chapters for %s: %d", video_id, len(moments))
return ChaptersResponse(
video_id=video_id,
chapters=[ChapterMarkerRead.model_validate(m) for m in moments],
)
@router.patch("/chapters/{chapter_id}", response_model=ChapterMarkerRead)
async def update_chapter(
chapter_id: uuid.UUID,
body: ChapterUpdate,
current_user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_session),
) -> ChapterMarkerRead:
"""Update a single chapter (title, times, status)."""
if current_user.creator_id is None:
raise HTTPException(status_code=403, detail="No creator profile linked")
# Fetch the chapter and verify ownership via the video
chapter = (await db.execute(
select(KeyMoment).where(KeyMoment.id == chapter_id)
)).scalar_one_or_none()
if chapter is None:
raise HTTPException(status_code=404, detail="Chapter not found")
await _verify_creator_owns_video(current_user, chapter.source_video_id, db)
# Apply partial updates
update_data = body.model_dump(exclude_unset=True)
if "chapter_status" in update_data:
update_data["chapter_status"] = ChapterStatus(update_data["chapter_status"])
for field, value in update_data.items():
setattr(chapter, field, value)
await db.commit()
await db.refresh(chapter)
logger.info("Updated chapter %s: %s", chapter_id, list(update_data.keys()))
return ChapterMarkerRead.model_validate(chapter)
@router.put("/{video_id}/chapters/reorder", response_model=ChaptersResponse)
async def reorder_chapters(
video_id: uuid.UUID,
body: ChapterReorderRequest,
current_user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_session),
) -> ChaptersResponse:
"""Reorder chapters for a video by setting sort_order values."""
await _verify_creator_owns_video(current_user, video_id, db)
for item in body.chapters:
await db.execute(
update(KeyMoment)
.where(KeyMoment.id == item.id, KeyMoment.source_video_id == video_id)
.values(sort_order=item.sort_order)
)
await db.commit()
# Return updated list
stmt = (
select(KeyMoment)
.where(KeyMoment.source_video_id == video_id)
.order_by(KeyMoment.sort_order, KeyMoment.start_time)
)
result = await db.execute(stmt)
moments = result.scalars().all()
logger.info("Reordered %d chapters for video %s", len(body.chapters), video_id)
return ChaptersResponse(
video_id=video_id,
chapters=[ChapterMarkerRead.model_validate(m) for m in moments],
)
@router.post("/{video_id}/chapters/approve", response_model=ChaptersResponse)
async def bulk_approve_chapters(
video_id: uuid.UUID,
body: ChapterBulkApproveRequest,
current_user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_session),
) -> ChaptersResponse:
"""Bulk-approve chapters by ID list."""
await _verify_creator_owns_video(current_user, video_id, db)
if body.chapter_ids:
await db.execute(
update(KeyMoment)
.where(
KeyMoment.id.in_(body.chapter_ids),
KeyMoment.source_video_id == video_id,
)
.values(chapter_status=ChapterStatus.approved)
)
await db.commit()
logger.info("Bulk-approved %d chapters for video %s", len(body.chapter_ids), video_id)
# Return updated list
stmt = (
select(KeyMoment)
.where(KeyMoment.source_video_id == video_id)
.order_by(KeyMoment.sort_order, KeyMoment.start_time)
)
result = await db.execute(stmt)
moments = result.scalars().all()
return ChaptersResponse(
video_id=video_id,
chapters=[ChapterMarkerRead.model_validate(m) for m in moments],
)