Download app/unit_resources.py from wzender/mathly: direct link, hf CLI and curl.
- Browser
- Download file 13.6 kB
-
https://huggingface.co/spaces/wzender/mathly/resolve/main/app/unit_resources.py
- Command line
-
hf download hf://spaces/wzender/mathly/app/unit_resources.py
-
curl -L -o unit_resources.py https://huggingface.co/spaces/wzender/mathly/resolve/main/app/unit_resources.py
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 | |
| 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 | |
| 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"}) | |
| 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"}) | |
| 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"}) | |
| 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)} | |
| 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"}) | |
| 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"}) | |
| 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} | |