video-accessibility/backend/app/api/v1/routes_review_notes.py
Vadym Samoilenko cf761c4bb6 feat: add linguist role and user management navigation
- Add LINGUIST role to UserRole enum (backend + frontend)
- Grant linguists access to QC Review, Final Review, review notes, and VTT editing
- Add MongoDB migration to update schema validator with linguist role
- Add admin seed: vadymsamoilenko@oliver.agency is promoted to admin on startup
- Add User Management sidebar link for admin users
- Fix Login.tsx role type cast to use UserRole instead of hardcoded union

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 11:46:33 +01:00

174 lines
5.8 KiB
Python

"""API routes for review notes - timestamped notes on video assets during review."""
from datetime import datetime
from typing import Optional
from bson import ObjectId
from fastapi import APIRouter, Depends, HTTPException, Query, status
from motor.motor_asyncio import AsyncIOMotorDatabase
from ...core.database import get_database
from ...core.dependencies import get_current_user, require_roles
from ...core.logging import get_logger
from ...models.user import User, UserRole
from ...schemas.review_note import (
ReviewNoteCreateRequest,
ReviewNoteResponse,
ReviewNotesListResponse,
ReviewNoteUpdateRequest,
)
logger = get_logger(__name__)
router = APIRouter(prefix="/jobs/{job_id}/review-notes", tags=["review-notes"])
@router.get("", response_model=ReviewNotesListResponse)
async def list_review_notes(
job_id: str,
asset_key: Optional[str] = Query(None, description="Filter notes by asset key"),
current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)),
db: AsyncIOMotorDatabase = Depends(get_database),
):
"""List all review notes for a job, optionally filtered by asset key."""
# Verify job exists
job = await db.jobs.find_one({"_id": job_id})
if not job:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Job not found"
)
# Build query
query = {"job_id": job_id}
if asset_key:
query["asset_key"] = asset_key
# Fetch notes sorted by timestamp
cursor = db.review_notes.find(query).sort("timestamp_seconds", 1)
notes = await cursor.to_list(length=1000)
return ReviewNotesListResponse(
notes=[ReviewNoteResponse.from_model(note) for note in notes],
total=len(notes)
)
@router.post("", response_model=ReviewNoteResponse, status_code=status.HTTP_201_CREATED)
async def create_review_note(
job_id: str,
request: ReviewNoteCreateRequest,
current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)),
db: AsyncIOMotorDatabase = Depends(get_database),
):
"""Create a new review note for a video asset."""
# Verify job exists
job = await db.jobs.find_one({"_id": job_id})
if not job:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Job not found"
)
# Create note document
note_id = str(ObjectId())
now = datetime.utcnow()
note_data = {
"_id": note_id,
"job_id": job_id,
"asset_key": request.asset_key,
"timestamp_seconds": request.timestamp_seconds,
"content": request.content,
"user_id": str(current_user.id),
"user_name": current_user.full_name,
"created_at": now,
"updated_at": None,
}
await db.review_notes.insert_one(note_data)
logger.info(f"Created review note {note_id} for job {job_id} at {request.timestamp_seconds}s")
return ReviewNoteResponse.from_model(note_data)
@router.get("/{note_id}", response_model=ReviewNoteResponse)
async def get_review_note(
job_id: str,
note_id: str,
current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)),
db: AsyncIOMotorDatabase = Depends(get_database),
):
"""Get a single review note by ID."""
note = await db.review_notes.find_one({"_id": note_id, "job_id": job_id})
if not note:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Note not found"
)
return ReviewNoteResponse.from_model(note)
@router.patch("/{note_id}", response_model=ReviewNoteResponse)
async def update_review_note(
job_id: str,
note_id: str,
request: ReviewNoteUpdateRequest,
current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)),
db: AsyncIOMotorDatabase = Depends(get_database),
):
"""Update a review note. Only the note owner can update."""
note = await db.review_notes.find_one({"_id": note_id, "job_id": job_id})
if not note:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Note not found"
)
# Check ownership (allow admin to bypass)
if note["user_id"] != str(current_user.id) and current_user.role != UserRole.ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You can only edit your own notes"
)
# Update note
now = datetime.utcnow()
await db.review_notes.update_one(
{"_id": note_id},
{"$set": {"content": request.content, "updated_at": now}}
)
# Fetch updated note
updated_note = await db.review_notes.find_one({"_id": note_id})
logger.info(f"Updated review note {note_id}")
return ReviewNoteResponse.from_model(updated_note)
@router.delete("/{note_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_review_note(
job_id: str,
note_id: str,
current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)),
db: AsyncIOMotorDatabase = Depends(get_database),
):
"""Delete a review note. Only the note owner can delete."""
note = await db.review_notes.find_one({"_id": note_id, "job_id": job_id})
if not note:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Note not found"
)
# Check ownership (allow admin to bypass)
if note["user_id"] != str(current_user.id) and current_user.role != UserRole.ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You can only delete your own notes"
)
await db.review_notes.delete_one({"_id": note_id})
logger.info(f"Deleted review note {note_id}")
return None