mirror of
https://git.victorphan.net/basketballcantho/ten-project.git
synced 2026-08-05 14:13:12 +07:00
307 lines
10 KiB
Python
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)
|
|
|