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 +25 -1
- app/access.py +13 -0
- app/ai_queue.py +2 -10
- app/answer_routes.py +50 -7
- app/answer_scans.py +15 -1
- app/assignments.py +248 -0
- app/auth.py +15 -3
- app/class_report.py +39 -5
- app/main.py +5 -1
- app/math_skills.py +37 -0
- app/quiz_grades.py +10 -6
- app/teacher_review.py +147 -0
- app/unit_resources.py +88 -8
- openspec/changes/add-class-assignments/tasks.md +11 -11
- openspec/changes/add-class-attention-view/tasks.md +11 -11
- openspec/changes/add-student-hints-and-resources/tasks.md +12 -12
- openspec/changes/add-teacher-grade-review/tasks.md +14 -14
- scripts/draft_examples.py +190 -0
- static/answer-scans.js +35 -5
- static/guide.js +30 -5
- static/math-board.js +215 -17
- static/math-sky.js +78 -2
- static/math.css +113 -0
- static/math.js +551 -39
- supabase/migrations/20261104150000_math_teacher_reviews.sql +131 -0
- supabase/migrations/20261104180000_math_resources_visible.sql +6 -0
- supabase/migrations/20261104210000_math_assignments.sql +19 -0
- tests/scan_support.py +10 -3
- tests/test_ai_queue.py +133 -21
- tests/test_answer_browser.py +52 -0
- tests/test_assignments.py +400 -0
- tests/test_board_browser.py +160 -0
- tests/test_class_report.py +93 -7
- tests/test_draft_examples.py +162 -0
- tests/test_math_skills.py +55 -0
- tests/test_phone_browser.py +672 -0
- tests/test_progress.py +9 -0
- tests/test_quiz_grades.py +26 -0
- tests/test_sky_browser.py +248 -0
- tests/test_teacher_review.py +336 -0
- tests/test_unit_quiz.py +62 -0
- tests/test_unit_resources.py +354 -0
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
|
|
|
|
|
|
|
| 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 |
-
|
| 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}",
|
| 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
|
| 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
|
| 164 |
-
"""The
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 92 |
-
return {key: row.get(key) for key in ("id", "unit_id", "kind", "note", "skill_id", "created_email", "created_at",
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(""),
|
|
|
|
| 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 |
-
- [
|
| 6 |
-
- [
|
| 7 |
-
- [
|
| 8 |
|
| 9 |
## 2. Completion
|
| 10 |
|
| 11 |
-
- [
|
| 12 |
|
| 13 |
## 3. Student UI
|
| 14 |
|
| 15 |
-
- [
|
| 16 |
-
- [
|
| 17 |
|
| 18 |
## 4. Teacher UI
|
| 19 |
|
| 20 |
-
- [
|
| 21 |
-
- [
|
| 22 |
-
- [
|
| 23 |
-
- [
|
| 24 |
|
| 25 |
## 5. Integration
|
| 26 |
|
| 27 |
-
- [
|
|
|
|
| 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 |
-
- [
|
| 6 |
-
- [
|
| 7 |
|
| 8 |
## 2. Needs-attention section
|
| 9 |
|
| 10 |
-
- [
|
| 11 |
-
- [
|
| 12 |
-
- [
|
| 13 |
|
| 14 |
## 3. Grid and sorting
|
| 15 |
|
| 16 |
-
- [
|
| 17 |
-
- [
|
| 18 |
-
- [
|
| 19 |
-
- [
|
| 20 |
-
- [
|
| 21 |
|
| 22 |
## 4. Docs and integration
|
| 23 |
|
| 24 |
-
- [
|
| 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 |
-
- [
|
| 6 |
-
- [
|
| 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 |
-
- [
|
| 12 |
-
- [
|
| 13 |
-
- [
|
| 14 |
|
| 15 |
## 3. Resource visibility
|
| 16 |
|
| 17 |
-
- [
|
| 18 |
-
- [
|
| 19 |
-
- [
|
| 20 |
-
- [
|
| 21 |
|
| 22 |
## 4. Report and docs
|
| 23 |
|
| 24 |
-
- [
|
| 25 |
-
- [
|
| 26 |
|
| 27 |
## 5. Integration
|
| 28 |
|
| 29 |
-
- [
|
|
|
|
| 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 |
-
- [
|
| 6 |
-
- [
|
| 7 |
|
| 8 |
## 2. Teacher routes
|
| 9 |
|
| 10 |
-
- [
|
| 11 |
-
- [
|
| 12 |
-
- [
|
| 13 |
|
| 14 |
## 3. Grades use the teacher score
|
| 15 |
|
| 16 |
-
- [
|
| 17 |
-
- [
|
| 18 |
|
| 19 |
## 4. Student side
|
| 20 |
|
| 21 |
-
- [
|
| 22 |
-
- [
|
| 23 |
-
- [
|
| 24 |
|
| 25 |
## 5. Teacher UI
|
| 26 |
|
| 27 |
-
- [
|
| 28 |
-
- [
|
| 29 |
-
- [
|
| 30 |
|
| 31 |
## 6. Integration
|
| 32 |
|
| 33 |
-
- [
|
|
|
|
| 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=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[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=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[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&¤t(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>הבדיקה האוטומטית יכולה לטעות: בדקו שהתמלול שמוצג תואם למה שכתבתם
|
| 29 |
-
|
|
|
|
| 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> פותח דוח על כל תלמיד
|
|
|
|
|
|
|
|
|
|
| 44 |
<ul><li>המיומנויות שסיים ביחידות של הכיתה.</li>
|
| 45 |
<li>הטעויות שחוזרות אצלו.</li>
|
| 46 |
-
<li>הציון ב<b>בוחן המסכם</b> של כל יחידה, ותוצאות <b>מבחני התרגול</b>.</li>
|
|
|
|
|
|
|
| 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>
|
| 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),
|
|
|
|
|
|
|
| 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));
|
|
|
|
| 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
|
| 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
|
| 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)&&
|
| 225 |
return [...out];
|
| 226 |
}
|
| 227 |
-
// a
|
|
|
|
|
|
|
|
|
|
| 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=>({"&":"&","<":"<",">":"&
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
${
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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>`:""}
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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?
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 1552 |
-
|
| 1553 |
-
|
| 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 |
-
<
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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>
|
| 1757 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 1779 |
-
form.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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&§ion&&!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
|
| 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
|
| 133 |
-
|
|
|
|
| 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
|
| 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
|
| 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 |
-
|
| 40 |
-
|
| 41 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 "
|
|
|
|
|
|
|
| 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()
|