mirror of
https://git.victorphan.net/basketballcantho/ten-project.git
synced 2026-08-05 13:13:11 +07:00
Hoàn thành chức năng collaborative
This commit is contained in:
@@ -18,6 +18,14 @@ def _verify_pdf_ownership(db: Session, pdf_id: int, user_id: int) -> PDF:
|
||||
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)
|
||||
@@ -27,7 +35,7 @@ def get_annotation(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
_verify_pdf_ownership(db, pdf_id, current_user.id)
|
||||
_any_pdf_or_404(db, pdf_id)
|
||||
|
||||
ann = (
|
||||
db.query(Annotation)
|
||||
@@ -52,6 +60,59 @@ def get_annotation(
|
||||
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()
|
||||
)
|
||||
|
||||
if not rows:
|
||||
return AnnotationOut(
|
||||
id=0,
|
||||
pdf_id=pdf_id,
|
||||
page_number=page_number,
|
||||
canvas_data={},
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
# Merge all objects arrays; use canvas metadata (background etc.) from latest record
|
||||
latest = max(rows, key=lambda r: r.updated_at)
|
||||
base: dict = dict(latest.canvas_data) if latest.canvas_data else {}
|
||||
merged_objects: list = []
|
||||
for row in rows:
|
||||
for obj in (row.canvas_data or {}).get("objects", []):
|
||||
# Tag each object with its owner so the frontend can avoid re-saving
|
||||
# other users' objects under the current user's record (prevents duplicates).
|
||||
obj_copy = dict(obj)
|
||||
obj_copy["_owner_id"] = row.user_id
|
||||
merged_objects.append(obj_copy)
|
||||
base["objects"] = merged_objects
|
||||
|
||||
return AnnotationOut(
|
||||
id=latest.id,
|
||||
pdf_id=pdf_id,
|
||||
page_number=page_number,
|
||||
canvas_data=base,
|
||||
updated_at=latest.updated_at,
|
||||
)
|
||||
|
||||
|
||||
# ── PUT /api/annotations/{pdf_id}/{page_number} ───────────────────────────────
|
||||
|
||||
@router.put("/{pdf_id}/{page_number}", response_model=AnnotationOut)
|
||||
@@ -62,7 +123,7 @@ def upsert_annotation(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
_verify_pdf_ownership(db, pdf_id, current_user.id)
|
||||
_any_pdf_or_404(db, pdf_id)
|
||||
|
||||
ann = (
|
||||
db.query(Annotation)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from fastapi import APIRouter, Cookie, Depends, HTTPException, Response, status
|
||||
from sqlalchemy.orm import Session
|
||||
import os
|
||||
|
||||
@@ -84,3 +84,16 @@ def logout(response: Response):
|
||||
@router.get("/me", response_model=UserOut)
|
||||
def me(current_user: User = Depends(get_current_user)):
|
||||
return current_user
|
||||
|
||||
|
||||
# ── GET /auth/token (return raw JWT for WebSocket auth) ─────────────────────
|
||||
|
||||
@router.get("/token")
|
||||
def get_token(access_token: str | None = Cookie(default=None)):
|
||||
"""
|
||||
Returns the current JWT so the frontend can pass it as a WebSocket
|
||||
query param (browsers can't send cookies on WS upgrade in all cases).
|
||||
"""
|
||||
if not access_token:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated.")
|
||||
return {"access_token": access_token}
|
||||
|
||||
@@ -30,6 +30,29 @@ def _own_or_404(db: Session, pdf_id: int, user_id: int) -> PDF:
|
||||
return pdf
|
||||
|
||||
|
||||
def _any_or_404(db: Session, pdf_id: int) -> PDF:
|
||||
"""Return PDF if it exists — any authenticated user may read it."""
|
||||
pdf = db.get(PDF, pdf_id)
|
||||
if not pdf:
|
||||
raise HTTPException(status_code=404, detail="PDF not found.")
|
||||
return pdf
|
||||
|
||||
|
||||
def _pdf_out(pdf: PDF, db: Session):
|
||||
"""Build PDFOut including owner_username."""
|
||||
from ..schemas import PDFOut as _PDFOut
|
||||
owner = db.get(User, pdf.user_id)
|
||||
data = {
|
||||
"id": pdf.id,
|
||||
"user_id": pdf.user_id,
|
||||
"owner_username": owner.username if owner else "unknown",
|
||||
"title": pdf.title,
|
||||
"total_pages": pdf.total_pages,
|
||||
"created_at": pdf.created_at,
|
||||
}
|
||||
return _PDFOut.model_validate(data)
|
||||
|
||||
|
||||
# ── GET /api/pdfs ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("", response_model=list[PDFOut])
|
||||
@@ -37,12 +60,8 @@ def list_pdfs(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return (
|
||||
db.query(PDF)
|
||||
.filter(PDF.user_id == current_user.id)
|
||||
.order_by(PDF.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
pdfs = db.query(PDF).order_by(PDF.created_at.desc()).all()
|
||||
return [_pdf_out(p, db) for p in pdfs]
|
||||
|
||||
|
||||
# ── POST /api/pdfs/upload ─────────────────────────────────────────────────────
|
||||
@@ -90,7 +109,7 @@ async def upload_pdfs(
|
||||
db.commit()
|
||||
db.refresh(pdf)
|
||||
|
||||
results.append(PDFUploadResult(filename=file.filename or "", success=True, pdf=PDFOut.model_validate(pdf)))
|
||||
results.append(PDFUploadResult(filename=file.filename or "", success=True, pdf=_pdf_out(pdf, db)))
|
||||
|
||||
return results
|
||||
|
||||
@@ -121,8 +140,8 @@ def serve_pdf(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Serve the raw PDF bytes — only to the owning user."""
|
||||
pdf = _own_or_404(db, pdf_id, current_user.id)
|
||||
"""Serve the raw PDF bytes — any authenticated user may read."""
|
||||
pdf = _any_or_404(db, pdf_id)
|
||||
file_path = Path(pdf.file_path)
|
||||
|
||||
if not file_path.exists():
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
WebSocket collaboration endpoint.
|
||||
|
||||
URL: ws://.../ws/pdf/{pdf_id}?token=<JWT>
|
||||
|
||||
Each PDF has its own "room". When a client connects it broadcasts a `presence`
|
||||
event to the room. All annotation mutations are forwarded verbatim to every
|
||||
other client in the room.
|
||||
|
||||
Message schema (JSON):
|
||||
→ client sends:
|
||||
{ "type": "object_add", "payload": <fabric object JSON> }
|
||||
{ "type": "object_remove", "payload": { "obj_id": "<id>" } }
|
||||
{ "type": "clear" }
|
||||
{ "type": "cursor", "payload": { "x": 0, "y": 0, "page": 1 } }
|
||||
{ "type": "ping" }
|
||||
|
||||
← server sends to ALL others in room:
|
||||
same messages, with "user_id" / "username" / "color" injected
|
||||
|
||||
← server sends to ALL (including sender) on join/leave:
|
||||
{ "type": "presence", "users": [ { "user_id", "username", "color" } ] }
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, status
|
||||
from jose import JWTError
|
||||
|
||||
from ..security import decode_access_token
|
||||
from ..database import get_db
|
||||
from ..models import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
# ── Deterministic colour per user (hue based on user_id) ─────────────────────
|
||||
|
||||
def _user_color(user_id: int) -> str:
|
||||
hue = (user_id * 61) % 360 # spread nicely around the wheel
|
||||
return f"hsl({hue},70%,50%)"
|
||||
|
||||
|
||||
# ── Room manager ──────────────────────────────────────────────────────────────
|
||||
|
||||
class _Room:
|
||||
def __init__(self) -> None:
|
||||
# websocket → user info dict
|
||||
self._clients: dict[WebSocket, dict[str, Any]] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def join(self, ws: WebSocket, user_info: dict[str, Any]) -> None:
|
||||
async with self._lock:
|
||||
self._clients[ws] = user_info
|
||||
await self._broadcast_presence()
|
||||
|
||||
async def leave(self, ws: WebSocket) -> None:
|
||||
async with self._lock:
|
||||
self._clients.pop(ws, None)
|
||||
await self._broadcast_presence()
|
||||
|
||||
@property
|
||||
def user_list(self) -> list[dict[str, Any]]:
|
||||
"""Deduplicated by user_id — same user may have multiple WS connections."""
|
||||
seen: set[int] = set()
|
||||
result: list[dict[str, Any]] = []
|
||||
for info in self._clients.values():
|
||||
uid = info["user_id"]
|
||||
if uid not in seen:
|
||||
seen.add(uid)
|
||||
result.append(info)
|
||||
return result
|
||||
|
||||
async def broadcast(self, message: dict[str, Any], exclude: WebSocket | None = None) -> None:
|
||||
"""Send message to every client except `exclude`."""
|
||||
async with self._lock:
|
||||
targets = [ws for ws in self._clients if ws is not exclude]
|
||||
for ws in targets:
|
||||
try:
|
||||
await ws.send_json(message)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def broadcast_all(self, message: dict[str, Any]) -> None:
|
||||
"""Send message to every client including sender."""
|
||||
await self.broadcast(message, exclude=None)
|
||||
|
||||
async def _broadcast_presence(self) -> None:
|
||||
await self.broadcast_all({"type": "presence", "users": self.user_list})
|
||||
|
||||
|
||||
class _RoomManager:
|
||||
def __init__(self) -> None:
|
||||
self._rooms: dict[int, _Room] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def get_or_create(self, pdf_id: int) -> _Room:
|
||||
async with self._lock:
|
||||
if pdf_id not in self._rooms:
|
||||
self._rooms[pdf_id] = _Room()
|
||||
return self._rooms[pdf_id]
|
||||
|
||||
async def cleanup(self, pdf_id: int) -> None:
|
||||
async with self._lock:
|
||||
room = self._rooms.get(pdf_id)
|
||||
if room and not room._clients:
|
||||
del self._rooms[pdf_id]
|
||||
|
||||
|
||||
manager = _RoomManager()
|
||||
|
||||
# ── WebSocket endpoint ────────────────────────────────────────────────────────
|
||||
|
||||
@router.websocket("/ws/pdf/{pdf_id}")
|
||||
async def pdf_collaboration(websocket: WebSocket, pdf_id: int):
|
||||
# ── Auth: JWT passed as query param (cookies aren't reliably sent on WS) ──
|
||||
token = websocket.query_params.get("token")
|
||||
if not token:
|
||||
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
||||
return
|
||||
|
||||
try:
|
||||
user_id = decode_access_token(token)
|
||||
except JWTError:
|
||||
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
||||
return
|
||||
|
||||
# ── Fetch username from DB ────────────────────────────────────────────────
|
||||
db = next(get_db())
|
||||
try:
|
||||
user: User | None = db.get(User, user_id)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if user is None:
|
||||
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
||||
return
|
||||
|
||||
color = _user_color(user_id)
|
||||
user_info = {"user_id": user_id, "username": user.username, "color": color}
|
||||
|
||||
await websocket.accept()
|
||||
room = await manager.get_or_create(pdf_id)
|
||||
await room.join(websocket, user_info)
|
||||
|
||||
try:
|
||||
while True:
|
||||
raw = await websocket.receive_text()
|
||||
try:
|
||||
msg = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
msg_type = msg.get("type")
|
||||
|
||||
if msg_type == "ping":
|
||||
await websocket.send_json({"type": "pong"})
|
||||
continue
|
||||
|
||||
# Inject sender identity and forward to all other clients
|
||||
if msg_type in {"object_add", "object_remove", "clear", "cursor"}:
|
||||
msg.update(user_info)
|
||||
await room.broadcast(msg, exclude=websocket)
|
||||
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
await room.leave(websocket)
|
||||
await manager.cleanup(pdf_id)
|
||||
Reference in New Issue
Block a user