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 +4 -0
- app/main.py +2 -0
- app/unit_resources.py +189 -0
- static/math.css +23 -0
- static/math.js +153 -7
- supabase/migrations/20261101090000_math_unit_resources.sql +27 -0
- tests/test_unit_resources.py +291 -0
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 |
-
|
| 250 |
-
|
|
|
|
|
|
|
| 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()
|