feat: Added VideoConsent and ConsentAuditLog models with ConsentField e…

- "backend/models.py"
- "alembic/versions/017_add_consent_tables.py"

GSD-Task: S03/T01
This commit is contained in:
jlightner 2026-04-03 22:09:27 +00:00
parent c60fc8c3b3
commit 8487af0282
14 changed files with 1135 additions and 2 deletions

View file

@ -41,3 +41,4 @@
| D033 | | architecture | Monetization approach for Phase 2 | Demo build with functional UI and "Coming Soon" payment placeholders. Stripe Connect deferred to Phase 3. | Phase 2 is a creator recruitment demo. Show working product, not a pitch deck. Tier UI exists but payment buttons show styled "Coming Soon" modals. Recruit creators first, build commerce layer after buy-in. | Yes | collaborative |
| D034 | | architecture | Documentation strategy for Phase 2 | Forgejo wiki at forgejo.xpltd.co populated incrementally — KB slice at end of every milestone | KB stays current by documenting what just shipped at each milestone boundary. Final comprehensive pass in M025. Newcomers can onboard at any point during Phase 2 development. | Yes | collaborative |
| D035 | | architecture | File/object storage for creator posts, shorts, and file distribution | MinIO (S3-compatible) self-hosted on ub01 home server stack | Docker-native, S3-compatible API for signed URLs with expiration. Already fits the self-hosted infrastructure model. Handles presets, sample packs, shorts output, and gated downloads. | Yes | collaborative |
| D036 | M019/S02 | architecture | JWT auth configuration for creator authentication | HS256 with existing app_secret_key, 24-hour expiry, OAuth2PasswordBearer at /api/v1/auth/login | Reuses existing secret from config.py settings. 24-hour expiry balances convenience with security for a single-admin/invite-only tool. OAuth2PasswordBearer integrates with FastAPI's dependency injection and auto-generates OpenAPI security schemes. | Yes | agent |

View file

@ -276,3 +276,15 @@
**Context:** The test PostgreSQL database runs inside the `chrysopedia-db` container. From the dev machine (aux), port 5433 is not reachable. From ub01's host, the DB password differs from the default "changeme" in conftest.py.
**Fix:** Run tests inside the API container: `docker exec -e TEST_DATABASE_URL='postgresql+asyncpg://chrysopedia:<actual_pw>@chrysopedia-db:5432/chrysopedia_test' chrysopedia-api python -m pytest tests/...`. Copy changed files in first with `docker cp`. The container has all Python dependencies and network access to the DB container.
## passlib is incompatible with bcrypt>=4.1
**Context:** passlib[bcrypt] has been unmaintained and fails at runtime with bcrypt 4.1+ (the version installed in current Docker images). The error manifests as attribute errors or version-check failures inside passlib's bcrypt backend.
**Fix:** Use `bcrypt` directly: `bcrypt.hashpw(password.encode(), bcrypt.gensalt())` and `bcrypt.checkpw(password.encode(), hashed)`. No wrapper needed. The bcrypt library is stable and well-maintained.
## AppShell extraction for React context consumers in the root component
**Context:** When `App.tsx` wraps everything in `<AuthProvider>` but also uses `useAuth()` itself (e.g., for auth-aware nav), the hook call fails because `useAuth()` must be inside `<AuthProvider>`. Simply moving the hook into `App` doesn't work.
**Fix:** Extract an `AppShell` component that contains the Routes and any hook-consuming UI (nav, footer). `App` renders `<AuthProvider><AppShell /></AuthProvider>`. This is the standard pattern whenever the root component needs to both provide and consume a context.

View file

@ -7,7 +7,7 @@ Stand up the two foundational systems for Phase 2: creator authentication with c
| ID | Slice | Risk | Depends | Done | After this |
|----|-------|------|---------|------|------------|
| S01 | [B] LightRAG Deployment + Docker Integration | high | — | ✅ | LightRAG service running in Docker, connected to Qdrant, entity extraction prompts producing music production entities from test input |
| S02 | [A] Creator Authentication + Dashboard Shell | high | — | | Creator registers with invite code, logs in, sees dashboard shell with nav and profile settings |
| S02 | [A] Creator Authentication + Dashboard Shell | high | — | | Creator registers with invite code, logs in, sees dashboard shell with nav and profile settings |
| S03 | [A] Consent Data Model + API Endpoints | medium | — | ⬜ | API accepts per-video consent toggles with versioned audit trail |
| S04 | [B] Reindex Existing Corpus Through LightRAG | medium | S01 | ⬜ | All existing content indexed in LightRAG with entity/relationship graph alongside current search |
| S05 | [A] Sprint 0 Refactoring Tasks | low | S02 | ⬜ | Any structural refactoring from M018 audit is complete |

View file

@ -0,0 +1,153 @@
---
id: S02
parent: M019
milestone: M019
provides:
- User and InviteCode models (backend/models.py)
- Auth router at /api/v1/auth/* (register, login, me, update profile)
- get_current_user and require_role FastAPI dependencies (backend/auth.py)
- AuthContext with useAuth() hook (frontend/src/context/AuthContext.tsx)
- ProtectedRoute component for gating routes (frontend/src/components/ProtectedRoute.tsx)
- SidebarNav component for dashboard layout (exported from CreatorDashboard.tsx)
- Auth API client functions with TypeScript types (frontend/src/api/public-client.ts)
requires:
[]
affects:
- S03
- S05
key_files:
- backend/models.py
- backend/auth.py
- backend/schemas.py
- backend/routers/auth.py
- backend/main.py
- backend/requirements.txt
- alembic/versions/016_add_users_and_invite_codes.py
- backend/tests/conftest.py
- backend/tests/test_auth.py
- frontend/src/context/AuthContext.tsx
- frontend/src/api/public-client.ts
- frontend/src/pages/Login.tsx
- frontend/src/pages/Register.tsx
- frontend/src/components/ProtectedRoute.tsx
- frontend/src/pages/CreatorDashboard.tsx
- frontend/src/pages/CreatorSettings.tsx
- frontend/src/App.tsx
key_decisions:
- D036: Direct bcrypt instead of passlib[bcrypt] — passlib incompatible with bcrypt>=4.1
- D037: HS256 JWT with 24h expiry, OAuth2PasswordBearer at /api/v1/auth/login
- AppShell extraction pattern for root-level context consumers
- AUTH_TOKEN_KEY constant in public-client.ts for shared localStorage key
- SidebarNav exported from CreatorDashboard for reuse in CreatorSettings
- seed_invite_codes() as callable async function, not auto-invoked at startup
patterns_established:
- Auth fixture chain: invite_code → registered_user → auth_headers for integration tests
- ProtectedRoute with returnTo param for post-login redirect
- AppShell extraction for components that both provide and consume React context
- Auto-inject Authorization header from localStorage in API client
- CSS module per page with dark theme custom properties for dashboard pages
observability_surfaces:
- GET /api/v1/auth/me validates token and returns user profile (auth health check)
- JWT decode errors surface as 401 with detail message
- Invite code validation returns specific 403 messages (invalid, expired, exhausted)
drill_down_paths:
- .gsd/milestones/M019/slices/S02/tasks/T01-SUMMARY.md
- .gsd/milestones/M019/slices/S02/tasks/T02-SUMMARY.md
- .gsd/milestones/M019/slices/S02/tasks/T03-SUMMARY.md
- .gsd/milestones/M019/slices/S02/tasks/T04-SUMMARY.md
duration: ""
verification_result: passed
completed_at: 2026-04-03T22:04:22.116Z
blocker_discovered: false
---
# S02: [A] Creator Authentication + Dashboard Shell
**Full-stack creator auth system: User/InviteCode models, JWT auth API with 20 passing tests, React auth context with login/register pages, protected dashboard shell with sidebar nav and profile settings.**
## What Happened
This slice delivered the complete creator authentication and dashboard infrastructure across four tasks spanning backend models, API endpoints, frontend auth context, and the dashboard UI shell.
**T01 — Auth Foundation:** Added User and InviteCode SQLAlchemy models with UserRole enum, Alembic migration 016, bcrypt password hashing, JWT encode/decode utilities, FastAPI get_current_user dependency with OAuth2PasswordBearer, and five Pydantic auth schemas. The migration was hand-written (no live DB for autogenerate).
**T02 — Auth API:** Built the /api/v1/auth router with POST /register (invite code validation, email uniqueness, optional creator linking), POST /login (JWT issuance), GET /me, and PUT /me (display name and password update). Added seed_invite_codes() for the default alpha code. Wrote 20 integration tests covering happy paths, error paths (invalid/expired/exhausted codes, duplicate email, wrong password, invalid JWT), boundary conditions, and public endpoint non-regression. Switched from passlib to direct bcrypt due to passlib incompatibility with bcrypt>=4.1.
**T03 — Frontend Auth:** Created AuthContext provider with login/register/logout and session rehydration from localStorage. Added auth API functions with TypeScript types to public-client.ts, with automatic Authorization header injection. Built Login and Register pages with CSS modules, form validation, and error handling. Wired AuthProvider and routes into App.tsx.
**T04 — Dashboard Shell:** Created ProtectedRoute component with returnTo redirect. Built CreatorDashboard with two-column sidebar layout (Dashboard active, Content placeholder, Settings link) using NavLink. Built CreatorSettings reusing the SidebarNav, with profile edit and password change forms. Extracted AppShell from App to allow useAuth() inside AuthProvider. Added AuthNav showing user state in the header.
## Verification
All slice verification checks pass:
1. Backend model imports (User, InviteCode, UserRole) — ✅
2. Auth utility imports (hash_password, verify_password, create_access_token, decode_access_token) — ✅
3. Schema imports (RegisterRequest, LoginRequest, TokenResponse, UserResponse, UpdateProfileRequest) — ✅
4. Alembic migration 016 exists with users and invite_codes tables — ✅
5. Frontend build (`npm run build`) — ✅ zero TypeScript errors, built in 944ms
6. All required files exist: AuthContext.tsx, Login.tsx, Register.tsx, ProtectedRoute.tsx, CreatorDashboard.tsx, CreatorSettings.tsx — ✅
7. All CSS modules exist: Login.module.css, Register.module.css, CreatorDashboard.module.css, CreatorSettings.module.css — ✅
8. App.tsx wiring: AuthProvider, ProtectedRoute, useAuth all present — ✅
9. 20 auth integration tests pass in Docker container on ub01 — ✅
10. Existing public API tests unaffected by auth changes — ✅
## Requirements Advanced
- R028 — Creator auth system implemented: invite-code registration, JWT login, profile management. Dashboard shell with protected routes ready for consent UI in S03.
## Requirements Validated
None.
## New Requirements Surfaced
None.
## Requirements Invalidated or Re-scoped
None.
## Deviations
- passlib[bcrypt] replaced with direct bcrypt due to passlib incompatibility with bcrypt>=4.1
- Alembic migration written manually (no live DB for autogenerate)
- AppShell extracted from App component to allow useAuth() inside AuthProvider
- AuthNav added as separate component rather than inline in App.tsx
## Known Limitations
- JWT InsecureKeyLengthWarning: default app_secret_key is 31 bytes, production should use 32+ bytes
- 7 pre-existing test_public_api.py failures unrelated to auth
- MomentDetail fetches full queue with limit=500 (pre-existing, not introduced by this slice)
## Follow-ups
- Seed invite code via startup event or CLI command (currently only callable programmatically)
- Run Alembic migration 016 on ub01 production DB before deploying
- Increase app_secret_key length to 32+ bytes for production
## Files Created/Modified
- `backend/models.py` — Added UserRole enum, User model, InviteCode model
- `backend/auth.py` — Created: password hashing (bcrypt), JWT encode/decode, get_current_user dependency, require_role, seed_invite_codes()
- `backend/schemas.py` — Added RegisterRequest, LoginRequest, TokenResponse, UserResponse, UpdateProfileRequest schemas
- `backend/routers/auth.py` — Created: POST /register, POST /login, GET /me, PUT /me endpoints
- `backend/main.py` — Registered auth router
- `backend/requirements.txt` — Added PyJWT, bcrypt dependencies
- `alembic/versions/016_add_users_and_invite_codes.py` — Migration creating users and invite_codes tables
- `backend/tests/conftest.py` — Added auth model imports, invite_code/registered_user/auth_headers fixtures
- `backend/tests/test_auth.py` — Created: 20 integration tests for all auth endpoints
- `frontend/src/context/AuthContext.tsx` — Created: AuthProvider, useAuth hook with login/register/logout/rehydration
- `frontend/src/api/public-client.ts` — Added auth API functions, TypeScript types, auto-inject Authorization header, exported ApiError and AUTH_TOKEN_KEY
- `frontend/src/pages/Login.tsx` — Created: login page with form, error handling, returnTo redirect
- `frontend/src/pages/Login.module.css` — Created: login page styles
- `frontend/src/pages/Register.tsx` — Created: register page with invite code, validation, error handling
- `frontend/src/pages/Register.module.css` — Created: register page styles
- `frontend/src/components/ProtectedRoute.tsx` — Created: auth gate component with returnTo redirect
- `frontend/src/pages/CreatorDashboard.tsx` — Created: dashboard shell with SidebarNav, placeholder cards
- `frontend/src/pages/CreatorDashboard.module.css` — Created: dashboard layout styles
- `frontend/src/pages/CreatorSettings.tsx` — Created: profile edit + password change with SidebarNav
- `frontend/src/pages/CreatorSettings.module.css` — Created: settings page styles
- `frontend/src/App.tsx` — Added AuthProvider wrapper, AppShell extraction, AuthNav, protected routes for /creator/*
- `frontend/src/App.css` — Added auth nav and dashboard CSS custom properties

View file

@ -0,0 +1,132 @@
# S02: [A] Creator Authentication + Dashboard Shell — UAT
**Milestone:** M019
**Written:** 2026-04-03T22:04:22.117Z
# S02 UAT: Creator Authentication + Dashboard Shell
## Preconditions
- Chrysopedia stack running on ub01 (docker compose up)
- Alembic migration 016 applied (`docker exec chrysopedia-api alembic upgrade head`)
- Seed invite code created (`CHRYSOPEDIA-ALPHA-2026` with 100 uses)
- Frontend rebuilt and deployed
---
## TC-01: Registration with valid invite code
1. Navigate to `/register`
2. Enter invite code: `CHRYSOPEDIA-ALPHA-2026`
3. Enter email: `testcreator@example.com`
4. Enter display name: `Test Creator`
5. Enter password: `SecurePass123!` and confirm
6. Click Register
**Expected:** Registration succeeds, redirected to `/login` with success message.
## TC-02: Registration with invalid invite code
1. Navigate to `/register`
2. Enter invite code: `INVALID-CODE`
3. Fill remaining fields with valid data
4. Click Register
**Expected:** 403 error displayed — "Invalid or expired invite code"
## TC-03: Registration with duplicate email
1. Complete TC-01 first
2. Navigate to `/register` again
3. Use same email `testcreator@example.com` with valid invite code
4. Click Register
**Expected:** 409 error displayed — "Email already registered"
## TC-04: Login with valid credentials
1. Navigate to `/login`
2. Enter email: `testcreator@example.com`
3. Enter password: `SecurePass123!`
4. Click Login
**Expected:** Redirected to `/creator/dashboard`. Header nav shows display name, Dashboard link, and Logout button.
## TC-05: Login with wrong password
1. Navigate to `/login`
2. Enter email: `testcreator@example.com`
3. Enter password: `WrongPassword!`
4. Click Login
**Expected:** 401 error displayed — "Invalid email or password"
## TC-06: Session persistence across page reload
1. Complete TC-04 (logged in)
2. Refresh the page (F5)
**Expected:** User remains logged in. Dashboard/nav still shows authenticated state. No redirect to /login.
## TC-07: Dashboard shell layout
1. Log in and navigate to `/creator/dashboard`
2. Observe sidebar nav and main content
**Expected:**
- Left sidebar with: Dashboard (active/highlighted), Content (disabled/placeholder), Settings (clickable link)
- Main content: Welcome message with user's display name
- Placeholder cards mentioning future analytics
## TC-08: Navigate to Settings from Dashboard
1. From `/creator/dashboard`, click Settings in sidebar
**Expected:** Navigated to `/creator/settings`. Same sidebar layout. Profile section shows display_name (editable) and email (read-only). Password change section visible.
## TC-09: Update display name
1. On `/creator/settings`, change display name to "Updated Creator"
2. Submit profile form
**Expected:** Success feedback. Header nav updates to show "Updated Creator".
## TC-10: Change password
1. On `/creator/settings`, enter current password
2. Enter new password and confirm
3. Submit
**Expected:** Success feedback. Logout, then login with new password succeeds.
## TC-11: ProtectedRoute redirect
1. Log out (or clear localStorage)
2. Navigate directly to `/creator/dashboard`
**Expected:** Redirected to `/login?returnTo=%2Fcreator%2Fdashboard`. After logging in, redirected back to `/creator/dashboard`.
## TC-12: Public routes unaffected
1. Without being logged in, navigate to `/`
2. Navigate to `/techniques`
3. Navigate to `/creators`
**Expected:** All public pages load normally. No auth required. No 401 errors.
## TC-13: Logout
1. While logged in, click Logout in header nav
**Expected:** Redirected to home page. Header shows Login link instead of user name. Navigating to `/creator/dashboard` redirects to `/login`.
## TC-14: Client-side form validation on Register
1. Navigate to `/register`
2. Leave invite code empty, click Register
**Expected:** Validation error on required fields.
3. Enter mismatched passwords
**Expected:** Validation error — passwords don't match.
4. Enter password shorter than 8 characters
**Expected:** Validation error — minimum length.
## TC-15: Expired invite code
1. Create an invite code with expires_at in the past (via DB or API)
2. Attempt registration with that code
**Expected:** 403 error — "Invalid or expired invite code"
## Edge Cases
- **Expired JWT:** After 24 hours without activity, next API call should return 401 and redirect to login
- **Concurrent sessions:** Login from two browsers with same account should both work independently
- **API direct access:** `curl -X GET /api/v1/auth/me` without Authorization header returns 401

View file

@ -0,0 +1,48 @@
{
"schemaVersion": 1,
"taskId": "T04",
"unitId": "M019/S02/T04",
"timestamp": 1775253724121,
"passed": false,
"discoverySource": "task-plan",
"checks": [
{
"command": "cd frontend",
"exitCode": 0,
"durationMs": 7,
"verdict": "pass"
},
{
"command": "npm run build",
"exitCode": 254,
"durationMs": 95,
"verdict": "fail"
},
{
"command": "test -f src/components/ProtectedRoute.tsx",
"exitCode": 1,
"durationMs": 7,
"verdict": "fail"
},
{
"command": "test -f src/pages/CreatorDashboard.tsx",
"exitCode": 1,
"durationMs": 7,
"verdict": "fail"
},
{
"command": "test -f src/pages/CreatorSettings.tsx",
"exitCode": 1,
"durationMs": 7,
"verdict": "fail"
},
{
"command": "grep -q 'ProtectedRoute' src/App.tsx",
"exitCode": 2,
"durationMs": 9,
"verdict": "fail"
}
],
"retryAttempt": 1,
"maxRetries": 2
}

View file

@ -1,6 +1,177 @@
# S03: [A] Consent Data Model + API Endpoints
**Goal:** Build consent infrastructure — database model, API endpoints, consent change logging
**Goal:** API accepts per-video consent toggles with versioned audit trail, gated by creator ownership.
**Demo:** After this: API accepts per-video consent toggles with versioned audit trail
## Tasks
- [x] **T01: Added VideoConsent and ConsentAuditLog models with ConsentField enum and Alembic migration 017** — Add the consent data model to the codebase: a mutable `VideoConsent` table (current consent state per video) and an append-only `ConsentAuditLog` table (versioned per-field change history). Create Alembic migration `017_add_consent_tables.py`.
## Steps
1. Read `backend/models.py` to see existing patterns (`_uuid_pk()`, `_now()`, `sa_relationship`, enum patterns).
2. Add `ConsentField` enum to `backend/models.py`: `kb_inclusion`, `training_usage`, `public_display`.
3. Add `VideoConsent` model:
- UUID PK via `_uuid_pk()`
- `source_video_id`: UUID FK → `source_videos.id`, unique constraint (one consent record per video)
- `creator_id`: UUID FK → `creators.id` (denormalized for fast queries)
- `kb_inclusion`: bool, default False
- `training_usage`: bool, default False
- `public_display`: bool, default True
- `updated_by`: UUID FK → `users.id`
- `created_at`, `updated_at`: datetime via `_now()`
- Relationships: `source_video`, `creator`, `audit_entries`
4. Add `ConsentAuditLog` model:
- UUID PK via `_uuid_pk()`
- `video_consent_id`: UUID FK → `video_consents.id`
- `version`: Integer (auto-incrementing per video_consent_id, assigned in application code)
- `field_name`: String (which consent field changed)
- `old_value`: Boolean, nullable
- `new_value`: Boolean
- `changed_by`: UUID FK → `users.id`
- `ip_address`: String(45), nullable
- `created_at`: datetime via `_now()`
5. Create `alembic/versions/017_add_consent_tables.py` — follow the pattern from `016_add_users_and_invite_codes.py`. Create both tables with proper FKs and the unique constraint on `source_video_id`.
6. Verify: import the models in a Python shell and confirm no import errors. Check that the migration file has valid `upgrade()` and `downgrade()` functions.
## Must-Haves
- [ ] `VideoConsent` model with unique constraint on `source_video_id`
- [ ] `ConsentAuditLog` model with `version` column
- [ ] `ConsentField` enum for field name validation
- [ ] Alembic migration `017` with upgrade and downgrade
- [ ] All timestamps use `_now()` helper (KNOWLEDGE: asyncpg rejects timezone-aware datetimes)
## Verification
- `cd backend && python -c "from models import VideoConsent, ConsentAuditLog, ConsentField; print('OK')"` exits 0
- `python -c "import importlib.util; spec = importlib.util.spec_from_file_location('m', 'alembic/versions/017_add_consent_tables.py'); mod = importlib.util.module_from_spec(spec)"` — migration file is valid Python
## Negative Tests
- **Boundary conditions**: unique constraint on `source_video_id` prevents duplicate consent records per video
## Inputs
- `backend/models.py` — existing model patterns to follow
- `alembic/versions/016_add_users_and_invite_codes.py` — migration pattern to follow
## Expected Output
- `backend/models.py` — updated with VideoConsent, ConsentAuditLog, ConsentField
- `alembic/versions/017_add_consent_tables.py` — new migration file
- Estimate: 30m
- Files: backend/models.py, alembic/versions/017_add_consent_tables.py
- 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.
## Steps
1. Read `backend/schemas.py` to see existing Pydantic v2 patterns (`ConfigDict(from_attributes=True)`, request vs response models).
2. Add consent schemas to `backend/schemas.py`:
- `VideoConsentUpdate(BaseModel)`: `kb_inclusion: bool | None = None`, `training_usage: bool | None = None`, `public_display: bool | None = None` (partial update — only non-None fields are changed)
- `VideoConsentRead(BaseModel)`: all consent fields + `source_video_id`, `video_filename`, `creator_id`, `updated_at`, `model_config = ConfigDict(from_attributes=True)`
- `ConsentAuditEntry(BaseModel)`: `version`, `field_name`, `old_value`, `new_value`, `changed_by`, `created_at`, `model_config = ConfigDict(from_attributes=True)`
- `ConsentListResponse(BaseModel)`: `items: list[VideoConsentRead]`, `total: int`
- `ConsentSummary(BaseModel)`: per-flag counts
3. Create `backend/routers/consent.py`:
- `APIRouter(prefix="/consent", tags=["consent"])`
- Helper `_verify_video_ownership(video_id, user, session)` — loads SourceVideo, checks `user.creator_id` matches `video.creator_id`. Raises 403 if user has no `creator_id` or doesn't own video. Admin role (checked via `user.role == UserRole.admin`) bypasses the ownership check.
- `GET /consent/videos` — list consent for current creator's videos, paginated (offset/limit). Join with SourceVideo to get filename.
- `GET /consent/videos/{video_id}` — single video consent status
- `PUT /consent/videos/{video_id}` — upsert consent: create `VideoConsent` if not exists, update changed fields, create `ConsentAuditLog` entry for each changed field with incrementing version number. Use `select ... for update` or compute max version from existing audit entries.
- `GET /consent/videos/{video_id}/history` — audit trail ordered by version
- `GET /consent/admin/summary` — admin-only aggregate stats, guarded by `require_role(UserRole.admin)`
- Add `chrysopedia.consent` logger, log on PUT: `logger.info("Consent updated", extra={"video_id": ..., "fields_changed": ...})`
4. Register router in `backend/main.py`: `from routers import consent` and `app.include_router(consent.router, prefix="/api/v1")`
5. Verify: start the app and confirm `/docs` shows the consent endpoints (or just verify the import chain works).
## Must-Haves
- [ ] Partial update schema (only non-None fields trigger changes)
- [ ] Ownership verification helper with admin bypass
- [ ] PUT creates audit log entries per changed field with incrementing versions
- [ ] GET /consent/videos returns video filename alongside consent flags
- [ ] All endpoints require authentication via `get_current_user`
- [ ] Users without `creator_id` get 403 (not 500)
- [ ] Admin summary endpoint requires admin role
- [ ] Router registered in main.py
## Failure Modes
| Dependency | On error | On timeout | On malformed response |
|------------|----------|-----------|----------------------|
| PostgreSQL | SQLAlchemy raises, FastAPI returns 500 | Connection pool timeout → 500 | N/A (ORM handles) |
| Auth (JWT) | 401 Unauthorized | N/A (local decode) | 401 (invalid token) |
## Negative Tests
- **Malformed inputs**: non-UUID video_id in path → 422. Empty PUT body (no fields) → no changes, no audit entries.
- **Error paths**: unauthenticated → 401, no creator_id → 403, wrong creator → 403, nonexistent video → 404
- **Boundary conditions**: first PUT for a video creates VideoConsent + audit entries. Repeated PUT with same values → no new audit entries.
## Inputs
- `backend/models.py` — VideoConsent, ConsentAuditLog, ConsentField models from T01
- `backend/schemas.py` — existing schema patterns
- `backend/routers/auth.py` — auth router pattern to follow
- `backend/auth.py` — get_current_user, require_role dependencies
- `backend/main.py` — router registration pattern
## Expected Output
- `backend/schemas.py` — updated with consent schemas
- `backend/routers/consent.py` — new consent router
- `backend/main.py` — updated with consent router registration
- Estimate: 45m
- Files: backend/schemas.py, backend/routers/consent.py, backend/main.py
- Verify: cd backend && python -c "from routers.consent import router; print(f'{len(router.routes)} routes OK')"
- [ ] **T03: Add integration tests for all consent endpoints** — Write comprehensive integration tests covering all consent endpoints, auth/authz edge cases, and audit trail correctness.
## Steps
1. Read `backend/tests/conftest.py` for existing fixture patterns (`client`, `auth_headers`, `registered_user`, `db_engine`).
2. Add new fixtures to `backend/tests/conftest.py`:
- `creator_with_videos`: creates a Creator + 2 SourceVideo records in the test DB, returns dict with `creator_id`, `video_ids`
- `creator_user_auth`: registers a user linked to that creator (set `creator_id` on User after registration), returns auth headers
- `admin_auth`: registers an admin-role user, returns auth headers
3. Create `backend/tests/test_consent.py` with these test cases:
- **Auth tests**: unauthenticated GET/PUT → 401. User without creator_id → 403 on all consent endpoints.
- **Ownership tests**: Creator A can't read/update Creator B's video consent. Returns 403.
- **Happy path — PUT**: First PUT creates VideoConsent + audit entries. Response has correct flags.
- **Happy path — GET list**: Returns creator's videos with consent status. Pagination works.
- **Happy path — GET single**: Returns consent for specific video.
- **Audit trail**: PUT changing 2 fields creates 2 audit entries with correct version numbers. GET history returns them ordered.
- **Idempotency**: PUT with same values as current state creates no new audit entries.
- **Partial update**: PUT with only `kb_inclusion=True` changes only that field, leaves others unchanged.
- **Admin access**: Admin can read any creator's consent via GET endpoints. Admin summary returns correct counts.
- **Nonexistent video**: GET/PUT for random UUID → 404.
4. Run the full test suite to confirm no regressions.
## Must-Haves
- [ ] Tests for 401 (no auth), 403 (no creator_id), 403 (wrong creator), 404 (no video)
- [ ] Tests for PUT creating audit entries with incrementing versions
- [ ] Test for idempotent PUT (no audit entries when values unchanged)
- [ ] Test for admin bypass on read endpoints
- [ ] Test for GET list pagination
- [ ] All existing tests still pass
## Verification
- `cd backend && python -m pytest tests/test_consent.py -v` — all consent tests pass
- `cd backend && python -m pytest tests/ -v --timeout=60` — full suite passes, no regressions
## Inputs
- `backend/tests/conftest.py` — existing test fixtures
- `backend/routers/consent.py` — consent router from T02
- `backend/schemas.py` — consent schemas from T02
- `backend/models.py` — consent models from T01
## Expected Output
- `backend/tests/conftest.py` — updated with creator_with_videos, creator_user_auth, admin_auth fixtures
- `backend/tests/test_consent.py` — new test file with comprehensive consent endpoint tests
- Estimate: 45m
- Files: backend/tests/test_consent.py, backend/tests/conftest.py
- Verify: cd backend && python -m pytest tests/test_consent.py -v && python -m pytest tests/ -v --timeout=60

View file

@ -0,0 +1,165 @@
# S03 Research: Consent Data Model + API Endpoints
## Summary
Straightforward data modeling and CRUD slice. The consent system tracks per-video consent toggles with a versioned audit trail. The codebase has well-established patterns for models, schemas, routers, auth dependencies, Alembic migrations, and integration tests. No unfamiliar technology.
**Target requirement:** No explicit R-number for consent yet — this slice establishes the consent infrastructure that Pipeline A depends on. The roadmap "After this" is: "API accepts per-video consent toggles with versioned audit trail."
## Recommendation
Follow existing patterns exactly. Two models: a mutable `VideoConsent` (current state per video) and an append-only `ConsentAuditLog` (versioned history). One new router at `/api/v1/consent`. Auth via existing `get_current_user` dependency with creator_id ownership checks.
## Implementation Landscape
### Existing Patterns to Follow
**Models** (`backend/models.py`):
- UUID PKs via `_uuid_pk()`, naive UTC timestamps via `_now()`
- Enums as `str, enum.Enum` with PostgreSQL `Enum(...)` columns
- `sa_relationship` alias (KNOWLEDGE: SQLAlchemy column names that shadow ORM functions)
- All models inherit from `Base` (imported from `database`)
**Schemas** (`backend/schemas.py`):
- Pydantic v2 with `model_config = ConfigDict(from_attributes=True)` for read models
- Request models are plain `BaseModel`, response models use `from_attributes`
**Routers** (`backend/routers/auth.py` is the template):
- `APIRouter(prefix="/consent", tags=["consent"])`
- `Annotated[AsyncSession, Depends(get_session)]` for DB
- `Annotated[User, Depends(get_current_user)]` for auth
- Registered in `backend/main.py` via `app.include_router(consent.router, prefix="/api/v1")`
**Auth** (`backend/auth.py`):
- `get_current_user` returns `User` ORM object with `creator_id` attribute
- `require_role(UserRole.admin)` for admin-only endpoints
- JWT via `OAuth2PasswordBearer`
**Migrations** (`alembic/versions/`):
- Sequential numbering: latest is `016_add_users_and_invite_codes.py`
- Next migration: `017_add_consent_tables.py`
- Run via `docker exec chrysopedia-api alembic upgrade head`
**Tests** (`backend/tests/`):
- pytest-asyncio with `@pytest.mark.asyncio`
- `client` fixture provides httpx `AsyncClient` wired to FastAPI
- `auth_headers` fixture provides `{"Authorization": "Bearer <jwt>"}` for a registered creator user
- `db_engine` fixture creates/drops all tables per test
### Data Model Design
**`VideoConsent` table** (mutable, current state):
```
video_consents
├── id: UUID PK
├── source_video_id: UUID FK → source_videos.id (unique — one consent record per video)
├── creator_id: UUID FK → creators.id (denormalized for fast queries)
├── kb_inclusion: bool (default false — consent to include in knowledge base)
├── training_usage: bool (default false — consent for AI training use)
├── public_display: bool (default true — consent for public display of derived content)
├── updated_by: UUID FK → users.id
├── created_at: datetime
├── updated_at: datetime
```
**`ConsentAuditLog` table** (append-only, versioned history):
```
consent_audit_log
├── id: UUID PK
├── video_consent_id: UUID FK → video_consents.id
├── version: int (auto-incrementing per video_consent_id)
├── field_name: str (which consent field changed)
├── old_value: bool | None
├── new_value: bool
├── changed_by: UUID FK → users.id
├── ip_address: str | None (optional, for legal audit)
├── created_at: datetime
```
**Design rationale:**
- Separate mutable state from audit trail — querying current consent is a simple SELECT on `video_consents` without scanning history
- `creator_id` denormalized on `video_consents` avoids a JOIN through `source_videos` for listing a creator's consent status
- Unique constraint on `source_video_id` enforces one consent record per video
- Audit log captures per-field changes, not full snapshots — more precise for legal review
- `version` column on audit log enables ordered reconstruction of consent history
### API Endpoints
All under `/api/v1/consent`, all require authentication:
1. **`GET /consent/videos`** — List consent status for the authenticated creator's videos
- Query: `offset`, `limit`
- Auth: `get_current_user`, filter by `user.creator_id`
- Response: paginated list of `VideoConsentRead` (video ID, filename, consent flags)
2. **`GET /consent/videos/{video_id}`** — Get consent status for a specific video
- Auth: ownership check (video's creator_id == user.creator_id)
- Response: `VideoConsentRead` with audit log entries
3. **`PUT /consent/videos/{video_id}`** — Update consent flags for a video
- Body: `VideoConsentUpdate` (partial — only changed fields)
- Auth: ownership check
- Creates `VideoConsent` if not exists (upsert), creates `ConsentAuditLog` entries for each changed field
- Response: `VideoConsentRead`
4. **`GET /consent/videos/{video_id}/history`** — Get full audit trail for a video
- Auth: ownership check
- Response: list of `ConsentAuditEntry` ordered by version
5. **(Admin) `GET /consent/admin/summary`** — Aggregate consent stats
- Auth: `require_role(UserRole.admin)`
- Response: counts of videos with/without consent per flag
### Authorization Pattern
The creator ownership check is a reusable pattern:
```python
async def _verify_video_ownership(video_id: UUID, user: User, session: AsyncSession) -> SourceVideo:
"""Load video and verify the current user owns it via creator_id."""
video = await session.get(SourceVideo, video_id)
if video is None:
raise HTTPException(404, "Video not found")
if user.creator_id is None or video.creator_id != user.creator_id:
raise HTTPException(403, "Not your video")
return video
```
Admin users (role=admin) should bypass the ownership check — they may need to view/manage consent for any video.
### Natural Task Seams
1. **T01: Models + Migration** — Add `VideoConsent` and `ConsentAuditLog` to `models.py`, create Alembic migration `017_add_consent_tables.py`. Verify migration applies cleanly.
2. **T02: Schemas + Router** — Add Pydantic schemas to `schemas.py`, create `backend/routers/consent.py` with all endpoints, register in `main.py`. Includes the ownership verification helper.
3. **T03: Integration Tests** — Add `backend/tests/test_consent.py` with tests for all endpoints including auth, ownership checks, audit trail creation, and edge cases.
### What to Build First
T01 (models + migration) is the foundation — T02 and T03 depend on it. T02 and T03 could theoretically be parallel but the tests need the router, so T01 → T02 → T03 is the natural order.
### Verification
- `alembic upgrade head` succeeds with new migration
- All consent endpoints return correct status codes with/without auth
- PUT creates audit log entries for each changed field
- Ownership check prevents cross-creator access
- Admin role bypasses ownership for read endpoints
- Existing tests still pass (no regression)
### Files Touched
- `backend/models.py` — add VideoConsent, ConsentAuditLog, ConsentField enum
- `backend/schemas.py` — add consent request/response schemas
- `backend/routers/consent.py` — new file, consent endpoints
- `backend/main.py` — register consent router
- `alembic/versions/017_add_consent_tables.py` — new migration
- `backend/tests/test_consent.py` — new test file
- `backend/tests/conftest.py` — add creator+video fixture for consent tests
### Constraints
- KNOWLEDGE: asyncpg rejects timezone-aware datetimes — use `_now()` helper for all timestamp defaults
- KNOWLEDGE: Alembic env.py sys.path needs both local and Docker paths — already handled
- User.creator_id is nullable — endpoints must guard against `user.creator_id is None` (user not linked to a creator)
- No frontend work in this slice — API only

View file

@ -0,0 +1,77 @@
---
estimated_steps: 43
estimated_files: 2
skills_used: []
---
# T01: Add VideoConsent + ConsentAuditLog models and Alembic migration
Add the consent data model to the codebase: a mutable `VideoConsent` table (current consent state per video) and an append-only `ConsentAuditLog` table (versioned per-field change history). Create Alembic migration `017_add_consent_tables.py`.
## Steps
1. Read `backend/models.py` to see existing patterns (`_uuid_pk()`, `_now()`, `sa_relationship`, enum patterns).
2. Add `ConsentField` enum to `backend/models.py`: `kb_inclusion`, `training_usage`, `public_display`.
3. Add `VideoConsent` model:
- UUID PK via `_uuid_pk()`
- `source_video_id`: UUID FK → `source_videos.id`, unique constraint (one consent record per video)
- `creator_id`: UUID FK → `creators.id` (denormalized for fast queries)
- `kb_inclusion`: bool, default False
- `training_usage`: bool, default False
- `public_display`: bool, default True
- `updated_by`: UUID FK → `users.id`
- `created_at`, `updated_at`: datetime via `_now()`
- Relationships: `source_video`, `creator`, `audit_entries`
4. Add `ConsentAuditLog` model:
- UUID PK via `_uuid_pk()`
- `video_consent_id`: UUID FK → `video_consents.id`
- `version`: Integer (auto-incrementing per video_consent_id, assigned in application code)
- `field_name`: String (which consent field changed)
- `old_value`: Boolean, nullable
- `new_value`: Boolean
- `changed_by`: UUID FK → `users.id`
- `ip_address`: String(45), nullable
- `created_at`: datetime via `_now()`
5. Create `alembic/versions/017_add_consent_tables.py` — follow the pattern from `016_add_users_and_invite_codes.py`. Create both tables with proper FKs and the unique constraint on `source_video_id`.
6. Verify: import the models in a Python shell and confirm no import errors. Check that the migration file has valid `upgrade()` and `downgrade()` functions.
## Must-Haves
- [ ] `VideoConsent` model with unique constraint on `source_video_id`
- [ ] `ConsentAuditLog` model with `version` column
- [ ] `ConsentField` enum for field name validation
- [ ] Alembic migration `017` with upgrade and downgrade
- [ ] All timestamps use `_now()` helper (KNOWLEDGE: asyncpg rejects timezone-aware datetimes)
## Verification
- `cd backend && python -c "from models import VideoConsent, ConsentAuditLog, ConsentField; print('OK')"` exits 0
- `python -c "import importlib.util; spec = importlib.util.spec_from_file_location('m', 'alembic/versions/017_add_consent_tables.py'); mod = importlib.util.module_from_spec(spec)"` — migration file is valid Python
## Negative Tests
- **Boundary conditions**: unique constraint on `source_video_id` prevents duplicate consent records per video
## Inputs
- `backend/models.py` — existing model patterns to follow
- `alembic/versions/016_add_users_and_invite_codes.py` — migration pattern to follow
## Expected Output
- `backend/models.py` — updated with VideoConsent, ConsentAuditLog, ConsentField
- `alembic/versions/017_add_consent_tables.py` — new migration file
## Inputs
- `backend/models.py`
- `alembic/versions/016_add_users_and_invite_codes.py`
## Expected Output
- `backend/models.py`
- `alembic/versions/017_add_consent_tables.py`
## Verification
cd backend && python -c "from models import VideoConsent, ConsentAuditLog, ConsentField; print('OK')"

View file

@ -0,0 +1,78 @@
---
id: T01
parent: S03
milestone: M019
provides: []
requires: []
affects: []
key_files: ["backend/models.py", "alembic/versions/017_add_consent_tables.py"]
key_decisions: ["Used RESTRICT on updated_by/changed_by FK to users to prevent orphaning consent audit history", "ConsentField enum in models.py for application-level validation, not as DB column type"]
patterns_established: []
drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: "Both verification checks pass: model imports succeed and migration file is valid Python."
completed_at: 2026-04-03T22:09:25.049Z
blocker_discovered: false
---
# T01: Added VideoConsent and ConsentAuditLog models with ConsentField enum and Alembic migration 017
> Added VideoConsent and ConsentAuditLog models with ConsentField enum and Alembic migration 017
## What Happened
---
id: T01
parent: S03
milestone: M019
key_files:
- backend/models.py
- alembic/versions/017_add_consent_tables.py
key_decisions:
- Used RESTRICT on updated_by/changed_by FK to users to prevent orphaning consent audit history
- ConsentField enum in models.py for application-level validation, not as DB column type
duration: ""
verification_result: passed
completed_at: 2026-04-03T22:09:25.049Z
blocker_discovered: false
---
# T01: Added VideoConsent and ConsentAuditLog models with ConsentField enum and Alembic migration 017
**Added VideoConsent and ConsentAuditLog models with ConsentField enum and Alembic migration 017**
## What Happened
Added ConsentField enum, VideoConsent model (mutable current consent state per video with unique constraint on source_video_id), and ConsentAuditLog model (append-only versioned change log) to backend/models.py. Created Alembic migration 017_add_consent_tables.py with both tables, FKs, unique constraint, and index. Used RESTRICT on user FKs to prevent orphaning audit records.
## Verification
Both verification checks pass: model imports succeed and migration file is valid Python.
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `cd backend && python -c "from models import VideoConsent, ConsentAuditLog, ConsentField; print('OK')"` | 0 | ✅ pass | 500ms |
| 2 | `python -c "import importlib.util; spec = importlib.util.spec_from_file_location('m', 'alembic/versions/017_add_consent_tables.py'); mod = importlib.util.module_from_spec(spec)"` | 0 | ✅ pass | 200ms |
## Deviations
None.
## Known Issues
None.
## Files Created/Modified
- `backend/models.py`
- `alembic/versions/017_add_consent_tables.py`
## Deviations
None.
## Known Issues
None.

View file

@ -0,0 +1,86 @@
---
estimated_steps: 48
estimated_files: 3
skills_used: []
---
# 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.
## Steps
1. Read `backend/schemas.py` to see existing Pydantic v2 patterns (`ConfigDict(from_attributes=True)`, request vs response models).
2. Add consent schemas to `backend/schemas.py`:
- `VideoConsentUpdate(BaseModel)`: `kb_inclusion: bool | None = None`, `training_usage: bool | None = None`, `public_display: bool | None = None` (partial update — only non-None fields are changed)
- `VideoConsentRead(BaseModel)`: all consent fields + `source_video_id`, `video_filename`, `creator_id`, `updated_at`, `model_config = ConfigDict(from_attributes=True)`
- `ConsentAuditEntry(BaseModel)`: `version`, `field_name`, `old_value`, `new_value`, `changed_by`, `created_at`, `model_config = ConfigDict(from_attributes=True)`
- `ConsentListResponse(BaseModel)`: `items: list[VideoConsentRead]`, `total: int`
- `ConsentSummary(BaseModel)`: per-flag counts
3. Create `backend/routers/consent.py`:
- `APIRouter(prefix="/consent", tags=["consent"])`
- Helper `_verify_video_ownership(video_id, user, session)` — loads SourceVideo, checks `user.creator_id` matches `video.creator_id`. Raises 403 if user has no `creator_id` or doesn't own video. Admin role (checked via `user.role == UserRole.admin`) bypasses the ownership check.
- `GET /consent/videos` — list consent for current creator's videos, paginated (offset/limit). Join with SourceVideo to get filename.
- `GET /consent/videos/{video_id}` — single video consent status
- `PUT /consent/videos/{video_id}` — upsert consent: create `VideoConsent` if not exists, update changed fields, create `ConsentAuditLog` entry for each changed field with incrementing version number. Use `select ... for update` or compute max version from existing audit entries.
- `GET /consent/videos/{video_id}/history` — audit trail ordered by version
- `GET /consent/admin/summary` — admin-only aggregate stats, guarded by `require_role(UserRole.admin)`
- Add `chrysopedia.consent` logger, log on PUT: `logger.info("Consent updated", extra={"video_id": ..., "fields_changed": ...})`
4. Register router in `backend/main.py`: `from routers import consent` and `app.include_router(consent.router, prefix="/api/v1")`
5. Verify: start the app and confirm `/docs` shows the consent endpoints (or just verify the import chain works).
## Must-Haves
- [ ] Partial update schema (only non-None fields trigger changes)
- [ ] Ownership verification helper with admin bypass
- [ ] PUT creates audit log entries per changed field with incrementing versions
- [ ] GET /consent/videos returns video filename alongside consent flags
- [ ] All endpoints require authentication via `get_current_user`
- [ ] Users without `creator_id` get 403 (not 500)
- [ ] Admin summary endpoint requires admin role
- [ ] Router registered in main.py
## Failure Modes
| Dependency | On error | On timeout | On malformed response |
|------------|----------|-----------|----------------------|
| PostgreSQL | SQLAlchemy raises, FastAPI returns 500 | Connection pool timeout → 500 | N/A (ORM handles) |
| Auth (JWT) | 401 Unauthorized | N/A (local decode) | 401 (invalid token) |
## Negative Tests
- **Malformed inputs**: non-UUID video_id in path → 422. Empty PUT body (no fields) → no changes, no audit entries.
- **Error paths**: unauthenticated → 401, no creator_id → 403, wrong creator → 403, nonexistent video → 404
- **Boundary conditions**: first PUT for a video creates VideoConsent + audit entries. Repeated PUT with same values → no new audit entries.
## Inputs
- `backend/models.py` — VideoConsent, ConsentAuditLog, ConsentField models from T01
- `backend/schemas.py` — existing schema patterns
- `backend/routers/auth.py` — auth router pattern to follow
- `backend/auth.py` — get_current_user, require_role dependencies
- `backend/main.py` — router registration pattern
## Expected Output
- `backend/schemas.py` — updated with consent schemas
- `backend/routers/consent.py` — new consent router
- `backend/main.py` — updated with consent router registration
## Inputs
- `backend/models.py`
- `backend/schemas.py`
- `backend/routers/auth.py`
- `backend/auth.py`
- `backend/main.py`
## Expected Output
- `backend/schemas.py`
- `backend/routers/consent.py`
- `backend/main.py`
## Verification
cd backend && python -c "from routers.consent import router; print(f'{len(router.routes)} routes OK')"

View file

@ -0,0 +1,71 @@
---
estimated_steps: 37
estimated_files: 2
skills_used: []
---
# T03: Add integration tests for all consent endpoints
Write comprehensive integration tests covering all consent endpoints, auth/authz edge cases, and audit trail correctness.
## Steps
1. Read `backend/tests/conftest.py` for existing fixture patterns (`client`, `auth_headers`, `registered_user`, `db_engine`).
2. Add new fixtures to `backend/tests/conftest.py`:
- `creator_with_videos`: creates a Creator + 2 SourceVideo records in the test DB, returns dict with `creator_id`, `video_ids`
- `creator_user_auth`: registers a user linked to that creator (set `creator_id` on User after registration), returns auth headers
- `admin_auth`: registers an admin-role user, returns auth headers
3. Create `backend/tests/test_consent.py` with these test cases:
- **Auth tests**: unauthenticated GET/PUT → 401. User without creator_id → 403 on all consent endpoints.
- **Ownership tests**: Creator A can't read/update Creator B's video consent. Returns 403.
- **Happy path — PUT**: First PUT creates VideoConsent + audit entries. Response has correct flags.
- **Happy path — GET list**: Returns creator's videos with consent status. Pagination works.
- **Happy path — GET single**: Returns consent for specific video.
- **Audit trail**: PUT changing 2 fields creates 2 audit entries with correct version numbers. GET history returns them ordered.
- **Idempotency**: PUT with same values as current state creates no new audit entries.
- **Partial update**: PUT with only `kb_inclusion=True` changes only that field, leaves others unchanged.
- **Admin access**: Admin can read any creator's consent via GET endpoints. Admin summary returns correct counts.
- **Nonexistent video**: GET/PUT for random UUID → 404.
4. Run the full test suite to confirm no regressions.
## Must-Haves
- [ ] Tests for 401 (no auth), 403 (no creator_id), 403 (wrong creator), 404 (no video)
- [ ] Tests for PUT creating audit entries with incrementing versions
- [ ] Test for idempotent PUT (no audit entries when values unchanged)
- [ ] Test for admin bypass on read endpoints
- [ ] Test for GET list pagination
- [ ] All existing tests still pass
## Verification
- `cd backend && python -m pytest tests/test_consent.py -v` — all consent tests pass
- `cd backend && python -m pytest tests/ -v --timeout=60` — full suite passes, no regressions
## Inputs
- `backend/tests/conftest.py` — existing test fixtures
- `backend/routers/consent.py` — consent router from T02
- `backend/schemas.py` — consent schemas from T02
- `backend/models.py` — consent models from T01
## Expected Output
- `backend/tests/conftest.py` — updated with creator_with_videos, creator_user_auth, admin_auth fixtures
- `backend/tests/test_consent.py` — new test file with comprehensive consent endpoint tests
## Inputs
- `backend/tests/conftest.py`
- `backend/routers/consent.py`
- `backend/schemas.py`
- `backend/models.py`
## Expected Output
- `backend/tests/test_consent.py`
- `backend/tests/conftest.py`
## Verification
cd backend && python -m pytest tests/test_consent.py -v && python -m pytest tests/ -v --timeout=60

View file

@ -0,0 +1,51 @@
"""Add video_consents and consent_audit_log tables for per-video consent management.
Revision ID: 017_add_consent_tables
Revises: 016_add_users_and_invite_codes
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
revision = "017_add_consent_tables"
down_revision = "016_add_users_and_invite_codes"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Create video_consents table
op.create_table(
"video_consents",
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.func.gen_random_uuid()),
sa.Column("source_video_id", UUID(as_uuid=True), sa.ForeignKey("source_videos.id", ondelete="CASCADE"), nullable=False),
sa.Column("creator_id", UUID(as_uuid=True), sa.ForeignKey("creators.id", ondelete="CASCADE"), nullable=False),
sa.Column("kb_inclusion", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("training_usage", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("public_display", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("updated_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("source_video_id", name="uq_video_consent_video"),
)
# Create consent_audit_log table
op.create_table(
"consent_audit_log",
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.func.gen_random_uuid()),
sa.Column("video_consent_id", UUID(as_uuid=True), sa.ForeignKey("video_consents.id", ondelete="CASCADE"), nullable=False),
sa.Column("version", sa.Integer(), nullable=False),
sa.Column("field_name", sa.String(50), nullable=False),
sa.Column("old_value", sa.Boolean(), nullable=True),
sa.Column("new_value", sa.Boolean(), nullable=False),
sa.Column("changed_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False),
sa.Column("ip_address", sa.String(45), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
)
op.create_index("ix_consent_audit_log_video_consent_id", "consent_audit_log", ["video_consent_id"])
def downgrade() -> None:
op.drop_index("ix_consent_audit_log_video_consent_id", table_name="consent_audit_log")
op.drop_table("consent_audit_log")
op.drop_table("video_consents")

View file

@ -566,3 +566,91 @@ class PipelineEvent(Base):
run: Mapped[PipelineRun | None] = sa_relationship(
back_populates="events", foreign_keys=[run_id]
)
# ── Consent Enums ────────────────────────────────────────────────────────────
class ConsentField(str, enum.Enum):
"""Fields that can be individually consented to per video."""
kb_inclusion = "kb_inclusion"
training_usage = "training_usage"
public_display = "public_display"
# ── Consent Models ───────────────────────────────────────────────────────────
class VideoConsent(Base):
"""Current consent state for a source video.
One row per video. Mutable updated when a creator toggles consent.
The full change history lives in ConsentAuditLog.
"""
__tablename__ = "video_consents"
__table_args__ = (
UniqueConstraint("source_video_id", name="uq_video_consent_video"),
)
id: Mapped[uuid.UUID] = _uuid_pk()
source_video_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("source_videos.id", ondelete="CASCADE"), nullable=False,
)
creator_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("creators.id", ondelete="CASCADE"), nullable=False,
)
kb_inclusion: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false",
)
training_usage: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false",
)
public_display: Mapped[bool] = mapped_column(
Boolean, default=True, server_default="true",
)
updated_by: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="RESTRICT"), nullable=False,
)
created_at: Mapped[datetime] = mapped_column(
default=_now, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
default=_now, server_default=func.now(), onupdate=_now
)
# relationships
source_video: Mapped[SourceVideo] = sa_relationship()
creator: Mapped[Creator] = sa_relationship()
audit_entries: Mapped[list[ConsentAuditLog]] = sa_relationship(
back_populates="video_consent", order_by="ConsentAuditLog.version"
)
class ConsentAuditLog(Base):
"""Append-only versioned record of per-field consent changes.
Each row captures a single field change. Version is auto-assigned
in application code (max(version) + 1 per video_consent_id).
"""
__tablename__ = "consent_audit_log"
id: Mapped[uuid.UUID] = _uuid_pk()
video_consent_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("video_consents.id", ondelete="CASCADE"), nullable=False, index=True,
)
version: Mapped[int] = mapped_column(Integer, nullable=False)
field_name: Mapped[str] = mapped_column(
String(50), nullable=False, doc="ConsentField value: kb_inclusion, training_usage, public_display"
)
old_value: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
new_value: Mapped[bool] = mapped_column(Boolean, nullable=False)
changed_by: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="RESTRICT"), nullable=False,
)
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)
created_at: Mapped[datetime] = mapped_column(
default=_now, server_default=func.now()
)
# relationships
video_consent: Mapped[VideoConsent] = sa_relationship(
back_populates="audit_entries"
)