File size: 13,555 Bytes
8e0e8fe 7badcff 8e0e8fe 7badcff 8e0e8fe 7badcff 8e0e8fe 7badcff 8e0e8fe 7badcff 8e0e8fe 7badcff 8e0e8fe 7badcff 8e0e8fe 7badcff 8e0e8fe 7badcff 8e0e8fe | 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 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 | """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}
|