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
This commit is contained in:
jlightner 2026-04-04 06:03:49 +00:00
parent ee00f288d9
commit dec6c0b812
6 changed files with 276 additions and 6 deletions

View file

@ -0,0 +1,37 @@
"""Add chapter_status and sort_order columns to key_moments.
Revision ID: 020_add_chapter_status_and_sort_order
Revises: 019_add_highlight_candidates
"""
from alembic import op
import sqlalchemy as sa
revision = "020_add_chapter_status_and_sort_order"
down_revision = "019_add_highlight_candidates"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Create the chapter_status enum type
chapter_status = sa.Enum("draft", "approved", "hidden", name="chapter_status", create_constraint=True)
chapter_status.create(op.get_bind(), checkfirst=True)
op.add_column(
"key_moments",
sa.Column("chapter_status", chapter_status, nullable=False, server_default="draft"),
)
op.add_column(
"key_moments",
sa.Column("sort_order", sa.Integer, nullable=False, server_default="0"),
)
op.create_index("ix_key_moments_chapter_status", "key_moments", ["chapter_status"])
def downgrade() -> None:
op.drop_index("ix_key_moments_chapter_status")
op.drop_column("key_moments", "sort_order")
op.drop_column("key_moments", "chapter_status")
sa.Enum(name="chapter_status").drop(op.get_bind(), checkfirst=True)

View file

@ -12,7 +12,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from config import get_settings from config import get_settings
from routers import admin, auth, chat, consent, creator_dashboard, creators, health, highlights, ingest, pipeline, reports, search, stats, techniques, topics, videos from routers import admin, auth, chat, consent, creator_chapters, creator_dashboard, creators, health, highlights, ingest, pipeline, reports, search, stats, techniques, topics, videos
def _setup_logging() -> None: def _setup_logging() -> None:
@ -83,6 +83,7 @@ app.include_router(auth.router, prefix="/api/v1")
app.include_router(chat.router, prefix="/api/v1") app.include_router(chat.router, prefix="/api/v1")
app.include_router(consent.router, prefix="/api/v1") app.include_router(consent.router, prefix="/api/v1")
app.include_router(creator_dashboard.router, prefix="/api/v1") app.include_router(creator_dashboard.router, prefix="/api/v1")
app.include_router(creator_chapters.router, prefix="/api/v1")
app.include_router(creators.router, prefix="/api/v1") app.include_router(creators.router, prefix="/api/v1")
app.include_router(highlights.router, prefix="/api/v1") app.include_router(highlights.router, prefix="/api/v1")
app.include_router(ingest.router, prefix="/api/v1") app.include_router(ingest.router, prefix="/api/v1")

View file

@ -87,6 +87,13 @@ class HighlightStatus(str, enum.Enum):
rejected = "rejected" rejected = "rejected"
class ChapterStatus(str, enum.Enum):
"""Review status for auto-detected chapters."""
draft = "draft"
approved = "approved"
hidden = "hidden"
# ── Helpers ────────────────────────────────────────────────────────────────── # ── Helpers ──────────────────────────────────────────────────────────────────
def _uuid_pk() -> Mapped[uuid.UUID]: def _uuid_pk() -> Mapped[uuid.UUID]:
@ -255,6 +262,13 @@ class KeyMoment(Base):
) )
plugins: Mapped[list[str] | None] = mapped_column(ARRAY(String), nullable=True) plugins: Mapped[list[str] | None] = mapped_column(ARRAY(String), nullable=True)
raw_transcript: Mapped[str | None] = mapped_column(Text, nullable=True) raw_transcript: Mapped[str | None] = mapped_column(Text, nullable=True)
chapter_status: Mapped[ChapterStatus] = mapped_column(
Enum(ChapterStatus, name="chapter_status", create_constraint=True),
nullable=False,
server_default="draft",
default=ChapterStatus.draft,
)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0", default=0)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
default=_now, server_default=func.now() default=_now, server_default=func.now()
) )

View file

@ -0,0 +1,172 @@
"""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],
)

View file

@ -145,20 +145,39 @@ async def get_video_chapters(
video_id: uuid.UUID, video_id: uuid.UUID,
db: AsyncSession = Depends(get_session), db: AsyncSession = Depends(get_session),
) -> ChaptersResponse: ) -> ChaptersResponse:
"""Return KeyMoment records for a video as chapter markers, sorted by start_time.""" """Return KeyMoment records for a video as chapter markers.
Prefers approved chapters if any exist; otherwise returns all chapters.
Sorted by sort_order then start_time.
"""
# Verify video exists # Verify video exists
video_stmt = select(SourceVideo.id).where(SourceVideo.id == video_id) video_stmt = select(SourceVideo.id).where(SourceVideo.id == video_id)
video_result = await db.execute(video_stmt) video_result = await db.execute(video_stmt)
if video_result.scalar_one_or_none() is None: if video_result.scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="Video not found") raise HTTPException(status_code=404, detail="Video not found")
stmt = ( # Try approved-only first
approved_stmt = (
select(KeyMoment) select(KeyMoment)
.where(KeyMoment.source_video_id == video_id) .where(
.order_by(KeyMoment.start_time) KeyMoment.source_video_id == video_id,
KeyMoment.chapter_status == "approved",
)
.order_by(KeyMoment.sort_order, KeyMoment.start_time)
) )
result = await db.execute(stmt) result = await db.execute(approved_stmt)
moments = result.scalars().all() moments = result.scalars().all()
# Fallback to all if none are approved
if not moments:
all_stmt = (
select(KeyMoment)
.where(KeyMoment.source_video_id == video_id)
.order_by(KeyMoment.sort_order, KeyMoment.start_time)
)
result = await db.execute(all_stmt)
moments = result.scalars().all()
logger.debug("Chapters for %s: %d key moments", video_id, len(moments)) logger.debug("Chapters for %s: %d key moments", video_id, len(moments))
return ChaptersResponse( return ChaptersResponse(
video_id=video_id, video_id=video_id,

View file

@ -672,9 +672,36 @@ class ChapterMarkerRead(BaseModel):
start_time: float start_time: float
end_time: float end_time: float
content_type: str content_type: str
chapter_status: str = "draft"
sort_order: int = 0
class ChaptersResponse(BaseModel): class ChaptersResponse(BaseModel):
"""Chapters (KeyMoments) for a video, sorted by start_time.""" """Chapters (KeyMoments) for a video, sorted by start_time."""
video_id: uuid.UUID video_id: uuid.UUID
chapters: list[ChapterMarkerRead] = Field(default_factory=list) chapters: list[ChapterMarkerRead] = Field(default_factory=list)
# ── Creator Chapter Management ──────────────────────────────────────────────
class ChapterUpdate(BaseModel):
"""Partial update for a single chapter."""
title: str | None = None
start_time: float | None = None
end_time: float | None = None
chapter_status: str | None = None
class ChapterReorderItem(BaseModel):
id: uuid.UUID
sort_order: int
class ChapterReorderRequest(BaseModel):
"""Reorder chapters for a video."""
chapters: list[ChapterReorderItem]
class ChapterBulkApproveRequest(BaseModel):
"""Bulk-approve chapters by IDs."""
chapter_ids: list[uuid.UUID]