3v324v23 commited on
Commit
7badcff
·
1 Parent(s): 0818184

Add tests for drafting and reviewing student answers

Browse files

- Implement tests for the drafting of worked examples in `test_draft_examples.py`, ensuring well-formed drafts are generated for skills without examples, and that the model's responses are validated.
- Create tests in `test_teacher_review.py` to cover the teacher's review process of student answers, including handling review requests, score validation, and ensuring proper access control for teachers and students.
- Ensure that the tests cover various scenarios, including error handling and edge cases, to maintain robustness in the review and drafting functionalities.

README.md CHANGED
@@ -80,8 +80,24 @@ For rollback, turn `MATHLY_ANSWER_SCANS_ENABLED` off and restart; preserve the n
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
 
87
  Students never call the AI themselves. When a student saves an open answer (or hands in a **בוחן מסכם**, or sends a photographed problem), the request goes to their class teacher's **תור AI** instead of to Claude. The teacher sees each waiting request with the student's photo and the question, then presses **שליחה ל-AI** or **דחייה** (with an optional note for the student). The student's page shows the answer as waiting for the teacher, then the grade and feedback once it is sent, or the teacher's note if it was declined (the student can ask again). A failed send stays in the queue for the teacher to retry. Managers see every class's requests.
@@ -90,9 +106,17 @@ Accounts with the teacher, admin or developer role, and every class teacher, sti
90
 
91
  Apply [the additive queue migration](supabase/migrations/20261104090000_math_ai_requests.sql) before deploying this code: it adds the service-only `math_ai_requests` table. Without it, students cannot have answers checked (they see that checking is unavailable); teachers are not affected.
92
 
 
 
 
 
 
 
93
  ### Lesson resources (teachers)
94
 
95
- 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.
 
 
96
 
97
  Integration tests use bundled disposable PostgreSQL and local private test blobs, without project credentials or Docker:
98
 
 
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
+ The report opens with **דורשים תשומת לב**: the students who have not practised for 7 days, are stuck on a skill (3 or more wrong answers in 14 days without finishing it), practised less than half as much this week as last (from Sunday, and only when last week had at least 10 right answers), have a unit-quiz grade below 55, or have not started. Each is listed with the reasons. The **טבלת מיומנויות** view is a grid of students by skill for a chosen unit (or by unit), with how many finished each skill and a ⚠ on skills where at least a third of the students are practising without having finished. The list can be sorted by attention, name, progress or last activity; the choices are kept on the teacher's device. Students are shown by the class nickname, else their Google name, else the start of their email. The thresholds are constants at the top of the teacher's signals in `static/math-sky.js`. These signals come from the device-reported practice data below, so they tell the teacher where to look; they are not grades.
84
+
85
  **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.
86
 
87
+ ### Assignments
88
+
89
+ The class teacher (or a manager) gives the class an **assignment** from the class report's **משימות** tab (`app/assignments.py`, table `math_assignments`): a title, a note, a due date and 1 to 20 items, each a skill to finish, a unit quiz to hand in, or a practice exam over chosen units. Every item must belong to the units open to the class; the same item twice is kept once. Students see their classes' assignments in a **משימות מהמורה** card on the home screen (`GET /api/math/assignments`, without the teacher's email), a continue button that opens the first item not done (a practice exam opens with its units chosen), and a 📌 on the path's stars of skills in unfinished assignments. Finished ones stay in a **הושלמו** list for two weeks after the due date.
90
+
91
+ Nothing about completion is stored: the page works it out (`assignmentState` in `static/math-sky.js`), the same way for the student and for the teacher's report. A skill is done when it is finished, whenever that was; a unit quiz when the server has a sitting of it handed in after the assignment was made (`app/quiz_grades.py`); a practice exam when one covering all the item's units was taken after that. Dates are Jerusalem days, so an item done on the due date is on time. The report's tab shows how many students finished each assignment and, per student, which items are done, late (✓ באיחור) or missing; a student with an unfinished assignment past its due date is added to **דורשים תשומת לב**. The completion of skills and practice exams is what the student's device reports, like the rest of the practice data.
92
+
93
+ Apply [the assignments migration](supabase/migrations/20261104210000_math_assignments.sql) before deploying: it adds the service-only table, which is deleted with its class. Without it the routes answer 503, students see no card, and the report's tab says assignments are unavailable.
94
+
95
+ ### Reviewing answers (teacher score and comment)
96
+
97
+ The class teacher (or a manager) can open any open answer of their students that has a saved photo: from the **פתיחה** button in the class report's unit-quiz table (it lists the latest sitting's answers), or from a handled answer in the AI queue. The page shows the question, the photo, the automatic check and a form for a **score** (0 to 100), a **comment**, or both (`app/teacher_review.py`, table `math_teacher_reviews`). The automatic check is never edited: the review is kept next to it, the newest review of an answer counts and older ones stay as history. Wherever an answer's score is used, the teacher's wins (`effective_score`): the unit-quiz grade (`app/quiz_grades.py`) and the skill itself, which a teacher score of 100 completes on the student's device the next time they open the answer. A teacher can also score an answer the AI could not read or has not yet checked. The student sees **הערת המורה** and its score above the automatic check, which stays visible and labelled, and a notice on the home screen until they open the answer. A student can press **לבקש מהמורה לבדוק שוב** on a checked answer with a short message: one open request per answer and five per student (`math_review_requests`). Requests appear in the AI queue's **בקשות לבדיקה חוזרת** tab (counted on the queue button) and on the student's report card, and saving a review closes the request. Only the teacher of the student's class, or a manager, can open or review the answer; anyone else gets a 403 and no image or check.
98
+
99
+ Apply [the reviews migration](supabase/migrations/20261104150000_math_teacher_reviews.sql) after the quiz-sittings migration and before deploying this code. It adds the two tables and two service-only functions, and replaces `math_quiz_sitting_json` so a sitting's grade can use the teacher's score. Without it the teacher's review routes answer 503, and students see their answers and grades as before, with no teacher section.
100
+
101
  ### The teacher's AI queue
102
 
103
  Students never call the AI themselves. When a student saves an open answer (or hands in a **בוחן מסכם**, or sends a photographed problem), the request goes to their class teacher's **תור AI** instead of to Claude. The teacher sees each waiting request with the student's photo and the question, then presses **שליחה ל-AI** or **דחייה** (with an optional note for the student). The student's page shows the answer as waiting for the teacher, then the grade and feedback once it is sent, or the teacher's note if it was declined (the student can ask again). A failed send stays in the queue for the teacher to retry. Managers see every class's requests.
 
106
 
107
  Apply [the additive queue migration](supabase/migrations/20261104090000_math_ai_requests.sql) before deploying this code: it adds the service-only `math_ai_requests` table. Without it, students cannot have answers checked (they see that checking is unavailable); teachers are not affected.
108
 
109
+ ### Worked examples (help when stuck)
110
+
111
+ A skill in `app/math_algebra9.json` may have an `example`: a question of its own (never one of the skill's practice questions), `steps` of `{do, why}`, and the `answer`. The **?** button in a skill's sheet opens it over the question, one step at a time; it is also offered after the second wrong answer in a round. Opening it is kept in the student's progress (`examples`: count and last time, merged across devices), changes nothing about finishing or gold, and shows on the student's report card for two weeks. Students never call the AI, so examples are written in advance and **a math teacher must review every one before it reaches students**. `app/math_skills.py` refuses data whose examples are malformed or copy a practice question, and `skills_without_example(graph)` lists what is still to write.
112
+
113
+ `scripts/draft_examples.py` helps write them: `draft --unit t` asks Claude for one example per skill of a unit that has none (one request per skill, using `ANTHROPIC_API_KEY` and `MATH_MODEL`; `--dry-run` shows what it would send) and writes a review file under `docs/example-drafts/` without touching the data. The teacher edits the file, sets `approved` and `reviewed_by` on the good ones, and `merge docs/example-drafts/t.json` adds only those to the data file after the app's own checks.
114
+
115
  ### Lesson resources (teachers)
116
 
117
+ 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 all resources, and only the teacher who added a photo (or a manager) can delete it or change who sees it.
118
+
119
+ **Showing resources to students.** Each resource has a **גלוי לתלמידים** box: checked by default for a theory explanation, unchecked for an exercise or a note, and changeable later by its teacher or a manager (`PATCH /api/math/resources/{id}`). Students see the visible resources of the units open to their class (`/api/math/units/{unit}/student-resources`, with no teacher email and no storage path; hidden ones and other classes' units answer 404). A resource tied to a skill shows in that skill's sheet and its **?** help panel under **הסברים מהמורה**; the unit's other visible resources are on a list opened from a button under the unit. Apply [the visibility migration](supabase/migrations/20261104180000_math_resources_visible.sql); it adds the `visible` column, and every resource added before it starts hidden. Without it, uploads still work (the box is ignored) and students see none.
120
 
121
  Integration tests use bundled disposable PostgreSQL and local private test blobs, without project credentials or Docker:
122
 
app/access.py CHANGED
@@ -36,3 +36,16 @@ def resolve_access(store: SupabaseAuth, token: str, *, fresh_permissions: bool =
36
  def has_permission(user: dict | None, permission: str) -> bool:
37
  permissions = set((user or {}).get("permissions", ()))
38
  return "*" in permissions or permission in permissions
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  def has_permission(user: dict | None, permission: str) -> bool:
37
  permissions = set((user or {}).get("permissions", ()))
38
  return "*" in permissions or permission in permissions
39
+
40
+
41
+ def students_of(user: dict, classes) -> set[str] | None:
42
+ """The students (emails) this account reviews: those of the classes it is the teacher of, and None, meaning every
43
+ student, for a manager. Shared by the AI queue and the teacher's review of answers."""
44
+ if has_permission(user, "management.access"):
45
+ return None
46
+ email = str(user.get("email", "")).lower()
47
+ out: set[str] = set()
48
+ for row in classes:
49
+ if email in row.get("teachers", ()):
50
+ out |= set(row.get("emails", ())) - set(row.get("teachers", ()))
51
+ return out
app/ai_queue.py CHANGED
@@ -19,7 +19,7 @@ from datetime import datetime, timedelta, timezone
19
  from fastapi import Depends
20
  from fastapi.responses import JSONResponse, Response
21
 
22
- from .access import has_permission
23
  from .answer_scans import ScanError, uuid_text
24
  from .auth import AuthUnavailable, ClassConflict
25
  from .math_photo import PhotoUnavailable
@@ -176,18 +176,10 @@ def install_ai_queue_routes(app, signed_in):
176
 
177
  def watched(user: dict) -> set[str] | None:
178
  """The students whose requests this account reviews; None: everyone's (a manager)."""
179
- if has_permission(user, "management.access"):
180
- return None
181
- email = str(user.get("email", "")).lower()
182
  try:
183
- classes = app.state.auth.classes()
184
  except AuthUnavailable as exc:
185
  raise ScanError(503, "queue_unavailable") from exc
186
- out: set[str] = set()
187
- for row in classes:
188
- if email in row.get("teachers", ()):
189
- out |= set(row.get("emails", ())) - set(row.get("teachers", ()))
190
- return out
191
 
192
  def reviewer(user=Depends(account)) -> dict:
193
  if not may_send(user) or watched(user) == set(): # a teacher with no class has no one to review
 
19
  from fastapi import Depends
20
  from fastapi.responses import JSONResponse, Response
21
 
22
+ from .access import has_permission, students_of
23
  from .answer_scans import ScanError, uuid_text
24
  from .auth import AuthUnavailable, ClassConflict
25
  from .math_photo import PhotoUnavailable
 
176
 
177
  def watched(user: dict) -> set[str] | None:
178
  """The students whose requests this account reviews; None: everyone's (a manager)."""
 
 
 
179
  try:
180
+ return students_of(user, app.state.auth.classes())
181
  except AuthUnavailable as exc:
182
  raise ScanError(503, "queue_unavailable") from exc
 
 
 
 
 
183
 
184
  def reviewer(user=Depends(account)) -> dict:
185
  if not may_send(user) or watched(user) == set(): # a teacher with no class has no one to review
app/answer_routes.py CHANGED
@@ -15,6 +15,7 @@ from .answer_scans import AnswerScans, MAX_IMAGE, ScanError, clean_image, public
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")
@@ -55,7 +56,8 @@ def install_answer_routes(app, signed_in):
55
  raise ScanError(503, "answer_storage_unavailable")
56
  return user["id"]
57
 
58
- def call(user, action, **data):
 
59
  result = app.state.answers.action(user, action, **data)
60
  if isinstance(result, dict) and result.get("attachment"):
61
  result["grading_available"] = bool(grading_key())
@@ -65,6 +67,17 @@ def install_answer_routes(app, signed_in):
65
  # A staged deployment must not block saved images or upload receipt.
66
  result["grading"] = None
67
  result["grading_available"] = False
 
 
 
 
 
 
 
 
 
 
 
68
  result["ai_request"] = None # the teacher's queue (ai_queue.py): this answer's latest request
69
  if app.state.ai_queue.configured and (result.get("grading") or {}).get("state") != "completed":
70
  try:
@@ -99,7 +112,7 @@ def install_answer_routes(app, signed_in):
99
  version = question_version(sample)
100
  if body.get("question_version") != version:
101
  raise ScanError(409, "question_changed")
102
- return reply(call(user, "resume", question_id=question["id"], question_version=version,
103
  snapshot={"name": question["name"], **sample}, new=body.get("new") is True))
104
 
105
  @app.post("/api/math/quiz-attempts")
@@ -115,13 +128,14 @@ def install_answer_routes(app, signed_in):
115
  version = question_version(snapshot)
116
  if body.get("question_version") != version:
117
  raise ScanError(409, "question_changed")
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)
@@ -130,6 +144,14 @@ def install_answer_routes(app, signed_in):
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")
@@ -141,7 +163,28 @@ def install_answer_routes(app, signed_in):
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)))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
 
146
  @app.post("/api/math/answer-attempts/{attempt_id}/grade")
147
  def grade_answer(attempt_id: str, body: dict | None = None, user=Depends(student), account=Depends(signed_in)):
 
15
  from .answer_grading import AnswerGrader
16
  from .math_photo import PhotoUnavailable
17
  from .quiz_grades import sitting_view
18
+ from .teacher_review import MAX_MESSAGE, effective_score, for_student as review_for_student, request_for_student
19
  from .math_skills import algebra9, question_version, quiz_snapshot
20
 
21
  log = logging.getLogger("mathly.answer_routes")
 
56
  raise ScanError(503, "answer_storage_unavailable")
57
  return user["id"]
58
 
59
+ def call(user, action, *, student_view=False, **data):
60
+ """`student_view`: the student is opening the answer, so a teacher's review of it counts as seen."""
61
  result = app.state.answers.action(user, action, **data)
62
  if isinstance(result, dict) and result.get("attachment"):
63
  result["grading_available"] = bool(grading_key())
 
67
  # A staged deployment must not block saved images or upload receipt.
68
  result["grading"] = None
69
  result["grading_available"] = False
70
+ marks = {"review": None, "request": None} # the teacher's review of the answer, and the student's request
71
+ try:
72
+ marks = app.state.answers.review(user, "get", attempt_id=result["attempt"]["id"])
73
+ if student_view and marks["review"] and not marks["review"].get("seen_at"):
74
+ app.state.answers.review(user, "seen", attempt_id=result["attempt"]["id"])
75
+ except ScanError:
76
+ pass # the reviews are not set up yet: the answer still shows
77
+ result["teacher_review"] = review_for_student(marks["review"])
78
+ result["review_request"] = request_for_student(marks["request"])
79
+ check = (result.get("grading") or {}).get("result") if (result.get("grading") or {}).get("state") == "completed" else None
80
+ result["effective_score"] = effective_score((check or {}).get("score"), (marks["review"] or {}).get("score"))
81
  result["ai_request"] = None # the teacher's queue (ai_queue.py): this answer's latest request
82
  if app.state.ai_queue.configured and (result.get("grading") or {}).get("state") != "completed":
83
  try:
 
112
  version = question_version(sample)
113
  if body.get("question_version") != version:
114
  raise ScanError(409, "question_changed")
115
+ return reply(call(user, "resume", student_view=True, question_id=question["id"], question_version=version,
116
  snapshot={"name": question["name"], **sample}, new=body.get("new") is True))
117
 
118
  @app.post("/api/math/quiz-attempts")
 
128
  version = question_version(snapshot)
129
  if body.get("question_version") != version:
130
  raise ScanError(409, "question_changed")
131
+ return reply(call(user, "resume", student_view=True, question_id=f"quiz:{unit['id']}:{index}",
132
+ question_version=version, snapshot=snapshot, new=body.get("new") is True))
133
 
134
  @app.post("/api/math/quiz-sittings")
135
+ def submit_sitting(body: dict, user=Depends(student), account=Depends(signed_in)):
136
  """Hands a unit quiz in: the sitting is kept on the server and its grade is worked out there from the checks
137
+ (app/quiz_grades.py). Sending the same `client_key` again returns the same sitting. A student's answers all go
138
+ to their teacher's queue right here, so none depends on the page staying open to ask for each one."""
139
  if open_ended_locked():
140
  raise ScanError(403, "open_ended_locked")
141
  unit = next((u for u in algebra9()["units"] if u["id"] == body.get("unit_id") and "quiz" in u), None)
 
144
  raise ScanError(404, "not_found")
145
  sitting = app.state.answers.quiz(user, "submit", unit_id=unit["id"], attempt_ids=[uuid_text(a) for a in attempts],
146
  time_up=body.get("time_up") is True, client_key=uuid_text(body.get("client_key")))
147
+ if account is not None and not may_send(account) and grading_key():
148
+ for aid in [a["attempt_id"] for a in sitting.get("attempts") or []]:
149
+ try:
150
+ found = app.state.answers.action(user, "get", attempt_id=aid)
151
+ if found.get("attachment") and not app.state.answers.grade(user, "get", attempt_id=aid):
152
+ ask_teacher(user, account, aid, found, True) # automatic: an answer the teacher declined stays declined
153
+ except ScanError as exc: # one answer that cannot be queued now must not lose the hand-in
154
+ log.warning("quiz answer not queued for the teacher: %s", exc.code)
155
  return JSONResponse(sitting_view(sitting), headers={"Cache-Control": "no-store"})
156
 
157
  @app.get("/api/math/quiz-sittings")
 
163
 
164
  @app.get("/api/math/answer-attempts/{attempt_id}")
165
  def get_attempt(attempt_id: str, user=Depends(student)):
166
+ return reply(call(user, "get", student_view=True, attempt_id=uuid_text(attempt_id)))
167
+
168
+ @app.post("/api/math/answer-attempts/{attempt_id}/review-request")
169
+ def ask_review(attempt_id: str, body: dict | None = None, user=Depends(student)):
170
+ """The student asks their teacher to look again at a checked answer (at most one open request per answer)."""
171
+ message = (body or {}).get("message")
172
+ if message is not None and not isinstance(message, str):
173
+ raise ScanError(400, "invalid_message")
174
+ message = (message or "").strip()
175
+ if len(message) > MAX_MESSAGE:
176
+ raise ScanError(400, "message_too_long")
177
+ marks = app.state.answers.review(user, "request", attempt_id=uuid_text(attempt_id), message=message)
178
+ return JSONResponse({"review_request": request_for_student(marks["request"])}, headers={"Cache-Control": "no-store"})
179
+
180
+ @app.get("/api/math/teacher-comments")
181
+ def teacher_comments(user=Depends(student)):
182
+ """Answers the teacher reviewed that the student has not opened since."""
183
+ try:
184
+ rows = app.state.answers.review(user, "unseen")
185
+ except ScanError:
186
+ rows = [] # not set up yet: nothing to tell
187
+ return JSONResponse({"comments": rows}, headers={"Cache-Control": "no-store"})
188
 
189
  @app.post("/api/math/answer-attempts/{attempt_id}/grade")
190
  def grade_answer(attempt_id: str, body: dict | None = None, user=Depends(student), account=Depends(signed_in)):
app/answer_scans.py CHANGED
@@ -159,6 +159,20 @@ class AnswerScans:
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"}
@@ -177,7 +191,7 @@ class AnswerScans:
177
  code, message = error.get("code"), error.get("message", "")
178
  if code == "P0002":
179
  raise ScanError(404, "not_found") from exc
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"}:
 
159
  raise
160
  return json.loads(raw)
161
 
162
+ def review(self, user_id: str | None, action: str, **data):
163
+ """The teacher's reviews of answers and the students' requests to look again (app/teacher_review.py). The
164
+ student's id is `user_id` (a teacher's route finds it first with the `owner` action)."""
165
+ if not self.configured:
166
+ raise ScanError(503, "answer_storage_unavailable")
167
+ body = {"p_user": user_id, "p_action": action, "p_data": data}
168
+ try:
169
+ raw = self._request("/rest/v1/rpc/math_review_action", "POST", json.dumps(body).encode(), "application/json")
170
+ except ScanError as exc:
171
+ if exc.code == "answer_storage_unavailable":
172
+ raise ScanError(503, "review_unavailable") from exc
173
+ raise
174
+ return json.loads(raw)
175
+
176
  def _request(self, path, method="GET", data=None, content_type=None):
177
  headers = {"Authorization": "Bearer " + self.auth.secret, "apikey": self.auth.secret,
178
  "User-Agent": "mathly/1.0"}
 
191
  code, message = error.get("code"), error.get("message", "")
192
  if code == "P0002":
193
  raise ScanError(404, "not_found") from exc
194
+ if code == "P0001" and message in {"grading_limit", "review_limit"}:
195
  raise ScanError(429, message) from exc
196
  if code == "P0001" and message in {"superseded", "invalid_upload", "upload_in_progress", "confirmation_required",
197
  "answer_required", "grading_busy", "grading_interrupted", "invalid_sitting"}:
app/assignments.py ADDED
@@ -0,0 +1,248 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Assignments: what a class teacher asks their class to do in the app, by a date.
2
+
3
+ An assignment (Supabase `math_assignments`) has a title, a note, a due date and 1 to 20 items, each one of
4
+ {"kind": "skill", "id"} finish this skill,
5
+ {"kind": "unit_quiz", "unit"} hand in this unit's summarizing quiz,
6
+ {"kind": "exam", "units": [...]} take a practice exam over these units.
7
+ Every item must belong to the units open to the class. Whether an item is done is not stored: the page works it out from
8
+ the student's progress and the server's quiz sittings (`assignmentState` in static/math-sky.js), the same way for the
9
+ student's home screen and for the teacher's report. The class's teacher (or a manager) writes them; the class reads them.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import urllib.parse
15
+ from datetime import date, datetime, timedelta, timezone
16
+ from zoneinfo import ZoneInfo
17
+
18
+ from fastapi import Depends, HTTPException
19
+ from fastapi.responses import JSONResponse
20
+
21
+ from .access import has_permission
22
+ from .answer_scans import ScanError
23
+ from .auth import AuthUnavailable
24
+ from .math_skills import algebra9
25
+
26
+ log = logging.getLogger("mathly.assignments")
27
+ MIGRATION = "supabase/migrations/20261104210000_math_assignments.sql"
28
+ TABLE = "/rest/v1/math_assignments"
29
+ FIELDS = "id,class_id,title,note,due_on,items,created_by_email,created_at,updated_at"
30
+ MAX_ITEMS, MAX_TITLE, MAX_NOTE, PER_CLASS = 20, 80, 500, 50
31
+ JERUSALEM = ZoneInfo("Asia/Jerusalem")
32
+
33
+
34
+ class AssignmentsUnavailable(RuntimeError):
35
+ pass
36
+
37
+
38
+ class Assignments:
39
+ """The table, through the server's secret key."""
40
+
41
+ def __init__(self, auth):
42
+ self.auth = auth
43
+ self._warned = False
44
+
45
+ @property
46
+ def configured(self) -> bool:
47
+ return bool(self.auth is not None and getattr(self.auth, "secret", None))
48
+
49
+ def _rest(self, query: str = "", method: str = "GET", body=None):
50
+ if not self.configured:
51
+ raise AssignmentsUnavailable("assignments_unavailable")
52
+ try:
53
+ return self.auth._admin(TABLE + query, method, body) or []
54
+ except AuthUnavailable as exc:
55
+ if "answered 404" in str(exc) and not self._warned:
56
+ log.warning("the math_assignments table is missing; apply %s", MIGRATION)
57
+ self._warned = True
58
+ raise AssignmentsUnavailable("assignments_unavailable") from exc
59
+
60
+ def for_classes(self, class_ids: list[int]) -> list[dict]:
61
+ """The newest-due assignments of these classes (at most PER_CLASS each)."""
62
+ if not class_ids:
63
+ return []
64
+ out = []
65
+ for class_id in class_ids:
66
+ out += self._rest(f"?select={FIELDS}&class_id=eq.{int(class_id)}&order=due_on.desc&limit={PER_CLASS}")
67
+ return out
68
+
69
+ def get(self, assignment_id: str) -> dict | None:
70
+ rows = self._rest(f"?select={FIELDS}&id=eq.{urllib.parse.quote(assignment_id)}")
71
+ return rows[0] if rows else None
72
+
73
+ def add(self, row: dict) -> dict:
74
+ return self._rest(f"?select={FIELDS}", "POST", row)[0]
75
+
76
+ def update(self, assignment_id: str, fields: dict) -> dict | None:
77
+ rows = self._rest(f"?id=eq.{urllib.parse.quote(assignment_id)}&select={FIELDS}", "PATCH", fields)
78
+ return rows[0] if rows else None
79
+
80
+ def remove(self, assignment_id: str) -> None:
81
+ self._rest(f"?id=eq.{urllib.parse.quote(assignment_id)}", "DELETE")
82
+
83
+
84
+ def clean_assignment(body, class_units: list[str]) -> dict:
85
+ """The fields of an assignment as sent, checked against the class's units; HTTPException 400 when something is off."""
86
+ if not isinstance(body, dict):
87
+ raise HTTPException(400, "send the assignment as an object")
88
+ title = " ".join(str(body.get("title") or "").split())
89
+ if not title or len(title) > MAX_TITLE:
90
+ raise HTTPException(400, f"the title is required (up to {MAX_TITLE} characters)")
91
+ note = str(body.get("note") or "").strip()
92
+ if len(note) > MAX_NOTE:
93
+ raise HTTPException(400, f"the note is longer than {MAX_NOTE} characters")
94
+ try:
95
+ due = date.fromisoformat(str(body.get("due_on") or ""))
96
+ if not 2020 <= due.year <= 2100 or len(str(body.get("due_on"))) != 10:
97
+ raise ValueError
98
+ except ValueError as exc:
99
+ raise HTTPException(400, "the due date must be a date (YYYY-MM-DD)") from exc
100
+ raw = body.get("items")
101
+ if not isinstance(raw, list) or not 1 <= len(raw) <= MAX_ITEMS:
102
+ raise HTTPException(400, f"an assignment has 1 to {MAX_ITEMS} items")
103
+ graph = algebra9()
104
+ skills = {s["id"]: s["unit"] for s in graph["skills"]}
105
+ quiz_units = {u["id"] for u in graph["units"] if "quiz" in u}
106
+ allowed, items, seen = set(class_units), [], set()
107
+ for item in raw:
108
+ kind = item.get("kind") if isinstance(item, dict) else None
109
+ if kind == "skill":
110
+ ok = skills.get(item.get("id")) in allowed
111
+ clean = {"kind": "skill", "id": item.get("id")}
112
+ elif kind == "unit_quiz":
113
+ ok = item.get("unit") in allowed and item.get("unit") in quiz_units
114
+ clean = {"kind": "unit_quiz", "unit": item.get("unit")}
115
+ elif kind == "exam":
116
+ units = item.get("units")
117
+ ok = isinstance(units, list) and bool(units) and len(set(units)) == len(units) and all(u in allowed for u in units)
118
+ clean = {"kind": "exam", "units": sorted(units) if ok else []}
119
+ else:
120
+ ok, clean = False, None
121
+ if not ok:
122
+ raise HTTPException(400, "an item is not part of this class's units")
123
+ key = repr(clean)
124
+ if key not in seen: # the same item twice is one item
125
+ seen.add(key)
126
+ items.append(clean)
127
+ return {"title": title, "note": note, "due_on": due.isoformat(), "items": items}
128
+
129
+
130
+ def public(row: dict, teacher: bool = False) -> dict:
131
+ out = {k: row.get(k) for k in ("id", "class_id", "title", "note", "due_on", "items", "created_at", "updated_at")}
132
+ if teacher:
133
+ out["created_by_email"] = row.get("created_by_email", "")
134
+ return out
135
+
136
+
137
+ def today_in_jerusalem() -> date:
138
+ return datetime.now(JERUSALEM).date()
139
+
140
+
141
+ def install_assignment_routes(app, signed_in, units_of):
142
+ if not hasattr(app.state, "assignments"):
143
+ app.state.assignments = Assignments(app.state.auth)
144
+
145
+ def store() -> Assignments:
146
+ return app.state.assignments
147
+
148
+ def class_row(class_id: int, user: dict) -> dict:
149
+ """The class, if this account is its teacher or a manager."""
150
+ if not user or not user.get("id"):
151
+ raise HTTPException(401, "sign in required")
152
+ try:
153
+ row = next((r for r in app.state.auth.classes() if r.get("id") == class_id), None)
154
+ except AuthUnavailable as exc:
155
+ raise HTTPException(503, str(exc)) from exc
156
+ if row is None:
157
+ raise HTTPException(404, "class not found")
158
+ if str(user.get("email", "")).lower() not in row.get("teachers", ()) and not has_permission(user, "management.access"):
159
+ raise HTTPException(403, "only the class's teacher can give it assignments")
160
+ return row
161
+
162
+ def unavailable(exc: AssignmentsUnavailable) -> HTTPException:
163
+ return HTTPException(503, "assignments_unavailable")
164
+
165
+ def reply(value, status=200):
166
+ return JSONResponse(value, status_code=status, headers={"Cache-Control": "no-store"})
167
+
168
+ def note(user, kind, row, label):
169
+ actions = getattr(app.state, "actions", None)
170
+ if actions is not None:
171
+ actions.record(user, kind, f"{label}: {row.get('title', '')}")
172
+
173
+ # ---- the class teacher
174
+ @app.get("/api/classes/{class_id}/assignments")
175
+ def list_assignments(class_id: int, user=Depends(signed_in)):
176
+ class_row(class_id, user)
177
+ try:
178
+ return reply({"assignments": [public(r, True) for r in store().for_classes([class_id])]})
179
+ except AssignmentsUnavailable as exc:
180
+ raise unavailable(exc) from exc
181
+
182
+ @app.post("/api/classes/{class_id}/assignments")
183
+ def create_assignment(class_id: int, body: dict, user=Depends(signed_in)):
184
+ row = class_row(class_id, user)
185
+ fields = clean_assignment(body, units_of(row))
186
+ try:
187
+ saved = store().add({**fields, "class_id": class_id, "created_by_email": str(user.get("email", "")).lower()})
188
+ except AssignmentsUnavailable as exc:
189
+ raise unavailable(exc) from exc
190
+ note(user, "assignment_saved", saved, row["name"])
191
+ return reply(public(saved, True), 201)
192
+
193
+ def owned(class_id: int, assignment_id: str):
194
+ try:
195
+ found = store().get(assignment_id)
196
+ except AssignmentsUnavailable as exc:
197
+ raise unavailable(exc) from exc
198
+ if not found or found.get("class_id") != class_id:
199
+ raise HTTPException(404, "assignment not found")
200
+ return found
201
+
202
+ @app.put("/api/classes/{class_id}/assignments/{assignment_id}")
203
+ def update_assignment(class_id: int, assignment_id: str, body: dict, user=Depends(signed_in)):
204
+ row = class_row(class_id, user)
205
+ owned(class_id, assignment_id)
206
+ fields = clean_assignment(body, units_of(row))
207
+ try:
208
+ saved = store().update(assignment_id, {**fields, "updated_at": datetime.now(timezone.utc).isoformat()})
209
+ except AssignmentsUnavailable as exc:
210
+ raise unavailable(exc) from exc
211
+ if not saved:
212
+ raise HTTPException(404, "assignment not found")
213
+ note(user, "assignment_saved", saved, row["name"])
214
+ return reply(public(saved, True))
215
+
216
+ @app.delete("/api/classes/{class_id}/assignments/{assignment_id}")
217
+ def delete_assignment(class_id: int, assignment_id: str, user=Depends(signed_in)):
218
+ row = class_row(class_id, user)
219
+ found = owned(class_id, assignment_id)
220
+ try:
221
+ store().remove(assignment_id)
222
+ except AssignmentsUnavailable as exc:
223
+ raise unavailable(exc) from exc
224
+ note(user, "assignment_deleted", found, row["name"])
225
+ return reply({"ok": True})
226
+
227
+ # ---- the students: their classes' assignments, and when they handed in each unit quiz
228
+ @app.get("/api/math/assignments")
229
+ def my_assignments(user=Depends(signed_in)):
230
+ if not user or not user.get("id"):
231
+ raise HTTPException(401, "sign in required")
232
+ classes = [c for c in user.get("classes") or [] if c.get("id") is not None]
233
+ try:
234
+ rows = store().for_classes([c["id"] for c in classes])
235
+ except AssignmentsUnavailable as exc:
236
+ raise unavailable(exc) from exc
237
+ names = {c["id"]: c.get("name", "") for c in classes}
238
+ quiz_units = sorted({i["unit"] for r in rows for i in r["items"] if i.get("kind") == "unit_quiz"})
239
+ sittings = []
240
+ if quiz_units:
241
+ try:
242
+ for unit in quiz_units:
243
+ sittings += [{"unit_id": s["unit_id"], "submitted_at": s["submitted_at"]}
244
+ for s in app.state.answers.quiz(user["id"], "list", unit_id=unit)]
245
+ except ScanError:
246
+ sittings = None # the server's record could not be read: unit-quiz items cannot be told done
247
+ return reply({"assignments": [{**public(r), "class_name": names.get(r["class_id"], "")} for r in rows],
248
+ "sittings": sittings, "today": today_in_jerusalem().isoformat()})
app/auth.py CHANGED
@@ -160,8 +160,9 @@ class SupabaseAuth:
160
  grades = []
161
  return {"attempts": attempts, "grades": grades}
162
 
163
- def user_ids(self, emails: list[str]) -> dict[str, str]:
164
- """The account id of each email that has signed in at least once (Supabase's admin user list), by email."""
 
165
  wanted, found = {email.lower() for email in emails}, {}
166
  page = 1
167
  while wanted - found.keys():
@@ -170,12 +171,23 @@ class SupabaseAuth:
170
  for user in users or []:
171
  email = str(user.get("email") or "").lower()
172
  if email in wanted and user.get("id"):
173
- found[email] = user["id"]
 
 
174
  if len(users or []) < 1000 or page >= 50:
175
  break
176
  page += 1
177
  return found
178
 
 
 
 
 
 
 
 
 
 
179
  def access_for(self, user_id: str, fresh: bool = False) -> dict | None:
180
  if not self.secret:
181
  return None
 
160
  grades = []
161
  return {"attempts": attempts, "grades": grades}
162
 
163
+ def user_accounts(self, emails: list[str]) -> dict[str, dict]:
164
+ """The id and Google name of each email that has signed in at least once (Supabase's admin user list), by email.
165
+ The name is "" when the account has none."""
166
  wanted, found = {email.lower() for email in emails}, {}
167
  page = 1
168
  while wanted - found.keys():
 
171
  for user in users or []:
172
  email = str(user.get("email") or "").lower()
173
  if email in wanted and user.get("id"):
174
+ meta = user.get("user_metadata") if isinstance(user.get("user_metadata"), dict) else {}
175
+ name = " ".join(str(meta.get("full_name") or meta.get("name") or "").split())
176
+ found[email] = {"id": user["id"], "name": name}
177
  if len(users or []) < 1000 or page >= 50:
178
  break
179
  page += 1
180
  return found
181
 
182
+ def user_email(self, user_id: str) -> str:
183
+ """The email of one account (Supabase's admin API), "" when it has none."""
184
+ data = self._admin(f"/auth/v1/admin/users/{user_id}") or {}
185
+ return str(data.get("email") or "").lower() if isinstance(data, dict) else ""
186
+
187
+ def user_ids(self, emails: list[str]) -> dict[str, str]:
188
+ """The account id of each email that has signed in at least once, by email."""
189
+ return {email: account["id"] for email, account in self.user_accounts(emails).items()}
190
+
191
  def access_for(self, user_id: str, fresh: bool = False) -> dict | None:
192
  if not self.secret:
193
  return None
app/class_report.py CHANGED
@@ -17,6 +17,7 @@ from fastapi import Depends, HTTPException
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
@@ -46,7 +47,7 @@ def report_part(data: dict) -> dict:
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
 
@@ -67,6 +68,33 @@ def server_sittings(app, ids: dict[str, str]):
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:
@@ -89,7 +117,8 @@ def install_class_report_routes(app, signed_in, units_of):
89
  students = [email for email in row.get("emails", ()) if email not in teachers]
90
  auth = app.state.auth
91
  try:
92
- ids = auth.user_ids(students) if students else {}
 
93
  rows = auth._admin(f"{TABLE}?select=user_id,data,updated_at&user_id=in.({','.join(ids.values())})") \
94
  if ids else []
95
  except AuthUnavailable as exc:
@@ -97,13 +126,18 @@ def install_class_report_routes(app, signed_in, units_of):
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"})
 
 
17
  from fastapi.responses import JSONResponse
18
 
19
  from .access import has_permission
20
+ from .assignments import AssignmentsUnavailable, public as assignment_public
21
  from .answer_scans import ScanError
22
  from .auth import AuthUnavailable
23
  from .quiz_grades import sitting_view
 
47
  "history": [{k: h.get(k) for k in ("at", "timeUp", "sid")} for h in history or [] if isinstance(h, dict)],
48
  "going": bool(isinstance(sitting, dict) and sitting.get("started") and not sitting.get("submitted"))}
49
  return {"done": obj("done"), "gold": obj("gold"), "days": obj("days"), "missed": obj("missed"),
50
+ "seen": obj("seen"), "tries": obj("tries"), "examples": obj("examples"), "slips": items("slips")[-SLIPS_SENT:], "quizzes": quizzes, "uq": unit_quizzes,
51
  "updated": data.get("updated")}
52
 
53
 
 
68
  return out
69
 
70
 
71
+ def server_requests(app, ids: dict[str, str]):
72
+ """The students' open requests to look again at a checked answer (app/teacher_review.py), by email. None when
73
+ they cannot be read."""
74
+ try:
75
+ raw = app.state.answers.review(None, "requests", users=list(ids.values())) if ids else []
76
+ except ScanError as exc:
77
+ log.warning("review requests not read: %s", exc.code)
78
+ return None
79
+ email_of = {user_id: email for email, user_id in ids.items()}
80
+ out: dict[str, list] = {email: [] for email in ids}
81
+ for row in raw:
82
+ if row.get("user_id") in email_of:
83
+ out[email_of[row["user_id"]]].append({k: row.get(k) for k in ("attempt_id", "message", "question", "created_at")})
84
+ return out
85
+
86
+
87
+ def server_assignments(app, class_id: int):
88
+ """The class's assignments (the teacher's view of them), or None when they cannot be read."""
89
+ store = getattr(app.state, "assignments", None)
90
+ if store is None:
91
+ return None
92
+ try:
93
+ return [assignment_public(r, True) for r in store.for_classes([class_id])]
94
+ except AssignmentsUnavailable:
95
+ return None
96
+
97
+
98
  def install_class_report_routes(app, signed_in, units_of):
99
  def class_of(class_id: int, user) -> dict:
100
  try:
 
117
  students = [email for email in row.get("emails", ()) if email not in teachers]
118
  auth = app.state.auth
119
  try:
120
+ accounts = auth.user_accounts(students) if students else {}
121
+ ids = {email: account["id"] for email, account in accounts.items()}
122
  rows = auth._admin(f"{TABLE}?select=user_id,data,updated_at&user_id=in.({','.join(ids.values())})") \
123
  if ids else []
124
  except AuthUnavailable as exc:
 
126
  raise HTTPException(503, "report_unavailable") from exc
127
  by_id = {r["user_id"]: r for r in rows or []}
128
  sittings = server_sittings(app, ids)
129
+ requests = server_requests(app, ids)
130
+ nicknames = {str(k).lower(): str(v).strip() for k, v in (row.get("nicknames") or {}).items()}
131
  out = []
132
  for email in students:
133
  saved = by_id.get(ids.get(email, ""))
134
  data = saved.get("data") if saved else None
135
+ name = nicknames.get(email) or accounts.get(email, {}).get("name") or None
136
+ out.append({"email": email, "name": name, "signed_in": email in ids,
137
  "progress": report_part(data) if isinstance(data, dict) else None,
138
  "saved_at": saved.get("updated_at") if saved else None,
139
+ "quiz_sittings": None if sittings is None else sittings.get(email, []),
140
+ "review_requests": [] if requests is None else requests.get(email, [])})
141
  return JSONResponse({"class": {"id": row["id"], "name": row["name"], "lessons": units_of(row)},
142
+ "students": out, "assignments": server_assignments(app, row["id"])},
143
+ headers={"Cache-Control": "no-store"})
app/main.py CHANGED
@@ -25,6 +25,8 @@ from .math_photo import (DAILY_PHOTOS, MAX_PHOTO, MEDIA_TYPES as PHOTO_TYPES,
25
  PhotoSolver, PhotoUnavailable, math_model, photo_client)
26
  from .math_skills import algebra9, practice_graph
27
  from .class_report import install_class_report_routes
 
 
28
  from .progress import install_progress_routes
29
  from .push import install_push_routes
30
  from .monitoring import ActionLog, RETURN_GAP, install_monitoring_routes
@@ -297,7 +299,9 @@ def create_app(auth: SupabaseAuth | None = None) -> FastAPI:
297
  install_push_routes(app, signed_in)
298
  install_answer_routes(app, signed_in)
299
  install_ai_queue_routes(app, signed_in)
300
- install_resource_routes(app, signed_in)
 
 
301
  install_science_routes(app)
302
  install_monitoring_routes(app, manager)
303
  return app
 
25
  PhotoSolver, PhotoUnavailable, math_model, photo_client)
26
  from .math_skills import algebra9, practice_graph
27
  from .class_report import install_class_report_routes
28
+ from .teacher_review import install_teacher_review_routes
29
+ from .assignments import install_assignment_routes
30
  from .progress import install_progress_routes
31
  from .push import install_push_routes
32
  from .monitoring import ActionLog, RETURN_GAP, install_monitoring_routes
 
299
  install_push_routes(app, signed_in)
300
  install_answer_routes(app, signed_in)
301
  install_ai_queue_routes(app, signed_in)
302
+ install_teacher_review_routes(app, signed_in)
303
+ install_assignment_routes(app, signed_in, units_of)
304
+ install_resource_routes(app, signed_in, units_of)
305
  install_science_routes(app)
306
  install_monitoring_routes(app, manager)
307
  return app
app/math_skills.py CHANGED
@@ -10,6 +10,9 @@ The questions are written for the app, and every skill, link and mistake still n
10
 
11
  `problems` is run by the tests and on loading: a broken graph is refused before a student meets it.
12
 
 
 
 
13
  A unit may end with a summarizing quiz (`quiz`): open-ended questions answered on paper and photographed, a time limit
14
  in minutes, and points per question that add up to 100. Its questions are graded only after the quiz is handed in.
15
  """
@@ -110,6 +113,39 @@ def sample_problems(where: str, sid: str, sample: dict, listed: bool, mistakes:
110
  return out
111
 
112
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  def quiz_problems(unit: dict) -> list[str]:
114
  """A unit's summarizing quiz: a time limit, and open-ended questions whose points add up to 100."""
115
  quiz, where = unit["quiz"], f"{unit['id']} quiz"
@@ -181,6 +217,7 @@ def problems(graph: dict) -> list[str]:
181
  for n, sample in enumerate(questions(s), 1):
182
  out += sample_problems(f"{s['id']} question {n}" if listed else s["id"], s["id"], sample, listed,
183
  mistakes, below)
 
184
  out += [f"mistake {k}: unknown skill {m['back']}" for k, m in mistakes.items() if m["back"] and m["back"] not in skills]
185
  return out
186
 
 
10
 
11
  `problems` is run by the tests and on loading: a broken graph is refused before a student meets it.
12
 
13
+ A skill may have a worked example (`example`: a question of its own, `steps` of {do, why}, and the `answer`) that the
14
+ student can open when stuck. The examples are written in advance and reviewed by a teacher: students never call the AI.
15
+
16
  A unit may end with a summarizing quiz (`quiz`): open-ended questions answered on paper and photographed, a time limit
17
  in minutes, and points per question that add up to 100. Its questions are graded only after the quiz is handed in.
18
  """
 
113
  return out
114
 
115
 
116
+ def example_problems(skill: dict) -> list[str]:
117
+ """A skill's worked example, if it has one: a question of its own solved in steps (what to do, and why), and the
118
+ answer. It must not be one of the skill's practice questions, or it would hand the student that question's answer."""
119
+ example = skill.get("example")
120
+ if example is None:
121
+ return []
122
+ where = f"{skill['id']} example"
123
+ if not isinstance(example, dict):
124
+ return [f"{where}: must be an object"]
125
+ text = lambda value: isinstance(value, str) and bool(value.strip())
126
+ out = []
127
+ if not text(example.get("question")):
128
+ out.append(f"{where}: question text is required")
129
+ if not text(example.get("answer")):
130
+ out.append(f"{where}: an answer is required")
131
+ steps = example.get("steps")
132
+ if not isinstance(steps, list) or not steps:
133
+ out.append(f"{where}: needs at least one step")
134
+ else:
135
+ out += [f"{where} step {n}: needs what to do (do) and why (why)" for n, step in enumerate(steps, 1)
136
+ if not isinstance(step, dict) or not text(step.get("do")) or not text(step.get("why"))]
137
+ squash = lambda value: " ".join(str(value).split())
138
+ if text(example.get("question")) and squash(example["question"]) in {squash(q.get("question", "")) for q in questions(skill)}:
139
+ out.append(f"{where}: repeats one of the skill's practice questions")
140
+ return out
141
+
142
+
143
+ def skills_without_example(graph: dict) -> list[str]:
144
+ """The skills with multiple-choice questions that have no worked example yet (the coverage still to write)."""
145
+ return [s["id"] for s in graph["skills"]
146
+ if "example" not in s and all(q.get("type", "multiple-choice") == "multiple-choice" for q in questions(s))]
147
+
148
+
149
  def quiz_problems(unit: dict) -> list[str]:
150
  """A unit's summarizing quiz: a time limit, and open-ended questions whose points add up to 100."""
151
  quiz, where = unit["quiz"], f"{unit['id']} quiz"
 
217
  for n, sample in enumerate(questions(s), 1):
218
  out += sample_problems(f"{s['id']} question {n}" if listed else s["id"], s["id"], sample, listed,
219
  mistakes, below)
220
+ out += example_problems(s)
221
  out += [f"mistake {k}: unknown skill {m['back']}" for k, m in mistakes.items() if m["back"] and m["back"] not in skills]
222
  return out
223
 
app/quiz_grades.py CHANGED
@@ -9,17 +9,21 @@ change it. Both the student's result screen and the class report use `sitting_vi
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
 
 
9
  from __future__ import annotations
10
 
11
  from .math_skills import algebra9
12
+ from .teacher_review import effective_score
13
 
14
 
15
  def question_result(points: int, attempt: dict | None) -> dict:
16
+ """One question of a sitting: its points, what it earned (None while its check is open) and why. The teacher's
17
+ score, when they gave one, counts instead of the automatic check's (see app/teacher_review.py)."""
18
  if not attempt or not attempt.get("has_photo"):
19
  return {"points": points, "earned": 0, "state": "missing"}
20
+ done = attempt.get("grade_state") == "completed"
21
+ teacher = attempt.get("teacher_score")
22
+ score = effective_score(attempt.get("score") if done else None, teacher)
23
+ if score is not None and 0 <= score <= 100:
24
+ return {"points": points, "earned": int(points * score / 100 + 0.5),
25
+ "state": "teacher" if type(teacher) is int else "checked", "verdict": attempt.get("verdict")}
26
+ if done: # a check without a score: unreadable, or needs a teacher's look
27
  return {"points": points, "earned": None, "state": attempt.get("verdict") or "waiting"}
28
  return {"points": points, "earned": None, "state": "waiting"}
29
 
app/teacher_review.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The class teacher's review of a student's open answer, and the student's request to look again.
2
+
3
+ An open answer (a photographed solution) is checked by the AI; this lets the class's teacher (or a manager) open any
4
+ such answer of their students, give it a score of their own and write a comment. Where an answer's score is used, the
5
+ teacher's score wins over the AI's (`effective_score`). The AI's check is never edited: the review is kept next to it
6
+ (Supabase `math_teacher_reviews`), the latest review of an answer counts and the older ones stay as history.
7
+
8
+ A student who disagrees with a check asks the teacher to look again (`math_review_requests`, at most one open per
9
+ answer and five per student); saving a review closes the request.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+
15
+ from fastapi import Depends
16
+ from fastapi.responses import JSONResponse, Response
17
+
18
+ from .access import students_of
19
+ from .answer_scans import ScanError, public_result, uuid_text
20
+ from .auth import AuthUnavailable
21
+
22
+ log = logging.getLogger("mathly.teacher_review")
23
+ MAX_COMMENT = 1000
24
+ MAX_MESSAGE = 300
25
+
26
+
27
+ def effective_score(check_score, teacher_score):
28
+ """The score an answer counts with: the teacher's when they gave one, else the automatic check's (or None)."""
29
+ return teacher_score if type(teacher_score) is int else check_score if type(check_score) is int else None
30
+
31
+
32
+ def clean_review(body) -> tuple[int | None, str]:
33
+ """The score and comment of a review as sent; ScanError when neither is given or either is out of range."""
34
+ body = body if isinstance(body, dict) else {}
35
+ score, comment = body.get("score"), body.get("comment")
36
+ if score is not None and (type(score) is not int or not 0 <= score <= 100):
37
+ raise ScanError(400, "invalid_score")
38
+ if comment is not None and not isinstance(comment, str):
39
+ raise ScanError(400, "invalid_comment")
40
+ comment = (comment or "").strip()
41
+ if len(comment) > MAX_COMMENT:
42
+ raise ScanError(400, "comment_too_long")
43
+ if score is None and not comment:
44
+ raise ScanError(400, "empty_review")
45
+ return score, comment
46
+
47
+
48
+ def for_student(review: dict | None) -> dict | None:
49
+ """A review as the student's page reads it: no teacher email."""
50
+ if not review:
51
+ return None
52
+ return {k: review.get(k) for k in ("score", "comment", "created_at")}
53
+
54
+
55
+ def request_for_student(request: dict | None) -> dict | None:
56
+ if not request:
57
+ return None
58
+ return {k: request.get(k) for k in ("id", "message", "state", "created_at", "closed_at")}
59
+
60
+
61
+ def install_teacher_review_routes(app, signed_in):
62
+ def students(user: dict) -> set[str] | None:
63
+ try:
64
+ return students_of(user, app.state.auth.classes())
65
+ except AuthUnavailable as exc:
66
+ raise ScanError(503, "review_unavailable") from exc
67
+
68
+ def teacher(user=Depends(signed_in)) -> dict:
69
+ """A class teacher, or a manager."""
70
+ if not user or not user.get("id"):
71
+ raise ScanError(401, "sign_in_required")
72
+ if not app.state.answers.configured:
73
+ raise ScanError(503, "answer_storage_unavailable")
74
+ if students(user) == set(): # a teacher with no class has no one to review
75
+ raise ScanError(403, "teacher_required")
76
+ return user
77
+
78
+ def email_of(user_id: str) -> str:
79
+ try:
80
+ return app.state.auth.user_email(user_id)
81
+ except AuthUnavailable as exc:
82
+ raise ScanError(503, "review_unavailable") from exc
83
+
84
+ def owned(user: dict, attempt_id: str) -> tuple[str, str, str]:
85
+ """(attempt id, the student's account id, their email) of an answer this teacher may review."""
86
+ aid = uuid_text(attempt_id)
87
+ owner = app.state.answers.review(None, "owner", attempt_id=aid)
88
+ email = email_of(owner)
89
+ allowed = students(user)
90
+ if allowed is not None and email not in allowed:
91
+ raise ScanError(403, "forbidden")
92
+ return aid, owner, email
93
+
94
+ def reply(value, status=200):
95
+ return JSONResponse(value, status_code=status, headers={"Cache-Control": "no-store"})
96
+
97
+ @app.get("/api/teacher/answers/{attempt_id}")
98
+ def open_answer(attempt_id: str, user=Depends(teacher)):
99
+ aid, owner, email = owned(user, attempt_id)
100
+ found = app.state.answers.action(owner, "get", attempt_id=aid)
101
+ if not found.get("attachment"):
102
+ raise ScanError(409, "answer_required")
103
+ grading = app.state.answers.grade(owner, "get", attempt_id=aid)
104
+ marks = app.state.answers.review(owner, "get", attempt_id=aid)
105
+ shown = public_result({"attempt": found["attempt"], "attachment": found["attachment"], "grading": grading})
106
+ check = (grading or {}).get("result") if (grading or {}).get("state") == "completed" else None
107
+ return reply({**shown, "student": {"email": email}, "review": marks["review"], "request": marks["request"],
108
+ "effective_score": effective_score((check or {}).get("score"), (marks["review"] or {}).get("score"))})
109
+
110
+ @app.get("/api/teacher/answers/{attempt_id}/image")
111
+ def answer_image(attempt_id: str, user=Depends(teacher)):
112
+ aid, owner, _email = owned(user, attempt_id)
113
+ found = app.state.answers.action(owner, "get", attempt_id=aid)
114
+ if not found.get("attachment"):
115
+ raise ScanError(404, "not_found")
116
+ data = app.state.answers.read(found["attachment"]["object_key"])
117
+ return Response(data, media_type="image/jpeg",
118
+ headers={"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff"})
119
+
120
+ @app.put("/api/teacher/answers/{attempt_id}/review")
121
+ def save_review(attempt_id: str, body: dict, user=Depends(teacher)):
122
+ score, comment = clean_review(body)
123
+ aid, owner, email = owned(user, attempt_id)
124
+ marks = app.state.answers.review(owner, "review", attempt_id=aid, reviewer=user.get("email", ""),
125
+ score=score, comment=comment)
126
+ actions = getattr(app.state, "actions", None)
127
+ if actions is not None:
128
+ actions.record(user, "answer_reviewed", email)
129
+ return reply(marks)
130
+
131
+ @app.get("/api/teacher/review-requests")
132
+ def review_requests(user=Depends(teacher)):
133
+ """Open requests to look again from this teacher's students (a manager: everyone's)."""
134
+ allowed = students(user)
135
+ try:
136
+ if allowed is None:
137
+ rows, emails = app.state.answers.review(None, "requests"), {}
138
+ else:
139
+ accounts = app.state.auth.user_accounts(sorted(allowed))
140
+ emails = {account["id"]: email for email, account in accounts.items()}
141
+ rows = app.state.answers.review(None, "requests", users=list(emails))
142
+ except AuthUnavailable as exc:
143
+ raise ScanError(503, "review_unavailable") from exc
144
+ for row in rows:
145
+ row["student"] = emails.get(row["user_id"]) or email_of(row["user_id"])
146
+ del row["user_id"]
147
+ return reply({"requests": rows})
app/unit_resources.py CHANGED
@@ -2,6 +2,10 @@
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
 
@@ -27,7 +31,8 @@ MAX_NOTE = 200
27
 
28
 
29
  class ResourceUnavailable(RuntimeError):
30
- pass
 
31
 
32
 
33
  class UnitResources:
@@ -51,6 +56,14 @@ class UnitResources:
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__))
@@ -70,7 +83,17 @@ class UnitResources:
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")
@@ -88,9 +111,21 @@ class UnitResources:
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:
@@ -100,7 +135,7 @@ def resource_id(value: str) -> str:
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
 
@@ -133,7 +168,8 @@ def install_resource_routes(app, signed_in):
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")
@@ -155,7 +191,8 @@ def install_resource_routes(app, signed_in):
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)
@@ -174,6 +211,49 @@ def install_resource_routes(app, signed_in):
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))
 
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
+ A resource is hidden from students until its teacher marks it visible (`visible`; a theory explanation starts visible,
7
+ an exercise or a note starts hidden). Students see the visible resources of the units open to their class, through
8
+ `/api/math/units/.../student-resources`, and never the teacher's email.
9
  """
10
  from __future__ import annotations
11
 
 
31
 
32
 
33
  class ResourceUnavailable(RuntimeError):
34
+ """The storage did not answer. `no_visible_column`: the answer was that the visible column is not there yet."""
35
+ no_visible_column = False
36
 
37
 
38
  class UnitResources:
 
56
  try:
57
  with urllib.request.urlopen(req, timeout=30) as response:
58
  return response.read()
59
+ except urllib.error.HTTPError as exc:
60
+ log.warning("resource storage failed: %s", exc.code)
61
+ failure = ResourceUnavailable("resource storage unavailable")
62
+ try:
63
+ failure.no_visible_column = exc.code == 400 and b"visible" in exc.read() # the migration is not applied
64
+ except OSError:
65
+ pass
66
+ raise failure from exc
67
  except (urllib.error.URLError, TimeoutError, OSError) as exc:
68
  # Never pass SQL, keys or storage paths on to the browser.
69
  log.warning("resource storage failed: %s", getattr(exc, "code", type(exc).__name__))
 
83
  return rows[0] if rows else None
84
 
85
  def add(self, row: dict) -> dict:
86
+ try:
87
+ return self._rows("/rest/v1/math_unit_resources", "POST", row)[0]
88
+ except ResourceUnavailable as exc:
89
+ if not exc.no_visible_column:
90
+ raise
91
+ log.warning("the visible column is missing; apply supabase/migrations/20261104180000_math_resources_visible.sql")
92
+ return self._rows("/rest/v1/math_unit_resources", "POST", {k: v for k, v in row.items() if k != "visible"})[0]
93
+
94
+ def set_visible(self, resource_id: str, visible: bool) -> dict | None:
95
+ rows = self._rows(f"/rest/v1/math_unit_resources?id=eq.{resource_id}", "PATCH", {"visible": visible})
96
+ return rows[0] if rows else None
97
 
98
  def remove(self, resource_id: str) -> None:
99
  self._rows(f"/rest/v1/math_unit_resources?id=eq.{resource_id}", "DELETE")
 
111
 
112
 
113
  def public(row: dict) -> dict:
114
+ """A resource as a teacher's browser sees it: no storage path."""
115
+ return {**{key: row.get(key) for key in ("id", "unit_id", "kind", "note", "skill_id", "created_email", "created_at",
116
+ "created_by")}, "visible": bool(row.get("visible"))}
117
+
118
+
119
+ def for_student(row: dict) -> dict:
120
+ """A resource as a student's browser sees it: no storage path and no teacher."""
121
+ return {key: row.get(key) for key in ("id", "unit_id", "kind", "note", "skill_id", "created_at")}
122
+
123
+
124
+ def wants_visible(value: str | None, kind: str) -> bool:
125
+ """The upload form's visibility: what it says, else a theory explanation shows and anything else stays hidden."""
126
+ if value is None or not value.strip():
127
+ return kind == "theory"
128
+ return value.strip().lower() in ("1", "true", "on", "yes")
129
 
130
 
131
  def resource_id(value: str) -> str:
 
135
  raise HTTPException(404, "resource not found") from exc
136
 
137
 
138
+ def install_resource_routes(app, signed_in, units_of=None):
139
  if not hasattr(app.state, "resources"):
140
  app.state.resources = UnitResources(app.state.auth)
141
 
 
168
 
169
  @app.post("/api/math/units/{unit_id}/resources")
170
  async def add_resource(unit_id: str, photo: UploadFile = File(...), kind: str = Form(...),
171
+ note: str = Form(""), skill: str = Form(""), visible: str | None = Form(None),
172
+ user: dict = Depends(teacher)):
173
  unit = unit_of(unit_id)
174
  if kind not in KINDS:
175
  raise HTTPException(400, "choose what the photo shows")
 
191
  try:
192
  row = await storage(app.state.resources.add, {
193
  "unit_id": unit_id, "kind": kind, "note": note, "skill_id": skill or None, "object_key": key,
194
+ "byte_size": len(image), "created_by": user["id"], "created_email": user.get("email", ""),
195
+ "visible": wants_visible(visible, kind)})
196
  except HTTPException:
197
  try:
198
  await run_in_threadpool(app.state.resources.delete, key)
 
211
  return Response(await storage(app.state.resources.read, row["object_key"]), media_type="image/jpeg",
212
  headers={"Cache-Control": "private, max-age=3600", "X-Content-Type-Options": "nosniff"})
213
 
214
+ @app.patch("/api/math/resources/{rid}")
215
+ async def change_resource(rid: str, body: dict, user: dict = Depends(teacher)):
216
+ """Shows a resource to students or hides it again: its teacher, or a manager."""
217
+ if type(body.get("visible")) is not bool:
218
+ raise HTTPException(400, "send {\"visible\": true or false}")
219
+ row = await storage(app.state.resources.get, resource_id(rid))
220
+ if not row:
221
+ raise HTTPException(404, "resource not found")
222
+ if row.get("created_by") != user["id"] and not has_permission(user, "management.access"):
223
+ raise HTTPException(403, "only the teacher who added it can change it")
224
+ changed = await storage(app.state.resources.set_visible, row["id"], body["visible"])
225
+ return JSONResponse(public(changed or {**row, "visible": body["visible"]}), headers={"Cache-Control": "no-store"})
226
+
227
+ # ---- the students: the visible resources of the units open to their class
228
+ def student(user=Depends(signed_in)) -> dict:
229
+ if not user or not user.get("id"):
230
+ raise HTTPException(401, "sign in required")
231
+ if not app.state.resources.configured:
232
+ raise HTTPException(503, "resource storage is not configured")
233
+ return user
234
+
235
+ def open_to(user: dict, unit_id: str) -> bool:
236
+ """Whether the unit is one of the account's class units; with no class (none configured), every unit."""
237
+ classes = user.get("classes") or []
238
+ return not classes or not units_of or unit_id in {u for row in classes for u in units_of(row)}
239
+
240
+ @app.get("/api/math/units/{unit_id}/student-resources")
241
+ async def student_resources(unit_id: str, user: dict = Depends(student)):
242
+ unit_of(unit_id)
243
+ if not open_to(user, unit_id):
244
+ raise HTTPException(404, "unit not found")
245
+ rows = await storage(app.state.resources.list, unit_id)
246
+ return JSONResponse({"resources": [for_student(r) for r in rows if r.get("visible")]},
247
+ headers={"Cache-Control": "no-store"})
248
+
249
+ @app.get("/api/math/student-resources/{rid}/image")
250
+ async def student_resource_image(rid: str, user: dict = Depends(student)):
251
+ row = await storage(app.state.resources.get, resource_id(rid))
252
+ if not row or not row.get("visible") or not open_to(user, row["unit_id"]):
253
+ raise HTTPException(404, "resource not found") # hidden, or of another class: as if it did not exist
254
+ return Response(await storage(app.state.resources.read, row["object_key"]), media_type="image/jpeg",
255
+ headers={"Cache-Control": "private, max-age=300", "X-Content-Type-Options": "nosniff"})
256
+
257
  @app.delete("/api/math/resources/{rid}")
258
  async def delete_resource(rid: str, user: dict = Depends(teacher)):
259
  row = await storage(app.state.resources.get, resource_id(rid))
openspec/changes/add-class-assignments/tasks.md CHANGED
@@ -2,26 +2,26 @@
2
 
3
  ## 1. Storage and routes
4
 
5
- - [ ] 1.1 Add migration `<ts>_math_assignments.sql` (`math_assignments`, cascade on class delete, service-only). Verify it applies on the test database and deleting a class removes its assignments.
6
- - [ ] 1.2 Add `app/assignments.py` with teacher CRUD routes, item validation against the skills data and the class's units, and the class-teacher-or-manager rule. Verify in `tests/test_assignments.py`: create, foreign unit rejected, empty items rejected, other teacher 403, student 403, edit, delete.
7
- - [ ] 1.3 Add `GET /api/math/assignments` for students (their classes' assignments in range, plus their unit-quiz sitting times). Verify a student in two classes gets both classes' assignments and none from other classes.
8
 
9
  ## 2. Completion
10
 
11
- - [ ] 2.1 Add `assignmentState` in `static/math.js` implementing the completion rules (skill any time, unit quiz after creation, exam covering all units after creation, late after the Jerusalem end of the due date). Verify with a browser unit test on fixed fixtures for each scenario in the spec.
12
 
13
  ## 3. Student UI
14
 
15
- - [ ] 3.1 Add the "משימות מהמורה" home card (open, late, done list for 14 days, continue button to the next item). Verify with a browser test for open, late and next-item navigation.
16
- - [ ] 3.2 Mark assigned skills on the path with an accessible label. Verify in the browser test.
17
 
18
  ## 4. Teacher UI
19
 
20
- - [ ] 4.1 Add the create/edit form (title, note, due date, item pickers limited to class units) and delete with a confirmation that names how many students started. Verify with a browser test that creates and edits an assignment.
21
- - [ ] 4.2 Add the report's "משימות" tab with per-assignment counts and per-student item states. Verify with a browser test on a 3-student fixture.
22
- - [ ] 4.3 Add the overdue-assignment signal to the "needs attention" list (if `add-class-attention-view` is in place). Verify in the report browser test.
23
- - [ ] 4.4 Update `static/guide.js` (student: "משימות מהמורה"; teacher: creating and following assignments) and `README.md` (feature and migration). Verify `tests/test_guide_browser.py` passes.
24
 
25
  ## 5. Integration
26
 
27
- - [ ] 5.1 Run the full `pytest` suite and check locally end to end: the teacher assigns two skills and a unit quiz, a student completes them, and the report shows the student as done.
 
2
 
3
  ## 1. Storage and routes
4
 
5
+ - [x] 1.1 Add migration `<ts>_math_assignments.sql` (`math_assignments`, cascade on class delete, service-only). Verify it applies on the test database and deleting a class removes its assignments.
6
+ - [x] 1.2 Add `app/assignments.py` with teacher CRUD routes, item validation against the skills data and the class's units, and the class-teacher-or-manager rule. Verify in `tests/test_assignments.py`: create, foreign unit rejected, empty items rejected, other teacher 403, student 403, edit, delete.
7
+ - [x] 1.3 Add `GET /api/math/assignments` for students (their classes' assignments in range, plus their unit-quiz sitting times). Verify a student in two classes gets both classes' assignments and none from other classes.
8
 
9
  ## 2. Completion
10
 
11
+ - [x] 2.1 Add `assignmentState` in `static/math.js` implementing the completion rules (skill any time, unit quiz after creation, exam covering all units after creation, late after the Jerusalem end of the due date). Verify with a browser unit test on fixed fixtures for each scenario in the spec.
12
 
13
  ## 3. Student UI
14
 
15
+ - [x] 3.1 Add the "משימות מהמורה" home card (open, late, done list for 14 days, continue button to the next item). Verify with a browser test for open, late and next-item navigation.
16
+ - [x] 3.2 Mark assigned skills on the path with an accessible label. Verify in the browser test.
17
 
18
  ## 4. Teacher UI
19
 
20
+ - [x] 4.1 Add the create/edit form (title, note, due date, item pickers limited to class units) and delete with a confirmation that names how many students started. Verify with a browser test that creates and edits an assignment.
21
+ - [x] 4.2 Add the report's "משימות" tab with per-assignment counts and per-student item states. Verify with a browser test on a 3-student fixture.
22
+ - [x] 4.3 Add the overdue-assignment signal to the "needs attention" list (if `add-class-attention-view` is in place). Verify in the report browser test.
23
+ - [x] 4.4 Update `static/guide.js` (student: "משימות מהמורה"; teacher: creating and following assignments) and `README.md` (feature and migration). Verify `tests/test_guide_browser.py` passes.
24
 
25
  ## 5. Integration
26
 
27
+ - [x] 5.1 Run the full `pytest` suite and check locally end to end: the teacher assigns two skills and a unit quiz, a student completes them, and the report shows the student as done.
openspec/changes/add-class-attention-view/tasks.md CHANGED
@@ -2,24 +2,24 @@
2
 
3
  ## 1. Report data
4
 
5
- - [ ] 1.1 In `app/class_report.py`, add `seen` and `tries` to `report_part`. Verify in `tests/test_class_report.py` that both are returned and non-object values are dropped.
6
- - [ ] 1.2 Return each student's display name (class nickname, else Google full name, else null) from the report, extending `app/auth.py` `user_ids` to collect `user_metadata` names. Verify with a test using a fake admin user list with and without names.
7
 
8
  ## 2. Needs-attention section
9
 
10
- - [ ] 2.1 Add the attention constants and a pure `attentionOf(student, units, now)` in `static/math.js` returning a list of reasons. Verify with a browser test (pattern of `tests/test_sky_browser.py`) feeding fixed progress objects for each signal, including the "small numbers are not a drop" case.
11
- - [ ] 2.2 Render the "דורשים תשומת לב" section at the top of the report, with the empty-state text, and each student's reasons linking to their card. Verify with a browser test of the report sheet.
12
- - [ ] 2.3 Show weekly activity (this week / last week) on each card. Verify in the same browser test with known `days`.
13
 
14
  ## 3. Grid and sorting
15
 
16
- - [ ] 3.1 Add list/grid tabs, the unit picker, the per-skill grid using `MathlySky.star` states, the column counts and the class-difficulty mark. Verify with a browser test on a 3-student fixture, checking cell states and the marked column.
17
- - [ ] 3.2 Add the units overview. Verify finished/total per unit in the browser test.
18
- - [ ] 3.3 Add the sort control (attention, name, progress, last activity), saved in `localStorage` with try/catch. Verify order changes in the browser test.
19
- - [ ] 3.4 Use display names everywhere in the report, with the full email kept in the card. Verify in the browser test.
20
- - [ ] 3.5 Add CSS for the grid with a sticky name column and sideways scrolling inside its box. Verify at 375 px width in Playwright that the page itself has no horizontal scroll.
21
 
22
  ## 4. Docs and integration
23
 
24
- - [ ] 4.1 Update the "דוח הכיתה" section of the teacher tab in `static/guide.js` and the class-teacher section of `README.md`. Verify `tests/test_guide_browser.py` passes.
25
  - [ ] 4.2 Run the full `pytest` suite and open the report for a real class locally to check the attention list against the cards.
 
2
 
3
  ## 1. Report data
4
 
5
+ - [x] 1.1 In `app/class_report.py`, add `seen` and `tries` to `report_part`. Verify in `tests/test_class_report.py` that both are returned and non-object values are dropped.
6
+ - [x] 1.2 Return each student's display name (class nickname, else Google full name, else null) from the report, extending `app/auth.py` `user_ids` to collect `user_metadata` names. Verify with a test using a fake admin user list with and without names.
7
 
8
  ## 2. Needs-attention section
9
 
10
+ - [x] 2.1 Add the attention constants and a pure `attentionOf(student, units, now)` in `static/math.js` returning a list of reasons. Verify with a browser test (pattern of `tests/test_sky_browser.py`) feeding fixed progress objects for each signal, including the "small numbers are not a drop" case.
11
+ - [x] 2.2 Render the "דורשים תשומת לב" section at the top of the report, with the empty-state text, and each student's reasons linking to their card. Verify with a browser test of the report sheet.
12
+ - [x] 2.3 Show weekly activity (this week / last week) on each card. Verify in the same browser test with known `days`.
13
 
14
  ## 3. Grid and sorting
15
 
16
+ - [x] 3.1 Add list/grid tabs, the unit picker, the per-skill grid using `MathlySky.star` states, the column counts and the class-difficulty mark. Verify with a browser test on a 3-student fixture, checking cell states and the marked column.
17
+ - [x] 3.2 Add the units overview. Verify finished/total per unit in the browser test.
18
+ - [x] 3.3 Add the sort control (attention, name, progress, last activity), saved in `localStorage` with try/catch. Verify order changes in the browser test.
19
+ - [x] 3.4 Use display names everywhere in the report, with the full email kept in the card. Verify in the browser test.
20
+ - [x] 3.5 Add CSS for the grid with a sticky name column and sideways scrolling inside its box. Verify at 375 px width in Playwright that the page itself has no horizontal scroll.
21
 
22
  ## 4. Docs and integration
23
 
24
+ - [x] 4.1 Update the "דוח הכיתה" section of the teacher tab in `static/guide.js` and the class-teacher section of `README.md`. Verify `tests/test_guide_browser.py` passes.
25
  - [ ] 4.2 Run the full `pytest` suite and open the report for a real class locally to check the attention list against the cards.
openspec/changes/add-student-hints-and-resources/tasks.md CHANGED
@@ -2,28 +2,28 @@
2
 
3
  ## 1. Worked example content
4
 
5
- - [ ] 1.1 Add `example` validation to `app/math_skills.py` (own question, at least one step with `do`/`why`, `answer`) and send it in the skills API. Verify in `tests/test_math_skills.py` with a valid example and one that copies a practice question.
6
- - [ ] 1.2 Write a one-off drafting script under `scripts/` that proposes examples for skills without one, writing to a review file, not to the data file. Verify it runs on one unit and produces well-formed drafts.
7
  - [ ] 1.3 Add reviewed examples to `app/math_algebra9.json`, unit by unit, each one approved by a math teacher. Verify the validation test passes and the coverage test lists the skills still missing one.
8
 
9
  ## 2. Help in practice
10
 
11
- - [ ] 2.1 In `static/math.js`, offer "איך פותרים? דוגמה פתורה" from the second wrong answer in the round, and show the example inside the sheet with a return to the same question. Verify with a browser test: no offer after one mistake, offer after two, same question after closing.
12
- - [ ] 2.2 Add the "?" help panel in the skill sheet (description, example, teacher explanations for the skill). Verify in the browser test.
13
- - [ ] 2.3 Record `P.examples[id]` (count and last) and merge it across devices in `mergeProgress`. Add it to `ensure` in `static/math-sky.js`. Verify with a browser test that finishing and gold are unchanged by opening the example, and with a merge test.
14
 
15
  ## 3. Resource visibility
16
 
17
- - [ ] 3.1 Add migration `<ts>_math_resources_visible.sql` (column `visible` default false). Verify it applies on the test database.
18
- - [ ] 3.2 In `app/unit_resources.py`, accept `visible` on upload and add `PATCH /api/math/resources/{rid}` with the owner-or-manager rule. Verify in `tests/test_unit_resources.py`: defaults by kind, owner can toggle, another teacher gets 403.
19
- - [ ] 3.3 Add the student routes (list and image) limited to visible resources of the student's open units, answering 404 otherwise. Verify the tests: hidden resource 404, unit not open 404 or empty, no `object_key` in the response.
20
- - [ ] 3.4 In the UI, add the visibility checkbox to the upload form, a toggle and a "visible" mark on each resource for teachers, and the "הסברים מהמורה" lists for students in the skill sheet and unit. Verify with browser tests for both roles.
21
 
22
  ## 4. Report and docs
23
 
24
- - [ ] 4.1 Add `examples` to `report_part` and show on the student's card the skills where an example was opened in the last 14 days. Verify in `tests/test_class_report.py` and the report browser test.
25
- - [ ] 4.2 Update `static/guide.js` (student: the help and teacher explanations; teacher: visibility of resources, replacing "בשלב זה המשאבים גלויים למורים בלבד") and `README.md`. Verify `tests/test_guide_browser.py` passes.
26
 
27
  ## 5. Integration
28
 
29
- - [ ] 5.1 Run the full `pytest` suite and check locally, as a teacher and a student in the same class, that a theory photo uploaded by the teacher appears in the student's skill sheet.
 
2
 
3
  ## 1. Worked example content
4
 
5
+ - [x] 1.1 Add `example` validation to `app/math_skills.py` (own question, at least one step with `do`/`why`, `answer`) and send it in the skills API. Verify in `tests/test_math_skills.py` with a valid example and one that copies a practice question.
6
+ - [x] 1.2 Write a one-off drafting script under `scripts/` that proposes examples for skills without one, writing to a review file, not to the data file. Verify it runs on one unit and produces well-formed drafts.
7
  - [ ] 1.3 Add reviewed examples to `app/math_algebra9.json`, unit by unit, each one approved by a math teacher. Verify the validation test passes and the coverage test lists the skills still missing one.
8
 
9
  ## 2. Help in practice
10
 
11
+ - [x] 2.1 In `static/math.js`, offer "איך פותרים? דוגמה פתורה" from the second wrong answer in the round, and show the example inside the sheet with a return to the same question. Verify with a browser test: no offer after one mistake, offer after two, same question after closing.
12
+ - [x] 2.2 Add the "?" help panel in the skill sheet (description, example, teacher explanations for the skill). Verify in the browser test.
13
+ - [x] 2.3 Record `P.examples[id]` (count and last) and merge it across devices in `mergeProgress`. Add it to `ensure` in `static/math-sky.js`. Verify with a browser test that finishing and gold are unchanged by opening the example, and with a merge test.
14
 
15
  ## 3. Resource visibility
16
 
17
+ - [x] 3.1 Add migration `<ts>_math_resources_visible.sql` (column `visible` default false). Verify it applies on the test database.
18
+ - [x] 3.2 In `app/unit_resources.py`, accept `visible` on upload and add `PATCH /api/math/resources/{rid}` with the owner-or-manager rule. Verify in `tests/test_unit_resources.py`: defaults by kind, owner can toggle, another teacher gets 403.
19
+ - [x] 3.3 Add the student routes (list and image) limited to visible resources of the student's open units, answering 404 otherwise. Verify the tests: hidden resource 404, unit not open 404 or empty, no `object_key` in the response.
20
+ - [x] 3.4 In the UI, add the visibility checkbox to the upload form, a toggle and a "visible" mark on each resource for teachers, and the "הסברים מהמורה" lists for students in the skill sheet and unit. Verify with browser tests for both roles.
21
 
22
  ## 4. Report and docs
23
 
24
+ - [x] 4.1 Add `examples` to `report_part` and show on the student's card the skills where an example was opened in the last 14 days. Verify in `tests/test_class_report.py` and the report browser test.
25
+ - [x] 4.2 Update `static/guide.js` (student: the help and teacher explanations; teacher: visibility of resources, replacing "בשלב זה המשאבים גלויים למורים בלבד") and `README.md`. Verify `tests/test_guide_browser.py` passes.
26
 
27
  ## 5. Integration
28
 
29
+ - [x] 5.1 Run the full `pytest` suite and check locally, as a teacher and a student in the same class, that a theory photo uploaded by the teacher appears in the student's skill sheet.
openspec/changes/add-teacher-grade-review/tasks.md CHANGED
@@ -2,32 +2,32 @@
2
 
3
  ## 1. Storage and access rule
4
 
5
- - [ ] 1.1 Add migration `<ts>_math_teacher_reviews.sql`: `math_teacher_reviews`, `math_review_requests` (unique open request per attempt), and a service-only RPC that saves a review and closes the open request in one transaction. Verify it applies to the disposable test PostgreSQL and that a second open request on one attempt fails.
6
- - [ ] 1.2 Move the teacher→students rule (`watched`) out of `app/ai_queue.py` into a shared helper used by both modules. Verify `tests/test_ai_queue.py` still passes unchanged.
7
 
8
  ## 2. Teacher routes
9
 
10
- - [ ] 2.1 Add `app/teacher_review.py` with `GET /api/teacher/answers/{id}` and `/image`, checking the class-teacher rule. Verify in `tests/test_teacher_review.py`: class teacher allowed, other teacher 403, manager allowed, no image bytes on 403.
11
- - [ ] 2.2 Add `PUT /api/teacher/answers/{id}/review` with validation (score 0–100, comment ≤ 1000, not both empty). Verify the tests for correcting a score, grading an unreadable answer, the empty review and out-of-range values.
12
- - [ ] 2.3 Add `GET /api/teacher/review-requests`. Verify a request appears for its class teacher only and disappears after a review is saved.
13
 
14
  ## 3. Grades use the teacher score
15
 
16
- - [ ] 3.1 Add `effective_score` and use it in the unit-quiz grade function from `add-server-quiz-grades` and in the attempt payload. Verify with a test where a 25-point question's AI 40 / teacher 80 contributes 20 points.
17
- - [ ] 3.2 In `static/math.js`, complete an open skill when the teacher score is 100. Verify with a browser test.
18
 
19
  ## 4. Student side
20
 
21
- - [ ] 4.1 Include `teacher_review` and `review_request` in the student's attempt read, and set `seen_at` when opened. Verify with tests.
22
- - [ ] 4.2 Add `POST /api/math/answer-attempts/{id}/review-request` (one open per answer, at most 5 open per student) and `GET /api/math/teacher-comments`. Verify tests for the duplicate and limit cases.
23
- - [ ] 4.3 Show "הערת המורה" with the teacher score above the AI feedback on quiz results and open skill answers, the "לבקש מהמורה לבדוק שוב" button with its message field, and the home notice. Verify with browser tests in `tests/test_answer_browser.py`.
24
 
25
  ## 5. Teacher UI
26
 
27
- - [ ] 5.1 Add the teacher's answer view (photo, question, AI check, review form, "100 completes the skill" hint), opened from the report's unit-quiz rows and from handled AI-queue rows. Verify with a browser test that saves a review and sees it in the report.
28
- - [ ] 5.2 Add the "בקשות לבדיקה חוזרת" tab in the AI queue sheet with a count on the header button. Verify with a browser test.
29
- - [ ] 5.3 Update both tabs of `static/guide.js` (the student "בדיקה דרך המורה" section and the teacher "תור AI"/"דוח הכיתה" sections) and `README.md`. Verify `tests/test_guide_browser.py` passes.
30
 
31
  ## 6. Integration
32
 
33
- - [ ] 6.1 Run the full `pytest` suite and check end to end locally: student submits a quiz, the teacher sends it to the AI, the student disputes one answer, the teacher corrects it, and the student sees the new grade and comment.
 
2
 
3
  ## 1. Storage and access rule
4
 
5
+ - [x] 1.1 Add migration `<ts>_math_teacher_reviews.sql`: `math_teacher_reviews`, `math_review_requests` (unique open request per attempt), and a service-only RPC that saves a review and closes the open request in one transaction. Verify it applies to the disposable test PostgreSQL and that a second open request on one attempt fails.
6
+ - [x] 1.2 Move the teacher→students rule (`watched`) out of `app/ai_queue.py` into a shared helper used by both modules. Verify `tests/test_ai_queue.py` still passes unchanged.
7
 
8
  ## 2. Teacher routes
9
 
10
+ - [x] 2.1 Add `app/teacher_review.py` with `GET /api/teacher/answers/{id}` and `/image`, checking the class-teacher rule. Verify in `tests/test_teacher_review.py`: class teacher allowed, other teacher 403, manager allowed, no image bytes on 403.
11
+ - [x] 2.2 Add `PUT /api/teacher/answers/{id}/review` with validation (score 0–100, comment ≤ 1000, not both empty). Verify the tests for correcting a score, grading an unreadable answer, the empty review and out-of-range values.
12
+ - [x] 2.3 Add `GET /api/teacher/review-requests`. Verify a request appears for its class teacher only and disappears after a review is saved.
13
 
14
  ## 3. Grades use the teacher score
15
 
16
+ - [x] 3.1 Add `effective_score` and use it in the unit-quiz grade function from `add-server-quiz-grades` and in the attempt payload. Verify with a test where a 25-point question's AI 40 / teacher 80 contributes 20 points.
17
+ - [x] 3.2 In `static/math.js`, complete an open skill when the teacher score is 100. Verify with a browser test.
18
 
19
  ## 4. Student side
20
 
21
+ - [x] 4.1 Include `teacher_review` and `review_request` in the student's attempt read, and set `seen_at` when opened. Verify with tests.
22
+ - [x] 4.2 Add `POST /api/math/answer-attempts/{id}/review-request` (one open per answer, at most 5 open per student) and `GET /api/math/teacher-comments`. Verify tests for the duplicate and limit cases.
23
+ - [x] 4.3 Show "הערת המורה" with the teacher score above the AI feedback on quiz results and open skill answers, the "לבקש מהמורה לבדוק שוב" button with its message field, and the home notice. Verify with browser tests in `tests/test_answer_browser.py`.
24
 
25
  ## 5. Teacher UI
26
 
27
+ - [x] 5.1 Add the teacher's answer view (photo, question, AI check, review form, "100 completes the skill" hint), opened from the report's unit-quiz rows and from handled AI-queue rows. Verify with a browser test that saves a review and sees it in the report.
28
+ - [x] 5.2 Add the "בקשות לבדיקה חוזרת" tab in the AI queue sheet with a count on the header button. Verify with a browser test.
29
+ - [x] 5.3 Update both tabs of `static/guide.js` (the student "בדיקה דרך המורה" section and the teacher "תור AI"/"דוח הכיתה" sections) and `README.md`. Verify `tests/test_guide_browser.py` passes.
30
 
31
  ## 6. Integration
32
 
33
+ - [x] 6.1 Run the full `pytest` suite and check end to end locally: student submits a quiz, the teacher sends it to the AI, the student disputes one answer, the teacher corrects it, and the student sees the new grade and comment.
scripts/draft_examples.py ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Draft worked examples for the skills of a unit, for a math teacher to review, and merge the approved ones.
3
+
4
+ The path shows a student who is stuck a worked example (`example` in app/math_algebra9.json): a question of its own,
5
+ solved in steps (what to do, and why), and the answer. Students never call the AI, so the examples are written in
6
+ advance. This script helps write them:
7
+
8
+ draft asks Claude for one example per skill of a unit that has none, checks each against the same rules the app
9
+ loads the data with, and writes them to a REVIEW FILE. It never touches app/math_algebra9.json, and it
10
+ spends API money (one request per skill: use --dry-run to see what it would send).
11
+ merge puts the drafts a teacher approved into app/math_algebra9.json. A draft is approved when its entry in the
12
+ review file has "approved": true and the teacher's name in "reviewed_by". The teacher may edit an example in
13
+ the review file first: what is merged is what the file says, after the app's own checks.
14
+
15
+ python scripts/draft_examples.py draft --unit t --dry-run
16
+ python scripts/draft_examples.py draft --unit t # writes docs/example-drafts/t.json
17
+ (the teacher reads docs/example-drafts/t.json, fixes what is wrong, and marks the good ones approved)
18
+ python scripts/draft_examples.py merge docs/example-drafts/t.json
19
+
20
+ Needs ANTHROPIC_API_KEY (or CLAUDE_API_KEY) for `draft`; MATH_MODEL picks the model, as for the rest of the app.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import json
26
+ import os
27
+ import sys
28
+ from datetime import date
29
+ from pathlib import Path
30
+
31
+ ROOT = Path(__file__).resolve().parent.parent
32
+ sys.path.insert(0, str(ROOT))
33
+
34
+ from app.math_photo import PhotoUnavailable, failure_reason, math_model, photo_client # noqa: E402
35
+ from app.math_skills import GRAPH, algebra9, example_problems, problems, questions, skills_without_example # noqa: E402
36
+
37
+ DRAFTS_DIR = ROOT / "docs" / "example-drafts"
38
+ STATUS = "טיוטה: עוד לא נבדקה על ידי מורה. סמנו approved ושם ב־reviewed_by רק אחרי שבדקתם את הדוגמה."
39
+ EXAMPLE_SCHEMA = { # Anthropic rejects numerical constraints in output schemas (minItems...): app.math_skills checks them
40
+ "type": "object", "additionalProperties": False, "required": ["question", "steps", "answer"],
41
+ "properties": {
42
+ "question": {"type": "string"},
43
+ "steps": {"type": "array", "items": {
44
+ "type": "object", "additionalProperties": False, "required": ["do", "why"],
45
+ "properties": {"do": {"type": "string"}, "why": {"type": "string"}}}},
46
+ "answer": {"type": "string"}}}
47
+ SYSTEM = """אתם מורים למתמטיקה לכיתה ט׳ וכותבים דוגמה פתורה למיומנות אחת במסלול תרגול.
48
+ תלמיד שנתקע יפתח את הדוגמה, ילמד ממנה את השיטה ויחזור לשאלות התרגול של המיומנות.
49
+ כתבו שאלה חדשה משלכם באותה רמה ובאותו סגנון של שאלות התרגול. היא לא יכולה להיות אחת מהן, וגם לא אחת מהן עם שינוי קל של המספרים.
50
+ פתרו אותה בשלבים קצרים (בין שלושה לשישה). בכל שלב: do הוא מה עושים ומה יוצא, ו־why הוא הסיבה, הרעיון שמאחורי הצעד,
51
+ ולא חזרה על ה־do במילים אחרות. אם הטעויות הנפוצות שבהמשך רלוונטיות, הראו בשלב המתאים איך נמנעים מהן.
52
+ בסוף כתבו את התשובה בקצרה, ובדקו אותה: חשבו כל שלב פעם נוספת לפני שאתם כותבים אותו.
53
+ כתבו בעברית. נוסחאות בין סימני דולר, כמו בשאלות התרגול: $(x + 3)(x − 5)$. השתמשו במינוס ״−״ ובחזקה ״²״ כמו בהן.
54
+ החזירו רק את הדוגמה במבנה שנדרש."""
55
+
56
+
57
+ def skill_brief(skill: dict, mistakes: dict) -> dict:
58
+ """What the model sees of a skill: its description and practice questions with the right answer and the mistakes."""
59
+ out = []
60
+ for q in questions(skill):
61
+ right = next((c["text"] for c in q.get("choices", []) if c.get("correct")), "")
62
+ slips = [{"answer": c["text"], "mistake": mistakes[c["mistake"]]["name"], "tip": mistakes[c["mistake"]]["tip"]}
63
+ for c in q.get("choices", []) if c.get("mistake") in mistakes]
64
+ out.append({"question": q.get("question", ""), "right_answer": right, "common_mistakes": slips})
65
+ return {"skill": skill["name"], "description": skill.get("description", ""), "practice_questions": out}
66
+
67
+
68
+ def draft_one(client, model: str, skill: dict, mistakes: dict) -> dict:
69
+ """One example for a skill; raises PhotoUnavailable if the model could not give one."""
70
+ import anthropic
71
+ try:
72
+ response = client.messages.create(
73
+ model=model, max_tokens=8000, system=SYSTEM,
74
+ output_config={"effort": "high", "format": {"type": "json_schema", "schema": EXAMPLE_SCHEMA}},
75
+ messages=[{"role": "user", "content": json.dumps(skill_brief(skill, mistakes), ensure_ascii=False)}])
76
+ except anthropic.APIError as exc:
77
+ reason, detail = failure_reason(exc)
78
+ raise PhotoUnavailable(f"{reason}: {detail}", reason, detail) from exc
79
+ if response.stop_reason != "end_turn":
80
+ raise PhotoUnavailable(f"stop_reason={response.stop_reason}", "refused" if response.stop_reason == "refusal" else "incomplete")
81
+ try:
82
+ return json.loads(next(b.text for b in response.content if b.type == "text"))
83
+ except (ValueError, StopIteration) as exc:
84
+ raise PhotoUnavailable("the answer was not JSON", "bad_output") from exc
85
+
86
+
87
+ def draft_unit(client, model: str, graph: dict, unit_id: str, only: list[str] | None = None, limit: int | None = None) -> dict:
88
+ """The review file's content for a unit: a draft (and what is wrong with it, if anything) per skill without one."""
89
+ wanted = [s for s in graph["skills"] if s["unit"] == unit_id and s["id"] in skills_without_example(graph)
90
+ and (not only or s["id"] in only)]
91
+ drafts = {}
92
+ for skill in wanted[:limit]:
93
+ entry = {"approved": False, "reviewed_by": "", "example": None, "problems": []}
94
+ try:
95
+ entry["example"] = draft_one(client, model, skill, graph["mistakes"])
96
+ entry["problems"] = example_problems({**skill, "example": entry["example"]})
97
+ except PhotoUnavailable as exc:
98
+ entry["problems"] = [f"no draft: {exc}"]
99
+ drafts[skill["id"]] = entry
100
+ return {"status": STATUS, "model": model, "unit": unit_id, "drafted": date.today().isoformat(), "drafts": drafts}
101
+
102
+
103
+ def merge_approved(review: dict, graph: dict) -> tuple[dict, list[str], list[str]]:
104
+ """The graph with the approved drafts added, the ids merged, and why any others were left out."""
105
+ merged, skipped = [], []
106
+ skills = {s["id"]: s for s in graph["skills"]}
107
+ for sid, entry in review.get("drafts", {}).items():
108
+ if not entry.get("approved"):
109
+ skipped.append(f"{sid}: not approved")
110
+ elif not str(entry.get("reviewed_by") or "").strip():
111
+ skipped.append(f"{sid}: approved without the reviewer's name (reviewed_by)")
112
+ elif sid not in skills:
113
+ skipped.append(f"{sid}: no such skill")
114
+ elif "example" in skills[sid]:
115
+ skipped.append(f"{sid}: already has an example")
116
+ elif found := example_problems({**skills[sid], "example": entry.get("example")}):
117
+ skipped.append(f"{sid}: {'; '.join(found)}")
118
+ else:
119
+ skills[sid]["example"] = entry["example"]
120
+ merged.append(sid)
121
+ return graph, merged, skipped
122
+
123
+
124
+ def cmd_draft(args) -> int:
125
+ graph = algebra9()
126
+ if not any(u["id"] == args.unit for u in graph["units"]):
127
+ print(f"no unit {args.unit}: " + ", ".join(u["id"] for u in graph["units"]), file=sys.stderr)
128
+ return 2
129
+ todo = [s for s in graph["skills"] if s["unit"] == args.unit and s["id"] in skills_without_example(graph)
130
+ and (not args.skill or s["id"] in args.skill)][:args.limit]
131
+ print(f"unit {args.unit}: {len(todo)} skills without an example: {', '.join(s['id'] for s in todo) or '-'}")
132
+ if args.dry_run or not todo:
133
+ if todo:
134
+ print("\nsystem prompt:\n" + SYSTEM + "\n\nfirst request:\n" +
135
+ json.dumps(skill_brief(todo[0], graph["mistakes"]), ensure_ascii=False, indent=1))
136
+ print(f"\n(dry run: {len(todo)} requests would be sent to {math_model()})")
137
+ return 0
138
+ key = os.environ.get("ANTHROPIC_API_KEY", "").strip() or os.environ.get("CLAUDE_API_KEY", "").strip()
139
+ if not key:
140
+ print("set ANTHROPIC_API_KEY (or CLAUDE_API_KEY)", file=sys.stderr)
141
+ return 2
142
+ out = Path(args.out) if args.out else DRAFTS_DIR / f"{args.unit}.json"
143
+ if out.exists() and not args.force:
144
+ print(f"{out} exists: it may hold a teacher's review. Use --force to overwrite, or --out for another file.", file=sys.stderr)
145
+ return 2
146
+ review = draft_unit(photo_client(key), math_model(), graph, args.unit, args.skill, args.limit)
147
+ out.parent.mkdir(parents=True, exist_ok=True)
148
+ out.write_text(json.dumps(review, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
149
+ bad = [sid for sid, e in review["drafts"].items() if e["problems"]]
150
+ print(f"wrote {out}: {len(review['drafts'])} drafts, {len(bad)} with problems{': ' + ', '.join(bad) if bad else ''}")
151
+ return 0
152
+
153
+
154
+ def cmd_merge(args) -> int:
155
+ review = json.loads(Path(args.review).read_text(encoding="utf-8"))
156
+ graph = json.loads(GRAPH.read_text(encoding="utf-8"))
157
+ graph, merged, skipped = merge_approved(review, graph)
158
+ for line in skipped:
159
+ print("left out: " + line)
160
+ if not merged:
161
+ print("nothing to merge")
162
+ return 1
163
+ if found := problems(graph): # the app loads the file with this check: never write what it would refuse
164
+ print("refusing to write, the data would not load: " + "; ".join(found), file=sys.stderr)
165
+ return 2
166
+ GRAPH.write_text(json.dumps(graph, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
167
+ print(f"added examples to {GRAPH.name}: {', '.join(merged)}")
168
+ return 0
169
+
170
+
171
+ def main(argv=None) -> int:
172
+ parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
173
+ sub = parser.add_subparsers(dest="command", required=True)
174
+ d = sub.add_parser("draft", help="ask Claude for drafts and write a review file")
175
+ d.add_argument("--unit", required=True)
176
+ d.add_argument("--skill", action="append", help="only this skill (repeat for several)")
177
+ d.add_argument("--limit", type=int, help="at most this many skills")
178
+ d.add_argument("--out", help="the review file (default docs/example-drafts/<unit>.json)")
179
+ d.add_argument("--force", action="store_true", help="overwrite an existing review file")
180
+ d.add_argument("--dry-run", action="store_true", help="show what would be sent; call nothing")
181
+ d.set_defaults(run=cmd_draft)
182
+ m = sub.add_parser("merge", help="add the approved drafts of a review file to app/math_algebra9.json")
183
+ m.add_argument("review")
184
+ m.set_defaults(run=cmd_merge)
185
+ args = parser.parse_args(argv)
186
+ return args.run(args)
187
+
188
+
189
+ if __name__ == "__main__":
190
+ sys.exit(main())
static/answer-scans.js CHANGED
@@ -4,7 +4,7 @@
4
  const esc=s=>String(s??"").replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
5
  const fmt=s=>esc(s).replace(/\$([^$]+)\$/g,'<span class="m">$1</span>');
6
  const states={waiting:"ממתינים לטלפון",confirmed:"ממתינים לטלפון",uploading:"מעלים את התשובה…",received:"התשובה התקבלה",failed:"ההעלאה נכשלה",expired:"בקשת הצילום פגה",superseded:"הבקשה הוחלפה",declined:"הבקשה נדחתה בטלפון"};
7
- const errors={expired:"בקשת הצילום פגה. צרו בקשה חדשה ונסו שוב.",superseded:"הבקשה הוחלפה. בחרו את הבקשה העדכנית.",upload_in_progress:"העלאה כבר מתבצעת. ממתינים לעדכון…",confirmation_required:"יש לאשר את בקשת הצילום תחילה.",invalid_upload:"ההעלאה הופסקה. נסו שוב.",question_changed:"השאלה השתנתה. רעננו את העמוד ופתחו אותה מחדש.",invalid_image:"לא ניתן לקרוא את התמונה. צלמו שוב.",image_too_large:"התמונה גדולה מדי. צלמו שוב.",unsupported_image:"בחרו תמונת JPEG, PNG או WebP.",answer_storage_unavailable:"שמירת תשובות אינה זמינה כרגע. נסו שוב מאוחר יותר.",sign_in_required:"יש להיכנס לאותו חשבון בשני המכשירים."};
8
  Object.assign(errors,{grading_unavailable:"בדיקת תשובות אינה זמינה כרגע באתר.",grading_failed:"הבדיקה נכשלה. התמונה נשמרה; אפשר לנסות שוב.",grading_interrupted:"הבדיקה הופסקה. אפשר לנסות שוב.",grading_limit:"הגעתם למכסת 20 הבדיקות היומית. נסו שוב מחר.",grading_busy:"כבר מתבצעות בדיקות בחשבון. נסו שוב בעוד רגע.",answer_required:"יש לצלם ולשלוח תשובה לפני הבדיקה.",queue_unavailable:"שליחת תשובות לבדיקת המורה אינה זמינה כרגע.",request_taken:"המורה כבר טיפל בבקשה. רעננו את העמוד."});
9
  /* Why an AI check failed (the server's `reason`, app/math_photo.py: failure_reason), for the people who can fix it:
10
  a teacher sending from the AI queue, or checking their own answer. Each is what happened and what to do. */
@@ -254,11 +254,11 @@ async function renderQuestion(q){
254
  // while the phone has the request, its code and progress are in the phone box itself (phoneLive)
255
  const statusText=checking?(q.grading&&queued?"שולחים למורה…":"בודקים את התשובה…"):waiting?"התשובה מחכה למורה":g?.state==="completed"?verdicts[g.result.verdict]:g?.state==="failed"?errors[g.error]||errors.grading_failed:!received&&phonePending(state)?"":states[state]||"כתבו את הפתרון על דף וצלמו אותו בטלפון.";
256
  // Rebuilding controls on every poll would steal focus and race a button click.
257
- const signature=[state,s?.id,r.attachment?.id,g?.id,g?.state,r.grading_available,req?.id,req?.state].join(":");
258
  if(q.signature!==signature){
259
  q.signature=signature;
260
  q.node.innerHTML=`<small>ניסיון ${esc(r.attempt.id.slice(0,8))}</small><p role="status" aria-live="polite">${esc(statusText)}</p>
261
- ${received?`<img class="mx-answer-image" alt="התשובה המצולמת שלכם" hidden>${gradingPanel(r,checking)}
262
  <button class="mx-btn" data-new-attempt ${checking?"disabled":""}>ניסיון חדש וצילום מחדש</button>`:phoneLive(s,`data-request`,`data-cancel-scan`,!s?"צילום התשובה בטלפון":"בקשת צילום חדשה לטלפון")}`;
263
  if(!received){
264
  const say=t=>{const el=q.node.querySelector("[role=status]");if(el)el.textContent=t;};
@@ -283,9 +283,13 @@ async function renderQuestion(q){
283
  const gradeButton=q.node.querySelector("[data-grade]");
284
  if(gradeButton)gradeButton.onclick=()=>checkAnswer(q);
285
  q.node.querySelector("[data-new-attempt]").onclick=()=>mountQuestion(q.node,q.id,q.version,true);
 
 
286
  }
287
  }else q.node.querySelector("[role=status]").textContent=statusText;
288
  if(g?.state==="completed"&&q.notified!==g.id){q.notified=g.id;onGraded?.({questionId:q.id,grading:g});}
 
 
289
  if(received&&!q.image&&!q.loadingImage){
290
  q.loadingImage=true;
291
  try{
@@ -304,12 +308,38 @@ async function renderQuestion(q){
304
  q.autoStarted=true;checkAnswer(q,true);
305
  }
306
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
307
  function gradingPanel(r,checking){
308
  const g=r.grading;
309
  if(g?.state==="completed"){
310
  const a=g.result;
311
  return `<section class="mx-grade ${a.verdict==="correct"?"mx-grade-correct":""}" aria-label="תוצאות בדיקת התשובה">
312
- <h4>${esc(verdicts[a.verdict])}${a.score==null?"":` · <span class="m">${esc(a.score)}/100</span>`}</h4>
313
  <p>${fmt(a.feedback)}</p>${a.next_step?`<p><b>הצעד הבא: </b>${fmt(a.next_step)}</p>`:""}
314
  ${a.transcription?`<details><summary>מה קראנו בתמונה</summary><p>${fmt(a.transcription)}</p></details>`:""}
315
  ${a.solution?`<details><summary>הפתרון המלא</summary><p class="mx-grade-solution">${fmt(a.solution)}</p></details>`:""}
@@ -490,5 +520,5 @@ document.addEventListener("visibilitychange",()=>{
490
  if(question?.result)pollQuestion(question);
491
  });
492
  window.addEventListener("online",()=>{if(account&&inbox)pollInbox(epoch);if(question?.result)pollQuestion(question);});
493
- window.MathlyAnswers={errorDetails,configure,mountQuestion,closeQuestion,reset,phoneLive,forgetPush,pushButton,paintPush,togglePush};
494
  })();
 
4
  const esc=s=>String(s??"").replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
5
  const fmt=s=>esc(s).replace(/\$([^$]+)\$/g,'<span class="m">$1</span>');
6
  const states={waiting:"ממתינים לטלפון",confirmed:"ממתינים לטלפון",uploading:"מעלים את התשובה…",received:"התשובה התקבלה",failed:"ההעלאה נכשלה",expired:"בקשת הצילום פגה",superseded:"הבקשה הוחלפה",declined:"הבקשה נדחתה בטלפון"};
7
+ const errors={expired:"בקשת הצילום פגה. צרו בקשה חדשה ונסו שוב.",superseded:"הבקשה הוחלפה. בחרו את הבקשה העדכנית.",upload_in_progress:"העלאה כבר מתבצעת. ממתינים לעדכון…",confirmation_required:"יש לאשר את בקשת הצילום תחילה.",invalid_upload:"ההעלאה הופסקה. נסו שוב.",question_changed:"השאלה השתנתה. רעננו את העמוד ופתחו אותה מחדש.",invalid_image:"לא ניתן לקרוא את התמונה. צלמו שוב.",image_too_large:"התמונה גדולה מדי. צלמו שוב.",unsupported_image:"בחרו תמונת JPEG, PNG או WebP.",answer_storage_unavailable:"שמירת תשובות אינה זמינה כרגע. נסו שוב מאוחר יותר.",sign_in_required:"יש להיכנס לאותו חשבון בשני המכשירים.",review_limit:"יש כבר 5 בקשות פתוחות למורה. אפשר לבקש שוב אחרי שהמורה יענה.",message_too_long:"ההודעה ארוכה מדי (עד 300 תווים).",invalid_message:"ההודעה אינה תקינה.",answer_required:"צריך לצלם את התשובה קודם.",review_unavailable:"הבקשה למורה אינה זמינה כרגע. נסו שוב מאוחר יותר."};
8
  Object.assign(errors,{grading_unavailable:"בדיקת תשובות אינה זמינה כרגע באתר.",grading_failed:"הבדיקה נכשלה. התמונה נשמרה; אפשר לנסות שוב.",grading_interrupted:"הבדיקה הופסקה. אפשר לנסות שוב.",grading_limit:"הגעתם למכסת 20 הבדיקות היומית. נסו שוב מחר.",grading_busy:"כבר מתבצעות בדיקות בחשבון. נסו שוב בעוד רגע.",answer_required:"יש לצלם ולשלוח תשובה לפני הבדיקה.",queue_unavailable:"שליחת תשובות לבדיקת המורה אינה זמינה כרגע.",request_taken:"המורה כבר טיפל בבקשה. רעננו את העמוד."});
9
  /* Why an AI check failed (the server's `reason`, app/math_photo.py: failure_reason), for the people who can fix it:
10
  a teacher sending from the AI queue, or checking their own answer. Each is what happened and what to do. */
 
254
  // while the phone has the request, its code and progress are in the phone box itself (phoneLive)
255
  const statusText=checking?(q.grading&&queued?"שולחים למורה…":"בודקים את התשובה…"):waiting?"התשובה מחכה למורה":g?.state==="completed"?verdicts[g.result.verdict]:g?.state==="failed"?errors[g.error]||errors.grading_failed:!received&&phonePending(state)?"":states[state]||"כתבו את הפתרון על דף וצלמו אותו בטלפון.";
256
  // Rebuilding controls on every poll would steal focus and race a button click.
257
+ const signature=[state,s?.id,r.attachment?.id,g?.id,g?.state,r.grading_available,req?.id,req?.state,r.teacher_review?.created_at,r.review_request?.id,r.review_request?.state].join(":");
258
  if(q.signature!==signature){
259
  q.signature=signature;
260
  q.node.innerHTML=`<small>ניסיון ${esc(r.attempt.id.slice(0,8))}</small><p role="status" aria-live="polite">${esc(statusText)}</p>
261
+ ${received?`<img class="mx-answer-image" alt="התשובה המצולמת שלכם" hidden>${teacherNote(r)}${gradingPanel(r,checking)}${reviewBox(r.attempt.id,r)}
262
  <button class="mx-btn" data-new-attempt ${checking?"disabled":""}>ניסיון חדש וצילום מחדש</button>`:phoneLive(s,`data-request`,`data-cancel-scan`,!s?"צילום התשובה בטלפון":"בקשת צילום חדשה לטלפון")}`;
263
  if(!received){
264
  const say=t=>{const el=q.node.querySelector("[role=status]");if(el)el.textContent=t;};
 
283
  const gradeButton=q.node.querySelector("[data-grade]");
284
  if(gradeButton)gradeButton.onclick=()=>checkAnswer(q);
285
  q.node.querySelector("[data-new-attempt]").onclick=()=>mountQuestion(q.node,q.id,q.version,true);
286
+ const ask=q.node.querySelector("[data-ask-review]");
287
+ if(ask)ask.onclick=async()=>{const sent=await askReview(ask);if(sent&&current(q)){q.result={...q.result,review_request:sent};q.signature=[q.signature,"asked"].join(":");}};
288
  }
289
  }else q.node.querySelector("[role=status]").textContent=statusText;
290
  if(g?.state==="completed"&&q.notified!==g.id){q.notified=g.id;onGraded?.({questionId:q.id,grading:g});}
291
+ const t=r.teacher_review; // the teacher's 100 counts like a correct check
292
+ if(t?.score===100&&q.notifiedTeacher!==t.created_at){q.notifiedTeacher=t.created_at;onGraded?.({questionId:q.id,grading:{id:"teacher:"+t.created_at,finished_at:t.created_at,result:{verdict:"correct",score:100}}});}
293
  if(received&&!q.image&&!q.loadingImage){
294
  q.loadingImage=true;
295
  try{
 
308
  q.autoStarted=true;checkAnswer(q,true);
309
  }
310
  }
311
+ /* The teacher's review of the answer (its own score and comment), shown above the automatic check, and the student's
312
+ request to look again. Used by the answer panel here and by the unit quiz's result in math.js. */
313
+ function teacherNote(r){
314
+ const t=r.teacher_review; if(!t)return "";
315
+ return `<section class="mx-teacher-note" aria-label="הערת המורה"><h4>הערת המורה${t.score==null?"":` · <span class="m">${esc(t.score)}/100</span>`}</h4>${t.comment?`<p>${esc(t.comment).replace(/\n/g,"<br>")}</p>`:""}</section>`;
316
+ }
317
+ function reviewBox(attemptId,r){
318
+ if(r.review_request?.state==="open")return '<p class="mx-small mx-ask-wait">ביקשתם מהמורה לבדוק שוב. הבקשה ממתינה.</p>';
319
+ if(r.grading?.state!=="completed")return ""; // only a checked answer can be questioned
320
+ return `<details class="mx-ask"><summary>לבקש מהמורה לבדוק שוב</summary>
321
+ <textarea maxlength="300" rows="2" data-ask-msg aria-label="הודעה למורה (לא חובה)" placeholder="מה לא נראה לכם בבדיקה? (לא חובה)"></textarea>
322
+ <button type="button" class="mx-btn" data-ask-review="${esc(attemptId)}">שליחה למורה</button></details>`;
323
+ }
324
+ // sends the request in the box the button is in; the box then says it waits
325
+ async function askReview(button){
326
+ const box=button.closest(".mx-ask"),message=box.querySelector("[data-ask-msg]").value;
327
+ button.disabled=true;box.querySelector(".mx-ask-error")?.remove();
328
+ try{
329
+ const sent=await post(`/api/math/answer-attempts/${button.dataset.askReview}/review-request`,{message});
330
+ box.outerHTML='<p class="mx-small mx-ask-wait" role="status">ביקשתם מהמורה לבדוק שוב. הבקשה ממתינה.</p>';
331
+ return sent.review_request;
332
+ }catch(error){
333
+ button.disabled=false;box.insertAdjacentHTML("beforeend",`<p class="mx-small mx-ask-error" role="alert">${esc(error.message)}</p>`);
334
+ return null;
335
+ }
336
+ }
337
  function gradingPanel(r,checking){
338
  const g=r.grading;
339
  if(g?.state==="completed"){
340
  const a=g.result;
341
  return `<section class="mx-grade ${a.verdict==="correct"?"mx-grade-correct":""}" aria-label="תוצאות בדיקת התשובה">
342
+ <h4>${r.teacher_review?"בדיקה אוטומטית: ":""}${esc(verdicts[a.verdict])}${a.score==null?"":` · <span class="m">${esc(a.score)}/100</span>`}</h4>
343
  <p>${fmt(a.feedback)}</p>${a.next_step?`<p><b>הצעד הבא: </b>${fmt(a.next_step)}</p>`:""}
344
  ${a.transcription?`<details><summary>מה קראנו בתמונה</summary><p>${fmt(a.transcription)}</p></details>`:""}
345
  ${a.solution?`<details><summary>הפתרון המלא</summary><p class="mx-grade-solution">${fmt(a.solution)}</p></details>`:""}
 
520
  if(question?.result)pollQuestion(question);
521
  });
522
  window.addEventListener("online",()=>{if(account&&inbox)pollInbox(epoch);if(question?.result)pollQuestion(question);});
523
+ window.MathlyAnswers={errorDetails,teacherNote,reviewBox,askReview,configure,mountQuestion,closeQuestion,reset,phoneLive,forgetPush,pushButton,paintPush,togglePush};
524
  })();
static/guide.js CHANGED
@@ -11,6 +11,17 @@ const STUDENT=[
11
  <li>הכוכב נדלק כשעונים נכון על כל השאלות, והופך ל<b>זהב</b> אחרי כמה תשובות נכונות ברצף בניסיון הראשון.</li>
12
  <li>כוכב שלא תרגלו שבועיים <b>דועך</b>. תשובה נכונה אחת מחזירה לו את האור.</li>
13
  <li>תשובה שגויה לא מגלה את התשובה הנכונה. השאלה תחזור אחרי האחרות, ואפשר לנסות שוב.</li></ul>`],
 
 
 
 
 
 
 
 
 
 
 
14
  ["🔥","היעד היומי והרצף",`<p>היעד היומי הוא 5 תשובות נכונות. מי שמגיע אליו כל יום בונה <b>רצף</b>. לוחצים על הלהבה כדי לראות את לוח הרצף של החודש.</p>`],
15
  ["🔁","חזרה מעורבת ודפוסים שחוזרים",`<p><b>חזרה מעורבת</b> מופיעה בבית כשמיומנויות שכבר סיימתם צריכות חזרה: כמה שאלות מנושאים שונים, כדי שמה שלמדתם יישאר.</p>
16
  <p><b>דפוסים שחוזרים</b> מראה טעות שחוזרת אצלכם בכמה נושאים, ואיזו מיומנות כדאי לחזק כדי שהיא תיעלם.</p>`],
@@ -25,8 +36,9 @@ const STUDENT=[
25
  <li>אם המורה לא אישר, מופיעה ההערה שלו. אפשר לתקן ולשלוח שוב (<b>שליחה מחדש למורה</b>).</li>
26
  <li>בקשה לעזרה בתרגיל מצולם אפשר לבטל ב<b>ביטול הבקשה</b>.</li>
27
  <li>אפשר לחכות לכל היותר ל־5 בקשות בבת אחת.</li>
28
- <li>הבדיקה האוטומטית יכולה לטעות: בדקו שהתמלול שמוצג תואם למה שכתבתם, ואם לא, הראו למורה.</li></ul>`],
29
- ["✏️","לוח טיוטה",`<p>הכפתור <b>לוח</b> בכל שאלה פותח דף לכתיבה: עט, קו ישר, עיגול, נוסחה מוקלדת ומחק. בטלפון הלוח נפתח במסך מלא, ו<b>חזרה לשאלה</b> מחזיר. גוללים עם שתי אצבעות או עם כלי ההזזה (כף היד). כפתור הלוח עם הדף (<b>השאלה</b>) מעתיק את השאלה לראש הדף.</p>`],
 
30
  ["🔔","התראות בטלפון",`<p>כשהמחשב מבקש צילום של תשובה, הטלפון יכול לקבל התראה גם כשהאפליקציה סגורה. מפעילים אותה בכפתור הפעמון. באייפון זה עובד רק מהאפליקציה שהוספה למסך הבית.</p>`],
31
  ["☄️","האוסף ושביטים",`<p>בלשונית <b>האוסף</b> רואים את כל הכוכבים וקבוצות הכוכבים שאספתם. לפעמים חולף על המסך שביט בזמן תרגול: מי שלוחץ עליו בזמן תופס אותו.</p>`],
32
  ["🚀","מדעים: חידות בהצבעה",`<p>המורה פותח הצבעה ומציג על הלוח <b>קוד בן 4 ספרות</b>. נכנסים לחידה, מקלידים את הקוד ב<b>הצטרפות להצבעה</b> ומצביעים. אפשר לשנות הצבעה עד שהמורה בודק.</p>`],
@@ -38,23 +50,36 @@ const TEACHER=[
38
  <li><b>שליחה ל-AI</b> מעביר את הבקשה לבדיקה, והתלמיד רואה את הציון והמשוב בעמוד שלו.</li>
39
  <li><b>דחייה</b> סוגרת את הבקשה, ואפשר לצרף הערה לתלמיד (למשל "התמונה מטושטשת, צלמו שוב").</li>
40
  <li>כשממתינות כמה בקשות, <b>שליחת כל הממתינות</b> שולח את כולן יחד. שליחה שנכשלה אפשר לנסות שוב.</li>
 
 
41
  <li>בקשות שטופלו נשארות ברשימה יממה. <b>רענון</b> טוען בקשות חדשות.</li></ul>
42
  <p>מורים ומנהלים שולחים את התשובות שלהם ישר ל-AI, בלי תור. מנהל רואה את הבקשות של כל הכיתות.</p>`],
43
- ["📊","דוח הכיתה",`<p><b>הכיתה שלי</b> פותח דוח על כל תלמיד:</p>
 
 
 
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>
52
  <li>רק מי שהוסיף תמונה (או מנהל) יכול למחוק אותה.</li></ul>
53
- <p>בשלב זה המשאבים גלויים למורים בלבד.</p>`],
54
  ["🚀","הצבעה כיתתית במדעים",`<p>במקצוע <b>מדעים</b> בוחרים חידה ולוחצים <b>פתיחת הצבעה לכיתה</b>. על המסך מופיע קוד בן 4 ספרות: מציגים אותו על הלוח והתלמידים מצטרפים איתו מהמכשירים שלהם.</p>
55
  <ul><li>ההצבעות מוסתרות עד שלוחצים <b>בדיקה</b>. הטיל משוגר עם התשובה שרוב הכיתה בחרה.</li>
56
  <li>בתיקו הטיל לא משוגר: התלמידים יכולים לשנות הצבעה, ובודקים שוב.</li>
57
  <li><b>לחידה הבאה</b> ממשיך באותה הצבעה.</li></ul>`],
 
 
 
 
 
 
58
  ["🗓️","מבחנים ותרגול לקראתם",`<p>לוח המבחנים של הכיתה מופיע לתלמידים במסך המקצועות ובמסלול. כשמבחן מתקרב, <b>מבחן התרגול</b> מכוון אליו ומציע את היחידות שנלמדו עד אז. כדאי להזכיר לתלמידים לעשות אותו בשבוע שלפני המבחן.</p>`],
59
  ["⚙️","ניהול (למנהלים)",`<p>הכפתור <b>ניהול</b> מופיע למי שיש לו הרשאה. שם יוצרים כיתות, מוסיפים תלמידים לפי כתובת דוא״ל (שורה לכל תלמיד), בוחרים יחידות ומקצועות, מגדירים לוח מבחנים ובוחרים את מורה הכיתה. כל כיתה פותחת לתלמידים שלה רק את היחידות שנבחרו לה. בלשונית המעקב רואים פעילות ושימוש.</p>`],
60
  ];
 
11
  <li>הכוכב נדלק כשעונים נכון על כל השאלות, והופך ל<b>זהב</b> אחרי כמה תשובות נכונות ברצף בניסיון הראשון.</li>
12
  <li>כוכב שלא תרגלו שבועיים <b>דועך</b>. תשובה נכונה אחת מחזירה לו את האור.</li>
13
  <li>תשובה שגויה לא מגלה את התשובה הנכונה. השאלה תחזור אחרי האחרות, ואפשר לנסות שוב.</li></ul>`],
14
+ ["🆘","כשנתקעים: עזרה ודוגמה פתורה",`<p>הכפתור <b>?</b> בראש כל מיומנות פותח חלון עזרה מעל השאלה: ההסבר של המיומנות, <b>דוגמה פתורה</b> והסברים שהמורה שמר לה. הדוגמה היא שאלה אחרת מהשאלות שלכם, פתורה צעד אחרי צעד, עם הסבר למה עושים כל צעד. מחזירים <b>עוד צעד</b> בכל לחיצה, והתשובה מגיעה בסוף.</p>
15
+ <ul><li>אחרי <b>הטעות השנייה</b> בסבב של מיומנות מופיע גם הכפתור <b>איך פותרים? דוגמה פתורה</b>.</li>
16
+ <li>סוגרים את החלון וחוזרים לאותה שאלה בדיוק. הדוגמה לא מגלה את התשובה לשאלה שלכם.</li>
17
+ <li>פתיחת הדוגמה לא משנה את התנאים להשלמת המיומנות או לכוכב זהב: צריך עדיין תשובות נכונות ברצף בניסיון הראשון. המורה רואה אצלכם באילו מיומנויות פתחתם דוגמה, כדי לדעת איפה לעזור.</li>
18
+ <li>כשהמורה שמר תמונות של הסברים לשיעור, הן מופיעות תחת <b>הסברים מהמורה</b> במיומנות, בחלון העזרה ומתחת ליחידה במסלול.</li></ul>`],
19
+ ["📌","משימות מהמורה",`<p>המורה יכול לתת לכיתה <b>משימה</b>: מיומנויות לסיים, בוחן מסכם להגיש או מבחן תרגול, עד תאריך. בבית מופיע הכרטיס <b>משימות מהמורה</b> עם הכותרת, ההערה, התאריך, כמה פריטים הושלמו ורשימת הפריטים.</p>
20
+ <ul><li>הכפתור <b>להתחיל</b> או <b>להמשיך</b> פותח את הפריט הראשון שעוד לא הושלם: המיומנות, הבוחן המסכם או מבחן התרגול (עם היחידות שהמורה ביקש כבר מסומנות).</li>
21
+ <li>מיומנות נחשבת גם אם סיימתם אותה לפני שהמשימה נוצרה. בוחן מסכם ומבחן תרגול נחשבים רק אם הוגשו אחרי שהמשימה נוצרה, ומבחן התרגול צריך לכלול את כל היחידות שהמורה ביקש.</li>
22
+ <li>אחרי התאריך משימה שלא הושלמה מסומנת <b>באיחור</b>. עד סוף יום התאריך (לפי שעון ישראל) עדיין בזמן.</li>
23
+ <li>משימה שהושלמה עוברת לרשימה <b>הושלמו</b> ונשארת בה שבועיים אחרי התאריך.</li>
24
+ <li>מיומנויות שבמשימה פתוחה מסומנות במסלול בסיכה 📌.</li></ul>`],
25
  ["🔥","היעד היומי והרצף",`<p>היעד היומי הוא 5 תשובות נכונות. מי שמגיע אליו כל יום בונה <b>רצף</b>. לוחצים על הלהבה כדי לראות את לוח הרצף של החודש.</p>`],
26
  ["🔁","חזרה מעורבת ודפוסים שחוזרים",`<p><b>חזרה מעורבת</b> מופיעה בבית כשמיומנויות שכבר סיימתם צריכות חזרה: כמה שאלות מנושאים שונים, כדי שמה שלמדתם יישאר.</p>
27
  <p><b>דפוסים שחוזרים</b> מראה טעות שחוזרת אצלכם בכמה נושאים, ואיזו מיומנות כדאי לחזק כדי שהיא תיעלם.</p>`],
 
36
  <li>אם המורה לא אישר, מופיעה ההערה שלו. אפשר לתקן ולשלוח שוב (<b>שליחה מחדש למורה</b>).</li>
37
  <li>בקשה לעזרה בתרגיל מצולם אפשר לבטל ב<b>ביטול הבקשה</b>.</li>
38
  <li>אפשר לחכות לכל היותר ל־5 בקשות בבת אחת.</li>
39
+ <li>הבדיקה האוטומטית יכולה לטעות: בדקו שהתמלול שמוצג תואם למה שכתבתם. אם לא מסכימים עם הבדיקה, לוחצים על <b>לבקש מהמורה לבדוק שוב</b> בתשובה שנבדקה, ואפשר לכתוב למורה מה לא נראה נכון. אפשר לחכות לעד 5 תשובות בבת אחת.</li>
40
+ <li>המורה יכול לתת לתשובה <b>ציון משלו והערה</b>. הם מופיעים מעל הבדיקה האוטומטית, והציון של המורה הוא שנחשב בציון הבוחן. הציון האוטומטי נשאר מוצג לידו. כשהמורה הגיב, בבית מופיעה ההודעה <b>המורה הגיב על תשובה שלכם</b>.</li></ul>`],
41
+ ["✏️","לוח טיוטה",`<p>הכפתור <b>לוח</b> בכל שאלה פותח דף לכתיבה: עט, קו ישר, עיגול, זווית, נוסחה מוקלדת ומחק. עם כלי הזווית מצביעים על פינה שבה שני קווים נפגשים, לוחצים, וכותבים מה שבזווית: מספר, אות כמו α, β או γ (בכפתורים שליד, או כותבים alpha, beta, gamma), או ביטוי כמו β+90. מספר מקבל סימן מעלות. בכפתורי הקשתות מסמנים זווית בקשת כפולה או משולשת, כדי להראות זוויות שוות, או זווית ישרה בריבוע קטן. בטלפון הלוח נפתח במסך מלא, ו<b>חזרה לשאלה</b> מחזיר. גוללים עם שתי אצבעות או עם כלי ההזזה (כף היד). כפתור הלוח עם הדף (<b>השאלה</b>) מעתיק את השאלה לראש הדף.</p>`],
42
  ["🔔","התראות בטלפון",`<p>כשהמחשב מבקש צילום של תשובה, הטלפון יכול לקבל התראה גם כשהאפליקציה סגורה. מפעילים אותה בכפתור הפעמון. באייפון זה עובד רק מהאפליקציה שהוספה למסך הבית.</p>`],
43
  ["☄️","האוסף ושביטים",`<p>בלשונית <b>האוסף</b> רואים את כל הכוכבים וקבוצות הכוכבים שאספתם. לפעמים חולף על המסך שביט בזמן תרגול: מי שלוחץ עליו בזמן תופס אותו.</p>`],
44
  ["🚀","מדעים: חידות בהצבעה",`<p>המורה פותח הצבעה ומציג על הלוח <b>קוד בן 4 ספרות</b>. נכנסים לחידה, מקלידים את הקוד ב<b>הצטרפות להצבעה</b> ומצביעים. אפשר לשנות הצבעה עד שהמורה בודק.</p>`],
 
50
  <li><b>שליחה ל-AI</b> מעביר את הבקשה לבדיקה, והתלמיד רואה את הציון והמשוב בעמוד שלו.</li>
51
  <li><b>דחייה</b> סוגרת את הבקשה, ואפשר לצרף הערה לתלמיד (למשל "התמונה מטושטשת, צלמו שוב").</li>
52
  <li>כשממתינות כמה בקשות, <b>שליחת כל הממתינות</b> שולח את כולן יחד. שליחה שנכשלה אפשר לנסות שוב.</li>
53
+ <li>בקשה שטופלה ושהיא בדיקת תשובה אפשר לפתוח שוב עם <b>פתיחת התשובה</b>, כדי לראות את התמונה והבדיקה ולתת ציון והערה.</li>
54
+ <li>בלשונית <b>בקשות לבדיקה חוזרת</b> מופיעים תלמידים שלא הסכימו עם בדיקה וביקשו שתסתכלו שוב, עם ההודעה שלהם. המספר על הכפתור כולל אותן.</li>
55
  <li>בקשות שטופלו נשארות ברשימה יממה. <b>רענון</b> טוען בקשות חדשות.</li></ul>
56
  <p>מורים ומנהלים שולחים את התשובות שלהם ישר ל-AI, בלי תור. מנהל רואה את הבקשות של כל הכיתות.</p>`],
57
+ ["📊","דוח הכיתה",`<p><b>הכיתה שלי</b> פותח את הדוח. בראשו מופיע <b>דורשים תשומת לב</b>: התלמידים שלא תרגלו 7 ימים או יותר, שנתקעו במיומנות (3 טעויות או יותר בשבועיים בלי לסיים אותה), שהתרגול שלהם ירד לפחות מחצי משבוע שעבר, שקיבלו ציון נמוך מ־55 בבוחן מסכם, או שעוד לא התחילו. ליד כל שם כתוב למה. לחיצה על שם פותחת את כרטיס התלמיד. התרגול של השבוע נספר מיום ראשון.</p>
58
+ <p>הכפתורים <b>רשימה</b> ו<b>טבלת מיומנויות</b> מחליפים תצוגה. בטבלה שורה לכל תלמיד ועמודה לכל מיומנות של יחידה שבוחרים (או לכל יחידה, ב"כל היחידות"): זהב, הושלמה, דועכת, מתרגל ועוד לא סיים, או לא התחיל. מתחת לכל עמודה כתוב כמה סיימו, וסימן ⚠ מציין מיומנות שלפחות שליש מהתלמידים מתרגלים ועוד לא סיימו, כדי לדעת מה לחזור עליו בכיתה. <b>מיון</b> מסדר את התלמידים לפי תשומת לב, שם, התקדמות או פעילות אחרונה, והבחירה נשמרת במכשיר. תלמידים מופיעים בשם שנקבע להם בכיתה, אחרת בשם חשבון Google, אחרת בתחילת כתובת הדוא״ל.</p>
59
+ <p><b>לתת ציון והערה לתשובה:</b> בכרטיס התלמיד, בטבלת הבוחן המסכם, <b>פתיחה</b> מציגה את תשובות הבוחן האחרון. בוחרים תשובה ורואים את השאלה, את התמונה ואת הבדיקה האוטומטית, וכותבים <b>ציון</b> (0 עד 100), <b>הערה</b> לתלמיד, או את שניהם. הציון שלכם מחליף את הציון האוטומטי בחישוב ציון הבוחן, ובתשובה פתוחה במסלול ציון 100 מסמן את המיומנות כהושלמה. אפשר לתת ציון גם לתשובה שה-AI לא הצליח לקרוא. הבדיקה האוטומטית עצמה לא משתנה ונשארת מוצגת לתלמיד. שמירה חדשה מחליפה את הקודמת. תלמיד שביקש שתסתכלו שוב מסומן בכרטיס, ושמירת בדיקה סוגרת את הבקשה.</p>
60
+ <p>בכרטיס של כל תלמיד:</p>
61
  <ul><li>המיומנויות שסיים ביחידות של הכיתה.</li>
62
  <li>הטעויות שחוזרות אצלו.</li>
63
+ <li>הציון ב<b>בוחן המסכם</b> של כל יחידה, ותוצאות <b>מבחני התרגול</b>.</li>
64
+ <li>כמה תשובות נכונות היו השבוע וכמה בשבוע שעבר.</li>
65
+ <li>באילו מיומנויות פתח דוגמה פתורה בשבועיים האחרונים: סימן שהוא נתקע שם.</li></ul>
66
  <p>בראש הדוח מופיעות הטעויות הנפוצות בכל הכיתה, כדי לדעת מה כדאי לחזור עליו בשיעור. הנתונים של תלמיד מופיעים אחרי שנכנס לחשבון ותרגל.</p>
67
  <p><b>אילו נתונים אפשר לסמוך עליהם:</b> ציוני הבוחן המסכם מחושבים בשרת מהבדיקות השמורות, והתלמיד לא יכול לשנות אותם. כשעוד לא כל התשובות נבדקו כתוב <b>ממתין לבדיקה</b> ואין ציון. הגשה מלפני העדכון, שהשרת לא שמר, מסומנת <b>לא מאומת</b> וללא ציון. התקדמות במיומנויות ומבחני התרגול מדווחים מהמכשיר של התלמיד, ומסומנים <b>נתוני תרגול מהמכשיר</b>.</p>`],
68
  ["📷","משאבי שיעור",`<p>במסלול המתמטיקה, לוחצים על שם יחידה (או על <b>📷 משאבים</b> מתחתיה) כדי לפתוח את המשאבים של השיעור.</p>
69
  <ul><li><b>📷 צילום תמונה</b>: מצלמים תרגיל או הסבר מהלוח או מהספר.</li>
70
  <li>מסמנים את הסוג: <b>תרגיל חדש</b>, <b>הסבר תיאורטי</b> או <b>אחר</b>, ואפשר לכתוב לאיזה נושא הוא שייך.</li>
71
  <li>רק מי שהוסיף תמונה (או מנהל) יכול למחוק אותה.</li></ul>
72
+ <p><b>מה התלמידים רואים:</b> לכל תמונה יש תיבה <b>גלוי לתלמידים</b>. הסבר תיאורטי מסומן כגלוי כברירת מחדל, ותרגיל חדש או תמונה אחרת נשארים מוסתרים עד שתסמנו. אפשר לשנות בכל רגע עם <b>הצגה לתלמידים</b> או <b>הסתרה מהתלמידים</b> (רק מי שהוסיף את התמונה, או מנהל). תמונות שנוספו לפני העדכון מוסתרות. תלמידי הכיתה רואים את התמונות הגלויות של היחידות שנפתחו להם: תמונה שקשורה לנושא מופיעה במיומנות ובחלון העזרה שלה, והשאר ברשימה מתחת ליחידה. תרגיל שיהיה במבחן עדיף להשאיר מוסתר.</p>`],
73
  ["🚀","הצבעה כיתתית במדעים",`<p>במקצוע <b>מדעים</b> בוחרים חידה ולוחצים <b>פתיחת הצבעה לכיתה</b>. על המסך מופיע קוד בן 4 ספרות: מציגים אותו על הלוח והתלמידים מצטרפים איתו מהמכשירים שלהם.</p>
74
  <ul><li>ההצבעות מוסתרות עד שלוחצים <b>בדיקה</b>. הטיל משוגר עם התשובה שרוב הכיתה בחרה.</li>
75
  <li>בתיקו הטיל לא משוגר: התלמידים יכולים לשנות הצבעה, ובודקים שוב.</li>
76
  <li><b>לחידה הבאה</b> ממשיך באותה הצבעה.</li></ul>`],
77
+ ["📌","משימות לכיתה",`<p>בדוח הכיתה, בלשונית <b>משימות</b>, נותנים לכיתה משימה עם <b>משימה חדשה</b>: כותרת, הערה לתלמידים, תאריך להגשה ופריט אחד או יותר (עד 20): מיומנויות לסיים, בוחנים מסכמים להגשה ומבחן תרגול על יחידות שבוחרים. אפשר לבחור רק מהיחידות שנפתחו לכיתה.</p>
78
+ <ul><li>התלמידים רואים את המשימה בבית, והכפתור שבה מוביל לפריט הבא שלהם.</li>
79
+ <li>בכל משימה רואים כמה תלמידים סיימו, ו<b>מי עשה מה</b> פותח טבלה: לכל תלמיד ולכל פריט ✓, ✓ באיחור או –. סימן ? אומר שאי אפשר לבדוק כרגע בוחן מסכם.</li>
80
+ <li>תלמיד שלא סיים משימה אחרי התאריך מופיע ב<b>דורשים תשומת לב</b>.</li>
81
+ <li><b>עריכה</b> משנה כל דבר במשימה, וגם את התאריך. <b>מחיקה</b> שואלת קודם וכותבת כמה תלמידים כבר השלימו ממנה פריט.</li>
82
+ <li>מיומנות נחשבת גם אם התלמיד סיים אותה לפני שנתתם את המשימה. בוחן ומבחן תרגול נחשבים רק מהרגע שהמשימה נוצרה. נתוני המיומנויות והמבחנים מהמכשיר של התלמיד, והבוחן המסכם מהשרת.</li></ul>`],
83
  ["🗓️","מבחנים ותרגול לקראתם",`<p>לוח המבחנים של הכיתה מופיע לתלמידים במסך המקצועות ובמסלול. כשמבחן מתקרב, <b>מבחן התרגול</b> מכוון אליו ומציע את היחידות שנלמדו עד אז. כדאי להזכיר לתלמידים לעשות אותו בשבוע שלפני המבחן.</p>`],
84
  ["⚙️","ניהול (למנהלים)",`<p>הכפתור <b>ניהול</b> מופיע למי שיש לו הרשאה. שם יוצרים כיתות, מוסיפים תלמידים לפי כתובת דוא״ל (שורה לכל תלמיד), בוחרים יחידות ומקצועות, מגדירים לוח מבחנים ובוחרים את מורה הכיתה. כל כיתה פותחת לתלמידים שלה רק את היחידות שנבחרו לה. בלשונית המעקב רואים פעילות ושימוש.</p>`],
85
  ];
static/math-board.js CHANGED
@@ -5,7 +5,9 @@
5
  as long as the page is open. Nothing is sent anywhere unless the student hands it in.
6
 
7
  Everything on the board is an element that can be moved or erased: a pen stroke (with a pen's pressure),
8
- a straight line, a circle (dragged from the center), typed math (the math keyboard, static/board-math.js), and a
 
 
9
  copy of the question (its text and figures).
10
  Line ends and circle centers snap to the ends and centers already drawn, so a triangle closes neatly;
11
  lines that meet that way move together. The eraser removes the lines and circles it touches and cuts pen strokes;
@@ -21,7 +23,7 @@ const setOn=on=>{ try{ localStorage.setItem(PREF,on?"on":"off"); }catch{} };
21
 
22
  const INKS=[["#1B2140","שחור"],["#2563D9","כחול"],["#D3455B","אדום"]];
23
  const ERASERS=[[16,"קטן"],[40,"בינוני"],[90,"גדול"]];
24
- const TOOLS=[["move","הזזה"],["pen","עט"],["line","קו ישר"],["circle","עיגול"],["math","נוסחה"],["eraser","מחק"]];
25
  /* one line-drawn icon set (24×24, the button's text colour), so every button looks alike on every device and
26
  follows the button's hover and pressed colours; "flip" icons point the other way in a right-to-left page */
27
  const DOT='fill="currentColor" stroke="none"';
@@ -30,6 +32,7 @@ const ICONS={
30
  pen:'<path d="M4 20l1.2-4.2L16.5 4.5a2.1 2.1 0 0 1 3 3L8.2 18.8z"/><path d="M14.5 6.5l3 3"/>',
31
  line:`<path d="M6 18L18 6"/><circle cx="6" cy="18" r="2" ${DOT}/><circle cx="18" cy="6" r="2" ${DOT}/>`,
32
  circle:`<circle cx="12" cy="12" r="8"/><circle cx="12" cy="12" r="1.6" ${DOT}/>`,
 
33
  math:'<path d="M17.5 5.5V4H6.5l6 8-6 8h11v-1.5"/>',
34
  eraser:'<path d="M20 20H9.5M4.6 15.4l9.8-9.8a2 2 0 0 1 2.8 0l2.2 2.2a2 2 0 0 1 0 2.8L11 19H8.2l-3.6-3.6"/><path d="M9.5 10.5l5 5"/>',
35
  question:'<rect x="5.5" y="5" width="13" height="16" rx="2"/><path d="M9 5V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v1M9 11h6M9 15h4"/>',
@@ -43,8 +46,15 @@ const ICONS={
43
  const FLIP=new Set(["undo","back"]);
44
  const ic=n=>`<svg class="mx-ic${FLIP.has(n)?" mx-ic-flip":""}" viewBox="0 0 24 24" aria-hidden="true">${ICONS[n]}</svg>`;
45
  const MATH_SIZE=26;
 
 
 
 
 
 
 
46
  const PEN=2.6, SHAPE=2.4, SNAP=12, GRAB=9, STEP=2, HISTORY=100;
47
- const PAPER_INK="#1B2140", PAPER_FAINT="#C5CBDF", SELECT="#2563D9";
48
  const FONT='500 17px Rubik, system-ui, "Segoe UI", sans-serif', LINE_H=27, CARD_PAD=14;
49
 
50
  /* sketch key -> {marks, past}: marks are drawn in order; past holds copies of marks for undo */
@@ -52,6 +62,8 @@ const sketches=new Map(), images=new Map();
52
  let el=null, canvas=null, ctx=null, key="", base="", pages=0, page=0, layer=null, source=null, hand=null, onPage=null;
53
  let tool="pen", ink=INKS[0][0], eraser=ERASERS[1][0], sendLabel="";
54
  let draft=null, drag=null, rub=null, slide=null, selection=[]; // slide: the move tool dragging empty paper, which scrolls it
 
 
55
  let editing=null; // the formula being typed: {mark (null for a new one), at, node, field, key}
56
  let full=null; // the board open full screen: {saved: the context to go back to, done: called when it closes}
57
  const touches=new Map(); let pan=null, penSeen=false;
@@ -105,7 +117,8 @@ function build(){
105
  canvas.addEventListener("pointerup",up);
106
  canvas.addEventListener("pointercancel",up);
107
  canvas.addEventListener("contextmenu",e=>e.preventDefault());
108
- canvas.addEventListener("dblclick",e=>{ const m=hit(at(e)); if(m?.kind==="math") editMath(m,at(e)); }); // typed math is edited where it is // the pen's side button: no browser menu over the board
 
109
  canvas.addEventListener("wheel",e=>{ // endless paper: scrolls down as far as needed, never above the top
110
  if(e.ctrlKey) return; // the browser's zoom
111
  e.preventDefault(); const s=sketch(), dy=e.deltaMode===1?e.deltaY*24:e.deltaMode===2?e.deltaY*canvas.clientHeight:e.deltaY;
@@ -115,16 +128,16 @@ function build(){
115
  setTool(tool);
116
  }
117
  function pressed(sel,b){ el.querySelectorAll(sel).forEach(x=>x.setAttribute("aria-pressed",x===b)); }
118
- function reset(){ draft=drag=rub=slide=null; select([]); if(editing) finishMath(false); }
119
  function setTool(t){
120
- tool=t; el.querySelectorAll("[data-tool]").forEach(x=>x.setAttribute("aria-pressed",x.dataset.tool===t));
121
  el.dataset.tool=t; if(t!=="move"&&selection.length){ select([]); paint(); }
122
  // the eraser's cursor is a ring its own size, so the student sees what it will wipe
123
  const r=eraser/2, svg=`<svg xmlns='http://www.w3.org/2000/svg' width='${eraser+2}' height='${eraser+2}'><circle cx='${r+1}' cy='${r+1}' r='${r}' fill='rgba(255,255,255,.35)' stroke='%231B2140' stroke-width='1.2'/></svg>`;
124
  canvas.style.cursor=t==="eraser"?`url("data:image/svg+xml,${svg.replace(/</g,"%3C").replace(/>/g,"%3E")}") ${r+1} ${r+1}, cell`:t==="move"?"grab":"";
125
  }
126
  function select(list){ selection=list; }
127
- function turn(p){ if(editing) finishMath(true); page=p; key=`${base}:${page}`; reset(); drawPages(); drawSend(); paint(); }
128
  function drawSend(){
129
  const b=el.querySelector(".mx-board-send"); b.hidden=!hand; el.querySelector(".mx-board-row").hidden=pages<2&&!hand; if(!hand) return;
130
  b.disabled=false; b.innerHTML=`${ic("send")}<span>${sendLabel||`הגשה לשאלה ${page+1}`}</span>`; b.title=sendLabel||`הגשת הלוח כתשובה לשאלה ${page+1}`;
@@ -150,6 +163,7 @@ function paint(){
150
  el.querySelector(".mx-board-top").hidden=y<1;
151
  marks().forEach(draw);
152
  if(draft?.kind==="circle") guide(draft);
 
153
  if(selection.length) outline(selection);
154
  const del=el.querySelector(".mx-board-del"); del.hidden=!selection.length||!!drag?.before;
155
  if(selection.length){ const [x0,y0]=edges(selection); del.style.left=`${Math.max(4,x0)}px`; const top=y0-y; del.style.top=`${top>40?top-36:Math.max(4,top+4)}px`; }
@@ -158,6 +172,7 @@ function draw(m){
158
  ctx.save(); ctx.strokeStyle=m.color; ctx.lineCap="round"; ctx.lineJoin="round";
159
  if(m.kind==="line"){ ctx.lineWidth=SHAPE; ctx.beginPath(); ctx.moveTo(...m.a); ctx.lineTo(...m.b); ctx.stroke(); }
160
  else if(m.kind==="circle"){ ctx.lineWidth=SHAPE; ctx.beginPath(); ctx.arc(m.a[0],m.a[1],dist(m.a,m.b),0,Math.PI*2); ctx.stroke(); }
 
161
  else if(m.kind==="card") card(m);
162
  else if(m.kind==="math"){ const L=mathBox(m); if(L){ ctx.fillStyle=m.color; ctx.direction="ltr"; ctx.textAlign="left"; ctx.textBaseline="alphabetic"; L.paint(ctx,m.x,m.y+L.a); } }
163
  else{ for(let i=1;i<m.pts.length;i++) segment(m,i); if(m.pts.length===1) segment(m,0); }
@@ -175,6 +190,30 @@ function guide(m){
175
  ctx.beginPath(); ctx.moveTo(...m.a); ctx.lineTo(...m.b); ctx.stroke();
176
  ctx.setLineDash([]); ctx.beginPath(); ctx.arc(m.a[0],m.a[1],2.5,0,Math.PI*2); ctx.fill(); ctx.restore();
177
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  function card(m){
179
  ctx.fillStyle="#EEF1FB"; ctx.strokeStyle=PAPER_FAINT; ctx.lineWidth=1;
180
  ctx.beginPath(); ctx.roundRect(m.x,m.y,m.w,m.h,12); ctx.fill(); ctx.stroke();
@@ -190,6 +229,7 @@ const mathBox=m=>window.MathlyBoardMath?.layout(m.latex,m.size||MATH_SIZE);
190
  function box(m){
191
  if(m.kind==="card") return [m.x,m.y,m.x+m.w,m.y+m.h];
192
  if(m.kind==="math"){ const L=mathBox(m)||{w:0,a:0,d:0}; return [m.x,m.y,m.x+L.w,m.y+L.a+L.d]; }
 
193
  if(m.kind==="circle"){ const r=dist(m.a,m.b); return [m.a[0]-r,m.a[1]-r,m.a[0]+r,m.a[1]+r]; }
194
  const pts=m.kind==="line"?[m.a,m.b]:m.pts, xs=pts.map(p=>p[0]), ys=pts.map(p=>p[1]);
195
  return [Math.min(...xs),Math.min(...ys),Math.max(...xs),Math.max(...ys)];
@@ -202,43 +242,50 @@ function outline(list){
202
  }
203
 
204
  /* ---------- geometry ---------- */
205
- function toSegment(p,a,b){
206
  const dx=b[0]-a[0], dy=b[1]-a[1], l=dx*dx+dy*dy, t=l?Math.max(0,Math.min(1,((p[0]-a[0])*dx+(p[1]-a[1])*dy)/l)):0;
207
- return Math.hypot(p[0]-a[0]-t*dx,p[1]-a[1]-t*dy);
208
  }
 
209
  // how far p is from the element's ink
210
  function reach(m,p){
211
  if(m.kind==="line") return toSegment(p,m.a,m.b);
212
  if(m.kind==="circle") return Math.min(Math.abs(dist(p,m.a)-dist(m.a,m.b)),dist(p,m.a));
 
 
213
  if(m.kind==="card"||m.kind==="math"){ const [x0,y0,x1,y1]=box(m), dx=Math.max(x0-p[0],0,p[0]-x1), dy=Math.max(y0-p[1],0,p[1]-y1);
214
  return m.kind==="card"?(dx||dy?Infinity:0):Math.hypot(dx,dy); }
215
  if(m.pts.length===1) return dist(p,m.pts[0]);
216
  let d=Infinity; for(let i=1;i<m.pts.length;i++) d=Math.min(d,toSegment(p,m.pts[i-1],m.pts[i])); return d;
217
  }
218
  const hit=p=>{ const all=marks(); for(let i=all.length-1;i>=0;i--) if(reach(all[i],p)<=GRAB) return all[i]; return null; };
219
- const joints=m=>m.kind==="line"?[m.a,m.b]:m.kind==="circle"?[m.a]:[];
220
  // an element and everything joined to it by a shared end or center: a triangle moves as one
221
  function group(m){
222
  const out=new Set([m]), todo=[m];
223
  while(todo.length){ const cur=todo.pop();
224
- for(const o of marks()) if(!out.has(o)&&joints(cur).some(p=>joints(o).some(q=>same(p,q)))){ out.add(o); todo.push(o); } }
225
  return [...out];
226
  }
227
- // a line's ends and a circle's center pull to the ends and centers already on the board
 
 
 
228
  function snap(p,skip){
229
  let best=null, d=SNAP;
230
  for(const m of marks()){ if(m===skip) continue; for(const q of joints(m)){ const k=dist(p,q); if(k<d){ d=k; best=q; } } }
 
231
  return best?[best[0],best[1]]:[p[0],p[1]];
232
  }
233
  function shift(m,dx,dy){
234
  const by=p=>{ p[0]+=dx; p[1]+=dy; };
235
- if(m.kind==="card"||m.kind==="math"){ m.x+=dx; m.y+=dy; } else if(m.kind==="free") m.pts.forEach(by); else { by(m.a); by(m.b); }
236
  }
237
  // the eraser at p: lines, circles and typed math it touches go; pen strokes are cut; the question's copy stays
238
  function rubOut(p){
239
  const r=eraser/2, all=marks(); let changed=false;
240
  for(let i=all.length-1;i>=0;i--){ const m=all[i];
241
- if(m.kind==="line"||m.kind==="circle"||m.kind==="math"){ if(reach(m,p)<=r+SHAPE/2){ all.splice(i,1); changed=true; } continue; }
242
  if(m.kind!=="free") continue;
243
  const gone=m.pts.map(q=>dist(p,q)<=r+PEN); if(!gone.some(Boolean)) continue;
244
  const runs=[]; let run=[]; m.pts.forEach((q,k)=>{ if(gone[k]){ if(run.length) runs.push(run); run=[]; } else run.push(q); }); if(run.length) runs.push(run);
@@ -246,6 +293,150 @@ function rubOut(p){
246
  }
247
  return changed;
248
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249
  // points along a to b, close enough that nothing between two of them is skipped
250
  function along(a,b,step){ const n=Math.max(1,Math.ceil(dist(a,b)/step)), out=[]; for(let k=1;k<=n;k++) out.push([a[0]+(b[0]-a[0])*k/n,a[1]+(b[1]-a[1])*k/n,a[2]+((b[2]??a[2])-a[2])*k/n]); return out; }
251
 
@@ -271,6 +462,8 @@ function down(e){
271
  if(e.button!==0&&e.button!==5) return; // 5: the pen's eraser end
272
  const p=at(e), use=e.button===5||(e.buttons&32)>0?"eraser":tool;
273
  if(editing){ e.preventDefault(); return finishMath(true); } // a tap beside the formula being typed: it is done
 
 
274
  if(use==="math"){ e.preventDefault(); const m=hit(p); return editMath(m?.kind==="math"?m:null,p); }
275
  capture(e.pointerId); e.preventDefault();
276
  if(use==="move"){
@@ -292,6 +485,11 @@ function move(e){
292
  }
293
  if(slide&&e.pointerId===slide.id){ sketch().y=Math.max(0,slide.s-(e.clientY-slide.y)); return paint(); }
294
  if(tool==="move"&&!drag&&!e.buttons) canvas.style.cursor=hit(at(e))?"move":"grab";
 
 
 
 
 
295
  if(drag&&e.pointerId===drag.id){
296
  const p=at(e), dx=p[0]-drag.last[0], dy=p[1]-drag.last[1]; if(!dx&&!dy) return;
297
  if(!drag.before){ drag.before=true; remember(); }
@@ -436,9 +634,9 @@ function snapshot(){
436
  const all=marks(); if(!all.some(m=>m.kind!=="card")) return null;
437
  const [,y0,,y1]=edges(all), top=Math.max(0,y0-24), W=canvas.clientWidth, H=Math.min(y1+24-top,8000), k=2;
438
  const out=document.createElement("canvas"); out.width=W*k; out.height=Math.round(H*k);
439
- const keep=ctx; ctx=out.getContext("2d");
440
  try{ ctx.fillStyle="#fff"; ctx.fillRect(0,0,out.width,out.height); ctx.setTransform(k,0,0,k,0,-top*k); all.forEach(draw); }
441
- finally{ ctx=keep; }
442
  return new Promise(ok=>out.toBlob(ok,"image/png"));
443
  }
444
  async function send(){
@@ -504,7 +702,7 @@ function openFull({host,key:k,pages:n=1,page:p=0,question=null,submit=null,turne
504
  el.querySelector(".mx-board-back").focus({preventScroll:true,focusVisible:false}); // reachable by keyboard, without a ring on a tap
505
  }
506
  function closeFull(){
507
- if(!full) return; if(editing) finishMath(true); const f=full; full=null;
508
  el.classList.remove("full"); el.removeAttribute("role"); el.removeAttribute("aria-modal"); el.remove();
509
  restore(f.saved); f.done?.();
510
  if(layer?.isConnected&&desk()) again();
 
5
  as long as the page is open. Nothing is sent anywhere unless the student hands it in.
6
 
7
  Everything on the board is an element that can be moved or erased: a pen stroke (with a pen's pressure),
8
+ a straight line, a circle (dragged from the center), an angle's arc with its words (the angle tool: pointing at a
9
+ corner where two lines meet shows the arc; a click adds it and asks for its words, and a number gets a degree
10
+ sign), typed math (the math keyboard, static/board-math.js), and a
11
  copy of the question (its text and figures).
12
  Line ends and circle centers snap to the ends and centers already drawn, so a triangle closes neatly;
13
  lines that meet that way move together. The eraser removes the lines and circles it touches and cuts pen strokes;
 
23
 
24
  const INKS=[["#1B2140","שחור"],["#2563D9","כחול"],["#D3455B","אדום"]];
25
  const ERASERS=[[16,"קטן"],[40,"בינוני"],[90,"גדול"]];
26
+ const TOOLS=[["move","הזזה"],["pen","עט"],["line","קו ישר"],["circle","עיגול"],["angle","זווית"],["math","נוסחה"],["eraser","מחק"]];
27
  /* one line-drawn icon set (24×24, the button's text colour), so every button looks alike on every device and
28
  follows the button's hover and pressed colours; "flip" icons point the other way in a right-to-left page */
29
  const DOT='fill="currentColor" stroke="none"';
 
32
  pen:'<path d="M4 20l1.2-4.2L16.5 4.5a2.1 2.1 0 0 1 3 3L8.2 18.8z"/><path d="M14.5 6.5l3 3"/>',
33
  line:`<path d="M6 18L18 6"/><circle cx="6" cy="18" r="2" ${DOT}/><circle cx="18" cy="6" r="2" ${DOT}/>`,
34
  circle:`<circle cx="12" cy="12" r="8"/><circle cx="12" cy="12" r="1.6" ${DOT}/>`,
35
+ angle:'<path d="M20 19H4l11-14"/><path d="M10.5 19a6.5 6.5 0 0 0-2.5-5.1"/>',
36
  math:'<path d="M17.5 5.5V4H6.5l6 8-6 8h11v-1.5"/>',
37
  eraser:'<path d="M20 20H9.5M4.6 15.4l9.8-9.8a2 2 0 0 1 2.8 0l2.2 2.2a2 2 0 0 1 0 2.8L11 19H8.2l-3.6-3.6"/><path d="M9.5 10.5l5 5"/>',
38
  question:'<rect x="5.5" y="5" width="13" height="16" rx="2"/><path d="M9 5V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v1M9 11h6M9 15h4"/>',
 
46
  const FLIP=new Set(["undo","back"]);
47
  const ic=n=>`<svg class="mx-ic${FLIP.has(n)?" mx-ic-flip":""}" viewBox="0 0 24 24" aria-hidden="true">${ICONS[n]}</svg>`;
48
  const MATH_SIZE=26;
49
+ const GREEK=["α","β","γ"], ARC_GAP=4; // the angle box's letters; how far apart a double or triple arc's arcs are
50
+ const ARCS=[[1,"קשת אחת"],[2,"קשת כפולה"],[3,"קשת משולשת"],["right","זווית ישרה (ריבוע)"]];
51
+ // a corner with one, two or three arcs: equal angles are marked alike
52
+ const arcsIcon=n=>n==="right"?'<svg class="mx-ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 20h18M3 20V4M3 12.5h7.5V20"/></svg>'
53
+ :`<svg class="mx-ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 20h18M3 20l12-14"/>${[7,10.5,14].slice(0,n).map(r=>`<path d="M${3+r} 20A${r} ${r} 0 0 0 ${(3+r*.65).toFixed(2)} ${(20-r*.76).toFixed(2)}"/>`).join("")}</svg>`;
54
+ const ANGLE_FONT='500 18px "STIX Two Text","Cambria Math","Times New Roman",serif', CORNER=32, CORNER_TOUCH=40; // how near a corner the angle tool reaches: about as far as an arc
55
+ const ON=3; // how close a point is to count as on a line: where an angle's corner is, and what moves with what
56
  const PEN=2.6, SHAPE=2.4, SNAP=12, GRAB=9, STEP=2, HISTORY=100;
57
+ const PAPER="#F8F7F2", PAPER_INK="#1B2140", PAPER_FAINT="#C5CBDF", SELECT="#2563D9";
58
  const FONT='500 17px Rubik, system-ui, "Segoe UI", sans-serif', LINE_H=27, CARD_PAD=14;
59
 
60
  /* sketch key -> {marks, past}: marks are drawn in order; past holds copies of marks for undo */
 
62
  let el=null, canvas=null, ctx=null, key="", base="", pages=0, page=0, layer=null, source=null, hand=null, onPage=null;
63
  let tool="pen", ink=INKS[0][0], eraser=ERASERS[1][0], sendLabel="";
64
  let draft=null, drag=null, rub=null, slide=null, selection=[]; // slide: the move tool dragging empty paper, which scrolls it
65
+ let paper=PAPER; // the paper's colour: the board's, or white in the picture handed in
66
+ let naming=null, hover=null; // naming: an angle's words being typed {mark, node, input, fresh, was, key}; hover: the corner the angle tool points at
67
  let editing=null; // the formula being typed: {mark (null for a new one), at, node, field, key}
68
  let full=null; // the board open full screen: {saved: the context to go back to, done: called when it closes}
69
  const touches=new Map(); let pan=null, penSeen=false;
 
117
  canvas.addEventListener("pointerup",up);
118
  canvas.addEventListener("pointercancel",up);
119
  canvas.addEventListener("contextmenu",e=>e.preventDefault());
120
+ canvas.addEventListener("dblclick",e=>{ const m=hit(at(e)); if(m?.kind==="math") editMath(m,at(e)); else if(m?.kind==="angle") startAngle(at(e),0); });
121
+ canvas.addEventListener("pointerleave",()=>{ if(hover){ hover=null; paint(); } }); // typed math is edited where it is // the pen's side button: no browser menu over the board
122
  canvas.addEventListener("wheel",e=>{ // endless paper: scrolls down as far as needed, never above the top
123
  if(e.ctrlKey) return; // the browser's zoom
124
  e.preventDefault(); const s=sketch(), dy=e.deltaMode===1?e.deltaY*24:e.deltaMode===2?e.deltaY*canvas.clientHeight:e.deltaY;
 
128
  setTool(tool);
129
  }
130
  function pressed(sel,b){ el.querySelectorAll(sel).forEach(x=>x.setAttribute("aria-pressed",x===b)); }
131
+ function reset(){ draft=drag=rub=slide=null; hover=null; select([]); if(editing) finishMath(false); if(naming) finishAngle(true); }
132
  function setTool(t){
133
+ tool=t; if(hover){ hover=null; paint(); } el.querySelectorAll("[data-tool]").forEach(x=>x.setAttribute("aria-pressed",x.dataset.tool===t));
134
  el.dataset.tool=t; if(t!=="move"&&selection.length){ select([]); paint(); }
135
  // the eraser's cursor is a ring its own size, so the student sees what it will wipe
136
  const r=eraser/2, svg=`<svg xmlns='http://www.w3.org/2000/svg' width='${eraser+2}' height='${eraser+2}'><circle cx='${r+1}' cy='${r+1}' r='${r}' fill='rgba(255,255,255,.35)' stroke='%231B2140' stroke-width='1.2'/></svg>`;
137
  canvas.style.cursor=t==="eraser"?`url("data:image/svg+xml,${svg.replace(/</g,"%3C").replace(/>/g,"%3E")}") ${r+1} ${r+1}, cell`:t==="move"?"grab":"";
138
  }
139
  function select(list){ selection=list; }
140
+ function turn(p){ if(editing) finishMath(true); if(naming) finishAngle(true); page=p; key=`${base}:${page}`; reset(); drawPages(); drawSend(); paint(); }
141
  function drawSend(){
142
  const b=el.querySelector(".mx-board-send"); b.hidden=!hand; el.querySelector(".mx-board-row").hidden=pages<2&&!hand; if(!hand) return;
143
  b.disabled=false; b.innerHTML=`${ic("send")}<span>${sendLabel||`הגשה לשאלה ${page+1}`}</span>`; b.title=sendLabel||`הגשת הלוח כתשובה לשאלה ${page+1}`;
 
163
  el.querySelector(".mx-board-top").hidden=y<1;
164
  marks().forEach(draw);
165
  if(draft?.kind==="circle") guide(draft);
166
+ if(hover&&!naming) aimed(hover);
167
  if(selection.length) outline(selection);
168
  const del=el.querySelector(".mx-board-del"); del.hidden=!selection.length||!!drag?.before;
169
  if(selection.length){ const [x0,y0]=edges(selection); del.style.left=`${Math.max(4,x0)}px`; const top=y0-y; del.style.top=`${top>40?top-36:Math.max(4,top+4)}px`; }
 
172
  ctx.save(); ctx.strokeStyle=m.color; ctx.lineCap="round"; ctx.lineJoin="round";
173
  if(m.kind==="line"){ ctx.lineWidth=SHAPE; ctx.beginPath(); ctx.moveTo(...m.a); ctx.lineTo(...m.b); ctx.stroke(); }
174
  else if(m.kind==="circle"){ ctx.lineWidth=SHAPE; ctx.beginPath(); ctx.arc(m.a[0],m.a[1],dist(m.a,m.b),0,Math.PI*2); ctx.stroke(); }
175
+ else if(m.kind==="angle") angleMark(m);
176
  else if(m.kind==="card") card(m);
177
  else if(m.kind==="math"){ const L=mathBox(m); if(L){ ctx.fillStyle=m.color; ctx.direction="ltr"; ctx.textAlign="left"; ctx.textBaseline="alphabetic"; L.paint(ctx,m.x,m.y+L.a); } }
178
  else{ for(let i=1;i<m.pts.length;i++) segment(m,i); if(m.pts.length===1) segment(m,0); }
 
190
  ctx.beginPath(); ctx.moveTo(...m.a); ctx.lineTo(...m.b); ctx.stroke();
191
  ctx.setLineDash([]); ctx.beginPath(); ctx.arc(m.a[0],m.a[1],2.5,0,Math.PI*2); ctx.fill(); ctx.restore();
192
  }
193
+ // an angle: the arc between its two edges, and its words just outside the arc (hidden while they are typed)
194
+ const arcsOf=m=>naming?.mark===m?naming.arcs:m.arcs||1; // while its box is open: the arcs picked there
195
+ // a right angle is marked with a little square in its corner: its sides along the edges, its far corner on the line splitting the angle
196
+ const square=m=>{
197
+ const k=Math.max(8,Math.min(14,arcR(m)*.5)), u=[Math.cos(m.s),Math.sin(m.s)], w=[Math.cos(m.s+m.w),Math.sin(m.s+m.w)];
198
+ return [[m.v[0]+k*u[0],m.v[1]+k*u[1]],[m.v[0]+k*(u[0]+w[0]),m.v[1]+k*(u[1]+w[1])],[m.v[0]+k*w[0],m.v[1]+k*w[1]]];
199
+ };
200
+ const rim=m=>arcsOf(m)==="right"?dist(square(m)[1],m.v):arcR(m)+ARC_GAP*(arcsOf(m)-1); // how far out the mark reaches
201
+ const colorOf=m=>naming?.mark===m?naming.color:m.color; // while its box is open: the colour picked there
202
+ function angleMark(m){
203
+ ctx.lineWidth=1.8; ctx.strokeStyle=colorOf(m);
204
+ if(arcsOf(m)==="right"){ const [a,b,c]=square(m); ctx.beginPath(); ctx.moveTo(...a); ctx.lineTo(...b); ctx.lineTo(...c); ctx.stroke(); }
205
+ else for(let k=0;k<arcsOf(m);k++){ ctx.beginPath(); ctx.arc(m.v[0],m.v[1],arcR(m)+k*ARC_GAP,m.s,m.s+m.w); ctx.stroke(); }
206
+ if(!m.text||naming?.mark===m) return;
207
+ const [x,y]=labelAt(m); ctx.font=ANGLE_FONT; ctx.direction="ltr"; ctx.textAlign="center"; ctx.textBaseline="middle";
208
+ ctx.strokeStyle=paper; ctx.lineWidth=5; ctx.strokeText(m.text,x,y); // a rim of paper, where the words cross a line
209
+ ctx.fillStyle=m.color; ctx.fillText(m.text,x,y);
210
+ }
211
+ // the corner the angle tool points at: the arc it would add (or the angle it would retype), dashed
212
+ function aimed(c){
213
+ ctx.save(); ctx.strokeStyle=ctx.fillStyle=SELECT; ctx.lineWidth=2; ctx.setLineDash([4,3]);
214
+ ctx.beginPath(); ctx.arc(c.v[0],c.v[1],c.r,c.s,c.s+c.w); ctx.stroke();
215
+ ctx.setLineDash([]); ctx.beginPath(); ctx.arc(c.v[0],c.v[1],3.5,0,TAU); ctx.fill(); ctx.restore();
216
+ }
217
  function card(m){
218
  ctx.fillStyle="#EEF1FB"; ctx.strokeStyle=PAPER_FAINT; ctx.lineWidth=1;
219
  ctx.beginPath(); ctx.roundRect(m.x,m.y,m.w,m.h,12); ctx.fill(); ctx.stroke();
 
229
  function box(m){
230
  if(m.kind==="card") return [m.x,m.y,m.x+m.w,m.y+m.h];
231
  if(m.kind==="math"){ const L=mathBox(m)||{w:0,a:0,d:0}; return [m.x,m.y,m.x+L.w,m.y+L.a+L.d]; }
232
+ if(m.kind==="angle"){ const [x,y,w]=labelAt(m), r=rim(m); return [Math.min(m.v[0]-r,x-w/2),Math.min(m.v[1]-r,y-10),Math.max(m.v[0]+r,x+w/2),Math.max(m.v[1]+r,y+10)]; }
233
  if(m.kind==="circle"){ const r=dist(m.a,m.b); return [m.a[0]-r,m.a[1]-r,m.a[0]+r,m.a[1]+r]; }
234
  const pts=m.kind==="line"?[m.a,m.b]:m.pts, xs=pts.map(p=>p[0]), ys=pts.map(p=>p[1]);
235
  return [Math.min(...xs),Math.min(...ys),Math.max(...xs),Math.max(...ys)];
 
242
  }
243
 
244
  /* ---------- geometry ---------- */
245
+ function nearestOn(p,a,b){
246
  const dx=b[0]-a[0], dy=b[1]-a[1], l=dx*dx+dy*dy, t=l?Math.max(0,Math.min(1,((p[0]-a[0])*dx+(p[1]-a[1])*dy)/l)):0;
247
+ return [a[0]+t*dx,a[1]+t*dy];
248
  }
249
+ const toSegment=(p,a,b)=>dist(p,nearestOn(p,a,b));
250
  // how far p is from the element's ink
251
  function reach(m,p){
252
  if(m.kind==="line") return toSegment(p,m.a,m.b);
253
  if(m.kind==="circle") return Math.min(Math.abs(dist(p,m.a)-dist(m.a,m.b)),dist(p,m.a));
254
+ if(m.kind==="angle"&&arcsOf(m)==="right"){ const [a,b,c]=square(m); return Math.min(toSegment(p,a,b),toSegment(p,b,c),toWords(m,p)); }
255
+ if(m.kind==="angle") return Math.min(inWedge(Math.atan2(p[1]-m.v[1],p[0]-m.v[0]),m.s,m.w)?Math.max(0,Math.abs(dist(p,m.v)-(arcR(m)+rim(m))/2)-(rim(m)-arcR(m))/2):Infinity,toWords(m,p));
256
  if(m.kind==="card"||m.kind==="math"){ const [x0,y0,x1,y1]=box(m), dx=Math.max(x0-p[0],0,p[0]-x1), dy=Math.max(y0-p[1],0,p[1]-y1);
257
  return m.kind==="card"?(dx||dy?Infinity:0):Math.hypot(dx,dy); }
258
  if(m.pts.length===1) return dist(p,m.pts[0]);
259
  let d=Infinity; for(let i=1;i<m.pts.length;i++) d=Math.min(d,toSegment(p,m.pts[i-1],m.pts[i])); return d;
260
  }
261
  const hit=p=>{ const all=marks(); for(let i=all.length-1;i>=0;i--) if(reach(all[i],p)<=GRAB) return all[i]; return null; };
262
+ const joints=m=>m.kind==="line"?[m.a,m.b]:m.kind==="circle"?[m.a]:m.kind==="angle"?[m.v]:[]; // an angle moves with its corner
263
  // an element and everything joined to it by a shared end or center: a triangle moves as one
264
  function group(m){
265
  const out=new Set([m]), todo=[m];
266
  while(todo.length){ const cur=todo.pop();
267
+ for(const o of marks()) if(!out.has(o)&&linked(cur,o)){ out.add(o); todo.push(o); } }
268
  return [...out];
269
  }
270
+ // joined: a shared end or center, a line's end on another line (drawn from a side), or an angle on a line through it
271
+ const lies=(m,o)=>o.kind==="line"&&(m.kind==="line"||m.kind==="angle")&&joints(m).some(p=>toSegment(p,o.a,o.b)<=ON);
272
+ const linked=(m,o)=>joints(m).some(p=>joints(o).some(q=>same(p,q)))||lies(m,o)||lies(o,m);
273
+ // a line's ends and a circle's center pull to the ends and centers already on the board, or else onto a line
274
  function snap(p,skip){
275
  let best=null, d=SNAP;
276
  for(const m of marks()){ if(m===skip) continue; for(const q of joints(m)){ const k=dist(p,q); if(k<d){ d=k; best=q; } } }
277
+ if(!best) for(const m of marks()) if(m!==skip&&m.kind==="line"){ const q=nearestOn(p,m.a,m.b), k=dist(p,q); if(k<d){ d=k; best=q; } }
278
  return best?[best[0],best[1]]:[p[0],p[1]];
279
  }
280
  function shift(m,dx,dy){
281
  const by=p=>{ p[0]+=dx; p[1]+=dy; };
282
+ if(m.kind==="card"||m.kind==="math"){ m.x+=dx; m.y+=dy; } else if(m.kind==="free") m.pts.forEach(by); else if(m.kind==="angle") by(m.v); else { by(m.a); by(m.b); }
283
  }
284
  // the eraser at p: lines, circles and typed math it touches go; pen strokes are cut; the question's copy stays
285
  function rubOut(p){
286
  const r=eraser/2, all=marks(); let changed=false;
287
  for(let i=all.length-1;i>=0;i--){ const m=all[i];
288
+ if(m.kind==="line"||m.kind==="circle"||m.kind==="math"||m.kind==="angle"){ if(reach(m,p)<=r+SHAPE/2){ all.splice(i,1); changed=true; } continue; }
289
  if(m.kind!=="free") continue;
290
  const gone=m.pts.map(q=>dist(p,q)<=r+PEN); if(!gone.some(Boolean)) continue;
291
  const runs=[]; let run=[]; m.pts.forEach((q,k)=>{ if(gone[k]){ if(run.length) runs.push(run); run=[]; } else run.push(q); }); if(run.length) runs.push(run);
 
293
  }
294
  return changed;
295
  }
296
+ /* ---------- angles at corners ---------- */
297
+ const TAU=Math.PI*2, turnOf=a=>(a%TAU+TAU)%TAU, inWedge=(t,s,w)=>turnOf(t-s)<=w;
298
+ const gap=(a,b)=>Math.abs(turnOf(a-b+Math.PI)-Math.PI);
299
+ // where two lines cross, or null (an end on the other line counts)
300
+ function cross(m,o){
301
+ const r=[m.b[0]-m.a[0],m.b[1]-m.a[1]], q=[o.b[0]-o.a[0],o.b[1]-o.a[1]], den=r[0]*q[1]-r[1]*q[0];
302
+ if(Math.abs(den)<1e-9) return null;
303
+ const ex=o.a[0]-m.a[0], ey=o.a[1]-m.a[1], t=(ex*q[1]-ey*q[0])/den, u=(ex*r[1]-ey*r[0])/den, k=.01;
304
+ return t>=-k&&t<=1+k&&u>=-k&&u<=1+k?[m.a[0]+t*r[0],m.a[1]+t*r[1]]:null;
305
+ }
306
+ // the points where lines meet: shared ends, an end partway along another line, and crossings
307
+ function meets(){
308
+ const lines=marks().filter(m=>m.kind==="line"), out=lines.flatMap(m=>[m.a,m.b]);
309
+ lines.forEach((m,i)=>lines.slice(i+1).forEach(o=>{ const c=cross(m,o); if(c) out.push(c); }));
310
+ return out;
311
+ }
312
+ // every line through v, as rays going out of v: one for a line ending there, two for a line passing through
313
+ function raysAt(v){
314
+ const rays=[], add=b=>{
315
+ if(dist(b,v)<=ON) return;
316
+ const t=turnOf(Math.atan2(b[1]-v[1],b[0]-v[0])), len=dist(v,b), twin=rays.find(r=>gap(r.t,t)<.02);
317
+ if(twin) twin.len=Math.min(twin.len,len); else rays.push({t,len});
318
+ };
319
+ for(const m of marks()) if(m.kind==="line"){
320
+ if(dist(m.a,v)<=ON) add(m.b); else if(dist(m.b,v)<=ON) add(m.a); else if(toSegment(v,m.a,m.b)<=ON){ add(m.a); add(m.b); }
321
+ }
322
+ return rays.sort((a,b)=>a.t-b.t);
323
+ }
324
+ /* A corner near p where lines meet, and the angle (under half a turn) between two of its rays that p points into:
325
+ the inside of a triangle's corner, either side of a line drawn from a side, or the smallest one when p is on the
326
+ corner itself. Pointing along a line that splits an angle picks the whole angle, its arc a little wider, and
327
+ its words go in the widest gap the inner lines leave. */
328
+ function corner(p,near){
329
+ let v=null, d=near;
330
+ for(const q of meets()){ const k=dist(p,q); if(k<d){ d=k; v=q; } }
331
+ if(!v) return null;
332
+ const rays=raysAt(v); if(rays.length<2) return null;
333
+ const wedges=[];
334
+ rays.forEach(r=>rays.forEach(n=>{
335
+ const w=turnOf(n.t-r.t); if(r===n||w<.02||w>Math.PI-.01) return;
336
+ wedges.push({s:r.t,w,len:Math.min(r.len,n.len),inner:rays.filter(x=>x!==r&&x!==n&&turnOf(x.t-r.t)<w)});
337
+ }));
338
+ if(!wedges.length) return null;
339
+ wedges.sort((a,b)=>a.w-b.w);
340
+ const t=Math.atan2(p[1]-v[1],p[0]-v[0]), far=dist(p,v)>6, along=far&&rays.find(r=>gap(r.t,t)<.1);
341
+ const off=x=>inWedge(t,x.s,x.w)?0:Math.min(gap(t,x.s),gap(t,x.s+x.w))+(x.inner.length?1:0); // outside them all: the nearest
342
+ const w=(along&&wedges.find(x=>x.inner.includes(along)))||(far&&wedges.reduce((a,b)=>off(b)<off(a)?b:a))||wedges[0];
343
+ const cuts=[0,...w.inner.map(x=>turnOf(x.t-w.s)).sort((a,b)=>a-b),w.w];
344
+ let f=w.w/2, g=w.w;
345
+ if(w.inner.length){ g=0; for(let k=1;k<cuts.length;k++) if(cuts[k]-cuts[k-1]>g){ g=cuts[k]-cuts[k-1]; f=(cuts[k]+cuts[k-1])/2; } }
346
+ return {v:[v[0],v[1]],s:w.s,w:w.w,r:Math.max(12,Math.min(30,w.len*.3))+10*w.inner.length,f:w.s+f};
347
+ }
348
+ // what the angle tool would act on at p: an angle already there (to retype its words), or a corner to add one to
349
+ // (an angle's words come first; then the corner decides, so pointing along a middle line over a half's arc still
350
+ // gives the whole angle; an arc whose corner is gone is still there to retype)
351
+ function aim(p,near){
352
+ const all=marks().filter(m=>m.kind==="angle").reverse(), as=m=>({mark:m,v:m.v,s:m.s,w:m.w,r:arcR(m)});
353
+ const named=all.find(m=>toWords(m,p)<=4); if(named) return as(named);
354
+ const c=corner(p,near);
355
+ if(c){ c.mark=all.find(m=>dist(m.v,c.v)<=ON&&gap(m.s,c.s)<.05&&Math.abs(m.w-c.w)<.05)||null; if(c.mark) c.r=arcR(c.mark); return c; }
356
+ const old=all.find(m=>reach(m,p)<=GRAB); return old?as(old):null;
357
+ }
358
+ // how far p is from an angle's words
359
+ function toWords(m,p){
360
+ if(!m.text) return Infinity;
361
+ const [x,y,w]=labelAt(m); return Math.hypot(Math.max(Math.abs(p[0]-x)-w/2,0),Math.max(Math.abs(p[1]-y)-10,0));
362
+ }
363
+ // where an angle's words sit: on the line splitting the angle, just past the arc, and far enough out to clear the edges
364
+ // (right by the arc, even in a narrow angle: they are drawn over a line's ink with a rim of paper)
365
+ function labelAt(m,text=m.text){
366
+ ctx.save(); ctx.font=ANGLE_FONT; const w=text?ctx.measureText(text).width:0; ctx.restore();
367
+ const f=m.f??m.s+m.w/2, out=Math.abs(w/2*Math.cos(f))+Math.abs(10*Math.sin(f)), c=Math.cos(f), s=Math.sin(f); // f: the words' way
368
+ // words of an angle beside it at the same corner, added before it: these move out until they are clear of them
369
+ const all=marks(), i=all.indexOf(m), mine=inside(m);
370
+ const near=i<0?[]:all.slice(0,i).filter(o=>o.kind==="angle"&&o.text&&dist(o.v,m.v)<=ON&&!mine.includes(o)&&!inside(o).includes(m)).map(o=>labelAt(o));
371
+ let d=rim(m)+5+out;
372
+ for(let k=0;k<30&&near.some(([x,y,ow])=>Math.abs(m.v[0]+d*c-x)<(w+ow)/2+4&&Math.abs(m.v[1]+d*s-y)<20);k++) d+=3;
373
+ return [m.v[0]+d*c,m.v[1]+d*s,w,out];
374
+ }
375
+ // the angles inside m at its corner (the halves of an angle a line splits)
376
+ const inside=m=>marks().filter(o=>o!==m&&o.kind==="angle"&&dist(o.v,m.v)<=ON&&o.w<m.w-.01&&turnOf(o.s-m.s)<=m.w-o.w+.01);
377
+ // an angle's arc goes around the arcs and words of the angles inside it, so each angle's words sit by its own arc
378
+ function arcR(m){
379
+ let r=m.r;
380
+ for(const o of inside(m)){ r=Math.max(r,rim(o)+8); if(o.text){ const [x,y,,out]=labelAt(o); r=Math.max(r,dist([x,y],m.v)+out+4); } }
381
+ return r;
382
+ }
383
+ // a number on its own or added to the rest ("60", "β+90", "180-α-β") is a size in degrees; a factor ("2β") is not
384
+ const TERM=/(^|[+\-−=]\s*)(\d+(?:[.,]\d+)?)°?(?=\s*(?:$|[+\-−=]))/g;
385
+ const NAMES={alpha:"α",beta:"β",gamma:"γ"};
386
+ const degrees=v=>v.trim().replace(/(?<![a-z])(alpha|beta|gamma)(?![a-z])/gi,w=>NAMES[w.toLowerCase()]).replace(TERM,"$1$2°");
387
+ const bare=t=>t.replace(TERM,"$1$2"); // for retyping: the signs degrees() puts back by itself
388
+ // adds the angle at p (or opens one already there), with a box over its words
389
+ function startAngle(p,near){
390
+ const c=aim(p,near); if(!c) return;
391
+ hover=null;
392
+ let m=c.mark; const fresh=!m;
393
+ if(fresh){ remember(); m={kind:"angle",color:ink,v:c.v,s:c.s,w:c.w,r:c.r,f:c.f,text:""}; marks().push(m); }
394
+ // the box for the words, with the usual names of a triangle's angles a tap away
395
+ const node=document.createElement("div"); node.className="mx-board-angleedit";
396
+ // two rows, to fit a phone: the words and the letters; the arcs and the colour
397
+ node.innerHTML=`<div class="mx-board-anglerow"><input type="text" class="mx-board-anglein" dir="auto" maxlength="16" placeholder="°" enterkeyhint="done"
398
+ aria-label="מה כתוב בזווית (מספר מקבל סימן מעלות)">${GREEK.map(g=>`<button type="button" tabindex="-1" data-letter title="${g}">${g}</button>`).join("")}</div>
399
+ <div class="mx-board-anglerow"><span class="mx-board-arcs">${ARCS.map(([n,name])=>`<button type="button" tabindex="-1" data-arcs="${n}" aria-pressed="${n===(m.arcs||1)}" title="${name}" aria-label="${name}">${arcsIcon(n)}</button>`).join("")}</span>
400
+ <span class="mx-board-colors">${INKS.map(([c,name])=>`<button type="button" tabindex="-1" data-color="${c}" aria-pressed="${c===m.color}" title="${name}" aria-label="${name}" style="--dot:${c}"></button>`).join("")}</span></div>`;
401
+ const input=node.querySelector("input"); input.value=bare(m.text);
402
+ naming={mark:m,node,input,fresh,was:m.text,arcs:m.arcs||1,color:m.color,key};
403
+ input.addEventListener("keydown",ev=>{
404
+ if(ev.key==="Enter"){ ev.preventDefault(); finishAngle(true); }
405
+ else if(ev.key==="Escape"){ ev.preventDefault(); ev.stopPropagation(); finishAngle(false); }
406
+ });
407
+ input.addEventListener("blur",()=>naming?.node===node&&finishAngle(true));
408
+ // a letter's button types it where the cursor is ("β+90" takes a tap and a few keys); the box keeps its focus
409
+ node.querySelectorAll("[data-letter]").forEach(b=>b.addEventListener("pointerdown",ev=>{
410
+ ev.preventDefault(); input.setRangeText(b.textContent,input.selectionStart??input.value.length,input.selectionEnd??input.value.length,"end");
411
+ }));
412
+ // one, two or three arcs: shown at once, kept with the words
413
+ node.querySelectorAll("[data-arcs]").forEach(b=>b.addEventListener("pointerdown",ev=>{
414
+ ev.preventDefault(); naming.arcs=b.dataset.arcs==="right"?"right":+b.dataset.arcs; node.querySelectorAll("[data-arcs]").forEach(x=>x.setAttribute("aria-pressed",x===b)); paint();
415
+ }));
416
+ // the angle's colour, its arcs and words together: shown at once on the arcs and in the box, kept with the words
417
+ const tint=()=>{ input.style.color=naming.color; paint(); };
418
+ node.querySelectorAll("[data-color]").forEach(b=>b.addEventListener("pointerdown",ev=>{
419
+ ev.preventDefault(); naming.color=b.dataset.color; node.querySelectorAll("[data-color]").forEach(x=>x.setAttribute("aria-pressed",x===b)); tint();
420
+ }));
421
+ input.style.color=m.color;
422
+ el.querySelector(".mx-board-stage").append(node);
423
+ const [x,y]=labelAt(m,"000°"), W=canvas.clientWidth, bw=node.offsetWidth;
424
+ node.style.left=`${Math.max(4,Math.min(W-4-bw,x-bw/2))}px`; node.style.top=`${Math.max(4,y-sketch().y-21)}px`;
425
+ paint(); input.focus({preventScroll:true});
426
+ if(pages>1) drawPages();
427
+ }
428
+ // keep: the words go in (a new angle with none keeps just its arc); otherwise a new angle is taken back
429
+ function finishAngle(keep){
430
+ const n=naming; if(!n) return; naming=null; n.node.remove();
431
+ const s=sketches.get(n.key), m=n.mark; if(!s) return;
432
+ if(keep){
433
+ const t=degrees(n.input.value); if(!n.fresh&&(t!==n.was||n.arcs!==(m.arcs||1)||n.color!==m.color)) remember();
434
+ m.text=t; m.color=n.color; if(n.arcs!==1) m.arcs=n.arcs; else delete m.arcs; // 2, 3, or "right"
435
+ }
436
+ else if(n.fresh){ const i=s.marks.indexOf(m); if(i>=0){ s.marks.splice(i,1); s.past.pop(); } }
437
+ paint(); if(pages>1) drawPages();
438
+ }
439
+
440
  // points along a to b, close enough that nothing between two of them is skipped
441
  function along(a,b,step){ const n=Math.max(1,Math.ceil(dist(a,b)/step)), out=[]; for(let k=1;k<=n;k++) out.push([a[0]+(b[0]-a[0])*k/n,a[1]+(b[1]-a[1])*k/n,a[2]+((b[2]??a[2])-a[2])*k/n]); return out; }
442
 
 
462
  if(e.button!==0&&e.button!==5) return; // 5: the pen's eraser end
463
  const p=at(e), use=e.button===5||(e.buttons&32)>0?"eraser":tool;
464
  if(editing){ e.preventDefault(); return finishMath(true); } // a tap beside the formula being typed: it is done
465
+ if(naming){ e.preventDefault(); return finishAngle(true); } // the same for an angle's words
466
+ if(use==="angle"){ e.preventDefault(); return startAngle(p,e.pointerType==="mouse"?CORNER:CORNER_TOUCH); }
467
  if(use==="math"){ e.preventDefault(); const m=hit(p); return editMath(m?.kind==="math"?m:null,p); }
468
  capture(e.pointerId); e.preventDefault();
469
  if(use==="move"){
 
485
  }
486
  if(slide&&e.pointerId===slide.id){ sketch().y=Math.max(0,slide.s-(e.clientY-slide.y)); return paint(); }
487
  if(tool==="move"&&!drag&&!e.buttons) canvas.style.cursor=hit(at(e))?"move":"grab";
488
+ if(tool==="angle"&&!e.buttons&&e.pointerType!=="touch"){ // a mouse or a pen over a corner: its arc, dashed
489
+ const c=naming?null:aim(at(e),CORNER), was=hover; hover=c; canvas.style.cursor=c?"pointer":"";
490
+ if(c?.v[0]!==was?.v[0]||c?.v[1]!==was?.v[1]||c?.s!==was?.s) paint();
491
+ return;
492
+ }
493
  if(drag&&e.pointerId===drag.id){
494
  const p=at(e), dx=p[0]-drag.last[0], dy=p[1]-drag.last[1]; if(!dx&&!dy) return;
495
  if(!drag.before){ drag.before=true; remember(); }
 
634
  const all=marks(); if(!all.some(m=>m.kind!=="card")) return null;
635
  const [,y0,,y1]=edges(all), top=Math.max(0,y0-24), W=canvas.clientWidth, H=Math.min(y1+24-top,8000), k=2;
636
  const out=document.createElement("canvas"); out.width=W*k; out.height=Math.round(H*k);
637
+ const keep=ctx; ctx=out.getContext("2d"); paper="#fff";
638
  try{ ctx.fillStyle="#fff"; ctx.fillRect(0,0,out.width,out.height); ctx.setTransform(k,0,0,k,0,-top*k); all.forEach(draw); }
639
+ finally{ ctx=keep; paper=PAPER; }
640
  return new Promise(ok=>out.toBlob(ok,"image/png"));
641
  }
642
  async function send(){
 
702
  el.querySelector(".mx-board-back").focus({preventScroll:true,focusVisible:false}); // reachable by keyboard, without a ring on a tap
703
  }
704
  function closeFull(){
705
+ if(!full) return; if(editing) finishMath(true); if(naming) finishAngle(true); const f=full; full=null;
706
  el.classList.remove("full"); el.removeAttribute("role"); el.removeAttribute("aria-modal"); el.remove();
707
  restore(f.saved); f.done?.();
708
  if(layer?.isConnected&&desk()) again();
static/math-sky.js CHANGED
@@ -20,7 +20,7 @@ const esc=s=>String(s??"").replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&
20
  /* ---------- rules ---------- */
21
  const dayKey=(d=new Date())=>`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
22
  const daysBack=(n,from)=>{const d=new Date(from);d.setDate(d.getDate()-n);return d};
23
- function ensure(P){ for(const k of ["run","gold","seen","days","review","missed"]) if(!P[k]||typeof P[k]!=="object") P[k]={}; for(const k of ["comets","slips"]) if(!Array.isArray(P[k])) P[k]=[]; return P; }
24
  function fading(P,id,now=new Date()){
25
  if(!P.done?.[id]) return false;
26
  const t=Date.parse(P.seen?.[id]||P.done[id]);
@@ -88,6 +88,82 @@ function patterns(P,mistakes={},now=new Date()){
88
  return {recurring:recurring.sort(rank).slice(0,PATTERNS_SHOWN),stopped:stopped.sort(rank).slice(0,PATTERNS_SHOWN)};
89
  }
90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  /* a star's look: gold, lit, fading (finished), next, open, locked, off */
92
  function star(P,id,status,isNext){
93
  if(status==="done") return P.gold?.[id]?"gold":fading(P,id)?"fading":"lit";
@@ -350,6 +426,6 @@ function comet(onCatch){
350
  }
351
  const cometCard=c=>`<div class="sky-card"><span class="sym">${esc(c.sym)}</span><b>${esc(c.name)}</b></div>`;
352
 
353
- window.MathlySky={GOAL,slip,patterns,FADE_DAYS,MASTER_RUN,GOLD_RUN,masterNeed,goldNeed,mastered,reviewed,due,COMETS,SPARK,spark,FLAME,owl,praise,dayKey,ensure,correct,wrong,fading,star,today,streak,dayState,month,best,
354
  cometDue,catchComet,shape,labels,mount,burst,burstAt,sound,buzz,soundOn,setSound,toast,celebrate,comet,cometCard};
355
  })();
 
20
  /* ---------- rules ---------- */
21
  const dayKey=(d=new Date())=>`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
22
  const daysBack=(n,from)=>{const d=new Date(from);d.setDate(d.getDate()-n);return d};
23
+ function ensure(P){ for(const k of ["run","gold","seen","days","review","missed","examples"]) if(!P[k]||typeof P[k]!=="object") P[k]={}; for(const k of ["comets","slips"]) if(!Array.isArray(P[k])) P[k]=[]; return P; }
24
  function fading(P,id,now=new Date()){
25
  if(!P.done?.[id]) return false;
26
  const t=Date.parse(P.seen?.[id]||P.done[id]);
 
88
  return {recurring:recurring.sort(rank).slice(0,PATTERNS_SHOWN),stopped:stopped.sort(rank).slice(0,PATTERNS_SHOWN)};
89
  }
90
 
91
+ /* ---------- the class teacher's signals ----------
92
+ Who needs the teacher's attention, worked out from what the report loads for a student: the saved progress, and the
93
+ server's quiz sittings (see class_report.py). The progress is what the student's device reports, so these are prompts
94
+ to look, not grades. A week runs from Sunday. */
95
+ const INACTIVE_DAYS=7, STUCK_SLIPS=3, STUCK_WINDOW_DAYS=14, DROP_RATIO=.5, DROP_MIN_LAST_WEEK=10, LOW_GRADE=55;
96
+ const midnight=d=>new Date(d.getFullYear(),d.getMonth(),d.getDate());
97
+ const keyDate=k=>{ const m=/^(\d{4})-(\d\d)-(\d\d)$/.exec(k); return m?new Date(+m[1],m[2]-1,+m[3]):null; };
98
+ const daysBetween=(a,b)=>Math.round((midnight(b)-midnight(a))/DAY);
99
+ // the latest day the student answered right: from the per-day counts and from when each skill was last practised
100
+ function lastPractice(P){
101
+ const times=[];
102
+ for(const [k,n] of Object.entries(P.days||{})){ const d=keyDate(k); if(d&&n>0) times.push(d.getTime()); }
103
+ for(const v of Object.values(P.seen||{})){ const t=Date.parse(v); if(!isNaN(t)) times.push(t); }
104
+ return times.length?new Date(Math.max(...times)):null;
105
+ }
106
+ // right answers this week (from Sunday) and in the week before
107
+ function weekly(P,now=new Date()){
108
+ const start=midnight(now); start.setDate(start.getDate()-start.getDay());
109
+ const prev=new Date(start); prev.setDate(prev.getDate()-7);
110
+ let thisWeek=0, lastWeek=0;
111
+ for(const [k,n] of Object.entries(P.days||{})){
112
+ const d=keyDate(k); if(!d||!(n>0)) continue;
113
+ if(d>=start) thisWeek+=n; else if(d>=prev) lastWeek+=n;
114
+ }
115
+ return {thisWeek,lastWeek};
116
+ }
117
+ /* The reasons a student needs attention (empty: none). `student`: {signed_in, progress, quiz_sittings}.
118
+ `ctx`: {skills: name by skill id (the class's skills), units: [{id, name}] (the class units with a quiz)}. */
119
+ function attention(student,{skills={},units=[],assignments=[]}={},now=new Date()){
120
+ const P=student.progress, out=[];
121
+ if(!P) return student.signed_in?[{kind:"new",text:"עוד לא התחיל לתרגל"}]:[];
122
+ const last=lastPractice(P);
123
+ if(last){ const n=daysBetween(last,now); if(n>=INACTIVE_DAYS) out.push({kind:"inactive",text:`לא תרגל ${n} ימים`}); }
124
+ const cut=now-STUCK_WINDOW_DAYS*DAY, count={};
125
+ for(const x of P.slips||[]){ const t=Date.parse(x?.at); if(x?.id&&!isNaN(t)&&t>=cut&&t<=now) count[x.id]=(count[x.id]||0)+1; }
126
+ const stuck=Object.keys(count).filter(id=>count[id]>=STUCK_SLIPS&&!P.done?.[id]&&skills[id]).sort((a,b)=>count[b]-count[a]);
127
+ if(stuck.length) out.push({kind:"stuck",skills:stuck,text:`${stuck.length===1?"תקוע במיומנות":"תקוע במיומנויות"}: ${stuck.map(id=>skills[id]).join(", ")}`});
128
+ const w=weekly(P,now);
129
+ if(w.lastWeek>=DROP_MIN_LAST_WEEK&&w.thisWeek<w.lastWeek*DROP_RATIO)
130
+ out.push({kind:"drop",text:`התרגול ירד: ${w.thisWeek} תשובות נכונות השבוע, ${w.lastWeek} בשבוע שעבר`});
131
+ for(const u of units){ // the server's grades only; without them (null) the signal is left out
132
+ const g=(student.quiz_sittings||[]).filter(x=>x.unit_id===u.id&&x.grade!=null).sort((a,b)=>String(a.submitted_at).localeCompare(String(b.submitted_at))).at(-1);
133
+ if(g&&g.grade<LOW_GRADE) out.push({kind:"grade",unit:u.id,text:`ציון נמוך בבוחן ${u.name}: ${g.grade}`});
134
+ }
135
+ for(const a of assignments){ // an assignment past its due date and not finished (left out when a unit quiz cannot be told)
136
+ const st=assignmentState(a,P,student.quiz_sittings,now);
137
+ if(st.overdue&&st.items.every(x=>x.known)) out.push({kind:"assignment",assignment:a.id,text:`משימה באיחור: ${a.title}`});
138
+ }
139
+ return out;
140
+ }
141
+
142
+ /* ---------- assignments (app/assignments.py) ----------
143
+ Whether an assignment's items are done is worked out from what already exists, the same way on the student's home
144
+ screen and in the teacher's report. A skill is done when it is finished, whenever that was. A unit quiz is done when
145
+ the server has a sitting of it handed in after the assignment was made (`sittings`: [{unit_id, submitted_at}], null
146
+ when the server's record could not be read), and a practice exam when one covering all the item's units was taken
147
+ after that (P.quizzes). Dates are Jerusalem days: an item done on the due date is on time. */
148
+ const jerusalemDay=t=>new Date(t).toLocaleDateString("en-CA",{timeZone:"Asia/Jerusalem"}); // YYYY-MM-DD
149
+ function assignmentState(a,P,sittings,now=new Date()){
150
+ const created=Date.parse(a.created_at)||0, due=String(a.due_on);
151
+ const earliest=list=>list.filter(Boolean).sort()[0]||null;
152
+ const items=(a.items||[]).map(item=>{
153
+ let at=null, known=true;
154
+ if(item.kind==="skill") at=P?.done?.[item.id]||null;
155
+ else if(item.kind==="unit_quiz"){
156
+ known=Array.isArray(sittings);
157
+ at=earliest((sittings||[]).filter(x=>x.unit_id===item.unit&&Date.parse(x.submitted_at)>created).map(x=>x.submitted_at));
158
+ }else if(item.kind==="exam")
159
+ at=earliest((P?.quizzes||[]).filter(x=>Date.parse(x.at)>created&&(item.units||[]).every(u=>(x.units||[]).includes(u))).map(x=>x.at));
160
+ return {item,done:!!at,at,known,late:!!at&&jerusalemDay(at)>due};
161
+ });
162
+ const doneCount=items.filter(x=>x.done).length, finished=items.length>0&&doneCount===items.length;
163
+ return {items,doneCount,total:items.length,finished,overdue:!finished&&jerusalemDay(now)>due,
164
+ late:finished&&items.some(x=>x.late),next:items.findIndex(x=>!x.done)};
165
+ }
166
+
167
  /* a star's look: gold, lit, fading (finished), next, open, locked, off */
168
  function star(P,id,status,isNext){
169
  if(status==="done") return P.gold?.[id]?"gold":fading(P,id)?"fading":"lit";
 
426
  }
427
  const cometCard=c=>`<div class="sky-card"><span class="sym">${esc(c.sym)}</span><b>${esc(c.name)}</b></div>`;
428
 
429
+ window.MathlySky={GOAL,slip,patterns,assignmentState,jerusalemDay,attention,weekly,lastPractice,FADE_DAYS,MASTER_RUN,GOLD_RUN,masterNeed,goldNeed,mastered,reviewed,due,COMETS,SPARK,spark,FLAME,owl,praise,dayKey,ensure,correct,wrong,fading,star,today,streak,dayState,month,best,
430
  cometDue,catchComet,shape,labels,mount,burst,burstAt,sound,buzz,soundOn,setSound,toast,celebrate,comet,cometCard};
431
  })();
static/math.css CHANGED
@@ -751,6 +751,26 @@ body{--keyboard-zindex:3000}
751
  .mx-board-mathedit math-field::part(virtual-keyboard-toggle),.mx-board-mathedit math-field::part(menu-toggle){display:none}
752
  .mx-board-mathedit button{flex:none;width:40px;height:40px;border-radius:10px;border:1px solid #C5CBDF;background:#F4F6FC;color:#1B2140;font:700 18px/1 var(--f-body);cursor:pointer}
753
  .mx-board-mathedit button[data-math="ok"]{background:var(--mx-accent);border-color:var(--mx-accent);color:var(--mx-on-accent)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
754
  .mx-board-mathwait{flex:1;color:#5B6694;font:500 14px/1.3 var(--f-body);padding:8px}
755
 
756
  /* the class teacher's report (math.js: classReport) */
@@ -810,3 +830,96 @@ body{--keyboard-zindex:3000}
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)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
751
  .mx-board-mathedit math-field::part(virtual-keyboard-toggle),.mx-board-mathedit math-field::part(menu-toggle){display:none}
752
  .mx-board-mathedit button{flex:none;width:40px;height:40px;border-radius:10px;border:1px solid #C5CBDF;background:#F4F6FC;color:#1B2140;font:700 18px/1 var(--f-body);cursor:pointer}
753
  .mx-board-mathedit button[data-math="ok"]{background:var(--mx-accent);border-color:var(--mx-accent);color:var(--mx-on-accent)}
754
+ /* an angle's words, typed in a box where they will sit */
755
+ .mx-board-angleedit{position:absolute;z-index:2;display:flex;flex-direction:column;gap:4px;padding:4px;background:#fff;border:1px solid #C5CBDF;border-radius:12px;
756
+ box-shadow:0 2px 10px rgb(27 33 64/.2)}
757
+ .mx-board-anglein{width:80px;height:34px;box-sizing:border-box;padding:0 6px;border:1.5px solid #2563D9;border-radius:9px;
758
+ background:#fff;color:#1B2140;font:500 17px/1 "STIX Two Text","Cambria Math","Times New Roman",serif;text-align:center;outline:none}
759
+ .mx-board-angleedit button{width:34px;height:34px;border:1px solid #C5CBDF;border-radius:9px;background:#F4F6FC;color:#1B2140;
760
+ font:500 18px/1 "STIX Two Text","Cambria Math","Times New Roman",serif;cursor:pointer}
761
+ .mx-board-angleedit button:hover{background:#DCE6FF;border-color:#2563D9}
762
+ .mx-board-arcs{display:flex;gap:4px;padding-inline-start:4px;border-inline-start:1px solid #C5CBDF}
763
+ .mx-board-arcs button{display:inline-flex;align-items:center;justify-content:center;padding:0}
764
+ .mx-board-arcs button[aria-pressed="true"]{background:#DCE6FF;border-color:#2563D9}
765
+ .mx-board-anglerow{display:flex;align-items:center;gap:4px}
766
+ .mx-board-anglerow .mx-board-anglein{flex:1}
767
+ .mx-board-anglerow > .mx-board-arcs{border:0;padding:0} /* first in its row: no line before it */
768
+ .mx-board-colors{display:flex;gap:4px;padding-inline-start:4px;border-inline-start:1px solid #C5CBDF}
769
+ .mx-board-colors button{display:inline-flex;align-items:center;justify-content:center;padding:0;background:none}
770
+ .mx-board-colors button::before{content:"";width:18px;height:18px;border-radius:50%;background:var(--dot)}
771
+ .mx-board-colors button[aria-pressed="true"]{background:none;border-color:transparent}
772
+ .mx-board-colors button[aria-pressed="true"]::before{box-shadow:0 0 0 2px #fff,0 0 0 4px var(--dot)}
773
+ .mx-board-anglein::placeholder{color:#8A93B8}
774
  .mx-board-mathwait{flex:1;color:#5B6694;font:500 14px/1.3 var(--f-body);padding:8px}
775
 
776
  /* the class teacher's report (math.js: classReport) */
 
830
  .mx-error-details p{margin:8px 0 0}
831
  .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}
832
  .mx-device{font-weight:400;color:var(--muted)}
833
+ .mx-report-attn{border:1px solid var(--sumac);border-radius:14px;background:var(--sumac-bg);padding:14px 16px}
834
+ .mx-report-attn h4{margin:0 0 6px;color:var(--sumac)}
835
+ .mx-report-open{background:none;border:0;padding:0;margin:0;color:inherit;font:inherit;cursor:pointer;text-decoration:underline}
836
+ .mx-report-week{color:var(--muted);font-size:14px}
837
+ .mx-report-bar{display:flex;flex-wrap:wrap;align-items:center;gap:8px 14px}
838
+ .mx-report-views{display:flex;gap:6px}
839
+ .mx-report-views [aria-pressed="true"]{border-color:var(--mx-accent);color:var(--mx-accent)}
840
+ .mx-report-bar select{font:inherit;color:inherit;background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:6px 8px;max-width:100%}
841
+ .mx-report,.mx-grid-wrap{min-width:0;max-width:100%}
842
+ .mx-grid-wrap{overflow-x:auto;border:1px solid var(--line);border-radius:14px;background:var(--surface)}
843
+ .mx-grid{border-collapse:separate;border-spacing:0;font-size:13px;min-width:max-content}
844
+ .mx-grid th,.mx-grid td{padding:6px;text-align:center;border-bottom:1px solid var(--line);font-weight:400}
845
+ .mx-grid .mx-grid-name{position:sticky;inset-inline-start:0;z-index:1;background:var(--surface);text-align:start;white-space:nowrap;border-inline-end:1px solid var(--line)}
846
+ .mx-grid thead th{vertical-align:bottom}
847
+ .mx-grid-unit{min-width:72px;white-space:normal}
848
+ .mx-grid-skill{width:34px;padding-inline:3px}
849
+ .mx-grid-skill span{display:inline-block;writing-mode:vertical-rl;transform:rotate(180deg);max-height:130px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
850
+ .mx-grid-skill.hard,.mx-grid-sum.hard{color:var(--sumac);font-weight:700}
851
+ .mx-grid-num{font-weight:700}
852
+ .mx-grid-num.good{color:var(--ok)}.mx-grid-num.mid{color:var(--amber)}.mx-grid-num.low{color:var(--sumac)}
853
+ .mx-grid-sum{color:var(--muted)}
854
+ .mx-grid tfoot th,.mx-grid tfoot td{border-bottom:0}
855
+ .mx-cell{display:block;width:22px;height:22px;margin:0 auto;border-radius:50%;border:2px solid var(--line)}
856
+ .mx-cell.trying{border-color:var(--amber);background:var(--amber-bg)}
857
+ .mx-cell.lit{border-color:var(--ok);background:var(--ok)}
858
+ .mx-cell.fading{border:2px dashed var(--ok);background:var(--ok-bg)}
859
+ .mx-cell.gold{border-color:var(--gold-deep);background:var(--gold)}
860
+ .mx-teacher-note{border:1px solid var(--aurora);border-radius:12px;background:var(--surface-2);padding:10px 14px;margin:8px 0}
861
+ .mx-teacher-note h4{margin:0 0 4px;color:var(--aurora)}
862
+ .mx-teacher-note p{margin:0}
863
+ .mx-ask{margin:8px 0}
864
+ .mx-ask summary{cursor:pointer;color:var(--muted)}
865
+ .mx-ask textarea{display:block;width:100%;margin:8px 0;font:inherit;color:inherit;background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:8px;box-sizing:border-box}
866
+ .mx-ask-wait{color:var(--muted)}
867
+ .mx-ask-error{color:var(--sumac)}
868
+ .mx-teacher-comment{display:flex;flex-direction:column;gap:4px;width:100%;margin-top:10px;padding:12px 14px;border:1px solid var(--aurora);border-inline-start:4px solid var(--aurora);border-radius:18px;background:var(--surface);color:var(--ink);font:inherit;text-align:start;cursor:pointer}
869
+ .mx-teacher-comment b{color:var(--aurora)}
870
+ .mx-teacher-comment span{color:var(--muted);font-size:13px;overflow-wrap:anywhere}
871
+ .mx-tr{display:flex;flex-direction:column;gap:12px}
872
+ .mx-tr h5{margin:0 0 6px;font-size:15px;color:var(--mx-accent)}
873
+ .mx-tr p{margin:0}
874
+ .mx-tr-img{max-width:100%;border-radius:12px;border:1px solid var(--line)}
875
+ .mx-tr-request{border:1px solid var(--amber);border-radius:12px;background:var(--amber-bg);padding:10px 14px}
876
+ .mx-tr-request p{margin:4px 0}
877
+ .mx-tr-form{display:flex;flex-direction:column;gap:10px;border-top:1px solid var(--line);padding-top:12px}
878
+ .mx-tr-form label{display:flex;flex-direction:column;gap:4px;font-size:14px;color:var(--muted)}
879
+ .mx-tr-form input,.mx-tr-form textarea{font:inherit;color:var(--ink);background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:8px;box-sizing:border-box;width:100%}
880
+ .mx-tr-form input[type=number]{max-width:9ch}
881
+ .mx-helpbtn{border:1px solid var(--line);background:none;color:var(--muted);font-weight:700;font-size:18px;line-height:1;font-family:inherit;border-radius:50%;width:34px;height:34px;padding:0;cursor:pointer}
882
+ .mx-help-wrap{position:static}
883
+ .mx-help-wrap .mx-scrim{z-index:63}
884
+ .mx-help{z-index:64}
885
+ .mx-example{border:1px solid var(--aurora);border-radius:14px;background:var(--surface-2);padding:12px 14px;margin:10px 0}
886
+ .mx-example h4{margin:0 0 6px;color:var(--aurora)}
887
+ .mx-example-q{font-weight:700}
888
+ .mx-example-steps{margin:8px 0;padding-inline-start:22px;display:flex;flex-direction:column;gap:10px}
889
+ .mx-example-steps li span{display:block;color:var(--muted);font-size:14px}
890
+ .mx-example-answer{margin:8px 0}
891
+ .mx-res-visible{flex-direction:row!important;align-items:center;gap:8px}
892
+ .mx-res-vis{font-size:12px;color:var(--muted)}.mx-res-vis.on{color:var(--ok)}
893
+ .mx-res-acts{display:flex;flex-direction:column;gap:6px;align-items:flex-end}
894
+ .mx-skill-res h4{margin:0 0 8px}
895
+ .mx-sres{display:flex;flex-wrap:wrap;gap:12px}
896
+ .mx-sres-item{margin:0;width:calc(50% - 6px);display:flex;flex-direction:column;gap:4px}
897
+ .mx-sres-img{border:1px solid var(--line);border-radius:10px;background:var(--surface-2);padding:0;cursor:zoom-in;overflow:hidden}
898
+ .mx-sres-img img{display:block;width:100%;height:110px;object-fit:cover}
899
+ .mx-sres-item figcaption{font-size:13px;color:var(--muted)}
900
+ .mx-sres-item.zoom{width:100%}.mx-sres-item.zoom .mx-sres-img{cursor:zoom-out}.mx-sres-item.zoom img{height:auto;max-height:70vh;object-fit:contain}
901
+ .mx-sres-open{display:block;margin:6px auto 0;border:1px solid var(--line);border-radius:999px;background:transparent;color:var(--muted);font:inherit;font-size:13px;padding:6px 14px;cursor:pointer}
902
+ .mx-asgs{margin-top:10px;display:flex;flex-direction:column;gap:10px}
903
+ .mx-asgs h2{margin:0;font-size:18px}
904
+ .mx-asg{border:1px solid var(--line);border-inline-start:4px solid var(--aurora);border-radius:18px;background:var(--surface);padding:12px 14px;display:flex;flex-direction:column;gap:6px}
905
+ .mx-asg.late{border-inline-start-color:var(--sumac)}
906
+ .mx-asg-head{display:flex;justify-content:space-between;gap:10px;align-items:baseline}
907
+ .mx-asg-due{color:var(--muted);font-size:13px;white-space:nowrap}.mx-asg.late .mx-asg-due{color:var(--sumac);font-weight:700}
908
+ .mx-asg-note,.mx-asg-count{margin:0;color:var(--muted);font-size:14px}.mx-asg-count b{color:var(--ink)}
909
+ .mx-asg-items{margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:4px;font-size:14px}
910
+ .mx-asg-items li.done{color:var(--muted)}.mx-asg-items li.done span:first-child{color:var(--ok)}
911
+ .mx-asg-done summary{cursor:pointer;color:var(--muted)}.mx-asg-done ul{margin:6px 0 0;padding-inline-start:18px}
912
+ .mx-asg-pin{position:absolute;top:-6px;inset-inline-end:-6px;font-size:14px;line-height:1;pointer-events:none}
913
+ .mx-asg-t{border:1px solid var(--line);border-radius:14px;background:var(--surface);padding:12px 14px;display:flex;flex-direction:column;gap:6px}
914
+ .mx-asg-t.past{border-color:var(--amber)}
915
+ .mx-asg-who summary{cursor:pointer;color:var(--mx-accent)}
916
+ .mx-asg-cell.ok{color:var(--ok)}.mx-asg-cell.late{color:var(--amber)}.mx-asg-cell.none,.mx-asg-cell.unknown{color:var(--muted)}
917
+ .mx-asg-form{display:flex;flex-direction:column;gap:10px;border:1px solid var(--line);border-radius:14px;background:var(--surface);padding:14px}
918
+ .mx-asg-form h4{margin:0}
919
+ .mx-asg-form label{display:flex;flex-direction:column;gap:4px;font-size:14px;color:var(--muted)}
920
+ .mx-asg-form input[type=text],.mx-asg-form input:not([type]),.mx-asg-form input[type=date],.mx-asg-form textarea{font:inherit;color:var(--ink);background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:8px;box-sizing:border-box;width:100%}
921
+ .mx-asg-form fieldset{border:1px solid var(--line);border-radius:10px;padding:8px 12px;display:flex;flex-direction:column;gap:6px}
922
+ .mx-asg-form legend{color:var(--mx-accent);font-size:14px;padding:0 6px}
923
+ .mx-asg-form details>summary{cursor:pointer}
924
+ .mx-asg-checks{display:flex;flex-direction:column;gap:4px;padding:6px 0}
925
+ .mx-asg-checks label{flex-direction:row;align-items:center;gap:8px;color:var(--ink)}
static/math.js CHANGED
@@ -47,6 +47,7 @@ function mergeProgress(a,b){
47
  const joined=(k,id)=>{ const by=new Map(); for(const x of list(k)) by.set(id(x),x); return [...by.values()]; };
48
  for(const k of new Set([...Object.keys(old),...Object.keys(neu)])) if(isObj(old[k])&&isObj(neu[k])) each(k); // newer per key
49
  each("done",earliest); each("gold",earliest); each("seen",latest); each("tries",most); each("days",most);
 
50
  each("solved",(x,y)=>[...new Set([...(Array.isArray(x)?x:[]),...(Array.isArray(y)?y:[])])].sort((p,q)=>p-q));
51
  out.comets=[...new Set(list("comets"))];
52
  out.slips=joined("slips",s=>JSON.stringify([s?.at,s?.id,s?.q,s?.m])).sort((x,y)=>String(x?.at).localeCompare(String(y?.at))).slice(-500);
@@ -72,12 +73,15 @@ document.addEventListener("visibilitychange",async()=>{
72
  if(!synced()||!G) return;
73
  if(document.visibilityState==="hidden"){ if(pushTimer) pushProgress(); return; }
74
  if(cur) return; // a question or exam is open: take the other device's work next time
 
 
75
  const remote=await pullProgress(); if(!remote||cur) return;
76
  P=Sky.ensure(mergeProgress(P,remote)); try{ localStorage.setItem(key(),JSON.stringify(P)); }catch{}
77
  if(ACTIVE_SUBJECT==="מתמטיקה"&&!root?.hidden) draw();
78
  });
79
 
80
  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="";
 
81
  let nav=null, FIRST=true, COMBO=0, REV=null, QZ=null, UQ=null; // UQ: the unit quiz on the sheet (see unit quiz below) // QZ: the practice exam in progress (see quiz below) // the tab bar; whether the shown question is still at its first try; first-try answers in a row
82
  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())
83
  let advanceTimer=null;
@@ -151,7 +155,12 @@ async function show(){
151
  nav=document.createElement("nav"); nav.className="mx-nav"; nav.setAttribute("aria-label","ניווט"); nav.hidden=true;
152
  root.append(path,sheet,picker,resPicker,nav); document.body.append(root); window.MathlySky.mount(root);
153
  root.addEventListener("click",onClick);
154
- document.addEventListener("keydown",e=>{ if(e.key==="Escape"&&cur) QZ?.items?quizQuit():closeSheet(); }); // an exam asks before it is left
 
 
 
 
 
155
  }
156
  root.hidden=false;
157
  if(!G){
@@ -165,6 +174,8 @@ async function show(){
165
  if(!P.set||typeof P.set!=="object") P.set={}; SHOWN=new Set(shownUnits().map(u=>u.id));
166
  hasExams()?drawSubjectPicker():selectSubject("מתמטיקה"); // no exams yet: straight to math, science is a tap away
167
  if(CFG.aiReview) aqCount();
 
 
168
  window.MathlyAnswers?.configure({api,account:CFG.userId,enabled:CFG.answerScans,root,queued:CFG.aiDirect===false,onGraded:({questionId,grading})=>{
169
  if(grading.result.verdict!=="correct"||P.done[questionId])return;
170
  const unitWas=unitDone(unitOf(questionId));
@@ -349,15 +360,15 @@ function orbitRing(sts){
349
  return `<svg class="mx-ring" viewBox="0 0 100 100" aria-hidden="true">${back}${arcs}</svg>`;
350
  }
351
  function pathView(){
352
- const nxt=next(), cls=MY_CLASS?` · כיתת ${esc(MY_CLASS.name)}`:"", teach=can("students.view");
353
  const fix=new Set(myPatterns().recurring.map(x=>x.practise)); // skills that a recurring mistake sends back to
354
  const units=mineUnits().map(u=>{
355
  const ids=unitIds(u), act=ids.filter(live), lit=act.filter(id=>P.done[id]).length, n=ids.length, full=unitDone(u);
356
  const sts=ids.map(id=>Sky.star(P,id,status(id),id===nxt));
357
- const steps=ids.map((id,i)=>{ const st=sts[i], tag=st==="next"?"ממשיכים כאן":fix.has(id)?"לחזק":st==="fading"?"דועך":"";
358
  const t=(-90+360*i/n)*Math.PI/180, dx=Math.cos(t), dy=Math.sin(t), up=dy<-.3;
359
  return `<div class="mx-step mx-orb u-${u.id}${up?" up":""}" style="--dx:${dx.toFixed(3)};--dy:${dy.toFixed(3)}">
360
- <button class="mx-node mx-star st-${st}${fix.has(id)?" fix":""}" data-id="${id}" aria-label="${i+1}. ${esc(plain(BY[id].name))}: ${STATE_NAME[st]}${fix.has(id)?", כדאי לחזק":""}">${Sky.spark()}<span class="mx-num" aria-hidden="true">${i+1}</span>${st==="next"?`<span class="mx-perch">${Sky.owl(34)}</span>`:""}</button>
361
  <span class="mx-lbl l-${st}${fix.has(id)?" l-fix":""}">${fmt(BY[id].short)}${tag?`<em>${tag}</em>`:""}</span></div>`; }).join("");
362
  const res=teach?` data-act="resources" data-unit="${u.id}"`:""; // a teacher opens the lesson's resources from its title
363
  const pct=act.length?Math.round(100*lit/act.length):0;
@@ -368,7 +379,7 @@ function pathView(){
368
  ${steps}</div>
369
  ${teach?`<button class="mx-res-open"${res} aria-label="משאבי השיעור ${esc(u.name)}">📷 משאבים</button>`:""}</section>${quizStep(u)}`;
370
  }).join("");
371
- return `${topBar()}${daily()}${reviewCard()}${patternsCard()}${examSoon()}${quizCard()}<div class="mx-pghead"><h2>המסלול שלי</h2><p class="mx-sub">אלגברה וגאומטריה כיתה ט׳ · 5 יח״ל${cls}</p></div>
372
  <div class="mx-trail">
373
  <div class="mx-start"><span class="mx-flag">${Sky.owl(40)}</span><b>התחלה</b></div>${units}
374
  <div class="mx-finish" aria-hidden="true">${Sky.spark()}<b>סוף המסלול</b></div></div>
@@ -395,6 +406,7 @@ function draw(){
395
  path.innerHTML=VIEW==="sky"?collectionView():pathView();
396
  nav.hidden=false;
397
  nav.innerHTML=`<div>${TABS.map(([v,label,icon])=>`<button data-view="${v}"${VIEW===v?' aria-current="page"':""}>${icon}<span>${label}</span></button>`).join("")}</div>`;
 
398
  }
399
  function setView(view,unit){
400
  VIEW=view; draw(); root.scrollTop=0;
@@ -437,7 +449,121 @@ function rewards(id,{r,lit,unitWas}){
437
  function chips(ids){
438
  return ids.length?ids.map(id=>`<button class="mx-chip u-${BY[id].unit}${P.done[id]?" is-done":""}" data-id="${id}">${P.done[id]?"✓ ":""}${fmt(BY[id].short)}</button>`).join(""):'<em>אין</em>';
439
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
440
  function open(id){
 
441
  stopPhone();
442
  cancelAdvance();
443
  const s=BY[id], st=status(id), unit=G.units.find(u=>u.id===s.unit), all=QS(s);
@@ -451,12 +577,13 @@ function open(id){
451
  <div class="mx-sheet u-${s.unit}" role="dialog" aria-modal="true" aria-labelledby="mxTitle">
452
  <div class="mx-shtop"><div><span class="mx-uchip">${esc(unit.name)}</span>${s.core?' <span class="mx-core">ליבה 5 יח״ל</span>':""}
453
  <h3 id="mxTitle">${fmt(s.name)}</h3><small class="mx-src">${s.month?`בפריסה: ${esc(s.month)}`:esc(unit.source)}${st==="done"?" · הושלמה ✓":""}</small></div>
454
- <div class="mx-shacts">${window.MathlyBoard?.button()||""}<button class="mx-x" data-act="close" aria-label="סגירה">✕</button></div></div>
455
  <p>${fmt(s.description)}</p>
456
  <div class="mx-q">${n>1?`<div class="mx-qprog"><span>שאלה ${r.indexOf(curQ)+1} מתוך ${n}</span><span class="mx-qdots" aria-label="נפתרו ${gotN} מתוך ${n}">${r.map(i=>`<i class="${got.has(i)?"ok":""}${i===curQ?" now":""}"></i>`).join("")}</span></div>`:""}
457
  <p>${fmt(q.question)}</p>${figures(q)}
458
  ${st==="off"?'<p class="mx-note mx-off">השאלה הזו סגורה כרגע לתשובות. היא לא נדרשת כדי להתקדם במסלול.</p>':openEnded?'<div class="mx-answer"></div>':choiceButtons(q)}
459
  <div class="mx-fb" aria-live="polite"></div></div>
 
460
  <div class="mx-sec"><h4>דורשת</h4><div class="mx-chips">${chips(s.prereqs)}</div></div>
461
  <div class="mx-sec"><h4>פותחת</h4><div class="mx-chips">${chips(POST[id])}</div></div>
462
  </div>`;
@@ -464,8 +591,9 @@ function open(id){
464
  window.MathlyBoard?.mount(sheet,`${id}:${curQ}`,{question:()=>sheet.querySelector(".mx-q")}); // the sketch board beside the exercise, when the student has it on
465
  sheet.querySelector(".mx-x").focus({preventScroll:true});
466
  if(openEnded&&st!=="off")window.MathlyAnswers?.mountQuestion(sheet.querySelector(".mx-answer"),id,s.question_version);
 
467
  }
468
- function closeSheet(){ stopPhone(); dropResources(); dropQueue(); cancelAdvance(); REV=null; stopQuizClock(); QZ=null; stopUnitQuiz(); if(sheet)sheet.hidden=true; cur=null; back?.focus?.({preventScroll:true}); back=null; }
469
 
470
  // counts down, then opens `target` (a skill id); stops if the student moved on in the meantime
471
  function autoAdvance(id,target){
@@ -498,7 +626,7 @@ function answer(btn){
498
  // done once every question is solved and the last answers were right at the first try
499
  const run=FIRST?(P.run[id]||0)+1:0, need=Sky.masterNeed(n);
500
  const first=!P.done[id]&&gotN>=n&&run>=need, unitWas=unitDone(unitOf(id));
501
- if(first){ P.done[id]=new Date().toISOString(); Sky.mastered(P,id); }
502
  COMBO=FIRST?COMBO+1:0;
503
  const persisted=!FIRST;
504
  const r=Sky.correct(P,id,{first:FIRST,n,finished:!!P.done[id]}); FIRST=false;
@@ -531,7 +659,11 @@ function answer(btn){
531
  // with a list of questions, a missed question comes back later, shuffled; the right answer is not shown,
532
  // so solving it then shows understanding, not memory
533
  if(n>1) buttons.forEach(b=>b.disabled=true); else btn.disabled=true;
 
 
 
534
  fb.innerHTML=`<div class="mx-bad"><small class="mx-small">טעות נפוצה, וטוב שהיא עולה עכשיו</small><b>${fmt(M.name)}</b><span>${fmt(M.tip)}</span>
 
535
  ${n>1?`<span class="mx-small">השאלה תחזור אחרי שאר השאלות, ותוכלו לנסות אותה שוב.</span><button class="mx-btn solid" data-id="${id}">לשאלה הבאה</button>`:""}
536
  ${to?`<button class="mx-btn" data-id="${to}">לחזק קודם את: ${fmt(BY[to].short)}</button>`:n>1?"":"<span class=\"mx-small\">אפשר לבחור תשובה אחרת.</span>"}
537
  ${patternNote(m,[id,to])}</div>`;
@@ -675,8 +807,13 @@ function quizCard(){
675
  <span>${QUIZ_SIZE} שאלות מ־${on.size} יחידות, מעורבבות, כמו במבחן. המשוב מגיע בסוף, עם תוכנית: מה לחזק עד המבחן.</span>
676
  ${last?`<span>בפעם הקודמת: ${last.right} מתוך ${last.n}</span>`:""}<span class="mx-dgo">להתחיל ‹</span></button>`;
677
  }
678
- function quizSetup(){
679
- const {exam,offered,on}=quizUnits();
 
 
 
 
 
680
  if(!cur) back=document.activeElement;
681
  cur="quiz"; QZ={setup:true,on,offered};
682
  sheet.innerHTML=`<div class="mx-scrim" data-act="close"></div>
@@ -684,7 +821,7 @@ function quizSetup(){
684
  <div class="mx-shtop"><div><span class="mx-uchip">מבחן תרגול</span><h3 id="mxTitle">${exam?`לקראת המבחן ב${esc(exam.subject)} ${inDays(exam.days)}`:"מבחן תרגול"}</h3>
685
  <small class="mx-src">${QUIZ_SIZE} שאלות · זמן מומלץ: כ־${Math.round(QUIZ_SIZE*QUIZ_MIN_PER_Q/5)*5} דקות, בלי לחץ: השעון רק מראה.</small></div>
686
  <button class="mx-x" data-act="close" aria-label="סגירה">✕</button></div>
687
- <p>אילו יחידות במבחן? סימנו את מה שנלמד עד ${exam?"המבחן":"עכשיו"}. אפשר לשנות.</p>
688
  <div class="mx-quiz-units">${offered.map(({u})=>`<button class="mx-btn" data-quiz-unit="${u.id}" aria-pressed="${on.has(u.id)}">${esc(u.name)}</button>`).join("")}</div>
689
  <p class="mx-small">השאלות מעורבבות ושם הנושא לא מופיע: לזהות איזו דרך מתאימה זה חלק מהמבחן. אין משוב באמצע, כמו במבחן אמיתי.</p>
690
  <button class="mx-btn solid" data-act="quiz-start"${on.size?"":" disabled"}>להתחיל</button></div>`;
@@ -1000,6 +1137,7 @@ async function uqResult(uid,h,check=false){
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=[];
@@ -1025,6 +1163,7 @@ async function uqResult(uid,h,check=false){
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?"הבדיקה האוטומטית אינה זמינה כרגע":"עוד לא נבדקה"); }
@@ -1032,8 +1171,10 @@ async function uqResult(uid,h,check=false){
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]}">`:""}
1034
  <p class="mx-uq-verdict ${a?.verdict||"none"}">${esc(say)}</p>
1035
- ${a?.feedback?`<p>${fmtLoose(a.feedback)}</p>`:""}${a?.next_step?`<p><b>הצעד הבא: </b>${fmtLoose(a.next_step)}</p>`:""}
 
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;
@@ -1056,6 +1197,21 @@ function onClick(e){
1056
  if(t.dataset.quizUnit) return quizToggle(t.dataset.quizUnit);
1057
  if(t.dataset.quizGo!=null) return quizGo(+t.dataset.quizGo);
1058
  if(t.dataset.uquiz) return uqIntro(t.dataset.uquiz); // a sitting in progress opens straight on its paper
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1059
  if(t.dataset.uqResult){ const [uid,h,check]=t.dataset.uqResult.split(":"); return uqResult(uid,+h,check==="check"); }
1060
  if(t.dataset.uqPhoto!=null){ UQ.pick=+t.dataset.uqPhoto; return sheet.querySelector("[data-uq-file]").click(); }
1061
  if(t.dataset.uqPhone!=null) return uqPhone(+t.dataset.uqPhone);
@@ -1068,9 +1224,12 @@ function onClick(e){
1068
  if(t.dataset.manageMode) return showManageMode(t.dataset.manageMode);
1069
  if(t.dataset.classAction) return manageClassAction(t.dataset.classAction,t.dataset.classId);
1070
  if(t.dataset.reportClass!=null) return classReport(t.dataset.reportClass);
 
 
1071
  if(t.dataset.aqSend) return aqSend(t.dataset.aqSend);
1072
  if(t.dataset.aqDecline) return aqDecline(t.dataset.aqDecline);
1073
  switch(t.dataset.act){
 
1074
  case "manage": return manageClasses();
1075
  case "class-report": return classReport();
1076
  case "ai-queue": return aiQueue();
@@ -1099,6 +1258,8 @@ function onClick(e){
1099
  case "resources": return openResources(t.dataset.unit);
1100
  case "res-take": case "res-retake": return resPicker.click();
1101
  case "res-cancel": RES.photo=null; RES.msg=""; return drawResources();
 
 
1102
  case "res-delete": return deleteResource(t.dataset.res);
1103
  case "photo": case "retake": return picker.click();
1104
  case "hint": PH.shown++; return drawPhoto();
@@ -1469,12 +1630,16 @@ function reportOf(student,units){
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;
1476
  if(!cur) back=document.activeElement;
1477
- closeSheetQuietly(); cur="report"; REPORT={id:classId,data:null};
1478
  const tabs=mine.length>1?`<div class="mx-report-tabs">${mine.map(row=>`<button class="mx-btn" data-report-class="${esc(row.id)}" aria-pressed="${String(row.id)===String(classId)}">${esc(row.name)}</button>`).join("")}</div>`:"";
1479
  sheet.innerHTML=`<div class="mx-scrim"></div>
1480
  <div class="mx-sheet mx-manage-sheet mx-report-sheet" role="dialog" aria-modal="true" aria-labelledby="mxTitle">
@@ -1489,51 +1654,290 @@ async function classReport(id){
1489
  REPORT.data=data; drawReport();
1490
  }catch(e){ if(cur==="report") sheet.querySelector(".mx-manage-state").textContent=e.message; }
1491
  }
 
 
 
 
 
1492
  function closeSheetQuietly(){ const b=back; closeSheet(); back=b; }
 
 
 
 
 
 
 
 
 
 
 
 
1493
  function drawReport(){
1494
  const {class:row,students}=REPORT.data, ids=new Set(row.lessons||[]);
1495
  const units=G.units.filter(u=>ids.has(u.id)&&unitIds(u).some(live));
1496
- const all=students.map(s=>({s,r:reportOf(s,units)}));
 
 
1497
  sheet.querySelector("#mxTitle").textContent=`הכיתה שלי: ${row.name}`;
1498
  sheet.querySelector(".mx-manage-state").textContent=`${students.length} תלמידים · ${all.filter(x=>x.r).length} התחילו לתרגל`;
1499
  // the mistakes that keep coming back for the most students
1500
  const common={}; all.forEach(({s,r})=>r?.recurring.forEach(x=>(common[x.m]||=[]).push(s.email)));
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})=>{
1508
- if(!r) return `<details class="mx-report-student"><summary><b dir="ltr">${esc(name(s.email))}</b><span class="mx-small">${s.signed_in?"עוד לא התחיל לתרגל":"עוד לא נכנס לחשבון"}</span></summary><p class="mx-small" dir="ltr">${esc(s.email)}</p></details>`;
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=`
 
 
 
 
 
 
 
 
 
 
 
1528
  ${top.length?`<section class="mx-report-common"><h4>קשיים נפוצים בכיתה</h4><ul class="mx-report-list">${top.map(([m,who])=>`<li><b>${fmt(G.mistakes[m].name)}</b> <small>${who.length} תלמידים: ${who.map(e=>esc(name(e))).join(", ")}</small></li>`).join("")}</ul></section>`:""}
1529
- <h4>תלמידים</h4>${students.length?all.map(card).join(""):'<p class="mx-note">אין עדיין תלמידים בכיתה.</p>'}`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1530
  }
1531
 
1532
  /* ---------- the teacher's AI queue (app/ai_queue.py) ----------
1533
  A student does not call the AI. Their answer to check (or a photographed problem) waits here for their class's
1534
  teacher, who looks at the photo and sends it to the AI or declines it with a note. The student's page shows the
1535
  result. A manager sees every class's requests. */
1536
- let AQ=null, AQ_OPEN=null; // AQ: the queue on the sheet; AQ_OPEN: how many requests wait (for the header button)
1537
  const AQ_KIND={photo:"📷 תרגיל לעזרה",grading:"📝 בדיקת תשובה"};
1538
  const AQ_STATE={pending:"ממתינה",sending:"נשלחת…",done:"נשלחה ל-AI",failed:"השליחה נכשלה",declined:"נדחתה",cancelled:"בוטלה"};
1539
  const AQ_ERR={grading_failed:"הבדיקה נכשלה. אפשר לנסות שוב.",grading_unavailable:"בדיקה אוטומטית אינה זמינה כרגע באתר.",open_ended_locked:"שאלות פתוחות נעולות כרגע.",
@@ -1547,23 +1951,24 @@ function aqError(error){
1547
  }
1548
  const AQ_VERDICT={correct:"נכונה",partial:"נכונה חלקית",incorrect:"שגויה",unreadable:"לא קריאה",needs_review:"דורשת בדיקה שלכם"};
1549
  const aqWaiting=r=>["pending","sending","failed"].includes(r.state);
1550
- const aqLabel=()=>`תור AI${AQ_OPEN?` <span class="mx-aq-badge">${AQ_OPEN}</span>`:""}`;
1551
- function aqBadge(rows){
1552
- AQ_OPEN=rows.filter(r=>r.state==="pending").length;
1553
- document.querySelectorAll('[data-act="ai-queue"]').forEach(b=>{ if(!b.closest(".mx-sheet")) b.innerHTML=aqLabel(); });
1554
- }
1555
  async function aqCount(){
1556
  try{ const r=await api("/api/ai-queue",{cache:"no-store"}); if(r.ok) aqBadge((await r.json()).requests||[]); }catch{}
 
1557
  }
1558
  function dropQueue(){ if(AQ) Object.values(AQ.urls).forEach(u=>u&&URL.revokeObjectURL(u)); AQ=null; }
1559
  async function aiQueue(){
1560
- if(cur!=="aiqueue"){ if(!cur) back=document.activeElement; closeSheetQuietly(); cur="aiqueue"; AQ={rows:null,urls:{},busy:new Set()}; }
1561
  const mine=AQ;
1562
  sheet.innerHTML=`<div class="mx-scrim"></div>
1563
  <div class="mx-sheet mx-manage-sheet mx-aq-sheet" role="dialog" aria-modal="true" aria-labelledby="mxTitle">
1564
  <div class="mx-manage-head"><h3 id="mxTitle">תור AI</h3><span class="mx-manage-spacer"></span>
1565
  <button class="mx-btn" data-act="aq-all" hidden>שליחת כל הממתינות</button><button class="mx-btn" data-act="ai-queue">רענון</button><button class="mx-x" data-act="close" aria-label="סגירה">✕</button></div>
1566
- <p class="mx-small">בקשות התלמידים מחכות כאן. הסתכלו בתמונה, ואז שלחו אותה ל-AI או דחו אותה. התלמיד רואה את התוצאה בעמוד שלו.</p>
 
1567
  <p class="mx-manage-state" aria-live="polite">טוענים את הבקשות…</p><div class="mx-aq"></div></div>`;
1568
  sheet.hidden=false; sheet.querySelector(".mx-x").focus({preventScroll:true});
1569
  try{
@@ -1572,14 +1977,27 @@ async function aiQueue(){
1572
  const d=await r.json(); if(AQ!==mine) return;
1573
  mine.rows=d.requests||[]; drawQueue();
1574
  }catch(e){ if(AQ===mine) sheet.querySelector(".mx-manage-state").textContent=e.message; }
 
 
 
 
 
1575
  }
 
1576
  function aqResult(r){
1577
  const x=r.result||{};
1578
  if(r.kind==="photo") return x.readable===false?"ה-AI לא הצליח לקרוא את התרגיל.":`התשובה שה-AI מצא: ${fmtLoose(x.answer||"")}`;
1579
  return `תוצאה: ${esc(AQ_VERDICT[x.verdict]||"")}${x.score==null?"":` · ${esc(x.score)}/100`}`;
1580
  }
1581
  function drawQueue(){
1582
- const box=sheet.querySelector(".mx-aq"); if(!box||!AQ?.rows) return;
 
 
 
 
 
 
 
1583
  const open=AQ.rows.filter(aqWaiting), rest=AQ.rows.filter(r=>!aqWaiting(r));
1584
  aqBadge(AQ.rows);
1585
  sheet.querySelector(".mx-manage-state").textContent=open.length?(open.length===1?"בקשה אחת ממתינה":`${open.length} בקשות ממתינות`):"אין בקשות ממתינות.";
@@ -1594,12 +2012,27 @@ function drawQueue(){
1594
  ${r.state==="failed"&&r.error?aqError(r.error):""}
1595
  ${r.state==="declined"&&r.note?`<p class="mx-small">ההערה שלכם: ${esc(r.note)}</p>`:""}
1596
  ${r.state==="done"?`<p class="mx-small">${aqResult(r)}</p>`:""}
 
1597
  ${act?`<div class="mx-row"><button class="mx-btn solid" data-aq-send="${esc(r.id)}"${busy?" disabled":""}>${r.state==="failed"?"שליחה חוזרת ל-AI":"שליחה ל-AI"}</button>
1598
  <button class="mx-btn" data-aq-decline="${esc(r.id)}"${busy?" disabled":""}>דחייה</button></div>`:""}</article>`;
1599
  };
1600
  box.innerHTML=`${open.map(card).join("")}${rest.length?`<h4 class="mx-aq-done">טופלו ביממה האחרונה</h4>${rest.map(card).join("")}`:""}`;
1601
  open.forEach(r=>aqImage(r.id));
1602
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1603
  async function aqImage(id){
1604
  const mine=AQ, show=url=>{ const img=sheet.querySelector(`[data-aq-img="${CSS.escape(id)}"]`); if(img){ img.src=url; img.hidden=false; } };
1605
  if(mine.urls[id]) return show(mine.urls[id]);
@@ -1703,6 +2136,64 @@ function drawPhoto(){
1703
  if(PH.fresh){ PH.fresh=false; sheet.querySelector(".mx-x").focus({preventScroll:true}); }
1704
  }
1705
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1706
  /* Lesson resources (app/unit_resources.py). A teacher taps a unit's title on the path, photographs an exercise or an
1707
  explanation, says what it shows (a new exercise, a theory explanation, or other words) and, if they like, which of
1708
  the unit's topics it belongs to. Images come through the API with the teacher's sign-in, so each is a blob URL. */
@@ -1743,6 +2234,8 @@ function resourceForm(){
1743
  <input name="note" maxlength="200" autocomplete="off" placeholder="כתבו מה יש בתמונה" aria-label="תיאור חופשי של התמונה" hidden></fieldset>
1744
  <label>נושא <small>(לא חובה)</small><select name="skill"><option value="">ללא נושא מסוים</option>
1745
  ${skills.map(s=>`<option value="${esc(s.id)}">${esc(plain(s.name))}</option>`).join("")}</select></label>
 
 
1746
  <div class="mx-row"><button type="submit" class="mx-btn solid"${RES.busy?" disabled":""}>${RES.busy?"שומרים…":"שמירה במשאבים"}</button>
1747
  <button type="button" class="mx-btn" data-act="res-retake"${RES.busy?" disabled":""}>צילום מחדש</button>
1748
  <button type="button" class="mx-btn" data-act="res-cancel"${RES.busy?" disabled":""}>ביטול</button></div></form>`;
@@ -1753,12 +2246,15 @@ function resourceItem(r){
1753
  return `<article class="mx-res-item"><a class="mx-res-img" data-res-link="${esc(r.id)}" target="_blank" rel="noopener" aria-label="פתיחת התמונה בגודל מלא">
1754
  <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>
1755
  <div><b>${esc(RES_KINDS[r.kind]||r.kind)}</b>${r.kind==="other"&&r.note?`<p>${esc(r.note)}</p>`:""}
1756
- <small>${skill?`${fmt(skill.short||skill.name)} · `:""}${esc(date)}${r.created_email?` · <span dir="ltr">${esc(r.created_email)}</span>`:""}</small></div>
1757
- ${mine?`<button class="mx-link" data-act="res-delete" data-res="${esc(r.id)}">מחיקה</button>`:""}</article>`;
 
 
1758
  }
1759
  function drawResources(){
1760
  const items=RES.items, was=sheet.querySelector(".mx-res-form"); // a redraw keeps what was filled in
1761
- const filled=was&&{kind:was.elements.kind.value,note:was.elements.note.value,skill:was.elements.skill.value};
 
1762
  const list=items===null?'<p class="mx-note">טוענים את המשאבים…</p>':items.length?`<div class="mx-res-list">${items.map(resourceItem).join("")}</div>`
1763
  :'<p class="mx-note">עוד אין משאבים לשיעור הזה. צלמו תרגיל או הסבר כדי להוסיף.</p>';
1764
  sheet.innerHTML=`<div class="mx-scrim" data-act="close"></div>
@@ -1775,8 +2271,14 @@ function drawResources(){
1775
  const form=sheet.querySelector(".mx-res-form");
1776
  if(form){
1777
  const note=form.elements.note;
1778
- 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"; }
1779
- form.addEventListener("change",()=>{ note.hidden=form.elements.kind.value!=="other"; if(!note.hidden) note.focus(); });
 
 
 
 
 
 
1780
  form.onsubmit=event=>{ event.preventDefault(); saveResource(form); };
1781
  }
1782
  (items||[]).forEach(r=>{ if(r.id in RES.urls) showResourceImage(r.id); else loadResourceImage(RES,r.id); });
@@ -1818,7 +2320,7 @@ async function saveResource(form){
1818
  if(kind==="other"&&!note){ status.textContent="כתבו בכמה מילים מה יש בתמונה."; form.elements.note.focus(); return; }
1819
  mine.busy=true; form.querySelectorAll("button").forEach(b=>b.disabled=true); status.textContent="שומרים…";
1820
  try{
1821
- 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);
1822
  const res=await api(`/api/math/units/${encodeURIComponent(mine.unit.id)}/resources`,{method:"POST",body}), data=await res.json().catch(()=>({}));
1823
  if(!res.ok) throw new Error(resError(res.status,"לא הצלחנו לשמור. נסו שוב."));
1824
  mine.urls[data.id]=URL.createObjectURL(mine.photo); // the saved photo, without downloading it again
@@ -1828,6 +2330,16 @@ async function saveResource(form){
1828
  mine.busy=false;
1829
  if(RES===mine&&cur==="resources") drawResources();
1830
  }
 
 
 
 
 
 
 
 
 
 
1831
  async function deleteResource(id){
1832
  const mine=RES; if(!mine||!confirm("למחוק את התמונה ממשאבי השיעור?")) return;
1833
  try{
 
47
  const joined=(k,id)=>{ const by=new Map(); for(const x of list(k)) by.set(id(x),x); return [...by.values()]; };
48
  for(const k of new Set([...Object.keys(old),...Object.keys(neu)])) if(isObj(old[k])&&isObj(neu[k])) each(k); // newer per key
49
  each("done",earliest); each("gold",earliest); each("seen",latest); each("tries",most); each("days",most);
50
+ each("examples",(x,y)=>({n:most(x?.n,y?.n),last:latest(x?.last||"",y?.last||"")})); // worked examples opened, per skill
51
  each("solved",(x,y)=>[...new Set([...(Array.isArray(x)?x:[]),...(Array.isArray(y)?y:[])])].sort((p,q)=>p-q));
52
  out.comets=[...new Set(list("comets"))];
53
  out.slips=joined("slips",s=>JSON.stringify([s?.at,s?.id,s?.q,s?.m])).sort((x,y)=>String(x?.at).localeCompare(String(y?.at))).slice(-500);
 
73
  if(!synced()||!G) return;
74
  if(document.visibilityState==="hidden"){ if(pushTimer) pushProgress(); return; }
75
  if(cur) return; // a question or exam is open: take the other device's work next time
76
+ loadComments(); // the teacher may have reviewed an answer meanwhile
77
+ loadAssignments();
78
  const remote=await pullProgress(); if(!remote||cur) return;
79
  P=Sky.ensure(mergeProgress(P,remote)); try{ localStorage.setItem(key(),JSON.stringify(P)); }catch{}
80
  if(ACTIVE_SUBJECT==="מתמטיקה"&&!root?.hidden) draw();
81
  });
82
 
83
  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="";
84
+ const ROUND_WRONG={}; // wrong answers per skill in its current round: from the second the worked example is offered
85
  let nav=null, FIRST=true, COMBO=0, REV=null, QZ=null, UQ=null; // UQ: the unit quiz on the sheet (see unit quiz below) // QZ: the practice exam in progress (see quiz below) // the tab bar; whether the shown question is still at its first try; first-try answers in a row
86
  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())
87
  let advanceTimer=null;
 
155
  nav=document.createElement("nav"); nav.className="mx-nav"; nav.setAttribute("aria-label","ניווט"); nav.hidden=true;
156
  root.append(path,sheet,picker,resPicker,nav); document.body.append(root); window.MathlySky.mount(root);
157
  root.addEventListener("click",onClick);
158
+ root.addEventListener("submit",e=>{
159
+ if(e.target.matches?.("[data-review-form]")){ e.preventDefault(); reviewSave(e.target); }
160
+ else if(e.target.matches?.("[data-asg-form]")){ e.preventDefault(); asgSave(e.target); }
161
+ });
162
+ root.addEventListener("change",e=>{ if(e.target.matches?.("[data-report-sort],[data-report-unit]")) reportControl(e.target); });
163
+ document.addEventListener("keydown",e=>{ if(e.key==="Escape"&&helpOpen()) return helpClose(); if(e.key==="Escape"&&cur) QZ?.items?quizQuit():closeSheet(); }); // an exam asks before it is left
164
  }
165
  root.hidden=false;
166
  if(!G){
 
174
  if(!P.set||typeof P.set!=="object") P.set={}; SHOWN=new Set(shownUnits().map(u=>u.id));
175
  hasExams()?drawSubjectPicker():selectSubject("מתמטיקה"); // no exams yet: straight to math, science is a tap away
176
  if(CFG.aiReview) aqCount();
177
+ loadComments();
178
+ loadAssignments();
179
  window.MathlyAnswers?.configure({api,account:CFG.userId,enabled:CFG.answerScans,root,queued:CFG.aiDirect===false,onGraded:({questionId,grading})=>{
180
  if(grading.result.verdict!=="correct"||P.done[questionId])return;
181
  const unitWas=unitDone(unitOf(questionId));
 
360
  return `<svg class="mx-ring" viewBox="0 0 100 100" aria-hidden="true">${back}${arcs}</svg>`;
361
  }
362
  function pathView(){
363
+ const nxt=next(), cls=MY_CLASS?` · כיתת ${esc(MY_CLASS.name)}`:"", teach=can("students.view"), marks=asgMarks();
364
  const fix=new Set(myPatterns().recurring.map(x=>x.practise)); // skills that a recurring mistake sends back to
365
  const units=mineUnits().map(u=>{
366
  const ids=unitIds(u), act=ids.filter(live), lit=act.filter(id=>P.done[id]).length, n=ids.length, full=unitDone(u);
367
  const sts=ids.map(id=>Sky.star(P,id,status(id),id===nxt));
368
+ const steps=ids.map((id,i)=>{ const st=sts[i], tag=st==="next"?"ממשיכים כאן":fix.has(id)?"לחזק":st==="fading"?"דועך":"", given=marks.get(id);
369
  const t=(-90+360*i/n)*Math.PI/180, dx=Math.cos(t), dy=Math.sin(t), up=dy<-.3;
370
  return `<div class="mx-step mx-orb u-${u.id}${up?" up":""}" style="--dx:${dx.toFixed(3)};--dy:${dy.toFixed(3)}">
371
+ <button class="mx-node mx-star st-${st}${fix.has(id)?" fix":""}" data-id="${id}" aria-label="${i+1}. ${esc(plain(BY[id].name))}: ${STATE_NAME[st]}${fix.has(id)?", כדאי לחזק":""}${given?`, משימה מהמורה: ${esc(given.join(", "))}`:""}">${Sky.spark()}<span class="mx-num" aria-hidden="true">${i+1}</span>${given?'<span class="mx-asg-pin" aria-hidden="true">📌</span>':""}${st==="next"?`<span class="mx-perch">${Sky.owl(34)}</span>`:""}</button>
372
  <span class="mx-lbl l-${st}${fix.has(id)?" l-fix":""}">${fmt(BY[id].short)}${tag?`<em>${tag}</em>`:""}</span></div>`; }).join("");
373
  const res=teach?` data-act="resources" data-unit="${u.id}"`:""; // a teacher opens the lesson's resources from its title
374
  const pct=act.length?Math.round(100*lit/act.length):0;
 
379
  ${steps}</div>
380
  ${teach?`<button class="mx-res-open"${res} aria-label="משאבי השיעור ${esc(u.name)}">📷 משאבים</button>`:""}</section>${quizStep(u)}`;
381
  }).join("");
382
+ return `${topBar()}${commentsCard()}${assignmentsCard()}${daily()}${reviewCard()}${patternsCard()}${examSoon()}${quizCard()}<div class="mx-pghead"><h2>המסלול שלי</h2><p class="mx-sub">אלגברה וגאומטריה כיתה ט׳ · 5 יח״ל${cls}</p></div>
383
  <div class="mx-trail">
384
  <div class="mx-start"><span class="mx-flag">${Sky.owl(40)}</span><b>התחלה</b></div>${units}
385
  <div class="mx-finish" aria-hidden="true">${Sky.spark()}<b>סוף המסלול</b></div></div>
 
406
  path.innerHTML=VIEW==="sky"?collectionView():pathView();
407
  nav.hidden=false;
408
  nav.innerHTML=`<div>${TABS.map(([v,label,icon])=>`<button data-view="${v}"${VIEW===v?' aria-current="page"':""}>${icon}<span>${label}</span></button>`).join("")}</div>`;
409
+ decorateUnits();
410
  }
411
  function setView(view,unit){
412
  VIEW=view; draw(); root.scrollTop=0;
 
449
  function chips(ids){
450
  return ids.length?ids.map(id=>`<button class="mx-chip u-${BY[id].unit}${P.done[id]?" is-done":""}" data-id="${id}">${P.done[id]?"✓ ":""}${fmt(BY[id].short)}</button>`).join(""):'<em>אין</em>';
451
  }
452
+ /* ---------- the teacher's assignments (app/assignments.py) ----------
453
+ GET /api/math/assignments gives the student's classes' assignments and when they handed in each unit quiz. Whether an
454
+ item is done is worked out on the page (Sky.assignmentState) from the progress and those sittings, so the card and the
455
+ teacher's report always agree. A card on the home screen lists the open ones, and the done ones for two weeks. */
456
+ let ASG=null;
457
+ async function loadAssignments(){
458
+ if(!CFG.userId||!MY_CLASSES.length) return;
459
+ try{ const r=await api("/api/math/assignments",{cache:"no-store"}); ASG=r.ok?await r.json():null; }catch{ ASG=null; }
460
+ if(ACTIVE_SUBJECT==="מתמטיקה"&&root&&!root.hidden&&!cur) draw(); // not over the subject picker
461
+ }
462
+ function asgView(){
463
+ if(!ASG?.assignments?.length) return null;
464
+ const now=new Date(), cutoff=Sky.jerusalemDay(now-14*864e5);
465
+ const rows=ASG.assignments.map(a=>({a,s:Sky.assignmentState(a,P,ASG.sittings,now)}));
466
+ return {open:rows.filter(x=>!x.s.finished).sort((p,q)=>p.a.due_on.localeCompare(q.a.due_on)),
467
+ done:rows.filter(x=>x.s.finished&&x.a.due_on>=cutoff).sort((p,q)=>q.a.due_on.localeCompare(p.a.due_on))};
468
+ }
469
+ const asgDate=due=>new Date(due+"T12:00:00").toLocaleDateString("he-IL",{day:"numeric",month:"numeric"});
470
+ const asgUnit=id=>esc(G.units.find(u=>u.id===id)?.name||id);
471
+ function asgItemLabel(item){
472
+ if(item.kind==="skill") return BY[item.id]?fmt(BY[item.id].name):esc(item.id);
473
+ if(item.kind==="unit_quiz") return `בוחן מסכם: ${asgUnit(item.unit)}`;
474
+ return `מבחן תרגול: ${(item.units||[]).map(asgUnit).join(", ")}`;
475
+ }
476
+ // the skills of unfinished assignments, with the titles, for the marker on the path
477
+ function asgMarks(){
478
+ const marks=new Map();
479
+ for(const {a} of asgView()?.open||[]) for(const item of a.items) if(item.kind==="skill") marks.set(item.id,[...(marks.get(item.id)||[]),a.title]);
480
+ return marks;
481
+ }
482
+ function assignmentsCard(){
483
+ const v=asgView(); if(!v||(!v.open.length&&!v.done.length)) return "";
484
+ const several=new Set(ASG.assignments.map(a=>a.class_id)).size>1;
485
+ const open=v.open.map(({a,s})=>`<article class="mx-asg${s.overdue?" late":""}">
486
+ <div class="mx-asg-head"><b>${esc(a.title)}</b><span class="mx-asg-due">${s.overdue?"באיחור · ":""}עד ${asgDate(a.due_on)}</span></div>
487
+ ${several?`<small class="mx-small">${esc(a.class_name)}</small>`:""}${a.note?`<p class="mx-asg-note">${esc(a.note)}</p>`:""}
488
+ <p class="mx-asg-count"><b>${s.doneCount}</b> מתוך ${s.total} הושלמו</p>
489
+ <ul class="mx-asg-items">${s.items.map(x=>`<li class="${x.done?"done":""}"><span aria-hidden="true">${x.done?"✓":"○"}</span> ${asgItemLabel(x.item)}${x.done?'<span class="mx-sr"> (הושלם)</span>':""}${x.known?"":" <small>(לא ניתן לבדוק כרגע)</small>"}</li>`).join("")}</ul>
490
+ <button type="button" class="mx-btn solid" data-asg-go="${esc(a.id)}">${s.doneCount?"להמשיך":"להתחיל"}: ${asgItemLabel(s.items[s.next].item)}</button></article>`).join("");
491
+ const done=v.done.length?`<details class="mx-asg-done"><summary>הושלמו (${v.done.length})</summary><ul>${v.done.map(({a,s})=>
492
+ `<li>✓ ${esc(a.title)} <small>עד ${asgDate(a.due_on)}${s.late?" · הוגשה באיחור":""}</small></li>`).join("")}</ul></details>`:"";
493
+ return `<section class="mx-asgs" aria-label="משימות מהמורה"><h2>משימות מהמורה</h2>${open}${done}</section>`;
494
+ }
495
+ // opens the first item of the assignment that is not done
496
+ function asgGo(id){
497
+ const a=ASG?.assignments.find(x=>x.id===id); if(!a) return;
498
+ const s=Sky.assignmentState(a,P,ASG.sittings), item=s.items[s.next]?.item; if(!item) return;
499
+ if(item.kind==="skill") return BY[item.id]&&open(item.id);
500
+ if(item.kind==="unit_quiz") return uqIntro(item.unit);
501
+ stopQuizClock(); QZ=null; return quizSetup(item.units);
502
+ }
503
+
504
+ /* Answers the teacher has reviewed that the student has not opened since (GET /api/math/teacher-comments): a card on
505
+ the home screen opens the first one. Opening an answer tells the server it was seen, and drops it from the list. */
506
+ let TC=[];
507
+ async function loadComments(){
508
+ if(!CFG.userId||!CFG.answerScans) return;
509
+ try{ const r=await api("/api/math/teacher-comments",{cache:"no-store"}); TC=r.ok?(await r.json()).comments||[]:[]; }catch{ TC=[]; }
510
+ if(ACTIVE_SUBJECT==="מתמטיקה"&&root&&!root.hidden&&!cur) draw(); // not over the subject picker
511
+ }
512
+ const commentSeen=pred=>{ const n=TC.length; TC=TC.filter(c=>!pred(c)); return TC.length!==n; };
513
+ function commentsCard(){
514
+ if(!TC.length) return "";
515
+ const c=TC[0], more=TC.length-1;
516
+ return `<button type="button" class="mx-teacher-comment" data-comment="${esc(c.attempt_id)}"><b>המורה הגיב על תשובה שלכם</b>
517
+ <span>${fmt(c.question||"")}${c.score==null?"":` · ${c.score}/100`}${more?` · ועוד ${more}`:""}</span></button>`;
518
+ }
519
+ function openComment(attemptId){
520
+ const c=TC.find(x=>x.attempt_id===attemptId); if(!c) return;
521
+ const [kind,uid]=String(c.question_id).split(":");
522
+ if(kind==="quiz"){
523
+ const h=(uqState(uid).history||[]).findIndex(e=>(e.attempts||[]).includes(attemptId));
524
+ return h>=0?uqResult(uid,h):uqIntro(uid); // this device does not have that sitting: the quiz page lists the past ones
525
+ }
526
+ if(BY[c.question_id]) open(c.question_id);
527
+ }
528
+ /* ---------- help while practising a skill ----------
529
+ The "?" in a skill's sheet opens a panel on top of the question: the skill's description, its worked example (a
530
+ question of its own, solved one step at a time with the reason for each) and the teacher's explanations of it. The
531
+ question underneath is not touched, so closing the panel returns to it as it was. The worked example is also offered
532
+ after the second mistake in a round. Opening the example is kept (P.examples) for the teacher's report; it changes
533
+ nothing about finishing or gold, which still need clean first-try answers. */
534
+ let helpBack=null;
535
+ const helpOpen=()=>!!sheet?.querySelector(".mx-help-wrap");
536
+ function helpClose(){ const el=sheet?.querySelector(".mx-help-wrap"); if(!el) return; el.remove(); helpBack?.focus?.({preventScroll:true}); helpBack=null; }
537
+ function exampleCard(ex){
538
+ return `<section class="mx-example"><h4>דוגמה פתורה</h4><p class="mx-example-q">${fmt(ex.question)}</p>
539
+ <ol class="mx-example-steps">${ex.steps.map((st,i)=>`<li${i?" hidden":""}><b>${fmt(st.do)}</b><span>${fmt(st.why)}</span></li>`).join("")}</ol>
540
+ <p class="mx-example-answer" hidden><b>התשובה: </b>${fmt(ex.answer)}</p>
541
+ <button type="button" class="mx-btn" data-example-next>${ex.steps.length>1?"הצעד הבא":"הצגת התשובה"}</button></section>`;
542
+ }
543
+ // one more step, then the answer
544
+ function exampleNext(btn){
545
+ const box=btn.closest(".mx-example"), next=box.querySelector(".mx-example-steps li[hidden]");
546
+ if(next){ next.hidden=false; if(!box.querySelector(".mx-example-steps li[hidden]")) btn.textContent="הצגת התשובה"; next.scrollIntoView({block:"nearest"}); return; }
547
+ box.querySelector(".mx-example-answer").hidden=false; btn.hidden=true;
548
+ }
549
+ function showHelp(id){
550
+ const s=BY[id]; if(!s||!sheet||sheet.hidden) return;
551
+ if(s.example){ const e=((P.examples||={})[id]||={n:0,last:""}); e.n++; e.last=new Date().toISOString(); save(); }
552
+ if(!helpOpen()) helpBack=document.activeElement;
553
+ sheet.querySelector(".mx-help-wrap")?.remove();
554
+ const el=document.createElement("div"); el.className="mx-help-wrap";
555
+ el.innerHTML=`<div class="mx-scrim" data-act="help-close"></div>
556
+ <div class="mx-sheet mx-help u-${s.unit}" role="dialog" aria-modal="true" aria-labelledby="mxHelpTitle">
557
+ <div class="mx-shtop"><h3 id="mxHelpTitle">עזרה: ${fmt(s.name)}</h3><div class="mx-shacts"><button class="mx-x" data-act="help-close" aria-label="חזרה לשאלה">✕</button></div></div>
558
+ <p>${fmt(s.description)}</p>
559
+ ${s.example?exampleCard(s.example):'<p class="mx-note">לכישור הזה עוד אין דוגמה פתורה.</p>'}
560
+ <div class="mx-help-resources" data-help-resources="${esc(id)}"></div>
561
+ <button type="button" class="mx-btn solid" data-act="help-close">חזרה לשאלה</button></div>`;
562
+ sheet.append(el); el.querySelector(".mx-x").focus({preventScroll:true});
563
+ fillExplanations(el.querySelector("[data-help-resources]"),s.unit,r=>r.skill_id===id);
564
+ }
565
  function open(id){
566
+ commentSeen(c=>c.question_id===id);
567
  stopPhone();
568
  cancelAdvance();
569
  const s=BY[id], st=status(id), unit=G.units.find(u=>u.id===s.unit), all=QS(s);
 
577
  <div class="mx-sheet u-${s.unit}" role="dialog" aria-modal="true" aria-labelledby="mxTitle">
578
  <div class="mx-shtop"><div><span class="mx-uchip">${esc(unit.name)}</span>${s.core?' <span class="mx-core">ליבה 5 יח״ל</span>':""}
579
  <h3 id="mxTitle">${fmt(s.name)}</h3><small class="mx-src">${s.month?`בפריסה: ${esc(s.month)}`:esc(unit.source)}${st==="done"?" · הושלמה ✓":""}</small></div>
580
+ <div class="mx-shacts">${window.MathlyBoard?.button()||""}<button class="mx-helpbtn" data-help="${id}" aria-label="עזרה: ההסבר והדוגמה הפתורה" title="עזרה">?</button><button class="mx-x" data-act="close" aria-label="סגירה">✕</button></div></div>
581
  <p>${fmt(s.description)}</p>
582
  <div class="mx-q">${n>1?`<div class="mx-qprog"><span>שאלה ${r.indexOf(curQ)+1} מתוך ${n}</span><span class="mx-qdots" aria-label="נפתרו ${gotN} מתוך ${n}">${r.map(i=>`<i class="${got.has(i)?"ok":""}${i===curQ?" now":""}"></i>`).join("")}</span></div>`:""}
583
  <p>${fmt(q.question)}</p>${figures(q)}
584
  ${st==="off"?'<p class="mx-note mx-off">השאלה הזו סגורה כרגע לתשובות. היא לא נדרשת כדי להתקדם במסלול.</p>':openEnded?'<div class="mx-answer"></div>':choiceButtons(q)}
585
  <div class="mx-fb" aria-live="polite"></div></div>
586
+ <div class="mx-sec mx-skill-res" data-skill-res hidden></div>
587
  <div class="mx-sec"><h4>דורשת</h4><div class="mx-chips">${chips(s.prereqs)}</div></div>
588
  <div class="mx-sec"><h4>פותחת</h4><div class="mx-chips">${chips(POST[id])}</div></div>
589
  </div>`;
 
591
  window.MathlyBoard?.mount(sheet,`${id}:${curQ}`,{question:()=>sheet.querySelector(".mx-q")}); // the sketch board beside the exercise, when the student has it on
592
  sheet.querySelector(".mx-x").focus({preventScroll:true});
593
  if(openEnded&&st!=="off")window.MathlyAnswers?.mountQuestion(sheet.querySelector(".mx-answer"),id,s.question_version);
594
+ fillExplanations(sheet.querySelector("[data-skill-res]"),s.unit,r=>r.skill_id===id);
595
  }
596
+ function closeSheet(){ stopPhone(); dropResources(); dropQueue(); dropReview(); cancelAdvance(); REV=null; stopQuizClock(); QZ=null; stopUnitQuiz(); if(sheet)sheet.hidden=true; cur=null; back?.focus?.({preventScroll:true}); back=null; }
597
 
598
  // counts down, then opens `target` (a skill id); stops if the student moved on in the meantime
599
  function autoAdvance(id,target){
 
626
  // done once every question is solved and the last answers were right at the first try
627
  const run=FIRST?(P.run[id]||0)+1:0, need=Sky.masterNeed(n);
628
  const first=!P.done[id]&&gotN>=n&&run>=need, unitWas=unitDone(unitOf(id));
629
+ if(first){ P.done[id]=new Date().toISOString(); Sky.mastered(P,id); delete ROUND_WRONG[id]; }
630
  COMBO=FIRST?COMBO+1:0;
631
  const persisted=!FIRST;
632
  const r=Sky.correct(P,id,{first:FIRST,n,finished:!!P.done[id]}); FIRST=false;
 
659
  // with a list of questions, a missed question comes back later, shuffled; the right answer is not shown,
660
  // so solving it then shows understanding, not memory
661
  if(n>1) buttons.forEach(b=>b.disabled=true); else btn.disabled=true;
662
+ // from the second mistake in the round the worked example is offered (the first is often a slip, and the tip covers it)
663
+ ROUND_WRONG[id]=(ROUND_WRONG[id]||0)+1;
664
+ const offer=ROUND_WRONG[id]>=2&&BY[id].example?`<button type="button" class="mx-btn" data-example="${id}">איך פותרים? דוגמה פתורה</button>`:"";
665
  fb.innerHTML=`<div class="mx-bad"><small class="mx-small">טעות נפוצה, וטוב שהיא עולה עכשיו</small><b>${fmt(M.name)}</b><span>${fmt(M.tip)}</span>
666
+ ${offer}
667
  ${n>1?`<span class="mx-small">השאלה תחזור אחרי שאר השאלות, ותוכלו לנסות אותה שוב.</span><button class="mx-btn solid" data-id="${id}">לשאלה הבאה</button>`:""}
668
  ${to?`<button class="mx-btn" data-id="${to}">לחזק קודם את: ${fmt(BY[to].short)}</button>`:n>1?"":"<span class=\"mx-small\">אפשר לבחור תשובה אחרת.</span>"}
669
  ${patternNote(m,[id,to])}</div>`;
 
807
  <span>${QUIZ_SIZE} שאלות מ־${on.size} יחידות, מעורבבות, כמו במבחן. המשוב מגיע בסוף, עם תוכנית: מה לחזק עד המבחן.</span>
808
  ${last?`<span>בפעם הקודמת: ${last.right} מתוך ${last.n}</span>`:""}<span class="mx-dgo">להתחיל ‹</span></button>`;
809
  }
810
+ function quizSetup(preset){
811
+ let {exam,offered,on}=quizUnits();
812
+ if(Array.isArray(preset)){ // an assignment asks for these units: they are offered whatever the schedule says, and chosen
813
+ const all=mineUnits().map(u=>({u,ids:unitIds(u).filter(id=>live(id)&&choiceSkill(id))})).filter(x=>x.ids.length);
814
+ offered=[...offered,...all.filter(x=>preset.includes(x.u.id)&&!offered.some(o=>o.u.id===x.u.id))];
815
+ on=new Set(preset.filter(id=>offered.some(o=>o.u.id===id)));
816
+ }
817
  if(!cur) back=document.activeElement;
818
  cur="quiz"; QZ={setup:true,on,offered};
819
  sheet.innerHTML=`<div class="mx-scrim" data-act="close"></div>
 
821
  <div class="mx-shtop"><div><span class="mx-uchip">מבחן תרגול</span><h3 id="mxTitle">${exam?`לקראת המבחן ב${esc(exam.subject)} ${inDays(exam.days)}`:"מבחן תרגול"}</h3>
822
  <small class="mx-src">${QUIZ_SIZE} שאלות · זמן מומלץ: כ־${Math.round(QUIZ_SIZE*QUIZ_MIN_PER_Q/5)*5} דקות, בלי לחץ: השעון רק מראה.</small></div>
823
  <button class="mx-x" data-act="close" aria-label="סגירה">✕</button></div>
824
+ <p>${Array.isArray(preset)?"המורה ביקש מבחן תרגול על היחידות המסומנות. כדי שהמשימה תיחשב, השאירו את כולן מסומנות.":`אילו יחידות במבחן? סימנו את מה שנלמד עד ${exam?"המבחן":"עכשיו"}. אפשר לשנות.`}</p>
825
  <div class="mx-quiz-units">${offered.map(({u})=>`<button class="mx-btn" data-quiz-unit="${u.id}" aria-pressed="${on.has(u.id)}">${esc(u.name)}</button>`).join("")}</div>
826
  <p class="mx-small">השאלות מעורבבות ושם הנושא לא מופיע: לזהות איזו דרך מתאימה זה חלק מהמבחן. אין משוב באמצע, כמו במבחן אמיתי.</p>
827
  <button class="mx-btn solid" data-act="quiz-start"${on.size?"":" disabled"}>להתחיל</button></div>`;
 
1137
  <button class="mx-x" data-act="close" aria-label="סגירה">✕</button></div><div class="mx-uq-report"></div></div>`;
1138
  sheet.hidden=false; sheet.querySelector(".mx-x").focus({preventScroll:true});
1139
  let sendError="";
1140
+ commentSeen(c=>entry.attempts.includes(c.attempt_id));
1141
  try{ await uqSend(uid,entry); }catch(e){ sendError=e.message; }
1142
  if(UQ?.uid!==uid) return;
1143
  const results=[];
 
1163
  const r=results[i], g=r?.grading, a=g?.state==="completed"?g.result:null, pts=sitting?sitting.questions[i].earned:null;
1164
  let say="";
1165
  if(!r?.attachment) say="לא צולמה תשובה";
1166
+ else if(sitting?.questions[i]?.state==="teacher") say="נבדק על ידי המורה";
1167
  else if(a&&a.score!=null) say={correct:"נכון ומלא",partial:"נכון בחלקו",incorrect:"לא נכון"}[a.verdict]||"";
1168
  else { const req=a?null:r.ai_request;
1169
  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?"הבדיקה האוטומטית אינה זמינה כרגע":"עוד לא נבדקה"); }
 
1171
  <p class="mx-uq-text">${fmt(q.question).replace(/\n/g,"<br>")}</p>
1172
  ${UQ.images[i]?`<img class="mx-uq-img" alt="התשובה שלכם לשאלה ${i+1}" src="${UQ.images[i]}">`:""}
1173
  <p class="mx-uq-verdict ${a?.verdict||"none"}">${esc(say)}</p>
1174
+ ${window.MathlyAnswers?.teacherNote?.(r||{})||""}
1175
+ ${a?.feedback?`<p>${r.teacher_review?"<b>בדיקה אוטומטית: </b>":""}${fmtLoose(a.feedback)}</p>`:""}${a?.next_step?`<p><b>הצעד הבא: </b>${fmtLoose(a.next_step)}</p>`:""}
1176
  ${a?.solution?`<details><summary>הפתרון המלא</summary><p class="mx-grade-solution">${fmtLoose(a.solution)}</p></details>`:""}
1177
+ ${r?.attachment?window.MathlyAnswers?.reviewBox?.(entry.attempts[i],r)||"":""}
1178
  ${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>`;
1179
  });
1180
  const earned=sitting?sitting.questions.reduce((sum,x)=>sum+(x.earned||0),0):0, open=sitting?sitting.open:n;
 
1197
  if(t.dataset.quizUnit) return quizToggle(t.dataset.quizUnit);
1198
  if(t.dataset.quizGo!=null) return quizGo(+t.dataset.quizGo);
1199
  if(t.dataset.uquiz) return uqIntro(t.dataset.uquiz); // a sitting in progress opens straight on its paper
1200
+ if(t.dataset.sres) return t.closest(".mx-sres-item").classList.toggle("zoom");
1201
+ if(t.dataset.help) return showHelp(t.dataset.help);
1202
+ if(t.dataset.example) return showHelp(t.dataset.example);
1203
+ if(t.dataset.exampleNext!=null) return exampleNext(t);
1204
+ if(t.dataset.aqTab) return aqTab(t.dataset.aqTab);
1205
+ if(t.dataset.reviewAnswer) return trOpenAnswer(t.dataset.reviewAnswer,t.dataset.reviewFrom);
1206
+ if(t.dataset.reviewBack!=null) return TR?.back?.();
1207
+ if(t.dataset.answers) return reviewSitting(...t.dataset.answers.split(":"));
1208
+ if(t.dataset.askReview!=null) return window.MathlyAnswers.askReview(t);
1209
+ if(t.dataset.comment) return openComment(t.dataset.comment);
1210
+ if(t.dataset.asgGo) return asgGo(t.dataset.asgGo);
1211
+ if(t.dataset.asgNew!=null){ REPORT.form={id:null}; drawReport(); return sheet.querySelector(".mx-asg-form input[name=title]")?.focus(); }
1212
+ if(t.dataset.asgEdit){ REPORT.form={id:t.dataset.asgEdit}; drawReport(); return sheet.querySelector(".mx-asg-form input[name=title]")?.focus(); }
1213
+ if(t.dataset.asgCancel!=null){ REPORT.form=null; return drawReport(); }
1214
+ if(t.dataset.asgDel) return asgDelete(t.dataset.asgDel);
1215
  if(t.dataset.uqResult){ const [uid,h,check]=t.dataset.uqResult.split(":"); return uqResult(uid,+h,check==="check"); }
1216
  if(t.dataset.uqPhoto!=null){ UQ.pick=+t.dataset.uqPhoto; return sheet.querySelector("[data-uq-file]").click(); }
1217
  if(t.dataset.uqPhone!=null) return uqPhone(+t.dataset.uqPhone);
 
1224
  if(t.dataset.manageMode) return showManageMode(t.dataset.manageMode);
1225
  if(t.dataset.classAction) return manageClassAction(t.dataset.classAction,t.dataset.classId);
1226
  if(t.dataset.reportClass!=null) return classReport(t.dataset.reportClass);
1227
+ if(t.dataset.reportStudent!=null) return reportOpenStudent(t.dataset.reportStudent);
1228
+ if(t.dataset.reportTab) return reportControl(t);
1229
  if(t.dataset.aqSend) return aqSend(t.dataset.aqSend);
1230
  if(t.dataset.aqDecline) return aqDecline(t.dataset.aqDecline);
1231
  switch(t.dataset.act){
1232
+ case "help-close": return helpClose();
1233
  case "manage": return manageClasses();
1234
  case "class-report": return classReport();
1235
  case "ai-queue": return aiQueue();
 
1258
  case "resources": return openResources(t.dataset.unit);
1259
  case "res-take": case "res-retake": return resPicker.click();
1260
  case "res-cancel": RES.photo=null; RES.msg=""; return drawResources();
1261
+ case "res-visible": return setResourceVisible(t.dataset.res,t.dataset.visible==="1");
1262
+ case "unit-explanations": return openExplanations(t.dataset.unit);
1263
  case "res-delete": return deleteResource(t.dataset.res);
1264
  case "photo": case "retake": return picker.click();
1265
  case "hint": PH.shown++; return drawPhoto();
 
1630
  const scores=quizzes.map(x=>x.last?.grade).filter(g=>g!=null);
1631
  const exams=(p.quizzes||[]).slice().reverse();
1632
  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];
1633
+ // worked examples the student opened in the last two weeks, in the class's skills (the device reports them)
1634
+ const classSkills=new Set(units.flatMap(u=>unitIds(u).filter(live))), since=Date.now()-14*864e5;
1635
+ const helped=Object.entries(p.examples||{}).filter(([id,e])=>classSkills.has(id)&&BY[id]&&Date.parse(e?.last)>=since)
1636
+ .map(([id,e])=>({id,n:+e.n||1,last:e.last})).sort((a,b)=>b.last.localeCompare(a.last));
1637
+ return {perUnit,n,done,helped,week:Sky.weekly(p),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};
1638
  }
1639
  async function classReport(id){
1640
  const mine=teachClasses(), classId=id??REPORT?.id??mine[0]?.id; if(classId==null) return;
1641
  if(!cur) back=document.activeElement;
1642
+ closeSheetQuietly(); cur="report"; REPORT={id:classId,data:null,form:null,tab:repGet("tab","list"),sort:repGet("sort","attention"),unit:repGet("unit","overview")};
1643
  const tabs=mine.length>1?`<div class="mx-report-tabs">${mine.map(row=>`<button class="mx-btn" data-report-class="${esc(row.id)}" aria-pressed="${String(row.id)===String(classId)}">${esc(row.name)}</button>`).join("")}</div>`:"";
1644
  sheet.innerHTML=`<div class="mx-scrim"></div>
1645
  <div class="mx-sheet mx-manage-sheet mx-report-sheet" role="dialog" aria-modal="true" aria-labelledby="mxTitle">
 
1654
  REPORT.data=data; drawReport();
1655
  }catch(e){ if(cur==="report") sheet.querySelector(".mx-manage-state").textContent=e.message; }
1656
  }
1657
+ // a student named in the attention list: their card opens and scrolls into view
1658
+ function reportOpenStudent(i){
1659
+ const card=sheet.querySelector(`#rs-${+i}`); if(!card) return;
1660
+ card.open=true; card.scrollIntoView({block:"nearest"}); card.querySelector("summary").focus({preventScroll:true});
1661
+ }
1662
  function closeSheetQuietly(){ const b=back; closeSheet(); back=b; }
1663
+ // the teacher's choices in the report (list or grid, which unit, the order) are kept on this device
1664
+ const repGet=(k,def)=>{ try{ return localStorage.getItem("mathly.report."+k)||def; }catch{ return def; } };
1665
+ const repSet=(k,v)=>{ try{ localStorage.setItem("mathly.report."+k,v); }catch{} };
1666
+ const REPORT_SORTS={attention:"דורשים תשומת לב קודם",name:"שם",progress:"התקדמות",activity:"פעילות אחרונה (הישנה קודם)"};
1667
+ // how a skill stands for a student, as on their own path: gold, lit, fading (finished), trying (practised, not finished), none
1668
+ function cellState(p,id){
1669
+ if(!p) return "none";
1670
+ if(p.done?.[id]) return Sky.star(p,id,"done",false);
1671
+ return p.tries?.[id]>0||(p.missed?.[id]||[]).length?"trying":"none";
1672
+ }
1673
+ const CELL_TEXT={gold:"כוכב זהב",lit:"הושלמה",fading:"הושלמה, הכוכב דועך",trying:"מתרגל, עוד לא סיים",none:"לא התחיל"};
1674
+ const HARD_SHARE=1/3; // a skill is a class-wide difficulty when this share of the students are on it without finishing
1675
  function drawReport(){
1676
  const {class:row,students}=REPORT.data, ids=new Set(row.lessons||[]);
1677
  const units=G.units.filter(u=>ids.has(u.id)&&unitIds(u).some(live));
1678
+ const skillNames={}; units.forEach(u=>unitIds(u).filter(live).forEach(id=>{ skillNames[id]=BY[id].name; }));
1679
+ const ctx={skills:skillNames,units:units.filter(u=>u.quiz).map(u=>({id:u.id,name:u.name})),assignments:REPORT.data.assignments||[]};
1680
+ const all=students.map(s=>({s,r:reportOf(s,units),att:Sky.attention(s,ctx)}));
1681
  sheet.querySelector("#mxTitle").textContent=`הכיתה שלי: ${row.name}`;
1682
  sheet.querySelector(".mx-manage-state").textContent=`${students.length} תלמידים · ${all.filter(x=>x.r).length} התחילו לתרגל`;
1683
  // the mistakes that keep coming back for the most students
1684
  const common={}; all.forEach(({s,r})=>r?.recurring.forEach(x=>(common[x.m]||=[]).push(s.email)));
1685
  const top=Object.entries(common).sort((a,b)=>b[1].length-a[1].length).slice(0,5);
1686
+ // a student is shown by the class's nickname, else their Google name, else the start of their email
1687
+ const name=email=>students.find(x=>x.email===email)?.name||email.split("@")[0];
1688
  const quizUnits=units.filter(u=>u.quiz);
1689
  const DEVICE_NOTE=' <small class="mx-device" title="המכשיר של התלמיד מדווח את הנתונים האלה, והשרת לא בודק אותם">· נתוני תרגול מהמכשיר</small>';
1690
  const grade=score=>score==null?"–":`${score}`;
1691
  const tone=score=>score==null?"":score>=80?" good":score>=55?" mid":" low";
1692
+ const card=({s,r},i)=>{
1693
+ if(!r) return `<details class="mx-report-student" id="rs-${i}"><summary><b dir="ltr">${esc(name(s.email))}</b><span class="mx-small">${s.signed_in?"עוד לא התחיל לתרגל":"עוד לא נכנס לחשבון"}</span></summary><p class="mx-small" dir="ltr">${esc(s.email)}</p></details>`;
1694
  const exam=r.exams[0];
1695
+ return `<details class="mx-report-student" id="rs-${i}"><summary><b dir="ltr">${esc(name(s.email))}</b>
1696
  <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>
1697
  <span class="mx-report-chip${tone(r.quizAvg)}" title="ממוצע בחנים מסכמים שנבדקו">בוחן ${grade(r.quizAvg)}</span>
1698
  <span class="mx-report-chip${tone(exam?pct(exam.right,exam.n):null)}" title="מבחן התרגול האחרון (נתוני מכשיר)">תרגול ${exam?`${exam.right}/${exam.n}`:"–"}</span>
1699
+ ${r.recurring.length?`<span class="mx-report-chip low">${r.recurring.length} קשיים</span>`:""}
1700
+ ${(s.review_requests||[]).length?`<span class="mx-report-chip low">ביקש בדיקה חוזרת</span>`:""}</summary>
1701
  <div class="mx-report-body">
1702
  <p class="mx-small"><span dir="ltr">${esc(s.email)}</span>${r.last?` · פעילות אחרונה ${r.last.toLocaleDateString("he-IL")}`:""}</p>
1703
+ ${(s.review_requests||[]).length?`<section class="mx-tr-request"><h5>ביקש שתסתכלו שוב</h5>${s.review_requests.map(q=>`<p>${fmt(q.question||"")}${q.message?`: ${esc(q.message)}`:""}
1704
+ <button type="button" class="mx-btn" data-review-answer="${esc(q.attempt_id)}" data-review-from="report">פתיחת התשובה</button></p>`).join("")}</section>`:""}
1705
+ ${r.helped.length?`<section><h5>דוגמאות פתורות שנפתחו בשבועיים האחרונים${DEVICE_NOTE}</h5><ul class="mx-report-list">${r.helped.map(x=>
1706
+ `<li><b>${fmt(BY[x.id].name)}</b> <small>${x.n===1?"פעם אחת":`${x.n} פעמים`}, לאחרונה ${shortDate(x.last)}</small></li>`).join("")}</ul></section>`:""}
1707
+ <p class="mx-report-week"><b>${r.week.thisWeek}</b> תשובות נכונות השבוע · <b>${r.week.lastWeek}</b> בשבוע שעבר</p>
1708
  <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>
1709
  <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>'}
1710
  ${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>
1711
+ <section><h5>בוחן מסכם</h5>${quizUnits.length?`<table class="mx-report-table"><thead><tr><th>יחידה</th><th>ציון אחרון</th><th>הגשות</th><th>תאריך</th><th>תשובות</th></tr></thead><tbody>${r.quizzes.map(({u,st,last,count,unverified})=>{
1712
  const cell=st.going?"בתהליך":!r.quizzesKnown?"לא זמין":last?(last.grade==null?"ממתין לבדיקה":`${last.grade}/100`):unverified?"לא מאומת":"–";
1713
  const subs=`${count||(unverified?"":"–")}${unverified&&r.quizzesKnown?`${count?" ":""}<small>(${unverified} לא מאומתות)</small>`:""}`;
1714
+ 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><td>${last?`<button type="button" class="mx-btn" data-answers="${i}:${esc(u.id)}">פתיחה</button>`:""}</td></tr>`; }).join("")}</tbody></table>`:'<p class="mx-small">ליחידות של הכיתה אין בוחן מסכם.</p>'}</section>
1715
  <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>
1716
  </div></details>`;
1717
  };
1718
+ // the order the teacher chose; ties keep the class list's order. `i` is the student's place in the class list (their card's id)
1719
+ const rank={attention:(a,b)=>b.att.length-a.att.length,
1720
+ name:(a,b)=>name(a.s.email).localeCompare(name(b.s.email),"he"),
1721
+ progress:(a,b)=>(b.r?b.r.done:-1)-(a.r?a.r.done:-1),
1722
+ activity:(a,b)=>(a.r?.last?.getTime()??0)-(b.r?.last?.getTime()??0)};
1723
+ const sort=rank[REPORT.sort]?REPORT.sort:"attention";
1724
+ const order=all.map((x,i)=>({...x,i})).sort(rank[sort]);
1725
+ const needy=order.filter(x=>x.att.length);
1726
+ const attn=`<section class="mx-report-attn"><h4>דורשים תשומת לב</h4>${needy.length?`<ul class="mx-report-list">${needy.map(({s,att,i})=>
1727
+ `<li><button type="button" class="mx-report-open" data-report-student="${i}"><b dir="ltr">${esc(name(s.email))}</b></button><small>${att.map(x=>fmt(x.text)).join(" · ")}</small></li>`).join("")}</ul>`
1728
+ :'<p class="mx-note">אף תלמיד לא דורש תשומת לב כרגע.</p>'}</section>`;
1729
+ const list=`${attn}
1730
  ${top.length?`<section class="mx-report-common"><h4>קשיים נפוצים בכיתה</h4><ul class="mx-report-list">${top.map(([m,who])=>`<li><b>${fmt(G.mistakes[m].name)}</b> <small>${who.length} תלמידים: ${who.map(e=>esc(name(e))).join(", ")}</small></li>`).join("")}</ul></section>`:""}
1731
+ <h4>תלמידים</h4>${students.length?order.map(x=>card(x,x.i)).join(""):'<p class="mx-note">אין עדיין תלמידים בכיתה.</p>'}`;
1732
+
1733
+ // the grid: a row per student; a column per skill of one unit, or per unit in the overview
1734
+ const started=all.filter(x=>x.r).length;
1735
+ const gridUnit=units.find(u=>u.id===REPORT.unit);
1736
+ let grid;
1737
+ if(!students.length) grid='<p class="mx-note">אין עדיין תלמידים בכיתה.</p>';
1738
+ else if(!gridUnit){
1739
+ grid=`<table class="mx-grid"><thead><tr><th class="mx-grid-name" scope="col">תלמיד</th>${units.map(u=>`<th scope="col" class="mx-grid-unit">${esc(u.name)}</th>`).join("")}</tr></thead><tbody>${
1740
+ order.map(({s,r})=>`<tr><th scope="row" class="mx-grid-name" dir="ltr">${esc(name(s.email))}</th>${units.map((u,k)=>{ const x=r?.perUnit[k];
1741
+ return x?`<td class="mx-grid-num${tone(pct(x.done,x.n))}">${x.done}/${x.n}</td>`:'<td class="mx-grid-num">–</td>'; }).join("")}</tr>`).join("")}</tbody></table>`;
1742
+ }else{
1743
+ const skills=unitIds(gridUnit).filter(live);
1744
+ const count=skills.map(id=>{ const st=all.map(x=>cellState(x.s.progress,id));
1745
+ const trying=st.filter(t=>t==="trying").length; return {id,done:st.filter(t=>["gold","lit","fading"].includes(t)).length,trying,hard:trying>0&&trying>=started*HARD_SHARE}; });
1746
+ grid=`<table class="mx-grid"><thead><tr><th class="mx-grid-name" scope="col">תלמיד</th>${count.map(c=>
1747
+ `<th scope="col" class="mx-grid-skill${c.hard?" hard":""}"${c.hard?' aria-label="קושי כיתתי"':""}><span title="${esc(BY[c.id].name)}">${c.hard?"⚠ ":""}${fmt(BY[c.id].short)}</span></th>`).join("")}</tr></thead><tbody>${
1748
+ order.map(({s})=>`<tr><th scope="row" class="mx-grid-name" dir="ltr">${esc(name(s.email))}</th>${skills.map(id=>{ const st=cellState(s.progress,id);
1749
+ return `<td><span class="mx-cell ${st}" role="img" aria-label="${CELL_TEXT[st]}" title="${CELL_TEXT[st]}"></span></td>`; }).join("")}</tr>`).join("")}</tbody><tfoot>
1750
+ <tr><th scope="row" class="mx-grid-name">סיימו</th>${count.map(c=>`<td class="mx-grid-sum">${c.done}/${started}</td>`).join("")}</tr>
1751
+ <tr><th scope="row" class="mx-grid-name">מתקשים</th>${count.map(c=>`<td class="mx-grid-sum${c.hard?" hard":""}">${c.trying||""}</td>`).join("")}</tr></tfoot></table>
1752
+ <p class="mx-small">⚠ מיומנות שלפחות שליש מהתלמידים מתרגלים ועוד לא סיימו.</p>`;
1753
+ }
1754
+ const view=["grid","tasks"].includes(REPORT.tab)?REPORT.tab:"list";
1755
+ const bar=`<div class="mx-report-bar"><div class="mx-report-views" role="group" aria-label="תצוגה">
1756
+ <button type="button" class="mx-btn" data-report-tab="list" aria-pressed="${view==="list"}">רשימה</button>
1757
+ <button type="button" class="mx-btn" data-report-tab="grid" aria-pressed="${view==="grid"}">טבלת מיומנויות</button>
1758
+ <button type="button" class="mx-btn" data-report-tab="tasks" aria-pressed="${view==="tasks"}">משימות</button></div>
1759
+ <label class="mx-small">מיון <select data-report-sort>${Object.entries(REPORT_SORTS).map(([k,v])=>`<option value="${k}"${k===sort?" selected":""}>${v}</option>`).join("")}</select></label>
1760
+ ${view==="grid"?`<label class="mx-small">יחידה <select data-report-unit><option value="overview">כל היחידות</option>${units.map(u=>`<option value="${esc(u.id)}"${u.id===REPORT.unit?" selected":""}>${esc(u.name)}</option>`).join("")}</select></label>`:""}</div>`;
1761
+ sheet.querySelector(".mx-report").innerHTML=bar+(view==="grid"?`<div class="mx-grid-wrap" tabindex="0" role="region" aria-label="טבלת מיומנויות של הכיתה">${grid}</div>`
1762
+ :view==="tasks"?(REPORT.form?asgFormHtml(units):asgTasksHtml(order,name)):list);
1763
+ }
1764
+
1765
+ /* ---------- the class's assignments, in the report (app/assignments.py) ----------
1766
+ The tab lists the class's assignments, how many students finished each and, for each student, which items are done
1767
+ (worked out by Sky.assignmentState from the progress and the server's quiz sittings, as on the student's own screen),
1768
+ and gives the teacher the form to make, change or delete one. */
1769
+ const ASG_ERR={400:"אחד הפריטים אינו שייך ליחידות של הכיתה, או שהתאריך אינו תקין.",403:"רק מורה הכיתה יכול לתת לה משימות.",404:"הכיתה או המשימה לא נמצאו.",
1770
+ 503:"המשימות אינן זמינות כרגע. נסו שוב מאוחר יותר."};
1771
+ const asgStates=(a,order)=>order.map(({s})=>({s,st:Sky.assignmentState(a,s.progress,s.quiz_sittings)}));
1772
+ function asgTasksHtml(order,name){
1773
+ const list=REPORT.data.assignments;
1774
+ if(list===null) return '<p class="mx-note">המשימות אינן זמינות כרגע באתר.</p>';
1775
+ const today=Sky.jerusalemDay(new Date());
1776
+ const cards=list.slice().sort((p,q)=>q.due_on.localeCompare(p.due_on)).map(a=>{
1777
+ const rows=asgStates(a,order), finished=rows.filter(x=>x.st.finished).length, past=today>a.due_on;
1778
+ return `<article class="mx-asg-t${past?" past":""}"><div class="mx-asg-head"><b>${esc(a.title)}</b><span class="mx-asg-due">${past?"המועד עבר · ":""}עד ${asgDate(a.due_on)}</span></div>
1779
+ ${a.note?`<p class="mx-asg-note">${esc(a.note)}</p>`:""}
1780
+ <p class="mx-asg-count"><b>${finished}</b> מתוך ${rows.length} תלמידים סיימו</p>
1781
+ <ul class="mx-asg-items">${a.items.map(i=>`<li>${asgItemLabel(i)}</li>`).join("")}</ul>
1782
+ ${rows.length?`<details class="mx-asg-who"><summary>מי עשה מה</summary><div class="mx-grid-wrap" tabindex="0" role="region" aria-label="מצב התלמידים במשימה"><table class="mx-grid">
1783
+ <thead><tr><th class="mx-grid-name" scope="col">תלמיד</th>${a.items.map(i=>`<th scope="col" class="mx-grid-unit">${asgItemLabel(i)}</th>`).join("")}</tr></thead><tbody>
1784
+ ${rows.map(({s,st})=>`<tr><th scope="row" class="mx-grid-name" dir="ltr">${esc(name(s.email))}</th>${st.items.map(x=>
1785
+ `<td class="mx-asg-cell ${x.done?(x.late?"late":"ok"):x.known?"none":"unknown"}">${x.done?(x.late?"✓ באיחור":"✓"):x.known?"–":"?"}</td>`).join("")}</tr>`).join("")}
1786
+ </tbody></table></div></details>`:""}
1787
+ <div class="mx-row"><button type="button" class="mx-btn" data-asg-edit="${esc(a.id)}">עריכה</button>
1788
+ <button type="button" class="mx-btn" data-asg-del="${esc(a.id)}">מחיקה</button></div></article>`;
1789
+ }).join("");
1790
+ return `<div class="mx-row"><button type="button" class="mx-btn solid" data-asg-new>משימה חדשה</button></div>
1791
+ ${cards||'<p class="mx-note">עוד לא נתתם משימות לכיתה. משימה היא מיומנויות לסיים, בוחן מסכם להגיש או מבחן תרגול, עד תאריך.</p>'}`;
1792
+ }
1793
+ function asgFormHtml(units){
1794
+ const old=REPORT.data.assignments.find(x=>x.id===REPORT.form.id);
1795
+ const a=old||{title:"",note:"",due_on:new Date(Date.now()+7*864e5).toLocaleDateString("en-CA",{timeZone:"Asia/Jerusalem"}),items:[]};
1796
+ const skills=new Set(a.items.filter(i=>i.kind==="skill").map(i=>i.id)), quizzes=new Set(a.items.filter(i=>i.kind==="unit_quiz").map(i=>i.unit));
1797
+ const exam=new Set(a.items.find(i=>i.kind==="exam")?.units||[]);
1798
+ const box=(name,value,on,label)=>`<label><input type="checkbox" name="${name}" value="${esc(value)}"${on?" checked":""}> ${label}</label>`;
1799
+ return `<form class="mx-asg-form" data-asg-form="${esc(old?.id||"")}" novalidate><h4>${old?"עריכת משימה":"משימה חדשה"}</h4>
1800
+ <label>כותרת<input name="title" maxlength="80" value="${esc(a.title)}" required></label>
1801
+ <label>הערה לתלמידים <small>(לא חובה)</small><textarea name="note" rows="2" maxlength="500">${esc(a.note)}</textarea></label>
1802
+ <label>להגיש עד<input type="date" name="due_on" value="${esc(a.due_on)}" required></label>
1803
+ <fieldset><legend>מיומנויות לסיים</legend>${units.map(u=>{ const ids=unitIds(u).filter(live);
1804
+ return `<details${ids.some(id=>skills.has(id))?" open":""}><summary>${esc(u.name)}</summary><div class="mx-asg-checks">${ids.map(id=>box("skill",id,skills.has(id),fmt(BY[id].short||BY[id].name))).join("")}</div></details>`; }).join("")}</fieldset>
1805
+ <fieldset><legend>בוחן מסכם להגשה</legend><div class="mx-asg-checks">${units.filter(u=>u.quiz).map(u=>box("quiz",u.id,quizzes.has(u.id),esc(u.name))).join("")||"<small>ליחידות של הכיתה אין בוחן מסכם.</small>"}</div></fieldset>
1806
+ <fieldset><legend>מבחן תרגול על היחידות</legend><div class="mx-asg-checks">${units.map(u=>box("exam",u.id,exam.has(u.id),esc(u.name))).join("")}</div>
1807
+ <small>מי שיסמן כמה יחידות יבקש מבחן תרגול שכולל את כולן.</small></fieldset>
1808
+ <div class="mx-row"><button type="submit" class="mx-btn solid">שמירה</button><button type="button" class="mx-btn" data-asg-cancel>ביטול</button>
1809
+ <span class="mx-small" role="status" aria-live="polite"></span></div></form>`;
1810
+ }
1811
+ async function asgSave(form){
1812
+ const id=form.dataset.asgForm, status=form.querySelector("[role=status]"), button=form.querySelector("[type=submit]"), classId=REPORT.id;
1813
+ const picked=name=>[...form.querySelectorAll(`[name=${name}]:checked`)].map(c=>c.value);
1814
+ const items=[...picked("skill").map(v=>({kind:"skill",id:v})),...picked("quiz").map(v=>({kind:"unit_quiz",unit:v}))];
1815
+ if(picked("exam").length) items.push({kind:"exam",units:picked("exam")});
1816
+ const body={title:form.elements.title.value,note:form.elements.note.value,due_on:form.elements.due_on.value,items};
1817
+ if(!body.title.trim()){ status.textContent="כתבו כותרת למשימה."; form.elements.title.focus(); return; }
1818
+ if(!body.due_on){ status.textContent="בחרו תאריך להגשה."; return; }
1819
+ if(!items.length){ status.textContent="בחרו לפחות פריט אחד: מיומנות, בוחן מסכם או מבחן תרגול."; return; }
1820
+ button.disabled=true; status.textContent="שומרים…";
1821
+ try{
1822
+ const r=await api(`/api/classes/${encodeURIComponent(classId)}/assignments${id?`/${encodeURIComponent(id)}`:""}`,
1823
+ {method:id?"PUT":"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)});
1824
+ const d=await r.json().catch(()=>({}));
1825
+ if(!r.ok) throw Error(ASG_ERR[r.status]||"השמירה נכשלה. נסו שוב.");
1826
+ if(cur!=="report"||REPORT?.id!==classId) return;
1827
+ const list=REPORT.data.assignments||[];
1828
+ REPORT.data.assignments=id?list.map(x=>x.id===id?d:x):[d,...list];
1829
+ REPORT.form=null; drawReport();
1830
+ sheet.querySelector(".mx-report").insertAdjacentHTML("afterbegin",`<p class="mx-small" role="status">${id?"המשימה עודכנה.":"המשימה נשמרה. התלמידים רואים אותה בבית."}</p>`);
1831
+ }catch(e){ if(cur==="report"){ button.disabled=false; status.textContent=e.message; } }
1832
+ }
1833
+ async function asgDelete(id){
1834
+ const a=REPORT.data.assignments.find(x=>x.id===id); if(!a) return;
1835
+ const started=REPORT.data.students.filter(s=>Sky.assignmentState(a,s.progress,s.quiz_sittings).doneCount>0).length;
1836
+ if(!confirm(`למחוק את המשימה "${a.title}"? התלמידים לא יראו אותה יותר.${started?` ${started} תלמידים כבר השלימו לפחות פריט אחד ממנה.`:""}`)) return;
1837
+ const classId=REPORT.id;
1838
+ try{
1839
+ const r=await api(`/api/classes/${encodeURIComponent(classId)}/assignments/${encodeURIComponent(id)}`,{method:"DELETE"});
1840
+ if(!r.ok) throw Error(ASG_ERR[r.status]||"המחיקה נכשלה. נסו שוב.");
1841
+ if(cur!=="report"||REPORT?.id!==classId) return;
1842
+ REPORT.data.assignments=REPORT.data.assignments.filter(x=>x.id!==id); drawReport();
1843
+ }catch(e){ if(cur==="report") sheet.querySelector(".mx-manage-state").textContent=e.message; }
1844
+ }
1845
+ // the report's own controls: the view, the order and the unit of the grid
1846
+ function reportControl(t){
1847
+ if(t.dataset.reportTab){ REPORT.tab=t.dataset.reportTab; repSet("tab",REPORT.tab); drawReport(); sheet.querySelector(`[data-report-tab="${REPORT.tab}"]`)?.focus(); }
1848
+ else if(t.matches("[data-report-sort]")){ REPORT.sort=t.value; repSet("sort",t.value); drawReport(); sheet.querySelector("[data-report-sort]")?.focus(); }
1849
+ else if(t.matches("[data-report-unit]")){ REPORT.unit=t.value; repSet("unit",t.value); drawReport(); sheet.querySelector("[data-report-unit]")?.focus(); }
1850
+ }
1851
+
1852
+ /* ---------- the teacher's review of a student's answer (app/teacher_review.py) ----------
1853
+ The class teacher opens an open answer of one of their students (from a unit-quiz row of the report, or a handled
1854
+ request of the AI queue): the question, the photo, the automatic check, and a form for a score of their own and a
1855
+ comment. The teacher's score counts instead of the AI's wherever the answer is graded. */
1856
+ let TR=null; // the answer on the sheet: {attemptId, back (how to return), data, url}
1857
+ const TR_ERR={invalid_score:"הציון חייב להיות מספר שלם בין 0 ל־100.",comment_too_long:"ההערה ארוכה מדי (עד 1000 תווים).",empty_review:"כתבו ציון או הערה.",
1858
+ invalid_comment:"ההערה אינה תקינה.",forbidden:"אין לכם הרשאה לתשובה הזו.",teacher_required:"אין לכם הרשאה לתשובה הזו.",not_found:"התשובה לא נמצאה.",
1859
+ answer_required:"לתשובה הזו אין תמונה שמורה.",review_unavailable:"הבדיקה אינה זמינה כרגע. נסו שוב מאוחר יותר.",answer_storage_unavailable:"שמירת תשובות אינה זמינה כרגע."};
1860
+ function dropReview(){ if(TR?.url) URL.revokeObjectURL(TR.url); TR=null; }
1861
+ const trName=email=>String(email||"").split("@")[0];
1862
+ function trShell(title,backLabel){
1863
+ if(cur!=="review"){ if(!cur) back=document.activeElement; closeSheetQuietly(); cur="review"; }
1864
+ sheet.innerHTML=`<div class="mx-scrim"></div>
1865
+ <div class="mx-sheet mx-manage-sheet mx-tr-sheet" role="dialog" aria-modal="true" aria-labelledby="mxTitle">
1866
+ <div class="mx-manage-head"><h3 id="mxTitle">${esc(title)}</h3><span class="mx-manage-spacer"></span>
1867
+ <button class="mx-btn" data-review-back>${esc(backLabel)}</button><button class="mx-x" data-act="close" aria-label="סגירה">✕</button></div>
1868
+ <p class="mx-manage-state" aria-live="polite"></p><div class="mx-tr"></div></div>`;
1869
+ sheet.hidden=false; sheet.querySelector(".mx-x").focus({preventScroll:true});
1870
+ }
1871
+ // the answers of one unit-quiz sitting of a student, to open one of them
1872
+ function reviewSitting(index,unitId){
1873
+ const student=REPORT?.data?.students[index], unit=G.units.find(u=>u.id===unitId);
1874
+ const sitting=(student?.quiz_sittings||[]).filter(x=>x.unit_id===unitId).at(-1);
1875
+ if(!sitting) return;
1876
+ trShell(`תשובות בבוחן: ${unit.name}`,"חזרה לדוח"); // opening the sheet closes the one before it, and its answer
1877
+ dropReview(); TR={back:()=>classReport(REPORT.id)};
1878
+ const STATE={missing:"לא צולמה תשובה",waiting:"ממתינה לבדיקה",unreadable:"לא קריאה",needs_review:"דורשת בדיקה שלכם",teacher:"נבדקה על ידכם",checked:"נבדקה"};
1879
+ sheet.querySelector(".mx-manage-state").textContent=`${trName(student.email)} · ${sitting.grade==null?"עוד אין ציון":`ציון ${sitting.grade}/100`}`;
1880
+ sheet.querySelector(".mx-tr").innerHTML=`<ol class="mx-uq-qs">${sitting.questions.map((q,k)=>`<li class="mx-uq-q"><div class="mx-uq-qhead"><b>שאלה ${k+1}</b>
1881
+ <span>${q.earned==null?"–":q.earned}/${q.points} נק׳</span></div><p class="mx-small">${STATE[q.state]||esc(q.state)}</p>
1882
+ ${q.state==="missing"?"":`<button class="mx-btn" data-review-answer="${esc(sitting.attempts[k])}" data-review-from="sitting:${index}:${esc(unitId)}">פתיחת התשובה</button>`}</li>`).join("")}</ol>`;
1883
+ }
1884
+ async function trOpenAnswer(attemptId,from){
1885
+ const [kind,a,b]=String(from||"").split(":");
1886
+ const backTo=kind==="sitting"?()=>reviewSitting(+a,b):kind==="queue"?()=>aiQueue():kind==="report"?()=>classReport(REPORT?.id):()=>closeSheet();
1887
+ trShell("תשובה של תלמיד",kind==="queue"?"חזרה לתור":"חזרה");
1888
+ dropReview(); const mine=TR={attemptId,back:backTo,data:null,url:null};
1889
+ sheet.querySelector(".mx-manage-state").textContent="טוענים את התשובה…";
1890
+ try{
1891
+ const r=await api(`/api/teacher/answers/${encodeURIComponent(attemptId)}`,{cache:"no-store"});
1892
+ const d=await r.json().catch(()=>({}));
1893
+ if(!r.ok) throw Error(TR_ERR[d.detail]||"לא הצלחנו לטעון את התשובה. נסו שוב.");
1894
+ if(TR!==mine) return;
1895
+ mine.data=d; drawReview();
1896
+ const img=await api(`/api/teacher/answers/${encodeURIComponent(attemptId)}/image`,{cache:"no-store"});
1897
+ if(img.ok){ const url=URL.createObjectURL(await img.blob()); if(TR!==mine) return URL.revokeObjectURL(url); mine.url=url; const el=sheet.querySelector(".mx-tr-img"); if(el){ el.src=url; el.hidden=false; } }
1898
+ }catch(e){ if(TR===mine) sheet.querySelector(".mx-manage-state").textContent=e.message; }
1899
+ }
1900
+ function drawReview(note=""){
1901
+ const d=TR.data, a=d.grading?.state==="completed"?d.grading.result:null, rv=d.review, rq=d.request;
1902
+ const isQuiz=String(d.attempt.question_id).startsWith("quiz:"), when=at=>new Date(at).toLocaleString("he-IL",{dateStyle:"short",timeStyle:"short"});
1903
+ sheet.querySelector(".mx-manage-state").textContent=`${trName(d.student.email)} · ${d.student.email}`;
1904
+ sheet.querySelector(".mx-tr").innerHTML=`
1905
+ <p class="mx-small">${fmt(d.attempt.snapshot.name||"")}</p>
1906
+ <p class="mx-aq-q">${fmtLoose(d.attempt.snapshot.question||"").replace(/\n/g,"<br>")}</p>
1907
+ <img class="mx-aq-img mx-tr-img" alt="התשובה שצילם התלמיד" ${TR.url?`src="${TR.url}"`:"hidden"}>
1908
+ ${rq?.state==="open"?`<div class="mx-tr-request" role="note"><b>התלמיד ביקש שתסתכלו שוב</b>${rq.message?`<p>${esc(rq.message)}</p>`:""}<small>${when(rq.created_at)}</small></div>`:""}
1909
+ <section class="mx-tr-ai"><h5>בדיקה אוטומטית (AI)</h5>${a?`<p><b>${esc(AQ_VERDICT[a.verdict]||"")}</b>${a.score==null?"":` · ${esc(a.score)}/100`}</p>
1910
+ ${a.feedback?`<p>${fmtLoose(a.feedback)}</p>`:""}
1911
+ ${a.transcription?`<details><summary>מה ה-AI קרא בתמונה</summary><p>${fmtLoose(a.transcription)}</p></details>`:""}
1912
+ ${a.solution?`<details><summary>הפתרון המלא</summary><p class="mx-grade-solution">${fmtLoose(a.solution)}</p></details>`:""}`:'<p class="mx-small">התשובה עוד לא נבדקה בבדיקה אוטומטית.</p>'}</section>
1913
+ ${rv?`<section><h5>הבדיקה שלכם</h5><p>${rv.score==null?"בלי ציון":`${esc(rv.score)}/100`} · <small>${when(rv.created_at)}</small></p>${rv.comment?`<p>${esc(rv.comment).replace(/\n/g,"<br>")}</p>`:""}</section>`:""}
1914
+ <form class="mx-tr-form" data-review-form>
1915
+ <h5>${rv?"עדכון הבדיקה שלכם":"הבדיקה שלכם"}</h5>
1916
+ <label>ציון (0 עד 100)<input type="number" name="score" min="0" max="100" step="1" inputmode="numeric" value="${rv?.score??""}"></label>
1917
+ <label>הערה לתלמיד<textarea name="comment" rows="3" maxlength="1000">${esc(rv?.comment||"")}</textarea></label>
1918
+ <p class="mx-small">${isQuiz?"הציון שלכם מחליף את הציון האוטומטי בחישוב ציון הבוחן.":"הציון שלכם מחליף את הציון האוטומטי. ציון 100 מסמן את המיומנות כהושלמה אצל התלמיד."}</p>
1919
+ <div class="mx-row"><button type="submit" class="mx-btn solid">שמירה</button><span class="mx-small" role="status" aria-live="polite">${esc(note)}</span></div></form>`;
1920
+ }
1921
+ async function reviewSave(form){
1922
+ const mine=TR, status=form.querySelector("[role=status]"), button=form.querySelector("[type=submit]");
1923
+ const raw=form.elements.score.value.trim(), body={comment:form.elements.comment.value};
1924
+ if(raw!=="") body.score=Number(raw);
1925
+ button.disabled=true; status.textContent="שומרים…";
1926
+ try{
1927
+ const r=await api(`/api/teacher/answers/${encodeURIComponent(mine.attemptId)}/review`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)});
1928
+ const d=await r.json().catch(()=>({}));
1929
+ if(!r.ok) throw Error(TR_ERR[d.detail]||"השמירה נכשלה. נסו שוב.");
1930
+ if(TR!==mine) return;
1931
+ mine.data.review=d.review; mine.data.request=d.request; mine.data.effective_score=d.review?.score??mine.data.effective_score;
1932
+ mine.changed=true; drawReview("נשמר. התלמיד יראה את הבדיקה שלכם.");
1933
+ }catch(e){ if(TR===mine){ button.disabled=false; status.textContent=e.message; } }
1934
  }
1935
 
1936
  /* ---------- the teacher's AI queue (app/ai_queue.py) ----------
1937
  A student does not call the AI. Their answer to check (or a photographed problem) waits here for their class's
1938
  teacher, who looks at the photo and sends it to the AI or declines it with a note. The student's page shows the
1939
  result. A manager sees every class's requests. */
1940
+ let AQ=null, AQ_OPEN=null, RQ_OPEN=0, AQ_TAB="queue"; // AQ: the queue on the sheet; AQ_OPEN: how many requests wait (for the header button)
1941
  const AQ_KIND={photo:"📷 תרגיל לעזרה",grading:"📝 בדיקת תשובה"};
1942
  const AQ_STATE={pending:"ממתינה",sending:"נשלחת…",done:"נשלחה ל-AI",failed:"השליחה נכשלה",declined:"נדחתה",cancelled:"בוטלה"};
1943
  const AQ_ERR={grading_failed:"הבדיקה נכשלה. אפשר לנסות שוב.",grading_unavailable:"בדיקה אוטומטית אינה זמינה כרגע באתר.",open_ended_locked:"שאלות פתוחות נעולות כרגע.",
 
1951
  }
1952
  const AQ_VERDICT={correct:"נכונה",partial:"נכונה חלקית",incorrect:"שגויה",unreadable:"לא קריאה",needs_review:"דורשת בדיקה שלכם"};
1953
  const aqWaiting=r=>["pending","sending","failed"].includes(r.state);
1954
+ const aqLabel=()=>`תור AI${(AQ_OPEN||0)+RQ_OPEN?` <span class="mx-aq-badge">${(AQ_OPEN||0)+RQ_OPEN}</span>`:""}`; // waiting requests, and students asking to look again
1955
+ function aqPaintBadge(){ document.querySelectorAll('[data-act="ai-queue"]').forEach(b=>{ if(!b.closest(".mx-sheet")) b.innerHTML=aqLabel(); }); }
1956
+ function aqBadge(rows){ AQ_OPEN=rows.filter(r=>r.state==="pending").length; aqPaintBadge(); }
1957
+ function rqBadge(rows){ RQ_OPEN=rows.length; aqPaintBadge(); }
 
1958
  async function aqCount(){
1959
  try{ const r=await api("/api/ai-queue",{cache:"no-store"}); if(r.ok) aqBadge((await r.json()).requests||[]); }catch{}
1960
+ try{ const r=await api("/api/teacher/review-requests",{cache:"no-store"}); if(r.ok) rqBadge((await r.json()).requests||[]); }catch{}
1961
  }
1962
  function dropQueue(){ if(AQ) Object.values(AQ.urls).forEach(u=>u&&URL.revokeObjectURL(u)); AQ=null; }
1963
  async function aiQueue(){
1964
+ if(cur!=="aiqueue"){ if(!cur) back=document.activeElement; closeSheetQuietly(); cur="aiqueue"; AQ={rows:null,reqs:null,tab:AQ_TAB,urls:{},busy:new Set()}; }
1965
  const mine=AQ;
1966
  sheet.innerHTML=`<div class="mx-scrim"></div>
1967
  <div class="mx-sheet mx-manage-sheet mx-aq-sheet" role="dialog" aria-modal="true" aria-labelledby="mxTitle">
1968
  <div class="mx-manage-head"><h3 id="mxTitle">תור AI</h3><span class="mx-manage-spacer"></span>
1969
  <button class="mx-btn" data-act="aq-all" hidden>שליחת כל הממתינות</button><button class="mx-btn" data-act="ai-queue">רענון</button><button class="mx-x" data-act="close" aria-label="סגירה">✕</button></div>
1970
+ <div class="mx-report-views" data-aq-tabs role="group" aria-label="תצוגה"></div>
1971
+ <p class="mx-small" data-aq-help>בקשות התלמידים מחכות כאן. הסתכלו בתמונה, ואז שלחו אותה ל-AI או דחו אותה. התלמיד רואה את התוצאה בעמוד שלו.</p>
1972
  <p class="mx-manage-state" aria-live="polite">טוענים את הבקשות…</p><div class="mx-aq"></div></div>`;
1973
  sheet.hidden=false; sheet.querySelector(".mx-x").focus({preventScroll:true});
1974
  try{
 
1977
  const d=await r.json(); if(AQ!==mine) return;
1978
  mine.rows=d.requests||[]; drawQueue();
1979
  }catch(e){ if(AQ===mine) sheet.querySelector(".mx-manage-state").textContent=e.message; }
1980
+ try{ // the students asking the teacher to look again at a checked answer
1981
+ const r=await api("/api/teacher/review-requests",{cache:"no-store"});
1982
+ mine.reqs=r.ok?(await r.json()).requests||[]:[];
1983
+ }catch{ mine.reqs=[]; }
1984
+ if(AQ===mine){ rqBadge(mine.reqs); drawQueue(); }
1985
  }
1986
+ function aqTab(tab){ if(!AQ) return; AQ.tab=AQ_TAB=tab; drawQueue(); sheet.querySelector(`[data-aq-tab="${tab}"]`)?.focus(); }
1987
  function aqResult(r){
1988
  const x=r.result||{};
1989
  if(r.kind==="photo") return x.readable===false?"ה-AI לא הצליח לקרוא את התרגיל.":`התשובה שה-AI מצא: ${fmtLoose(x.answer||"")}`;
1990
  return `תוצאה: ${esc(AQ_VERDICT[x.verdict]||"")}${x.score==null?"":` · ${esc(x.score)}/100`}`;
1991
  }
1992
  function drawQueue(){
1993
+ const box=sheet.querySelector(".mx-aq"); if(!box) return;
1994
+ const tabs=sheet.querySelector("[data-aq-tabs]"), asks=AQ?.reqs;
1995
+ if(tabs) tabs.innerHTML=`<button type="button" class="mx-btn" data-aq-tab="queue" aria-pressed="${AQ.tab!=="requests"}">בקשות לבדיקה</button>
1996
+ <button type="button" class="mx-btn" data-aq-tab="requests" aria-pressed="${AQ.tab==="requests"}">בקשות לבדיקה חוזרת${asks?.length?` <span class="mx-aq-badge">${asks.length}</span>`:""}</button>`;
1997
+ if(AQ?.tab==="requests") return drawRequests();
1998
+ sheet.querySelector('[data-act="aq-all"]').hidden=true;
1999
+ sheet.querySelector("[data-aq-help]").hidden=false;
2000
+ if(!AQ?.rows) return;
2001
  const open=AQ.rows.filter(aqWaiting), rest=AQ.rows.filter(r=>!aqWaiting(r));
2002
  aqBadge(AQ.rows);
2003
  sheet.querySelector(".mx-manage-state").textContent=open.length?(open.length===1?"בקשה אחת ממתינה":`${open.length} בקשות ממתינות`):"אין בקשות ממתינות.";
 
2012
  ${r.state==="failed"&&r.error?aqError(r.error):""}
2013
  ${r.state==="declined"&&r.note?`<p class="mx-small">ההערה שלכם: ${esc(r.note)}</p>`:""}
2014
  ${r.state==="done"?`<p class="mx-small">${aqResult(r)}</p>`:""}
2015
+ ${r.kind==="grading"&&r.attempt_id&&!aqWaiting(r)?`<button type="button" class="mx-btn" data-review-answer="${esc(r.attempt_id)}" data-review-from="queue">פתיחת התשובה</button>`:""}
2016
  ${act?`<div class="mx-row"><button class="mx-btn solid" data-aq-send="${esc(r.id)}"${busy?" disabled":""}>${r.state==="failed"?"שליחה חוזרת ל-AI":"שליחה ל-AI"}</button>
2017
  <button class="mx-btn" data-aq-decline="${esc(r.id)}"${busy?" disabled":""}>דחייה</button></div>`:""}</article>`;
2018
  };
2019
  box.innerHTML=`${open.map(card).join("")}${rest.length?`<h4 class="mx-aq-done">טופלו ביממה האחרונה</h4>${rest.map(card).join("")}`:""}`;
2020
  open.forEach(r=>aqImage(r.id));
2021
  }
2022
+ // students who asked the teacher to look again at a checked answer
2023
+ function drawRequests(){
2024
+ const box=sheet.querySelector(".mx-aq"), rows=AQ.reqs;
2025
+ sheet.querySelector('[data-act="aq-all"]').hidden=true;
2026
+ sheet.querySelector("[data-aq-help]").hidden=true;
2027
+ const state=sheet.querySelector(".mx-manage-state");
2028
+ if(!rows){ state.textContent="טוענים את הבקשות…"; box.innerHTML=""; return; }
2029
+ state.textContent=rows.length?(rows.length===1?"בקשה אחת לבדיקה חוזרת":`${rows.length} בקשות לבדיקה חוזרת`):"אין בקשות לבדיקה חוזרת.";
2030
+ const when=at=>new Date(at).toLocaleString("he-IL",{dateStyle:"short",timeStyle:"short"});
2031
+ box.innerHTML=rows.map(r=>`<article class="mx-aq-card pending"><div class="mx-aq-head"><b dir="ltr">${esc(String(r.student).split("@")[0])}</b><span>🔁 בקשה לבדיקה חוזרת</span><small>${when(r.created_at)}</small></div>
2032
+ ${r.question?`<p class="mx-aq-label">${fmt(r.question)}</p>`:""}
2033
+ ${r.message?`<p class="mx-aq-q">${esc(r.message)}</p>`:'<p class="mx-small">בלי הודעה.</p>'}
2034
+ <div class="mx-row"><button type="button" class="mx-btn solid" data-review-answer="${esc(r.attempt_id)}" data-review-from="queue">פתיחת התשובה</button></div></article>`).join("");
2035
+ }
2036
  async function aqImage(id){
2037
  const mine=AQ, show=url=>{ const img=sheet.querySelector(`[data-aq-img="${CSS.escape(id)}"]`); if(img){ img.src=url; img.hidden=false; } };
2038
  if(mine.urls[id]) return show(mine.urls[id]);
 
2136
  if(PH.fresh){ PH.fresh=false; sheet.querySelector(".mx-x").focus({preventScroll:true}); }
2137
  }
2138
 
2139
+ /* The teacher's explanations, for students (GET /api/math/units/.../student-resources): the resources a teacher marked
2140
+ visible. Those tagged with a skill appear in that skill's sheet and in its help panel; the rest of the unit's are
2141
+ on a list opened from the unit on the path. Images come with the student's sign-in, so each is a blob URL. */
2142
+ const SRES={}; // unit id → {rows, p (the request), at, urls}
2143
+ const SRES_TTL=300000;
2144
+ function sresLoad(unitId){
2145
+ const c=SRES[unitId]||=({rows:null,p:null,at:0,urls:{}});
2146
+ if(!c.p||Date.now()-c.at>SRES_TTL){
2147
+ c.at=Date.now();
2148
+ c.p=(async()=>{ try{ const r=await api(`/api/math/units/${encodeURIComponent(unitId)}/student-resources`,{cache:"no-store"}); c.rows=r.ok?(await r.json()).resources||[]:[]; }catch{ c.rows=c.rows||[]; } return c.rows; })();
2149
+ }
2150
+ return c.p;
2151
+ }
2152
+ async function sresImage(unitId,id){
2153
+ const c=SRES[unitId]; if(c.urls[id]) return c.urls[id];
2154
+ try{ const r=await api(`/api/math/student-resources/${encodeURIComponent(id)}/image`); if(r.ok) return c.urls[id]=URL.createObjectURL(await r.blob()); }catch{}
2155
+ return "";
2156
+ }
2157
+ const sresList=rows=>`<div class="mx-sres">${rows.map(r=>`<figure class="mx-sres-item"><button type="button" class="mx-sres-img" data-sres="${esc(r.id)}" aria-label="הגדלה או הקטנה של התמונה">
2158
+ <img data-sres-img="${esc(r.id)}" alt="${esc(r.kind==="other"?r.note:RES_KINDS[r.kind]||"")}"></button>
2159
+ <figcaption><b>${esc(RES_KINDS[r.kind]||"")}</b>${r.note?` · ${esc(r.note)}`:""}</figcaption></figure>`).join("")}</div>`;
2160
+ function sresPaint(node,unitId,rows){
2161
+ node.insertAdjacentHTML("beforeend",sresList(rows));
2162
+ for(const r of rows) sresImage(unitId,r.id).then(url=>{ const img=node.querySelector(`[data-sres-img="${CSS.escape(r.id)}"]`); if(img&&url) img.src=url; });
2163
+ }
2164
+ // fills a box with the unit's explanations that `pick` chooses (a skill's own); the box stays hidden when there are none
2165
+ async function fillExplanations(node,unitId,pick){
2166
+ if(!node||!CFG.userId) return;
2167
+ const rows=(await sresLoad(unitId)).filter(pick);
2168
+ if(!rows.length||!node.isConnected) return;
2169
+ node.innerHTML="<h4>הסברים מהמורה</h4>"; node.hidden=false; sresPaint(node,unitId,rows);
2170
+ }
2171
+ // the unit's explanations that belong to no skill: a button under the unit, once they are known to exist
2172
+ function decorateUnits(){
2173
+ if(!CFG.userId||can("students.view")||VIEW==="sky"||!path) return;
2174
+ for(const u of mineUnits()){
2175
+ const c=SRES[u.id];
2176
+ if(!c?.rows){ if(!c?.p) sresLoad(u.id).then(()=>{ if(root&&!root.hidden&&!cur) decorateUnits(); }); continue; }
2177
+ const general=c.rows.filter(r=>!r.skill_id), section=path.querySelector(`#mx-unit-${u.id}`);
2178
+ if(general.length&&section&&!section.querySelector(".mx-sres-open"))
2179
+ section.insertAdjacentHTML("beforeend",`<button type="button" class="mx-sres-open" data-act="unit-explanations" data-unit="${esc(u.id)}">📷 הסברים מהמורה (${general.length})</button>`);
2180
+ }
2181
+ }
2182
+ async function openExplanations(unitId){
2183
+ const unit=G.units.find(u=>u.id===unitId); if(!unit) return;
2184
+ stopPhone(); cancelAdvance(); if(!cur) back=document.activeElement; closeSheetQuietly(); cur="explanations";
2185
+ sheet.innerHTML=`<div class="mx-scrim" data-act="close"></div>
2186
+ <div class="mx-sheet mx-res u-${unit.id}" role="dialog" aria-modal="true" aria-labelledby="mxTitle">
2187
+ <div class="mx-shtop"><div><span class="mx-uchip">${esc(unit.name)}</span><h3 id="mxTitle">הסברים מהמורה</h3>
2188
+ <small class="mx-src">תמונות שהמורה שמר לשיעור הזה</small></div><button class="mx-x" data-act="close" aria-label="סגירה">✕</button></div>
2189
+ <div class="mx-skill-res" data-unit-res><p class="mx-note">טוענים…</p></div></div>`;
2190
+ sheet.hidden=false; sheet.querySelector(".mx-x").focus({preventScroll:true});
2191
+ const rows=(await sresLoad(unitId)).filter(r=>!r.skill_id), node=sheet.querySelector("[data-unit-res]");
2192
+ if(!node||cur!=="explanations") return;
2193
+ node.innerHTML=rows.length?"":'<p class="mx-note">אין כרגע הסברים לשיעור הזה.</p>';
2194
+ if(rows.length) sresPaint(node,unitId,rows);
2195
+ }
2196
+
2197
  /* Lesson resources (app/unit_resources.py). A teacher taps a unit's title on the path, photographs an exercise or an
2198
  explanation, says what it shows (a new exercise, a theory explanation, or other words) and, if they like, which of
2199
  the unit's topics it belongs to. Images come through the API with the teacher's sign-in, so each is a blob URL. */
 
2234
  <input name="note" maxlength="200" autocomplete="off" placeholder="כתבו מה יש בתמונה" aria-label="תיאור חופשי של התמונה" hidden></fieldset>
2235
  <label>נושא <small>(לא חובה)</small><select name="skill"><option value="">ללא נושא מסוים</option>
2236
  ${skills.map(s=>`<option value="${esc(s.id)}">${esc(plain(s.name))}</option>`).join("")}</select></label>
2237
+ <label class="mx-res-visible"><input type="checkbox" name="visible"><span>גלוי לתלמידים</span></label>
2238
+ <small class="mx-small">הסבר תיאורטי מוצג לתלמידים כברירת מחדל. תרגיל והערה נשארים מוסתרים עד שתסמנו.</small>
2239
  <div class="mx-row"><button type="submit" class="mx-btn solid"${RES.busy?" disabled":""}>${RES.busy?"שומרים…":"שמירה במשאבים"}</button>
2240
  <button type="button" class="mx-btn" data-act="res-retake"${RES.busy?" disabled":""}>צילום מחדש</button>
2241
  <button type="button" class="mx-btn" data-act="res-cancel"${RES.busy?" disabled":""}>ביטול</button></div></form>`;
 
2246
  return `<article class="mx-res-item"><a class="mx-res-img" data-res-link="${esc(r.id)}" target="_blank" rel="noopener" aria-label="פתיחת התמונה בגודל מלא">
2247
  <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>
2248
  <div><b>${esc(RES_KINDS[r.kind]||r.kind)}</b>${r.kind==="other"&&r.note?`<p>${esc(r.note)}</p>`:""}
2249
+ <small>${skill?`${fmt(skill.short||skill.name)} · `:""}${esc(date)}${r.created_email?` · <span dir="ltr">${esc(r.created_email)}</span>`:""}</small>
2250
+ <span class="mx-res-vis${r.visible?" on":""}">${r.visible?"👁 גלוי לתלמידים":"🔒 מוסתר מהתלמידים"}</span></div>
2251
+ ${mine?`<div class="mx-res-acts"><button class="mx-link" data-act="res-visible" data-res="${esc(r.id)}" data-visible="${r.visible?0:1}">${r.visible?"הסתרה מהתלמידים":"הצגה לתלמידים"}</button>
2252
+ <button class="mx-link" data-act="res-delete" data-res="${esc(r.id)}">מחיקה</button></div>`:""}</article>`;
2253
  }
2254
  function drawResources(){
2255
  const items=RES.items, was=sheet.querySelector(".mx-res-form"); // a redraw keeps what was filled in
2256
+ const filled=was&&{kind:was.elements.kind.value,note:was.elements.note.value,skill:was.elements.skill.value,
2257
+ visible:was.elements.visible.checked,touched:!!was.elements.visible.dataset.touched};
2258
  const list=items===null?'<p class="mx-note">טוענים את המשאבים…</p>':items.length?`<div class="mx-res-list">${items.map(resourceItem).join("")}</div>`
2259
  :'<p class="mx-note">עוד אין משאבים לשיעור הזה. צלמו תרגיל או הסבר כדי להוסיף.</p>';
2260
  sheet.innerHTML=`<div class="mx-scrim" data-act="close"></div>
 
2271
  const form=sheet.querySelector(".mx-res-form");
2272
  if(form){
2273
  const note=form.elements.note;
2274
+ const visible=form.elements.visible; // shown to students: a theory explanation by default, until the teacher decides
2275
+ 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";
2276
+ visible.checked=filled.visible; if(filled.touched) visible.dataset.touched="1"; }
2277
+ form.addEventListener("change",event=>{
2278
+ if(event.target===visible){ visible.dataset.touched="1"; return; }
2279
+ note.hidden=form.elements.kind.value!=="other"; if(!note.hidden) note.focus();
2280
+ if(!visible.dataset.touched) visible.checked=form.elements.kind.value==="theory";
2281
+ });
2282
  form.onsubmit=event=>{ event.preventDefault(); saveResource(form); };
2283
  }
2284
  (items||[]).forEach(r=>{ if(r.id in RES.urls) showResourceImage(r.id); else loadResourceImage(RES,r.id); });
 
2320
  if(kind==="other"&&!note){ status.textContent="כתבו בכמה מילים מה יש בתמונה."; form.elements.note.focus(); return; }
2321
  mine.busy=true; form.querySelectorAll("button").forEach(b=>b.disabled=true); status.textContent="שומרים…";
2322
  try{
2323
+ 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); body.append("visible",form.elements.visible.checked?"1":"0");
2324
  const res=await api(`/api/math/units/${encodeURIComponent(mine.unit.id)}/resources`,{method:"POST",body}), data=await res.json().catch(()=>({}));
2325
  if(!res.ok) throw new Error(resError(res.status,"לא הצלחנו לשמור. נסו שוב."));
2326
  mine.urls[data.id]=URL.createObjectURL(mine.photo); // the saved photo, without downloading it again
 
2330
  mine.busy=false;
2331
  if(RES===mine&&cur==="resources") drawResources();
2332
  }
2333
+ async function setResourceVisible(id,visible){
2334
+ const mine=RES; if(!mine) return;
2335
+ try{
2336
+ const res=await api(`/api/math/resources/${encodeURIComponent(id)}`,{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({visible})}), data=await res.json().catch(()=>({}));
2337
+ if(!res.ok) throw new Error(resError(res.status,"לא הצלחנו לשנות. נסו שוב."));
2338
+ const i=mine.items.findIndex(r=>r.id===id); if(i>=0) mine.items[i]={...mine.items[i],visible:data.visible};
2339
+ mine.msg=data.visible?"התמונה גלויה עכשיו לתלמידים.":"התמונה מוסתרת מהתלמידים.";
2340
+ }catch(e){ mine.msg=e.message; }
2341
+ if(RES===mine&&cur==="resources") drawResources();
2342
+ }
2343
  async function deleteResource(id){
2344
  const mine=RES; if(!mine||!confirm("למחוק את התמונה ממשאבי השיעור?")) return;
2345
  try{
supabase/migrations/20261104150000_math_teacher_reviews.sql ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- The class teacher's review of a student's checked open answer (app/teacher_review.py): a score of their own and a
2
+ -- comment, and the student's request to look again. Additive: two tables and two functions, and the quiz-sitting
3
+ -- function now also reads the teacher's score. Apply after the quiz-sittings migration.
4
+ begin;
5
+ create table public.math_teacher_reviews (
6
+ id uuid primary key default gen_random_uuid(),
7
+ user_id uuid not null references auth.users(id) on delete cascade, -- the student
8
+ attempt_id uuid not null,
9
+ reviewer_email text not null,
10
+ score integer check (score between 0 and 100),
11
+ comment text not null default '' check (char_length(comment) <= 1000),
12
+ created_at timestamptz not null default clock_timestamp(),
13
+ seen_at timestamptz, -- when the student opened the answer after it
14
+ foreign key (attempt_id, user_id) references public.math_question_attempts(id, user_id) on delete cascade,
15
+ check (score is not null or comment <> '')
16
+ );
17
+ -- a newer review replaces the older one: the latest of an attempt counts, the rest is history
18
+ create index math_teacher_reviews_attempt on public.math_teacher_reviews(attempt_id, created_at desc, id desc);
19
+ create index math_teacher_reviews_unseen on public.math_teacher_reviews(user_id) where seen_at is null;
20
+
21
+ create table public.math_review_requests (
22
+ id uuid primary key default gen_random_uuid(),
23
+ user_id uuid not null references auth.users(id) on delete cascade,
24
+ attempt_id uuid not null,
25
+ message text not null default '' check (char_length(message) <= 300),
26
+ state text not null default 'open' check (state in ('open', 'closed')),
27
+ created_at timestamptz not null default clock_timestamp(),
28
+ closed_at timestamptz,
29
+ foreign key (attempt_id, user_id) references public.math_question_attempts(id, user_id) on delete cascade
30
+ );
31
+ create unique index math_review_one_open on public.math_review_requests(attempt_id) where state = 'open';
32
+ create index math_review_requests_open on public.math_review_requests(user_id) where state = 'open';
33
+ alter table public.math_teacher_reviews enable row level security;
34
+ alter table public.math_review_requests enable row level security;
35
+ revoke all on public.math_teacher_reviews, public.math_review_requests from public, anon, authenticated;
36
+ grant all on public.math_teacher_reviews, public.math_review_requests to service_role;
37
+
38
+ -- an attempt's latest review and latest request
39
+ create function public.math_review_json(p_attempt uuid) returns jsonb
40
+ language sql stable security invoker set search_path='' as $$
41
+ select jsonb_build_object(
42
+ 'review', (select to_jsonb(r) from (select id, reviewer_email, score, comment, created_at, seen_at
43
+ from public.math_teacher_reviews where attempt_id = p_attempt order by created_at desc, id desc limit 1) r),
44
+ 'request', (select to_jsonb(q) from (select id, message, state, created_at, closed_at
45
+ from public.math_review_requests where attempt_id = p_attempt order by created_at desc, id desc limit 1) q))
46
+ $$;
47
+
48
+ create function public.math_review_action(p_user uuid, p_action text, p_data jsonb default '{}')
49
+ returns jsonb language plpgsql security invoker set search_path='' as $$
50
+ declare
51
+ a public.math_question_attempts;
52
+ q public.math_review_requests;
53
+ begin
54
+ if p_action = 'owner' then -- whose answer this is (the teacher's routes then act as the student)
55
+ select * into a from public.math_question_attempts where id = (p_data->>'attempt_id')::uuid;
56
+ if a.id is null then raise exception 'not_found' using errcode = 'P0002'; end if;
57
+ return to_jsonb(a.user_id);
58
+ elsif p_action = 'requests' then -- open requests to look again; `users` null: everyone's
59
+ return coalesce((select jsonb_agg(jsonb_build_object('id', r.id, 'attempt_id', r.attempt_id, 'user_id', r.user_id,
60
+ 'message', r.message, 'created_at', r.created_at, 'question', coalesce(t.snapshot->>'name', left(t.snapshot->>'question', 80)), 'question_id', t.question_id)
61
+ order by r.created_at)
62
+ from public.math_review_requests r
63
+ join public.math_question_attempts t on t.id = r.attempt_id and t.user_id = r.user_id
64
+ where r.state = 'open' and (p_data->'users' is null
65
+ or r.user_id in (select u.x::uuid from jsonb_array_elements_text(p_data->'users') as u(x)))), '[]'::jsonb);
66
+ end if;
67
+ if p_user is null then raise exception 'not_found' using errcode = 'P0002'; end if;
68
+ if p_action = 'unseen' then -- reviews the student has not opened since
69
+ return coalesce((select jsonb_agg(jsonb_build_object('attempt_id', r.attempt_id, 'question', coalesce(t.snapshot->>'name', left(t.snapshot->>'question', 80)),
70
+ 'question_id', t.question_id, 'score', r.score, 'comment', r.comment, 'created_at', r.created_at)
71
+ order by r.created_at desc)
72
+ from (select distinct on (attempt_id) * from public.math_teacher_reviews where user_id = p_user
73
+ order by attempt_id, created_at desc, id desc) r
74
+ join public.math_question_attempts t on t.id = r.attempt_id and t.user_id = r.user_id
75
+ where r.seen_at is null), '[]'::jsonb);
76
+ end if;
77
+ select * into a from public.math_question_attempts where id = (p_data->>'attempt_id')::uuid and user_id = p_user;
78
+ if a.id is null then raise exception 'not_found' using errcode = 'P0002'; end if;
79
+ if p_action = 'get' then
80
+ return public.math_review_json(a.id);
81
+ elsif p_action = 'seen' then
82
+ update public.math_teacher_reviews set seen_at = clock_timestamp() where attempt_id = a.id and seen_at is null;
83
+ return public.math_review_json(a.id);
84
+ elsif p_action in ('review', 'request') then
85
+ if not exists(select 1 from public.math_answer_attachments where attempt_id = a.id) then
86
+ raise exception 'answer_required' using errcode = 'P0001';
87
+ end if;
88
+ if p_action = 'review' then
89
+ insert into public.math_teacher_reviews(user_id, attempt_id, reviewer_email, score, comment)
90
+ values (p_user, a.id, p_data->>'reviewer', (p_data->>'score')::int, coalesce(p_data->>'comment', ''));
91
+ update public.math_review_requests set state = 'closed', closed_at = clock_timestamp()
92
+ where attempt_id = a.id and state = 'open';
93
+ else
94
+ select * into q from public.math_review_requests where attempt_id = a.id and state = 'open';
95
+ if q.id is null then
96
+ -- serialize this student's requests, so the limit holds across workers
97
+ perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtextextended('review:' || p_user::text, 0));
98
+ if (select count(*) from public.math_review_requests where user_id = p_user and state = 'open') >= 5 then
99
+ raise exception 'review_limit' using errcode = 'P0001';
100
+ end if;
101
+ insert into public.math_review_requests(user_id, attempt_id, message)
102
+ values (p_user, a.id, coalesce(p_data->>'message', ''));
103
+ end if;
104
+ end if;
105
+ return public.math_review_json(a.id);
106
+ end if;
107
+ raise exception 'invalid_action';
108
+ end $$;
109
+ revoke all on function public.math_review_json(uuid) from public, anon, authenticated;
110
+ revoke all on function public.math_review_action(uuid, text, jsonb) from public, anon, authenticated;
111
+ grant execute on function public.math_review_json(uuid) to service_role;
112
+ grant execute on function public.math_review_action(uuid, text, jsonb) to service_role;
113
+
114
+ -- the quiz sitting also carries each answer's teacher score, so its grade can use it (app/quiz_grades.py)
115
+ create or replace function public.math_quiz_sitting_json(p_id uuid) returns jsonb
116
+ language sql stable security invoker set search_path='' as $$
117
+ select jsonb_build_object('id', s.id, 'user_id', s.user_id, 'unit_id', s.unit_id,
118
+ 'submitted_at', s.submitted_at, 'time_up', s.time_up,
119
+ 'attempts', (select coalesce(jsonb_agg(jsonb_build_object(
120
+ 'attempt_id', x.aid, 'question_id', a.question_id, 'has_photo', att.id is not null,
121
+ 'grade_state', g.state, 'score', g.result->'score', 'verdict', g.result->>'verdict',
122
+ 'teacher_score', (select r.score from public.math_teacher_reviews r where r.attempt_id = x.aid
123
+ order by r.created_at desc, r.id desc limit 1)) order by x.ord), '[]'::jsonb)
124
+ from unnest(s.attempt_ids) with ordinality as x(aid, ord)
125
+ left join public.math_question_attempts a on a.id = x.aid
126
+ left join public.math_answer_attachments att on att.attempt_id = x.aid
127
+ left join lateral (select state, result from public.math_answer_grades
128
+ where attempt_id = x.aid order by created_at desc, id desc limit 1) g on true))
129
+ from public.math_quiz_sittings s where s.id = p_id
130
+ $$;
131
+ commit;
supabase/migrations/20261104180000_math_resources_visible.sql ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ -- Lesson resources a teacher chooses to show students (app/unit_resources.py). Additive: one column. Every resource
2
+ -- added before this starts hidden, so nothing a teacher photographed for themselves reaches a student by surprise.
3
+ begin;
4
+ alter table public.math_unit_resources add column visible boolean not null default false;
5
+ create index math_unit_resources_visible on public.math_unit_resources(unit_id, created_at desc) where visible;
6
+ commit;
supabase/migrations/20261104210000_math_assignments.sql ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- Assignments a class teacher gives a class (app/assignments.py): skills to finish, a unit quiz, a practice exam, by a
2
+ -- date. Additive: one new table, read and written only with the server's secret key.
3
+ begin;
4
+ create table public.math_assignments (
5
+ id uuid primary key default gen_random_uuid(),
6
+ class_id bigint not null references public.classes(id) on delete cascade,
7
+ title text not null check (char_length(title) between 1 and 80),
8
+ note text not null default '' check (char_length(note) <= 500),
9
+ due_on date not null,
10
+ items jsonb not null check (jsonb_typeof(items) = 'array' and jsonb_array_length(items) between 1 and 20),
11
+ created_by_email text not null default '',
12
+ created_at timestamptz not null default now(),
13
+ updated_at timestamptz not null default now()
14
+ );
15
+ create index math_assignments_class on public.math_assignments(class_id, due_on desc);
16
+ alter table public.math_assignments enable row level security;
17
+ revoke all on public.math_assignments from public, anon, authenticated;
18
+ grant all on public.math_assignments to service_role;
19
+ commit;
tests/scan_support.py CHANGED
@@ -48,6 +48,9 @@ class ScanAuth:
48
  def classes(self):
49
  return []
50
 
 
 
 
51
  def access_for(self, _id, **kwargs):
52
  return None
53
 
@@ -71,6 +74,9 @@ class DatabaseAnswers(AnswerScans):
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':
@@ -84,7 +90,7 @@ class DatabaseAnswers(AnswerScans):
84
  raise ScanError(404, 'not_found') from exc
85
  except psycopg.errors.RaiseException as exc:
86
  message = str(exc).splitlines()[0]
87
- raise ScanError(429 if message == 'grading_limit' else 409, message) from exc
88
 
89
  def put(self, key, data):
90
  if self.fail_put:
@@ -129,8 +135,9 @@ def answer_store(postgres_server, tmp_path):
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:
 
48
  def classes(self):
49
  return []
50
 
51
+ def user_email(self, user_id):
52
+ return user_id + '@test.invalid'
53
+
54
  def access_for(self, _id, **kwargs):
55
  return None
56
 
 
74
  def quiz(self, user_id, action, **data):
75
  return self.rpc('math_quiz_action', user_id, action, data)
76
 
77
+ def review(self, user_id, action, **data):
78
+ return self.rpc('math_review_action', user_id, action, data)
79
+
80
  def rpc(self, name, user_id, action, data):
81
  import psycopg
82
  if self.fail_finalize and action == 'finalize':
 
90
  raise ScanError(404, 'not_found') from exc
91
  except psycopg.errors.RaiseException as exc:
92
  message = str(exc).splitlines()[0]
93
+ raise ScanError(429 if message in ('grading_limit', 'review_limit') else 409, message) from exc
94
 
95
  def put(self, key, data):
96
  if self.fail_put:
 
135
  if migration.name.endswith('_math_answer_scans.sql'):
136
  continue
137
  c.execute(migration.read_text())
138
+ for name in ('quiz_sittings', 'teacher_reviews'): # in this order: the second rewrites a function of the first
139
+ for migration in sorted(Path('supabase/migrations').glob(f'*_math_{name}.sql')):
140
+ c.execute(migration.read_text())
141
  c.execute('insert into auth.users values(%s),(%s)', (OWNER, OTHER))
142
  yield store
143
  with psycopg.connect(postgres_server.get_uri(), autocommit=True) as c:
tests/test_ai_queue.py CHANGED
@@ -353,8 +353,11 @@ def test_api_errors_are_named(status, message, reason):
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'):
@@ -362,10 +365,35 @@ class ReportAuth(ClassAuth):
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())
@@ -380,24 +408,7 @@ def test_a_quiz_checked_through_the_teachers_queue_has_one_grade_for_student_and
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():
@@ -413,3 +424,104 @@ def test_a_quiz_checked_through_the_teachers_queue_has_one_grade_for_student_and
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())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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_accounts(self, emails):
357
+ return {mail(uid): {'id': uid, 'name': ''} for uid in (OWNER, OTHER) if mail(uid) in emails}
358
+
359
+ def user_email(self, user_id):
360
+ return mail(user_id)
361
 
362
  def _admin(self, path, *args, **kwargs):
363
  if path.startswith('/rest/v1/math_progress'):
 
365
  return super()._admin(path, *args, **kwargs)
366
 
367
 
368
+ async def hand_in_quiz(student, unit):
369
+ """The student photographs the four answers of a unit quiz, hands it in and asks each answer to be checked (as the
370
+ page does). Returns the attempt ids, the queue request ids and the sitting."""
371
+ from app.math_skills import question_version, quiz_snapshot
372
+ attempts, requests = [], []
373
+ for index in range(4):
374
+ version = question_version(quiz_snapshot(unit, index))
375
+ made = (await student.post('/api/math/quiz-attempts',
376
+ json={'unit': unit['id'], 'index': index, 'question_version': version, 'new': True})).json()
377
+ aid = made['attempt']['id']
378
+ scan = (await student.post(f'/api/math/answer-attempts/{aid}/scans', json={'renew': True})).json()['scan']
379
+ await student.post(f"/api/math/answer-scans/{scan['id']}/confirm")
380
+ lease = (await student.post(f"/api/math/answer-scans/{scan['id']}/uploads", json={'key': str(uuid.uuid4())})).json()
381
+ up = await student.post(f"/api/math/answer-scans/{scan['id']}/uploads/{lease['lease_id']}",
382
+ files={'photo': ('a.png', png(), 'image/png')})
383
+ assert up.status_code == 200
384
+ attempts.append(aid)
385
+ handed = (await student.post('/api/math/quiz-sittings', json={'unit_id': unit['id'], 'attempts': attempts,
386
+ 'client_key': str(uuid.uuid4())})).json()
387
+ for aid in attempts:
388
+ asked = (await student.post(f'/api/math/answer-attempts/{aid}/grade', json={})).json()
389
+ requests.append(asked['ai_request']['id'])
390
+ return attempts, requests, handed
391
+
392
+
393
  def test_a_quiz_checked_through_the_teachers_queue_has_one_grade_for_student_and_teacher(answer_store):
394
  """The student hands in a unit quiz; each answer waits in the teacher's queue. The sitting has no grade until the
395
  teacher has sent them all, and then the student's screen and the teacher's class report show the same grade."""
396
+ from app.math_skills import algebra9
397
 
398
  store = answer_store
399
  app = create_app(auth=ReportAuth())
 
408
 
409
  async def check():
410
  async with as_user(app, OWNER) as student, as_user(app, OTHER) as teacher:
411
+ attempts, requests, handed = await hand_in_quiz(student, unit)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
412
  assert handed['grade'] is None and handed['open'] == 4 # every answer waits for the teacher
413
 
414
  def seen_by_teacher():
 
424
  report = (await seen_by_teacher()).json()['students'][0]['quiz_sittings'][0]
425
  assert mine['grade'] == report['grade'] == 25 + 15 + 25 + 10 and mine['id'] == report['id'] == handed['id']
426
  asyncio.run(check())
427
+
428
+
429
+ def test_a_student_disputes_a_check_and_the_teachers_correction_reaches_both_screens(answer_store):
430
+ """The whole path: the student hands in a quiz, the teacher sends every answer to the AI, the student asks the
431
+ teacher to look again at one, the teacher corrects it with a comment, and the new grade, the comment and the closed
432
+ request show on the student's screen and in the teacher's class report."""
433
+ from app.math_skills import algebra9
434
+
435
+ app = create_app(auth=ReportAuth())
436
+ app.state.answers, app.state.ai_queue = answer_store, MemoryQueue()
437
+ scores = {0: 100, 1: 60, 2: 100, 3: 40}
438
+
439
+ class ByQuestion:
440
+ def grade(self, image, snapshot):
441
+ return validate_assessment(assessment(score=scores[int(snapshot['name'][-1]) - 1]))
442
+ app.state.answer_grader = ByQuestion()
443
+ unit = next(u for u in algebra9()['units'] if u['id'] == 't')
444
+
445
+ async def run():
446
+ async with as_user(app, OWNER) as student, as_user(app, OTHER) as teacher:
447
+ attempts, requests, handed = await hand_in_quiz(student, unit)
448
+ for rid in requests:
449
+ assert (await teacher.post(f'/api/ai-queue/{rid}/send')).status_code == 200
450
+ sittings = lambda: student.get('/api/math/quiz-sittings', params={'unit_id': 't'})
451
+ card = lambda: teacher.get('/api/classes/1/report')
452
+ assert (await sittings()).json()['sittings'][0]['grade'] == 25 + 15 + 25 + 10 # the AI's: 75
453
+ # the student disagrees with the second check
454
+ asked = await student.post(f'/api/math/answer-attempts/{attempts[1]}/review-request', json={'message': 'כתבתי x=4'})
455
+ assert asked.status_code == 200
456
+ waiting = (await teacher.get('/api/teacher/review-requests')).json()['requests']
457
+ assert [(x['attempt_id'], x['student'], x['message']) for x in waiting] == [(attempts[1], mail(OWNER), 'כתבתי x=4')]
458
+ assert (await card()).json()['students'][0]['review_requests'][0]['attempt_id'] == attempts[1]
459
+ opened = (await teacher.get(f'/api/teacher/answers/{attempts[1]}')).json()
460
+ assert opened['grading']['result']['score'] == 60 and opened['request']['state'] == 'open'
461
+ assert (await teacher.get(f'/api/teacher/answers/{attempts[1]}/image')).status_code == 200
462
+ # the teacher agrees with the student
463
+ saved = await teacher.put(f'/api/teacher/answers/{attempts[1]}/review', json={'score': 100, 'comment': 'צדקתם, הפתרון נכון'})
464
+ assert saved.status_code == 200 and saved.json()['request']['state'] == 'closed'
465
+ # the student's screen: the new grade, the teacher's words, the AI's check unchanged, the request closed
466
+ mine = (await sittings()).json()['sittings'][0]
467
+ assert mine['grade'] == 25 + 25 + 25 + 10 and mine['questions'][1]['state'] == 'teacher'
468
+ assert [c['comment'] for c in (await student.get('/api/math/teacher-comments')).json()['comments']] == ['צדקתם, הפתרון נכון']
469
+ answer = (await student.get(f'/api/math/answer-attempts/{attempts[1]}')).json()
470
+ assert answer['teacher_review']['score'] == 100 and answer['grading']['result']['score'] == 60
471
+ assert answer['review_request']['state'] == 'closed' and answer['effective_score'] == 100
472
+ assert (await student.get('/api/math/teacher-comments')).json() == {'comments': []}
473
+ # the teacher's report: the same grade, and nobody waiting
474
+ report = (await card()).json()['students'][0]
475
+ assert report['quiz_sittings'][0]['grade'] == mine['grade'] == 85 and report['quiz_sittings'][0]['id'] == handed['id']
476
+ assert report['review_requests'] == []
477
+ assert (await teacher.get('/api/teacher/review-requests')).json() == {'requests': []}
478
+ asyncio.run(run())
479
+
480
+
481
+ def test_handing_a_quiz_in_queues_every_photographed_answer_for_the_teacher_without_the_page_asking(answer_store):
482
+ """A student's answers all reach their teacher at hand-in, even if the page never asks for the checks one by one."""
483
+ from app.math_skills import algebra9, question_version, quiz_snapshot
484
+ from test_unit_quiz import quiz_answer
485
+
486
+ app = make_app(answer_store)
487
+ app.state.auth = ReportAuth()
488
+ unit = next(u for u in algebra9()['units'] if u['id'] == 't')
489
+ ids = [quiz_answer(answer_store, i, photo=i != 2) for i in range(4)] # the third question has no photo
490
+ key = str(uuid.uuid4())
491
+
492
+ async def run():
493
+ async with as_user(app, OWNER) as student, as_user(app, OTHER) as teacher:
494
+ r = await student.post('/api/math/quiz-sittings', json={'unit_id': 't', 'attempts': ids, 'client_key': key})
495
+ assert r.status_code == 200
496
+ rows = (await teacher.get('/api/ai-queue')).json()['requests']
497
+ assert sorted(x['label'][-1] for x in rows) == ['1', '2', '4'] and {x['state'] for x in rows} == {'pending'}
498
+ assert {x['attempt_id'] for x in rows} == {ids[0], ids[1], ids[3]} and all(x['kind'] == 'grading' for x in rows)
499
+ # handing the same quiz in again changes nothing, and the page's own checks find the requests waiting
500
+ assert (await student.post('/api/math/quiz-sittings', json={'unit_id': 't', 'attempts': ids, 'client_key': key})).status_code == 200
501
+ assert len((await teacher.get('/api/ai-queue')).json()['requests']) == 3
502
+ asked = (await student.post(f'/api/math/answer-attempts/{ids[0]}/grade', json={})).json()
503
+ assert asked['ai_request']['state'] == 'pending' and len((await teacher.get('/api/ai-queue')).json()['requests']) == 3
504
+ # an answer the teacher declined stays declined when the hand-in is repeated
505
+ declined = rows[0]['id']
506
+ await teacher.post(f'/api/ai-queue/{declined}/decline', json={'note': 'צלמו שוב'})
507
+ await student.post('/api/math/quiz-sittings', json={'unit_id': 't', 'attempts': ids, 'client_key': key})
508
+ states = {x['id']: x['state'] for x in (await teacher.get('/api/ai-queue')).json()['requests']}
509
+ assert states[declined] == 'declined' and len(states) == 3
510
+ asyncio.run(run())
511
+ assert len(app.state.ai_queue.rows) == 3
512
+
513
+
514
+ def test_a_teachers_own_hand_in_does_not_go_through_the_queue(answer_store):
515
+ """Accounts that send straight to the AI keep doing so: the hand-in queues nothing for them."""
516
+ from test_unit_quiz import quiz_answer
517
+
518
+ app = make_app(answer_store)
519
+ app.state.auth = ReportAuth()
520
+ app.state.auth.teachers = frozenset({mail(OWNER)}) # OWNER has the teacher role now
521
+ ids = [quiz_answer(answer_store, i) for i in range(4)]
522
+
523
+ async def run():
524
+ async with as_user(app, OWNER) as me:
525
+ assert (await me.post('/api/math/quiz-sittings', json={'unit_id': 't', 'attempts': ids, 'client_key': str(uuid.uuid4())})).status_code == 200
526
+ asyncio.run(run())
527
+ assert app.state.ai_queue.rows == []
tests/test_answer_browser.py CHANGED
@@ -451,3 +451,55 @@ def test_the_phone_answers_a_photo_request_by_drawing_on_the_board(scan_server):
451
  assert not errors
452
  finally:
453
  browser.close()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
451
  assert not errors
452
  finally:
453
  browser.close()
454
+
455
+
456
+ @pytest.mark.parametrize('teacher_score,completes', [(100, True), (85, False)])
457
+ def test_the_teachers_review_shows_above_the_ai_check_and_a_100_completes_the_skill(scan_server, teacher_score, completes):
458
+ import uuid
459
+ url, store = scan_server
460
+ from test_answer_scans import begin, finish
461
+ from app.math_skills import algebra9, question_version
462
+ skill = next(s for s in algebra9()['skills'] if s['id'] == 'a_30_side')
463
+ made = store.action(OWNER, 'resume', question_id=skill['id'],
464
+ question_version=question_version(skill['sample']), snapshot=skill['sample'])
465
+ aid = finish(store, begin(store, store.action(OWNER, 'create_scan', attempt_id=made['attempt']['id'])))['attempt']['id']
466
+ lease = str(uuid.uuid4()) # the AI called it partial: 60
467
+ store.grade(OWNER, 'start', attempt_id=aid, lease_id=lease)
468
+ store.grade(OWNER, 'complete', attempt_id=aid, lease_id=lease, result=validate_assessment(
469
+ assessment(complete=False, score=60, feedback='חסר פירוט בשלב האחרון')))
470
+ store.review(OWNER, 'review', attempt_id=aid, reviewer='teacher@test.invalid', score=teacher_score,
471
+ comment='הדרך נכונה <b>מאוד</b>')
472
+ key = 'mathly.progress.v1:' + OWNER + '@test.invalid'
473
+ with playwright.sync_playwright() as p:
474
+ browser = p.chromium.launch()
475
+ try:
476
+ page = session(browser).new_page(); errors = []
477
+ page.on('pageerror', lambda error: errors.append(str(error)))
478
+ page.goto(url); open_question(page)
479
+ note = page.locator('.mx-teacher-note')
480
+ playwright.expect(note).to_contain_text('הערת המורה')
481
+ playwright.expect(note).to_contain_text(f'{teacher_score}/100')
482
+ playwright.expect(note).to_contain_text('הדרך נכונה <b>מאוד</b>') # shown as text, never as markup
483
+ assert note.locator('b').count() == 0
484
+ grade = page.locator('.mx-grade')
485
+ playwright.expect(grade.locator('h4')).to_contain_text('בדיקה אוטומטית: ')
486
+ playwright.expect(grade.locator('h4')).to_contain_text('60/100') # the AI's own score stays visible
487
+ assert page.locator('.mx-teacher-note, .mx-grade').first.get_attribute('class').startswith('mx-teacher-note') # above it
488
+ page.wait_for_timeout(500)
489
+ assert page.evaluate("key=>!!JSON.parse(localStorage.getItem(key)||'{}').done?.a_30_side", key) == completes
490
+ for _ in range(6): # a finished skill is celebrated: dismiss it
491
+ if not page.locator('.sky-cele [data-sky=go]').count():
492
+ break
493
+ page.locator('.sky-cele [data-sky=go]').first.click(); page.wait_for_timeout(200)
494
+ # the student asks the teacher to look again
495
+ page.locator('.mx-ask summary').click()
496
+ page.locator('[data-ask-msg]').fill('כתבתי את כל השלבים')
497
+ page.locator('[data-ask-review]').click()
498
+ playwright.expect(page.locator('.mx-ask-wait')).to_contain_text('הבקשה ממתינה')
499
+ asked = store.review(OWNER, 'get', attempt_id=aid)['request']
500
+ assert asked['state'] == 'open' and asked['message'] == 'כתבתי את כל השלבים'
501
+ page.reload(); open_question(page)
502
+ playwright.expect(page.locator('.mx-ask-wait')).to_contain_text('הבקשה ממתינה') # and it is remembered
503
+ assert page.locator('[data-ask-review]').count() == 0 and not errors
504
+ finally:
505
+ browser.close()
tests/test_assignments.py ADDED
@@ -0,0 +1,400 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Assignments: what a class teacher asks their class to do. Storage is in memory; the migration runs on real PostgreSQL."""
2
+ import asyncio
3
+ import uuid
4
+ from pathlib import Path
5
+
6
+ import httpx
7
+ import pytest
8
+
9
+ from app.answer_scans import ScanError
10
+ from app.assignments import Assignments, AssignmentsUnavailable
11
+ from app.main import create_app
12
+ from app.math_skills import algebra9
13
+ from scan_support import BOOTSTRAP, answer_store, postgres_server
14
+
15
+ TEACHER, STUDENT, OTHER_TEACHER, OUTSIDER, MANAGER, PUPIL4 = "t-id", "s-id", "o-id", "x-id", "m-id", "p-id"
16
+ EMAILS = {TEACHER: "teacher@x.test", STUDENT: "student@x.test", OTHER_TEACHER: "other@x.test",
17
+ OUTSIDER: "out@x.test", MANAGER: "boss@x.test", PUPIL4: "pupil4@x.test"}
18
+ UNITS = algebra9()["units"]
19
+ NO_QUIZ = next(u["id"] for u in UNITS if "quiz" not in u)
20
+
21
+
22
+ class ClassAuth:
23
+ url, key, secret = "https://test.invalid", "public-test", "server-test"
24
+ developers = frozenset()
25
+ teachers = frozenset({"boss@x.test"}) # a manager
26
+
27
+ def __init__(self):
28
+ self.rows = [
29
+ {"id": 3, "name": "ט׳1", "lessons": ["t", "k", NO_QUIZ], "teachers": ["teacher@x.test"],
30
+ "emails": ["student@x.test", "teacher@x.test"], "nicknames": {}, "exams": []},
31
+ {"id": 4, "name": "ט׳2", "lessons": ["t"], "teachers": ["other@x.test"],
32
+ "emails": ["pupil4@x.test", "other@x.test"], "nicknames": {}, "exams": []}]
33
+
34
+ def user(self, token):
35
+ return {"id": token, "email": EMAILS[token]} if token in EMAILS else None
36
+
37
+ def classes(self, fresh=False):
38
+ return self.rows
39
+
40
+ def access_for(self, _id, **kwargs):
41
+ return None
42
+
43
+
44
+ class MemoryAssignments(Assignments):
45
+ configured = True
46
+
47
+ def __init__(self, fail=False):
48
+ self.rows, self.fail = {}, fail
49
+
50
+ def _check(self):
51
+ if self.fail:
52
+ raise AssignmentsUnavailable("assignments_unavailable")
53
+
54
+ def for_classes(self, class_ids):
55
+ self._check()
56
+ return sorted((r for r in self.rows.values() if r["class_id"] in class_ids), key=lambda r: r["due_on"], reverse=True)
57
+
58
+ def get(self, assignment_id):
59
+ self._check()
60
+ return self.rows.get(assignment_id)
61
+
62
+ def add(self, row):
63
+ self._check()
64
+ row = {**row, "id": str(uuid.uuid4()), "created_at": "2026-11-04T08:00:00+00:00", "updated_at": "2026-11-04T08:00:00+00:00"}
65
+ self.rows[row["id"]] = row
66
+ return row
67
+
68
+ def update(self, assignment_id, fields):
69
+ self._check()
70
+ self.rows[assignment_id] = {**self.rows[assignment_id], **fields}
71
+ return self.rows[assignment_id]
72
+
73
+ def remove(self, assignment_id):
74
+ self._check()
75
+ self.rows.pop(assignment_id, None)
76
+
77
+
78
+ class Sittings:
79
+ """The server's unit-quiz sittings (app/quiz_grades.py): quiz(user, "list", unit_id=...)."""
80
+ configured = True
81
+
82
+ def __init__(self, by_user=None, fail=False):
83
+ self.by_user, self.fail = by_user or {}, fail
84
+
85
+ def quiz(self, user_id, action, **data):
86
+ if self.fail:
87
+ raise ScanError(503, "quiz_sittings_unavailable")
88
+ return [s for s in self.by_user.get(user_id, []) if s["unit_id"] == data["unit_id"]]
89
+
90
+
91
+ def make_app(store=None, sittings=None):
92
+ app = create_app(auth=ClassAuth())
93
+ app.state.assignments = store or MemoryAssignments()
94
+ app.state.answers = sittings or Sittings()
95
+ return app
96
+
97
+
98
+ def client(app, user):
99
+ return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test",
100
+ headers={"Authorization": f"Bearer {user}"})
101
+
102
+
103
+ GOOD = {"title": "חזרה לפני המבחן", "note": "בהצלחה", "due_on": "2026-11-12",
104
+ "items": [{"kind": "skill", "id": "t_ext"}, {"kind": "unit_quiz", "unit": "k"},
105
+ {"kind": "exam", "units": ["k", "t"]}]} # exam units are kept sorted
106
+
107
+
108
+ def test_the_class_teacher_creates_lists_edits_and_deletes_assignments():
109
+ store = MemoryAssignments()
110
+ app = make_app(store)
111
+
112
+ async def check():
113
+ async with client(app, TEACHER) as teacher:
114
+ made = await teacher.post("/api/classes/3/assignments", json=GOOD)
115
+ assert made.status_code == 201
116
+ row = made.json()
117
+ assert (row["title"], row["note"], row["due_on"], row["class_id"]) == ("חזרה לפני המבחן", "בהצלחה", "2026-11-12", 3)
118
+ assert row["items"] == GOOD["items"] and row["created_by_email"] == "teacher@x.test"
119
+ assert [r["id"] for r in store.rows.values()] == [row["id"]] and store.rows[row["id"]]["class_id"] == 3
120
+ listed = (await teacher.get("/api/classes/3/assignments")).json()["assignments"]
121
+ assert [r["id"] for r in listed] == [row["id"]]
122
+ edited = await teacher.put(f"/api/classes/3/assignments/{row['id']}",
123
+ json={**GOOD, "title": " חזרה מורחבת ", "due_on": "2026-11-14",
124
+ "items": [{"kind": "skill", "id": "t_ext"}, {"kind": "skill", "id": "t_ext"}]})
125
+ assert edited.status_code == 200
126
+ assert (edited.json()["title"], edited.json()["due_on"], edited.json()["items"]) == (
127
+ "חזרה מורחבת", "2026-11-14", [{"kind": "skill", "id": "t_ext"}]) # spacing tidied, a repeated item once
128
+ gone = await teacher.delete(f"/api/classes/3/assignments/{row['id']}")
129
+ assert gone.json() == {"ok": True} and not store.rows
130
+ assert (await teacher.delete(f"/api/classes/3/assignments/{row['id']}")).status_code == 404
131
+ asyncio.run(check())
132
+ assert [a["kind"] for a in app.state.actions.recent()].count("assignment_saved") == 2
133
+
134
+
135
+ def test_an_assignment_is_checked_against_the_classs_units():
136
+ app = make_app()
137
+ skills = algebra9()["skills"]
138
+ class_skills = [s["id"] for s in skills if s["unit"] in ("t", "t2", "k", "i", NO_QUIZ)]
139
+ with_items = lambda *items: {**GOOD, "items": list(items)}
140
+ bad = [
141
+ with_items({"kind": "skill", "id": "no_such_skill"}),
142
+ with_items({"kind": "unit_quiz", "unit": NO_QUIZ}), # a unit without a quiz
143
+ with_items({"kind": "exam", "units": []}),
144
+ with_items({"kind": "exam", "units": ["t", "t"]}),
145
+ with_items({"kind": "homework"}), with_items("t_ext"), with_items(),
146
+ {**GOOD, "items": [{"kind": "skill", "id": sid} for sid in class_skills[:21]]}, # one too many
147
+ {**GOOD, "title": ""}, {**GOOD, "title": " "}, {**GOOD, "title": "א" * 81}, {**GOOD, "note": "א" * 501},
148
+ {**GOOD, "due_on": "2026-13-45"}, {**GOOD, "due_on": "12/11/2026"}, {**GOOD, "due_on": "2026-11-12T10:00:00"},
149
+ {**GOOD, "due_on": ""}, {**GOOD, "due_on": 20261112}, {k: v for k, v in GOOD.items() if k != "due_on"},
150
+ {**GOOD, "items": "t_ext"}]
151
+
152
+ async def check():
153
+ async with client(app, TEACHER) as teacher:
154
+ for body in bad:
155
+ assert (await teacher.post("/api/classes/3/assignments", json=body)).status_code == 400, body
156
+ assert (await teacher.post("/api/classes/3/assignments", json=GOOD)).status_code == 201
157
+ twenty = [{"kind": "skill", "id": sid} for sid in class_skills[:20]]
158
+ assert (await teacher.post("/api/classes/3/assignments", json={**GOOD, "items": twenty})).status_code == 201
159
+ assert len(app.state.assignments.rows) == 2 # nothing else was stored
160
+ async with client(app, OTHER_TEACHER) as other: # class 4 has unit t (and t2, split off from it), not k
161
+ ok = {"kind": "skill", "id": "t_ext"}
162
+ for foreign in ({"kind": "skill", "id": next(s["id"] for s in skills if s["unit"] == "k")},
163
+ {"kind": "unit_quiz", "unit": "k"}, {"kind": "exam", "units": ["t", "k"]}):
164
+ assert (await other.post("/api/classes/4/assignments", json=with_items(ok, foreign))).status_code == 400, foreign
165
+ assert (await other.post("/api/classes/4/assignments", json=with_items(ok, {"kind": "unit_quiz", "unit": "t"}))).status_code == 201
166
+ asyncio.run(check())
167
+
168
+
169
+ def test_only_the_classs_teacher_or_a_manager_may_manage_its_assignments():
170
+ store = MemoryAssignments()
171
+ app = make_app(store)
172
+
173
+ async def check():
174
+ async with client(app, TEACHER) as teacher:
175
+ row = (await teacher.post("/api/classes/3/assignments", json=GOOD)).json()
176
+ for who in (STUDENT, OTHER_TEACHER, OUTSIDER):
177
+ async with client(app, who) as c:
178
+ assert (await c.get("/api/classes/3/assignments")).status_code == 403, who
179
+ assert (await c.post("/api/classes/3/assignments", json=GOOD)).status_code == 403, who
180
+ assert (await c.put(f"/api/classes/3/assignments/{row['id']}", json=GOOD)).status_code == 403
181
+ assert (await c.delete(f"/api/classes/3/assignments/{row['id']}")).status_code == 403
182
+ assert len(store.rows) == 1
183
+ async with client(app, MANAGER) as manager:
184
+ assert (await manager.post("/api/classes/3/assignments", json=GOOD)).status_code == 201
185
+ assert (await manager.post("/api/classes/99/assignments", json=GOOD)).status_code == 404
186
+ async with client(app, OTHER_TEACHER) as other: # their own class, but not another class's assignment
187
+ assert (await other.delete(f"/api/classes/4/assignments/{row['id']}")).status_code == 404
188
+ assert (await other.put(f"/api/classes/4/assignments/{row['id']}", json=GOOD)).status_code == 404
189
+ assert (await other.get("/api/classes/4/assignments")).json() == {"assignments": []}
190
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as guest:
191
+ assert (await guest.get("/api/classes/3/assignments")).status_code == 401
192
+ asyncio.run(check())
193
+
194
+
195
+ def test_students_get_only_their_classs_assignments_and_when_they_handed_in_each_unit_quiz():
196
+ store = MemoryAssignments()
197
+ app = make_app(store, Sittings({STUDENT: [
198
+ {"id": "s1", "unit_id": "k", "submitted_at": "2026-11-05T10:00:00Z", "grade": 80},
199
+ {"id": "s2", "unit_id": "t", "submitted_at": "2026-11-01T10:00:00Z", "grade": 90}]}))
200
+
201
+ async def check():
202
+ async with client(app, TEACHER) as teacher, client(app, OTHER_TEACHER) as other:
203
+ mine = (await teacher.post("/api/classes/3/assignments", json=GOOD)).json()
204
+ await other.post("/api/classes/4/assignments", json={**GOOD, "title": "של כיתה אחרת", "items": [{"kind": "skill", "id": "t_ext"}]})
205
+ async with client(app, STUDENT) as student:
206
+ r = await student.get("/api/math/assignments")
207
+ assert r.status_code == 200
208
+ data = r.json()
209
+ assert [a["id"] for a in data["assignments"]] == [mine["id"]] and data["assignments"][0]["class_name"] == "ט׳1"
210
+ assert "created_by_email" not in data["assignments"][0] and "teacher@x.test" not in r.text # no teacher email
211
+ assert data["sittings"] == [{"unit_id": "k", "submitted_at": "2026-11-05T10:00:00Z"}] # only the assigned unit's
212
+ assert len(data["today"]) == 10
213
+ async with client(app, OUTSIDER) as outsider: # in no class while classes exist: not let in at all
214
+ assert (await outsider.get("/api/math/assignments")).status_code == 403
215
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as guest:
216
+ assert (await guest.get("/api/math/assignments")).status_code == 401
217
+ asyncio.run(check())
218
+
219
+
220
+ def test_without_the_servers_sittings_or_table_the_answers_say_so():
221
+ store = MemoryAssignments()
222
+ app = make_app(store, Sittings(fail=True))
223
+
224
+ async def check():
225
+ async with client(app, TEACHER) as teacher:
226
+ await teacher.post("/api/classes/3/assignments", json=GOOD)
227
+ async with client(app, STUDENT) as student:
228
+ data = (await student.get("/api/math/assignments")).json()
229
+ assert len(data["assignments"]) == 1 and data["sittings"] is None # unit-quiz items cannot be told done
230
+ store.fail = True
231
+ async with client(app, STUDENT) as student, client(app, TEACHER) as teacher:
232
+ r = await student.get("/api/math/assignments")
233
+ assert (r.status_code, r.json()["detail"]) == (503, "assignments_unavailable")
234
+ assert (await teacher.post("/api/classes/3/assignments", json=GOOD)).status_code == 503
235
+ asyncio.run(check())
236
+
237
+
238
+ def test_the_migration_is_additive_service_only_and_follows_its_class(postgres_server):
239
+ psycopg = pytest.importorskip("psycopg")
240
+ database = "asg_" + uuid.uuid4().hex
241
+ with psycopg.connect(postgres_server.get_uri(), autocommit=True) as c:
242
+ c.execute("create database " + database)
243
+ try:
244
+ with psycopg.connect(postgres_server.get_uri(database=database), autocommit=True) as c:
245
+ c.execute(BOOTSTRAP)
246
+ c.execute("insert into classes values (2, 'other class')")
247
+ c.execute(Path("supabase/migrations/20261104210000_math_assignments.sql").read_text())
248
+ assert c.execute("select name from classes order by id").fetchall() == [("existing math class",), ("other class",)]
249
+ c.execute("set role service_role")
250
+ add = lambda cls, items='[{"kind":"skill","id":"t_ext"}]', title="חזרה", due="2026-11-12": c.execute(
251
+ "insert into math_assignments(class_id,title,due_on,items) values(%s,%s,%s,%s::jsonb)", (cls, title, due, items))
252
+ add(1)
253
+ add(1, title="שני")
254
+ add(2)
255
+ for bad in (dict(cls=1, title=""), dict(cls=1, title="א" * 81), dict(cls=1, items="[]"), dict(cls=1, items="{}"),
256
+ dict(cls=1, items="[" + ",".join(['{"kind":"skill","id":"x"}'] * 21) + "]"), dict(cls=99)):
257
+ with pytest.raises(psycopg.errors.Error):
258
+ add(**bad)
259
+ c.execute("reset role")
260
+ c.execute("delete from classes where id = 1") # a deleted class takes its assignments with it
261
+ assert c.execute("select count(*) from math_assignments").fetchone()[0] == 1
262
+ c.execute("set role authenticated")
263
+ with pytest.raises(psycopg.errors.InsufficientPrivilege):
264
+ c.execute("select * from math_assignments")
265
+ finally:
266
+ with psycopg.connect(postgres_server.get_uri(), autocommit=True) as c:
267
+ c.execute("drop database " + database + " with (force)")
268
+
269
+
270
+ def test_a_student_in_two_classes_gets_the_assignments_of_both_and_none_of_a_third():
271
+ app = make_app()
272
+ app.state.auth.rows[1]["emails"].append("student@x.test") # the student is in class 4 as well
273
+ app.state.auth.rows.append({"id": 5, "name": "ט׳3", "lessons": ["t"], "teachers": ["other@x.test"],
274
+ "emails": ["pupil4@x.test"], "nicknames": {}, "exams": []})
275
+
276
+ async def check():
277
+ async with client(app, TEACHER) as teacher, client(app, OTHER_TEACHER) as other:
278
+ await teacher.post("/api/classes/3/assignments", json={**GOOD, "title": "כיתה 3"})
279
+ await other.post("/api/classes/4/assignments", json={**GOOD, "title": "כיתה 4", "items": [{"kind": "skill", "id": "t_ext"}]})
280
+ await other.post("/api/classes/5/assignments", json={**GOOD, "title": "כיתה 5", "items": [{"kind": "skill", "id": "t_ext"}]})
281
+ async with client(app, STUDENT) as student:
282
+ got = (await student.get("/api/math/assignments")).json()["assignments"]
283
+ assert sorted((a["title"], a["class_name"]) for a in got) == [("כיתה 3", "ט׳1"), ("כיתה 4", "ט׳2")]
284
+ asyncio.run(check())
285
+
286
+
287
+ # ---- the whole path: a real server and database, a real browser for the student
288
+ def test_an_assignment_given_by_the_teacher_is_completed_by_the_student_and_shows_done_in_the_report(answer_store, monkeypatch):
289
+ """The teacher gives a class two skills and a unit quiz. The student's home card follows them as the student finishes
290
+ the skills in the browser and hands the quiz in; the teacher's report then carries what is needed to show the student
291
+ as done, and the rules of the page agree."""
292
+ import socket
293
+ import threading
294
+ import time
295
+ import urllib.parse
296
+
297
+ import uvicorn
298
+ from scan_support import OTHER, OWNER, ScanAuth
299
+ from test_answer_browser import session
300
+ from test_sky_browser import answer_skill_right
301
+ from test_unit_quiz import check, quiz_answer
302
+ playwright = pytest.importorskip("playwright.sync_api")
303
+ monkeypatch.setenv("MATHLY_OPEN_ENDED_LOCKED", "0")
304
+ mail = lambda uid: uid + "@test.invalid"
305
+
306
+ class SchoolAuth(ScanAuth):
307
+ teachers = frozenset() # nobody is a manager: OTHER is the class teacher, OWNER a student
308
+
309
+ def __init__(self):
310
+ self.progress = {}
311
+
312
+ def user(self, token):
313
+ return {"id": token, "email": mail(token)} if token in (OWNER, OTHER) else None
314
+
315
+ def classes(self, fresh=False):
316
+ return [{"id": 3, "name": "ט׳1", "lessons": ["t", "k"], "teachers": [mail(OTHER)],
317
+ "emails": [mail(OWNER), mail(OTHER)], "nicknames": {}, "exams": []}]
318
+
319
+ def user_accounts(self, emails):
320
+ return {mail(uid): {"id": uid, "name": ""} for uid in (OWNER, OTHER) if mail(uid) in emails}
321
+
322
+ def _admin(self, path, method="GET", body=None, prefer=""):
323
+ from app.auth import AuthUnavailable
324
+ table, _, query = path.partition("?")
325
+ if table != "/rest/v1/math_progress":
326
+ raise AuthUnavailable("supabase answered 404: no other tables here")
327
+ if method == "POST":
328
+ self.progress[body["user_id"]] = {"data": body["data"], "updated_at": body["updated_at"]}
329
+ return None
330
+ wanted = dict(urllib.parse.parse_qsl(query))["user_id"]
331
+ ids = wanted.removeprefix("in.(").removesuffix(")").split(",") if wanted.startswith("in.") else [wanted.removeprefix("eq.")]
332
+ return [{"user_id": i, **self.progress[i]} for i in ids if i in self.progress]
333
+
334
+ auth = SchoolAuth()
335
+ app = create_app(auth=auth)
336
+ app.state.answers, app.state.assignments = answer_store, MemoryAssignments()
337
+ plan = {"title": "חזרה על הפילוג", "note": "", "due_on": "2099-01-01",
338
+ "items": [{"kind": "skill", "id": "t_ext"}, {"kind": "skill", "id": "t_simplify"}, {"kind": "unit_quiz", "unit": "t"}]}
339
+ with socket.socket() as sock:
340
+ sock.bind(("127.0.0.1", 0))
341
+ server = uvicorn.Server(uvicorn.Config(app, log_level="error"))
342
+ thread = threading.Thread(target=server.run, kwargs={"sockets": [sock]}, daemon=True)
343
+ thread.start()
344
+ try:
345
+ while not server.started:
346
+ time.sleep(.01)
347
+ url = f"http://127.0.0.1:{sock.getsockname()[1]}"
348
+ as_ = lambda uid: {"Authorization": f"Bearer {uid}"}
349
+ made = httpx.post(url + "/api/classes/3/assignments", json=plan, headers=as_(OTHER))
350
+ assert made.status_code == 201
351
+ with playwright.sync_playwright() as p:
352
+ browser = p.chromium.launch()
353
+ try:
354
+ page = session(browser, mobile=True).new_page()
355
+ errors = []
356
+ page.on("pageerror", lambda e: errors.append(str(e)))
357
+ page.goto(url)
358
+ page.locator('.mx-nav [data-view="path"]').click()
359
+ card = page.locator(".mx-asgs .mx-asg")
360
+ playwright.expect(card).to_contain_text("חזרה על הפילוג")
361
+ playwright.expect(card).to_contain_text("0 מתוך 3 הושלמו")
362
+ playwright.expect(page.locator('.mx-star[data-id="t_ext"] .mx-asg-pin')).to_have_count(1)
363
+ for skill, count in (("t_ext", 1), ("t_simplify", 2)): # the student finishes the skills
364
+ page.locator(f'.mx-star[data-id="{skill}"]').click()
365
+ answer_skill_right(page)
366
+ while page.locator(".sky-cele [data-sky=go]").count():
367
+ page.locator(".sky-cele [data-sky=go]").first.click()
368
+ page.locator(".mx-x").last.click()
369
+ playwright.expect(card).to_contain_text(f"{count} מתוך 3 הושלמו")
370
+ playwright.expect(card.locator("[data-asg-go]")).to_contain_text("בוחן מסכם") # what is left
371
+ # the unit quiz: photographed, checked and handed in
372
+ ids = [quiz_answer(answer_store, i) for i in range(4)]
373
+ for aid in ids:
374
+ check(answer_store, aid, 100)
375
+ handed = httpx.post(url + "/api/math/quiz-sittings", headers=as_(OWNER), json={
376
+ "unit_id": "t", "attempts": ids, "client_key": str(uuid.uuid4())})
377
+ assert handed.status_code == 200 and handed.json()["grade"] == 100
378
+ page.reload()
379
+ page.locator('.mx-nav [data-view="path"]').click()
380
+ playwright.expect(page.locator(".mx-asgs .mx-asg")).to_have_count(0) # nothing open is left
381
+ playwright.expect(page.locator(".mx-asg-done summary")).to_have_text("הושלמו (1)")
382
+ assert page.locator(".mx-asg-pin").count() == 0 # and no markers
383
+ # what the student's device saved reaches the server; then the teacher's report
384
+ deadline = time.monotonic() + 10
385
+ while time.monotonic() < deadline and not {"t_ext", "t_simplify"} <= set(auth.progress.get(OWNER, {}).get("data", {}).get("done", {})):
386
+ time.sleep(.2)
387
+ report = httpx.get(url + "/api/classes/3/report", headers=as_(OTHER)).json()
388
+ (student,) = report["students"]
389
+ (assignment,) = report["assignments"]
390
+ assert assignment["id"] == made.json()["id"] and student["email"] == mail(OWNER)
391
+ assert {"t_ext", "t_simplify"} <= set(student["progress"]["done"])
392
+ assert [s["grade"] for s in student["quiz_sittings"]] == [100]
393
+ seen = page.evaluate("([a, s]) => MathlySky.assignmentState(a, s.progress, s.quiz_sittings)", [assignment, student])
394
+ assert seen["finished"] and seen["doneCount"] == 3 and not seen["overdue"] and not seen["late"]
395
+ assert not errors
396
+ finally:
397
+ browser.close()
398
+ finally:
399
+ server.should_exit = True
400
+ thread.join(timeout=5)
tests/test_board_browser.py CHANGED
@@ -372,3 +372,163 @@ def test_the_move_tool_scrolls_when_it_drags_empty_paper(browser, server):
372
  touch(page, [("pointerdown", 4, 300, 200), *[("pointermove", 4, 300, 200 + k * 60) for k in range(1, 11)], ("pointerup", 4, 300, 800)])
373
  playwright.expect(page.locator(".mx-board-top")).to_be_hidden()
374
  assert errors == []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
372
  touch(page, [("pointerdown", 4, 300, 200), *[("pointermove", 4, 300, 200 + k * 60) for k in range(1, 11)], ("pointerup", 4, 300, 800)])
373
  playwright.expect(page.locator(".mx-board-top")).to_be_hidden()
374
  assert errors == []
375
+
376
+
377
+ def test_an_angle_gets_an_arc_and_its_words_at_a_triangles_corner(browser, server):
378
+ page, errors = open_skill(browser, server, 1440, 900)
379
+ board_on(page)
380
+ page.locator(".mx-board [data-tool=line]").click()
381
+ drag(page, (100, 300), (300, 300))
382
+ drag(page, (302, 302), (200, 150))
383
+ drag(page, (198, 148), (102, 298))
384
+ page.locator(".mx-board [data-tool=angle]").click()
385
+ box = page.locator(".mx-board canvas").bounding_box()
386
+ at = lambda x, y: (box["x"] + x, box["y"] + y)
387
+
388
+ # the bottom-left corner: its edges go right (0°) and up to the top (-56°), so the arc is at radius 30, around -28°
389
+ arc, words = (126, 286), (138, 280)
390
+ ink_by = lambda x, y: max(alpha_near(page, x + dx, y + dy) for dx in (-6, -3, 0, 3, 6) for dy in (-4, 0, 4))
391
+ assert alpha_near(page, *arc) == 0
392
+ page.mouse.move(*at(112, 294)) # pointing into the corner: its arc shows, dashed, before anything is added
393
+ assert alpha_near(page, *arc) > 0
394
+ page.mouse.move(*at(400, 500)) # away from every corner: nothing
395
+ assert alpha_near(page, *arc) == 0
396
+
397
+ page.mouse.click(*at(112, 294))
398
+ field = page.locator(".mx-board-anglein")
399
+ playwright.expect(field).to_be_focused()
400
+ field.fill("60")
401
+ field.press("Enter")
402
+ playwright.expect(field).to_have_count(0)
403
+ assert alpha_near(page, *arc) > 0 and ink_by(*words) > 0
404
+
405
+ # the words open again for a change; a number is shown without its degree sign, which comes back by itself
406
+ page.mouse.click(*at(*words))
407
+ playwright.expect(field).to_have_value("60")
408
+ field.press("Escape") # leaves it as it was, and the board open
409
+ playwright.expect(field).to_have_count(0)
410
+ playwright.expect(page.locator(".mx-board")).to_be_visible()
411
+ assert ink_by(*words) > 0
412
+
413
+ # the angle belongs to its corner: it moves with the triangle and goes with an undo
414
+ page.locator(".mx-board [data-tool=move]").click()
415
+ drag(page, (200, 300), (200, 400))
416
+ assert alpha_near(page, *arc) == 0 and alpha_near(page, arc[0], arc[1] + 100) > 0
417
+ page.keyboard.press("Control+z") # the move
418
+ page.keyboard.press("Control+z") # the angle
419
+ assert alpha_near(page, *arc) == 0 and alpha_near(page, 200, 300) > 0
420
+
421
+ # a corner where nothing meets is no angle; Escape on a new one takes it back
422
+ page.locator(".mx-board [data-tool=angle]").click()
423
+ page.mouse.click(*at(500, 500))
424
+ playwright.expect(field).to_have_count(0)
425
+ page.mouse.click(*at(112, 294))
426
+ field.press("Escape")
427
+ assert alpha_near(page, *arc) == 0
428
+
429
+ # α, β and γ are a tap away, typed where the cursor is; a number added to them is in degrees too
430
+ page.mouse.click(*at(112, 294))
431
+ page.locator(".mx-board-angleedit button", has_text="β").click()
432
+ playwright.expect(field).to_have_value("β")
433
+ playwright.expect(field).to_be_focused()
434
+ page.keyboard.type("+90")
435
+ page.keyboard.press("Enter")
436
+ playwright.expect(field).to_have_count(0)
437
+ assert alpha_near(page, *arc) > 0 and ink_by(*words) > 0
438
+ page.mouse.click(*at(*words))
439
+ playwright.expect(field).to_have_value("β+90") # its degree sign comes back by itself
440
+ field.fill("180-alpha") # the letters' names become the letters
441
+ field.press("Enter")
442
+ page.mouse.click(*at(*words))
443
+ playwright.expect(field).to_have_value("180-α")
444
+
445
+ # a double arc (equal angles are marked alike): a second arc 4 px outside the first, kept, and undone like the rest
446
+ outer = (100 + 34 * 0.883, 300 - 34 * 0.469)
447
+ assert alpha_near(page, *outer) == 0
448
+ page.locator(".mx-board-arcs [data-arcs='2']").click()
449
+ assert alpha_near(page, *outer) > 0 # shown at once
450
+ field.press("Enter")
451
+ assert alpha_near(page, *arc) > 0 and alpha_near(page, *outer) > 0
452
+ page.mouse.click(*at(112, 294))
453
+ playwright.expect(page.locator(".mx-board-arcs [data-arcs='2']")).to_have_attribute("aria-pressed", "true")
454
+ field.press("Escape")
455
+ page.keyboard.press("Control+z")
456
+ assert alpha_near(page, *arc) > 0 and alpha_near(page, *outer) == 0
457
+
458
+ # its colour, arcs and words together, picked in the same box
459
+ red = lambda: page.evaluate("""([x,y])=>{ const c=document.querySelector('.mx-board canvas'), d=devicePixelRatio||1;
460
+ const px=c.getContext('2d').getImageData(Math.round(x*d)-1,Math.round(y*d)-1,3,3).data; let n=0;
461
+ for(let i=0;i<px.length;i+=4) if(px[i+3]>128&&px[i]>150&&px[i+2]<130) n++; return n; }""", list(arc))
462
+ assert red() == 0
463
+ page.mouse.click(*at(112, 294))
464
+ page.locator(".mx-board-colors [data-color='#D3455B']").click()
465
+ assert red() > 0 # shown at once
466
+ field.press("Enter")
467
+ assert red() > 0
468
+ page.keyboard.press("Control+z")
469
+ assert red() == 0
470
+ assert errors == []
471
+
472
+
473
+ def test_angles_where_a_line_meets_a_side_or_crosses_another(browser, server):
474
+ page, errors = open_skill(browser, server, 1440, 900)
475
+ board_on(page)
476
+ page.locator(".mx-board [data-tool=line]").click()
477
+ drag(page, (90, 438), (420, 438))
478
+ drag(page, (92, 436), (200, 80))
479
+ drag(page, (202, 82), (418, 436))
480
+ drag(page, (200, 82), (262, 433)) # from the top down to the base, ending a little short: it snaps onto the base
481
+ drag(page, (120, 300), (380, 300)) # across the triangle
482
+ page.locator(".mx-board [data-tool=angle]").click()
483
+ box = page.locator(".mx-board canvas").bounding_box()
484
+ field = page.locator(".mx-board-anglein")
485
+
486
+ def add(x, y, text):
487
+ page.mouse.click(box["x"] + x, box["y"] + y)
488
+ playwright.expect(field).to_be_focused()
489
+ field.fill(text)
490
+ field.press("Enter")
491
+
492
+ foot = 262 + (438 - 433) * 62 / 353 # where the middle line meets the base
493
+ assert alpha_near(page, foot, 437) > 0 and alpha_near(page, foot, 441) == 0 # it reached the base, not past it
494
+ add(250, 430, "α") # either side of the line drawn from the base
495
+ add(275, 430, "β")
496
+ assert alpha_near(page, foot - 30, 438 - 3) > 0 and alpha_near(page, foot + 30, 438 - 3) > 0
497
+ add(191, 106, "x") # the two halves of the top corner
498
+ add(211, 104, "y")
499
+ add(205, 105, "2α+β") # pointing along the middle line: the whole top angle, a wider arc
500
+ assert page.locator(".mx-board-anglein").count() == 0
501
+ # a narrow half's words sit right by its arc (radius 30), not far down between the lines; the whole angle's arc
502
+ # goes around them
503
+ x_words = (200 + 45 * -0.07, 80 + 45 * 1.0)
504
+ assert max(alpha_near(page, x_words[0] + dx, x_words[1] + dy) for dx in (-4, 0, 4) for dy in (-4, 0, 4)) > 0
505
+ add(250, 306, "60") # where two lines cross
506
+ # it all moves as one: the line from the base and the crossing line come with the triangle, their angles too
507
+ page.locator(".mx-board [data-tool=move]").click()
508
+ drag(page, (150, 438), (150, 538))
509
+ assert alpha_near(page, foot - 30, 535) > 0 and alpha_near(page, foot - 30, 435) == 0
510
+ assert alpha_near(page, 300, 400) > 0 # the crossing line, 100 lower
511
+ assert errors == []
512
+
513
+
514
+ def test_a_right_angle_is_marked_with_a_square(browser, server):
515
+ page, errors = open_skill(browser, server, 1440, 900)
516
+ board_on(page)
517
+ page.locator(".mx-board [data-tool=line]").click()
518
+ drag(page, (100, 400), (340, 400))
519
+ drag(page, (102, 398), (100, 160))
520
+ drag(page, (100, 162), (338, 398))
521
+ page.locator(".mx-board [data-tool=angle]").click()
522
+ box = page.locator(".mx-board canvas").bounding_box()
523
+ page.mouse.click(box["x"] + 112, box["y"] + 388)
524
+ page.locator(".mx-board-arcs [data-arcs='right']").click()
525
+ page.locator(".mx-board-anglein").press("Enter")
526
+ # a square of side 14 in the corner: its far corner at (114, 386), and no arc at radius 30
527
+ assert alpha_near(page, 114, 386) > 0 and alpha_near(page, 107, 386) > 0 and alpha_near(page, 114, 393) > 0
528
+ assert alpha_near(page, 100 + 30 * 0.707, 400 - 30 * 0.707) == 0
529
+ page.mouse.click(box["x"] + 112, box["y"] + 388) # the square opens it again, as an arc does
530
+ playwright.expect(page.locator(".mx-board-arcs [data-arcs='right']")).to_have_attribute("aria-pressed", "true")
531
+ page.locator(".mx-board-arcs [data-arcs='1']").click()
532
+ page.locator(".mx-board-anglein").press("Enter")
533
+ assert alpha_near(page, 114, 386) == 0 and alpha_near(page, 100 + 30 * 0.707, 400 - 30 * 0.707) > 0
534
+ assert errors == []
tests/test_class_report.py CHANGED
@@ -5,6 +5,7 @@ import urllib.parse
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"
@@ -17,9 +18,12 @@ class ReportAuth:
17
 
18
  def __init__(self):
19
  self.row = {"id": 3, "name": "ט׳1", "lessons": [], "teachers": ["teacher@x.test"],
20
- "emails": ["newbie@x.test", "student@x.test", "teacher@x.test", "ghost@x.test"]}
 
21
  self.saved = []
22
  self.progress = {STUDENT: {"done": {"t1": "2026-11-01"}, "updated": 5, "comets": ["c1"],
 
 
23
  "slips": [{"at": "2026-11-01T10:00:00Z", "id": "t1", "q": 0, "m": "sign"}],
24
  "quizzes": [{"at": "2026-11-02", "n": 12, "right": 9, "ms": 600000, "units": ["t"],
25
  "weak": ["t1"]}],
@@ -36,16 +40,18 @@ class ReportAuth:
36
  def access_for(self, _id, **kwargs):
37
  return None
38
 
39
- def user_ids(self, emails):
40
- return {e: i for i, e in EMAILS.items() if e in emails and i != NEWBIE} | (
41
- {"newbie@x.test": NEWBIE} if "newbie@x.test" in emails else {})
 
42
 
43
  def save_class(self, class_id, name, emails, nicknames, lessons, *, teachers=None):
44
  self.saved.append({"emails": emails, "teachers": teachers})
45
 
46
  def _admin(self, path, method="GET", body=None, prefer=""):
47
  table, _, query = path.partition("?")
48
- assert table == "/rest/v1/math_progress"
 
49
  ids = dict(urllib.parse.parse_qsl(query))["user_id"].removeprefix("in.(").removesuffix(")").split(",")
50
  return [{"user_id": i, "data": self.progress[i], "updated_at": "2026-11-03T00:00:00Z"}
51
  for i in ids if i in self.progress]
@@ -64,6 +70,12 @@ class Sittings:
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}
@@ -91,13 +103,15 @@ def test_teacher_sees_the_other_students():
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()
103
  assert me["classes"][0]["teacher"] is True
@@ -108,6 +122,32 @@ def test_teacher_sees_the_other_students():
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)
@@ -163,3 +203,49 @@ def test_the_teacher_is_one_of_the_students():
163
  assert (await c.get("/api/classes/3/report")).status_code == 200
164
 
165
  asyncio.run(check())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  import httpx
6
 
7
  from app.answer_scans import ScanError
8
+ from app.auth import AuthUnavailable
9
  from app.main import create_app
10
 
11
  TEACHER, STUDENT, NEWBIE, OUTSIDER = "t-id", "s-id", "n-id", "o-id"
 
18
 
19
  def __init__(self):
20
  self.row = {"id": 3, "name": "ט׳1", "lessons": [], "teachers": ["teacher@x.test"],
21
+ "emails": ["newbie@x.test", "student@x.test", "teacher@x.test", "ghost@x.test"],
22
+ "nicknames": {"newbie@x.test": "נועה ל."}}
23
  self.saved = []
24
  self.progress = {STUDENT: {"done": {"t1": "2026-11-01"}, "updated": 5, "comets": ["c1"],
25
+ "seen": {"t1": "2026-11-01T10:00:00Z"}, "tries": {"t1": 4, "t2": 1}, "secret_extra": 1,
26
+ "examples": {"t1": {"n": 3, "last": "2026-11-02T10:00:00Z"}},
27
  "slips": [{"at": "2026-11-01T10:00:00Z", "id": "t1", "q": 0, "m": "sign"}],
28
  "quizzes": [{"at": "2026-11-02", "n": 12, "right": 9, "ms": 600000, "units": ["t"],
29
  "weak": ["t1"]}],
 
40
  def access_for(self, _id, **kwargs):
41
  return None
42
 
43
+ NAMES = {STUDENT: "Sam Student"} # Google names; the others have none
44
+
45
+ def user_accounts(self, emails):
46
+ return {e: {"id": i, "name": self.NAMES.get(i, "")} for i, e in EMAILS.items() if e in emails}
47
 
48
  def save_class(self, class_id, name, emails, nicknames, lessons, *, teachers=None):
49
  self.saved.append({"emails": emails, "teachers": teachers})
50
 
51
  def _admin(self, path, method="GET", body=None, prefer=""):
52
  table, _, query = path.partition("?")
53
+ if table != "/rest/v1/math_progress":
54
+ raise AuthUnavailable("supabase answered 404: no other tables here") # e.g. the assignments table: not set up
55
  ids = dict(urllib.parse.parse_qsl(query))["user_id"].removeprefix("in.(").removesuffix(")").split(",")
56
  return [{"user_id": i, "data": self.progress[i], "updated_at": "2026-11-03T00:00:00Z"}
57
  for i in ids if i in self.progress]
 
70
  raise ScanError(503, "quiz_sittings_unavailable")
71
  return [s for s in self.sittings if s["user_id"] in data["users"]]
72
 
73
+ def review(self, user_id, action, **data): # the students' open requests to look again
74
+ assert action == "requests"
75
+ if self.fail:
76
+ raise ScanError(503, "review_unavailable")
77
+ return [r for r in getattr(self, "requests", []) if r["user_id"] in data["users"]]
78
+
79
 
80
  def attempt(score, state="completed", photo=True, verdict="correct"):
81
  return {"attempt_id": "a", "has_photo": photo, "grade_state": state, "score": score, "verdict": verdict}
 
103
  students = {s["email"]: s for s in data["students"]}
104
  assert "teacher@x.test" not in students # the teacher is not in their own report
105
  assert students["ghost@x.test"] == {"email": "ghost@x.test", "signed_in": False, "progress": None,
106
+ "saved_at": None, "quiz_sittings": [], "name": None, "review_requests": []}
107
  assert students["newbie@x.test"]["signed_in"] and students["newbie@x.test"]["progress"] is None
108
  p = students["student@x.test"]["progress"]
109
  assert p["done"] == {"t1": "2026-11-01"} and p["slips"][0]["m"] == "sign"
110
  assert p["quizzes"] == [{"at": "2026-11-02", "n": 12, "right": 9, "ms": 600000, "units": ["t"]}]
111
  assert p["uq"] == {"t": {"history": [{"at": "2026-11-03", "timeUp": None, "sid": None}], "going": False}}
112
+ assert p["seen"] == {"t1": "2026-11-01T10:00:00Z"} and p["tries"] == {"t1": 4, "t2": 1}
113
+ assert p["examples"] == {"t1": {"n": 3, "last": "2026-11-02T10:00:00Z"}} # the worked examples the student opened
114
+ assert "comets" not in p and "secret_extra" not in p
115
  assert (await c.get("/api/classes/99/report")).status_code == 404
116
  me = (await c.get("/api/me")).json()
117
  assert me["classes"][0]["teacher"] is True
 
122
  asyncio.run(check())
123
 
124
 
125
+ def test_students_are_named_by_nickname_then_google_name_then_nothing():
126
+ app = create_app(auth=ReportAuth())
127
+ app.state.answers = Sittings()
128
+
129
+ async def check():
130
+ async with client(app, TEACHER) as c:
131
+ students = {s["email"]: s for s in (await c.get("/api/classes/3/report")).json()["students"]}
132
+ assert students["newbie@x.test"]["name"] == "נועה ל." # the class's nickname
133
+ assert students["student@x.test"]["name"] == "Sam Student" # else the Google name
134
+ assert students["ghost@x.test"]["name"] is None # else the page uses the email's start
135
+ asyncio.run(check())
136
+
137
+
138
+ def test_google_names_come_from_the_admin_user_list():
139
+ from app.auth import SupabaseAuth
140
+ auth = SupabaseAuth("https://x.invalid", "key", secret="secret")
141
+ users = [{"id": "u1", "email": "A@x.test", "user_metadata": {"full_name": " Noa Levi "}},
142
+ {"id": "u2", "email": "b@x.test", "user_metadata": {"name": "Ben"}},
143
+ {"id": "u3", "email": "c@x.test"}, {"id": "u4", "email": "other@x.test"}]
144
+ auth._admin = lambda path, *a, **k: {"users": users}
145
+ got = auth.user_accounts(["a@x.test", "B@x.test", "c@x.test", "nobody@x.test"])
146
+ assert got == {"a@x.test": {"id": "u1", "name": "Noa Levi"}, "b@x.test": {"id": "u2", "name": "Ben"},
147
+ "c@x.test": {"id": "u3", "name": ""}}
148
+ assert auth.user_ids(["a@x.test"]) == {"a@x.test": "u1"}
149
+
150
+
151
  def test_unit_quiz_grades_are_the_servers_and_not_the_devices():
152
  auth = ReportAuth() # the student's uploaded progress claims 85 for unit t
153
  app = create_app(auth=auth)
 
203
  assert (await c.get("/api/classes/3/report")).status_code == 200
204
 
205
  asyncio.run(check())
206
+
207
+
208
+ def test_progress_parts_that_are_not_objects_are_dropped():
209
+ from app.class_report import report_part
210
+ part = report_part({"seen": "yesterday", "tries": [1, 2], "days": None, "examples": "many", "done": {"a": 1}})
211
+ assert part["seen"] == {} and part["tries"] == {} and part["days"] == {} and part["examples"] == {} and part["done"] == {"a": 1}
212
+
213
+
214
+ def test_the_report_lists_each_students_open_requests_to_look_again():
215
+ app = create_app(auth=ReportAuth())
216
+ app.state.answers = answers = Sittings()
217
+ answers.requests = [
218
+ {"attempt_id": "a1", "user_id": STUDENT, "message": "כתבתי x=4", "question": "בוחן: שאלה 2", "created_at": "2026-11-04T10:00:00Z"},
219
+ {"attempt_id": "a2", "user_id": OUTSIDER, "message": "של כיתה אחרת", "question": "q", "created_at": "2026-11-04T10:00:00Z"}]
220
+
221
+ async def check():
222
+ async with client(app, TEACHER) as c:
223
+ students = {s["email"]: s for s in (await c.get("/api/classes/3/report")).json()["students"]}
224
+ assert students["student@x.test"]["review_requests"] == [
225
+ {"attempt_id": "a1", "message": "כתבתי x=4", "question": "בוחן: שאלה 2", "created_at": "2026-11-04T10:00:00Z"}]
226
+ assert students["newbie@x.test"]["review_requests"] == [] and "של כיתה אחרת" not in str(students)
227
+ answers.fail = True # unreadable: the report still opens, without them
228
+ async with client(app, TEACHER) as c:
229
+ r = await c.get("/api/classes/3/report")
230
+ assert r.status_code == 200 and all(s["review_requests"] == [] for s in r.json()["students"])
231
+ asyncio.run(check())
232
+
233
+
234
+ def test_the_report_carries_the_classs_assignments_for_its_teacher():
235
+ from test_assignments import GOOD, MemoryAssignments
236
+ store = MemoryAssignments()
237
+ app = create_app(auth=ReportAuth())
238
+ app.state.answers, app.state.assignments = Sittings(), store
239
+ store.add({**GOOD, "class_id": 3, "created_by_email": "teacher@x.test"})
240
+ store.add({**GOOD, "class_id": 99, "title": "של כיתה אחרת", "created_by_email": "x@x.test"})
241
+
242
+ async def check():
243
+ async with client(app, TEACHER) as c:
244
+ data = (await c.get("/api/classes/3/report")).json()
245
+ assert [(a["title"], a["class_id"], a["created_by_email"]) for a in data["assignments"]] == [(GOOD["title"], 3, "teacher@x.test")]
246
+ assert data["assignments"][0]["items"] == GOOD["items"]
247
+ store.fail = True
248
+ async with client(app, TEACHER) as c: # the table is not there: the report opens, without assignments
249
+ r = await c.get("/api/classes/3/report")
250
+ assert r.status_code == 200 and r.json()["assignments"] is None
251
+ asyncio.run(check())
tests/test_draft_examples.py ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """scripts/draft_examples.py: drafting worked examples for a teacher to review, and merging the approved ones.
2
+ The model is a stand-in: nothing here calls the API."""
3
+ import copy
4
+ import importlib.util
5
+ import json
6
+ from pathlib import Path
7
+ from types import SimpleNamespace
8
+
9
+ import pytest
10
+
11
+ from app.math_skills import algebra9, problems, skills_without_example
12
+
13
+ SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "draft_examples.py"
14
+ spec = importlib.util.spec_from_file_location("draft_examples", SCRIPT)
15
+ script = importlib.util.module_from_spec(spec)
16
+ spec.loader.exec_module(script)
17
+
18
+ EXAMPLE = {"question": "$(x + 5)(x − 2) = ?$",
19
+ "steps": [{"do": "כופלים כל איבר בכל איבר: $x² − 2x + 5x − 10$", "why": "כך לא שוכחים אף מכפלה."},
20
+ {"do": "מחברים איברים דומים: $−2x + 5x = 3x$", "why": "רק איברים עם אותה חזקה של $x$ מתחברים."}],
21
+ "answer": "$x² + 3x − 10$"}
22
+
23
+
24
+ class FakeClaude:
25
+ """messages.create answers with `reply` (a dict, or a function of the request's skill name) and keeps the calls."""
26
+ def __init__(self, reply=EXAMPLE, stop_reason="end_turn", text=None):
27
+ self.calls, self.reply, self.stop_reason, self.text = [], reply, stop_reason, text
28
+ self.messages = SimpleNamespace(create=self.create)
29
+
30
+ def create(self, **kwargs):
31
+ self.calls.append(kwargs)
32
+ skill = json.loads(kwargs["messages"][0]["content"])["skill"]
33
+ body = self.text if self.text is not None else json.dumps(self.reply(skill) if callable(self.reply) else self.reply)
34
+ return SimpleNamespace(stop_reason=self.stop_reason, model=kwargs["model"],
35
+ content=[SimpleNamespace(type="text", text=body)])
36
+
37
+
38
+ def test_a_unit_gets_a_well_formed_draft_for_each_skill_without_an_example():
39
+ graph = copy.deepcopy(algebra9())
40
+ graph["skills"][0]["example"] = copy.deepcopy(EXAMPLE) # t_ext already has one: it is not drafted again
41
+ client = FakeClaude()
42
+ review = script.draft_unit(client, "test-model", graph, "t")
43
+ wanted = [s["id"] for s in graph["skills"] if s["unit"] == "t" and "example" not in s]
44
+ assert list(review["drafts"]) == wanted and "t_ext" not in review["drafts"] and len(client.calls) == len(wanted)
45
+ assert all(d["problems"] == [] and d["example"] == EXAMPLE and d["approved"] is False and d["reviewed_by"] == ""
46
+ for d in review["drafts"].values())
47
+ assert review["unit"] == "t" and review["model"] == "test-model" and "לא נבדקה" in review["status"]
48
+
49
+
50
+ def test_the_request_gives_the_model_the_skill_and_asks_for_the_examples_structure():
51
+ graph = algebra9()
52
+ client = FakeClaude()
53
+ script.draft_unit(client, "test-model", graph, "t", only=["t_ext"])
54
+ (call,) = client.calls
55
+ assert call["model"] == "test-model" and call["system"] == script.SYSTEM and call["max_tokens"] == 8000
56
+ assert call["output_config"]["format"] == {"type": "json_schema", "schema": script.EXAMPLE_SCHEMA}
57
+ assert call["output_config"]["effort"] == "high"
58
+ assert not {"thinking", "temperature", "top_p", "top_k", "tool_choice"} & set(call) # removed or rejected on current models
59
+ brief = json.loads(call["messages"][0]["content"])
60
+ skill = next(s for s in graph["skills"] if s["id"] == "t_ext")
61
+ assert brief["skill"] == skill["name"] and len(brief["practice_questions"]) == len(skill["questions"])
62
+ first = brief["practice_questions"][0]
63
+ assert first["right_answer"] == "$x² − 2x − 15$" and first["common_mistakes"][0]["tip"]
64
+
65
+
66
+ def test_a_draft_that_breaks_the_rules_says_what_is_wrong():
67
+ graph = algebra9()
68
+ skill = next(s for s in graph["skills"] if s["id"] == "t_ext")
69
+ copied = {**EXAMPLE, "question": skill["questions"][1]["question"]}
70
+ review = script.draft_unit(FakeClaude(copied), "m", graph, "t", only=["t_ext"])
71
+ assert review["drafts"]["t_ext"]["problems"] == ["t_ext example: repeats one of the skill's practice questions"]
72
+ review = script.draft_unit(FakeClaude({**EXAMPLE, "steps": []}), "m", graph, "t", only=["t_ext"])
73
+ assert review["drafts"]["t_ext"]["problems"] == ["t_ext example: needs at least one step"]
74
+
75
+
76
+ @pytest.mark.parametrize("client,expected", [
77
+ (FakeClaude(stop_reason="refusal"), "stop_reason=refusal"),
78
+ (FakeClaude(stop_reason="max_tokens"), "stop_reason=max_tokens"),
79
+ (FakeClaude(text="not json"), "the answer was not JSON")])
80
+ def test_a_skill_the_model_cannot_draft_is_reported_and_the_others_go_on(client, expected):
81
+ review = script.draft_unit(client, "m", algebra9(), "t", limit=2)
82
+ drafts = review["drafts"]
83
+ assert len(drafts) == 2 and all(d["example"] is None and expected in d["problems"][0] for d in drafts.values())
84
+
85
+
86
+ def test_the_command_writes_a_review_file_and_never_the_data_file(tmp_path, monkeypatch, capsys):
87
+ data = script.GRAPH.read_bytes()
88
+ client = FakeClaude()
89
+ monkeypatch.setattr(script, "photo_client", lambda key: client)
90
+ monkeypatch.setattr(script, "math_model", lambda: "test-model")
91
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
92
+ out = tmp_path / "t.json"
93
+ assert script.main(["draft", "--unit", "t", "--out", str(out)]) == 0
94
+ review = json.loads(out.read_text(encoding="utf-8"))
95
+ assert set(review["drafts"]) == {s["id"] for s in algebra9()["skills"] if s["unit"] == "t"}
96
+ assert script.GRAPH.read_bytes() == data # the data file is for the merge step alone
97
+ calls = len(client.calls)
98
+ assert script.main(["draft", "--unit", "t", "--out", str(out)]) == 2 # it may hold a teacher's review
99
+ assert len(client.calls) == calls and "--force" in capsys.readouterr().err
100
+ assert script.main(["draft", "--unit", "t", "--out", str(out), "--force", "--limit", "1"]) == 0
101
+ assert len(json.loads(out.read_text(encoding="utf-8"))["drafts"]) == 1
102
+
103
+
104
+ def test_a_dry_run_and_a_missing_key_call_nothing(tmp_path, monkeypatch, capsys):
105
+ def no_client(key):
106
+ raise AssertionError("the API must not be called")
107
+ monkeypatch.setattr(script, "photo_client", no_client)
108
+ monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
109
+ monkeypatch.delenv("CLAUDE_API_KEY", raising=False)
110
+ assert script.main(["draft", "--unit", "t", "--dry-run"]) == 0
111
+ assert "dry run: 5 requests" in capsys.readouterr().out
112
+ assert script.main(["draft", "--unit", "t", "--out", str(tmp_path / "x.json")]) == 2
113
+ assert script.main(["draft", "--unit", "nope"]) == 2
114
+ assert not (tmp_path / "x.json").exists()
115
+
116
+
117
+ # ---- merging what a teacher approved
118
+ def review_with(**entries):
119
+ return {"drafts": {sid: {"approved": False, "reviewed_by": "", "example": copy.deepcopy(EXAMPLE), "problems": [], **entry}
120
+ for sid, entry in entries.items()}}
121
+
122
+
123
+ def test_only_approved_and_named_and_valid_drafts_are_merged():
124
+ graph = copy.deepcopy(algebra9())
125
+ practice = next(s for s in graph["skills"] if s["id"] == "t_simplify")["questions"][0]["question"]
126
+ review = review_with(
127
+ t_ext={"approved": True, "reviewed_by": "מורה כהן"},
128
+ t_simplify={"approved": True, "reviewed_by": "מורה כהן", "example": {**EXAMPLE, "question": practice}}, # copies a question
129
+ t_ext_eq={"approved": True}, # no reviewer's name
130
+ t_area={}, # not approved
131
+ nope={"approved": True, "reviewed_by": "x"})
132
+ merged_graph, merged, skipped = script.merge_approved(review, graph)
133
+ assert merged == ["t_ext"] and next(s for s in merged_graph["skills"] if s["id"] == "t_ext")["example"] == EXAMPLE
134
+ reasons = dict(line.split(": ", 1) for line in skipped)
135
+ assert "repeats one of the skill's practice questions" in reasons["t_simplify"]
136
+ assert "reviewer's name" in reasons["t_ext_eq"] and reasons["t_area"] == "not approved" and reasons["nope"] == "no such skill"
137
+ assert problems(merged_graph) == []
138
+
139
+
140
+ def test_an_example_already_in_the_data_is_not_overwritten():
141
+ graph = copy.deepcopy(algebra9())
142
+ graph["skills"][0]["example"] = {**EXAMPLE, "answer": "קיים"}
143
+ _, merged, skipped = script.merge_approved(review_with(t_ext={"approved": True, "reviewed_by": "מורה"}), graph)
144
+ assert merged == [] and skipped == ["t_ext: already has an example"]
145
+
146
+
147
+ def test_the_merge_command_changes_only_the_approved_skills_in_the_data_file(tmp_path, monkeypatch, capsys):
148
+ data = tmp_path / "math_algebra9.json"
149
+ original = script.GRAPH.read_text(encoding="utf-8")
150
+ data.write_text(original, encoding="utf-8")
151
+ monkeypatch.setattr(script, "GRAPH", data)
152
+ review = tmp_path / "t.json"
153
+ review.write_text(json.dumps(review_with(t_ext={"approved": True, "reviewed_by": "מורה כהן"}, t_area={})), encoding="utf-8")
154
+ assert script.main(["merge", str(review)]) == 0
155
+ after = json.loads(data.read_text(encoding="utf-8"))
156
+ skill = next(s for s in after["skills"] if s["id"] == "t_ext")
157
+ assert skill["example"] == EXAMPLE and problems(after) == []
158
+ assert "t_area" in skills_without_example(after) and "t_ext" not in skills_without_example(after)
159
+ skill.pop("example") # nothing else moved: the file is the old one plus that example, in the same format
160
+ assert json.dumps(after, ensure_ascii=False, indent=1) + "\n" == original
161
+ assert script.main(["merge", str(review)]) == 1 # a second time there is nothing to add
162
+ assert "already has an example" in capsys.readouterr().out
tests/test_math_skills.py CHANGED
@@ -212,3 +212,58 @@ def test_unit_t_answers_are_right_and_no_wrong_choice_is_equivalent():
212
  assert same(c) == bool(c.get("correct")), (skill["id"], q["question"], c["text"])
213
  checked += 1
214
  assert checked >= 90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  assert same(c) == bool(c.get("correct")), (skill["id"], q["question"], c["text"])
213
  checked += 1
214
  assert checked >= 90
215
+
216
+
217
+ # ---- worked examples
218
+ EXAMPLE = {"question": "$(x + 5)(x − 2) = ?$",
219
+ "steps": [{"do": "כופלים כל איבר בסוגריים הראשונים בכל איבר בשניים: $x·x + x·(−2) + 5·x + 5·(−2)$",
220
+ "why": "כך לא שוכחים אף מכפלה."},
221
+ {"do": "מחברים את האיברים הדומים: $−2x + 5x = 3x$", "why": "אותו חזקה של $x$ אפשר לחבר."}],
222
+ "answer": "$x² + 3x − 10$"}
223
+
224
+
225
+ def with_example(example):
226
+ graph = copy.deepcopy(algebra9())
227
+ graph["skills"][0]["example"] = example
228
+ return graph
229
+
230
+
231
+ def test_a_well_formed_worked_example_loads_and_reaches_the_page():
232
+ graph = with_example(copy.deepcopy(EXAMPLE))
233
+ assert problems(graph) == []
234
+
235
+
236
+ def test_the_skills_api_sends_a_skills_example(monkeypatch):
237
+ graph = with_example(copy.deepcopy(EXAMPLE))
238
+ monkeypatch.setattr("app.math_skills.algebra9", lambda: graph)
239
+ sent = practice_graph(False)
240
+ assert sent["skills"][0]["example"] == EXAMPLE and "example" not in sent["skills"][1]
241
+
242
+
243
+ def test_a_broken_worked_example_is_named():
244
+ skill_id = algebra9()["skills"][0]["id"]
245
+ broken = lambda **changes: problems(with_example({**copy.deepcopy(EXAMPLE), **changes}))
246
+ assert f"{skill_id} example: needs at least one step" in broken(steps=[])
247
+ assert f"{skill_id} example: needs at least one step" in broken(steps="x")
248
+ assert any("step 2: needs what to do" in item for item in broken(steps=[EXAMPLE["steps"][0], {"do": "מחברים", "why": " "}]))
249
+ assert any("step 1: needs what to do" in item for item in broken(steps=["not an object"]))
250
+ assert f"{skill_id} example: an answer is required" in broken(answer="")
251
+ assert f"{skill_id} example: question text is required" in broken(question=" ")
252
+ assert problems(with_example("text")) == [f"{skill_id} example: must be an object"]
253
+
254
+
255
+ def test_an_example_may_not_be_one_of_the_skills_practice_questions():
256
+ skill = algebra9()["skills"][0]
257
+ practice = skill["questions"][2]["question"]
258
+ spaced = " " + practice.replace(" ", " ") + "\n" # the same question, spaced differently
259
+ for copied in (practice, spaced):
260
+ found = problems(with_example({**copy.deepcopy(EXAMPLE), "question": copied}))
261
+ assert f"{skill['id']} example: repeats one of the skill's practice questions" in found
262
+
263
+
264
+ def test_the_skills_still_missing_a_worked_example_are_listed():
265
+ from app.math_skills import skills_without_example
266
+ graph = with_example(copy.deepcopy(EXAMPLE))
267
+ missing = skills_without_example(graph)
268
+ assert graph["skills"][0]["id"] not in missing and graph["skills"][1]["id"] in missing
269
+ assert set(missing) <= {s["id"] for s in graph["skills"]}
tests/test_phone_browser.py CHANGED
@@ -201,6 +201,678 @@ def test_class_teacher_report(phone_server):
201
  browser.close()
202
 
203
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
  def test_class_editor_picks_the_teacher_from_the_students(phone_server):
205
  import json
206
 
 
201
  browser.close()
202
 
203
 
204
+ def open_report(p, server, students, now=None, lessons=("t",), me=None, routes=None, assignments=None):
205
+ """The class teacher's report for class 4 (lessons: unit t) with these students; returns (browser, page, sheet)."""
206
+ from datetime import datetime
207
+ row = {"id": 4, "name": "ט׳2", "lessons": list(lessons), "teacher": True, "exams": []}
208
+ report = {"class": {"id": 4, "name": "ט׳2", "lessons": list(lessons)}, "students": students, "assignments": assignments}
209
+ browser = p.chromium.launch()
210
+ page = browser.new_page()
211
+ page.clock.set_fixed_time(now or datetime(2026, 11, 4, 12)) # a Wednesday: this week began on Sunday 1 November
212
+ page.route("https://cdn.jsdelivr.net/**", lambda route: route.abort())
213
+ page.add_init_script('''window.supabase={createClient:()=>({auth:{
214
+ getSession:async()=>({data:{session:{access_token:"test",user:{id:"test"}}}}),
215
+ onAuthStateChange:()=>{},signOut:async()=>{}
216
+ }})};''')
217
+
218
+ def index(route):
219
+ response = route.fetch()
220
+ route.fulfill(response=response, body=response.text().replace('supabaseUrl:""', 'supabaseUrl:"https://auth.invalid"'))
221
+
222
+ page.route(server + "/", index)
223
+ page.route("**/api/me", lambda route: route.fulfill(json={
224
+ "email": "teacher@example.com", "user_id": "test", "answer_scans": False,
225
+ "permissions": ["path.use"], "classes": [row], **(me or {})}))
226
+ page.route("**/api/math/progress", lambda route: route.fulfill(json={"progress": {}}))
227
+ page.route("**/api/classes/4/report", lambda route: route.fulfill(json=report))
228
+ if routes:
229
+ routes(page)
230
+ page.goto(server)
231
+ page.locator('[data-act="class-report"]').click()
232
+ return browser, page, page.locator(".mx-report-sheet")
233
+
234
+
235
+ def pupil(email, days=None, slips=(), signed_in=True, progress=True, **extra):
236
+ base = {"done": {}, "gold": {}, "days": days or {}, "missed": {}, "slips": list(slips), "updated": 1, "quizzes": [], "uq": {}}
237
+ return {"email": email, "signed_in": signed_in, "saved_at": None, "quiz_sittings": [],
238
+ "progress": {**base, **extra} if progress else None}
239
+
240
+
241
+ def test_a_students_card_lists_the_skills_where_they_opened_the_worked_example(phone_server):
242
+ students = [pupil("dana@example.com", {"2026-11-03": 5}, examples={
243
+ "t_ext": {"n": 3, "last": "2026-11-02T10:00:00Z"}, # this week
244
+ "t_simplify": {"n": 1, "last": "2026-10-25T10:00:00Z"}, # within two weeks
245
+ "t_area": {"n": 4, "last": "2026-10-01T10:00:00Z"}, # too long ago
246
+ "g_pyth": {"n": 2, "last": "2026-11-03T10:00:00Z"}}), # not a skill of this class
247
+ pupil("omer@example.com", {"2026-11-03": 5})]
248
+ with playwright.sync_playwright() as p:
249
+ browser, page, sheet = open_report(p, phone_server, students)
250
+ try:
251
+ dana = sheet.locator("#rs-0")
252
+ dana.locator("summary").click()
253
+ helped = dana.locator("section", has_text="דוגמאות פתורות")
254
+ playwright.expect(helped.locator("li")).to_have_count(2)
255
+ playwright.expect(helped.locator("li").nth(0)).to_contain_text("3 פעמים") # the newest first
256
+ playwright.expect(helped.locator("li").nth(1)).to_contain_text("פעם אחת")
257
+ playwright.expect(helped.locator("h5")).to_contain_text("נתוני תרגול מהמכשיר")
258
+ omer = sheet.locator("#rs-1")
259
+ omer.locator("summary").click()
260
+ assert omer.locator("section", has_text="דוגמאות פתורות").count() == 0
261
+ finally:
262
+ browser.close()
263
+
264
+
265
+ def test_the_report_opens_with_the_students_who_need_attention(phone_server):
266
+ stuck = [{"at": f"2026-11-0{d}T09:00:00Z", "id": "t_ext", "q": 0, "m": "ext_partial"} for d in (1, 2, 3)]
267
+ students = [pupil("ben@example.com", {"2026-11-03": 5, "2026-10-27": 7}),
268
+ pupil("dana@example.com", {"2026-10-27": 6}),
269
+ pupil("eli@example.com", {"2026-11-03": 5}, slips=stuck),
270
+ pupil("noa@example.com", progress=False),
271
+ pupil("omer@example.com", signed_in=False, progress=False)]
272
+ with playwright.sync_playwright() as p:
273
+ browser, page, sheet = open_report(p, phone_server, students)
274
+ try:
275
+ attn = sheet.locator(".mx-report-attn")
276
+ playwright.expect(attn.locator("h4")).to_have_text("דורשים תשומת לב")
277
+ items = attn.locator("li")
278
+ playwright.expect(items).to_have_count(3) # ben is fine; omer has not signed in yet
279
+ playwright.expect(items.nth(0)).to_contain_text("dana")
280
+ playwright.expect(items.nth(0)).to_contain_text("לא תרגל 8 ימים")
281
+ playwright.expect(items.nth(1)).to_contain_text("eli")
282
+ playwright.expect(items.nth(1)).to_contain_text("תקוע במיומנות")
283
+ playwright.expect(items.nth(2)).to_contain_text("noa")
284
+ playwright.expect(items.nth(2)).to_contain_text("עוד לא התחיל לתרגל")
285
+ assert "ben" not in attn.inner_text()
286
+ # a name in the list opens that student's card
287
+ eli = sheet.locator("#rs-2") # the card's id is the student's place in the class list
288
+ assert eli.get_attribute("open") is None
289
+ items.nth(1).locator("button").click()
290
+ assert eli.get_attribute("open") is not None
291
+ # this week and last week on a card
292
+ ben = sheet.locator("#rs-0")
293
+ ben.locator("summary").click()
294
+ playwright.expect(ben.locator(".mx-report-week")).to_have_text("5 תשובות נכונות השבוע · 7 בשבוע שעבר")
295
+ finally:
296
+ browser.close()
297
+
298
+
299
+ def test_the_report_says_when_nobody_needs_attention(phone_server):
300
+ with playwright.sync_playwright() as p:
301
+ browser, page, sheet = open_report(p, phone_server, [pupil("ben@example.com", {"2026-11-03": 5})])
302
+ try:
303
+ playwright.expect(sheet.locator(".mx-report-attn")).to_contain_text("אף תלמיד לא דורש תשומת לב כרגע")
304
+ playwright.expect(sheet.locator(".mx-report-attn li")).to_have_count(0)
305
+ finally:
306
+ browser.close()
307
+
308
+
309
+ def test_the_report_grid_shows_every_students_skills_and_where_the_class_is_stuck(phone_server):
310
+ from app.math_skills import algebra9
311
+ skills = [x["id"] for x in algebra9()["skills"] if x["unit"] == "t"]
312
+ first, second = skills[0], skills[1]
313
+ fresh, old = "2026-10-30T10:00:00Z", "2026-09-01T10:00:00Z"
314
+ students = [
315
+ pupil("cy@example.com", {"2026-10-20": 5}, tries={second: 2}),
316
+ pupil("ana@example.com", {"2026-11-03": 5}, done={first: fresh}, gold={first: fresh}, seen={first: fresh}, tries={first: 6, second: 3}),
317
+ {**pupil("eve@example.com", progress=False), "name": "נועה ל."},
318
+ pupil("bo@example.com", {"2026-09-01": 5}, done={first: old}, seen={first: old}, tries={second: 1}),
319
+ pupil("dov@example.com")]
320
+ with playwright.sync_playwright() as p:
321
+ browser, page, sheet = open_report(p, phone_server, students, lessons=[u["id"] for u in algebra9()["units"][:6]])
322
+ try:
323
+ sheet.locator("[data-report-tab=grid]").click()
324
+ sheet.locator("[data-report-sort]").select_option("name")
325
+ names = sheet.locator("tbody th")
326
+ playwright.expect(names).to_have_text(["נועה ל.", "ana", "bo", "cy", "dov"]) # by name (Hebrew first); a class name wins over the email
327
+ sheet.locator("[data-report-unit]").select_option("t")
328
+ playwright.expect(sheet.locator("thead th")).to_have_count(1 + len(skills))
329
+ row = lambda who: sheet.locator("tbody tr", has=page.locator(f"th:text-is('{who}')"))
330
+ cell = lambda who, skill_no: row(who).locator("td").nth(skill_no).locator(".mx-cell")
331
+ playwright.expect(cell("ana", 0)).to_have_attribute("aria-label", "כוכב זהב")
332
+ playwright.expect(cell("bo", 0)).to_have_attribute("aria-label", "הושלמה, הכוכב דועך") # not practised for two weeks
333
+ playwright.expect(cell("cy", 0)).to_have_attribute("aria-label", "לא התחיל")
334
+ playwright.expect(cell("ana", 1)).to_have_attribute("aria-label", "מתרגל, עוד לא סיים")
335
+ playwright.expect(cell("נועה ל.", 1)).to_have_attribute("aria-label", "לא התחיל")
336
+ # four students have progress: two finished the first skill; three are on the second without finishing it
337
+ foot = sheet.locator("tfoot tr")
338
+ playwright.expect(foot.nth(0).locator("td").nth(0)).to_have_text("2/4")
339
+ playwright.expect(foot.nth(1).locator("td").nth(1)).to_have_text("3")
340
+ playwright.expect(sheet.locator("thead th.hard")).to_have_count(1) # only the second skill is a class-wide difficulty
341
+ assert "hard" in sheet.locator("thead th").nth(2).get_attribute("class")
342
+ # the unit overview
343
+ sheet.locator("[data-report-unit]").select_option("overview")
344
+ playwright.expect(row("ana").locator("td").first).to_have_text(f"1/{len(skills)}")
345
+ playwright.expect(row("dov").locator("td").first).to_have_text(f"0/{len(skills)}")
346
+ playwright.expect(row("נועה ל.").locator("td").first).to_have_text("–")
347
+ # six units are wider than a phone: the table scrolls inside its own box, never the page or the sheet
348
+ page.set_viewport_size({"width": 375, "height": 700})
349
+ assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
350
+ assert sheet.evaluate("el => el.scrollWidth <= el.clientWidth")
351
+ assert sheet.locator(".mx-grid-wrap").evaluate("el => el.scrollWidth > el.clientWidth")
352
+ finally:
353
+ browser.close()
354
+
355
+
356
+ def test_the_reports_order_and_view_are_kept_and_names_come_from_the_class(phone_server):
357
+ students = [pupil("cy@example.com", {"2026-10-31": 5}), # all practised within the week: nobody has a signal
358
+ {**pupil("ana@example.com", {"2026-11-03": 5}), "name": "אנה כהן"},
359
+ pupil("bo@example.com", {"2026-10-29": 5}),
360
+ pupil("dov@example.com", {"2026-11-02": 5})]
361
+ with playwright.sync_playwright() as p:
362
+ browser, page, sheet = open_report(p, phone_server, students)
363
+ try:
364
+ order = lambda: sheet.locator(".mx-report-student > summary > b").all_inner_texts()
365
+ sort = sheet.locator("[data-report-sort]")
366
+ assert order() == ["cy", "אנה כהן", "bo", "dov"] # nobody has a signal: the class's order
367
+ sort.select_option("name")
368
+ assert order() == ["אנה כהן", "bo", "cy", "dov"]
369
+ sort.select_option("activity")
370
+ assert order() == ["bo", "cy", "dov", "אנה כהן"] and order() != ["cy", "אנה כהן", "bo", "dov"] # longest ago first
371
+ sheet.locator("#rs-1 summary").click()
372
+ playwright.expect(sheet.locator("#rs-1")).to_contain_text("ana@example.com") # the full email stays in the card
373
+ sort.select_option("progress")
374
+ sheet.locator("[data-report-tab=grid]").click()
375
+ # the choices stay when the report is opened again
376
+ sheet.locator(".mx-x").click()
377
+ page.locator('[data-act="class-report"]').click()
378
+ playwright.expect(sheet.locator("[data-report-sort]")).to_have_value("progress")
379
+ playwright.expect(sheet.locator("[data-report-tab=grid]")).to_have_attribute("aria-pressed", "true")
380
+ playwright.expect(sheet.locator(".mx-grid")).to_be_visible()
381
+ finally:
382
+ browser.close()
383
+
384
+
385
+ def sitting_of(*attempts, unit="t", grade=None):
386
+ return {"id": "sit-1", "unit_id": unit, "submitted_at": "2026-11-04T09:00:00Z", "grade": grade, "open": 0,
387
+ "attempts": list(attempts), "questions": [{"points": 25, "earned": 25, "state": "checked"} for _ in attempts]}
388
+
389
+
390
+ def answer_of(attempt_id, review=None, request=None):
391
+ return {"attempt": {"id": attempt_id, "question_id": "quiz:t:1", "snapshot": {"name": "בוחן מסכם: שאלה 2", "question": "פתרו: $x + 1 = 3$"}},
392
+ "attachment": {"id": "att"}, "student": {"email": "dana@example.com"},
393
+ "grading": {"state": "completed", "result": {"verdict": "partial", "score": 40, "feedback": "חסר פירוט",
394
+ "transcription": "x = 2", "solution": "$x = 2$"}},
395
+ "review": review, "request": request, "effective_score": 40}
396
+
397
+
398
+ PNG = bytes.fromhex("89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360000002000001e221bc330000000049454e44ae426082")
399
+
400
+
401
+ def test_the_teacher_opens_a_quiz_answer_and_saves_a_score_and_a_comment(phone_server):
402
+ import json
403
+ students = [{**pupil("dana@example.com"), "quiz_sittings": [sitting_of("a1", "a2", "a3", "a4", grade=100)]}]
404
+ puts, state = [], {"review": None}
405
+
406
+ def routes(page):
407
+ def answer(route):
408
+ route.fulfill(json=answer_of("a2", state["review"], {"id": "r1", "state": "open", "message": "כתבתי x=2", "created_at": "2026-11-04T10:00:00Z"}))
409
+
410
+ def save(route):
411
+ puts.append(json.loads(route.request.post_data))
412
+ state["review"] = {"id": "v1", "score": puts[-1].get("score"), "comment": puts[-1].get("comment", ""),
413
+ "created_at": "2026-11-04T11:00:00Z", "reviewer_email": "t@x"}
414
+ route.fulfill(json={"review": state["review"], "request": {"id": "r1", "state": "closed", "message": "כתבתי x=2"}})
415
+
416
+ page.route("**/api/teacher/answers/a2", lambda route: save(route) if route.request.method == "GET" and False else answer(route))
417
+ page.route("**/api/teacher/answers/a2/review", save)
418
+ page.route("**/api/teacher/answers/a2/image", lambda route: route.fulfill(body=PNG, content_type="image/jpeg"))
419
+
420
+ with playwright.sync_playwright() as p:
421
+ browser, page, sheet = open_report(p, phone_server, students, routes=routes)
422
+ try:
423
+ sheet.locator("#rs-0 summary").click()
424
+ sheet.locator("[data-answers]").click() # the answers of the latest sitting
425
+ sheet = page.locator(".mx-tr-sheet")
426
+ playwright.expect(sheet.locator("#mxTitle")).to_contain_text("תשובות בבוחן")
427
+ playwright.expect(sheet.locator("[data-review-answer]")).to_have_count(4)
428
+ sheet.locator("[data-review-answer]").nth(1).click()
429
+ playwright.expect(sheet.locator(".mx-tr-request")).to_contain_text("כתבתי x=2") # the student's request
430
+ playwright.expect(sheet.locator(".mx-tr-ai")).to_contain_text("40/100")
431
+ playwright.expect(sheet.locator(".mx-tr-ai")).to_contain_text("חסר פירוט")
432
+ playwright.expect(sheet.locator(".mx-tr-img")).to_be_visible()
433
+ form = sheet.locator("[data-review-form]")
434
+ form.locator("[name=score]").fill("150") # out of range: the page's own limit
435
+ assert form.locator("[name=score]").evaluate("el => el.validity.rangeOverflow")
436
+ form.locator("[name=score]").fill("90")
437
+ form.locator("[name=comment]").fill("הדרך נכונה, טעות חישוב")
438
+ form.locator("[type=submit]").click()
439
+ playwright.expect(form.locator("[role=status]")).to_contain_text("נשמר")
440
+ assert puts == [{"score": 90, "comment": "הדרך נכונה, טעות חישוב"}]
441
+ playwright.expect(sheet.locator("section", has_text="הבדיקה שלכם")).to_contain_text("90/100")
442
+ playwright.expect(sheet.locator(".mx-tr-request")).to_have_count(0) # answered: the request is closed
443
+ sheet.locator("[data-review-back]").click() # back to the sitting's answers
444
+ playwright.expect(sheet.locator("#mxTitle")).to_contain_text("תשובות בבוחן")
445
+ sheet.locator("[data-review-back]").click() # and to the report
446
+ playwright.expect(page.locator(".mx-report-sheet #mxTitle")).to_contain_text("הכיתה שלי")
447
+ finally:
448
+ browser.close()
449
+
450
+
451
+ def test_a_comment_alone_is_saved_without_a_score_and_a_teacher_without_access_is_told(phone_server):
452
+ import json
453
+ students = [{**pupil("dana@example.com"), "quiz_sittings": [sitting_of("a1", "a2", "a3", "a4", grade=100)]}]
454
+ puts = []
455
+
456
+ def routes(page):
457
+ page.route("**/api/teacher/answers/a1", lambda route: route.fulfill(json=answer_of("a1")))
458
+ page.route("**/api/teacher/answers/a1/image", lambda route: route.fulfill(body=PNG, content_type="image/jpeg"))
459
+ page.route("**/api/teacher/answers/a1/review", lambda route: (puts.append(json.loads(route.request.post_data)),
460
+ route.fulfill(json={"review": {"score": None, "comment": "יפה", "created_at": "2026-11-04T11:00:00Z"}, "request": None}))[1])
461
+ page.route("**/api/teacher/answers/a3", lambda route: route.fulfill(status=403, json={"detail": "forbidden"}))
462
+ page.route("**/api/teacher/answers/a3/image", lambda route: route.fulfill(status=403, json={"detail": "forbidden"}))
463
+
464
+ with playwright.sync_playwright() as p:
465
+ browser, page, sheet = open_report(p, phone_server, students, routes=routes)
466
+ try:
467
+ sheet.locator("#rs-0 summary").click()
468
+ sheet.locator("[data-answers]").click()
469
+ sheet = page.locator(".mx-tr-sheet")
470
+ sheet.locator("[data-review-answer]").nth(2).click()
471
+ playwright.expect(sheet.locator(".mx-manage-state")).to_have_text("אין לכם הרשאה לתשובה הזו.")
472
+ sheet.locator("[data-review-back]").click()
473
+ sheet.locator("[data-review-answer]").nth(0).click()
474
+ form = sheet.locator("[data-review-form]")
475
+ form.locator("[name=comment]").fill("יפה")
476
+ form.locator("[type=submit]").click()
477
+ playwright.expect(form.locator("[role=status]")).to_contain_text("נשמר")
478
+ assert puts == [{"comment": "יפה"}] # no score key: the AI's score stays
479
+ finally:
480
+ browser.close()
481
+
482
+
483
+ def test_a_handled_request_in_the_queue_opens_the_answer(phone_server):
484
+ rows = [{"id": "q1", "state": "done", "kind": "grading", "email": "dana@example.com", "attempt_id": "a2", "label": "בוחן: שאלה 2",
485
+ "question": "פתרו", "created_at": "2026-11-04T08:00:00Z", "result": {"verdict": "partial", "score": 40}},
486
+ {"id": "q2", "state": "done", "kind": "photo", "email": "dana@example.com", "attempt_id": None, "label": "תרגיל",
487
+ "created_at": "2026-11-04T08:00:00Z", "result": {"readable": True, "answer": "3"}}]
488
+
489
+ def routes(page):
490
+ page.route("**/api/ai-queue", lambda route: route.fulfill(json={"requests": rows, "all": False}))
491
+ page.route("**/api/teacher/answers/a2", lambda route: route.fulfill(json=answer_of("a2")))
492
+ page.route("**/api/teacher/answers/a2/image", lambda route: route.fulfill(body=PNG, content_type="image/jpeg"))
493
+
494
+ with playwright.sync_playwright() as p:
495
+ browser, page, sheet = open_report(p, phone_server, [pupil("dana@example.com")], me={"ai_review": True}, routes=routes)
496
+ try:
497
+ sheet.locator(".mx-x").click()
498
+ page.locator('[data-act="ai-queue"]').first.click()
499
+ queue = page.locator(".mx-aq-sheet")
500
+ playwright.expect(queue.locator("[data-review-answer]")).to_have_count(1) # only an answer, not a photographed problem
501
+ queue.locator("[data-review-answer]").click()
502
+ playwright.expect(page.locator(".mx-tr-sheet .mx-tr-ai")).to_contain_text("40/100")
503
+ page.locator("[data-review-back]").click()
504
+ playwright.expect(page.locator(".mx-aq-sheet")).to_be_visible()
505
+ finally:
506
+ browser.close()
507
+
508
+
509
+ def test_requests_to_look_again_have_their_own_tab_and_count_in_the_queue_button(phone_server):
510
+ asking = [{"id": "r1", "attempt_id": "a2", "student": "dana@example.com", "message": "כתבתי x=2", "question": "בוחן: שאלה 2",
511
+ "created_at": "2026-11-04T10:00:00Z"}]
512
+ waiting = [{"id": "q1", "state": "pending", "kind": "grading", "email": "omer@example.com", "attempt_id": "a9", "label": "תרגיל",
513
+ "question": "פתרו", "created_at": "2026-11-04T08:00:00Z"}]
514
+ students = [{**pupil("dana@example.com"), "review_requests": [
515
+ {"attempt_id": "a2", "message": "כתבתי x=2", "question": "בוחן: שאלה 2", "created_at": "2026-11-04T10:00:00Z"}]}]
516
+
517
+ def routes(page):
518
+ page.route("**/api/ai-queue", lambda route: route.fulfill(json={"requests": waiting, "all": False}))
519
+ page.route("**/api/ai-queue/q1/image", lambda route: route.fulfill(body=PNG, content_type="image/jpeg"))
520
+ page.route("**/api/teacher/review-requests", lambda route: route.fulfill(json={"requests": asking}))
521
+ page.route("**/api/teacher/answers/a2", lambda route: route.fulfill(json=answer_of(
522
+ "a2", None, {"id": "r1", "state": "open", "message": "כתבתי x=2", "created_at": "2026-11-04T10:00:00Z"})))
523
+ page.route("**/api/teacher/answers/a2/image", lambda route: route.fulfill(body=PNG, content_type="image/jpeg"))
524
+
525
+ with playwright.sync_playwright() as p:
526
+ browser, page, sheet = open_report(p, phone_server, students, me={"ai_review": True}, routes=routes)
527
+ try:
528
+ # the report names the student who asked, and opens the answer; back leads to the report
529
+ playwright.expect(sheet.locator("#rs-0 summary")).to_contain_text("ביקש בדיקה חוזרת")
530
+ sheet.locator("#rs-0 summary").click()
531
+ sheet.locator("#rs-0 [data-review-answer]").click()
532
+ playwright.expect(page.locator(".mx-tr-request")).to_contain_text("כתבתי x=2")
533
+ page.locator("[data-review-back]").click()
534
+ playwright.expect(page.locator(".mx-report-sheet #mxTitle")).to_contain_text("הכיתה שלי")
535
+ page.locator(".mx-report-sheet .mx-x").click()
536
+ # one waiting request and one student asking to look again: the button says 2
537
+ button = page.locator('[data-act="ai-queue"]').first
538
+ playwright.expect(button).to_contain_text("2")
539
+ button.click()
540
+ queue = page.locator(".mx-aq-sheet")
541
+ playwright.expect(queue.locator("[data-aq-tab=queue]")).to_have_attribute("aria-pressed", "true")
542
+ playwright.expect(queue.locator(".mx-aq-card")).to_have_count(1) # the waiting request
543
+ queue.locator("[data-aq-tab=requests]").click()
544
+ playwright.expect(queue.locator("[data-aq-tab=requests]")).to_contain_text("1")
545
+ card = queue.locator(".mx-aq-card")
546
+ playwright.expect(card).to_have_count(1)
547
+ playwright.expect(card).to_contain_text("dana")
548
+ playwright.expect(card).to_contain_text("כתבתי x=2")
549
+ card.locator("[data-review-answer]").click()
550
+ playwright.expect(page.locator(".mx-tr-sheet .mx-tr-ai")).to_contain_text("40/100")
551
+ page.locator("[data-review-back]").click()
552
+ playwright.expect(page.locator(".mx-aq-sheet [data-aq-tab=requests]")).to_have_attribute("aria-pressed", "true") # the tab is kept
553
+ finally:
554
+ browser.close()
555
+
556
+
557
+ def open_student_app(p, server, assignments, sittings=(), progress=None, now=None):
558
+ """A student of class 4 (units t and k) on the path, with these assignments from the server; returns (browser, page)."""
559
+ from datetime import datetime
560
+ row = {"id": 4, "name": "ט׳2", "lessons": ["t", "k"], "teacher": False, "exams": []}
561
+ browser = p.chromium.launch()
562
+ page = browser.new_page()
563
+ page.clock.set_fixed_time(now or datetime(2026, 11, 4, 12))
564
+ page.route("https://cdn.jsdelivr.net/**", lambda route: route.abort())
565
+ page.add_init_script('''window.supabase={createClient:()=>({auth:{
566
+ getSession:async()=>({data:{session:{access_token:"test",user:{id:"test"}}}}),
567
+ onAuthStateChange:()=>{},signOut:async()=>{}
568
+ }})};''')
569
+
570
+ def index(route):
571
+ response = route.fetch()
572
+ route.fulfill(response=response, body=response.text().replace('supabaseUrl:""', 'supabaseUrl:"https://auth.invalid"'))
573
+
574
+ page.route(server + "/", index)
575
+ page.route("**/api/me", lambda route: route.fulfill(json={
576
+ "email": "student@example.com", "user_id": "test", "answer_scans": False, "permissions": ["path.use"], "classes": [row]}))
577
+ page.route("**/api/math/progress", lambda route: route.fulfill(json={"progress": progress or {}}))
578
+ page.route("**/api/math/assignments", lambda route: route.fulfill(json={
579
+ "assignments": assignments, "sittings": list(sittings), "today": "2026-11-04"}))
580
+ page.goto(server)
581
+ page.locator('.mx-nav [data-view="path"]').click()
582
+ return browser, page
583
+
584
+
585
+ def given(title, due, items, id=None, created="2026-11-01T10:00:00Z"):
586
+ return {"id": id or title, "class_id": 4, "class_name": "ט׳2", "title": title, "note": "", "due_on": due,
587
+ "items": items, "created_at": created, "updated_at": created}
588
+
589
+
590
+ SKILL, QUIZ, EXAM = {"kind": "skill", "id": "t_ext"}, {"kind": "unit_quiz", "unit": "k"}, {"kind": "exam", "units": ["k", "t"]}
591
+
592
+
593
+ def test_the_home_screen_lists_the_open_assignments_late_ones_and_the_recently_done(phone_server):
594
+ assignments = [
595
+ given("חזרה לפני המבחן", "2026-11-12", [SKILL, QUIZ, EXAM], "open"),
596
+ given("משימה שאיחרה", "2026-11-02", [{"kind": "skill", "id": "t_simplify"}], "late"),
597
+ given("משימה שהסתיימה", "2026-11-01", [SKILL], "done"),
598
+ given("משימה ישנה שהסתיימה", "2026-10-01", [SKILL], "old")]
599
+ progress = {"done": {"t_ext": "2026-09-01T10:00:00Z"}}
600
+ with playwright.sync_playwright() as p:
601
+ browser, page = open_student_app(p, phone_server, assignments, progress=progress)
602
+ try:
603
+ cards = page.locator(".mx-asgs .mx-asg")
604
+ playwright.expect(cards).to_have_count(2) # the finished ones are not cards
605
+ playwright.expect(cards.nth(0)).to_contain_text("משימה שאיחרה") # the earliest due date first
606
+ playwright.expect(cards.nth(0)).to_contain_text("באיחור")
607
+ assert "late" in cards.nth(0).get_attribute("class")
608
+ open_ = cards.nth(1)
609
+ playwright.expect(open_).to_contain_text("חזרה לפני המבחן")
610
+ playwright.expect(open_).to_contain_text("1 מתוך 3 הושלמו")
611
+ items = open_.locator(".mx-asg-items li")
612
+ assert [items.nth(i).get_attribute("class") for i in range(3)] == ["done", "", ""]
613
+ playwright.expect(items.nth(1)).to_contain_text("בוחן מסכם")
614
+ playwright.expect(items.nth(2)).to_contain_text("מבחן תרגול")
615
+ playwright.expect(open_.locator("[data-asg-go]")).to_contain_text("להמשיך: בוחן מסכם") # the next one to do
616
+ done = page.locator(".mx-asg-done")
617
+ playwright.expect(done.locator("summary")).to_have_text("הושלמו (1)")
618
+ done.locator("summary").click()
619
+ playwright.expect(done).to_contain_text("משימה שהסתיימה")
620
+ assert "משימה ישנה שהסתיימה" not in page.locator(".mx-asgs").inner_text()
621
+ finally:
622
+ browser.close()
623
+
624
+
625
+ def test_continue_opens_the_first_item_that_is_not_done(phone_server):
626
+ first = [given("ראשון", "2026-11-12", [SKILL, QUIZ, EXAM], "a")]
627
+ with playwright.sync_playwright() as p:
628
+ browser, page = open_student_app(p, phone_server, first) # nothing done: the skill
629
+ try:
630
+ playwright.expect(page.locator("[data-asg-go]")).to_contain_text("להתחיל")
631
+ page.locator("[data-asg-go]").click()
632
+ playwright.expect(page.locator(".mx-sheet h3").first).to_contain_text("חוק הפילוג המורחב")
633
+ finally:
634
+ browser.close()
635
+ with playwright.sync_playwright() as p:
636
+ browser, page = open_student_app(p, phone_server, first, progress={"done": {"t_ext": "2026-09-01T10:00:00Z"}})
637
+ try: # the skill done: the unit quiz
638
+ page.locator("[data-asg-go]").click()
639
+ playwright.expect(page.locator(".mx-uq-sheet")).to_be_visible()
640
+ finally:
641
+ browser.close()
642
+ sitting = [{"unit_id": "k", "submitted_at": "2026-11-03T09:00:00Z"}]
643
+ with playwright.sync_playwright() as p:
644
+ browser, page = open_student_app(p, phone_server, first, sitting, {"done": {"t_ext": "2026-09-01T10:00:00Z"}})
645
+ try: # then the practice exam, with its units chosen
646
+ playwright.expect(page.locator("[data-asg-go]")).to_contain_text("להמשיך: מבחן תרגול")
647
+ page.locator("[data-asg-go]").click()
648
+ units = page.locator(".mx-quiz-sheet [data-quiz-unit]")
649
+ playwright.expect(page.locator(".mx-quiz-sheet")).to_contain_text("המורה ביקש מבחן תרגול")
650
+ assert units.count() >= 2 and all(units.nth(i).get_attribute("aria-pressed") == "true" for i in range(units.count()))
651
+ finally:
652
+ browser.close()
653
+
654
+
655
+ def test_a_finished_assignment_leaves_the_card_and_the_skills_of_open_ones_wear_a_marker(phone_server):
656
+ assignments = [given("חזרה", "2026-11-12", [SKILL, {"kind": "skill", "id": "t_simplify"}], "a"),
657
+ given("עוד אחת", "2026-11-12", [SKILL], "b")]
658
+ with playwright.sync_playwright() as p:
659
+ browser, page = open_student_app(p, phone_server, assignments)
660
+ try:
661
+ star = page.locator('.mx-star[data-id="t_ext"]')
662
+ playwright.expect(star.locator(".mx-asg-pin")).to_have_count(1)
663
+ label = star.get_attribute("aria-label")
664
+ assert "משימה מהמורה: חזרה, עוד אחת" in label # both assignments name it
665
+ playwright.expect(page.locator('.mx-star[data-id="t_simplify"] .mx-asg-pin')).to_have_count(1)
666
+ assert "משימה מהמורה: חזרה" in page.locator('.mx-star[data-id="t_simplify"]').get_attribute("aria-label")
667
+ assert page.locator('.mx-star[data-id="t_ext_eq"] .mx-asg-pin').count() == 0 # not assigned: no marker
668
+ assert "משימה" not in page.locator('.mx-star[data-id="t_ext_eq"]').get_attribute("aria-label")
669
+ finally:
670
+ browser.close()
671
+ done = {"done": {"t_ext": "2026-09-01T10:00:00Z", "t_simplify": "2026-09-02T10:00:00Z"}}
672
+ with playwright.sync_playwright() as p:
673
+ browser, page = open_student_app(p, phone_server, assignments, progress=done)
674
+ try:
675
+ playwright.expect(page.locator(".mx-asgs")).to_contain_text("הושלמו (2)")
676
+ assert page.locator(".mx-asg-pin").count() == 0 and page.locator(".mx-asg").count() == 0
677
+ finally:
678
+ browser.close()
679
+
680
+
681
+ def test_the_home_screen_is_quiet_without_assignments(phone_server):
682
+ with playwright.sync_playwright() as p:
683
+ browser, page = open_student_app(p, phone_server, [])
684
+ try:
685
+ playwright.expect(page.locator(".mx-star").first).to_be_visible()
686
+ assert page.locator(".mx-asgs").count() == 0 and page.locator(".mx-asg-pin").count() == 0
687
+ finally:
688
+ browser.close()
689
+
690
+
691
+ def taught(title, due, items, id=None, created="2026-11-01T10:00:00Z"):
692
+ return {"id": id or title, "class_id": 4, "title": title, "note": "", "due_on": due, "items": items,
693
+ "created_by_email": "teacher@example.com", "created_at": created, "updated_at": created}
694
+
695
+
696
+ ASG_STUDENTS = [
697
+ # all three items done: the skill long ago, the unit quiz and the practice exam after the assignment was made
698
+ {**pupil("dana@example.com", {"2026-11-03": 5}, done={"t_ext": "2026-09-01T10:00:00Z"},
699
+ quizzes=[{"at": "2026-11-03T09:00:00Z", "n": 12, "right": 9, "units": ["k", "t"]}]),
700
+ "quiz_sittings": [{"id": "s1", "unit_id": "k", "submitted_at": "2026-11-03T09:00:00Z", "grade": 90, "open": 0}]},
701
+ {**pupil("noa@example.com", {"2026-11-03": 5}, done={"t_ext": "2026-09-01T10:00:00Z"}), "quiz_sittings": []},
702
+ {**pupil("omer@example.com", {"2026-11-03": 5}), "quiz_sittings": []}]
703
+
704
+
705
+ def test_the_assignments_tab_shows_how_many_finished_and_who_did_what(phone_server):
706
+ assignments = [taught("חזרה לפני המבחן", "2026-11-12", [SKILL, QUIZ, EXAM], "a"),
707
+ taught("משימה ישנה", "2026-11-02", [SKILL], "b")]
708
+ with playwright.sync_playwright() as p:
709
+ browser, page, sheet = open_report(p, phone_server, ASG_STUDENTS, lessons=["t", "k"], assignments=assignments)
710
+ try:
711
+ sheet.locator("[data-report-tab=tasks]").click()
712
+ cards = sheet.locator(".mx-asg-t")
713
+ playwright.expect(cards).to_have_count(2)
714
+ playwright.expect(cards.nth(0)).to_contain_text("חזרה לפני המבחן") # the latest due date first
715
+ playwright.expect(cards.nth(0)).to_contain_text("1 מתוך 3 תלמידים סיימו")
716
+ playwright.expect(cards.nth(1)).to_contain_text("המועד עבר")
717
+ playwright.expect(cards.nth(1)).to_contain_text("2 מתוך 3 תלמידים סיימו") # the skill alone: dana and noa
718
+ first = cards.nth(0)
719
+ first.locator("summary").click()
720
+ row = lambda who: first.locator("tbody tr", has=page.locator(f"th:text-is('{who}')")).locator("td")
721
+ playwright.expect(row("dana")).to_have_text(["✓", "✓", "✓"])
722
+ playwright.expect(row("noa")).to_have_text(["✓", "–", "–"])
723
+ playwright.expect(row("omer")).to_have_text(["–", "–", "–"])
724
+ finally:
725
+ browser.close()
726
+
727
+
728
+ def test_a_student_who_is_late_with_an_assignment_needs_attention(phone_server):
729
+ assignments = [taught("משימה ישנה", "2026-11-02", [SKILL], "b")]
730
+ with playwright.sync_playwright() as p:
731
+ browser, page, sheet = open_report(p, phone_server, ASG_STUDENTS, assignments=assignments)
732
+ try:
733
+ attn = sheet.locator(".mx-report-attn li")
734
+ playwright.expect(attn).to_have_count(1) # omer: dana and noa finished it
735
+ playwright.expect(attn).to_contain_text("omer")
736
+ playwright.expect(attn).to_contain_text("משימה באיחור: משימה ישנה")
737
+ finally:
738
+ browser.close()
739
+
740
+
741
+ def test_the_assignments_tab_says_when_assignments_cannot_be_read(phone_server):
742
+ with playwright.sync_playwright() as p:
743
+ browser, page, sheet = open_report(p, phone_server, ASG_STUDENTS, assignments=None)
744
+ try:
745
+ sheet.locator("[data-report-tab=tasks]").click()
746
+ playwright.expect(sheet).to_contain_text("המשימות אינן זמינות כרגע")
747
+ assert sheet.locator("[data-asg-new]").count() == 0
748
+ finally:
749
+ browser.close()
750
+
751
+
752
+ def asg_routes(sent, saved):
753
+ """POST/PUT/DELETE of class 4's assignments, kept in `sent`; `saved(body, id)` makes the answer."""
754
+ import json
755
+
756
+ def handle(route):
757
+ request = route.request
758
+ body = json.loads(request.post_data) if request.post_data else None
759
+ sent.append((request.method, request.url.rsplit("/assignments", 1)[1], body))
760
+ if request.method == "DELETE":
761
+ return route.fulfill(json={"ok": True})
762
+ route.fulfill(status=201 if request.method == "POST" else 200, json=saved(body, request.url.rsplit("/", 1)[1]))
763
+
764
+ return lambda page: page.route("**/api/classes/4/assignments**", handle)
765
+
766
+
767
+ def test_the_teacher_makes_an_assignment_with_the_form(phone_server):
768
+ sent = []
769
+ made = lambda body, _id: {**taught(body["title"], body["due_on"], body["items"], "new"), "note": body["note"]}
770
+ with playwright.sync_playwright() as p:
771
+ browser, page, sheet = open_report(p, phone_server, ASG_STUDENTS, lessons=["t", "k"], assignments=[],
772
+ routes=asg_routes(sent, made))
773
+ try:
774
+ sheet.locator("[data-report-tab=tasks]").click()
775
+ playwright.expect(sheet).to_contain_text("עוד לא נתתם משימות")
776
+ sheet.locator("[data-asg-new]").click()
777
+ form = sheet.locator("[data-asg-form]")
778
+ status = form.locator("[role=status]")
779
+ form.locator("button[type=submit]").click()
780
+ playwright.expect(status).to_have_text("כתבו כותרת למשימה.")
781
+ form.locator("[name=title]").fill("חזרה לפני המבחן")
782
+ form.locator("button[type=submit]").click()
783
+ playwright.expect(status).to_contain_text("בחרו לפחות פריט אחד")
784
+ form.locator("[name=note]").fill("בהצלחה")
785
+ form.locator("[name=due_on]").fill("2026-11-14")
786
+ form.locator("details", has_text="טכניקה אלגברית 1").first.locator("summary").click()
787
+ form.locator("[name=skill][value=t_ext]").check()
788
+ form.locator("[name=skill][value=t_simplify]").check()
789
+ form.locator("[name=quiz][value=k]").check()
790
+ form.locator("[name=exam][value=t]").check()
791
+ form.locator("[name=exam][value=k]").check()
792
+ form.locator("button[type=submit]").click()
793
+ playwright.expect(sheet.locator(".mx-asg-t")).to_have_count(1)
794
+ playwright.expect(sheet.locator(".mx-asg-t")).to_contain_text("חזרה לפני המבחן")
795
+ playwright.expect(sheet).to_contain_text("המשימה נשמרה")
796
+ assert sent == [("POST", "", {"title": "חזרה לפני המבחן", "note": "בהצלחה", "due_on": "2026-11-14", "items": [
797
+ {"kind": "skill", "id": "t_ext"}, {"kind": "skill", "id": "t_simplify"}, {"kind": "unit_quiz", "unit": "k"},
798
+ {"kind": "exam", "units": ["t", "k"]}]})]
799
+ assert sheet.locator("[data-asg-form]").count() == 0
800
+ finally:
801
+ browser.close()
802
+
803
+
804
+ def test_the_teacher_edits_an_assignment_and_a_refusal_is_explained(phone_server):
805
+ sent = []
806
+ changed = lambda body, id_: {**taught(body["title"], body["due_on"], body["items"], id_), "note": body["note"]}
807
+ assignments = [taught("חזרה", "2026-11-12", [SKILL, QUIZ, EXAM], "a")]
808
+ with playwright.sync_playwright() as p:
809
+ browser, page, sheet = open_report(p, phone_server, ASG_STUDENTS, lessons=["t", "k"], assignments=assignments,
810
+ routes=asg_routes(sent, changed))
811
+ try:
812
+ sheet.locator("[data-report-tab=tasks]").click()
813
+ sheet.locator("[data-asg-edit=a]").click()
814
+ form = sheet.locator("[data-asg-form]")
815
+ playwright.expect(form.locator("[name=title]")).to_have_value("חזרה")
816
+ playwright.expect(form.locator("[name=due_on]")).to_have_value("2026-11-12")
817
+ playwright.expect(form.locator("[name=skill][value=t_ext]")).to_be_checked()
818
+ playwright.expect(form.locator("[name=quiz][value=k]")).to_be_checked()
819
+ assert form.locator("[name=exam]:checked").count() == 2
820
+ form.locator("[name=title]").fill("חזרה מורחבת")
821
+ form.locator("[name=due_on]").fill("2026-11-15")
822
+ form.locator("button[type=submit]").click()
823
+ playwright.expect(sheet.locator(".mx-asg-t")).to_contain_text("חזרה מורחבת")
824
+ assert sent[0][0] == "PUT" and sent[0][1] == "/a" and sent[0][2]["due_on"] == "2026-11-15"
825
+ assert sent[0][2]["items"] == [SKILL, QUIZ, {"kind": "exam", "units": ["t", "k"]}] # the form's items, in unit order
826
+ sheet.locator("[data-asg-edit=a]").click()
827
+ sheet.locator("[data-asg-cancel]").click() # cancelling changes nothing
828
+ assert len(sent) == 1 and sheet.locator("[data-asg-form]").count() == 0
829
+ finally:
830
+ browser.close()
831
+ with playwright.sync_playwright() as p:
832
+ browser, page, sheet = open_report(p, phone_server, ASG_STUDENTS, assignments=[], routes=lambda pg: pg.route(
833
+ "**/api/classes/4/assignments**", lambda route: route.fulfill(status=403, json={"detail": "x"})))
834
+ try:
835
+ sheet.locator("[data-report-tab=tasks]").click()
836
+ sheet.locator("[data-asg-new]").click()
837
+ form = sheet.locator("[data-asg-form]")
838
+ form.locator("[name=title]").fill("x")
839
+ form.locator("details").first.locator("summary").click()
840
+ form.locator("[name=skill]").first.check()
841
+ form.locator("button[type=submit]").click()
842
+ playwright.expect(form.locator("[role=status]")).to_have_text("רק מורה הכיתה יכול לתת לה משימות.")
843
+ playwright.expect(form.locator("button[type=submit]")).to_be_enabled()
844
+ finally:
845
+ browser.close()
846
+
847
+
848
+ def test_deleting_an_assignment_asks_first_and_says_how_many_already_did_part_of_it(phone_server):
849
+ sent, messages = [], []
850
+ assignments = [taught("חזרה", "2026-11-12", [SKILL, QUIZ], "a")]
851
+ with playwright.sync_playwright() as p:
852
+ browser, page, sheet = open_report(p, phone_server, ASG_STUDENTS, lessons=["t", "k"], assignments=assignments,
853
+ routes=asg_routes(sent, lambda body, id_: body))
854
+ try:
855
+ def answer(accept):
856
+ def on_dialog(dialog):
857
+ messages.append(dialog.message)
858
+ dialog.accept() if accept else dialog.dismiss()
859
+ page.once("dialog", on_dialog)
860
+
861
+ sheet.locator("[data-report-tab=tasks]").click()
862
+ answer(False)
863
+ sheet.locator("[data-asg-del=a]").click()
864
+ page.wait_for_timeout(200)
865
+ assert sent == [] and sheet.locator(".mx-asg-t").count() == 1 # dismissed: nothing is deleted
866
+ answer(True)
867
+ sheet.locator("[data-asg-del=a]").click()
868
+ playwright.expect(sheet.locator(".mx-asg-t")).to_have_count(0)
869
+ assert sent == [("DELETE", "/a", None)]
870
+ assert 'למחוק את המשימה "חזרה"' in messages[0] and "2 תלמידים כבר השלימו" in messages[0] # dana and noa did the skill
871
+ assert messages[1] == messages[0]
872
+ finally:
873
+ browser.close()
874
+
875
+
876
  def test_class_editor_picks_the_teacher_from_the_students(phone_server):
877
  import json
878
 
tests/test_progress.py CHANGED
@@ -107,3 +107,12 @@ def test_merge_keeps_the_work_of_both_devices():
107
  assert out["quizzes"] == [{"at": "2026-11-01T08:00", "n": 5}] and out["updated"] == 200
108
  alone = merge({}, phone) # a device with nothing yet takes the server's copy as it is
109
  assert alone == merge(phone, {}) and {k: alone[k] for k in phone} == phone
 
 
 
 
 
 
 
 
 
 
107
  assert out["quizzes"] == [{"at": "2026-11-01T08:00", "n": 5}] and out["updated"] == 200
108
  alone = merge({}, phone) # a device with nothing yet takes the server's copy as it is
109
  assert alone == merge(phone, {}) and {k: alone[k] for k in phone} == phone
110
+
111
+
112
+ @pytest.mark.skipif(not shutil.which("node"), reason="needs node")
113
+ def test_merge_keeps_the_most_worked_example_openings_and_the_latest_time():
114
+ phone = {"updated": 200, "examples": {"a": {"n": 2, "last": "2026-11-02T10:00:00Z"}}}
115
+ laptop = {"updated": 100, "examples": {"a": {"n": 3, "last": "2026-11-01T10:00:00Z"}, "b": {"n": 1, "last": "2026-11-01T11:00:00Z"}}}
116
+ for out in (merge(phone, laptop), merge(laptop, phone)):
117
+ assert out["examples"] == {"a": {"n": 3, "last": "2026-11-02T10:00:00Z"}, "b": {"n": 1, "last": "2026-11-01T11:00:00Z"}}
118
+ assert merge({}, phone)["examples"] == phone["examples"]
tests/test_quiz_grades.py CHANGED
@@ -46,3 +46,29 @@ def test_points_are_rounded_per_question():
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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
49
+
50
+
51
+ def test_the_teachers_score_counts_instead_of_the_automatic_one():
52
+ ai_40_teacher_80 = {**attempt(40, verdict="partial"), "teacher_score": 80}
53
+ view = sitting_view(sitting(attempt(100), ai_40_teacher_80, attempt(100), attempt(100)))
54
+ assert view["questions"][1]["earned"] == 20 and view["questions"][1]["state"] == "teacher"
55
+ assert view["grade"] == 25 + 20 + 25 + 25
56
+
57
+
58
+ def test_a_teacher_score_grades_what_the_check_could_not_or_has_not_yet():
59
+ for unreadable in ({**attempt(None, verdict="unreadable"), "teacher_score": 70},
60
+ {**attempt(None, state="grading", verdict=None), "teacher_score": 70},
61
+ {**attempt(None, state=None, verdict=None), "teacher_score": 70}):
62
+ view = sitting_view(sitting(attempt(100), unreadable, attempt(100), attempt(100)))
63
+ assert view["open"] == 0 and view["questions"][1]["earned"] == 18 and view["grade"] == 25 + 18 + 25 + 25
64
+
65
+
66
+ def test_a_teacher_score_of_zero_is_a_score_and_a_comment_alone_changes_nothing():
67
+ zero = {**attempt(100), "teacher_score": 0}
68
+ assert question_result(25, zero)["earned"] == 0
69
+ assert question_result(25, {**attempt(100), "teacher_score": None})["earned"] == 25 # the latest review had no score
70
+
71
+
72
+ def test_a_missing_photo_stays_zero_whatever_the_teacher_says():
73
+ assert question_result(25, {**attempt(photo=False, state=None, verdict=None), "teacher_score": 100}) == \
74
+ {"points": 25, "earned": 0, "state": "missing"}
tests/test_sky_browser.py CHANGED
@@ -496,3 +496,251 @@ def test_a_first_mistake_has_no_pattern_note(browser, server):
496
  playwright.expect(page.locator(".mx-bad")).to_be_visible()
497
  playwright.expect(page.locator(".mx-pattern-note")).to_have_count(0)
498
  assert not errors
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
496
  playwright.expect(page.locator(".mx-bad")).to_be_visible()
497
  playwright.expect(page.locator(".mx-pattern-note")).to_have_count(0)
498
  assert not errors
499
+
500
+
501
+ # ---- the class teacher's signals (MathlySky.attention): who needs attention, on Wednesday 4 November 2026
502
+ CLASS = {"skills": {"t_ext": "פילוג מורחב", "g_pyth": "פיתגורס"}, "units": [{"id": "t", "name": "טכניקה אלגברית 1"}]}
503
+
504
+
505
+ def attention(page, student, ctx=CLASS):
506
+ return page.evaluate("([s, c]) => MathlySky.attention(s, c, new Date(2026, 10, 4, 12))", [student, ctx])
507
+
508
+
509
+ def kinds(found):
510
+ return [x["kind"] for x in found]
511
+
512
+
513
+ def student(**progress):
514
+ return {"signed_in": True, "progress": {"done": {}, "days": {}, "slips": [], **progress}, "quiz_sittings": []}
515
+
516
+
517
+ def slips(skill, *days_ago):
518
+ return [{"at": (datetime(2026, 11, 4, 9) - timedelta(days=d)).isoformat() + "Z", "id": skill, "q": 0, "m": "x"}
519
+ for d in days_ago]
520
+
521
+
522
+ def test_a_student_who_stopped_practising_needs_attention(browser, server):
523
+ page, errors = page_with(browser, server)
524
+ found = attention(page, student(days={"2026-10-27": 6}))
525
+ assert [(x["kind"], x["text"]) for x in found] == [("inactive", "לא תרגל 8 ימים")]
526
+ assert attention(page, student(days={"2026-10-28": 6})) != [] and attention(page, student(days={"2026-10-29": 6})) == [] # 7 days: yes; 6: no
527
+ assert attention(page, student(days={"2026-11-02": 5})) == []
528
+ assert attention(page, student(seen={"t_ext": "2026-11-03T10:00:00Z"}, days={"2026-10-01": 9})) == [] # the last practice counts
529
+ assert not errors
530
+
531
+
532
+ def test_a_student_stuck_on_a_skill_is_named_with_it(browser, server):
533
+ page, errors = page_with(browser, server)
534
+ found = attention(page, student(days={"2026-11-03": 5}, slips=slips("t_ext", 1, 2, 3)))
535
+ assert [(x["kind"], x["skills"]) for x in found] == [("stuck", ["t_ext"])] and "פילוג מורחב" in found[0]["text"]
536
+ assert attention(page, student(days={"2026-11-03": 5}, slips=slips("t_ext", 1, 2))) == [] # two slips are not stuck
537
+ assert attention(page, student(days={"2026-11-03": 5}, slips=slips("t_ext", 1, 20, 30))) == [] # old ones do not count
538
+ assert attention(page, student(days={"2026-11-03": 5}, done={"t_ext": "2026-11-04"}, slips=slips("t_ext", 1, 2, 3))) == []
539
+ assert attention(page, student(days={"2026-11-03": 5}, slips=slips("other", 1, 2, 3))) == [] # not a skill of the class
540
+ assert not errors
541
+
542
+
543
+ def test_a_drop_in_practice_needs_enough_practice_to_drop_from(browser, server):
544
+ page, errors = page_with(browser, server)
545
+ week = lambda this, last: {"2026-11-02": this, "2026-10-27": last // 2, "2026-10-29": last - last // 2}
546
+ assert page.evaluate("p => MathlySky.weekly(p, new Date(2026, 10, 4, 12))", {"days": week(14, 22)}) == {"thisWeek": 14, "lastWeek": 22}
547
+ found = attention(page, student(days=week(6, 20)))
548
+ assert kinds(found) == ["drop"] and "6" in found[0]["text"] and "20" in found[0]["text"]
549
+ assert attention(page, student(days=week(2, 8))) == [] # 8 last week is too little to call a drop
550
+ assert attention(page, student(days=week(10, 20))) == [] # exactly half is not less than half
551
+ assert not errors
552
+
553
+
554
+ def test_a_low_server_grade_needs_attention_but_the_devices_does_not(browser, server):
555
+ page, errors = page_with(browser, server)
556
+ sit = lambda grade, at: {"id": at, "unit_id": "t", "grade": grade, "open": 0 if grade is not None else 1, "submitted_at": at}
557
+ base = student(days={"2026-11-03": 5})
558
+ found = attention(page, {**base, "quiz_sittings": [sit(40, "2026-11-03T10:00:00Z")]})
559
+ assert [(x["kind"], x["unit"]) for x in found] == [("grade", "t")] and "40" in found[0]["text"]
560
+ assert attention(page, {**base, "quiz_sittings": [sit(40, "2026-10-01T10:00:00Z"), sit(70, "2026-11-03T10:00:00Z")]}) == [] # the latest counts
561
+ assert attention(page, {**base, "quiz_sittings": [sit(40, "2026-10-01T10:00:00Z"), sit(None, "2026-11-03T10:00:00Z")]}) != [] # an open one is no grade
562
+ assert attention(page, {**base, "quiz_sittings": None}) == []
563
+ assert attention(page, {**base, "progress": {**base["progress"], "uq": {"t": {"history": [{"score": 10}]}}}}) == []
564
+ assert attention(page, {**base, "quiz_sittings": [sit(55, "2026-11-03T10:00:00Z")]}) == [] # 55 is not below 55
565
+ assert not errors
566
+
567
+
568
+ def test_a_student_who_never_started_is_listed_only_once_signed_in(browser, server):
569
+ page, errors = page_with(browser, server)
570
+ assert [x["text"] for x in attention(page, {"signed_in": True, "progress": None, "quiz_sittings": []})] == ["עוד לא התחיל לתרגל"]
571
+ assert attention(page, {"signed_in": False, "progress": None, "quiz_sittings": []}) == []
572
+ assert not errors
573
+
574
+
575
+ # ---- the worked example and the help panel. The page is given an example for every skill, as the data will have.
576
+ HELP_EXAMPLE = {"question": "$(x + 5)(x − 2) = ?$",
577
+ "steps": [{"do": "כופלים כל איבר בכל איבר: $x² − 2x + 5x − 10$", "why": "כך לא שוכחים אף מכפלה."},
578
+ {"do": "מחברים איברים דומים: $−2x + 5x = 3x$", "why": "רק איברים עם אותה חזקה מתחברים."},
579
+ {"do": "התוצאה: $x² + 3x − 10$", "why": "סידרנו מהחזקה הגבוהה לנמוכה."}],
580
+ "answer": "$x² + 3x − 10$"}
581
+
582
+
583
+ def page_with_examples(browser, server, progress=None):
584
+ """Like page_with, with a worked example added to every skill in the skills the server sends."""
585
+ ctx = browser.new_context(viewport={"width": 390, "height": 844})
586
+ ctx.add_init_script("Math.random=()=>0.99")
587
+ ctx.route("https://cdn.jsdelivr.net/**", lambda route: route.abort())
588
+ ctx.route("https://fonts.googleapis.com/**", lambda route: route.abort())
589
+ ctx.add_init_script("try{localStorage.setItem('mathly.sound','off')}catch{}")
590
+ if progress is not None:
591
+ ctx.add_init_script(f"localStorage.setItem({json.dumps(KEY)},{json.dumps(json.dumps(progress))})")
592
+
593
+ def skills(route):
594
+ graph = route.fetch().json()
595
+ for skill in graph["skills"]:
596
+ skill["example"] = HELP_EXAMPLE
597
+ route.fulfill(json=graph)
598
+
599
+ ctx.route("**/api/math/skills", skills)
600
+ page = ctx.new_page()
601
+ errors = []
602
+ page.on("pageerror", lambda e: errors.append(str(e)))
603
+ page.goto(server)
604
+ playwright.expect(page.locator(".mx-trail")).to_be_visible()
605
+ return page, errors
606
+
607
+
608
+ def wrong_answer(page):
609
+ page.locator(".mx-choices button:not([data-right])").first.click()
610
+ playwright.expect(page.locator(".mx-bad")).to_be_visible()
611
+
612
+
613
+ def test_the_worked_example_is_offered_from_the_second_mistake_in_a_round(browser, server):
614
+ page, errors = page_with_examples(browser, server)
615
+ skill = page.locator(".mx-star.st-next").get_attribute("data-id")
616
+ page.locator(".mx-star.st-next").click()
617
+ wrong_answer(page)
618
+ assert page.locator("[data-example]").count() == 0 # the first mistake: only the tip
619
+ page.locator(".mx-bad button.solid").click() # on to the next question
620
+ asked = page.locator(".mx-q p").first.inner_text()
621
+ wrong_answer(page)
622
+ offer = page.locator(".mx-bad [data-example]")
623
+ playwright.expect(offer).to_have_text("איך פותרים? דוגמה פתורה")
624
+ # open it: a panel over the question, one step at a time, and the answer only at the end
625
+ offer.click()
626
+ help_ = page.locator(".mx-help")
627
+ playwright.expect(help_.locator(".mx-example")).to_be_visible()
628
+ playwright.expect(help_.locator(".mx-example-q")).to_contain_text("(x + 5)(x − 2)")
629
+ steps = help_.locator(".mx-example-steps li")
630
+ assert [steps.nth(i).is_visible() for i in range(3)] == [True, False, False]
631
+ assert not help_.locator(".mx-example-answer").is_visible()
632
+ help_.locator("[data-example-next]").click()
633
+ assert [steps.nth(i).is_visible() for i in range(3)] == [True, True, False]
634
+ help_.locator("[data-example-next]").click()
635
+ playwright.expect(help_.locator("[data-example-next]")).to_have_text("הצגת התשובה")
636
+ help_.locator("[data-example-next]").click()
637
+ playwright.expect(help_.locator(".mx-example-answer")).to_be_visible()
638
+ playwright.expect(help_.locator("[data-example-next]")).to_be_hidden()
639
+ # closing returns to the same question, with the same feedback
640
+ help_.locator("button.solid").click()
641
+ playwright.expect(page.locator(".mx-help-wrap")).to_have_count(0)
642
+ assert page.locator(".mx-q p").first.inner_text() == asked
643
+ playwright.expect(page.locator(".mx-bad")).to_be_visible()
644
+ # a third mistake offers it again; Escape closes the panel before the sheet
645
+ page.locator(".mx-bad button.solid").click()
646
+ wrong_answer(page)
647
+ page.locator(".mx-bad [data-example]").click()
648
+ playwright.expect(page.locator(".mx-help")).to_be_visible()
649
+ page.keyboard.press("Escape")
650
+ playwright.expect(page.locator(".mx-help-wrap")).to_have_count(0)
651
+ playwright.expect(page.locator(".mx-sheet").first).to_be_visible()
652
+ assert progress_of(page)["examples"][skill]["n"] == 2 # both openings are kept
653
+ assert not errors
654
+
655
+
656
+ def test_a_skill_without_an_example_offers_none(browser, server):
657
+ page, errors = page_with(browser, server) # the data has no examples yet
658
+ page.locator(".mx-star.st-next").click()
659
+ wrong_answer(page)
660
+ page.locator(".mx-bad button.solid").click()
661
+ wrong_answer(page)
662
+ assert page.locator("[data-example]").count() == 0
663
+ page.locator(".mx-helpbtn").click() # the help is there all the same
664
+ playwright.expect(page.locator(".mx-help")).to_contain_text("לכישור הזה עוד אין דוגמה פתורה")
665
+ assert "examples" not in progress_of(page) or progress_of(page)["examples"] == {}
666
+ assert not errors
667
+
668
+
669
+ def test_opening_the_example_is_recorded_and_changes_neither_finishing_nor_gold(browser, server):
670
+ page, errors = page_with_examples(browser, server)
671
+ nxt = page.locator(".mx-star.st-next").get_attribute("data-id")
672
+ page.locator(".mx-star.st-next").click()
673
+ page.locator(".mx-helpbtn").click() # the "?" shows the example before any answer
674
+ playwright.expect(page.locator(".mx-help .mx-example")).to_be_visible()
675
+ from app.math_skills import algebra9
676
+ description = next(x["description"] for x in algebra9()["skills"] if x["id"] == nxt)
677
+ playwright.expect(page.locator(".mx-help")).to_contain_text(description.split("$")[0].strip()[:20]) # and the skill's description
678
+ page.locator(".mx-help button.solid").click()
679
+ p = progress_of(page)
680
+ assert p["examples"][nxt]["n"] == 1 and p["examples"][nxt]["last"] and not p["done"].get(nxt)
681
+ answer_skill_right(page) # clean first-try answers still make it gold
682
+ playwright.expect(page.locator(".sky-cele h2").first).to_have_text("כוכב זהב!")
683
+ while page.locator(".sky-cele [data-sky=go]").count():
684
+ page.locator(".sky-cele [data-sky=go]").first.click()
685
+ p = progress_of(page)
686
+ assert p["done"][nxt] and p["gold"][nxt] and p["examples"][nxt]["n"] == 1
687
+ assert not errors
688
+
689
+
690
+ # ---- assignments (MathlySky.assignmentState): created on 1 November 2026 (10:00 UTC), due 12 November, now Wednesday 4 November
691
+ ASSIGNMENT = {"id": "a1", "created_at": "2026-11-01T10:00:00Z", "due_on": "2026-11-12", "title": "חזרה",
692
+ "items": [{"kind": "skill", "id": "g_pyth"}, {"kind": "unit_quiz", "unit": "g"}, {"kind": "exam", "units": ["g", "t"]}]}
693
+
694
+
695
+ def state(page, progress=None, sittings=(), now="2026-11-04T12:00:00", assignment=ASSIGNMENT):
696
+ return page.evaluate("([a, p, s, n]) => MathlySky.assignmentState(a, p, s, new Date(n))",
697
+ [assignment, progress or {}, None if sittings is None else list(sittings), now])
698
+
699
+
700
+ def test_a_skill_finished_before_the_assignment_is_already_done_and_on_time(browser, server):
701
+ page, errors = page_with(browser, server)
702
+ got = state(page, {"done": {"g_pyth": "2026-09-20T08:00:00Z"}})
703
+ first = got["items"][0]
704
+ assert (first["done"], first["late"]) == (True, False) and got["doneCount"] == 1 and got["total"] == 3
705
+ assert got["next"] == 1 and not got["finished"] and not got["overdue"] # the next one to do is the unit quiz
706
+ assert state(page, {})["items"][0]["done"] is False
707
+ assert not errors
708
+
709
+
710
+ def test_a_unit_quiz_counts_only_when_handed_in_after_the_assignment_was_made(browser, server):
711
+ page, errors = page_with(browser, server)
712
+ before = [{"unit_id": "g", "submitted_at": "2026-10-30T09:00:00Z"}]
713
+ after = [{"unit_id": "g", "submitted_at": "2026-11-03T09:00:00Z"}]
714
+ other = [{"unit_id": "t", "submitted_at": "2026-11-03T09:00:00Z"}]
715
+ assert state(page, sittings=before)["items"][1]["done"] is False
716
+ assert state(page, sittings=other)["items"][1]["done"] is False
717
+ assert state(page, sittings=before + after)["items"][1]["done"] is True
718
+ assert state(page, sittings=after)["items"][1]["at"] == "2026-11-03T09:00:00Z"
719
+ unknown = state(page, sittings=None)["items"][1] # the server's record could not be read
720
+ assert (unknown["done"], unknown["known"]) == (False, False)
721
+ assert not errors
722
+
723
+
724
+ def test_a_practice_exam_must_cover_every_unit_of_the_item_and_come_after_the_assignment(browser, server):
725
+ page, errors = page_with(browser, server)
726
+ exam = lambda at, *units: {"quizzes": [{"at": at, "n": 12, "right": 9, "units": list(units)}]}
727
+ assert state(page, exam("2026-11-03T09:00:00Z", "g"))["items"][2]["done"] is False # missing a unit
728
+ assert state(page, exam("2026-10-30T09:00:00Z", "g", "t"))["items"][2]["done"] is False # before the assignment
729
+ assert state(page, exam("2026-11-03T09:00:00Z", "g", "t"))["items"][2]["done"] is True
730
+ assert state(page, exam("2026-11-03T09:00:00Z", "t", "g", "k"))["items"][2]["done"] is True # more units than asked is fine
731
+ assert not errors
732
+
733
+
734
+ def test_late_means_after_the_due_day_in_jerusalem_and_overdue_means_unfinished_after_it(browser, server):
735
+ page, errors = page_with(browser, server)
736
+ all_done = lambda skill_at: {"done": {"g_pyth": skill_at}, "quizzes": [{"at": "2026-11-03T09:00:00Z", "units": ["g", "t"]}]}
737
+ sit = [{"unit_id": "g", "submitted_at": "2026-11-03T09:00:00Z"}]
738
+ on_time = state(page, all_done("2026-11-12T21:30:00Z"), sit) # 23:30 on the 12th in Jerusalem (UTC+2)
739
+ assert on_time["finished"] and not on_time["late"] and not on_time["overdue"]
740
+ late = state(page, all_done("2026-11-12T22:30:00Z"), sit) # 00:30 on the 13th
741
+ assert late["finished"] and late["late"] and late["items"][0]["late"] and not late["items"][1]["late"]
742
+ assert not state(page, {}, [], now="2026-11-12T20:00:00")["overdue"] # the evening of the due day: not yet
743
+ assert state(page, {}, [], now="2026-11-13T09:00:00")["overdue"] # the day after, unfinished
744
+ done_late = state(page, all_done("2026-11-02T09:00:00Z"), sit, now="2026-11-20T09:00:00")
745
+ assert done_late["finished"] and not done_late["overdue"] # finished: never overdue
746
+ assert not errors
tests/test_teacher_review.py ADDED
@@ -0,0 +1,336 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The class teacher's review of an open answer, and the student's request to look again.
2
+ Answers are kept in real PostgreSQL (scan_support); the AI check is a stand-in."""
3
+ import asyncio
4
+ import uuid
5
+
6
+ import httpx
7
+ import psycopg
8
+ import pytest
9
+
10
+ from app.answer_grading import validate_assessment
11
+ from app.answer_scans import ScanError, clean_image
12
+ from app.main import create_app
13
+ from scan_support import OWNER, OTHER, ScanAuth, answer_store, postgres_server
14
+ from test_answer_grading import assessment
15
+ from test_ai_queue import ClassAuth, STRANGER, as_user, mail
16
+ from test_answer_scans import png, request
17
+
18
+
19
+ def photographed(store, owner=OWNER, question='geometry'):
20
+ """An attempt of `owner` with a saved photo; returns its id."""
21
+ sid = request(store, owner, question=question)['scan']['id']
22
+ store.action(owner, 'confirm', scan_id=sid)
23
+ scan = store.action(owner, 'begin', scan_id=sid, key=str(uuid.uuid4()))['scan']
24
+ key = f"{owner}/{scan['id']}/{scan['lease_id']}/{uuid.uuid4()}.jpg"
25
+ store.put(key, clean_image(png(), 'image/png'))
26
+ done = store.action(owner, 'finalize', scan_id=scan['id'], lease_id=scan['lease_id'], object_key=key, byte_size=500)
27
+ return done['attempt']['id']
28
+
29
+
30
+ def test_the_reviews_migration_is_additive_and_service_only(answer_store):
31
+ with answer_store.connect() as c:
32
+ assert c.execute("select name from classes").fetchone()[0] == "existing math class"
33
+ for table in ("math_teacher_reviews", "math_review_requests"):
34
+ assert c.execute("select relrowsecurity from pg_class where relname=%s", (table,)).fetchone()[0]
35
+ c.execute("set role authenticated")
36
+ with pytest.raises(psycopg.errors.InsufficientPrivilege):
37
+ c.execute(f"select * from {table}")
38
+ c.execute("reset role")
39
+ c.execute("set role anon")
40
+ with pytest.raises(psycopg.errors.InsufficientPrivilege):
41
+ c.execute("select math_review_action(null,'requests','{}')")
42
+
43
+
44
+ def test_a_review_replaces_the_one_before_and_closes_the_students_request(answer_store):
45
+ store = answer_store
46
+ aid = photographed(store)
47
+ assert store.review(OWNER, 'get', attempt_id=aid) == {'review': None, 'request': None}
48
+ asked = store.review(OWNER, 'request', attempt_id=aid, message='כתבתי x=4')
49
+ assert asked['request']['state'] == 'open' and asked['request']['message'] == 'כתבתי x=4'
50
+ assert store.review(OWNER, 'request', attempt_id=aid)['request']['id'] == asked['request']['id'] # one open request
51
+ with answer_store.connect() as c: # the database itself keeps a second open request out
52
+ with pytest.raises(psycopg.errors.UniqueViolation):
53
+ c.execute("insert into math_review_requests(user_id, attempt_id) select user_id, attempt_id from math_review_requests")
54
+ first = store.review(OWNER, 'review', attempt_id=aid, reviewer='t@x', score=85, comment='הדרך נכונה')
55
+ assert first['review']['score'] == 85 and first['review']['reviewer_email'] == 't@x'
56
+ assert first['request']['state'] == 'closed'
57
+ second = store.review(OWNER, 'review', attempt_id=aid, reviewer='t@x', score=None, comment='רק הערה')
58
+ assert second['review']['score'] is None and second['review']['comment'] == 'רק הערה' # replaces, not merges
59
+ assert store.review(OWNER, 'owner', attempt_id=aid) == OWNER
60
+ with answer_store.connect() as c:
61
+ assert c.execute("select count(*) from math_teacher_reviews").fetchone()[0] == 2 # the first is history
62
+
63
+
64
+ def test_only_an_answer_with_a_photo_can_be_reviewed_or_questioned(answer_store):
65
+ store = answer_store
66
+ bare = store.action(OWNER, 'resume', question_id='geometry', question_version='v1',
67
+ snapshot={'question': 'q', 'type': 'open-ended'})['attempt']['id']
68
+ for action, data in (('review', {'score': 50, 'reviewer': 't@x'}), ('request', {})):
69
+ with pytest.raises(ScanError, match='answer_required'):
70
+ store.review(OWNER, action, attempt_id=bare, **data)
71
+ with pytest.raises(ScanError) as exc:
72
+ store.review(OTHER, 'get', attempt_id=photographed(store)) # another student's answer
73
+ assert exc.value.status == 404
74
+
75
+
76
+ def test_a_review_needs_a_score_or_a_comment_and_a_sane_score(answer_store):
77
+ store = answer_store
78
+ aid = photographed(store)
79
+ with pytest.raises(Exception):
80
+ store.review(OWNER, 'review', attempt_id=aid, reviewer='t@x', score=None, comment='')
81
+ with pytest.raises(Exception):
82
+ store.review(OWNER, 'review', attempt_id=aid, reviewer='t@x', score=120, comment='')
83
+ assert store.review(OWNER, 'get', attempt_id=aid)['review'] is None
84
+
85
+
86
+ def test_a_student_has_at_most_five_open_requests(answer_store):
87
+ store = answer_store
88
+ ids = [photographed(store, question=f'q{i}') for i in range(6)]
89
+ for aid in ids[:5]:
90
+ store.review(OWNER, 'request', attempt_id=aid)
91
+ with pytest.raises(ScanError) as exc:
92
+ store.review(OWNER, 'request', attempt_id=ids[5])
93
+ assert (exc.value.status, exc.value.code) == (429, 'review_limit')
94
+ store.review(OWNER, 'review', attempt_id=ids[0], reviewer='t@x', score=70, comment='') # one closes: room again
95
+ assert store.review(OWNER, 'request', attempt_id=ids[5])['request']['state'] == 'open'
96
+
97
+
98
+ def test_unseen_reviews_are_listed_until_the_student_opens_the_answer(answer_store):
99
+ store = answer_store
100
+ aid = photographed(store)
101
+ assert store.review(OWNER, 'unseen') == []
102
+ store.review(OWNER, 'review', attempt_id=aid, reviewer='t@x', score=60, comment='נסו שוב')
103
+ unseen = store.review(OWNER, 'unseen')
104
+ assert [(x['attempt_id'], x['score'], x['comment']) for x in unseen] == [(aid, 60, 'נסו שוב')]
105
+ assert store.review(OTHER, 'unseen') == []
106
+ store.review(OWNER, 'seen', attempt_id=aid)
107
+ assert store.review(OWNER, 'unseen') == []
108
+ store.review(OWNER, 'review', attempt_id=aid, reviewer='t@x', score=90, comment='תוקן') # a newer review: unseen again
109
+ assert len(store.review(OWNER, 'unseen')) == 1
110
+
111
+
112
+ def test_open_requests_are_listed_for_chosen_students(answer_store):
113
+ store = answer_store
114
+ mine, theirs = photographed(store), photographed(store, OTHER)
115
+ store.review(OWNER, 'request', attempt_id=mine, message='א')
116
+ store.review(OTHER, 'request', attempt_id=theirs, message='ב')
117
+ both = store.review(None, 'requests')
118
+ assert {x['attempt_id'] for x in both} == {mine, theirs}
119
+ only = store.review(None, 'requests', users=[OWNER])
120
+ assert [(x['attempt_id'], x['user_id'], x['message']) for x in only] == [(mine, OWNER, 'א')]
121
+ assert store.review(None, 'requests', users=[]) == []
122
+
123
+
124
+ # ---- the teacher's routes. OWNER is a student of class 1, whose teacher is OTHER; STRANGER teaches class 2.
125
+ class ReviewAuth(ClassAuth):
126
+ def user_email(self, user_id):
127
+ return mail(user_id)
128
+
129
+ def user_accounts(self, emails):
130
+ return {mail(uid): {'id': uid, 'name': ''} for uid in (OWNER, OTHER, STRANGER) if mail(uid) in emails}
131
+
132
+
133
+ def checked(store, aid, score=40, verdict='partial'):
134
+ lease = str(uuid.uuid4())
135
+ store.grade(OWNER, 'start', attempt_id=aid, lease_id=lease)
136
+ store.grade(OWNER, 'complete', attempt_id=aid, lease_id=lease, result={'verdict': verdict, 'score': score})
137
+
138
+
139
+ def review_app(store, manager=False):
140
+ auth = ReviewAuth()
141
+ if manager:
142
+ auth.teachers = frozenset({mail(STRANGER)}) # STRANGER is a manager here
143
+ app = create_app(auth=auth)
144
+ app.state.answers = store
145
+ return app
146
+
147
+
148
+ def test_the_class_teacher_opens_an_answer_with_its_photo_and_check(answer_store):
149
+ store = answer_store
150
+ aid = photographed(store)
151
+ checked(store, aid, 40)
152
+ app = review_app(store)
153
+
154
+ async def run():
155
+ async with as_user(app, OTHER) as teacher:
156
+ r = await teacher.get(f'/api/teacher/answers/{aid}')
157
+ assert r.status_code == 200
158
+ data = r.json()
159
+ assert data['student'] == {'email': mail(OWNER)} and data['grading']['result']['score'] == 40
160
+ assert 'object_key' not in str(data) and data['effective_score'] == 40
161
+ assert data['attempt']['snapshot']['question'] and data['review'] is None and data['request'] is None
162
+ image = await teacher.get(f'/api/teacher/answers/{aid}/image')
163
+ assert image.status_code == 200 and image.headers['content-type'] == 'image/jpeg' and image.content
164
+ assert image.headers['cache-control'] == 'no-store'
165
+ asyncio.run(run())
166
+
167
+
168
+ def test_other_accounts_are_refused_and_get_neither_the_check_nor_the_image(answer_store):
169
+ store = answer_store
170
+ aid = photographed(store)
171
+ checked(store, aid)
172
+ app = review_app(store)
173
+
174
+ async def run():
175
+ async with as_user(app, STRANGER) as other, as_user(app, OWNER) as student:
176
+ for client, status in ((other, 403), (student, 403)): # another class's teacher; the student themself
177
+ for path in (f'/api/teacher/answers/{aid}', f'/api/teacher/answers/{aid}/image'):
178
+ r = await client.get(path)
179
+ assert r.status_code == status and r.headers['content-type'].startswith('application/json')
180
+ assert 'score' not in r.text and 'attachment' not in r.text
181
+ assert (await other.put(f'/api/teacher/answers/{aid}/review', json={'score': 100})).status_code == 403
182
+ assert (await other.get(f'/api/teacher/answers/{uuid.uuid4()}')).status_code == 404
183
+ asyncio.run(run())
184
+ with answer_store.connect() as c:
185
+ assert c.execute('select count(*) from math_teacher_reviews').fetchone()[0] == 0
186
+
187
+
188
+ def test_a_manager_may_open_any_answer(answer_store):
189
+ store = answer_store
190
+ aid = photographed(store)
191
+ app = review_app(store, manager=True)
192
+
193
+ async def run():
194
+ async with as_user(app, STRANGER) as manager:
195
+ assert (await manager.get(f'/api/teacher/answers/{aid}')).status_code == 200
196
+ assert (await manager.get(f'/api/teacher/answers/{aid}/image')).status_code == 200
197
+ asyncio.run(run())
198
+
199
+
200
+ def test_the_teacher_corrects_a_score_and_grades_what_the_ai_could_not_read(answer_store):
201
+ store = answer_store
202
+ first, unreadable = photographed(store, question='a'), photographed(store, question='b')
203
+ checked(store, first, 40)
204
+ checked(store, unreadable, None, verdict='unreadable')
205
+ app = review_app(store)
206
+
207
+ async def run():
208
+ async with as_user(app, OTHER) as teacher:
209
+ put = lambda aid, **body: teacher.put(f'/api/teacher/answers/{aid}/review', json=body)
210
+ r = await put(first, score=85, comment='הדרך נכונה, טעות חישוב בשורה 3')
211
+ assert r.status_code == 200 and r.json()['review']['score'] == 85
212
+ assert r.json()['review']['reviewer_email'] == mail(OTHER)
213
+ seen = (await teacher.get(f'/api/teacher/answers/{first}')).json()
214
+ assert seen['grading']['result']['score'] == 40 and seen['effective_score'] == 85 # the AI's check is kept
215
+ assert (await put(unreadable, score=70)).status_code == 200
216
+ assert (await teacher.get(f'/api/teacher/answers/{unreadable}')).json()['effective_score'] == 70
217
+ assert (await put(first, comment='רק הערה')).json()['review']['score'] is None # a newer review replaces
218
+ for bad in ({}, {'comment': ' '}, {'score': 120}, {'score': -1}, {'score': 7.5}, {'score': True},
219
+ {'score': '80'}, {'comment': 'א' * 1001}, {'comment': 5}):
220
+ assert (await put(first, **bad)).status_code == 400, bad
221
+ assert (await put(first, score=0, comment='')).status_code == 200 # zero is a score
222
+ assert (await put(first, comment='א' * 1000)).status_code == 200
223
+ asyncio.run(run())
224
+
225
+
226
+ def test_requests_to_look_again_are_listed_for_the_class_teacher_until_answered(answer_store):
227
+ store = answer_store
228
+ aid = photographed(store)
229
+ store.review(OWNER, 'request', attempt_id=aid, message='כתבתי x=4')
230
+ app = review_app(store)
231
+ app_manager = review_app(store, manager=True)
232
+
233
+ async def run():
234
+ async with as_user(app, OTHER) as teacher, as_user(app, STRANGER) as other:
235
+ listed = (await teacher.get('/api/teacher/review-requests')).json()['requests']
236
+ assert [(x['attempt_id'], x['student'], x['message']) for x in listed] == [(aid, mail(OWNER), 'כתבתי x=4')]
237
+ assert 'user_id' not in listed[0] and listed[0]['question']
238
+ assert (await other.get('/api/teacher/review-requests')).json() == {'requests': []} # another class's teacher
239
+ async with as_user(app_manager, STRANGER) as manager:
240
+ assert len((await manager.get('/api/teacher/review-requests')).json()['requests']) == 1
241
+ assert (await teacher.put(f'/api/teacher/answers/{aid}/review', json={'comment': 'בדקתי שוב'})).status_code == 200
242
+ assert (await teacher.get('/api/teacher/review-requests')).json() == {'requests': []}
243
+ asyncio.run(run())
244
+
245
+
246
+ # ---- what the student sees and can do
247
+ @pytest.fixture(autouse=True)
248
+ def open_ended_unlocked(monkeypatch):
249
+ monkeypatch.setenv('MATHLY_OPEN_ENDED_LOCKED', '0')
250
+ monkeypatch.setenv('ANTHROPIC_API_KEY', 'test-key')
251
+
252
+
253
+ def test_the_student_sees_the_teachers_review_apart_from_the_ai_check(answer_store):
254
+ store = answer_store
255
+ aid = photographed(store)
256
+ checked(store, aid, 40)
257
+ app = review_app(store)
258
+
259
+ async def run():
260
+ async with as_user(app, OWNER) as student, as_user(app, OTHER) as teacher:
261
+ got = (await student.get(f'/api/math/answer-attempts/{aid}')).json()
262
+ assert got['teacher_review'] is None and got['review_request'] is None and got['effective_score'] == 40
263
+ assert (await student.get('/api/math/teacher-comments')).json() == {'comments': []}
264
+ await teacher.put(f'/api/teacher/answers/{aid}/review', json={'score': 85, 'comment': 'הדרך נכונה'})
265
+ comments = (await student.get('/api/math/teacher-comments')).json()['comments']
266
+ assert [(x['attempt_id'], x['score'], x['comment']) for x in comments] == [(aid, 85, 'הדרך נכונה')]
267
+ got = await student.get(f'/api/math/answer-attempts/{aid}')
268
+ data = got.json()
269
+ assert data['teacher_review']['score'] == 85 and data['teacher_review']['comment'] == 'הדרך נכונה'
270
+ assert data['grading']['result']['score'] == 40 and data['effective_score'] == 85 # both, labelled apart
271
+ assert mail(OTHER) not in got.text and 'reviewer' not in got.text # no teacher email
272
+ assert (await student.get('/api/math/teacher-comments')).json() == {'comments': []} # opened: seen
273
+ await teacher.put(f'/api/teacher/answers/{aid}/review', json={'comment': 'תוקן'})
274
+ assert len((await student.get('/api/math/teacher-comments')).json()['comments']) == 1 # a newer one
275
+ assert (await student.get('/api/math/answer-attempts/' + aid)).json()['teacher_review']['comment'] == 'תוקן'
276
+ asyncio.run(run())
277
+
278
+
279
+ def test_the_teacher_checking_on_the_students_behalf_does_not_mark_a_review_seen(answer_store):
280
+ store = answer_store
281
+ aid = photographed(store)
282
+ app = review_app(store)
283
+
284
+ async def run():
285
+ async with as_user(app, OTHER) as teacher, as_user(app, OWNER) as student:
286
+ await teacher.put(f'/api/teacher/answers/{aid}/review', json={'score': 50})
287
+ await teacher.get(f'/api/teacher/answers/{aid}')
288
+ assert len((await student.get('/api/math/teacher-comments')).json()['comments']) == 1
289
+ asyncio.run(run())
290
+
291
+
292
+ def test_a_student_asks_the_teacher_to_look_again(answer_store):
293
+ store = answer_store
294
+ ids = [photographed(store, question=f'q{i}') for i in range(6)]
295
+ app = review_app(store)
296
+
297
+ async def run():
298
+ async with as_user(app, OWNER) as student, as_user(app, OTHER) as teacher:
299
+ ask = lambda aid, **body: student.post(f'/api/math/answer-attempts/{aid}/review-request', json=body)
300
+ r = await ask(ids[0], message='כתבתי x=4, לא x=1')
301
+ assert r.status_code == 200 and r.json()['review_request']['state'] == 'open'
302
+ again = await ask(ids[0], message='שוב')
303
+ assert again.json()['review_request']['id'] == r.json()['review_request']['id'] # one open request
304
+ assert (await student.get(f'/api/math/answer-attempts/{ids[0]}')).json()['review_request']['state'] == 'open'
305
+ assert (await ask(ids[1], message='א' * 301)).status_code == 400
306
+ assert (await ask(ids[1], message=7)).status_code == 400
307
+ for aid in ids[1:5]:
308
+ assert (await ask(aid)).status_code == 200 # five open
309
+ assert (await ask(ids[5])).status_code == 429
310
+ await teacher.put(f'/api/teacher/answers/{ids[0]}/review', json={'score': 100})
311
+ closed = (await student.get(f'/api/math/answer-attempts/{ids[0]}')).json()['review_request']
312
+ assert closed['state'] == 'closed'
313
+ assert (await ask(ids[5])).status_code == 200 # room again
314
+ other = as_user(app, OTHER)
315
+ async with other as stranger: # a request is for the student's own answers only
316
+ assert (await stranger.post(f'/api/math/answer-attempts/{ids[0]}/review-request', json={})).status_code == 404
317
+ asyncio.run(run())
318
+
319
+
320
+ def test_a_teacher_score_changes_the_grade_of_a_handed_in_quiz(answer_store):
321
+ from test_unit_quiz import check, quiz_answer
322
+ store = answer_store
323
+ ids = [quiz_answer(store, i) for i in range(4)]
324
+ for aid in ids:
325
+ check(store, aid, 100)
326
+ app = review_app(store)
327
+
328
+ async def run():
329
+ async with as_user(app, OWNER) as student:
330
+ made = (await student.post('/api/math/quiz-sittings', json={
331
+ 'unit_id': 't', 'attempts': ids, 'client_key': str(uuid.uuid4())})).json()
332
+ assert made['grade'] == 100
333
+ store.review(OWNER, 'review', attempt_id=ids[1], reviewer=mail(OTHER), score=40, comment='')
334
+ mine = (await student.get('/api/math/quiz-sittings', params={'unit_id': 't'})).json()['sittings'][0]
335
+ assert mine['grade'] == 25 + 10 + 25 + 25 and mine['questions'][1]['state'] == 'teacher'
336
+ asyncio.run(run())
tests/test_unit_quiz.py CHANGED
@@ -423,3 +423,65 @@ def test_a_check_that_finishes_after_handing_in_gives_the_sitting_its_grade(answ
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())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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())
426
+
427
+
428
+ def test_a_quiz_result_shows_the_teachers_words_and_the_home_screen_tells_about_them(quiz_server, answer_store):
429
+ """A sitting handed in and checked, then the teacher (OWNER is a manager in these tests) reviews one answer: the
430
+ student's home screen says so, the result shows the teacher above the AI's check, and the student can ask again."""
431
+ import uuid
432
+ url, calls = quiz_server
433
+ store = answer_store
434
+ ids = [quiz_answer(store, i) for i in range(4)]
435
+ for aid, score in zip(ids, (100, 60, 100, 40)):
436
+ check(store, aid, score)
437
+ headers = {"Authorization": f"Bearer {OWNER}"}
438
+ sitting = httpx.post(url + "/api/math/quiz-sittings", headers=headers, json={
439
+ "unit_id": "t", "attempts": ids, "client_key": str(uuid.uuid4())}).json()
440
+ review = httpx.put(url + f"/api/teacher/answers/{ids[1]}/review", headers=headers,
441
+ json={"score": 90, "comment": "הדרך נכונה, שימו לב לסימן"})
442
+ assert review.status_code == 200
443
+ entry = {"at": "2026-11-04T09:00:00.000Z", "attempts": ids, "score": None, "timeUp": False,
444
+ "ck": str(uuid.uuid4()), "sid": sitting["id"]}
445
+ seed = json.dumps(json.dumps({"uq": {"t": {"history": [entry]}}}))
446
+ key = json.dumps("mathly.progress.v1:" + OWNER + "@test.invalid")
447
+ from test_answer_browser import session
448
+ with playwright.sync_playwright() as p:
449
+ browser = p.chromium.launch()
450
+ try:
451
+ context = session(browser, mobile=True)
452
+ context.add_init_script(f"if(!localStorage.getItem({key})) localStorage.setItem({key},{seed})")
453
+ page = context.new_page(); errors = []
454
+ page.on("pageerror", lambda e: errors.append(str(e)))
455
+ page.goto(url)
456
+ page.locator('.mx-nav [data-view="path"]').click()
457
+ notice = page.locator(".mx-teacher-comment")
458
+ playwright.expect(notice).to_contain_text("המורה הגיב על תשובה שלכם")
459
+ playwright.expect(notice).to_contain_text("90/100")
460
+ notice.click()
461
+ sheet = page.locator(".mx-uq-sheet")
462
+ playwright.expect(sheet.locator("h3")).to_have_text("בוחן מסכם: 83/100", timeout=15000) # 25 + 23 + 25 + 10
463
+ second = sheet.locator(".mx-uq-q").nth(1)
464
+ playwright.expect(second.locator(".mx-teacher-note")).to_contain_text("הערת המורה")
465
+ playwright.expect(second.locator(".mx-teacher-note")).to_contain_text("90/100")
466
+ playwright.expect(second.locator(".mx-teacher-note")).to_contain_text("שימו לב לסימן")
467
+ playwright.expect(second.locator(".mx-uq-verdict")).to_have_text("נבדק על ידי המורה")
468
+ playwright.expect(second.locator(".mx-uq-qhead span")).to_have_text("23/25 נק׳")
469
+ playwright.expect(second).to_contain_text("בדיקה אוטומטית:")
470
+ assert sheet.locator(".mx-teacher-note").count() == 1 # only the reviewed answer has one
471
+ # the student asks the teacher to look again at the third answer
472
+ third = sheet.locator(".mx-uq-q").nth(2)
473
+ third.locator(".mx-ask summary").click()
474
+ third.locator("[data-ask-msg]").fill("כתבתי את כל השלבים")
475
+ third.locator("[data-ask-review]").click()
476
+ playwright.expect(third.locator(".mx-ask-wait")).to_contain_text("הבקשה ממתינה")
477
+ listed = httpx.get(url + "/api/teacher/review-requests", headers=headers).json()["requests"]
478
+ assert [(x["attempt_id"], x["message"]) for x in listed] == [(ids[2], "כתבתי את כל השלבים")]
479
+ # the answer was opened: the notice is gone, and stays gone after a reload
480
+ page.locator(".mx-uq-sheet .mx-x, .mx-uq-sheet [data-act=close]").first.click()
481
+ playwright.expect(page.locator(".mx-teacher-comment")).to_have_count(0)
482
+ page.reload(); page.locator('.mx-nav [data-view="path"]').click()
483
+ playwright.expect(page.locator(".mx-trail")).to_be_visible()
484
+ playwright.expect(page.locator(".mx-teacher-comment")).to_have_count(0)
485
+ assert not errors
486
+ finally:
487
+ browser.close()
tests/test_unit_resources.py CHANGED
@@ -62,6 +62,11 @@ class MemoryResources(UnitResources):
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
 
@@ -289,3 +294,352 @@ def test_teacher_photographs_a_lesson_resource_on_a_phone(resource_server, tmp_p
289
  expect(student.locator('.mx-res')).to_have_count(0)
290
  finally:
291
  browser.close()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  def remove(self, resource_id):
63
  self.rows.pop(resource_id, None)
64
 
65
+ def set_visible(self, resource_id, visible):
66
+ if resource_id in self.rows:
67
+ self.rows[resource_id]['visible'] = visible
68
+ return self.rows.get(resource_id)
69
+
70
  def put(self, key, data):
71
  self.files[key] = data
72
 
 
294
  expect(student.locator('.mx-res')).to_have_count(0)
295
  finally:
296
  browser.close()
297
+
298
+
299
+ # ---- what students see
300
+ def classed(*students_in_class_t):
301
+ """An Auth with one class that has unit t, for the students listed; the others are in no class."""
302
+ class ClassedAuth(Auth):
303
+ def classes(self):
304
+ return [{'id': 1, 'name': 'ט1', 'lessons': ['t'], 'emails': [s[:4] + '@test.invalid' for s in students_in_class_t],
305
+ 'teachers': [], 'nicknames': {}, 'exams': []}]
306
+ return ClassedAuth()
307
+
308
+
309
+ def test_the_migration_adds_a_visible_column_that_starts_false(postgres_server):
310
+ psycopg = pytest.importorskip('psycopg')
311
+ database = 'resvis_' + uuid.uuid4().hex
312
+ with psycopg.connect(postgres_server.get_uri(), autocommit=True) as c:
313
+ c.execute('create database ' + database)
314
+ try:
315
+ with psycopg.connect(postgres_server.get_uri(database=database), autocommit=True) as c:
316
+ c.execute(BOOTSTRAP)
317
+ c.execute(Path('supabase/migrations/20261101090000_math_unit_resources.sql').read_text())
318
+ c.execute('insert into auth.users values(%s)', (TEACHER,))
319
+ c.execute("insert into math_unit_resources(unit_id,kind,object_key,byte_size) values('t','theory','t/old.jpg',10)")
320
+ c.execute(Path('supabase/migrations/20261104180000_math_resources_visible.sql').read_text())
321
+ c.execute("insert into math_unit_resources(unit_id,kind,object_key,byte_size) values('t','exercise','t/new.jpg',10)")
322
+ c.execute("insert into math_unit_resources(unit_id,kind,object_key,byte_size,visible) values('t','theory','t/shown.jpg',10,true)")
323
+ rows = dict(c.execute('select object_key, visible from math_unit_resources').fetchall())
324
+ assert rows == {'t/old.jpg': False, 't/new.jpg': False, 't/shown.jpg': True} # what existed stays hidden
325
+ assert c.execute("select is_nullable from information_schema.columns where table_name='math_unit_resources' "
326
+ "and column_name='visible'").fetchone()[0] == 'NO'
327
+ c.execute('set role authenticated')
328
+ with pytest.raises(psycopg.errors.InsufficientPrivilege):
329
+ c.execute('select visible from math_unit_resources')
330
+ finally:
331
+ with psycopg.connect(postgres_server.get_uri(), autocommit=True) as c:
332
+ c.execute('drop database ' + database + ' with (force)')
333
+
334
+
335
+ def test_a_resource_is_visible_by_default_only_when_it_is_a_theory_explanation():
336
+ store = MemoryResources()
337
+ app = app_with(store)
338
+
339
+ async def check():
340
+ async with client(app, TEACHER) as teacher:
341
+ made = {k: (await add(teacher, kind=k, note='הערה')).json() for k in ('exercise', 'theory', 'other')}
342
+ assert [made[k]['visible'] for k in ('exercise', 'theory', 'other')] == [False, True, False]
343
+ on = (await teacher.post('/api/math/units/t/resources', data={'kind': 'exercise', 'visible': '1'},
344
+ files={'photo': ('p.png', png(), 'image/png')})).json()
345
+ off = (await teacher.post('/api/math/units/t/resources', data={'kind': 'theory', 'visible': '0'},
346
+ files={'photo': ('p.png', png(), 'image/png')})).json()
347
+ assert (on['visible'], off['visible']) == (True, False) # the form's box wins over the default
348
+ listed = (await teacher.get('/api/math/units/t/resources')).json()['resources']
349
+ assert {r['id']: r['visible'] for r in listed} == {made['exercise']['id']: False, made['theory']['id']: True,
350
+ made['other']['id']: False, on['id']: True, off['id']: False}
351
+ asyncio.run(check())
352
+
353
+
354
+ def test_only_the_teacher_who_added_a_resource_or_a_manager_changes_who_sees_it():
355
+ store = MemoryResources()
356
+ app = app_with(store)
357
+
358
+ async def check():
359
+ async with client(app, TEACHER) as teacher, client(app, COLLEAGUE) as colleague, \
360
+ client(app, STUDENT) as student, client(app, ADMIN) as admin:
361
+ rid = (await add(teacher)).json()['id']
362
+ patch = lambda c, **body: c.patch(f'/api/math/resources/{rid}', json=body)
363
+ assert (await patch(colleague, visible=True)).status_code == 403
364
+ assert (await patch(student, visible=True)).status_code == 403
365
+ assert store.rows[rid]['visible'] is False # still hidden
366
+ shown = await patch(teacher, visible=True)
367
+ assert shown.status_code == 200 and shown.json()['visible'] is True and 'object_key' not in shown.json()
368
+ assert (await patch(admin, visible=False)).json()['visible'] is False
369
+ for bad in ({}, {'visible': 'yes'}, {'visible': 1}, {'visible': None}):
370
+ assert (await patch(teacher, **bad)).status_code == 400
371
+ assert (await teacher.patch(f'/api/math/resources/{uuid.uuid4()}', json={'visible': True})).status_code == 404
372
+ assert (await teacher.patch('/api/math/resources/not-a-uuid', json={'visible': True})).status_code == 404
373
+ asyncio.run(check())
374
+
375
+
376
+ def test_students_see_only_the_visible_resources_of_their_classs_units():
377
+ store = MemoryResources()
378
+ app = create_app(auth=classed(STUDENT))
379
+ app.state.resources = store
380
+
381
+ async def check():
382
+ async with client(app, TEACHER) as teacher, client(app, STUDENT) as student, client(app, COLLEAGUE) as colleague:
383
+ shown = (await add(teacher, kind='theory', skill='t_ext')).json()
384
+ hidden = (await add(teacher, kind='exercise')).json()
385
+ other_unit = (await add(teacher, unit='k', kind='theory')).json() # t2 would come with t: it was split off from it
386
+ assert shown['visible'] and not hidden['visible']
387
+ listed = await student.get('/api/math/units/t/student-resources')
388
+ assert listed.status_code == 200
389
+ rows = listed.json()['resources']
390
+ assert [r['id'] for r in rows] == [shown['id']] and rows[0]['skill_id'] == 't_ext'
391
+ assert set(rows[0]) == {'id', 'unit_id', 'kind', 'note', 'skill_id', 'created_at'} # no teacher, no storage path
392
+ assert 'test.invalid' not in listed.text and 'object_key' not in listed.text
393
+ image = await student.get(f"/api/math/student-resources/{shown['id']}/image")
394
+ assert image.status_code == 200 and image.headers['content-type'] == 'image/jpeg'
395
+ assert image.headers['cache-control'].startswith('private')
396
+ # a hidden resource, asked for by its id, is not found
397
+ assert (await student.get(f"/api/math/student-resources/{hidden['id']}/image")).status_code == 404
398
+ # a unit their class does not have
399
+ assert (await student.get('/api/math/units/k/student-resources')).status_code == 404
400
+ assert (await student.get(f"/api/math/student-resources/{other_unit['id']}/image")).status_code == 404
401
+ assert (await student.get('/api/math/units/nope/student-resources')).status_code == 404
402
+ assert (await student.get('/api/math/student-resources/not-a-uuid/image')).status_code == 404
403
+ # the teacher's own list still has all of them, each marked
404
+ assert len((await teacher.get('/api/math/units/t/resources')).json()['resources']) == 2
405
+ # hiding it again takes it away from the student at once
406
+ await teacher.patch(f"/api/math/resources/{shown['id']}", json={'visible': False})
407
+ assert (await student.get('/api/math/units/t/student-resources')).json() == {'resources': []}
408
+ assert (await student.get(f"/api/math/student-resources/{shown['id']}/image")).status_code == 404
409
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as guest:
410
+ assert (await guest.get('/api/math/units/t/student-resources')).status_code == 401
411
+ asyncio.run(check())
412
+
413
+
414
+ def test_without_any_class_every_unit_is_open_to_students():
415
+ store = MemoryResources()
416
+ app = app_with(store) # no classes configured: the whole path is open
417
+
418
+ async def check():
419
+ async with client(app, TEACHER) as teacher, client(app, STUDENT) as student:
420
+ await add(teacher, unit='t2', kind='theory')
421
+ assert len((await student.get('/api/math/units/t2/student-resources')).json()['resources']) == 1
422
+ asyncio.run(check())
423
+
424
+
425
+ def jpeg(color='white'):
426
+ out = io.BytesIO()
427
+ Image.new('RGB', (200, 120), color).save(out, 'JPEG')
428
+ return out.getvalue()
429
+
430
+
431
+ def seed(store, kind='theory', skill=None, note='', visible=True, unit='t', owner=TEACHER):
432
+ key = f'{unit}/{uuid.uuid4()}.jpg'
433
+ store.put(key, jpeg())
434
+ return store.add({'unit_id': unit, 'kind': kind, 'note': note, 'skill_id': skill, 'object_key': key, 'byte_size': 10,
435
+ 'created_by': owner, 'created_email': 'aaaa@test.invalid', 'visible': visible})
436
+
437
+
438
+ def test_the_teacher_decides_what_students_see_with_a_box_and_a_toggle(resource_server, tmp_path):
439
+ playwright = pytest.importorskip('playwright.sync_api')
440
+ url, store = resource_server
441
+ photo = tmp_path / 'board.png'
442
+ photo.write_bytes(png('lightyellow'))
443
+ expect = playwright.expect
444
+ with playwright.sync_playwright() as p:
445
+ browser = p.chromium.launch()
446
+ try:
447
+ page = phone(browser, TEACHER)
448
+ errors = []
449
+ page.on('pageerror', lambda error: errors.append(str(error)))
450
+ page.goto(url)
451
+ page.locator('.mx-nav [data-view="path"]').click()
452
+ page.locator('#mx-unit-t .mx-uhead h2').click()
453
+ sheet = page.locator('.mx-res')
454
+
455
+ def take(kind):
456
+ with page.expect_file_chooser() as chooser:
457
+ sheet.locator('[data-act="res-take"]').click()
458
+ chooser.value.set_files(str(photo))
459
+ form = sheet.locator('.mx-res-form')
460
+ form.locator('label', has_text=kind).click()
461
+ return form
462
+
463
+ # the box follows the kind until the teacher decides
464
+ form = take('הסבר תיאורטי')
465
+ box = form.locator('input[name=visible]')
466
+ expect(box).to_be_checked()
467
+ form.locator('label', has_text='תרגיל חדש').click()
468
+ expect(box).not_to_be_checked()
469
+ form.locator('label', has_text='הסבר תיאורטי').click()
470
+ expect(box).to_be_checked()
471
+ form.locator('label', has_text='תרגיל חדש').click()
472
+ box.check() # an exercise the teacher wants shown
473
+ form.locator('label', has_text='הסבר תיאורטי').click()
474
+ expect(box).to_be_checked() # a decision is not overridden
475
+ form.locator('label', has_text='תרגיל חדש').click()
476
+ expect(box).to_be_checked()
477
+ form.locator('button[type=submit]').click()
478
+ expect(sheet.locator('.mx-res-item')).to_have_count(1)
479
+ expect(sheet.locator('.mx-res-vis')).to_have_text('👁 גלוי לתלמידים')
480
+ # a theory explanation is shown without touching the box; an exercise left alone stays hidden
481
+ form = take('הסבר תיאורטי')
482
+ form.locator('button[type=submit]').click()
483
+ form = take('תרגיל חדש')
484
+ expect(form.locator('input[name=visible]')).not_to_be_checked()
485
+ form.locator('button[type=submit]').click()
486
+ expect(sheet.locator('.mx-res-item')).to_have_count(3)
487
+ assert sorted(r['visible'] for r in store.rows.values()) == [False, True, True]
488
+ hidden = sheet.locator('.mx-res-item', has=page.locator('.mx-res-vis:not(.on)'))
489
+ expect(hidden).to_have_count(1)
490
+ hidden.locator('[data-act=res-visible]').click()
491
+ expect(sheet.locator('.mx-res-msg')).to_have_text('התמונה גלויה עכשיו לתלמידים.')
492
+ assert [r['visible'] for r in store.rows.values()].count(True) == 3
493
+ expect(sheet.locator('.mx-res-vis.on')).to_have_count(3)
494
+ sheet.locator('.mx-res-item').first.locator('[data-act=res-visible]').click()
495
+ expect(sheet.locator('.mx-res-msg')).to_have_text('התמונה מוסתרת מהתלמידים.')
496
+ expect(sheet.locator('.mx-res-vis.on')).to_have_count(2)
497
+ # a colleague sees which are shown, but cannot change them
498
+ colleague = phone(browser, COLLEAGUE)
499
+ colleague.goto(url)
500
+ colleague.locator('.mx-nav [data-view="path"]').click()
501
+ colleague.locator('#mx-unit-t .mx-uhead h2').click()
502
+ other = colleague.locator('.mx-res')
503
+ expect(other.locator('.mx-res-item')).to_have_count(3)
504
+ expect(other.locator('.mx-res-vis')).to_have_count(3)
505
+ expect(other.locator('[data-act=res-visible]')).to_have_count(0)
506
+ assert errors == []
507
+ finally:
508
+ browser.close()
509
+
510
+
511
+ def test_a_student_finds_the_teachers_explanations_in_the_skill_its_help_and_the_unit(resource_server):
512
+ playwright = pytest.importorskip('playwright.sync_api')
513
+ url, store = resource_server
514
+ expect = playwright.expect
515
+ seed(store, kind='theory', skill='t_ext', note='') # for the skill
516
+ seed(store, kind='other', note='סיכום היחידה', visible=True) # for the whole unit
517
+ seed(store, kind='exercise', skill='t_ext', visible=False) # hidden
518
+ seed(store, kind='theory', skill='t_simplify', visible=False)
519
+ with playwright.sync_playwright() as p:
520
+ browser = p.chromium.launch()
521
+ try:
522
+ page = phone(browser, STUDENT)
523
+ errors = []
524
+ page.on('pageerror', lambda error: errors.append(str(error)))
525
+ page.goto(url)
526
+ page.locator('.mx-nav [data-view="path"]').click()
527
+ # the skill's sheet: the explanation tagged with it, and only that one
528
+ page.locator('.mx-star[data-id="t_ext"]').click()
529
+ box = page.locator('.mx-sheet [data-skill-res]')
530
+ expect(box).to_be_visible()
531
+ expect(box.locator('h4')).to_have_text('הסברים מהמורה')
532
+ expect(box.locator('figure')).to_have_count(1)
533
+ expect(box.locator('figure img')).to_have_attribute('src', re.compile('^blob:'))
534
+ expect(box.locator('figcaption')).to_contain_text('הסבר תיאורטי')
535
+ # its help panel shows it too
536
+ page.locator('.mx-helpbtn').click()
537
+ expect(page.locator('.mx-help [data-help-resources] figure')).to_have_count(1)
538
+ expect(page.locator('.mx-help [data-help-resources] img')).to_have_attribute('src', re.compile('^blob:'))
539
+ page.locator('.mx-help button.solid').click()
540
+ page.locator('.mx-sheet .mx-x').last.click()
541
+ # a skill with none visible shows no such box
542
+ page.locator('.mx-star[data-id="t_simplify"]').click()
543
+ expect(page.locator('.mx-sheet [data-skill-res]')).to_be_hidden()
544
+ page.locator('.mx-sheet .mx-x').last.click()
545
+ # the unit's own: a button under the unit, then the list
546
+ button = page.locator('#mx-unit-t .mx-sres-open')
547
+ expect(button).to_have_text('📷 הסברים מהמורה (1)')
548
+ button.click()
549
+ listed = page.locator('.mx-res [data-unit-res]')
550
+ expect(listed.locator('figure')).to_have_count(1)
551
+ expect(listed.locator('figcaption')).to_contain_text('סיכום היחידה')
552
+ listed.locator('.mx-sres-img').click() # tap to enlarge
553
+ expect(listed.locator('.mx-sres-item.zoom')).to_have_count(1)
554
+ assert errors == []
555
+ finally:
556
+ browser.close()
557
+
558
+
559
+ def test_a_unit_without_explanations_for_the_unit_has_no_button(resource_server):
560
+ playwright = pytest.importorskip('playwright.sync_api')
561
+ url, store = resource_server
562
+ seed(store, kind='theory', skill='t_ext') # tied to a skill only
563
+ seed(store, kind='other', note='מוסתר', visible=False)
564
+ with playwright.sync_playwright() as p:
565
+ browser = p.chromium.launch()
566
+ try:
567
+ page = phone(browser, STUDENT)
568
+ page.goto(url)
569
+ page.locator('.mx-nav [data-view="path"]').click()
570
+ playwright.expect(page.locator('#mx-unit-t')).to_be_visible()
571
+ page.wait_for_timeout(800)
572
+ assert page.locator('.mx-sres-open').count() == 0
573
+ finally:
574
+ browser.close()
575
+
576
+
577
+ @pytest.fixture
578
+ def class_server():
579
+ """The app with one class (unit t) that the student is in, and the resources kept in memory."""
580
+ store = MemoryResources()
581
+ app = create_app(auth=classed(STUDENT))
582
+ app.state.resources = store
583
+ with socket.socket() as sock:
584
+ sock.bind(('127.0.0.1', 0))
585
+ server = uvicorn.Server(uvicorn.Config(app, log_level='error'))
586
+ thread = threading.Thread(target=server.run, kwargs={'sockets': [sock]}, daemon=True)
587
+ thread.start()
588
+ try:
589
+ deadline = time.monotonic() + 5
590
+ while not server.started:
591
+ assert thread.is_alive() and time.monotonic() < deadline
592
+ time.sleep(.01)
593
+ yield f'http://127.0.0.1:{sock.getsockname()[1]}', store
594
+ finally:
595
+ server.should_exit = True
596
+ thread.join(timeout=5)
597
+
598
+
599
+ def test_a_theory_photo_a_teacher_uploads_appears_in_their_students_skill_sheet(class_server, tmp_path):
600
+ """The whole path in two browsers: the teacher photographs a theory explanation of a skill, and the student of the
601
+ class, opening that skill, finds it under the teacher's explanations."""
602
+ playwright = pytest.importorskip('playwright.sync_api')
603
+ url, store = class_server
604
+ photo = tmp_path / 'board.png'
605
+ photo.write_bytes(png('lightyellow'))
606
+ expect = playwright.expect
607
+ with playwright.sync_playwright() as p:
608
+ browser = p.chromium.launch()
609
+ try:
610
+ teacher = phone(browser, TEACHER)
611
+ teacher.goto(url)
612
+ teacher.locator('.mx-nav [data-view="path"]').click()
613
+ teacher.locator('#mx-unit-t .mx-uhead h2').click()
614
+ sheet = teacher.locator('.mx-res')
615
+ with teacher.expect_file_chooser() as chooser:
616
+ sheet.locator('[data-act="res-take"]').click()
617
+ chooser.value.set_files(str(photo))
618
+ form = sheet.locator('.mx-res-form')
619
+ form.locator('label', has_text='הסבר תיאורטי').click()
620
+ form.locator('select[name=skill]').select_option('t_ext')
621
+ form.locator('button[type=submit]').click()
622
+ expect(sheet.locator('.mx-res-vis')).to_have_text('👁 גלוי לתלמידים')
623
+ (row,) = store.rows.values()
624
+ assert (row['kind'], row['skill_id'], row['visible']) == ('theory', 't_ext', True)
625
+
626
+ student = phone(browser, STUDENT)
627
+ errors = []
628
+ student.on('pageerror', lambda error: errors.append(str(error)))
629
+ student.goto(url)
630
+ student.locator('.mx-nav [data-view="path"]').click()
631
+ student.locator('.mx-star[data-id="t_ext"]').click()
632
+ box = student.locator('.mx-sheet [data-skill-res]')
633
+ expect(box).to_be_visible()
634
+ expect(box.locator('figure')).to_have_count(1)
635
+ expect(box.locator('figure img')).to_have_attribute('src', re.compile('^blob:'))
636
+ assert errors == []
637
+ # the teacher hides it: the student no longer finds it (after the page asks again)
638
+ teacher.locator('.mx-res [data-act=res-visible]').click()
639
+ expect(teacher.locator('.mx-res-vis')).to_have_text('🔒 מוסתר מהתלמידים')
640
+ student.reload()
641
+ student.locator('.mx-nav [data-view="path"]').click()
642
+ student.locator('.mx-star[data-id="t_ext"]').click()
643
+ expect(student.locator('.mx-sheet [data-skill-res]')).to_be_hidden()
644
+ finally:
645
+ browser.close()