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:
parent
8487af0282
commit
db135f738e
6 changed files with 471 additions and 2 deletions
|
|
@ -62,7 +62,7 @@
|
||||||
- Estimate: 30m
|
- Estimate: 30m
|
||||||
- Files: backend/models.py, alembic/versions/017_add_consent_tables.py
|
- Files: backend/models.py, alembic/versions/017_add_consent_tables.py
|
||||||
- Verify: cd backend && python -c "from models import VideoConsent, ConsentAuditLog, ConsentField; print('OK')"
|
- Verify: cd backend && python -c "from models import VideoConsent, ConsentAuditLog, ConsentField; print('OK')"
|
||||||
- [ ] **T02: Create consent schemas, router, and wire into main.py** — Build the consent API: Pydantic schemas for request/response, a new router with 5 endpoints (list, get, update, history, admin summary), ownership verification, and registration in main.py.
|
- [x] **T02: Added consent API router with 5 endpoints (list, get, upsert with audit trail, history, admin summary) and Pydantic schemas, wired into main.py** — Build the consent API: Pydantic schemas for request/response, a new router with 5 endpoints (list, get, update, history, admin summary), ownership verification, and registration in main.py.
|
||||||
|
|
||||||
## Steps
|
## Steps
|
||||||
|
|
||||||
|
|
|
||||||
16
.gsd/milestones/M019/slices/S03/tasks/T01-VERIFY.json
Normal file
16
.gsd/milestones/M019/slices/S03/tasks/T01-VERIFY.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"taskId": "T01",
|
||||||
|
"unitId": "M019/S03/T01",
|
||||||
|
"timestamp": 1775254167886,
|
||||||
|
"passed": true,
|
||||||
|
"discoverySource": "task-plan",
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"command": "cd backend",
|
||||||
|
"exitCode": 0,
|
||||||
|
"durationMs": 5,
|
||||||
|
"verdict": "pass"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
82
.gsd/milestones/M019/slices/S03/tasks/T02-SUMMARY.md
Normal file
82
.gsd/milestones/M019/slices/S03/tasks/T02-SUMMARY.md
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
---
|
||||||
|
id: T02
|
||||||
|
parent: S03
|
||||||
|
milestone: M019
|
||||||
|
provides: []
|
||||||
|
requires: []
|
||||||
|
affects: []
|
||||||
|
key_files: ["backend/routers/consent.py", "backend/schemas.py", "backend/main.py"]
|
||||||
|
key_decisions: ["Used selectinload for SourceVideo join on list endpoint", "GET single video returns defaults when no consent record exists", "Each changed field gets its own audit entry with incrementing version"]
|
||||||
|
patterns_established: []
|
||||||
|
drill_down_paths: []
|
||||||
|
observability_surfaces: []
|
||||||
|
duration: ""
|
||||||
|
verification_result: "Ran task verification: `from routers.consent import router` → 5 routes OK. Ran both slice-level checks: model imports OK, migration file valid Python."
|
||||||
|
completed_at: 2026-04-03T22:11:33.455Z
|
||||||
|
blocker_discovered: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# T02: Added consent API router with 5 endpoints (list, get, upsert with audit trail, history, admin summary) and Pydantic schemas, wired into main.py
|
||||||
|
|
||||||
|
> Added consent API router with 5 endpoints (list, get, upsert with audit trail, history, admin summary) and Pydantic schemas, wired into main.py
|
||||||
|
|
||||||
|
## What Happened
|
||||||
|
---
|
||||||
|
id: T02
|
||||||
|
parent: S03
|
||||||
|
milestone: M019
|
||||||
|
key_files:
|
||||||
|
- backend/routers/consent.py
|
||||||
|
- backend/schemas.py
|
||||||
|
- backend/main.py
|
||||||
|
key_decisions:
|
||||||
|
- Used selectinload for SourceVideo join on list endpoint
|
||||||
|
- GET single video returns defaults when no consent record exists
|
||||||
|
- Each changed field gets its own audit entry with incrementing version
|
||||||
|
duration: ""
|
||||||
|
verification_result: passed
|
||||||
|
completed_at: 2026-04-03T22:11:33.455Z
|
||||||
|
blocker_discovered: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# T02: Added consent API router with 5 endpoints (list, get, upsert with audit trail, history, admin summary) and Pydantic schemas, wired into main.py
|
||||||
|
|
||||||
|
**Added consent API router with 5 endpoints (list, get, upsert with audit trail, history, admin summary) and Pydantic schemas, wired into main.py**
|
||||||
|
|
||||||
|
## What Happened
|
||||||
|
|
||||||
|
Built the consent API layer on top of T01 models. Added 5 Pydantic schemas to schemas.py, created routers/consent.py with ownership verification helper (admin bypass), 5 endpoints covering CRUD + audit history + admin summary, and registered in main.py. PUT endpoint upserts consent with per-field audit log entries using auto-incrementing versions.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Ran task verification: `from routers.consent import router` → 5 routes OK. Ran both slice-level checks: model imports OK, migration file valid Python.
|
||||||
|
|
||||||
|
## Verification Evidence
|
||||||
|
|
||||||
|
| # | Command | Exit Code | Verdict | Duration |
|
||||||
|
|---|---------|-----------|---------|----------|
|
||||||
|
| 1 | `cd backend && python -c "from routers.consent import router; print(f'{len(router.routes)} routes OK')"` | 0 | ✅ pass | 500ms |
|
||||||
|
| 2 | `cd backend && python -c "from models import VideoConsent, ConsentAuditLog, ConsentField; print('OK')"` | 0 | ✅ pass | 500ms |
|
||||||
|
| 3 | `python -c "import importlib.util; spec = importlib.util.spec_from_file_location('m', 'alembic/versions/017_add_consent_tables.py'); ..."` | 0 | ✅ pass | 300ms |
|
||||||
|
|
||||||
|
|
||||||
|
## Deviations
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
- `backend/routers/consent.py`
|
||||||
|
- `backend/schemas.py`
|
||||||
|
- `backend/main.py`
|
||||||
|
|
||||||
|
|
||||||
|
## Deviations
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
None.
|
||||||
|
|
@ -12,7 +12,7 @@ from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from config import get_settings
|
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:
|
def _setup_logging() -> None:
|
||||||
|
|
@ -79,6 +79,7 @@ app.include_router(health.router)
|
||||||
|
|
||||||
# Versioned API
|
# Versioned API
|
||||||
app.include_router(auth.router, prefix="/api/v1")
|
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(creators.router, prefix="/api/v1")
|
||||||
app.include_router(ingest.router, prefix="/api/v1")
|
app.include_router(ingest.router, prefix="/api/v1")
|
||||||
app.include_router(pipeline.router, prefix="/api/v1")
|
app.include_router(pipeline.router, prefix="/api/v1")
|
||||||
|
|
|
||||||
322
backend/routers/consent.py
Normal file
322
backend/routers/consent.py
Normal 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,
|
||||||
|
)
|
||||||
|
|
@ -545,3 +545,51 @@ class UpdateProfileRequest(BaseModel):
|
||||||
display_name: str | None = Field(None, min_length=1, max_length=255)
|
display_name: str | None = Field(None, min_length=1, max_length=255)
|
||||||
current_password: str | None = None
|
current_password: str | None = None
|
||||||
new_password: str | None = Field(None, min_length=8, max_length=128)
|
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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue