File size: 3,033 Bytes
a4bc651 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 | """The student's path progress, kept with the account so it follows them to every device.
The page keeps its progress (P in static/math.js) on the device and sends a copy here after each change. A device
that opens the app reads this copy and merges it with its own (the merge rules are in math.js), so work done on
either device is kept. The server only stores the object: one row per account in the Supabase table `math_progress`,
read and written with the server's secret key.
"""
from __future__ import annotations
import json
import logging
from datetime import datetime, timezone
from fastapi import Depends, HTTPException, Request
from fastapi.responses import JSONResponse
from .auth import AuthUnavailable
log = logging.getLogger("mathly.progress")
MAX_BYTES = 1_000_000
MIGRATION = "supabase/migrations/20261103200000_math_progress.sql"
TABLE = "/rest/v1/math_progress"
def load_progress(auth, user_id: str) -> dict:
rows = auth._admin(f"{TABLE}?select=data&user_id=eq.{user_id}") or []
return rows[0]["data"] if rows and isinstance(rows[0].get("data"), dict) else {}
def save_progress(auth, user_id: str, data: dict) -> None:
auth._admin(f"{TABLE}?on_conflict=user_id", "POST",
{"user_id": user_id, "data": data, "updated_at": datetime.now(timezone.utc).isoformat()},
prefer="resolution=merge-duplicates,return=minimal")
def install_progress_routes(app, signed_in):
"""GET and PUT /api/math/progress: the signed-in student's progress."""
def account(user=Depends(signed_in)) -> str:
if not user or not user.get("id"):
raise HTTPException(401, "sign_in_required")
return user["id"]
def unavailable(exc: AuthUnavailable) -> HTTPException:
if "answered 404" in str(exc):
log.warning("the math_progress table is missing; apply %s", MIGRATION)
else:
log.error("progress not synced: %s", exc)
return HTTPException(503, "progress_unavailable")
@app.get("/api/math/progress")
def get_progress(user: str = Depends(account)):
try:
data = load_progress(app.state.auth, user)
except AuthUnavailable as exc:
raise unavailable(exc) from exc
return JSONResponse({"progress": data}, headers={"Cache-Control": "no-store"})
@app.put("/api/math/progress")
async def put_progress(request: Request, user: str = Depends(account)):
raw = await request.body()
if len(raw) > MAX_BYTES:
raise HTTPException(413, "progress_too_large")
try:
data = json.loads(raw).get("progress")
except (ValueError, AttributeError) as exc:
raise HTTPException(400, "send {\"progress\": {...}}") from exc
if not isinstance(data, dict):
raise HTTPException(400, "send {\"progress\": {...}}")
try:
save_progress(app.state.auth, user, data)
except AuthUnavailable as exc:
raise unavailable(exc) from exc
return {"ok": True}
|