mathly / app /unit_resources.py
3v324v23's picture
Add tests for drafting and reviewing student answers
7badcff
Raw History Blame Contribute Delete
13.6 kB
"""Lesson resources: photos a teacher adds to a unit (an exercise, a theory explanation, or a free note).
Rows live in `math_unit_resources`, images in the private `mathly-resources` bucket. Only the backend, with the
service key, reads or writes either; teachers reach them through `/api/math/units/.../resources`.
A resource is hidden from students until its teacher marks it visible (`visible`; a theory explanation starts visible,
an exercise or a note starts hidden). Students see the visible resources of the units open to their class, through
`/api/math/units/.../student-resources`, and never the teacher's email.
"""
from __future__ import annotations
import json
import logging
import urllib.error
import urllib.parse
import urllib.request
import uuid
from fastapi import Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import JSONResponse, Response
from starlette.concurrency import run_in_threadpool
from .access import has_permission
from .answer_scans import MAX_IMAGE, ScanError, clean_image
from .math_skills import algebra9
log = logging.getLogger("mathly.unit_resources")
BUCKET = "mathly-resources"
KINDS = {"exercise": "转专讙讬诇 讞讚砖", "theory": "讛住讘专 转讬讗讜专讟讬", "other": "讗讞专"}
MAX_NOTE = 200
class ResourceUnavailable(RuntimeError):
"""The storage did not answer. `no_visible_column`: the answer was that the visible column is not there yet."""
no_visible_column = False
class UnitResources:
"""Supabase storage for lesson resources, through the service key."""
def __init__(self, auth):
self.auth = auth
@property
def configured(self) -> bool:
return bool(self.auth and self.auth.secret)
def _request(self, path, method="GET", data=None, content_type=None, prefer=None) -> bytes:
headers = {"Authorization": "Bearer " + self.auth.secret, "apikey": self.auth.secret,
"User-Agent": "mathly/1.0"}
if content_type:
headers["Content-Type"] = content_type
if prefer:
headers["Prefer"] = prefer
req = urllib.request.Request(self.auth.url + path, data=data, method=method, headers=headers)
try:
with urllib.request.urlopen(req, timeout=30) as response:
return response.read()
except urllib.error.HTTPError as exc:
log.warning("resource storage failed: %s", exc.code)
failure = ResourceUnavailable("resource storage unavailable")
try:
failure.no_visible_column = exc.code == 400 and b"visible" in exc.read() # the migration is not applied
except OSError:
pass
raise failure from exc
except (urllib.error.URLError, TimeoutError, OSError) as exc:
# Never pass SQL, keys or storage paths on to the browser.
log.warning("resource storage failed: %s", getattr(exc, "code", type(exc).__name__))
raise ResourceUnavailable("resource storage unavailable") from exc
def _rows(self, path, method="GET", body=None):
raw = self._request(path, method, json.dumps(body).encode() if body is not None else None,
"application/json", "return=representation")
return json.loads(raw or b"null") or []
def list(self, unit_id: str) -> list[dict]:
query = urllib.parse.urlencode({"unit_id": f"eq.{unit_id}", "select": "*", "order": "created_at.desc"})
return self._rows(f"/rest/v1/math_unit_resources?{query}")
def get(self, resource_id: str) -> dict | None:
rows = self._rows(f"/rest/v1/math_unit_resources?id=eq.{resource_id}&select=*")
return rows[0] if rows else None
def add(self, row: dict) -> dict:
try:
return self._rows("/rest/v1/math_unit_resources", "POST", row)[0]
except ResourceUnavailable as exc:
if not exc.no_visible_column:
raise
log.warning("the visible column is missing; apply supabase/migrations/20261104180000_math_resources_visible.sql")
return self._rows("/rest/v1/math_unit_resources", "POST", {k: v for k, v in row.items() if k != "visible"})[0]
def set_visible(self, resource_id: str, visible: bool) -> dict | None:
rows = self._rows(f"/rest/v1/math_unit_resources?id=eq.{resource_id}", "PATCH", {"visible": visible})
return rows[0] if rows else None
def remove(self, resource_id: str) -> None:
self._rows(f"/rest/v1/math_unit_resources?id=eq.{resource_id}", "DELETE")
def put(self, object_key: str, data: bytes) -> None:
self._request(f"/storage/v1/object/{BUCKET}/{urllib.parse.quote(object_key, safe='/')}",
"POST", data, "image/jpeg")
def read(self, object_key: str) -> bytes:
return self._request(f"/storage/v1/object/authenticated/{BUCKET}/{urllib.parse.quote(object_key, safe='/')}")
def delete(self, object_key: str) -> None:
self._request(f"/storage/v1/object/{BUCKET}", "DELETE",
json.dumps({"prefixes": [object_key]}).encode(), "application/json")
def public(row: dict) -> dict:
"""A resource as a teacher's browser sees it: no storage path."""
return {**{key: row.get(key) for key in ("id", "unit_id", "kind", "note", "skill_id", "created_email", "created_at",
"created_by")}, "visible": bool(row.get("visible"))}
def for_student(row: dict) -> dict:
"""A resource as a student's browser sees it: no storage path and no teacher."""
return {key: row.get(key) for key in ("id", "unit_id", "kind", "note", "skill_id", "created_at")}
def wants_visible(value: str | None, kind: str) -> bool:
"""The upload form's visibility: what it says, else a theory explanation shows and anything else stays hidden."""
if value is None or not value.strip():
return kind == "theory"
return value.strip().lower() in ("1", "true", "on", "yes")
def resource_id(value: str) -> str:
try:
return str(uuid.UUID(value))
except ValueError as exc:
raise HTTPException(404, "resource not found") from exc
def install_resource_routes(app, signed_in, units_of=None):
if not hasattr(app.state, "resources"):
app.state.resources = UnitResources(app.state.auth)
def teacher(user=Depends(signed_in)) -> dict:
if not user or not user.get("id"):
raise HTTPException(401, "sign in required")
if not has_permission(user, "students.view"):
raise HTTPException(403, "teacher permission required")
if not app.state.resources.configured:
raise HTTPException(503, "resource storage is not configured")
return user
def unit_of(unit_id: str) -> dict:
unit = next((u for u in algebra9()["units"] if u["id"] == unit_id), None)
if unit is None:
raise HTTPException(404, "unit not found")
return unit
async def storage(call, *args):
try:
return await run_in_threadpool(call, *args)
except ResourceUnavailable as exc:
raise HTTPException(503, "resource storage unavailable") from exc
@app.get("/api/math/units/{unit_id}/resources")
async def list_resources(unit_id: str, _user: dict = Depends(teacher)):
unit_of(unit_id)
rows = await storage(app.state.resources.list, unit_id)
return JSONResponse({"resources": [public(row) for row in rows]}, headers={"Cache-Control": "no-store"})
@app.post("/api/math/units/{unit_id}/resources")
async def add_resource(unit_id: str, photo: UploadFile = File(...), kind: str = Form(...),
note: str = Form(""), skill: str = Form(""), visible: str | None = Form(None),
user: dict = Depends(teacher)):
unit = unit_of(unit_id)
if kind not in KINDS:
raise HTTPException(400, "choose what the photo shows")
note = " ".join(note.split())
if len(note) > MAX_NOTE:
raise HTTPException(400, f"the description is longer than {MAX_NOTE} characters")
if kind == "other" and not note:
raise HTTPException(400, "describe the photo")
skills = {s["id"]: s for s in algebra9()["skills"] if s["unit"] == unit_id}
if skill and skill not in skills:
raise HTTPException(400, "the topic is not part of this lesson")
data = await photo.read(MAX_IMAGE + 1)
try:
image = clean_image(data, photo.content_type or "")
except ScanError as exc:
raise HTTPException(exc.status, exc.code) from exc
key = f"{unit_id}/{uuid.uuid4()}.jpg"
await storage(app.state.resources.put, key, image)
try:
row = await storage(app.state.resources.add, {
"unit_id": unit_id, "kind": kind, "note": note, "skill_id": skill or None, "object_key": key,
"byte_size": len(image), "created_by": user["id"], "created_email": user.get("email", ""),
"visible": wants_visible(visible, kind)})
except HTTPException:
try:
await run_in_threadpool(app.state.resources.delete, key)
except ResourceUnavailable:
pass # an orphaned image holds no row and is never shown
raise
label = " 路 ".join(filter(None, [unit["name"], KINDS[kind], skills[skill]["short"] if skill else ""]))
app.state.actions.record(user, "resource_added", label)
return JSONResponse(public(row), status_code=201, headers={"Cache-Control": "no-store"})
@app.get("/api/math/resources/{rid}/image")
async def resource_image(rid: str, _user: dict = Depends(teacher)):
row = await storage(app.state.resources.get, resource_id(rid))
if not row:
raise HTTPException(404, "resource not found")
return Response(await storage(app.state.resources.read, row["object_key"]), media_type="image/jpeg",
headers={"Cache-Control": "private, max-age=3600", "X-Content-Type-Options": "nosniff"})
@app.patch("/api/math/resources/{rid}")
async def change_resource(rid: str, body: dict, user: dict = Depends(teacher)):
"""Shows a resource to students or hides it again: its teacher, or a manager."""
if type(body.get("visible")) is not bool:
raise HTTPException(400, "send {\"visible\": true or false}")
row = await storage(app.state.resources.get, resource_id(rid))
if not row:
raise HTTPException(404, "resource not found")
if row.get("created_by") != user["id"] and not has_permission(user, "management.access"):
raise HTTPException(403, "only the teacher who added it can change it")
changed = await storage(app.state.resources.set_visible, row["id"], body["visible"])
return JSONResponse(public(changed or {**row, "visible": body["visible"]}), headers={"Cache-Control": "no-store"})
# ---- the students: the visible resources of the units open to their class
def student(user=Depends(signed_in)) -> dict:
if not user or not user.get("id"):
raise HTTPException(401, "sign in required")
if not app.state.resources.configured:
raise HTTPException(503, "resource storage is not configured")
return user
def open_to(user: dict, unit_id: str) -> bool:
"""Whether the unit is one of the account's class units; with no class (none configured), every unit."""
classes = user.get("classes") or []
return not classes or not units_of or unit_id in {u for row in classes for u in units_of(row)}
@app.get("/api/math/units/{unit_id}/student-resources")
async def student_resources(unit_id: str, user: dict = Depends(student)):
unit_of(unit_id)
if not open_to(user, unit_id):
raise HTTPException(404, "unit not found")
rows = await storage(app.state.resources.list, unit_id)
return JSONResponse({"resources": [for_student(r) for r in rows if r.get("visible")]},
headers={"Cache-Control": "no-store"})
@app.get("/api/math/student-resources/{rid}/image")
async def student_resource_image(rid: str, user: dict = Depends(student)):
row = await storage(app.state.resources.get, resource_id(rid))
if not row or not row.get("visible") or not open_to(user, row["unit_id"]):
raise HTTPException(404, "resource not found") # hidden, or of another class: as if it did not exist
return Response(await storage(app.state.resources.read, row["object_key"]), media_type="image/jpeg",
headers={"Cache-Control": "private, max-age=300", "X-Content-Type-Options": "nosniff"})
@app.delete("/api/math/resources/{rid}")
async def delete_resource(rid: str, user: dict = Depends(teacher)):
row = await storage(app.state.resources.get, resource_id(rid))
if not row:
raise HTTPException(404, "resource not found")
if row.get("created_by") != user["id"] and not has_permission(user, "management.access"):
raise HTTPException(403, "only the teacher who added it can delete it")
await storage(app.state.resources.remove, row["id"])
try:
await run_in_threadpool(app.state.resources.delete, row["object_key"])
except ResourceUnavailable:
log.warning("a deleted resource's image stayed in storage")
return {"ok": True}