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 from ..redis_client import ANN_TTL, ann_temp_key, ann_temp_page_pattern, get_redis from ..schemas import AnnotationIn, AnnotationOut 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