From dec6c0b8126ea577d6b3dc69a4d1bcf2f7f73164 Mon Sep 17 00:00:00 2001 From: jlightner Date: Sat, 4 Apr 2026 06:03:49 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Added=20ChapterStatus=20enum,=20sort=5F?= =?UTF-8?q?order=20column,=20migration=20020,=20chapt=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "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 --- .../020_add_chapter_status_and_sort_order.py | 37 ++++ backend/main.py | 3 +- backend/models.py | 14 ++ backend/routers/creator_chapters.py | 172 ++++++++++++++++++ backend/routers/videos.py | 29 ++- backend/schemas.py | 27 +++ 6 files changed, 276 insertions(+), 6 deletions(-) create mode 100644 alembic/versions/020_add_chapter_status_and_sort_order.py create mode 100644 backend/routers/creator_chapters.py diff --git a/alembic/versions/020_add_chapter_status_and_sort_order.py b/alembic/versions/020_add_chapter_status_and_sort_order.py new file mode 100644 index 0000000..b5b5d75 --- /dev/null +++ b/alembic/versions/020_add_chapter_status_and_sort_order.py @@ -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) diff --git a/backend/main.py b/backend/main.py index 6141af8..b39c31b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -12,7 +12,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware 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: @@ -83,6 +83,7 @@ app.include_router(auth.router, prefix="/api/v1") app.include_router(chat.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_chapters.router, prefix="/api/v1") app.include_router(creators.router, prefix="/api/v1") app.include_router(highlights.router, prefix="/api/v1") app.include_router(ingest.router, prefix="/api/v1") diff --git a/backend/models.py b/backend/models.py index 66a0e43..ca8d041 100644 --- a/backend/models.py +++ b/backend/models.py @@ -87,6 +87,13 @@ class HighlightStatus(str, enum.Enum): rejected = "rejected" +class ChapterStatus(str, enum.Enum): + """Review status for auto-detected chapters.""" + draft = "draft" + approved = "approved" + hidden = "hidden" + + # ── Helpers ────────────────────────────────────────────────────────────────── def _uuid_pk() -> Mapped[uuid.UUID]: @@ -255,6 +262,13 @@ class KeyMoment(Base): ) plugins: Mapped[list[str] | None] = mapped_column(ARRAY(String), 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( default=_now, server_default=func.now() ) diff --git a/backend/routers/creator_chapters.py b/backend/routers/creator_chapters.py new file mode 100644 index 0000000..750f237 --- /dev/null +++ b/backend/routers/creator_chapters.py @@ -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], + ) diff --git a/backend/routers/videos.py b/backend/routers/videos.py index 6b3f878..b3e68db 100644 --- a/backend/routers/videos.py +++ b/backend/routers/videos.py @@ -145,20 +145,39 @@ async def get_video_chapters( video_id: uuid.UUID, db: AsyncSession = Depends(get_session), ) -> 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 video_stmt = select(SourceVideo.id).where(SourceVideo.id == video_id) video_result = await db.execute(video_stmt) if video_result.scalar_one_or_none() is None: raise HTTPException(status_code=404, detail="Video not found") - stmt = ( + # Try approved-only first + approved_stmt = ( select(KeyMoment) - .where(KeyMoment.source_video_id == video_id) - .order_by(KeyMoment.start_time) + .where( + 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() + + # 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)) return ChaptersResponse( video_id=video_id, diff --git a/backend/schemas.py b/backend/schemas.py index c33e11f..9180f28 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -672,9 +672,36 @@ class ChapterMarkerRead(BaseModel): start_time: float end_time: float content_type: str + chapter_status: str = "draft" + sort_order: int = 0 class ChaptersResponse(BaseModel): """Chapters (KeyMoments) for a video, sorted by start_time.""" video_id: uuid.UUID 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]