feat: Added consent API router with 5 endpoints (list, get, upsert with…

- "backend/routers/consent.py"
- "backend/schemas.py"
- "backend/main.py"

GSD-Task: S03/T02
This commit is contained in:
jlightner 2026-04-03 22:11:36 +00:00
parent 4b7511d363
commit ab3c723533
3 changed files with 372 additions and 1 deletions

View file

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

322
backend/routers/consent.py Normal file
View file

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

View file

@ -545,3 +545,51 @@ class UpdateProfileRequest(BaseModel):
display_name: str | None = Field(None, min_length=1, max_length=255)
current_password: str | None = None
new_password: str | None = Field(None, min_length=8, max_length=128)
# ── Consent ──────────────────────────────────────────────────────────────────
class VideoConsentUpdate(BaseModel):
"""Partial update — only non-None fields trigger changes."""
kb_inclusion: bool | None = None
training_usage: bool | None = None
public_display: bool | None = None
class VideoConsentRead(BaseModel):
"""Current consent state for a video."""
model_config = ConfigDict(from_attributes=True)
source_video_id: uuid.UUID
video_filename: str = ""
creator_id: uuid.UUID
kb_inclusion: bool = False
training_usage: bool = False
public_display: bool = True
updated_at: datetime
class ConsentAuditEntry(BaseModel):
"""Single audit trail entry for a consent change."""
model_config = ConfigDict(from_attributes=True)
version: int
field_name: str
old_value: bool | None = None
new_value: bool
changed_by: uuid.UUID
created_at: datetime
class ConsentListResponse(BaseModel):
"""Paginated list of video consent records."""
items: list[VideoConsentRead] = Field(default_factory=list)
total: int = 0
class ConsentSummary(BaseModel):
"""Aggregate consent flag counts across all videos."""
total_videos: int = 0
kb_inclusion_granted: int = 0
training_usage_granted: int = 0
public_display_granted: int = 0