Files

307 lines
10 KiB
Python

from datetime import datetime, timezone
import json
from fastapi import APIRouter, Depends, HTTPException, status as http_status
from sqlalchemy.orm import Session
from ..database import get_db
from ..dependencies import get_current_user
from ..models import Annotation, PDF, User, UserRole
from ..redis_client import ANN_TTL, ann_temp_key, ann_temp_page_pattern, get_redis
from ..schemas import AnnotationIn, AnnotationOut
def _do_delete_annotation(ann: Annotation, current_user: User, db: Session) -> None:
"""Shared RBAC + delete logic, used by both delete endpoints."""
from ..models import UserRole # avoid circular at module level
ann_owner = db.get(User, ann.user_id)
if current_user.role == UserRole.admin:
pass # full access
elif current_user.role == UserRole.teacher:
if ann.user_id == current_user.id:
pass # own annotation
elif ann_owner and ann_owner.role == UserRole.admin:
raise HTTPException(
status_code=http_status.HTTP_403_FORBIDDEN,
detail="Teachers cannot delete annotations belonging to an admin.",
)
else:
pass # can delete student annotations (or annotations of deleted users)
else: # student
if ann.user_id != current_user.id:
raise HTTPException(
status_code=http_status.HTTP_403_FORBIDDEN,
detail="You can only delete your own annotations.",
)
pdf_id = ann.pdf_id
page_number = ann.page_number
user_id = ann.user_id
db.delete(ann)
db.commit()
# Remove Redis temp entry so /all doesn't serve stale data
r = get_redis()
if r is not None:
try:
r.delete(ann_temp_key(pdf_id, page_number, user_id))
except Exception:
pass
router = APIRouter(prefix="/annotations", tags=["annotations"])
def _verify_pdf_ownership(db: Session, pdf_id: int, user_id: int) -> PDF:
pdf = db.get(PDF, pdf_id)
if not pdf or pdf.user_id != user_id:
raise HTTPException(status_code=404, detail="PDF not found.")
return pdf
def _any_pdf_or_404(db: Session, pdf_id: int) -> PDF:
"""Return PDF regardless of owner — any authenticated user may read annotations."""
pdf = db.get(PDF, pdf_id)
if not pdf:
raise HTTPException(status_code=404, detail="PDF not found.")
return pdf
# ── GET /api/annotations/{pdf_id}/{page_number} ───────────────────────────────
@router.get("/{pdf_id}/{page_number}", response_model=AnnotationOut)
def get_annotation(
pdf_id: int,
page_number: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
_any_pdf_or_404(db, pdf_id)
ann = (
db.query(Annotation)
.filter(
Annotation.pdf_id == pdf_id,
Annotation.user_id == current_user.id,
Annotation.page_number == page_number,
)
.first()
)
# Return empty canvas if no annotation exists yet — not an error
if ann is None:
return AnnotationOut(
id=0,
pdf_id=pdf_id,
page_number=page_number,
canvas_data={},
updated_at=datetime.now(timezone.utc),
)
return ann
# ── GET /api/annotations/{pdf_id}/{page_number}/all ───────────────────────────
# Returns a merged canvas_data whose "objects" array contains annotations from
# ALL users for the given page. Any authenticated user may call this.
@router.get("/{pdf_id}/{page_number}/all", response_model=AnnotationOut)
def get_all_annotations(
pdf_id: int,
page_number: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
_any_pdf_or_404(db, pdf_id)
rows = (
db.query(Annotation)
.filter(
Annotation.pdf_id == pdf_id,
Annotation.page_number == page_number,
)
.all()
)
# Build user_id → canvas_data from DB (permanent saves)
# Key = pdf_id + page_number + user_id → completely isolated per file/page/user
user_data: dict[int, dict] = {}
for row in rows:
user_data[row.user_id] = dict(row.canvas_data) if row.canvas_data else {}
# Overlay with Redis temp data — unsaved strokes written by path:created / clear.
# If a user has BOTH a DB record and a temp entry, Redis wins (more recent).
r = get_redis()
if r is not None:
try:
pattern = ann_temp_page_pattern(pdf_id, page_number)
temp_keys: list[str] = r.keys(pattern)
for key in temp_keys:
# key format: ann:{pdf_id}:{page_number}:{user_id}
try:
uid = int(key.split(":")[-1])
except ValueError:
continue
raw = r.get(key)
if raw:
try:
user_data[uid] = json.loads(raw)
except Exception:
pass
except Exception:
pass # Redis hiccup — fall through with DB data only
if not user_data:
return AnnotationOut(
id=0,
pdf_id=pdf_id,
page_number=page_number,
canvas_data={},
updated_at=datetime.now(timezone.utc),
)
# Canvas-level metadata (version, background…) comes from the latest DB record
latest = max(rows, key=lambda row: row.updated_at) if rows else None
base: dict = dict(latest.canvas_data) if latest and latest.canvas_data else {}
merged_objects: list = []
for uid, canvas in user_data.items():
for obj in (canvas or {}).get("objects", []):
obj_copy = dict(obj)
obj_copy["_owner_id"] = uid
merged_objects.append(obj_copy)
base["objects"] = merged_objects
return AnnotationOut(
id=latest.id if latest else 0,
pdf_id=pdf_id,
page_number=page_number,
canvas_data=base,
updated_at=latest.updated_at if latest else datetime.now(timezone.utc),
)
# ── PUT /api/annotations/{pdf_id}/{page_number}/temp ──────────────────────────
# Writes unsaved canvas state to Redis so every subsequent /all call includes it.
# Called automatically (debounced) by the frontend after each stroke.
@router.put("/{pdf_id}/{page_number}/temp", status_code=http_status.HTTP_204_NO_CONTENT)
def upsert_temp_annotation(
pdf_id: int,
page_number: int,
body: AnnotationIn,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
_any_pdf_or_404(db, pdf_id)
r = get_redis()
if r is None:
return # Redis unavailable — silently skip
key = ann_temp_key(pdf_id, page_number, current_user.id)
try:
r.setex(key, ANN_TTL, json.dumps(body.canvas_data))
except Exception:
pass # non-critical
# ── PUT /api/annotations/{pdf_id}/{page_number} ───────────────────────────────
@router.put("/{pdf_id}/{page_number}", response_model=AnnotationOut)
def upsert_annotation(
pdf_id: int,
page_number: int,
body: AnnotationIn,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
_any_pdf_or_404(db, pdf_id)
ann = (
db.query(Annotation)
.filter(
Annotation.pdf_id == pdf_id,
Annotation.user_id == current_user.id,
Annotation.page_number == page_number,
)
.first()
)
if ann is None:
ann = Annotation(
pdf_id=pdf_id,
user_id=current_user.id,
page_number=page_number,
canvas_data=body.canvas_data,
)
db.add(ann)
else:
ann.canvas_data = body.canvas_data
ann.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(ann)
# Data is now in DB — remove the Redis temp entry so /all doesn't double-count
r = get_redis()
if r is not None:
try:
r.delete(ann_temp_key(pdf_id, page_number, current_user.id))
except Exception:
pass
return ann
# ── DELETE /api/annotations/{annotation_id} ───────────────────────────────────
@router.delete("/{annotation_id}", status_code=http_status.HTTP_204_NO_CONTENT)
def delete_annotation(
annotation_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Delete annotation by primary key (RBAC enforced)."""
ann = db.get(Annotation, annotation_id)
if not ann:
raise HTTPException(status_code=404, detail="Annotation not found.")
_do_delete_annotation(ann, current_user, db)
# ── DELETE /api/annotations/{pdf_id}/{page_number}/user/{target_user_id} ──────
@router.delete("/{pdf_id}/{page_number}/user/{target_user_id}", status_code=http_status.HTTP_204_NO_CONTENT)
def delete_user_page_annotation(
pdf_id: int,
page_number: int,
target_user_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""
Delete a specific user's annotation on a given page (RBAC enforced).
Useful for teachers clearing a student's page without knowing the annotation id.
Returns 204 even when no annotation exists (idempotent).
"""
_any_pdf_or_404(db, pdf_id)
ann = (
db.query(Annotation)
.filter(
Annotation.pdf_id == pdf_id,
Annotation.page_number == page_number,
Annotation.user_id == target_user_id,
)
.first()
)
if ann is None:
# Nothing in DB — still clean up any Redis temp entry
r = get_redis()
if r is not None:
try:
r.delete(ann_temp_key(pdf_id, page_number, target_user_id))
except Exception:
pass
return
_do_delete_annotation(ann, current_user, db)