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:
parent
ee00f288d9
commit
dec6c0b812
6 changed files with 276 additions and 6 deletions
37
alembic/versions/020_add_chapter_status_and_sort_order.py
Normal file
37
alembic/versions/020_add_chapter_status_and_sort_order.py
Normal 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)
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
|
|
|
|||
172
backend/routers/creator_chapters.py
Normal file
172
backend/routers/creator_chapters.py
Normal 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],
|
||||
)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue