"""Consent router — per-video consent toggles with versioned audit trail. Creator endpoints (ownership-gated): GET /consent/videos List consent for the current creator's videos GET /consent/videos/{video_id} Single video consent status PUT /consent/videos/{video_id} Upsert consent (partial update, audit logged) GET /consent/videos/{video_id}/history Audit trail for a video Admin endpoint: GET /consent/admin/summary Aggregate consent flag counts """ from __future__ import annotations import logging import uuid from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from auth import get_current_user, reject_impersonation, require_role from database import get_session from models import ( ConsentAuditLog, ConsentField, SourceVideo, User, UserRole, VideoConsent, ) from schemas import ( ConsentAuditEntry, ConsentListResponse, ConsentSummary, VideoConsentRead, VideoConsentUpdate, ) logger = logging.getLogger("chrysopedia.consent") router = APIRouter(prefix="/consent", tags=["consent"]) # ── Helpers ────────────────────────────────────────────────────────────────── async def _verify_video_ownership( video_id: uuid.UUID, user: User, session: AsyncSession, ) -> SourceVideo: """Load a SourceVideo and verify the user owns it (or is admin). Returns the SourceVideo on success. Raises 403 if user has no creator_id or doesn't own the video. Raises 404 if video doesn't exist. """ result = await session.execute( select(SourceVideo).where(SourceVideo.id == video_id) ) video = result.scalar_one_or_none() if video is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Video not found", ) # Admin bypasses ownership check if user.role == UserRole.admin: return video if user.creator_id is None: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="User is not linked to a creator profile", ) if video.creator_id != user.creator_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You do not own this video", ) return video def _consent_to_read(consent: VideoConsent, filename: str) -> VideoConsentRead: """Map a VideoConsent ORM instance to the read schema.""" return VideoConsentRead( source_video_id=consent.source_video_id, video_filename=filename, creator_id=consent.creator_id, kb_inclusion=consent.kb_inclusion, training_usage=consent.training_usage, public_display=consent.public_display, updated_at=consent.updated_at, ) # ── Endpoints ──────────────────────────────────────────────────────────────── @router.get("/videos", response_model=ConsentListResponse) async def list_video_consents( current_user: Annotated[User, Depends(get_current_user)], session: Annotated[AsyncSession, Depends(get_session)], offset: Annotated[int, Query(ge=0)] = 0, limit: Annotated[int, Query(ge=1, le=100)] = 50, ): """List consent records for the current creator's videos.""" if current_user.creator_id is None and current_user.role != UserRole.admin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="User is not linked to a creator profile", ) stmt = ( select(VideoConsent) .join(SourceVideo, VideoConsent.source_video_id == SourceVideo.id) .options(selectinload(VideoConsent.source_video)) ) # Non-admin sees only their own videos if current_user.role != UserRole.admin: stmt = stmt.where(VideoConsent.creator_id == current_user.creator_id) # Count count_stmt = select(func.count()).select_from(stmt.subquery()) total = (await session.execute(count_stmt)).scalar() or 0 # Fetch page stmt = stmt.order_by(VideoConsent.updated_at.desc()) stmt = stmt.offset(offset).limit(limit) result = await session.execute(stmt) consents = result.scalars().all() items = [ _consent_to_read(c, c.source_video.filename) for c in consents ] return ConsentListResponse(items=items, total=total) @router.get("/videos/{video_id}", response_model=VideoConsentRead) async def get_video_consent( video_id: uuid.UUID, current_user: Annotated[User, Depends(get_current_user)], session: Annotated[AsyncSession, Depends(get_session)], ): """Get consent status for a single video.""" video = await _verify_video_ownership(video_id, current_user, session) result = await session.execute( select(VideoConsent).where(VideoConsent.source_video_id == video_id) ) consent = result.scalar_one_or_none() if consent is None: # No consent record yet — return defaults return VideoConsentRead( source_video_id=video_id, video_filename=video.filename, creator_id=video.creator_id, kb_inclusion=False, training_usage=False, public_display=True, updated_at=video.created_at, ) return _consent_to_read(consent, video.filename) @router.put("/videos/{video_id}", response_model=VideoConsentRead) async def update_video_consent( video_id: uuid.UUID, body: VideoConsentUpdate, current_user: Annotated[User, Depends(reject_impersonation)], session: Annotated[AsyncSession, Depends(get_session)], request: Request, ): """Upsert consent for a video. Only non-None fields are changed. Creates audit log entries for each changed field with incrementing version numbers. """ video = await _verify_video_ownership(video_id, current_user, session) # Load or create consent record result = await session.execute( select(VideoConsent).where(VideoConsent.source_video_id == video_id) ) consent = result.scalar_one_or_none() is_new = consent is None if is_new: consent = VideoConsent( source_video_id=video_id, creator_id=video.creator_id, updated_by=current_user.id, ) session.add(consent) await session.flush() # get consent.id for audit entries # Determine the next version number max_version_result = await session.execute( select(func.coalesce(func.max(ConsentAuditLog.version), 0)).where( ConsentAuditLog.video_consent_id == consent.id ) ) next_version = (max_version_result.scalar() or 0) + 1 # Collect client IP for audit client_ip = request.client.host if request.client else None # Apply changes and build audit entries fields_changed: list[str] = [] update_data = body.model_dump(exclude_none=True) for field_name, new_value in update_data.items(): # Validate field name against the enum try: ConsentField(field_name) except ValueError: continue old_value = getattr(consent, field_name) # Skip if no actual change if old_value == new_value: continue # Update the consent record setattr(consent, field_name, new_value) fields_changed.append(field_name) # Create audit entry audit_entry = ConsentAuditLog( video_consent_id=consent.id, version=next_version, field_name=field_name, old_value=old_value if not is_new else None, new_value=new_value, changed_by=current_user.id, ip_address=client_ip, ) session.add(audit_entry) next_version += 1 if fields_changed: consent.updated_by = current_user.id await session.commit() await session.refresh(consent) logger.info( "Consent updated: video_id=%s fields_changed=%s user=%s", video_id, fields_changed, current_user.id, ) else: # No actual changes — still commit if we created a new record if is_new: await session.commit() await session.refresh(consent) else: # Nothing changed, no audit entries pass return _consent_to_read(consent, video.filename) @router.get("/videos/{video_id}/history", response_model=list[ConsentAuditEntry]) async def get_consent_history( video_id: uuid.UUID, current_user: Annotated[User, Depends(get_current_user)], session: Annotated[AsyncSession, Depends(get_session)], ): """Get the audit trail for a video's consent changes.""" await _verify_video_ownership(video_id, current_user, session) # Find the consent record result = await session.execute( select(VideoConsent).where(VideoConsent.source_video_id == video_id) ) consent = result.scalar_one_or_none() if consent is None: return [] # Fetch audit entries ordered by version audit_result = await session.execute( select(ConsentAuditLog) .where(ConsentAuditLog.video_consent_id == consent.id) .order_by(ConsentAuditLog.version.asc()) ) return audit_result.scalars().all() @router.get( "/admin/summary", response_model=ConsentSummary, dependencies=[Depends(require_role(UserRole.admin))], ) async def consent_admin_summary( session: Annotated[AsyncSession, Depends(get_session)], ): """Aggregate consent flag counts across all videos (admin only).""" result = await session.execute( select( func.count().label("total"), func.sum(VideoConsent.kb_inclusion.cast(int)).label("kb"), func.sum(VideoConsent.training_usage.cast(int)).label("tu"), func.sum(VideoConsent.public_display.cast(int)).label("pd"), ) ) row = result.one() return ConsentSummary( total_videos=row.total or 0, kb_inclusion_granted=row.kb or 0, training_usage_granted=row.tu or 0, public_display_granted=row.pd or 0, )