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}