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