"""Review queue endpoints for Chrysopedia API. Provides admin review workflow: list queue, stats, approve, reject, edit, split, merge key moments, and toggle review/auto mode via Redis. """ import logging import uuid from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import case, func, select from sqlalchemy.ext.asyncio import AsyncSession from config import get_settings from database import get_session from models import Creator, KeyMoment, KeyMomentContentType, ReviewStatus, SourceVideo from redis_client import get_redis from schemas import ( KeyMomentRead, MomentEditRequest, MomentMergeRequest, MomentSplitRequest, ReviewModeResponse, ReviewModeUpdate, ReviewQueueItem, ReviewQueueResponse, ReviewStatsResponse, ) logger = logging.getLogger("chrysopedia.review") router = APIRouter(prefix="/review", tags=["review"]) REDIS_MODE_KEY = "chrysopedia:review_mode" VALID_STATUSES = {"pending", "approved", "edited", "rejected", "all"} # ── Helpers ────────────────────────────────────────────────────────────────── def _moment_to_queue_item( moment: KeyMoment, video_filename: str, creator_name: str ) -> ReviewQueueItem: """Convert a KeyMoment ORM instance + joined fields to a ReviewQueueItem.""" data = KeyMomentRead.model_validate(moment).model_dump() data["video_filename"] = video_filename data["creator_name"] = creator_name return ReviewQueueItem(**data) # ── Endpoints ──────────────────────────────────────────────────────────────── @router.get("/queue", response_model=ReviewQueueResponse) async def list_queue( status: Annotated[str, Query()] = "pending", offset: Annotated[int, Query(ge=0)] = 0, limit: Annotated[int, Query(ge=1, le=1000)] = 50, db: AsyncSession = Depends(get_session), ) -> ReviewQueueResponse: """List key moments in the review queue, filtered by status.""" if status not in VALID_STATUSES: raise HTTPException( status_code=400, detail=f"Invalid status filter '{status}'. Must be one of: {', '.join(sorted(VALID_STATUSES))}", ) # Base query joining KeyMoment → SourceVideo → Creator base = ( select( KeyMoment, SourceVideo.filename.label("video_filename"), Creator.name.label("creator_name"), ) .join(SourceVideo, KeyMoment.source_video_id == SourceVideo.id) .join(Creator, SourceVideo.creator_id == Creator.id) ) if status != "all": base = base.where(KeyMoment.review_status == ReviewStatus(status)) # Count total matching rows count_stmt = select(func.count()).select_from(base.subquery()) total = (await db.execute(count_stmt)).scalar_one() # Fetch paginated results stmt = base.order_by(KeyMoment.created_at.desc()).offset(offset).limit(limit) rows = (await db.execute(stmt)).all() items = [ _moment_to_queue_item(row.KeyMoment, row.video_filename, row.creator_name) for row in rows ] return ReviewQueueResponse(items=items, total=total, offset=offset, limit=limit) @router.get("/stats", response_model=ReviewStatsResponse) async def get_stats( db: AsyncSession = Depends(get_session), ) -> ReviewStatsResponse: """Return counts of key moments grouped by review status.""" stmt = ( select( KeyMoment.review_status, func.count().label("cnt"), ) .group_by(KeyMoment.review_status) ) result = await db.execute(stmt) counts = {row.review_status.value: row.cnt for row in result.all()} return ReviewStatsResponse( pending=counts.get("pending", 0), approved=counts.get("approved", 0), edited=counts.get("edited", 0), rejected=counts.get("rejected", 0), ) @router.post("/moments/{moment_id}/approve", response_model=KeyMomentRead) async def approve_moment( moment_id: uuid.UUID, db: AsyncSession = Depends(get_session), ) -> KeyMomentRead: """Approve a key moment for publishing.""" moment = await db.get(KeyMoment, moment_id) if moment is None: raise HTTPException( status_code=404, detail=f"Key moment {moment_id} not found", ) moment.review_status = ReviewStatus.approved await db.commit() await db.refresh(moment) logger.info("Approved key moment %s", moment_id) return KeyMomentRead.model_validate(moment) @router.post("/moments/{moment_id}/reject", response_model=KeyMomentRead) async def reject_moment( moment_id: uuid.UUID, db: AsyncSession = Depends(get_session), ) -> KeyMomentRead: """Reject a key moment.""" moment = await db.get(KeyMoment, moment_id) if moment is None: raise HTTPException( status_code=404, detail=f"Key moment {moment_id} not found", ) moment.review_status = ReviewStatus.rejected await db.commit() await db.refresh(moment) logger.info("Rejected key moment %s", moment_id) return KeyMomentRead.model_validate(moment) @router.put("/moments/{moment_id}", response_model=KeyMomentRead) async def edit_moment( moment_id: uuid.UUID, body: MomentEditRequest, db: AsyncSession = Depends(get_session), ) -> KeyMomentRead: """Update editable fields of a key moment and set status to edited.""" moment = await db.get(KeyMoment, moment_id) if moment is None: raise HTTPException( status_code=404, detail=f"Key moment {moment_id} not found", ) update_data = body.model_dump(exclude_unset=True) # Convert content_type string to enum if provided if "content_type" in update_data and update_data["content_type"] is not None: try: update_data["content_type"] = KeyMomentContentType(update_data["content_type"]) except ValueError: raise HTTPException( status_code=400, detail=f"Invalid content_type '{update_data['content_type']}'", ) for field, value in update_data.items(): setattr(moment, field, value) moment.review_status = ReviewStatus.edited await db.commit() await db.refresh(moment) logger.info("Edited key moment %s (fields: %s)", moment_id, list(update_data.keys())) return KeyMomentRead.model_validate(moment) @router.post("/moments/{moment_id}/split", response_model=list[KeyMomentRead]) async def split_moment( moment_id: uuid.UUID, body: MomentSplitRequest, db: AsyncSession = Depends(get_session), ) -> list[KeyMomentRead]: """Split a key moment into two at the given timestamp.""" moment = await db.get(KeyMoment, moment_id) if moment is None: raise HTTPException( status_code=404, detail=f"Key moment {moment_id} not found", ) # Validate split_time is strictly between start_time and end_time if body.split_time <= moment.start_time or body.split_time >= moment.end_time: raise HTTPException( status_code=400, detail=( f"split_time ({body.split_time}) must be strictly between " f"start_time ({moment.start_time}) and end_time ({moment.end_time})" ), ) # Update original moment to [start_time, split_time) original_end = moment.end_time moment.end_time = body.split_time moment.review_status = ReviewStatus.pending # Create new moment for [split_time, end_time] new_moment = KeyMoment( source_video_id=moment.source_video_id, technique_page_id=moment.technique_page_id, title=f"{moment.title} (split)", summary=moment.summary, start_time=body.split_time, end_time=original_end, content_type=moment.content_type, plugins=moment.plugins, review_status=ReviewStatus.pending, raw_transcript=moment.raw_transcript, ) db.add(new_moment) await db.commit() await db.refresh(moment) await db.refresh(new_moment) logger.info( "Split key moment %s at %.2f → original [%.2f, %.2f), new [%.2f, %.2f]", moment_id, body.split_time, moment.start_time, moment.end_time, new_moment.start_time, new_moment.end_time, ) return [ KeyMomentRead.model_validate(moment), KeyMomentRead.model_validate(new_moment), ] @router.post("/moments/{moment_id}/merge", response_model=KeyMomentRead) async def merge_moments( moment_id: uuid.UUID, body: MomentMergeRequest, db: AsyncSession = Depends(get_session), ) -> KeyMomentRead: """Merge two key moments into one.""" if moment_id == body.target_moment_id: raise HTTPException( status_code=400, detail="Cannot merge a moment with itself", ) source = await db.get(KeyMoment, moment_id) if source is None: raise HTTPException( status_code=404, detail=f"Key moment {moment_id} not found", ) target = await db.get(KeyMoment, body.target_moment_id) if target is None: raise HTTPException( status_code=404, detail=f"Target key moment {body.target_moment_id} not found", ) # Both must belong to the same source video if source.source_video_id != target.source_video_id: raise HTTPException( status_code=400, detail="Cannot merge moments from different source videos", ) # Merge: combined summary, min start, max end source.summary = f"{source.summary}\n\n{target.summary}" source.start_time = min(source.start_time, target.start_time) source.end_time = max(source.end_time, target.end_time) source.review_status = ReviewStatus.pending # Delete target await db.delete(target) await db.commit() await db.refresh(source) logger.info( "Merged key moment %s with %s → [%.2f, %.2f]", moment_id, body.target_moment_id, source.start_time, source.end_time, ) return KeyMomentRead.model_validate(source) @router.get("/moments/{moment_id}", response_model=ReviewQueueItem) async def get_moment( moment_id: uuid.UUID, db: AsyncSession = Depends(get_session), ) -> ReviewQueueItem: """Get a single key moment by ID with video and creator info.""" stmt = ( select(KeyMoment, SourceVideo.file_path, Creator.name) .join(SourceVideo, KeyMoment.source_video_id == SourceVideo.id) .join(Creator, SourceVideo.creator_id == Creator.id) .where(KeyMoment.id == moment_id) ) result = await db.execute(stmt) row = result.one_or_none() if row is None: raise HTTPException(status_code=404, detail=f"Moment {moment_id} not found") moment, file_path, creator_name = row return _moment_to_queue_item(moment, file_path or "", creator_name) @router.get("/mode", response_model=ReviewModeResponse) async def get_mode() -> ReviewModeResponse: """Get the current review mode (review vs auto).""" settings = get_settings() try: redis = await get_redis() try: value = await redis.get(REDIS_MODE_KEY) if value is not None: return ReviewModeResponse(review_mode=value.lower() == "true") finally: await redis.aclose() except Exception as exc: # Redis unavailable — fall back to config default logger.warning("Redis unavailable for mode read, using config default: %s", exc) return ReviewModeResponse(review_mode=settings.review_mode) @router.put("/mode", response_model=ReviewModeResponse) async def set_mode( body: ReviewModeUpdate, ) -> ReviewModeResponse: """Set the review mode (review vs auto).""" try: redis = await get_redis() try: await redis.set(REDIS_MODE_KEY, str(body.review_mode)) finally: await redis.aclose() except Exception as exc: logger.error("Failed to set review mode in Redis: %s", exc) raise HTTPException( status_code=503, detail=f"Redis unavailable: {exc}", ) logger.info("Review mode set to %s", body.review_mode) return ReviewModeResponse(review_mode=body.review_mode)