- 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>
174 lines
5.8 KiB
Python
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
|