Your Name commited on
Commit
8e0e8fe
·
1 Parent(s): 6322def

feat: add lesson resources feature for teachers to photograph and manage unit resources

Browse files

- Implemented frontend components in math.js and math.css for resource management.
- Created backend API in unit_resources.py to handle resource storage and retrieval.
- Added database migration for math_unit_resources table to store resource metadata.
- Developed tests for resource management functionality, including adding, listing, and deleting resources.
- Enabled teachers to photograph exercises and explanations, with appropriate permissions and validations.

README.md CHANGED
@@ -65,6 +65,10 @@ Checks and feedback are stored privately against the original attempt and photo,
65
 
66
  For rollback, turn `MATHLY_ANSWER_SCANS_ENABLED` off and restart; preserve the new tables and private bucket. The skills API serves the original multiple-choice geometry sample while scanning is disabled, including for guest practice and older frontends. Enabling scanning again restores open-ended presentation and access to saved answers. Keep stored answer records for recovery. No destructive down migration is needed.
67
 
 
 
 
 
68
  Integration tests use bundled disposable PostgreSQL and local private test blobs, without project credentials or Docker:
69
 
70
  ```bash
 
65
 
66
  For rollback, turn `MATHLY_ANSWER_SCANS_ENABLED` off and restart; preserve the new tables and private bucket. The skills API serves the original multiple-choice geometry sample while scanning is disabled, including for guest practice and older frontends. Enabling scanning again restores open-ended presentation and access to saved answers. Keep stored answer records for recovery. No destructive down migration is needed.
67
 
68
+ ### Lesson resources (teachers)
69
+
70
+ A teacher taps a unit's title on the path (for example **טכניקה אלגברית 1**), photographs an exercise or an explanation, and saves it to that lesson's resources. Each photo is marked as **תרגיל חדש**, **הסבר תיאורטי**, or **אחר** (with a short free-text description), and can optionally name the topic it belongs to (for example **פילוג מורחב**). Apply [the additive resources migration](supabase/migrations/20261101090000_math_unit_resources.sql); it adds the `math_unit_resources` table and the private `mathly-resources` bucket. Only accounts with the teacher role see, add, or open resources, and only the teacher who added a photo (or a manager) can delete it. Students do not see resources yet.
71
+
72
  Integration tests use bundled disposable PostgreSQL and local private test blobs, without project credentials or Docker:
73
 
74
  ```bash
app/main.py CHANGED
@@ -23,6 +23,7 @@ from .math_photo import (DAILY_PHOTOS, MAX_PHOTO, MEDIA_TYPES as PHOTO_TYPES,
23
  PhotoSolver, PhotoUnavailable, math_model, photo_client)
24
  from .math_skills import algebra9, practice_graph
25
  from .monitoring import ActionLog, RETURN_GAP, install_monitoring_routes
 
26
 
27
  logging.basicConfig(level=logging.INFO)
28
  log = logging.getLogger("mathly")
@@ -306,6 +307,7 @@ self.addEventListener("activate", event => event.waitUntil((async () => {
306
  return {"ok": True, "readable": result.get("readable", False)}
307
 
308
  install_answer_routes(app, signed_in)
 
309
  install_monitoring_routes(app, manager)
310
  return app
311
 
 
23
  PhotoSolver, PhotoUnavailable, math_model, photo_client)
24
  from .math_skills import algebra9, practice_graph
25
  from .monitoring import ActionLog, RETURN_GAP, install_monitoring_routes
26
+ from .unit_resources import install_resource_routes
27
 
28
  logging.basicConfig(level=logging.INFO)
29
  log = logging.getLogger("mathly")
 
307
  return {"ok": True, "readable": result.get("readable", False)}
308
 
309
  install_answer_routes(app, signed_in)
310
+ install_resource_routes(app, signed_in)
311
  install_monitoring_routes(app, manager)
312
  return app
313
 
app/unit_resources.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Lesson resources: photos a teacher adds to a unit (an exercise, a theory explanation, or a free note).
2
+
3
+ Rows live in `math_unit_resources`, images in the private `mathly-resources` bucket. Only the backend, with the
4
+ service key, reads or writes either; teachers reach them through `/api/math/units/.../resources`.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import logging
10
+ import urllib.error
11
+ import urllib.parse
12
+ import urllib.request
13
+ import uuid
14
+
15
+ from fastapi import Depends, File, Form, HTTPException, UploadFile
16
+ from fastapi.responses import JSONResponse, Response
17
+ from starlette.concurrency import run_in_threadpool
18
+
19
+ from .access import has_permission
20
+ from .answer_scans import MAX_IMAGE, ScanError, clean_image
21
+ from .math_skills import algebra9
22
+
23
+ log = logging.getLogger("mathly.unit_resources")
24
+ BUCKET = "mathly-resources"
25
+ KINDS = {"exercise": "תרגיל חדש", "theory": "הסבר תיאורטי", "other": "אחר"}
26
+ MAX_NOTE = 200
27
+
28
+
29
+ class ResourceUnavailable(RuntimeError):
30
+ pass
31
+
32
+
33
+ class UnitResources:
34
+ """Supabase storage for lesson resources, through the service key."""
35
+
36
+ def __init__(self, auth):
37
+ self.auth = auth
38
+
39
+ @property
40
+ def configured(self) -> bool:
41
+ return bool(self.auth and self.auth.secret)
42
+
43
+ def _request(self, path, method="GET", data=None, content_type=None, prefer=None) -> bytes:
44
+ headers = {"Authorization": "Bearer " + self.auth.secret, "apikey": self.auth.secret,
45
+ "User-Agent": "mathly/1.0"}
46
+ if content_type:
47
+ headers["Content-Type"] = content_type
48
+ if prefer:
49
+ headers["Prefer"] = prefer
50
+ req = urllib.request.Request(self.auth.url + path, data=data, method=method, headers=headers)
51
+ try:
52
+ with urllib.request.urlopen(req, timeout=30) as response:
53
+ return response.read()
54
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
55
+ # Never pass SQL, keys or storage paths on to the browser.
56
+ log.warning("resource storage failed: %s", getattr(exc, "code", type(exc).__name__))
57
+ raise ResourceUnavailable("resource storage unavailable") from exc
58
+
59
+ def _rows(self, path, method="GET", body=None):
60
+ raw = self._request(path, method, json.dumps(body).encode() if body is not None else None,
61
+ "application/json", "return=representation")
62
+ return json.loads(raw or b"null") or []
63
+
64
+ def list(self, unit_id: str) -> list[dict]:
65
+ query = urllib.parse.urlencode({"unit_id": f"eq.{unit_id}", "select": "*", "order": "created_at.desc"})
66
+ return self._rows(f"/rest/v1/math_unit_resources?{query}")
67
+
68
+ def get(self, resource_id: str) -> dict | None:
69
+ rows = self._rows(f"/rest/v1/math_unit_resources?id=eq.{resource_id}&select=*")
70
+ return rows[0] if rows else None
71
+
72
+ def add(self, row: dict) -> dict:
73
+ return self._rows("/rest/v1/math_unit_resources", "POST", row)[0]
74
+
75
+ def remove(self, resource_id: str) -> None:
76
+ self._rows(f"/rest/v1/math_unit_resources?id=eq.{resource_id}", "DELETE")
77
+
78
+ def put(self, object_key: str, data: bytes) -> None:
79
+ self._request(f"/storage/v1/object/{BUCKET}/{urllib.parse.quote(object_key, safe='/')}",
80
+ "POST", data, "image/jpeg")
81
+
82
+ def read(self, object_key: str) -> bytes:
83
+ return self._request(f"/storage/v1/object/authenticated/{BUCKET}/{urllib.parse.quote(object_key, safe='/')}")
84
+
85
+ def delete(self, object_key: str) -> None:
86
+ self._request(f"/storage/v1/object/{BUCKET}", "DELETE",
87
+ json.dumps({"prefixes": [object_key]}).encode(), "application/json")
88
+
89
+
90
+ def public(row: dict) -> dict:
91
+ """A resource as the browser sees it: no storage path."""
92
+ return {key: row.get(key) for key in ("id", "unit_id", "kind", "note", "skill_id", "created_email", "created_at",
93
+ "created_by")}
94
+
95
+
96
+ def resource_id(value: str) -> str:
97
+ try:
98
+ return str(uuid.UUID(value))
99
+ except ValueError as exc:
100
+ raise HTTPException(404, "resource not found") from exc
101
+
102
+
103
+ def install_resource_routes(app, signed_in):
104
+ if not hasattr(app.state, "resources"):
105
+ app.state.resources = UnitResources(app.state.auth)
106
+
107
+ def teacher(user=Depends(signed_in)) -> dict:
108
+ if not user or not user.get("id"):
109
+ raise HTTPException(401, "sign in required")
110
+ if not has_permission(user, "students.view"):
111
+ raise HTTPException(403, "teacher permission required")
112
+ if not app.state.resources.configured:
113
+ raise HTTPException(503, "resource storage is not configured")
114
+ return user
115
+
116
+ def unit_of(unit_id: str) -> dict:
117
+ unit = next((u for u in algebra9()["units"] if u["id"] == unit_id), None)
118
+ if unit is None:
119
+ raise HTTPException(404, "unit not found")
120
+ return unit
121
+
122
+ async def storage(call, *args):
123
+ try:
124
+ return await run_in_threadpool(call, *args)
125
+ except ResourceUnavailable as exc:
126
+ raise HTTPException(503, "resource storage unavailable") from exc
127
+
128
+ @app.get("/api/math/units/{unit_id}/resources")
129
+ async def list_resources(unit_id: str, _user: dict = Depends(teacher)):
130
+ unit_of(unit_id)
131
+ rows = await storage(app.state.resources.list, unit_id)
132
+ return JSONResponse({"resources": [public(row) for row in rows]}, headers={"Cache-Control": "no-store"})
133
+
134
+ @app.post("/api/math/units/{unit_id}/resources")
135
+ async def add_resource(unit_id: str, photo: UploadFile = File(...), kind: str = Form(...),
136
+ note: str = Form(""), skill: str = Form(""), user: dict = Depends(teacher)):
137
+ unit = unit_of(unit_id)
138
+ if kind not in KINDS:
139
+ raise HTTPException(400, "choose what the photo shows")
140
+ note = " ".join(note.split())
141
+ if len(note) > MAX_NOTE:
142
+ raise HTTPException(400, f"the description is longer than {MAX_NOTE} characters")
143
+ if kind == "other" and not note:
144
+ raise HTTPException(400, "describe the photo")
145
+ skills = {s["id"]: s for s in algebra9()["skills"] if s["unit"] == unit_id}
146
+ if skill and skill not in skills:
147
+ raise HTTPException(400, "the topic is not part of this lesson")
148
+ data = await photo.read(MAX_IMAGE + 1)
149
+ try:
150
+ image = clean_image(data, photo.content_type or "")
151
+ except ScanError as exc:
152
+ raise HTTPException(exc.status, exc.code) from exc
153
+ key = f"{unit_id}/{uuid.uuid4()}.jpg"
154
+ await storage(app.state.resources.put, key, image)
155
+ try:
156
+ row = await storage(app.state.resources.add, {
157
+ "unit_id": unit_id, "kind": kind, "note": note, "skill_id": skill or None, "object_key": key,
158
+ "byte_size": len(image), "created_by": user["id"], "created_email": user.get("email", "")})
159
+ except HTTPException:
160
+ try:
161
+ await run_in_threadpool(app.state.resources.delete, key)
162
+ except ResourceUnavailable:
163
+ pass # an orphaned image holds no row and is never shown
164
+ raise
165
+ label = " · ".join(filter(None, [unit["name"], KINDS[kind], skills[skill]["short"] if skill else ""]))
166
+ app.state.actions.record(user, "resource_added", label)
167
+ return JSONResponse(public(row), status_code=201, headers={"Cache-Control": "no-store"})
168
+
169
+ @app.get("/api/math/resources/{rid}/image")
170
+ async def resource_image(rid: str, _user: dict = Depends(teacher)):
171
+ row = await storage(app.state.resources.get, resource_id(rid))
172
+ if not row:
173
+ raise HTTPException(404, "resource not found")
174
+ return Response(await storage(app.state.resources.read, row["object_key"]), media_type="image/jpeg",
175
+ headers={"Cache-Control": "private, max-age=3600", "X-Content-Type-Options": "nosniff"})
176
+
177
+ @app.delete("/api/math/resources/{rid}")
178
+ async def delete_resource(rid: str, user: dict = Depends(teacher)):
179
+ row = await storage(app.state.resources.get, resource_id(rid))
180
+ if not row:
181
+ raise HTTPException(404, "resource not found")
182
+ if row.get("created_by") != user["id"] and not has_permission(user, "management.access"):
183
+ raise HTTPException(403, "only the teacher who added it can delete it")
184
+ await storage(app.state.resources.remove, row["id"])
185
+ try:
186
+ await run_in_threadpool(app.state.resources.delete, row["object_key"])
187
+ except ResourceUnavailable:
188
+ log.warning("a deleted resource's image stayed in storage")
189
+ return {"ok": True}
static/math.css CHANGED
@@ -411,6 +411,29 @@ button.sky-chip.flame{padding:6px 11px;color:var(--ink)}
411
  .mx-uhead{display:flex;align-items:center;gap:4px 10px;flex-wrap:wrap}
412
  .mx-uhead h2{font-size:20px;color:var(--uc);flex:1;min-width:0}
413
  .mx-unit.full .mx-uhead h2::after{content:" ✦";color:var(--gold)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
414
  .mx-pill{padding:3px 10px;border-radius:999px;font:700 12px/1.4 var(--f-body);background:color-mix(in srgb,var(--uc) 18%,transparent);color:var(--uc);font-variant-numeric:tabular-nums}
415
  .mx-usub{width:100%;color:var(--muted);font-size:13px}
416
  .mx-step{position:relative;display:flex;flex-direction:column;align-items:center;gap:6px;width:150px;transform:translateX(calc(var(--x) * var(--amp)))}
 
411
  .mx-uhead{display:flex;align-items:center;gap:4px 10px;flex-wrap:wrap}
412
  .mx-uhead h2{font-size:20px;color:var(--uc);flex:1;min-width:0}
413
  .mx-unit.full .mx-uhead h2::after{content:" ✦";color:var(--gold)}
414
+ /* lesson resources: a teacher opens them from a unit's title (static/math.js, openResources) */
415
+ .mx-uhead.is-teach{cursor:pointer}
416
+ .mx-res-open{border:1px solid color-mix(in srgb,var(--uc) 55%,var(--line));background:var(--bg);color:var(--ink);border-radius:999px;padding:6px 12px;font:600 13px/1 var(--f-body);cursor:pointer;min-height:32px}
417
+ .mx-res-take{align-self:stretch;min-height:52px;font-size:17px}
418
+ .mx-res-msg{min-height:1.3em;color:var(--muted);font-size:14px}
419
+ .mx-res-form{display:flex;flex-direction:column;gap:14px}
420
+ .mx-res-form .mx-thumb{max-height:260px}
421
+ .mx-res-form fieldset{margin:0;padding:0;border:0;display:flex;flex-direction:column;gap:10px}
422
+ .mx-res-form legend,.mx-res-form>label{font-weight:700;margin-bottom:6px}
423
+ .mx-res-form>label{display:flex;flex-direction:column;gap:6px}.mx-res-form>label small{color:var(--muted);font-weight:500}
424
+ .mx-res-kinds{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}
425
+ .mx-res-kinds label{display:flex;align-items:center;justify-content:center;gap:6px;min-height:48px;border:1px solid var(--line);border-radius:12px;padding:8px;text-align:center;cursor:pointer;font-weight:600}
426
+ .mx-res-kinds label:has(input:checked){border-color:var(--mx-accent);background:var(--mx-accent-soft)}
427
+ .mx-res-kinds input{position:absolute;opacity:0;pointer-events:none}
428
+ .mx-res-kinds label:has(input:focus-visible){outline:2px solid var(--mx-accent);outline-offset:2px}
429
+ .mx-res-form input[name="note"],.mx-res-form select{box-sizing:border-box;width:100%;border:1px solid var(--line);border-radius:10px;background:var(--bg);color:var(--ink);padding:11px 12px;font:inherit}
430
+ .mx-res-list{display:flex;flex-direction:column;gap:10px}
431
+ .mx-res-item{display:grid;grid-template-columns:88px 1fr auto;gap:12px;align-items:start;border:1px solid var(--line);border-radius:12px;background:var(--bg);padding:10px}
432
+ .mx-res-img{display:block;width:88px;height:88px;border-radius:8px;overflow:hidden;background:var(--surface-2)}
433
+ .mx-res-img img{width:100%;height:100%;object-fit:cover;display:block}
434
+ .mx-res-item>div{display:flex;flex-direction:column;gap:4px;min-width:0}
435
+ .mx-res-item p{margin:0;overflow-wrap:anywhere}.mx-res-item small{color:var(--muted);overflow-wrap:anywhere}
436
+ @media (max-width:380px){.mx-res-kinds{grid-template-columns:1fr}}
437
  .mx-pill{padding:3px 10px;border-radius:999px;font:700 12px/1.4 var(--f-body);background:color-mix(in srgb,var(--uc) 18%,transparent);color:var(--uc);font-variant-numeric:tabular-nums}
438
  .mx-usub{width:100%;color:var(--muted);font-size:13px}
439
  .mx-step{position:relative;display:flex;flex-direction:column;align-items:center;gap:6px;width:150px;transform:translateX(calc(var(--x) * var(--amp)))}
static/math.js CHANGED
@@ -26,7 +26,7 @@ const save=()=>{ try{ localStorage.setItem(key(),JSON.stringify(P)); }catch{} };
26
 
27
  let G=null, BY={}, POST={}, ORDER=[], P={done:{},tries:{},solved:{},at:{}}, root=null, curQ=0, path=null, sheet=null, cur=null, back=null, ACTIVE_SUBJECT="";
28
  let nav=null, FIRST=true, COMBO=0; // the tab bar; whether the shown question is still at its first try; first-try answers in a row
29
- let picker=null, PH=null; // the photo file picker, and the photographed problem on the sheet (see photo())
30
  let advanceTimer=null;
31
  let phonePair=null;
32
  function stopPhone(){
@@ -74,8 +74,11 @@ async function show(){
74
  // no capture attribute: the phone offers the camera and the gallery both
75
  picker=document.createElement("input"); picker.type="file"; picker.accept="image/*"; picker.hidden=true;
76
  picker.onchange=()=>{ const f=picker.files[0]; picker.value=""; if(f) photo(f); };
 
 
 
77
  nav=document.createElement("nav"); nav.className="mx-nav"; nav.setAttribute("aria-label","ניווט"); nav.hidden=true;
78
- root.append(path,sheet,picker,nav); document.body.append(root); window.MathlySky.mount(root);
79
  root.addEventListener("click",onClick);
80
  document.addEventListener("keydown",e=>{ if(e.key==="Escape"&&cur) closeSheet(); });
81
  }
@@ -238,7 +241,7 @@ function examSoon(){
238
  The nodes sway left and right; trail() then draws the line through their centres, lit up to the last skill done. */
239
  const SWAY=[0,.55,.9,.55,0,-.55,-.9,-.55];
240
  function pathView(){
241
- const nxt=next(), cls=MY_CLASS?` · כיתת ${esc(MY_CLASS.name)}`:"";
242
  let k=0;
243
  const units=mineUnits().map(u=>{
244
  const ids=unitIds(u), act=ids.filter(live), lit=act.filter(id=>P.done[id]).length;
@@ -246,8 +249,10 @@ function pathView(){
246
  return `<div class="mx-step u-${u.id}" style="--x:${SWAY[k++%SWAY.length]}">
247
  <button class="mx-node mx-star st-${st}" data-id="${id}" aria-label="${esc(plain(BY[id].name))}: ${STATE_NAME[st]}">${Sky.spark()}${st==="next"?`<span class="mx-perch">${Sky.owl(34)}</span>`:""}</button>
248
  <span class="mx-lbl l-${st}">${fmt(BY[id].short)}${tag?`<em>${tag}</em>`:""}</span></div>`; }).join("");
249
- return `<section class="mx-unit u-${u.id}${unitDone(u)?" full":""}" id="mx-unit-${u.id}"><div class="mx-uhead">
250
- <h2>${esc(u.name)}</h2><span class="mx-pill">${lit}/${act.length}</span>${u.when?`<span class="mx-usub">${esc(u.when)}</span>`:""}</div></section>${steps}`;
 
 
251
  }).join("");
252
  return `${topBar()}${daily()}${examSoon()}<div class="mx-pghead"><h2>המסלול שלי</h2><p class="mx-sub">אלגברה וגאומטריה כיתה ט׳ · 5 יח״ל${cls}</p></div>
253
  <div class="mx-trail"><svg class="mx-line" aria-hidden="true"></svg>
@@ -357,7 +362,7 @@ function open(id){
357
  sheet.querySelector(".mx-x").focus({preventScroll:true});
358
  if(openEnded&&st!=="off")window.MathlyAnswers?.mountQuestion(sheet.querySelector(".mx-answer"),id,s.question_version);
359
  }
360
- function closeSheet(){ stopPhone(); cancelAdvance(); if(sheet)sheet.hidden=true; cur=null; back?.focus?.({preventScroll:true}); back=null; }
361
 
362
  // counts down, then opens `target` (a skill id); stops if the student moved on in the meantime
363
  function autoAdvance(id,target){
@@ -435,6 +440,10 @@ function onClick(e){
435
  case "cal-next": return showStreak(1);
436
  case "sound": Sky.setSound(!Sky.soundOn()); Sky.sound.tap(); return draw();
437
  case "phone": return pairPhone();
 
 
 
 
438
  case "copy-phone": return copyPhone(t);
439
  case "photo": case "retake": return picker.click();
440
  case "hint": PH.shown++; return drawPhoto();
@@ -543,7 +552,7 @@ function showManageMode(mode){
543
  /* Monitoring (ניטור): a snapshot of the system, open-answer activity, and the server's latest warnings.
544
  Each source loads and fails on its own, so a slow Supabase does not hold up the rest. */
545
  const MON_RANGES={day:["יום","24 השעות האחרונות","15 דקות"],week:["שבוע","7 הימים האחרונים","שעה"],month:["חודש","30 הימים האחרונים","יום"]};
546
- const MON_ACTIONS={visit:["👋","נכנס/ה לאפליקציה"],photo:["📷","צילם/ה תרגיל לעזרה"],answer_upload:["📝","שלח/ה תשובה פתוחה"],answer_graded:["✔️","תשובה נבדקה"],class_saved:["🏫","שמר/ה כיתה"],class_deleted:["🗑️","מחק/ה כיתה"]};
547
  const VERDICTS={correct:"נכונות",partial:"נכונות חלקית",incorrect:"שגויות",unreadable:"לא קריאות",needs_review:"דורשות בדיקה",grading:"בבדיקה",failed:"הבדיקה נכשלה"};
548
  let MON={}, MON_RANGE=(()=>{try{return localStorage.getItem("mathly.monRange")}catch{return null}})();
549
  if(!MON_RANGES[MON_RANGE]) MON_RANGE="week";
@@ -876,6 +885,143 @@ async function pairPhone(){
876
  }catch(error){if(phonePair===pair)message.textContent=error.message;}
877
  }
878
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
879
  function geometryDiagram(diagram){
880
  const triangle=diagram.type==="triangle";
881
  return `<div class="mx-geometry"><svg viewBox="0 0 320 210" role="img" aria-label="${esc(diagram.alt)}">
 
26
 
27
  let G=null, BY={}, POST={}, ORDER=[], P={done:{},tries:{},solved:{},at:{}}, root=null, curQ=0, path=null, sheet=null, cur=null, back=null, ACTIVE_SUBJECT="";
28
  let nav=null, FIRST=true, COMBO=0; // the tab bar; whether the shown question is still at its first try; first-try answers in a row
29
+ let picker=null, PH=null, resPicker=null, RES=null; // RES: the open lesson-resources sheet (see openResources()) // the photo file picker, and the photographed problem on the sheet (see photo())
30
  let advanceTimer=null;
31
  let phonePair=null;
32
  function stopPhone(){
 
74
  // no capture attribute: the phone offers the camera and the gallery both
75
  picker=document.createElement("input"); picker.type="file"; picker.accept="image/*"; picker.hidden=true;
76
  picker.onchange=()=>{ const f=picker.files[0]; picker.value=""; if(f) photo(f); };
77
+ resPicker=document.createElement("input"); resPicker.type="file"; resPicker.accept="image/*"; resPicker.hidden=true;
78
+ resPicker.setAttribute("capture","environment"); // a lesson resource is photographed: the phone opens its camera
79
+ resPicker.onchange=()=>{ const f=resPicker.files[0]; resPicker.value=""; if(f) resourcePhoto(f); };
80
  nav=document.createElement("nav"); nav.className="mx-nav"; nav.setAttribute("aria-label","ניווט"); nav.hidden=true;
81
+ root.append(path,sheet,picker,resPicker,nav); document.body.append(root); window.MathlySky.mount(root);
82
  root.addEventListener("click",onClick);
83
  document.addEventListener("keydown",e=>{ if(e.key==="Escape"&&cur) closeSheet(); });
84
  }
 
241
  The nodes sway left and right; trail() then draws the line through their centres, lit up to the last skill done. */
242
  const SWAY=[0,.55,.9,.55,0,-.55,-.9,-.55];
243
  function pathView(){
244
+ const nxt=next(), cls=MY_CLASS?` · כיתת ${esc(MY_CLASS.name)}`:"", teach=can("students.view");
245
  let k=0;
246
  const units=mineUnits().map(u=>{
247
  const ids=unitIds(u), act=ids.filter(live), lit=act.filter(id=>P.done[id]).length;
 
249
  return `<div class="mx-step u-${u.id}" style="--x:${SWAY[k++%SWAY.length]}">
250
  <button class="mx-node mx-star st-${st}" data-id="${id}" aria-label="${esc(plain(BY[id].name))}: ${STATE_NAME[st]}">${Sky.spark()}${st==="next"?`<span class="mx-perch">${Sky.owl(34)}</span>`:""}</button>
251
  <span class="mx-lbl l-${st}">${fmt(BY[id].short)}${tag?`<em>${tag}</em>`:""}</span></div>`; }).join("");
252
+ const res=teach?` data-act="resources" data-unit="${u.id}"`:""; // a teacher opens the lesson's resources from its title
253
+ return `<section class="mx-unit u-${u.id}${unitDone(u)?" full":""}" id="mx-unit-${u.id}"><div class="mx-uhead${teach?" is-teach":""}"${res}>
254
+ <h2>${esc(u.name)}</h2><span class="mx-pill">${lit}/${act.length}</span>${u.when?`<span class="mx-usub">${esc(u.when)}</span>`:""}
255
+ ${teach?`<button class="mx-res-open"${res} aria-label="משאבי השיעור ${esc(u.name)}">📷 משאבים</button>`:""}</div></section>${steps}`;
256
  }).join("");
257
  return `${topBar()}${daily()}${examSoon()}<div class="mx-pghead"><h2>המסלול שלי</h2><p class="mx-sub">אלגברה וגאומטריה כיתה ט׳ · 5 יח״ל${cls}</p></div>
258
  <div class="mx-trail"><svg class="mx-line" aria-hidden="true"></svg>
 
362
  sheet.querySelector(".mx-x").focus({preventScroll:true});
363
  if(openEnded&&st!=="off")window.MathlyAnswers?.mountQuestion(sheet.querySelector(".mx-answer"),id,s.question_version);
364
  }
365
+ function closeSheet(){ stopPhone(); dropResources(); cancelAdvance(); if(sheet)sheet.hidden=true; cur=null; back?.focus?.({preventScroll:true}); back=null; }
366
 
367
  // counts down, then opens `target` (a skill id); stops if the student moved on in the meantime
368
  function autoAdvance(id,target){
 
440
  case "cal-next": return showStreak(1);
441
  case "sound": Sky.setSound(!Sky.soundOn()); Sky.sound.tap(); return draw();
442
  case "phone": return pairPhone();
443
+ case "resources": return openResources(t.dataset.unit);
444
+ case "res-take": case "res-retake": return resPicker.click();
445
+ case "res-cancel": RES.photo=null; RES.msg=""; return drawResources();
446
+ case "res-delete": return deleteResource(t.dataset.res);
447
  case "copy-phone": return copyPhone(t);
448
  case "photo": case "retake": return picker.click();
449
  case "hint": PH.shown++; return drawPhoto();
 
552
  /* Monitoring (ניטור): a snapshot of the system, open-answer activity, and the server's latest warnings.
553
  Each source loads and fails on its own, so a slow Supabase does not hold up the rest. */
554
  const MON_RANGES={day:["יום","24 השעות האחרונות","15 דקות"],week:["שבוע","7 הימים האחרונים","שעה"],month:["חודש","30 הימים האחרונים","יום"]};
555
+ const MON_ACTIONS={visit:["👋","נכנס/ה לאפליקציה"],photo:["📷","צילם/ה תרגיל לעזרה"],answer_upload:["📝","שלח/ה תשובה פתוחה"],answer_graded:["✔️","תשובה נבדקה"],class_saved:["🏫","שמר/ה כיתה"],class_deleted:["🗑️","מחק/ה כיתה"],resource_added:["🖼️","הוסיף/ה תמונה למשאבי שיעור"]};
556
  const VERDICTS={correct:"נכונות",partial:"נכונות חלקית",incorrect:"שגויות",unreadable:"לא קריאות",needs_review:"דורשות בדיקה",grading:"בבדיקה",failed:"הבדיקה נכשלה"};
557
  let MON={}, MON_RANGE=(()=>{try{return localStorage.getItem("mathly.monRange")}catch{return null}})();
558
  if(!MON_RANGES[MON_RANGE]) MON_RANGE="week";
 
885
  }catch(error){if(phonePair===pair)message.textContent=error.message;}
886
  }
887
 
888
+ /* Lesson resources (app/unit_resources.py). A teacher taps a unit's title on the path, photographs an exercise or an
889
+ explanation, says what it shows (a new exercise, a theory explanation, or other words) and, if they like, which of
890
+ the unit's topics it belongs to. Images come through the API with the teacher's sign-in, so each is a blob URL. */
891
+ const RES_KINDS={exercise:"תרגיל חדש",theory:"הסבר תיאורטי",other:"אחר"};
892
+ const resError=(status,fallback)=>({401:"צריך להיכנס מחדש.",403:"רק מורים יכולים לראות ולהוסיף משאבים.",404:"השיעור או התמונה לא נמצאו.",
893
+ 413:"התמונה גדולה מדי. נסו לצלם שוב.",415:"לא הצלחנו לקרוא את התמונה. צלמו שוב או בחרו JPEG, PNG או WebP.",
894
+ 503:"שמירת משאבים עדיין לא זמינה בשרת. נסו שוב מאוחר יותר."}[status]||fallback);
895
+ function dropResources(){
896
+ if(!RES) return;
897
+ Object.values(RES.urls).forEach(url=>URL.revokeObjectURL(url));
898
+ if(RES.preview) URL.revokeObjectURL(RES.preview);
899
+ RES=null;
900
+ }
901
+ function openResources(unitId){
902
+ const unit=G.units.find(u=>u.id===unitId);
903
+ if(!unit||!can("students.view")) return;
904
+ stopPhone(); cancelAdvance(); dropResources();
905
+ if(!cur) back=document.activeElement;
906
+ cur="resources";
907
+ RES={unit,items:null,error:"",urls:{},photo:null,preview:null,busy:false,msg:"",fresh:true};
908
+ drawResources();
909
+ loadResources(RES);
910
+ }
911
+ async function loadResources(mine){
912
+ try{
913
+ const res=await api(`/api/math/units/${encodeURIComponent(mine.unit.id)}/resources`,{cache:"no-store"}), data=await res.json().catch(()=>({}));
914
+ if(!res.ok) throw new Error(resError(res.status,"לא הצלחנו לטעון את המשאבים."));
915
+ mine.items=data.resources||[]; mine.error="";
916
+ }catch(e){ mine.items=mine.items||[]; mine.error=e.message; }
917
+ if(RES===mine&&cur==="resources") drawResources();
918
+ }
919
+ function resourceForm(){
920
+ const skills=unitSkills(RES.unit.id);
921
+ return `<form class="mx-res-form" novalidate>
922
+ <img class="mx-thumb" src="${RES.preview}" alt="התמונה שצילמתם">
923
+ <fieldset><legend>מה בתמונה?</legend><div class="mx-res-kinds">
924
+ ${Object.entries(RES_KINDS).map(([k,label])=>`<label><input type="radio" name="kind" value="${k}"><span>${label}</span></label>`).join("")}</div>
925
+ <input name="note" maxlength="200" autocomplete="off" placeholder="כתבו מה יש בתמונה" aria-label="תיאור חופשי של התמונה" hidden></fieldset>
926
+ <label>נושא <small>(לא חובה)</small><select name="skill"><option value="">ללא נושא מסוים</option>
927
+ ${skills.map(s=>`<option value="${esc(s.id)}">${esc(plain(s.name))}</option>`).join("")}</select></label>
928
+ <div class="mx-row"><button type="submit" class="mx-btn solid"${RES.busy?" disabled":""}>${RES.busy?"שומרים…":"שמירה במשאבים"}</button>
929
+ <button type="button" class="mx-btn" data-act="res-retake"${RES.busy?" disabled":""}>צילום מחדש</button>
930
+ <button type="button" class="mx-btn" data-act="res-cancel"${RES.busy?" disabled":""}>ביטול</button></div></form>`;
931
+ }
932
+ function resourceItem(r){
933
+ const skill=r.skill_id&&BY[r.skill_id], mine=r.created_by===CFG.userId||can("management.access");
934
+ const when=new Date(r.created_at), date=Number.isNaN(when.getTime())?"":when.toLocaleDateString("he-IL",{day:"numeric",month:"short",year:"numeric"});
935
+ return `<article class="mx-res-item"><a class="mx-res-img" data-res-link="${esc(r.id)}" target="_blank" rel="noopener" aria-label="פתיחת התמונה בגודל מלא">
936
+ <img data-res-img="${esc(r.id)}" alt="${esc(r.kind==="other"?r.note:RES_KINDS[r.kind]||"")}"${RES.urls[r.id]?` src="${RES.urls[r.id]}"`:""}></a>
937
+ <div><b>${esc(RES_KINDS[r.kind]||r.kind)}</b>${r.kind==="other"&&r.note?`<p>${esc(r.note)}</p>`:""}
938
+ <small>${skill?`${fmt(skill.short||skill.name)} · `:""}${esc(date)}${r.created_email?` · <span dir="ltr">${esc(r.created_email)}</span>`:""}</small></div>
939
+ ${mine?`<button class="mx-link" data-act="res-delete" data-res="${esc(r.id)}">מחיקה</button>`:""}</article>`;
940
+ }
941
+ function drawResources(){
942
+ const items=RES.items, was=sheet.querySelector(".mx-res-form"); // a redraw keeps what was filled in
943
+ const filled=was&&{kind:was.elements.kind.value,note:was.elements.note.value,skill:was.elements.skill.value};
944
+ const list=items===null?'<p class="mx-note">טוענים את המשאבים…</p>':items.length?`<div class="mx-res-list">${items.map(resourceItem).join("")}</div>`
945
+ :'<p class="mx-note">עוד אין משאבים לשיעור הזה. צלמו תרגיל או הסבר כדי להוסיף.</p>';
946
+ sheet.innerHTML=`<div class="mx-scrim" data-act="close"></div>
947
+ <div class="mx-sheet mx-res u-${RES.unit.id}" role="dialog" aria-modal="true" aria-labelledby="mxTitle">
948
+ <div class="mx-shtop"><div><span class="mx-uchip">${esc(RES.unit.name)}</span><h3 id="mxTitle">משאבי השיעור</h3>
949
+ <small class="mx-src">תמונות של תרגילים והסברים לשיעור הזה</small></div>
950
+ <button class="mx-x" data-act="close" aria-label="סגירה">✕</button></div>
951
+ ${RES.photo?resourceForm():'<button class="mx-btn solid mx-res-take" data-act="res-take">📷 צילום תמונה</button>'}
952
+ <p class="mx-res-msg" role="status" aria-live="polite">${esc(RES.msg)}</p>
953
+ ${RES.error?`<p class="mx-bad">${esc(RES.error)}</p>`:""}
954
+ <div class="mx-sec"><h4>משאבים${items?.length?` (${items.length})`:""}</h4>${list}</div></div>`;
955
+ sheet.hidden=false;
956
+ if(RES.fresh){ RES.fresh=false; sheet.querySelector(".mx-x").focus({preventScroll:true}); }
957
+ const form=sheet.querySelector(".mx-res-form");
958
+ if(form){
959
+ const note=form.elements.note;
960
+ if(filled){ form.querySelectorAll('[name="kind"]').forEach(r=>r.checked=r.value===filled.kind); note.value=filled.note; form.elements.skill.value=filled.skill; note.hidden=filled.kind!=="other"; }
961
+ form.addEventListener("change",()=>{ note.hidden=form.elements.kind.value!=="other"; if(!note.hidden) note.focus(); });
962
+ form.onsubmit=event=>{ event.preventDefault(); saveResource(form); };
963
+ }
964
+ (items||[]).forEach(r=>{ if(r.id in RES.urls) showResourceImage(r.id); else loadResourceImage(RES,r.id); });
965
+ }
966
+ function showResourceImage(id){
967
+ const url=RES.urls[id], img=sheet.querySelector(`[data-res-img="${CSS.escape(id)}"]`), link=sheet.querySelector(`[data-res-link="${CSS.escape(id)}"]`);
968
+ if(!url) return;
969
+ if(img) img.src=url;
970
+ if(link) link.href=url;
971
+ }
972
+ async function loadResourceImage(mine,id){
973
+ mine.urls[id]=null; // asked for once
974
+ try{
975
+ const res=await api(`/api/math/resources/${encodeURIComponent(id)}/image`);
976
+ if(!res.ok) return;
977
+ const url=URL.createObjectURL(await res.blob());
978
+ if(RES!==mine){ URL.revokeObjectURL(url); return; }
979
+ mine.urls[id]=url; showResourceImage(id);
980
+ }catch{}
981
+ }
982
+ async function resourcePhoto(file){
983
+ if(!RES||cur!=="resources") return;
984
+ const mine=RES;
985
+ mine.msg="מכינים את התמונה…"; drawResources();
986
+ let blob=null;
987
+ try{ blob=await window.MathlyPhoto.prepare(file); }catch{}
988
+ if(!blob&&["image/jpeg","image/png","image/webp"].includes(file.type)) blob=file;
989
+ if(RES!==mine) return;
990
+ if(!blob||blob.size>5000000){ mine.msg="לא הצלחנו להכין את התמונה. צלמו שוב."; return drawResources(); }
991
+ if(mine.preview) URL.revokeObjectURL(mine.preview);
992
+ mine.photo=blob; mine.preview=URL.createObjectURL(blob); mine.msg="בחרו מה יש בתמונה, ואם תרצו גם את הנושא.";
993
+ drawResources();
994
+ sheet.querySelector(".mx-res-form")?.scrollIntoView({block:"start"});
995
+ }
996
+ async function saveResource(form){
997
+ const mine=RES, kind=form.elements.kind.value, note=form.elements.note.value.trim(), skill=form.elements.skill.value;
998
+ const status=sheet.querySelector(".mx-res-msg");
999
+ if(!kind){ status.textContent="בחרו מה יש בתמונה: תרגיל חדש, הסבר תיאורטי או אחר."; return; }
1000
+ if(kind==="other"&&!note){ status.textContent="כתבו בכמה מילים מה יש בתמונה."; form.elements.note.focus(); return; }
1001
+ mine.busy=true; form.querySelectorAll("button").forEach(b=>b.disabled=true); status.textContent="שומרים…";
1002
+ try{
1003
+ const body=new FormData(); body.append("photo",mine.photo,"resource.jpg"); body.append("kind",kind); body.append("note",kind==="other"?note:""); body.append("skill",skill);
1004
+ const res=await api(`/api/math/units/${encodeURIComponent(mine.unit.id)}/resources`,{method:"POST",body}), data=await res.json().catch(()=>({}));
1005
+ if(!res.ok) throw new Error(resError(res.status,"לא הצלחנו לשמור. נסו שוב."));
1006
+ mine.urls[data.id]=URL.createObjectURL(mine.photo); // the saved photo, without downloading it again
1007
+ mine.items=[data,...(mine.items||[])];
1008
+ URL.revokeObjectURL(mine.preview); mine.photo=mine.preview=null; mine.msg="התמונה נשמרה במשאבי השיעור.";
1009
+ }catch(e){ mine.msg=e.message; }
1010
+ mine.busy=false;
1011
+ if(RES===mine&&cur==="resources") drawResources();
1012
+ }
1013
+ async function deleteResource(id){
1014
+ const mine=RES; if(!mine||!confirm("למחוק את התמונה ממשאבי השיעור?")) return;
1015
+ try{
1016
+ const res=await api(`/api/math/resources/${encodeURIComponent(id)}`,{method:"DELETE"});
1017
+ if(!res.ok) throw new Error(resError(res.status,"לא הצלחנו למחוק. נסו שוב."));
1018
+ mine.items=mine.items.filter(r=>r.id!==id);
1019
+ if(mine.urls[id]) URL.revokeObjectURL(mine.urls[id]);
1020
+ delete mine.urls[id]; mine.msg="התמונה נמחקה.";
1021
+ }catch(e){ mine.msg=e.message; }
1022
+ if(RES===mine&&cur==="resources") drawResources();
1023
+ }
1024
+
1025
  function geometryDiagram(diagram){
1026
  const triangle=diagram.type==="triangle";
1027
  return `<div class="mx-geometry"><svg viewBox="0 0 320 210" role="img" aria-label="${esc(diagram.alt)}">
supabase/migrations/20261101090000_math_unit_resources.sql ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- Lesson resources: photos teachers add to a unit. Additive; existing tables are untouched.
2
+ begin;
3
+ create table public.math_unit_resources (
4
+ id uuid primary key default gen_random_uuid(),
5
+ unit_id text not null,
6
+ kind text not null check (kind in ('exercise','theory','other')),
7
+ note text not null default '' check (char_length(note) <= 200),
8
+ skill_id text,
9
+ object_key text not null unique,
10
+ byte_size integer not null check (byte_size > 0 and byte_size <= 5000000),
11
+ created_by uuid references auth.users(id) on delete set null,
12
+ created_email text not null default '',
13
+ created_at timestamptz not null default now(),
14
+ check (kind <> 'other' or note <> '')
15
+ );
16
+ create index math_unit_resources_unit on public.math_unit_resources(unit_id, created_at desc);
17
+ alter table public.math_unit_resources enable row level security;
18
+ -- Only the backend (service role) reads or writes; it checks the teacher first.
19
+ revoke all on public.math_unit_resources from anon, authenticated;
20
+ grant all on public.math_unit_resources to service_role;
21
+
22
+ insert into storage.buckets(id, name, public, file_size_limit, allowed_mime_types)
23
+ values ('mathly-resources', 'mathly-resources', false, 5000000, array['image/jpeg']);
24
+ -- Restrictive, so a permissive "all buckets" policy cannot open it.
25
+ create policy math_resource_private on storage.objects as restrictive for all to anon, authenticated
26
+ using (bucket_id <> 'mathly-resources') with check (bucket_id <> 'mathly-resources');
27
+ commit;
tests/test_unit_resources.py ADDED
@@ -0,0 +1,291 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Lesson resources: teachers photograph exercises and explanations for a unit."""
2
+ import asyncio
3
+ import io
4
+ import re
5
+ import socket
6
+ import threading
7
+ import time
8
+ import uuid
9
+ from pathlib import Path
10
+
11
+ import httpx
12
+ import pytest
13
+ import uvicorn
14
+ from PIL import Image
15
+
16
+ from app.main import create_app
17
+ from app.unit_resources import UnitResources
18
+ from scan_support import BOOTSTRAP, postgres_server
19
+
20
+ TEACHER = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
21
+ COLLEAGUE = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
22
+ STUDENT = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
23
+ ADMIN = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'
24
+ ROLES = {TEACHER: ['teacher'], COLLEAGUE: ['teacher'], STUDENT: ['student'], ADMIN: ['admin']}
25
+
26
+
27
+ class Auth:
28
+ url = 'https://test.invalid'
29
+ key = 'public-test'
30
+ secret = 'server-test'
31
+ teachers = frozenset()
32
+ developers = frozenset()
33
+
34
+ def user(self, token):
35
+ return {'id': token, 'email': token[:4] + '@test.invalid'} if token in ROLES else None
36
+
37
+ def classes(self):
38
+ return []
39
+
40
+ def access_for(self, user_id, **_kwargs):
41
+ return {'roles': ROLES[user_id]}
42
+
43
+
44
+ class MemoryResources(UnitResources):
45
+ """The storage calls, kept in memory."""
46
+
47
+ def __init__(self):
48
+ super().__init__(Auth())
49
+ self.rows, self.files = {}, {}
50
+
51
+ def list(self, unit_id):
52
+ return sorted((r for r in self.rows.values() if r['unit_id'] == unit_id), key=lambda r: r['created_at'], reverse=True)
53
+
54
+ def get(self, resource_id):
55
+ return self.rows.get(resource_id)
56
+
57
+ def add(self, row):
58
+ row = {**row, 'id': str(uuid.uuid4()), 'created_at': f'2026-11-01T10:{len(self.rows):02d}:00Z'}
59
+ self.rows[row['id']] = row
60
+ return row
61
+
62
+ def remove(self, resource_id):
63
+ self.rows.pop(resource_id, None)
64
+
65
+ def put(self, key, data):
66
+ self.files[key] = data
67
+
68
+ def read(self, key):
69
+ return self.files[key]
70
+
71
+ def delete(self, key):
72
+ self.files.pop(key, None)
73
+
74
+
75
+ def png(color='white'):
76
+ out = io.BytesIO()
77
+ Image.new('RGB', (200, 120), color).save(out, 'PNG')
78
+ return out.getvalue()
79
+
80
+
81
+ def app_with(store):
82
+ app = create_app(auth=Auth())
83
+ app.state.resources = store
84
+ return app
85
+
86
+
87
+ def client(app, token):
88
+ return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test',
89
+ headers={'Authorization': f'Bearer {token}'})
90
+
91
+
92
+ def add(c, unit='t', kind='exercise', note='', skill=''):
93
+ return c.post(f'/api/math/units/{unit}/resources', data={'kind': kind, 'note': note, 'skill': skill},
94
+ files={'photo': ('p.png', png(), 'image/png')})
95
+
96
+
97
+ def test_teacher_adds_lists_opens_and_deletes_lesson_photos():
98
+ store = MemoryResources()
99
+ app = app_with(store)
100
+
101
+ async def check():
102
+ async with client(app, TEACHER) as teacher, client(app, COLLEAGUE) as colleague, \
103
+ client(app, STUDENT) as student, client(app, ADMIN) as admin:
104
+ # an exercise on a topic of the lesson
105
+ saved = await add(teacher, skill='t_ext')
106
+ assert saved.status_code == 201
107
+ first = saved.json()
108
+ assert first['kind'] == 'exercise' and first['skill_id'] == 't_ext' and first['note'] == ''
109
+ assert 'object_key' not in first
110
+ # a theory explanation with no topic, and free text
111
+ assert (await add(teacher, kind='theory')).json()['skill_id'] is None
112
+ other = (await add(teacher, kind='other', note=' סיכום השיעור ')).json()
113
+ assert other['note'] == 'סיכום השיעור'
114
+ # what the form refuses
115
+ assert (await add(teacher, kind='photo')).status_code == 400
116
+ assert (await add(teacher, kind='other', note=' ')).status_code == 400
117
+ assert (await add(teacher, kind='other', note='x' * 201)).status_code == 400
118
+ assert (await add(teacher, skill='q_vertex')).status_code == 400 # a topic of another lesson
119
+ assert (await add(teacher, unit='nope')).status_code == 404
120
+ bad = await teacher.post('/api/math/units/t/resources', data={'kind': 'exercise'},
121
+ files={'photo': ('p.txt', b'not an image', 'text/plain')})
122
+ assert bad.status_code == 415
123
+ assert len(store.rows) == len(store.files) == 3
124
+
125
+ listed = (await colleague.get('/api/math/units/t/resources')).json()['resources']
126
+ assert [r['kind'] for r in listed] == ['other', 'theory', 'exercise']
127
+ assert all('object_key' not in r for r in listed)
128
+ assert (await teacher.get('/api/math/units/t2/resources')).json() == {'resources': []}
129
+
130
+ image = await colleague.get(f"/api/math/resources/{first['id']}/image")
131
+ assert image.status_code == 200 and image.headers['content-type'] == 'image/jpeg'
132
+ assert Image.open(io.BytesIO(image.content)).format == 'JPEG' # re-encoded, metadata dropped
133
+ assert (await teacher.get('/api/math/resources/not-a-uuid/image')).status_code == 404
134
+
135
+ # students neither see nor add resources
136
+ assert (await student.get('/api/math/units/t/resources')).status_code == 403
137
+ assert (await add(student)).status_code == 403
138
+ assert (await student.get(f"/api/math/resources/{first['id']}/image")).status_code == 403
139
+
140
+ # only the teacher who added a photo, or a manager, deletes it
141
+ assert (await colleague.delete(f"/api/math/resources/{first['id']}")).status_code == 403
142
+ assert (await teacher.delete(f"/api/math/resources/{first['id']}")).json() == {'ok': True}
143
+ assert (await admin.delete(f"/api/math/resources/{other['id']}")).json() == {'ok': True}
144
+ assert (await teacher.delete(f"/api/math/resources/{other['id']}")).status_code == 404
145
+ assert len(store.rows) == len(store.files) == 1
146
+
147
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as guest:
148
+ assert (await guest.get('/api/math/units/t/resources')).status_code == 401
149
+
150
+ asyncio.run(check())
151
+ assert [a['kind'] for a in app.state.actions.recent()].count('resource_added') == 3
152
+
153
+
154
+ def test_resources_fail_closed_without_storage_key():
155
+ store = MemoryResources()
156
+ store.auth = type('NoSecret', (Auth,), {'secret': ''})()
157
+ app = app_with(store)
158
+
159
+ async def check():
160
+ async with client(app, TEACHER) as teacher:
161
+ assert (await teacher.get('/api/math/units/t/resources')).status_code == 503
162
+ asyncio.run(check())
163
+
164
+
165
+ def test_migration_keeps_resources_private(postgres_server):
166
+ psycopg = pytest.importorskip('psycopg')
167
+ database = 'res_' + uuid.uuid4().hex
168
+ with psycopg.connect(postgres_server.get_uri(), autocommit=True) as c:
169
+ c.execute('create database ' + database)
170
+ try:
171
+ with psycopg.connect(postgres_server.get_uri(database=database), autocommit=True) as c:
172
+ c.execute(BOOTSTRAP)
173
+ c.execute(Path('supabase/migrations/20261101090000_math_unit_resources.sql').read_text())
174
+ c.execute('insert into auth.users values(%s)', (TEACHER,))
175
+ assert c.execute("select public from storage.buckets where id='mathly-resources'").fetchone()[0] is False
176
+ c.execute("set role service_role")
177
+ c.execute("insert into math_unit_resources(unit_id,kind,skill_id,object_key,byte_size,created_by) "
178
+ "values('t','exercise','t_ext','t/a.jpg',10,%s)", (TEACHER,))
179
+ for bad in ("('t','other','','t/b.jpg',10)", "('t','photo','','t/c.jpg',10)", "('t','theory','','t/a.jpg',10)"):
180
+ with pytest.raises(psycopg.errors.IntegrityError):
181
+ c.execute(f"insert into math_unit_resources(unit_id,kind,note,object_key,byte_size) values{bad}")
182
+ c.execute("insert into storage.objects(bucket_id,name) values('mathly-resources','t/a.jpg')")
183
+ c.execute('reset role')
184
+ c.execute('set role authenticated')
185
+ with pytest.raises(psycopg.errors.InsufficientPrivilege):
186
+ c.execute('select * from math_unit_resources')
187
+ assert c.execute("select count(*) from storage.objects where bucket_id='mathly-resources'").fetchone()[0] == 0
188
+ finally:
189
+ with psycopg.connect(postgres_server.get_uri(), autocommit=True) as c:
190
+ c.execute('drop database ' + database + ' with (force)')
191
+
192
+
193
+ @pytest.fixture
194
+ def resource_server():
195
+ store = MemoryResources()
196
+ app = app_with(store)
197
+ with socket.socket() as sock:
198
+ sock.bind(('127.0.0.1', 0))
199
+ server = uvicorn.Server(uvicorn.Config(app, log_level='error'))
200
+ thread = threading.Thread(target=server.run, kwargs={'sockets': [sock]}, daemon=True)
201
+ thread.start()
202
+ try:
203
+ deadline = time.monotonic() + 5
204
+ while not server.started:
205
+ assert thread.is_alive() and time.monotonic() < deadline
206
+ time.sleep(.01)
207
+ yield f'http://127.0.0.1:{sock.getsockname()[1]}', store
208
+ finally:
209
+ server.should_exit = True
210
+ thread.join(timeout=5)
211
+
212
+
213
+ def phone(browser, account):
214
+ ctx = browser.new_context(viewport={'width': 390, 'height': 844}, is_mobile=True, has_touch=True)
215
+ ctx.route('https://cdn.jsdelivr.net/**', lambda route: route.abort())
216
+ ctx.add_init_script('''window.supabase={createClient:()=>({auth:{
217
+ getSession:async()=>({data:{session:{access_token:'%s',user:{id:'%s'}}}}),
218
+ onAuthStateChange:()=>({data:{subscription:{unsubscribe(){}}}}),signOut:async()=>{}}})};''' % (account, account))
219
+ return ctx.new_page()
220
+
221
+
222
+ def test_teacher_photographs_a_lesson_resource_on_a_phone(resource_server, tmp_path):
223
+ playwright = pytest.importorskip('playwright.sync_api')
224
+ url, store = resource_server
225
+ photo = tmp_path / 'board.png'
226
+ photo.write_bytes(png('lightyellow'))
227
+ expect = playwright.expect
228
+ with playwright.sync_playwright() as p:
229
+ browser = p.chromium.launch()
230
+ try:
231
+ page = phone(browser, TEACHER)
232
+ errors = []
233
+ page.on('pageerror', lambda error: errors.append(str(error)))
234
+ page.goto(url)
235
+ page.locator('.mx-nav [data-view="path"]').click()
236
+ page.locator('#mx-unit-t .mx-uhead h2').click() # tapping the lesson's title
237
+ sheet = page.locator('.mx-res')
238
+ expect(sheet.locator('h3')).to_have_text('משאבי השיעור')
239
+ expect(sheet.locator('.mx-uchip')).to_have_text('טכניקה אלגברית 1')
240
+ expect(sheet).to_contain_text('עוד אין משאבים')
241
+ assert page.locator('input[capture="environment"]').count() == 1 # the camera opens directly
242
+
243
+ with page.expect_file_chooser() as chooser:
244
+ sheet.locator('[data-act="res-take"]').click()
245
+ chooser.value.set_files(str(photo))
246
+ form = sheet.locator('.mx-res-form')
247
+ expect(form.locator('img.mx-thumb')).to_be_visible()
248
+ form.locator('button[type=submit]').click()
249
+ expect(sheet.locator('.mx-res-msg')).to_contain_text('בחרו מה יש בתמונה')
250
+ assert not store.rows
251
+
252
+ form.locator('label', has_text='אחר').click()
253
+ expect(form.locator('input[name=note]')).to_be_visible()
254
+ form.locator('label', has_text='תרגיל חדש').click()
255
+ expect(form.locator('input[name=note]')).to_be_hidden()
256
+ form.locator('select[name=skill]').select_option('t_ext')
257
+ form.locator('button[type=submit]').click()
258
+ expect(sheet.locator('.mx-res-msg')).to_have_text('התמונה נשמרה במשאבי השיעור.')
259
+ item = sheet.locator('.mx-res-item')
260
+ expect(item).to_have_count(1)
261
+ expect(item).to_contain_text('תרגיל חדש')
262
+ expect(item).to_contain_text('פילוג מורחב')
263
+ (row,) = store.rows.values()
264
+ assert (row['unit_id'], row['kind'], row['skill_id']) == ('t', 'exercise', 't_ext')
265
+
266
+ # free text, with no topic
267
+ with page.expect_file_chooser() as chooser:
268
+ sheet.locator('[data-act="res-take"]').click()
269
+ chooser.value.set_files(str(photo))
270
+ form.locator('label', has_text='אחר').click()
271
+ form.locator('input[name=note]').fill('דף עבודה לשיעור בית')
272
+ form.locator('button[type=submit]').click()
273
+ expect(item).to_have_count(2)
274
+ expect(item.first).to_contain_text('דף עבודה לשיעור בית')
275
+
276
+ # reopening loads the saved photos from the server
277
+ sheet.locator('.mx-x').click()
278
+ page.locator('#mx-unit-t .mx-res-open').click()
279
+ expect(item).to_have_count(2)
280
+ expect(item.last.locator('img')).to_have_attribute('src', re.compile('^blob:'))
281
+ assert errors == []
282
+
283
+ student = phone(browser, STUDENT)
284
+ student.goto(url)
285
+ student.locator('.mx-nav [data-view="path"]').click()
286
+ expect(student.locator('#mx-unit-t')).to_be_visible()
287
+ expect(student.locator('.mx-res-open')).to_have_count(0)
288
+ student.locator('#mx-unit-t .mx-uhead h2').click()
289
+ expect(student.locator('.mx-res')).to_have_count(0)
290
+ finally:
291
+ browser.close()