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)