mirror of
https://git.victorphan.net/basketballcantho/ten-project.git
synced 2026-08-05 09:53:10 +07:00
214 lines
6.8 KiB
Python
214 lines
6.8 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
|
|
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
|