3v324v23 commited on
Commit
0818184
·
1 Parent(s): 96e3b4c

feat: implement server-side grading for unit quizzes

Browse files

- Added `math_quiz_sittings` table to store quiz sittings on the server.
- Introduced functions `math_quiz_sitting_json` and `math_quiz_action` for handling quiz data.
- Updated `sitting_view` to compute grades based on stored attempts and checks.
- Enhanced tests to validate the new grading logic and ensure correct behavior of quiz sittings.
- Modified existing tests to reflect changes in quiz grading and reporting.

README.md CHANGED
@@ -78,7 +78,9 @@ For rollback, turn `MATHLY_ANSWER_SCANS_ENABLED` off and restart; preserve the n
78
 
79
  ### Class teacher
80
 
81
- Each class has one teacher, chosen from its students in the class editor (**ניהול → כיתות → עריכה → מורה הכיתה**). That account gets a **הכיתה שלי** button that opens the class report: for every other student, the skills finished in the class's units, the mistakes that keep coming back, the **בוחן מסכם** grade per unit, and the **מבחני תרגול** results, plus the mistakes most common across the class. Managers open the same report from a class's card (**מעקב**). The report reads the students' saved progress (`math_progress`) and Supabase's user list with the server's secret key; it needs no new migration. A student's numbers show up once they have signed in and practised on the current version of the app.
 
 
82
 
83
  ### The teacher's AI queue
84
 
 
78
 
79
  ### Class teacher
80
 
81
+ Each class has one teacher, chosen from its students in the class editor (**ניהול → כיתות → עריכה → מורה הכיתה**). That account gets a **הכיתה שלי** button that opens the class report: for every other student, the skills finished in the class's units, the mistakes that keep coming back, the **בוחן מסכם** grade per unit, and the **מבחני תרגול** results, plus the mistakes most common across the class. Managers open the same report from a class's card (**מעקב**). The report reads the students' saved progress (`math_progress`) and Supabase's user list with the server's secret key. A student's numbers show up once they have signed in and practised on the current version of the app.
82
+
83
+ **What the report can be trusted for.** Progress is kept by the student's device, so skills finished, gold stars and practice-exam results are only what the device reports; the report labels them **נתוני תרגול מהמכשיר**. The **בוחן מסכם** grade is different: when a student hands a quiz in, the server stores the sitting (`math_quiz_sittings`) and works the grade out itself from the stored checks and the quiz's points (`app/quiz_grades.py`), so a score edited in the browser changes nothing. A sitting has no grade while any photographed answer still waits for its check (the report says **ממתין לבדיקה**); a question without a photo earns 0. Submissions from before this was deployed exist only on the student's device and show as **לא מאומת**, with no grade. Apply [the sittings migration](supabase/migrations/20261104120000_math_quiz_sittings.sql) (after the answer-scan and grading migrations) before deploying this code. Without it, handing a quiz in fails with "quiz sittings unavailable" and the report shows no quiz grades.
84
 
85
  ### The teacher's AI queue
86
 
app/answer_routes.py CHANGED
@@ -14,6 +14,7 @@ from .ai_queue import AiQueue, for_student, may_send
14
  from .answer_scans import AnswerScans, MAX_IMAGE, ScanError, clean_image, public_result, uuid_text
15
  from .answer_grading import AnswerGrader
16
  from .math_photo import PhotoUnavailable
 
17
  from .math_skills import algebra9, question_version, quiz_snapshot
18
 
19
  log = logging.getLogger("mathly.answer_routes")
@@ -117,6 +118,27 @@ def install_answer_routes(app, signed_in):
117
  return reply(call(user, "resume", question_id=f"quiz:{unit['id']}:{index}", question_version=version,
118
  snapshot=snapshot, new=body.get("new") is True))
119
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  @app.get("/api/math/answer-attempts/{attempt_id}")
121
  def get_attempt(attempt_id: str, user=Depends(student)):
122
  return reply(call(user, "get", attempt_id=uuid_text(attempt_id)))
 
14
  from .answer_scans import AnswerScans, MAX_IMAGE, ScanError, clean_image, public_result, uuid_text
15
  from .answer_grading import AnswerGrader
16
  from .math_photo import PhotoUnavailable
17
+ from .quiz_grades import sitting_view
18
  from .math_skills import algebra9, question_version, quiz_snapshot
19
 
20
  log = logging.getLogger("mathly.answer_routes")
 
118
  return reply(call(user, "resume", question_id=f"quiz:{unit['id']}:{index}", question_version=version,
119
  snapshot=snapshot, new=body.get("new") is True))
120
 
121
+ @app.post("/api/math/quiz-sittings")
122
+ def submit_sitting(body: dict, user=Depends(student)):
123
+ """Hands a unit quiz in: the sitting is kept on the server and its grade is worked out there from the checks
124
+ (app/quiz_grades.py). Sending the same `client_key` again returns the same sitting."""
125
+ if open_ended_locked():
126
+ raise ScanError(403, "open_ended_locked")
127
+ unit = next((u for u in algebra9()["units"] if u["id"] == body.get("unit_id") and "quiz" in u), None)
128
+ attempts = body.get("attempts")
129
+ if not unit or not isinstance(attempts, list) or len(attempts) != len(unit["quiz"]["questions"]):
130
+ raise ScanError(404, "not_found")
131
+ sitting = app.state.answers.quiz(user, "submit", unit_id=unit["id"], attempt_ids=[uuid_text(a) for a in attempts],
132
+ time_up=body.get("time_up") is True, client_key=uuid_text(body.get("client_key")))
133
+ return JSONResponse(sitting_view(sitting), headers={"Cache-Control": "no-store"})
134
+
135
+ @app.get("/api/math/quiz-sittings")
136
+ def my_sittings(unit_id: str | None = None, user=Depends(student)):
137
+ """The student's own sittings (newest first), each with the grade the server worked out."""
138
+ data = {"unit_id": unit_id} if unit_id else {}
139
+ views = [sitting_view(s) for s in app.state.answers.quiz(user, "list", **data)]
140
+ return JSONResponse({"sittings": [v for v in views if v]}, headers={"Cache-Control": "no-store"})
141
+
142
  @app.get("/api/math/answer-attempts/{attempt_id}")
143
  def get_attempt(attempt_id: str, user=Depends(student)):
144
  return reply(call(user, "get", attempt_id=uuid_text(attempt_id)))
app/answer_scans.py CHANGED
@@ -146,6 +146,19 @@ class AnswerScans:
146
  raw = self._request("/rest/v1/rpc/math_grade_action", "POST", json.dumps(body).encode(), "application/json")
147
  return json.loads(raw)
148
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  def _request(self, path, method="GET", data=None, content_type=None):
150
  headers = {"Authorization": "Bearer " + self.auth.secret, "apikey": self.auth.secret,
151
  "User-Agent": "mathly/1.0"}
@@ -167,7 +180,7 @@ class AnswerScans:
167
  if code == "P0001" and message == "grading_limit":
168
  raise ScanError(429, message) from exc
169
  if code == "P0001" and message in {"superseded", "invalid_upload", "upload_in_progress", "confirmation_required",
170
- "answer_required", "grading_busy", "grading_interrupted"}:
171
  raise ScanError(409, message) from exc
172
  raise ScanError(503, "answer_storage_unavailable") from exc
173
  except (urllib.error.URLError, TimeoutError, OSError) as exc:
 
146
  raw = self._request("/rest/v1/rpc/math_grade_action", "POST", json.dumps(body).encode(), "application/json")
147
  return json.loads(raw)
148
 
149
+ def quiz(self, user_id: str | None, action: str, **data):
150
+ """Unit-quiz sittings (app/quiz_grades.py): submit, list a student's own, or `report` for a set of accounts."""
151
+ if not self.configured:
152
+ raise ScanError(503, "answer_storage_unavailable")
153
+ body = {"p_user": user_id, "p_action": action, "p_data": data}
154
+ try:
155
+ raw = self._request("/rest/v1/rpc/math_quiz_action", "POST", json.dumps(body).encode(), "application/json")
156
+ except ScanError as exc:
157
+ if exc.code == "answer_storage_unavailable":
158
+ raise ScanError(503, "quiz_sittings_unavailable") from exc
159
+ raise
160
+ return json.loads(raw)
161
+
162
  def _request(self, path, method="GET", data=None, content_type=None):
163
  headers = {"Authorization": "Bearer " + self.auth.secret, "apikey": self.auth.secret,
164
  "User-Agent": "mathly/1.0"}
 
180
  if code == "P0001" and message == "grading_limit":
181
  raise ScanError(429, message) from exc
182
  if code == "P0001" and message in {"superseded", "invalid_upload", "upload_in_progress", "confirmation_required",
183
+ "answer_required", "grading_busy", "grading_interrupted", "invalid_sitting"}:
184
  raise ScanError(409, message) from exc
185
  raise ScanError(503, "answer_storage_unavailable") from exc
186
  except (urllib.error.URLError, TimeoutError, OSError) as exc:
app/class_report.py CHANGED
@@ -4,6 +4,10 @@ A class has one teacher, chosen from its students (`classes.teachers`). That acc
4
  GET /api/classes/{id}/report: for each other student of the class, the parts of their saved progress
5
  (app/progress.py) the teacher's page shows. The page works out the rest (stars, mistake patterns) the same way the
6
  student's own page does.
 
 
 
 
7
  """
8
  from __future__ import annotations
9
 
@@ -13,7 +17,9 @@ from fastapi import Depends, HTTPException
13
  from fastapi.responses import JSONResponse
14
 
15
  from .access import has_permission
 
16
  from .auth import AuthUnavailable
 
17
 
18
  log = logging.getLogger("mathly.class_report")
19
  TABLE = "/rest/v1/math_progress"
@@ -36,13 +42,31 @@ def report_part(data: dict) -> dict:
36
  history = state.get("history") if isinstance(state, dict) else None
37
  sitting = state.get("sitting") if isinstance(state, dict) else None
38
  unit_quizzes[unit] = {
39
- "history": [{k: h.get(k) for k in ("at", "score", "timeUp")} for h in history or [] if isinstance(h, dict)],
 
40
  "going": bool(isinstance(sitting, dict) and sitting.get("started") and not sitting.get("submitted"))}
41
  return {"done": obj("done"), "gold": obj("gold"), "days": obj("days"), "missed": obj("missed"),
42
  "slips": items("slips")[-SLIPS_SENT:], "quizzes": quizzes, "uq": unit_quizzes,
43
  "updated": data.get("updated")}
44
 
45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  def install_class_report_routes(app, signed_in, units_of):
47
  def class_of(class_id: int, user) -> dict:
48
  try:
@@ -72,12 +96,14 @@ def install_class_report_routes(app, signed_in, units_of):
72
  log.error("class report failed: %s", exc)
73
  raise HTTPException(503, "report_unavailable") from exc
74
  by_id = {r["user_id"]: r for r in rows or []}
 
75
  out = []
76
  for email in students:
77
  saved = by_id.get(ids.get(email, ""))
78
  data = saved.get("data") if saved else None
79
  out.append({"email": email, "signed_in": email in ids,
80
  "progress": report_part(data) if isinstance(data, dict) else None,
81
- "saved_at": saved.get("updated_at") if saved else None})
 
82
  return JSONResponse({"class": {"id": row["id"], "name": row["name"], "lessons": units_of(row)},
83
  "students": out}, headers={"Cache-Control": "no-store"})
 
4
  GET /api/classes/{id}/report: for each other student of the class, the parts of their saved progress
5
  (app/progress.py) the teacher's page shows. The page works out the rest (stars, mistake patterns) the same way the
6
  student's own page does.
7
+
8
+ Progress is kept by the student's device, so everything in it is only what the device reports. The unit quizzes'
9
+ grades are the exception: they come from the server's own record of each sitting (app/quiz_grades.py), and a score
10
+ in the uploaded progress is never passed on.
11
  """
12
  from __future__ import annotations
13
 
 
17
  from fastapi.responses import JSONResponse
18
 
19
  from .access import has_permission
20
+ from .answer_scans import ScanError
21
  from .auth import AuthUnavailable
22
+ from .quiz_grades import sitting_view
23
 
24
  log = logging.getLogger("mathly.class_report")
25
  TABLE = "/rest/v1/math_progress"
 
42
  history = state.get("history") if isinstance(state, dict) else None
43
  sitting = state.get("sitting") if isinstance(state, dict) else None
44
  unit_quizzes[unit] = {
45
+ # no score: the device's copy of a grade is not evidence. `sid` ties a submission to the server's sitting.
46
+ "history": [{k: h.get(k) for k in ("at", "timeUp", "sid")} for h in history or [] if isinstance(h, dict)],
47
  "going": bool(isinstance(sitting, dict) and sitting.get("started") and not sitting.get("submitted"))}
48
  return {"done": obj("done"), "gold": obj("gold"), "days": obj("days"), "missed": obj("missed"),
49
  "slips": items("slips")[-SLIPS_SENT:], "quizzes": quizzes, "uq": unit_quizzes,
50
  "updated": data.get("updated")}
51
 
52
 
53
+ def server_sittings(app, ids: dict[str, str]):
54
+ """The students' unit-quiz sittings with the server's grades, by email. None when they cannot be read: the report
55
+ then shows no quiz grades, rather than the device's."""
56
+ try:
57
+ raw = app.state.answers.quiz(None, "report", users=list(ids.values())) if ids else []
58
+ except ScanError as exc:
59
+ log.warning("quiz sittings not read: %s", exc.code)
60
+ return None
61
+ email_of = {user_id: email for email, user_id in ids.items()}
62
+ out: dict[str, list] = {email: [] for email in ids}
63
+ for sitting in raw:
64
+ view = sitting_view(sitting)
65
+ if view and sitting.get("user_id") in email_of:
66
+ out[email_of[sitting["user_id"]]].append(view)
67
+ return out
68
+
69
+
70
  def install_class_report_routes(app, signed_in, units_of):
71
  def class_of(class_id: int, user) -> dict:
72
  try:
 
96
  log.error("class report failed: %s", exc)
97
  raise HTTPException(503, "report_unavailable") from exc
98
  by_id = {r["user_id"]: r for r in rows or []}
99
+ sittings = server_sittings(app, ids)
100
  out = []
101
  for email in students:
102
  saved = by_id.get(ids.get(email, ""))
103
  data = saved.get("data") if saved else None
104
  out.append({"email": email, "signed_in": email in ids,
105
  "progress": report_part(data) if isinstance(data, dict) else None,
106
+ "saved_at": saved.get("updated_at") if saved else None,
107
+ "quiz_sittings": None if sittings is None else sittings.get(email, [])})
108
  return JSONResponse({"class": {"id": row["id"], "name": row["name"], "lessons": units_of(row)},
109
  "students": out}, headers={"Cache-Control": "no-store"})
app/quiz_grades.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit-quiz grades, worked out on the server from the stored checks.
2
+
3
+ A sitting (Supabase `math_quiz_sittings`, written when the student hands the quiz in) lists its attempts in question
4
+ order. The database says, for each, whether it has a saved photo and what its latest check is; the quiz's points per
5
+ question are in app/math_algebra9.json. The grade is computed here every time it is read, so it follows a check that
6
+ finishes later (for example one the teacher sends from the AI queue), and nothing the student's device uploads can
7
+ change it. Both the student's result screen and the class report use `sitting_view`.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from .math_skills import algebra9
12
+
13
+
14
+ def question_result(points: int, attempt: dict | None) -> dict:
15
+ """One question of a sitting: its points, what it earned (None while its check is open) and why."""
16
+ if not attempt or not attempt.get("has_photo"):
17
+ return {"points": points, "earned": 0, "state": "missing"}
18
+ score = attempt.get("score")
19
+ if attempt.get("grade_state") == "completed" and type(score) is int and 0 <= score <= 100:
20
+ return {"points": points, "earned": int(points * score / 100 + 0.5), "state": "checked",
21
+ "verdict": attempt.get("verdict")}
22
+ if attempt.get("grade_state") == "completed": # a check without a score: unreadable, or needs a teacher's look
23
+ return {"points": points, "earned": None, "state": attempt.get("verdict") or "waiting"}
24
+ return {"points": points, "earned": None, "state": "waiting"}
25
+
26
+
27
+ def sitting_view(sitting: dict, units: list[dict] | None = None) -> dict | None:
28
+ """A stored sitting as the page reads it: when, the grade (None while an answer is open), the open count and
29
+ each question's points. None for a unit that has no quiz."""
30
+ unit = next((u for u in units or algebra9()["units"] if u["id"] == sitting.get("unit_id")), None)
31
+ if not unit or "quiz" not in unit:
32
+ return None
33
+ attempts = sitting.get("attempts") or []
34
+ questions = [question_result(q["points"], attempts[i] if i < len(attempts) else None)
35
+ for i, q in enumerate(unit["quiz"]["questions"])]
36
+ open_count = sum(1 for q in questions if q["earned"] is None)
37
+ return {"id": sitting["id"], "unit_id": unit["id"], "submitted_at": sitting.get("submitted_at"),
38
+ "time_up": bool(sitting.get("time_up")), "attempts": [a.get("attempt_id") for a in attempts],
39
+ "questions": questions, "open": open_count,
40
+ "grade": None if open_count else sum(q["earned"] for q in questions)}
openspec/changes/add-server-quiz-grades/tasks.md CHANGED
@@ -2,20 +2,20 @@
2
 
3
  ## 1. Sitting storage
4
 
5
- - [ ] 1.1 Add migration `supabase/migrations/<ts>_math_quiz_sittings.sql`: table `math_quiz_sittings` (id, user_id → auth.users on delete cascade, unit_id, attempt_ids uuid[], submitted_at, time_up, client_key, unique(user_id, client_key)), RLS on, service_role only. Verify it applies cleanly to the disposable test PostgreSQL used by `tests/scan_support.py`.
6
- - [ ] 1.2 Add the grade function (points × check score, 0 for a missing photo, open while any photographed answer lacks a scored completed check). Verify with unit tests covering all-checked, waiting, unreadable, needs_review and missing-photo cases.
7
 
8
  ## 2. Student routes
9
 
10
- - [ ] 2.1 Add `POST /api/math/quiz-sittings` with ownership, question-position checks and an idempotent `client_key`. Verify with tests in `tests/test_unit_quiz.py`: success, another student's attempt, wrong question order, repeated submit returning the same id.
11
- - [ ] 2.2 Add `GET /api/math/quiz-sittings?unit_id=` returning the grade, open count and per-question points. Verify with a test where one check completes after submission and the grade appears.
12
- - [ ] 2.3 In `static/math.js`, make `uqSubmit` post the sitting (with retry on next open when it failed) and make `uqResult` show the server grade. Verify with a browser test where the device progress has a forged score and the screen shows the server's.
13
 
14
  ## 3. Class report
15
 
16
- - [ ] 3.1 In `app/class_report.py`, load sittings and their grades for the class's students and stop sending `uq` scores from the progress object. Verify in `tests/test_class_report.py` that a forged progress score does not appear and the server grade does.
17
- - [ ] 3.2 In the report UI, show server grades, "ממתין לבדיקה" for open sittings, "לא מאומת" for old device-only sittings, and the device-data label on the skills and practice-exam sections. Verify with a browser test of the report.
18
- - [ ] 3.3 Update the teacher tab in `static/guide.js` and the class-teacher and migration sections of `README.md`. Verify `tests/test_guide_browser.py` still passes.
19
 
20
  ## 4. Integration
21
 
 
2
 
3
  ## 1. Sitting storage
4
 
5
+ - [x] 1.1 Add migration `supabase/migrations/<ts>_math_quiz_sittings.sql`: table `math_quiz_sittings` (id, user_id → auth.users on delete cascade, unit_id, attempt_ids uuid[], submitted_at, time_up, client_key, unique(user_id, client_key)), RLS on, service_role only. Verify it applies cleanly to the disposable test PostgreSQL used by `tests/scan_support.py`.
6
+ - [x] 1.2 Add the grade function (points × check score, 0 for a missing photo, open while any photographed answer lacks a scored completed check). Verify with unit tests covering all-checked, waiting, unreadable, needs_review and missing-photo cases.
7
 
8
  ## 2. Student routes
9
 
10
+ - [x] 2.1 Add `POST /api/math/quiz-sittings` with ownership, question-position checks and an idempotent `client_key`. Verify with tests in `tests/test_unit_quiz.py`: success, another student's attempt, wrong question order, repeated submit returning the same id.
11
+ - [x] 2.2 Add `GET /api/math/quiz-sittings?unit_id=` returning the grade, open count and per-question points. Verify with a test where one check completes after submission and the grade appears.
12
+ - [x] 2.3 In `static/math.js`, make `uqSubmit` post the sitting (with retry on next open when it failed) and make `uqResult` show the server grade. Verify with a browser test where the device progress has a forged score and the screen shows the server's.
13
 
14
  ## 3. Class report
15
 
16
+ - [x] 3.1 In `app/class_report.py`, load sittings and their grades for the class's students and stop sending `uq` scores from the progress object. Verify in `tests/test_class_report.py` that a forged progress score does not appear and the server grade does.
17
+ - [x] 3.2 In the report UI, show server grades, "ממתין לבדיקה" for open sittings, "לא מאומת" for old device-only sittings, and the device-data label on the skills and practice-exam sections. Verify with a browser test of the report.
18
+ - [x] 3.3 Update the teacher tab in `static/guide.js` and the class-teacher and migration sections of `README.md`. Verify `tests/test_guide_browser.py` still passes.
19
 
20
  ## 4. Integration
21
 
static/guide.js CHANGED
@@ -44,7 +44,8 @@ const TEACHER=[
44
  <ul><li>המיומנויות שסיים ביחידות של הכיתה.</li>
45
  <li>הטעויות שחוזרות אצלו.</li>
46
  <li>הציון ב<b>בוחן המסכם</b> של כל יחידה, ותוצאות <b>מבחני התרגול</b>.</li></ul>
47
- <p>בראש הדוח מופיעות הטעויות הנפוצות בכל הכיתה, כדי לדעת מה כדאי לחזור עליו בשיעור. הנתונים של תלמיד מופיעים אחרי שנכנס לחשבון ותרגל.</p>`],
 
48
  ["📷","משאבי שיעור",`<p>במסלול המתמטיקה, לוחצים על שם יחידה (או על <b>📷 משאבים</b> מתחתיה) כדי לפתוח את המשאבים של השיעור.</p>
49
  <ul><li><b>📷 צילום תמונה</b>: מצלמים תרגיל או הסבר מהלוח או מהספר.</li>
50
  <li>מסמנים את הסוג: <b>תרגיל חדש</b>, <b>הסבר תיאורטי</b> או <b>אחר</b>, ואפשר לכתוב לאיזה נושא הוא שייך.</li>
 
44
  <ul><li>המיומנויות שסיים ביחידות של הכיתה.</li>
45
  <li>הטעויות שחוזרות אצלו.</li>
46
  <li>הציון ב<b>בוחן המסכם</b> של כל יחידה, ותוצאות <b>מבחני התרגול</b>.</li></ul>
47
+ <p>בראש הדוח מופיעות הטעויות הנפוצות בכל הכיתה, כדי לדעת מה כדאי לחזור עליו בשיעור. הנתונים של תלמיד מופיעים אחרי שנכנס לחשבון ותרגל.</p>
48
+ <p><b>אילו נתונים אפשר לסמוך עליהם:</b> ציוני הבוחן המסכם מחושבים בשרת מהבדיקות השמורות, והתלמיד לא יכול לשנות אותם. כשעוד לא כל התשובות נבדקו כתוב <b>ממתין לבדיקה</b> ואין ציון. הגשה מלפני העדכון, שהשרת לא שמר, מסומנת <b>לא מאומת</b> וללא ציון. התקדמות במיומנויות ומבחני התרגול מדווחים מהמכשיר של התלמיד, ומסומנים <b>נתוני תרגול מהמכשיר</b>.</p>`],
49
  ["📷","משאבי שיעור",`<p>במסלול המתמטיקה, לוחצים על שם יחידה (או על <b>📷 משאבים</b> מתחתיה) כדי לפתוח את המשאבים של השיעור.</p>
50
  <ul><li><b>📷 צילום תמונה</b>: מצלמים תרגיל או הסבר מהלוח או מהספר.</li>
51
  <li>מסמנים את הסוג: <b>תרגיל חדש</b>, <b>הסבר תיאורטי</b> או <b>אחר</b>, ואפשר לכתוב לאיזה נושא הוא שייך.</li>
static/math.css CHANGED
@@ -809,3 +809,4 @@ body{--keyboard-zindex:3000}
809
  .mx-error-details[open]>summary{border-color:var(--sumac)}
810
  .mx-error-details p{margin:8px 0 0}
811
  .mx-error-details code{display:block;margin-top:8px;padding:8px 10px;border-radius:8px;background:var(--surface-2);color:var(--muted);font-size:12px;white-space:pre-wrap;overflow-wrap:anywhere;text-align:left}
 
 
809
  .mx-error-details[open]>summary{border-color:var(--sumac)}
810
  .mx-error-details p{margin:8px 0 0}
811
  .mx-error-details code{display:block;margin-top:8px;padding:8px 10px;border-radius:8px;background:var(--surface-2);color:var(--muted);font-size:12px;white-space:pre-wrap;overflow-wrap:anywhere;text-align:left}
812
+ .mx-device{font-weight:400;color:var(--muted)}
static/math.js CHANGED
@@ -819,7 +819,7 @@ async function uqJson(url,options={}){
819
  return d;
820
  }
821
  const uqPost=(url,body={})=>uqJson(url,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)});
822
- const UQ_ERRORS={question_changed:"הבוחן עודכן. רעננו את העמוד והתחילו מחדש.",image_too_large:"התמונה גדולה מדי. צלמו שוב.",invalid_image:"לא ניתן לקרוא את התמונה. צלמו שוב.",
823
  unsupported_image:"בחרו תמונת JPEG, PNG או WebP.",answer_storage_unavailable:"שמירת תשובות אינה זמינה כרגע.",grading_unavailable:"הבדיקה האוטומטית אינה זמינה כרגע.",
824
  grading_failed:"הבדיקה נכשלה. התמונה נשמרה; אפשר לנסות שוב.",grading_limit:"הגעתם למכסת הבדיקות היומית. נסו שוב מחר.",grading_busy:"כבר מתבצעות בדיקות. נסו שוב בעוד רגע.",
825
  upload_in_progress:"העלאה כבר מתבצעת. רגע…",open_ended_locked:"בוחנים פתוחים סגורים כרגע באתר.",sign_in_required:"יש להיכנס לחשבון."};
@@ -973,10 +973,20 @@ function uqSubmit(timeUp=false){
973
  return;
974
  }
975
  sit.submitted=Date.now();
976
- const st=uqState(uid); st.history.push({at:new Date().toISOString(),attempts:sit.attempts.slice(),score:null,timeUp}); st.history=st.history.slice(-10);
977
  save(); draw();
978
  uqResult(uid,st.history.length-1,true);
979
  }
 
 
 
 
 
 
 
 
 
 
980
  // the report: each answer is checked now (once), then points per question, feedback and the solution
981
  async function uqResult(uid,h,check=false){
982
  const u=G.units.find(x=>x.id===uid), st=uqState(uid), entry=st.history[h];
@@ -989,6 +999,9 @@ async function uqResult(uid,h,check=false){
989
  <small class="mx-src" aria-live="polite">${CFG.aiDirect===false?"שולחים את התשובות לבדיקת המורה":"בודקים את התשובות, זה לוקח כחצי דקה לשאלה"}</small></div>
990
  <button class="mx-x" data-act="close" aria-label="סגירה">✕</button></div><div class="mx-uq-report"></div></div>`;
991
  sheet.hidden=false; sheet.querySelector(".mx-x").focus({preventScroll:true});
 
 
 
992
  const results=[];
993
  for(let i=0;i<n;i++){
994
  let r=null;
@@ -1004,16 +1017,17 @@ async function uqResult(uid,h,check=false){
1004
  if(UQ?.uid!==uid) return;
1005
  results.push(r);
1006
  }
1007
- // points: a checked answer earns its share; a missing one 0; one not checked yet leaves the total open
1008
- let earned=0, open=0;
 
 
1009
  const rows=u.quiz.questions.map((q,i)=>{
1010
- const r=results[i], g=r?.grading, a=g?.state==="completed"?g.result:null;
1011
- let pts=null, say="";
1012
- if(!r?.attachment) { pts=0; say="לא צולמה תשובה"; }
1013
- else if(a&&a.score!=null){ pts=Math.round(q.points*a.score/100); say={correct:"נכון ומלא",partial:"נכון בחלקו",incorrect:"לא נכון"}[a.verdict]||""; }
1014
- else { open++; const req=a?null:r.ai_request;
1015
  say=req&&["pending","sending","failed"].includes(req.state)?"מחכה למורה שיאשר את הבדיקה":req?.state==="declined"?`המורה לא אישר את הבדיקה${req.note?`: ${req.note}`:""}`:a?.verdict==="unreadable"?"לא הצלחנו לקרוא את התמונה":a?.verdict==="needs_review"?"נדרשת בדיקה של מורה":r.error||(!r.grading_available?"הבדיקה האוטומטית אינה זמינה כרגע":"עוד לא נבדקה"); }
1016
- if(pts!=null) earned+=pts;
1017
  return `<li class="mx-uq-q"><div class="mx-uq-qhead"><b>שאלה ${i+1}</b><span>${pts==null?"–":pts}/${q.points} נק׳</span></div>
1018
  <p class="mx-uq-text">${fmt(q.question).replace(/\n/g,"<br>")}</p>
1019
  ${UQ.images[i]?`<img class="mx-uq-img" alt="התשובה שלכם לשאלה ${i+1}" src="${UQ.images[i]}">`:""}
@@ -1022,12 +1036,14 @@ async function uqResult(uid,h,check=false){
1022
  ${a?.solution?`<details><summary>הפתרון המלא</summary><p class="mx-grade-solution">${fmtLoose(a.solution)}</p></details>`:""}
1023
  ${r?.attachment&&!a&&r.grading_available&&!["pending","sending","failed"].includes(r.ai_request?.state)?`<button class="mx-btn" data-uq-result="${uid}:${h}:check">${CFG.aiDirect===false?"שליחה מחדש למורה":"בדיקה נוספת"}</button>`:""}</li>`;
1024
  });
1025
- entry.score=open?null:earned; save(); draw();
1026
- const total=open?`${earned} נקודות עד עכשיו · ${open===1?"שאלה אחת לא נבדקה":`${open} שאלות לא נבדקו`}`:`${earned}/100`;
 
1027
  sheet.querySelector("#mxTitle").textContent=`בוחן מסכם: ${total}`;
1028
  sheet.querySelector(".mx-src").textContent=`הוגש ${new Date(entry.at).toLocaleString("he-IL",{dateStyle:"short",timeStyle:"short"})}${entry.timeUp?" · הוגש כשהזמן נגמר":""}`;
1029
  const weak=u.quiz.questions.map((q,i)=>i).filter(i=>{ const a=results[i]?.grading?.result; return !results[i]?.attachment||(a&&a.score!=null&&a.score<70); });
1030
  sheet.querySelector(".mx-uq-report").innerHTML=`<ol class="mx-uq-qs">${rows.join("")}</ol>
 
1031
  ${weak.length&&!open?`<p class="mx-note">כדאי לחזור על ${weak.map(i=>`שאלה ${i+1}`).join(", ")} ולתרגל את המיומנויות של היחידה במסלול לפני הבוחן הבא.</p>`:""}
1032
  <p class="mx-small">הבדיקה אוטומטית באמצעות AI: בדקו שהיא תואמת למה שכתבתם, ואם משהו נראה לא נכון, הראו למורה.</p>
1033
  <div class="mx-quiz-nav"><button class="mx-btn" data-uquiz="${uid}">לבוחן</button><button class="mx-btn solid" data-act="close">חזרה למסלול</button></div>`;
@@ -1443,11 +1459,17 @@ function reportOf(student,units){
1443
  gold:ids.filter(id=>p.gold?.[id]).length,stuck:ids.filter(id=>!p.done?.[id]&&(p.missed?.[id]||[]).length).map(id=>BY[id])}; });
1444
  const n=perUnit.reduce((s,x)=>s+x.n,0), done=perUnit.reduce((s,x)=>s+x.done,0);
1445
  const {recurring}=Sky.patterns(p,G.mistakes);
1446
- const quizzes=units.filter(u=>u.quiz).map(u=>({u,st:p.uq?.[u.id]||{history:[]}}));
1447
- const scores=quizzes.map(x=>x.st.history.filter(h=>h.score!=null).at(-1)?.score).filter(s=>s!=null);
 
 
 
 
 
 
1448
  const exams=(p.quizzes||[]).slice().reverse();
1449
  const days=Object.keys(p.days||{}).sort(), last=[p.updated?new Date(+p.updated):null,days.length?new Date(days.at(-1)):null].filter(d=>d&&!isNaN(d)).sort((a,b)=>b-a)[0];
1450
- return {perUnit,n,done,recurring:recurring.filter(x=>G.mistakes[x.m]),quizzes,quizAvg:scores.length?Math.round(scores.reduce((a,b)=>a+b,0)/scores.length):null,exams,last};
1451
  }
1452
  async function classReport(id){
1453
  const mine=teachClasses(), classId=id??REPORT?.id??mine[0]?.id; if(classId==null) return;
@@ -1479,6 +1501,7 @@ function drawReport(){
1479
  const top=Object.entries(common).sort((a,b)=>b[1].length-a[1].length).slice(0,5);
1480
  const name=email=>email.split("@")[0];
1481
  const quizUnits=units.filter(u=>u.quiz);
 
1482
  const grade=score=>score==null?"–":`${score}`;
1483
  const tone=score=>score==null?"":score>=80?" good":score>=55?" mid":" low";
1484
  const card=({s,r})=>{
@@ -1486,17 +1509,19 @@ function drawReport(){
1486
  const exam=r.exams[0];
1487
  return `<details class="mx-report-student"><summary><b dir="ltr">${esc(name(s.email))}</b>
1488
  <span class="mx-report-prog"><span class="mx-meter" aria-hidden="true"><i style="width:${pct(r.done,r.n)}%"></i></span><small>${r.done}/${r.n} מיומנויות</small></span>
1489
- <span class="mx-report-chip${tone(r.quizAvg)}" title="ממוצע בחנים מסכמים">בוחן ${grade(r.quizAvg)}</span>
1490
- <span class="mx-report-chip${tone(exam?pct(exam.right,exam.n):null)}" title="מבחן התרגול האחרון">תרגול ${exam?`${exam.right}/${exam.n}`:"–"}</span>
1491
  ${r.recurring.length?`<span class="mx-report-chip low">${r.recurring.length} קשיים</span>`:""}</summary>
1492
  <div class="mx-report-body">
1493
  <p class="mx-small"><span dir="ltr">${esc(s.email)}</span>${r.last?` · פעילות אחרונה ${r.last.toLocaleDateString("he-IL")}`:""}</p>
1494
- <section><h5>התקדמות ביחידות</h5><div class="mx-quiz-units-score">${r.perUnit.map(x=>`<div><span>${esc(x.u.name)}${x.gold?` <small>· ${x.gold} זהב</small>`:""}</span><b>${x.done}/${x.n}</b><span class="mx-meter" aria-hidden="true"><i style="width:${pct(x.done,x.n)}%"></i></span></div>`).join("")}</div></section>
1495
- <section><h5>קשיים</h5>${r.recurring.length?`<ul class="mx-report-list">${r.recurring.map(x=>`<li><b>${fmt(G.mistakes[x.m].name)}</b> <small>${x.n} פעמים ב־${x.skills.length} מיומנויות, לאחרונה ${shortDate(x.last)}</small>${BY[x.practise]?`<small>לחזק: ${fmt(BY[x.practise].name)}</small>`:""}</li>`).join("")}</ul>`:'<p class="mx-small">אין טעויות שחוזרות לאחרונה.</p>'}
1496
  ${r.perUnit.some(x=>x.stuck.length)?`<p class="mx-small">מיומנויות עם שאלות שעוד לא נפתרו: ${r.perUnit.flatMap(x=>x.stuck).slice(0,8).map(sk=>fmt(sk.name)).join(" · ")}</p>`:""}</section>
1497
- <section><h5>בוחן מסכם</h5>${quizUnits.length?`<table class="mx-report-table"><thead><tr><th>יחידה</th><th>ציון אחרון</th><th>הגשות</th><th>תאריך</th></tr></thead><tbody>${r.quizzes.map(({u,st})=>{ const h=st.history.at(-1);
1498
- return `<tr><td>${esc(u.name)}</td><td class="mx-report-grade${tone(h?.score)}">${st.going?"בתהליך":h?(h.score==null?"ממתין לבדיקה":`${h.score}/100`):"–"}</td><td>${st.history.length||"–"}</td><td>${h?shortDate(h.at):"–"}</td></tr>`; }).join("")}</tbody></table>`:'<p class="mx-small">ליחידות של הכיתה אין בוחן מסכם.</p>'}</section>
1499
- <section><h5>מבחני תרגול</h5>${r.exams.length?`<table class="mx-report-table"><thead><tr><th>תאריך</th><th>ציון</th><th>תשובות נכונות</th><th>זמן</th></tr></thead><tbody>${r.exams.slice(0,10).map(q=>`<tr><td>${shortDate(q.at)}</td><td class="mx-report-grade${tone(pct(q.right,q.n))}">${pct(q.right,q.n)}</td><td>${q.right}/${q.n}</td><td>${q.ms?`${Math.round(q.ms/60000)} דק׳`:"–"}</td></tr>`).join("")}</tbody></table>`:'<p class="mx-small">עוד לא נעשו מבחני תרגול.</p>'}</section>
 
 
1500
  </div></details>`;
1501
  };
1502
  sheet.querySelector(".mx-report").innerHTML=`
 
819
  return d;
820
  }
821
  const uqPost=(url,body={})=>uqJson(url,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)});
822
+ const UQ_ERRORS={quiz_sittings_unavailable:"שמירת ההגשה אינה זמינה כרגע. נסו שוב מאוחר יותר.",invalid_sitting:"ההגשה לא תואמת לתשובות השמורות. פתחו את הבוחן מחדש.",question_changed:"הבוחן עודכן. רעננו את העמוד והתחילו מחדש.",image_too_large:"התמונה גדולה מדי. צלמו שוב.",invalid_image:"לא ניתן לקרוא את התמונה. צלמו שוב.",
823
  unsupported_image:"בחרו תמונת JPEG, PNG או WebP.",answer_storage_unavailable:"שמירת תשובות אינה זמינה כרגע.",grading_unavailable:"הבדיקה האוטומטית אינה זמינה כרגע.",
824
  grading_failed:"הבדיקה נכשלה. התמונה נשמרה; אפשר לנסות שוב.",grading_limit:"הגעתם למכסת הבדיקות היומית. נסו שוב מחר.",grading_busy:"כבר מתבצעות בדיקות. נסו שוב בעוד רגע.",
825
  upload_in_progress:"העלאה כבר מתבצעת. רגע…",open_ended_locked:"בוחנים פתוחים סגורים כרגע באתר.",sign_in_required:"יש להיכנס לחשבון."};
 
973
  return;
974
  }
975
  sit.submitted=Date.now();
976
+ const st=uqState(uid); st.history.push({at:new Date().toISOString(),attempts:sit.attempts.slice(),score:null,timeUp,ck:crypto.randomUUID()}); st.history=st.history.slice(-10);
977
  save(); draw();
978
  uqResult(uid,st.history.length-1,true);
979
  }
980
+ /* The grade of a sitting is worked out by the server from the stored checks (app/quiz_grades.py); the score kept in
981
+ this device's progress is only a copy of it. A sitting is handed in on the server once (`sid`); the same key makes
982
+ a retry after a lost response the same sitting. */
983
+ async function uqSend(uid,entry){
984
+ if(entry.sid) return entry.sid;
985
+ if(!entry.ck){ entry.ck=crypto.randomUUID(); save(); }
986
+ entry.sid=(await uqPost("/api/math/quiz-sittings",{unit_id:uid,attempts:entry.attempts,time_up:!!entry.timeUp,client_key:entry.ck})).id;
987
+ save(); return entry.sid;
988
+ }
989
+ async function uqSitting(uid,sid){ return (await uqJson(`/api/math/quiz-sittings?unit_id=${encodeURIComponent(uid)}`)).sittings.find(x=>x.id===sid)||null; }
990
  // the report: each answer is checked now (once), then points per question, feedback and the solution
991
  async function uqResult(uid,h,check=false){
992
  const u=G.units.find(x=>x.id===uid), st=uqState(uid), entry=st.history[h];
 
999
  <small class="mx-src" aria-live="polite">${CFG.aiDirect===false?"שולחים את התשובות לבדיקת המורה":"בודקים את התשובות, זה לוקח כחצי דקה לשאלה"}</small></div>
1000
  <button class="mx-x" data-act="close" aria-label="סגירה">✕</button></div><div class="mx-uq-report"></div></div>`;
1001
  sheet.hidden=false; sheet.querySelector(".mx-x").focus({preventScroll:true});
1002
+ let sendError="";
1003
+ try{ await uqSend(uid,entry); }catch(e){ sendError=e.message; }
1004
+ if(UQ?.uid!==uid) return;
1005
  const results=[];
1006
  for(let i=0;i<n;i++){
1007
  let r=null;
 
1017
  if(UQ?.uid!==uid) return;
1018
  results.push(r);
1019
  }
1020
+ // points: the server's, per question (a checked answer earns its share; a missing one 0; one not checked yet leaves the total open)
1021
+ let sitting=null;
1022
+ if(entry.sid){ try{ sitting=await uqSitting(uid,entry.sid); }catch(e){ sendError||=e.message; } }
1023
+ if(UQ?.uid!==uid) return;
1024
  const rows=u.quiz.questions.map((q,i)=>{
1025
+ const r=results[i], g=r?.grading, a=g?.state==="completed"?g.result:null, pts=sitting?sitting.questions[i].earned:null;
1026
+ let say="";
1027
+ if(!r?.attachment) say="לא צולמה תשובה";
1028
+ else if(a&&a.score!=null) say={correct:"נכון ומלא",partial:"נכון בחלקו",incorrect:"לא נכון"}[a.verdict]||"";
1029
+ else { const req=a?null:r.ai_request;
1030
  say=req&&["pending","sending","failed"].includes(req.state)?"מחכה למורה שיאשר את הבדיקה":req?.state==="declined"?`המורה לא אישר את הבדיקה${req.note?`: ${req.note}`:""}`:a?.verdict==="unreadable"?"לא הצלחנו לקרוא את התמונה":a?.verdict==="needs_review"?"נדרשת בדיקה של מורה":r.error||(!r.grading_available?"הבדיקה האוטומטית אינה זמינה כרגע":"עוד לא נבדקה"); }
 
1031
  return `<li class="mx-uq-q"><div class="mx-uq-qhead"><b>שאלה ${i+1}</b><span>${pts==null?"–":pts}/${q.points} נק׳</span></div>
1032
  <p class="mx-uq-text">${fmt(q.question).replace(/\n/g,"<br>")}</p>
1033
  ${UQ.images[i]?`<img class="mx-uq-img" alt="התשובה שלכם לשאלה ${i+1}" src="${UQ.images[i]}">`:""}
 
1036
  ${a?.solution?`<details><summary>הפתרון המלא</summary><p class="mx-grade-solution">${fmtLoose(a.solution)}</p></details>`:""}
1037
  ${r?.attachment&&!a&&r.grading_available&&!["pending","sending","failed"].includes(r.ai_request?.state)?`<button class="mx-btn" data-uq-result="${uid}:${h}:check">${CFG.aiDirect===false?"שליחה מחדש למורה":"בדיקה נוספת"}</button>`:""}</li>`;
1038
  });
1039
+ const earned=sitting?sitting.questions.reduce((sum,x)=>sum+(x.earned||0),0):0, open=sitting?sitting.open:n;
1040
+ entry.score=sitting?.grade??null; save(); draw();
1041
+ const total=!sitting?"ההגשה לא נשמרה בשרת":open?`${earned} נקודות עד עכשיו · ${open===1?"שאלה אחת לא נבדקה":`${open} שאלות לא נבדקו`}`:`${earned}/100`;
1042
  sheet.querySelector("#mxTitle").textContent=`בוחן מסכם: ${total}`;
1043
  sheet.querySelector(".mx-src").textContent=`הוגש ${new Date(entry.at).toLocaleString("he-IL",{dateStyle:"short",timeStyle:"short"})}${entry.timeUp?" · הוגש כשהזמן נגמר":""}`;
1044
  const weak=u.quiz.questions.map((q,i)=>i).filter(i=>{ const a=results[i]?.grading?.result; return !results[i]?.attachment||(a&&a.score!=null&&a.score<70); });
1045
  sheet.querySelector(".mx-uq-report").innerHTML=`<ol class="mx-uq-qs">${rows.join("")}</ol>
1046
+ ${sendError&&!sitting?`<p class="mx-note mx-quiz-warn">${esc(sendError)} <button class="mx-btn" data-uq-result="${uid}:${h}:check">ניסיון נוסף</button></p>`:""}
1047
  ${weak.length&&!open?`<p class="mx-note">כדאי לחזור על ${weak.map(i=>`שאלה ${i+1}`).join(", ")} ולתרגל את המיומנויות של היחידה במסלול לפני הבוחן הבא.</p>`:""}
1048
  <p class="mx-small">הבדיקה אוטומטית באמצעות AI: בדקו שהיא תואמת למה שכתבתם, ואם משהו נראה לא נכון, הראו למורה.</p>
1049
  <div class="mx-quiz-nav"><button class="mx-btn" data-uquiz="${uid}">לבוחן</button><button class="mx-btn solid" data-act="close">חזרה למסלול</button></div>`;
 
1459
  gold:ids.filter(id=>p.gold?.[id]).length,stuck:ids.filter(id=>!p.done?.[id]&&(p.missed?.[id]||[]).length).map(id=>BY[id])}; });
1460
  const n=perUnit.reduce((s,x)=>s+x.n,0), done=perUnit.reduce((s,x)=>s+x.done,0);
1461
  const {recurring}=Sky.patterns(p,G.mistakes);
1462
+ // the unit quizzes' grades are the server's (student.quiz_sittings, oldest first; null: they could not be read); the
1463
+ // device's own history only says which submissions the server has not seen ("unverified")
1464
+ const sittings=student.quiz_sittings;
1465
+ const quizzes=units.filter(u=>u.quiz).map(u=>{
1466
+ const st=p.uq?.[u.id]||{history:[]}, mine=(sittings||[]).filter(x=>x.unit_id===u.id), known=new Set(mine.map(x=>x.id));
1467
+ return {u,st,last:mine.at(-1)||null,count:mine.length,unverified:st.history.filter(h=>!h.sid||!known.has(h.sid)).length};
1468
+ });
1469
+ const scores=quizzes.map(x=>x.last?.grade).filter(g=>g!=null);
1470
  const exams=(p.quizzes||[]).slice().reverse();
1471
  const days=Object.keys(p.days||{}).sort(), last=[p.updated?new Date(+p.updated):null,days.length?new Date(days.at(-1)):null].filter(d=>d&&!isNaN(d)).sort((a,b)=>b-a)[0];
1472
+ return {perUnit,n,done,recurring:recurring.filter(x=>G.mistakes[x.m]),quizzes,quizzesKnown:sittings!=null,quizAvg:scores.length?Math.round(scores.reduce((a,b)=>a+b,0)/scores.length):null,exams,last};
1473
  }
1474
  async function classReport(id){
1475
  const mine=teachClasses(), classId=id??REPORT?.id??mine[0]?.id; if(classId==null) return;
 
1501
  const top=Object.entries(common).sort((a,b)=>b[1].length-a[1].length).slice(0,5);
1502
  const name=email=>email.split("@")[0];
1503
  const quizUnits=units.filter(u=>u.quiz);
1504
+ const DEVICE_NOTE=' <small class="mx-device" title="המכשיר של התלמיד מדווח את הנתונים האלה, והשרת לא בודק אותם">· נתוני תרגול מהמכשיר</small>';
1505
  const grade=score=>score==null?"–":`${score}`;
1506
  const tone=score=>score==null?"":score>=80?" good":score>=55?" mid":" low";
1507
  const card=({s,r})=>{
 
1509
  const exam=r.exams[0];
1510
  return `<details class="mx-report-student"><summary><b dir="ltr">${esc(name(s.email))}</b>
1511
  <span class="mx-report-prog"><span class="mx-meter" aria-hidden="true"><i style="width:${pct(r.done,r.n)}%"></i></span><small>${r.done}/${r.n} מיומנויות</small></span>
1512
+ <span class="mx-report-chip${tone(r.quizAvg)}" title="ממוצע בחנים מסכמים שנבדקו">בוחן ${grade(r.quizAvg)}</span>
1513
+ <span class="mx-report-chip${tone(exam?pct(exam.right,exam.n):null)}" title="מבחן התרגול האחרון (נתוני מכשיר)">תרגול ${exam?`${exam.right}/${exam.n}`:"–"}</span>
1514
  ${r.recurring.length?`<span class="mx-report-chip low">${r.recurring.length} קשיים</span>`:""}</summary>
1515
  <div class="mx-report-body">
1516
  <p class="mx-small"><span dir="ltr">${esc(s.email)}</span>${r.last?` · פעילות אחרונה ${r.last.toLocaleDateString("he-IL")}`:""}</p>
1517
+ <section><h5>התקדמות ביחידות${DEVICE_NOTE}</h5><div class="mx-quiz-units-score">${r.perUnit.map(x=>`<div><span>${esc(x.u.name)}${x.gold?` <small>· ${x.gold} זהב</small>`:""}</span><b>${x.done}/${x.n}</b><span class="mx-meter" aria-hidden="true"><i style="width:${pct(x.done,x.n)}%"></i></span></div>`).join("")}</div></section>
1518
+ <section><h5>קשיים${DEVICE_NOTE}</h5>${r.recurring.length?`<ul class="mx-report-list">${r.recurring.map(x=>`<li><b>${fmt(G.mistakes[x.m].name)}</b> <small>${x.n} פעמים ב־${x.skills.length} מיומנויות, לאחרונה ${shortDate(x.last)}</small>${BY[x.practise]?`<small>לחזק: ${fmt(BY[x.practise].name)}</small>`:""}</li>`).join("")}</ul>`:'<p class="mx-small">אין טעויות שחוזרות לאחרונה.</p>'}
1519
  ${r.perUnit.some(x=>x.stuck.length)?`<p class="mx-small">מיומנויות עם שאלות שעוד לא נפתרו: ${r.perUnit.flatMap(x=>x.stuck).slice(0,8).map(sk=>fmt(sk.name)).join(" · ")}</p>`:""}</section>
1520
+ <section><h5>בוחן מסכם</h5>${quizUnits.length?`<table class="mx-report-table"><thead><tr><th>יחידה</th><th>ציון אחרון</th><th>הגשות</th><th>תאריך</th></tr></thead><tbody>${r.quizzes.map(({u,st,last,count,unverified})=>{
1521
+ const cell=st.going?"בתהליך":!r.quizzesKnown?"לא זמין":last?(last.grade==null?"ממתין לבדיקה":`${last.grade}/100`):unverified?"לא מאומת":"–";
1522
+ const subs=`${count||(unverified?"":"–")}${unverified&&r.quizzesKnown?`${count?" ":""}<small>(${unverified} לא מאומתות)</small>`:""}`;
1523
+ return `<tr><td>${esc(u.name)}</td><td class="mx-report-grade${tone(last?.grade)}">${cell}</td><td>${subs}</td><td>${last?shortDate(last.submitted_at):"–"}</td></tr>`; }).join("")}</tbody></table>`:'<p class="mx-small">ליחידות של הכיתה אין בוחן מסכם.</p>'}</section>
1524
+ <section><h5>מבחני תרגול${DEVICE_NOTE}</h5>${r.exams.length?`<table class="mx-report-table"><thead><tr><th>תאריך</th><th>ציון</th><th>תשובות נכונות</th><th>זמן</th></tr></thead><tbody>${r.exams.slice(0,10).map(q=>`<tr><td>${shortDate(q.at)}</td><td class="mx-report-grade${tone(pct(q.right,q.n))}">${pct(q.right,q.n)}</td><td>${q.right}/${q.n}</td><td>${q.ms?`${Math.round(q.ms/60000)} דק׳`:"–"}</td></tr>`).join("")}</tbody></table>`:'<p class="mx-small">עוד לא נעשו מבחני תרגול.</p>'}</section>
1525
  </div></details>`;
1526
  };
1527
  sheet.querySelector(".mx-report").innerHTML=`
supabase/migrations/20261104120000_math_quiz_sittings.sql ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- Unit-quiz sittings kept on the server (app/quiz_grades.py), so a quiz grade comes from the stored checks and not
2
+ -- from the student's device. Additive: one new table and two functions; nothing existing changes.
3
+ -- Apply after the answer-scan and answer-grading migrations: the functions read their tables.
4
+ begin;
5
+ create table public.math_quiz_sittings (
6
+ id uuid primary key default gen_random_uuid(),
7
+ user_id uuid not null references auth.users(id) on delete cascade,
8
+ unit_id text not null,
9
+ attempt_ids uuid[] not null check (cardinality(attempt_ids) between 1 and 20),
10
+ submitted_at timestamptz not null default now(),
11
+ time_up boolean not null default false,
12
+ client_key uuid not null,
13
+ unique (user_id, client_key)
14
+ );
15
+ create index math_quiz_sittings_user on public.math_quiz_sittings(user_id, unit_id, submitted_at desc);
16
+ alter table public.math_quiz_sittings enable row level security;
17
+ revoke all on public.math_quiz_sittings from public, anon, authenticated;
18
+ grant all on public.math_quiz_sittings to service_role;
19
+
20
+ -- A sitting with what its grade is computed from: for each attempt in question order, whether it has a saved photo
21
+ -- and its latest check (state, score, verdict). The points per question are in the app's data, not here.
22
+ create function public.math_quiz_sitting_json(p_id uuid) returns jsonb
23
+ language sql stable security invoker set search_path='' as $$
24
+ select jsonb_build_object('id', s.id, 'user_id', s.user_id, 'unit_id', s.unit_id,
25
+ 'submitted_at', s.submitted_at, 'time_up', s.time_up,
26
+ 'attempts', (select coalesce(jsonb_agg(jsonb_build_object(
27
+ 'attempt_id', x.aid, 'question_id', a.question_id, 'has_photo', att.id is not null,
28
+ 'grade_state', g.state, 'score', g.result->'score', 'verdict', g.result->>'verdict') order by x.ord), '[]'::jsonb)
29
+ from unnest(s.attempt_ids) with ordinality as x(aid, ord)
30
+ left join public.math_question_attempts a on a.id = x.aid
31
+ left join public.math_answer_attachments att on att.attempt_id = x.aid
32
+ left join lateral (select state, result from public.math_answer_grades
33
+ where attempt_id = x.aid order by created_at desc, id desc limit 1) g on true))
34
+ from public.math_quiz_sittings s where s.id = p_id
35
+ $$;
36
+
37
+ create function public.math_quiz_action(p_user uuid, p_action text, p_data jsonb default '{}')
38
+ returns jsonb language plpgsql security invoker set search_path='' as $$
39
+ declare
40
+ s public.math_quiz_sittings;
41
+ ids uuid[];
42
+ bad int;
43
+ begin
44
+ if p_action = 'report' then -- the class report: the latest sittings of these accounts
45
+ return coalesce((select jsonb_agg(public.math_quiz_sitting_json(r.id) order by r.submitted_at) from (
46
+ select q.id, q.submitted_at, row_number() over (partition by q.user_id, q.unit_id
47
+ order by q.submitted_at desc, q.id desc) as rn
48
+ from public.math_quiz_sittings q
49
+ where q.user_id in (select t.x::uuid from jsonb_array_elements_text(p_data->'users') as t(x))) r
50
+ where r.rn <= 10), '[]'::jsonb);
51
+ end if;
52
+ if p_user is null then raise exception 'not_found' using errcode = 'P0002'; end if;
53
+ if p_action = 'list' then
54
+ return coalesce((select jsonb_agg(public.math_quiz_sitting_json(r.id) order by r.submitted_at desc) from (
55
+ select q.id, q.submitted_at from public.math_quiz_sittings q
56
+ where q.user_id = p_user and (p_data->>'unit_id' is null or q.unit_id = p_data->>'unit_id')
57
+ order by q.submitted_at desc, q.id desc limit 10) r), '[]'::jsonb);
58
+ elsif p_action = 'submit' then
59
+ -- the same submit again (a lost response) is the same sitting
60
+ select * into s from public.math_quiz_sittings
61
+ where user_id = p_user and client_key = (p_data->>'client_key')::uuid;
62
+ if s.id is not null then return public.math_quiz_sitting_json(s.id); end if;
63
+ ids := array(select t.x::uuid from jsonb_array_elements_text(p_data->'attempt_ids') with ordinality as t(x, o) order by t.o);
64
+ -- every attempt is the student's own, for the quiz question at its position
65
+ select count(*) into bad from unnest(ids) with ordinality as x(aid, o)
66
+ left join public.math_question_attempts a on a.id = x.aid and a.user_id = p_user
67
+ where a.id is null or a.question_id <> 'quiz:' || (p_data->>'unit_id') || ':' || (x.o - 1);
68
+ if bad > 0 or exists(select 1 from public.math_quiz_sittings where user_id = p_user and attempt_ids && ids) then
69
+ raise exception 'invalid_sitting' using errcode = 'P0001';
70
+ end if;
71
+ insert into public.math_quiz_sittings(user_id, unit_id, attempt_ids, time_up, client_key)
72
+ values (p_user, p_data->>'unit_id', ids, coalesce((p_data->>'time_up')::boolean, false), (p_data->>'client_key')::uuid)
73
+ returning * into s;
74
+ return public.math_quiz_sitting_json(s.id);
75
+ end if;
76
+ raise exception 'invalid_action';
77
+ end $$;
78
+ revoke all on function public.math_quiz_sitting_json(uuid) from public, anon, authenticated;
79
+ revoke all on function public.math_quiz_action(uuid, text, jsonb) from public, anon, authenticated;
80
+ grant execute on function public.math_quiz_sitting_json(uuid) to service_role;
81
+ grant execute on function public.math_quiz_action(uuid, text, jsonb) to service_role;
82
+ commit;
tests/scan_support.py CHANGED
@@ -68,6 +68,9 @@ class DatabaseAnswers(AnswerScans):
68
  def grade(self, user_id, action, **data):
69
  return self.rpc('math_grade_action', user_id, action, data)
70
 
 
 
 
71
  def rpc(self, name, user_id, action, data):
72
  import psycopg
73
  if self.fail_finalize and action == 'finalize':
@@ -126,6 +129,8 @@ def answer_store(postgres_server, tmp_path):
126
  if migration.name.endswith('_math_answer_scans.sql'):
127
  continue
128
  c.execute(migration.read_text())
 
 
129
  c.execute('insert into auth.users values(%s),(%s)', (OWNER, OTHER))
130
  yield store
131
  with psycopg.connect(postgres_server.get_uri(), autocommit=True) as c:
 
68
  def grade(self, user_id, action, **data):
69
  return self.rpc('math_grade_action', user_id, action, data)
70
 
71
+ def quiz(self, user_id, action, **data):
72
+ return self.rpc('math_quiz_action', user_id, action, data)
73
+
74
  def rpc(self, name, user_id, action, data):
75
  import psycopg
76
  if self.fail_finalize and action == 'finalize':
 
129
  if migration.name.endswith('_math_answer_scans.sql'):
130
  continue
131
  c.execute(migration.read_text())
132
+ for migration in sorted(Path('supabase/migrations').glob('*_math_quiz_sittings.sql')):
133
+ c.execute(migration.read_text())
134
  c.execute('insert into auth.users values(%s),(%s)', (OWNER, OTHER))
135
  yield store
136
  with psycopg.connect(postgres_server.get_uri(), autocommit=True) as c:
tests/test_ai_queue.py CHANGED
@@ -349,3 +349,67 @@ def test_api_errors_are_named(status, message, reason):
349
  got, detail = failure_reason(exc)
350
  assert got == reason and str(status) in detail and message[:20] in detail
351
  assert failure_reason(anthropic.APIConnectionError(request=response.request))[0] == 'no_connection'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
349
  got, detail = failure_reason(exc)
350
  assert got == reason and str(status) in detail and message[:20] in detail
351
  assert failure_reason(anthropic.APIConnectionError(request=response.request))[0] == 'no_connection'
352
+
353
+
354
+ class ReportAuth(ClassAuth):
355
+ """ClassAuth that can also answer the class report's two lookups: user ids and (empty) saved progress."""
356
+ def user_ids(self, emails):
357
+ return {mail(uid): uid for uid in (OWNER, OTHER) if mail(uid) in emails}
358
+
359
+ def _admin(self, path, *args, **kwargs):
360
+ if path.startswith('/rest/v1/math_progress'):
361
+ return []
362
+ return super()._admin(path, *args, **kwargs)
363
+
364
+
365
+ def test_a_quiz_checked_through_the_teachers_queue_has_one_grade_for_student_and_teacher(answer_store):
366
+ """The student hands in a unit quiz; each answer waits in the teacher's queue. The sitting has no grade until the
367
+ teacher has sent them all, and then the student's screen and the teacher's class report show the same grade."""
368
+ from app.math_skills import algebra9, question_version, quiz_snapshot
369
+
370
+ store = answer_store
371
+ app = create_app(auth=ReportAuth())
372
+ app.state.answers, app.state.ai_queue = store, MemoryQueue()
373
+ scores = {0: 100, 1: 60, 2: 100, 3: 40}
374
+
375
+ class ByQuestion:
376
+ def grade(self, image, snapshot):
377
+ return validate_assessment(assessment(score=scores[int(snapshot['name'][-1]) - 1]))
378
+ app.state.answer_grader = ByQuestion()
379
+ unit = next(u for u in algebra9()['units'] if u['id'] == 't')
380
+
381
+ async def check():
382
+ async with as_user(app, OWNER) as student, as_user(app, OTHER) as teacher:
383
+ attempts, requests = [], []
384
+ for index in range(4):
385
+ version = question_version(quiz_snapshot(unit, index))
386
+ made = (await student.post('/api/math/quiz-attempts',
387
+ json={'unit': 't', 'index': index, 'question_version': version, 'new': True})).json()
388
+ aid = made['attempt']['id']
389
+ scan = (await student.post(f'/api/math/answer-attempts/{aid}/scans', json={'renew': True})).json()['scan']
390
+ await student.post(f"/api/math/answer-scans/{scan['id']}/confirm")
391
+ lease = (await student.post(f"/api/math/answer-scans/{scan['id']}/uploads", json={'key': str(uuid.uuid4())})).json()
392
+ up = await student.post(f"/api/math/answer-scans/{scan['id']}/uploads/{lease['lease_id']}",
393
+ files={'photo': ('a.png', png(), 'image/png')})
394
+ assert up.status_code == 200
395
+ attempts.append(aid)
396
+ handed = (await student.post('/api/math/quiz-sittings', json={'unit_id': 't', 'attempts': attempts,
397
+ 'client_key': str(uuid.uuid4())})).json()
398
+ for aid in attempts: # as the page does after handing in: each answer asks the teacher to check it
399
+ asked = (await student.post(f'/api/math/answer-attempts/{aid}/grade', json={})).json()
400
+ requests.append(asked['ai_request']['id'])
401
+ assert handed['grade'] is None and handed['open'] == 4 # every answer waits for the teacher
402
+
403
+ def seen_by_teacher():
404
+ return teacher.get('/api/classes/1/report')
405
+
406
+ for rid in requests[:3]:
407
+ assert (await teacher.post(f'/api/ai-queue/{rid}/send')).status_code == 200
408
+ mine = (await student.get('/api/math/quiz-sittings', params={'unit_id': 't'})).json()['sittings'][0]
409
+ report = (await seen_by_teacher()).json()['students'][0]['quiz_sittings'][0]
410
+ assert mine['grade'] is None and report['grade'] is None and mine['open'] == report['open'] == 1
411
+ assert (await teacher.post(f'/api/ai-queue/{requests[3]}/send')).status_code == 200
412
+ mine = (await student.get('/api/math/quiz-sittings', params={'unit_id': 't'})).json()['sittings'][0]
413
+ report = (await seen_by_teacher()).json()['students'][0]['quiz_sittings'][0]
414
+ assert mine['grade'] == report['grade'] == 25 + 15 + 25 + 10 and mine['id'] == report['id'] == handed['id']
415
+ asyncio.run(check())
tests/test_class_report.py CHANGED
@@ -4,6 +4,7 @@ import urllib.parse
4
 
5
  import httpx
6
 
 
7
  from app.main import create_app
8
 
9
  TEACHER, STUDENT, NEWBIE, OUTSIDER = "t-id", "s-id", "n-id", "o-id"
@@ -50,6 +51,28 @@ class ReportAuth:
50
  for i in ids if i in self.progress]
51
 
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  def client(app, user):
54
  return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test",
55
  headers={"Authorization": f"Bearer {user}"})
@@ -57,6 +80,7 @@ def client(app, user):
57
 
58
  def test_teacher_sees_the_other_students():
59
  app = create_app(auth=ReportAuth())
 
60
 
61
  async def check():
62
  async with client(app, TEACHER) as c:
@@ -67,12 +91,12 @@ def test_teacher_sees_the_other_students():
67
  students = {s["email"]: s for s in data["students"]}
68
  assert "teacher@x.test" not in students # the teacher is not in their own report
69
  assert students["ghost@x.test"] == {"email": "ghost@x.test", "signed_in": False, "progress": None,
70
- "saved_at": None}
71
  assert students["newbie@x.test"]["signed_in"] and students["newbie@x.test"]["progress"] is None
72
  p = students["student@x.test"]["progress"]
73
  assert p["done"] == {"t1": "2026-11-01"} and p["slips"][0]["m"] == "sign"
74
  assert p["quizzes"] == [{"at": "2026-11-02", "n": 12, "right": 9, "ms": 600000, "units": ["t"]}]
75
- assert p["uq"] == {"t": {"history": [{"at": "2026-11-03", "score": 85, "timeUp": None}], "going": False}}
76
  assert "comets" not in p
77
  assert (await c.get("/api/classes/99/report")).status_code == 404
78
  me = (await c.get("/api/me")).json()
@@ -84,6 +108,42 @@ def test_teacher_sees_the_other_students():
84
  asyncio.run(check())
85
 
86
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  def test_the_teacher_is_one_of_the_students():
88
  auth = ReportAuth()
89
  app = create_app(auth=auth)
 
4
 
5
  import httpx
6
 
7
+ from app.answer_scans import ScanError
8
  from app.main import create_app
9
 
10
  TEACHER, STUDENT, NEWBIE, OUTSIDER = "t-id", "s-id", "n-id", "o-id"
 
51
  for i in ids if i in self.progress]
52
 
53
 
54
+ class Sittings:
55
+ """The server's record of unit-quiz sittings (app/quiz_grades.py), as the database returns it."""
56
+ configured = True
57
+
58
+ def __init__(self, sittings=(), fail=False):
59
+ self.sittings, self.fail, self.asked = list(sittings), fail, []
60
+
61
+ def quiz(self, user_id, action, **data):
62
+ self.asked.append((user_id, action, data))
63
+ if self.fail:
64
+ raise ScanError(503, "quiz_sittings_unavailable")
65
+ return [s for s in self.sittings if s["user_id"] in data["users"]]
66
+
67
+
68
+ def attempt(score, state="completed", photo=True, verdict="correct"):
69
+ return {"attempt_id": "a", "has_photo": photo, "grade_state": state, "score": score, "verdict": verdict}
70
+
71
+
72
+ def sitting(user, *attempts, sid="s1", at="2026-11-04T09:00:00Z"):
73
+ return {"id": sid, "user_id": user, "unit_id": "t", "submitted_at": at, "time_up": False, "attempts": list(attempts)}
74
+
75
+
76
  def client(app, user):
77
  return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test",
78
  headers={"Authorization": f"Bearer {user}"})
 
80
 
81
  def test_teacher_sees_the_other_students():
82
  app = create_app(auth=ReportAuth())
83
+ app.state.answers = Sittings()
84
 
85
  async def check():
86
  async with client(app, TEACHER) as c:
 
91
  students = {s["email"]: s for s in data["students"]}
92
  assert "teacher@x.test" not in students # the teacher is not in their own report
93
  assert students["ghost@x.test"] == {"email": "ghost@x.test", "signed_in": False, "progress": None,
94
+ "saved_at": None, "quiz_sittings": []}
95
  assert students["newbie@x.test"]["signed_in"] and students["newbie@x.test"]["progress"] is None
96
  p = students["student@x.test"]["progress"]
97
  assert p["done"] == {"t1": "2026-11-01"} and p["slips"][0]["m"] == "sign"
98
  assert p["quizzes"] == [{"at": "2026-11-02", "n": 12, "right": 9, "ms": 600000, "units": ["t"]}]
99
+ assert p["uq"] == {"t": {"history": [{"at": "2026-11-03", "timeUp": None, "sid": None}], "going": False}}
100
  assert "comets" not in p
101
  assert (await c.get("/api/classes/99/report")).status_code == 404
102
  me = (await c.get("/api/me")).json()
 
108
  asyncio.run(check())
109
 
110
 
111
+ def test_unit_quiz_grades_are_the_servers_and_not_the_devices():
112
+ auth = ReportAuth() # the student's uploaded progress claims 85 for unit t
113
+ app = create_app(auth=auth)
114
+ app.state.answers = answers = Sittings([
115
+ sitting(STUDENT, attempt(100), attempt(60), attempt(40), attempt(48), sid="s-done"), # 25 + 15 + 10 + 12
116
+ sitting(NEWBIE, attempt(100), attempt(None, state="grading", verdict=None), attempt(100), attempt(100),
117
+ sid="s-open"),
118
+ sitting(OUTSIDER, attempt(100), attempt(100), attempt(100), attempt(100), sid="s-other-class")])
119
+
120
+ async def check():
121
+ async with client(app, TEACHER) as c:
122
+ data = (await c.get("/api/classes/3/report")).json()
123
+ students = {s["email"]: s for s in data["students"]}
124
+ done = students["student@x.test"]["quiz_sittings"]
125
+ assert [(x["id"], x["grade"], x["open"]) for x in done] == [("s-done", 62, 0)] # not 85
126
+ waiting = students["newbie@x.test"]["quiz_sittings"]
127
+ assert [(x["id"], x["grade"], x["open"]) for x in waiting] == [("s-open", None, 1)]
128
+ assert students["ghost@x.test"]["quiz_sittings"] == []
129
+ assert "score" not in str(students["student@x.test"]["progress"]["uq"]) # the device's number is not passed on
130
+ assert answers.asked[0][:2] == (None, "report")
131
+ assert OUTSIDER not in answers.asked[0][2]["users"] and "s-other-class" not in str(students)
132
+ asyncio.run(check())
133
+
134
+
135
+ def test_without_the_servers_record_the_report_shows_no_quiz_grades():
136
+ app = create_app(auth=ReportAuth())
137
+ app.state.answers = Sittings(fail=True)
138
+
139
+ async def check():
140
+ async with client(app, TEACHER) as c:
141
+ r = await c.get("/api/classes/3/report")
142
+ assert r.status_code == 200
143
+ assert all(s["quiz_sittings"] is None for s in r.json()["students"])
144
+ asyncio.run(check())
145
+
146
+
147
  def test_the_teacher_is_one_of_the_students():
148
  auth = ReportAuth()
149
  app = create_app(auth=auth)
tests/test_phone_browser.py CHANGED
@@ -139,8 +139,18 @@ def test_class_teacher_report(phone_server):
139
  {"email": "dana@example.com", "signed_in": True, "saved_at": None, "progress": {
140
  "done": {"t_ext": "2026-11-01"}, "gold": {}, "days": {}, "missed": {}, "slips": slips, "updated": 1,
141
  "quizzes": [{"at": "2026-11-02T10:00:00Z", "n": 12, "right": 9, "ms": 600000, "units": ["t"]}],
142
- "uq": {"t": {"history": [{"at": "2026-11-03T10:00:00Z", "score": 85}], "going": False}}}},
143
- {"email": "omer@example.com", "signed_in": False, "saved_at": None, "progress": None}]}
 
 
 
 
 
 
 
 
 
 
144
  with playwright.sync_playwright() as p:
145
  browser = p.chromium.launch()
146
  try:
@@ -168,12 +178,23 @@ def test_class_teacher_report(phone_server):
168
  playwright.expect(sheet.locator("#mxTitle")).to_have_text("הכיתה שלי: ט׳2")
169
  playwright.expect(sheet.locator(".mx-report-common")).to_contain_text("1 תלמידים: dana")
170
  dana, omer = sheet.locator(".mx-report-student").nth(0), sheet.locator(".mx-report-student").nth(1)
171
- playwright.expect(dana.locator("summary")).to_contain_text("בוחן 85")
172
  playwright.expect(dana.locator("summary")).to_contain_text("תרגול 9/12")
173
  playwright.expect(omer).to_contain_text("עוד לא נכנס לחשבון")
174
  dana.locator("summary").click()
175
- playwright.expect(dana.locator(".mx-report-table").first).to_contain_text("85/100")
 
 
176
  playwright.expect(dana.locator(".mx-report-table").nth(1)).to_contain_text("75")
 
 
 
 
 
 
 
 
 
177
  page.locator(".mx-report-sheet .mx-x").click()
178
  playwright.expect(sheet).to_be_hidden()
179
  finally:
 
139
  {"email": "dana@example.com", "signed_in": True, "saved_at": None, "progress": {
140
  "done": {"t_ext": "2026-11-01"}, "gold": {}, "days": {}, "missed": {}, "slips": slips, "updated": 1,
141
  "quizzes": [{"at": "2026-11-02T10:00:00Z", "n": 12, "right": 9, "ms": 600000, "units": ["t"]}],
142
+ # the device claims 85; the server's own record of the sitting says 62
143
+ "uq": {"t": {"history": [{"at": "2026-11-03T10:00:00Z", "score": 85, "sid": "s1"}], "going": False}}},
144
+ "quiz_sittings": [{"id": "s1", "unit_id": "t", "submitted_at": "2026-11-04T10:00:00Z", "grade": 62, "open": 0}]},
145
+ {"email": "omer@example.com", "signed_in": False, "saved_at": None, "progress": None, "quiz_sittings": []},
146
+ {"email": "noa@example.com", "signed_in": True, "saved_at": None, "progress": { # handed in, a check still waits
147
+ "done": {}, "gold": {}, "days": {}, "missed": {}, "slips": [], "updated": 1, "quizzes": [],
148
+ "uq": {"t": {"history": [{"at": "2026-11-04T09:00:00Z", "sid": "s2"}], "going": False}}},
149
+ "quiz_sittings": [{"id": "s2", "unit_id": "t", "submitted_at": "2026-11-04T09:00:00Z", "grade": None, "open": 1}]},
150
+ {"email": "ben@example.com", "signed_in": True, "saved_at": None, "progress": { # from before the server kept sittings
151
+ "done": {}, "gold": {}, "days": {}, "missed": {}, "slips": [], "updated": 1, "quizzes": [],
152
+ "uq": {"t": {"history": [{"at": "2026-10-20T09:00:00Z", "score": 100}], "going": False}}},
153
+ "quiz_sittings": []}]}
154
  with playwright.sync_playwright() as p:
155
  browser = p.chromium.launch()
156
  try:
 
178
  playwright.expect(sheet.locator("#mxTitle")).to_have_text("הכיתה שלי: ט׳2")
179
  playwright.expect(sheet.locator(".mx-report-common")).to_contain_text("1 תלמידים: dana")
180
  dana, omer = sheet.locator(".mx-report-student").nth(0), sheet.locator(".mx-report-student").nth(1)
181
+ playwright.expect(dana.locator("summary")).to_contain_text("בוחן 62")
182
  playwright.expect(dana.locator("summary")).to_contain_text("תרגול 9/12")
183
  playwright.expect(omer).to_contain_text("עוד לא נכנס לחשבון")
184
  dana.locator("summary").click()
185
+ quiz = dana.locator("section", has_text="בוחן מסכם").locator(".mx-report-table")
186
+ playwright.expect(quiz).to_contain_text("62/100")
187
+ playwright.expect(quiz).not_to_contain_text("85") # the device's number is never shown
188
  playwright.expect(dana.locator(".mx-report-table").nth(1)).to_contain_text("75")
189
+ # the practice data says it comes from the device; the quiz grades do not
190
+ playwright.expect(dana.locator("section h5", has_text="התקדמות ביחידות")).to_contain_text("נתוני תרגול מהמכשיר")
191
+ playwright.expect(dana.locator("section h5", has_text="מבחני תרגול")).to_contain_text("נתוני תרגול מהמכשיר")
192
+ playwright.expect(dana.locator("section h5", has_text="בוחן מסכם")).not_to_contain_text("מהמכשיר")
193
+ noa, ben = sheet.locator(".mx-report-student").nth(2), sheet.locator(".mx-report-student").nth(3)
194
+ noa.locator("summary").click(); ben.locator("summary").click()
195
+ playwright.expect(noa.locator(".mx-report-grade")).to_have_text("ממתין לבדיקה")
196
+ playwright.expect(ben.locator(".mx-report-grade")).to_have_text("לא מאומת") # not "100"
197
+ playwright.expect(ben.locator(".mx-report-student, summary").first).not_to_contain_text("בוחן 100")
198
  page.locator(".mx-report-sheet .mx-x").click()
199
  playwright.expect(sheet).to_be_hidden()
200
  finally:
tests/test_quiz_grades.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """How a unit-quiz sitting is graded on the server: points x the check's score, 0 for a missing photo, open while a
2
+ photographed answer has no scored check."""
3
+ from app.quiz_grades import question_result, sitting_view
4
+
5
+
6
+ def attempt(score=None, state="completed", verdict="correct", photo=True):
7
+ return {"attempt_id": "a", "has_photo": photo, "grade_state": state, "score": score, "verdict": verdict}
8
+
9
+
10
+ def sitting(*attempts, unit="t"):
11
+ return {"id": "s1", "unit_id": unit, "submitted_at": "2026-11-04T10:00:00Z", "time_up": False,
12
+ "attempts": list(attempts)}
13
+
14
+
15
+ def test_all_answers_checked_give_the_weighted_sum():
16
+ view = sitting_view(sitting(attempt(100), attempt(60, verdict="partial"), attempt(100), attempt(40, verdict="partial")))
17
+ assert [q["earned"] for q in view["questions"]] == [25, 15, 25, 10] # the quiz is four questions of 25
18
+ assert view["grade"] == 75 and view["open"] == 0
19
+
20
+
21
+ def test_a_missing_photo_earns_zero_and_does_not_keep_the_grade_open():
22
+ view = sitting_view(sitting(attempt(100), attempt(photo=False, state=None, verdict=None), attempt(100),
23
+ attempt(photo=False, state=None, verdict=None)))
24
+ assert [q["state"] for q in view["questions"]] == ["checked", "missing", "checked", "missing"]
25
+ assert view["grade"] == 50 and view["open"] == 0
26
+
27
+
28
+ def test_a_photographed_answer_without_a_finished_check_leaves_the_grade_open():
29
+ for waiting in (attempt(state=None, verdict=None), attempt(state="grading", verdict=None),
30
+ attempt(state="failed", verdict=None)):
31
+ view = sitting_view(sitting(attempt(100), waiting, attempt(100), attempt(100)))
32
+ assert view["grade"] is None and view["open"] == 1
33
+ assert view["questions"][1] == {"points": 25, "earned": None, "state": "waiting"}
34
+
35
+
36
+ def test_an_unreadable_or_needs_review_check_has_no_score_and_stays_open():
37
+ for verdict in ("unreadable", "needs_review"):
38
+ view = sitting_view(sitting(attempt(100), attempt(None, verdict=verdict), attempt(100), attempt(100)))
39
+ assert view["grade"] is None and view["open"] == 1 and view["questions"][1]["state"] == verdict
40
+
41
+
42
+ def test_points_are_rounded_per_question():
43
+ assert question_result(25, attempt(70))["earned"] == 18 # 17.5 rounds up, as the page always did
44
+ assert question_result(25, attempt(0, verdict="incorrect"))["earned"] == 0
45
+
46
+
47
+ def test_a_sitting_of_a_unit_without_a_quiz_has_no_view():
48
+ assert sitting_view(sitting(attempt(100), unit="nope")) is None
tests/test_unit_quiz.py CHANGED
@@ -185,6 +185,17 @@ def test_a_unit_quiz_is_written_photographed_handed_in_and_then_checked(quiz_ser
185
  page.locator("[data-uq-result]").first.click()
186
  playwright.expect(page.locator(".mx-uq-sheet h3")).to_have_text("בוחן מסכם: 65/100", timeout=10000)
187
  assert len(calls) == 3 and not errors
 
 
 
 
 
 
 
 
 
 
 
188
  finally:
189
  browser.close()
190
 
@@ -295,3 +306,120 @@ def test_on_a_phone_a_unit_quiz_answer_is_handed_in_from_the_full_screen_board(q
295
  assert errors == []
296
  finally:
297
  browser.close()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  page.locator("[data-uq-result]").first.click()
186
  playwright.expect(page.locator(".mx-uq-sheet h3")).to_have_text("בוחן מסכם: 65/100", timeout=10000)
187
  assert len(calls) == 3 and not errors
188
+ # a score written into this device's progress does not change what the result screen shows
189
+ page.evaluate("""() => { for (const k of Object.keys(localStorage)) if (k.startsWith('mathly.progress.v1:')) {
190
+ const p = JSON.parse(localStorage.getItem(k)); p.uq.t.history.at(-1).score = 100;
191
+ localStorage.setItem(k, JSON.stringify(p)); } }""")
192
+ page.reload()
193
+ page.locator('.mx-nav [data-view="path"]').click()
194
+ page.locator('.mx-uquiz[data-uquiz="t"]').click()
195
+ page.locator("[data-uq-result]").first.click()
196
+ playwright.expect(page.locator(".mx-uq-sheet h3")).to_have_text("בוחן מסכם: 65/100", timeout=10000)
197
+ playwright.expect(page.locator(".mx-uq-qhead span")).to_have_text(["25/25 נק׳", "15/25 נק׳", "25/25 נק׳", "0/25 נק׳"])
198
+ assert len(calls) == 3 and not errors
199
  finally:
200
  browser.close()
201
 
 
306
  assert errors == []
307
  finally:
308
  browser.close()
309
+
310
+
311
+ # ---- sittings kept on the server (app/quiz_grades.py)
312
+ def quiz_answer(store, index, *, unit="t", photo=True, owner=OWNER):
313
+ """A fresh attempt at quiz question `index` of `unit`, with a saved photo when asked."""
314
+ from test_answer_scans import begin, finish
315
+ graph_unit = next(u for u in algebra9()["units"] if u["id"] == unit)
316
+ snapshot = quiz_snapshot(graph_unit, index)
317
+ made = store.action(owner, "resume", question_id=f"quiz:{unit}:{index}", question_version=question_version(snapshot),
318
+ snapshot=snapshot, new=True)
319
+ aid = made["attempt"]["id"]
320
+ if photo:
321
+ scan = store.action(owner, "create_scan", attempt_id=aid)
322
+ finish(store, begin(store, scan))
323
+ return aid
324
+
325
+
326
+ def check(store, aid, score, owner=OWNER):
327
+ import uuid
328
+ lease = str(uuid.uuid4())
329
+ store.grade(owner, "start", attempt_id=aid, lease_id=lease)
330
+ store.grade(owner, "complete", attempt_id=aid, lease_id=lease, result=validate_assessment(assessment(score=score)))
331
+
332
+
333
+ def test_the_sittings_migration_is_additive_and_service_only(answer_store):
334
+ import psycopg
335
+ with answer_store.connect() as c:
336
+ assert c.execute("select name from classes").fetchone()[0] == "existing math class"
337
+ assert c.execute("select relrowsecurity from pg_class where relname='math_quiz_sittings'").fetchone()[0]
338
+ c.execute("set role authenticated")
339
+ with pytest.raises(psycopg.errors.InsufficientPrivilege):
340
+ c.execute("select * from math_quiz_sittings")
341
+ c.execute("reset role")
342
+ c.execute("set role anon")
343
+ with pytest.raises(psycopg.errors.InsufficientPrivilege):
344
+ c.execute("select math_quiz_action(null,'report','{\"users\":[]}')")
345
+
346
+
347
+ def sitting_app(store):
348
+ app = create_app(auth=ScanAuth()); app.state.answers = store
349
+ return app
350
+
351
+
352
+ def api(app, user=OWNER):
353
+ return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test",
354
+ headers={"Authorization": f"Bearer {user}"})
355
+
356
+
357
+ def test_handing_in_keeps_a_sitting_with_a_grade_from_the_stored_checks(answer_store):
358
+ store = answer_store
359
+ ids = [quiz_answer(store, i, photo=i < 3) for i in range(4)] # the last question has no photo
360
+ for aid, score in zip(ids[:3], (100, 60, 80)):
361
+ check(store, aid, score)
362
+ key = "9f0d6c3e-8c51-4a51-9c7e-0d5f4b8a1e11"
363
+ app = sitting_app(store)
364
+
365
+ async def run():
366
+ async with api(app) as c:
367
+ r = await c.post("/api/math/quiz-sittings", json={"unit_id": "t", "attempts": ids, "client_key": key})
368
+ assert r.status_code == 200
369
+ sitting = r.json()
370
+ assert sitting["unit_id"] == "t" and sitting["attempts"] == ids
371
+ assert sitting["grade"] == 25 + 15 + 20 + 0 and sitting["open"] == 0
372
+ again = (await c.post("/api/math/quiz-sittings", json={"unit_id": "t", "attempts": ids, "client_key": key,
373
+ "time_up": True})).json()
374
+ assert again["id"] == sitting["id"] # a lost response, sent again
375
+ mine = (await c.get("/api/math/quiz-sittings", params={"unit_id": "t"})).json()["sittings"]
376
+ assert [s["id"] for s in mine] == [sitting["id"]]
377
+ assert (await c.get("/api/math/quiz-sittings", params={"unit_id": "k"})).json() == {"sittings": []}
378
+ asyncio.run(run())
379
+ with answer_store.connect() as c:
380
+ assert c.execute("select count(*) from math_quiz_sittings").fetchone()[0] == 1
381
+
382
+
383
+ def test_a_sitting_with_the_wrong_attempts_is_refused(answer_store):
384
+ store = answer_store
385
+ ids = [quiz_answer(store, i) for i in range(4)]
386
+ theirs = quiz_answer(store, 0, owner=OTHER, photo=False)
387
+ other_unit = quiz_answer(store, 0, unit="k")
388
+ app = sitting_app(store)
389
+ post = lambda c, attempts, key, unit="t": c.post("/api/math/quiz-sittings",
390
+ json={"unit_id": unit, "attempts": attempts, "client_key": key})
391
+ keys = [f"00000000-0000-4000-8000-00000000000{i}" for i in range(6)]
392
+
393
+ async def run():
394
+ async with api(app) as c:
395
+ assert (await post(c, [theirs] + ids[1:], keys[0])).status_code == 409 # another student's
396
+ assert (await post(c, [ids[1], ids[0], ids[2], ids[3]], keys[1])).status_code == 409 # wrong order
397
+ assert (await post(c, [other_unit] + ids[1:], keys[2])).status_code == 409 # another unit's question
398
+ assert (await post(c, ids[:3], keys[3])).status_code == 404 # a question short
399
+ assert (await post(c, ids, keys[4], unit="nope")).status_code == 404
400
+ assert (await post(c, ["x"] * 4, keys[5])).status_code == 404 # not attempt ids
401
+ assert (await post(c, ids, keys[0])).status_code == 200
402
+ reused = await post(c, ids, keys[1]) # the same attempts again
403
+ assert (reused.status_code, reused.json()["detail"]) == (409, "invalid_sitting")
404
+ asyncio.run(run())
405
+ with answer_store.connect() as c:
406
+ assert c.execute("select count(*) from math_quiz_sittings").fetchone()[0] == 1
407
+
408
+
409
+ def test_a_check_that_finishes_after_handing_in_gives_the_sitting_its_grade(answer_store):
410
+ store = answer_store
411
+ ids = [quiz_answer(store, i) for i in range(4)]
412
+ for aid in ids[:3]:
413
+ check(store, aid, 100)
414
+ app = sitting_app(store)
415
+
416
+ async def run():
417
+ async with api(app) as c:
418
+ first = (await c.post("/api/math/quiz-sittings", json={"unit_id": "t", "attempts": ids,
419
+ "client_key": "9f0d6c3e-8c51-4a51-9c7e-0d5f4b8a1e11"})).json()
420
+ assert first["grade"] is None and first["open"] == 1
421
+ assert first["questions"][3]["state"] == "waiting"
422
+ check(store, ids[3], 40) # for example the teacher sends it from the queue
423
+ mine = (await c.get("/api/math/quiz-sittings", params={"unit_id": "t"})).json()["sittings"]
424
+ assert mine[0]["grade"] == 85 and mine[0]["open"] == 0
425
+ asyncio.run(run())