After a quiz, exercise test or the simulation: answer the wrong questions again
Browse filesA mistakes round (kind mistakes_round) over the questions the handed-in attempt got wrong, in the lesson round's sheet: instant feedback,
never the right answer, counted as practice, the grade unchanged. Needs migration 20261122090000_m2_mistakes_round.sql.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- mathly2/rounds.py +50 -7
- mathly2/static/quiz.js +1 -1
- mathly2/static/round.js +27 -5
- mathly2/static/student.js +8 -1
- mathly2/supabase/migrations/20261122090000_m2_mistakes_round.sql +9 -0
- mathly2/tests/test_home_browser.py +28 -0
- mathly2/tests/test_permissions.py +1 -0
- mathly2/tests/test_realtests_api.py +34 -0
mathly2/rounds.py
CHANGED
|
@@ -6,7 +6,11 @@ ROUND_SIZE from all of them. The questions are answered in order, each once, and
|
|
| 6 |
for mastery and the daily goal; a wrong one goes to the review list). After the last answer the round is graded and closed.
|
| 7 |
|
| 8 |
A lesson's stars: 3 when every skill is mastered, else 2 when a finished round had at least TWO_STARS percent right, else 1 when a round was
|
| 9 |
-
finished, else 0. Nothing here ever says which choice was right.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
from __future__ import annotations
|
| 11 |
|
| 12 |
import logging
|
|
@@ -27,6 +31,8 @@ KIND = "lesson_round"
|
|
| 27 |
ROUND_SIZE = 10
|
| 28 |
PER_SKILL = 3 # three right in a row is what mastery needs, so a skill never takes more of a round
|
| 29 |
TWO_STARS = 80 # percent right in a finished round for the second star
|
|
|
|
|
|
|
| 30 |
|
| 31 |
|
| 32 |
class RoundAnswer(BaseModel):
|
|
@@ -102,10 +108,16 @@ def _lesson_stars(request: Request, uid: str, lesson_id: str, rows: list[dict])
|
|
| 102 |
def view(request: Request, row: dict) -> dict:
|
| 103 |
"""An open round: its size, a result per answered question and the next question (without the right answer). A finished round: its result."""
|
| 104 |
bank, store = request.app.state.bank, request.app.state.store
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
done = _graded(bank, row)
|
| 107 |
-
base = {"id": row["id"], "lesson": {"id": lesson["id"], "name": lesson["name"]}, "subject_id": lesson.get("subject_id", "math"),
|
| 108 |
-
"results": [right for _, right, _ in done]}
|
| 109 |
if attempts.is_open(row):
|
| 110 |
nxt = row["questions"][len(done)] if len(done) < len(row["questions"]) else None
|
| 111 |
served = bank.practice_question(nxt["skill_id"], nxt["key"]) if nxt else None
|
|
@@ -134,19 +146,50 @@ def view(request: Request, row: dict) -> dict:
|
|
| 134 |
served = bank.practice_question(q["skill_id"], q["key"])
|
| 135 |
missed.append({"question": served["question"] if served else "", "skill_name": (bank.skill(q["skill_id"]) or {}).get("name", ""),
|
| 136 |
"mistake": {"name": mistake["name"], "tip": mistake["tip"]} if mistake else None})
|
| 137 |
-
|
| 138 |
return {**base, "state": "done", "question": None,
|
| 139 |
-
"result": {"right": sum(base["results"]), "total": base["total"], "grade": row["grade"], "stars":
|
| 140 |
"mastered": mastered, "almost": almost, "missed": missed}}
|
| 141 |
|
| 142 |
|
| 143 |
def _mine(request: Request, user: dict, round_id: str) -> dict:
|
| 144 |
row = request.app.state.store.get_attempt(round_id)
|
| 145 |
-
if row is None or row["user_id"] != user["id"] or row["kind"]
|
| 146 |
raise HTTPException(404, "הסבב לא נמצא")
|
| 147 |
return row
|
| 148 |
|
| 149 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
@router.post("/lessons/{lesson_id}/round/start")
|
| 151 |
def start(lesson_id: str, request: Request, user: dict = Depends(learner)) -> dict:
|
| 152 |
"""Resume the lesson's open round, else draw a new one."""
|
|
|
|
| 6 |
for mastery and the daily goal; a wrong one goes to the review list). After the last answer the round is graded and closed.
|
| 7 |
|
| 8 |
A lesson's stars: 3 when every skill is mastered, else 2 when a finished round had at least TWO_STARS percent right, else 1 when a round was
|
| 9 |
+
finished, else 0. Nothing here ever says which choice was right.
|
| 10 |
+
|
| 11 |
+
A mistakes round (חזרה על הטעויות, kind `mistakes_round`, scope: the handed-in attempt's id) is the same sheet over the questions a handed-in
|
| 12 |
+
quiz, exercise test or simulation got wrong (or left empty): answered again one by one with instant feedback, as practice. It has no stars and
|
| 13 |
+
never changes the attempt's grade."""
|
| 14 |
from __future__ import annotations
|
| 15 |
|
| 16 |
import logging
|
|
|
|
| 31 |
ROUND_SIZE = 10
|
| 32 |
PER_SKILL = 3 # three right in a row is what mastery needs, so a skill never takes more of a round
|
| 33 |
TWO_STARS = 80 # percent right in a finished round for the second star
|
| 34 |
+
RETRY = "mistakes_round"
|
| 35 |
+
RETRY_OF = ("quiz", "exercise_test", "simulation")
|
| 36 |
|
| 37 |
|
| 38 |
class RoundAnswer(BaseModel):
|
|
|
|
| 108 |
def view(request: Request, row: dict) -> dict:
|
| 109 |
"""An open round: its size, a result per answered question and the next question (without the right answer). A finished round: its result."""
|
| 110 |
bank, store = request.app.state.bank, request.app.state.store
|
| 111 |
+
source = _source_info(request, row) if row["kind"] == RETRY else None
|
| 112 |
+
if source:
|
| 113 |
+
first = bank.skill(row["questions"][0]["skill_id"]) if row["questions"] else None
|
| 114 |
+
lesson = next((l for l in bank.lessons if first and l["id"] == first["unit"]), {})
|
| 115 |
+
lesson = {"id": row["scope"], "name": source["title"], "subject_id": lesson.get("subject_id", "math")}
|
| 116 |
+
else:
|
| 117 |
+
lesson = next((l for l in bank.lessons if l["id"] == row["scope"]), {"id": row["scope"], "name": row["scope"]})
|
| 118 |
done = _graded(bank, row)
|
| 119 |
+
base = {"id": row["id"], "kind": row["kind"], "lesson": {"id": lesson["id"], "name": lesson["name"]}, "subject_id": lesson.get("subject_id", "math"),
|
| 120 |
+
"total": len(row["questions"]), "results": [right for _, right, _ in done], "source": source}
|
| 121 |
if attempts.is_open(row):
|
| 122 |
nxt = row["questions"][len(done)] if len(done) < len(row["questions"]) else None
|
| 123 |
served = bank.practice_question(nxt["skill_id"], nxt["key"]) if nxt else None
|
|
|
|
| 146 |
served = bank.practice_question(q["skill_id"], q["key"])
|
| 147 |
missed.append({"question": served["question"] if served else "", "skill_name": (bank.skill(q["skill_id"]) or {}).get("name", ""),
|
| 148 |
"mistake": {"name": mistake["name"], "tip": mistake["tip"]} if mistake else None})
|
| 149 |
+
stars_now = None if source else _lesson_stars(request, uid, row["scope"], store.attempts_of(uid, KIND, row["scope"]))
|
| 150 |
return {**base, "state": "done", "question": None,
|
| 151 |
+
"result": {"right": sum(base["results"]), "total": base["total"], "grade": row["grade"], "stars": stars_now,
|
| 152 |
"mastered": mastered, "almost": almost, "missed": missed}}
|
| 153 |
|
| 154 |
|
| 155 |
def _mine(request: Request, user: dict, round_id: str) -> dict:
|
| 156 |
row = request.app.state.store.get_attempt(round_id)
|
| 157 |
+
if row is None or row["user_id"] != user["id"] or row["kind"] not in (KIND, RETRY):
|
| 158 |
raise HTTPException(404, "הסבב לא נמצא")
|
| 159 |
return row
|
| 160 |
|
| 161 |
|
| 162 |
+
def _source_info(request: Request, row: dict) -> dict:
|
| 163 |
+
"""The handed-in attempt a mistakes round repeats: its kind, its test (if any) and a title."""
|
| 164 |
+
source = request.app.state.store.get_attempt(row["scope"])
|
| 165 |
+
kind = source["kind"] if source else "quiz"
|
| 166 |
+
test_id = int(source["scope"].split(":")[0]) if source and kind != "quiz" and source["scope"].split(":")[0].isdigit() else None
|
| 167 |
+
name = {"quiz": "הבוחן", "exercise_test": "מבחן התרגול", "simulation": "מבחן הסימולציה"}[kind]
|
| 168 |
+
return {"id": row["scope"], "kind": kind, "test_id": test_id, "lesson_id": source["scope"] if source and kind == "quiz" else None,
|
| 169 |
+
"title": f"חזרה על הטעויות ב{name}"}
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
@router.post("/attempts/{attempt_id}/retry/start")
|
| 173 |
+
def start_retry(attempt_id: str, request: Request, user: dict = Depends(learner)) -> dict:
|
| 174 |
+
"""A mistakes round over the questions this handed-in attempt got wrong (or left empty): resume the open one, else a new one."""
|
| 175 |
+
store, bank, uid = request.app.state.store, request.app.state.bank, user["id"]
|
| 176 |
+
source = store.get_attempt(attempt_id)
|
| 177 |
+
if source is None or source["user_id"] != uid or source["kind"] not in RETRY_OF:
|
| 178 |
+
raise HTTPException(404, "הבוחן לא נמצא")
|
| 179 |
+
if attempts.is_open(source):
|
| 180 |
+
raise HTTPException(409, "קודם מגישים, ואז חוזרים על הטעויות")
|
| 181 |
+
for row in store.attempts_of(uid, RETRY, attempt_id):
|
| 182 |
+
if attempts.is_open(row):
|
| 183 |
+
return view(request, row)
|
| 184 |
+
wrong = [{"skill_id": q["skill_id"], "key": q["key"]} for q in source["questions"]
|
| 185 |
+
if bank.practice_question(q["skill_id"], q["key"]) and not (bank.grade(q["skill_id"], q["key"], source["answers"].get(q["key"]))
|
| 186 |
+
if isinstance(source["answers"].get(q["key"]), int) else {"correct": False}).get("correct")]
|
| 187 |
+
if not wrong:
|
| 188 |
+
raise HTTPException(409, "אין שאלות לא נכונות לחזור עליהן")
|
| 189 |
+
now = clock.now()
|
| 190 |
+
return view(request, store.create_attempt(uid, RETRY, attempt_id, now, now + timedelta(days=365), wrong))
|
| 191 |
+
|
| 192 |
+
|
| 193 |
@router.post("/lessons/{lesson_id}/round/start")
|
| 194 |
def start(lesson_id: str, request: Request, user: dict = Depends(learner)) -> dict:
|
| 195 |
"""Resume the lesson's open round, else draw a new one."""
|
mathly2/static/quiz.js
CHANGED
|
@@ -242,7 +242,7 @@ function renderResult() {
|
|
| 242 |
${m.picked ? `<p class="muted">בחרתם: <span dir="auto">${fmt(m.picked)}</span></p>` : `<p class="muted">לא ענו על השאלה.</p>`}
|
| 243 |
${m.mistake ? `<p><b>${esc(m.mistake.name)}</b>${m.mistake.tip ? `<br>${fmt(m.mistake.tip)}` : ""}</p>` : ""}</li>`).join("")}</ol>` : ""}
|
| 244 |
${reviewHtml(r)}
|
| 245 |
-
<p class="qz-actions">${names(r).again ? `<button type="button" class="primary" id="qz-retake">${names(r).again}</button> ` : ""}<button type="button" data-close>${r.kind === "quiz" ? "חזרה למסלול" : "חזרה למבחן"}</button></p>`;
|
| 246 |
if (r.review?.state === "pending") waitForReview(r.id);
|
| 247 |
const best = r.kind === "quiz" ? api(`/api/lessons/${encodeURIComponent(r.lesson.id)}/quiz`).then(f => f.best === null ? "" : `הציון של השיעור: ${f.best}`)
|
| 248 |
: r.kind === "exercise_test" ? api(`/api/tests/${encodeURIComponent(r.test.id)}`).then(t => `הציון הטוב במבחן התרגול: ${t.exercise_tests[r.test.number - 1].best ?? ""}`) : Promise.resolve("");
|
|
|
|
| 242 |
${m.picked ? `<p class="muted">בחרתם: <span dir="auto">${fmt(m.picked)}</span></p>` : `<p class="muted">לא ענו על השאלה.</p>`}
|
| 243 |
${m.mistake ? `<p><b>${esc(m.mistake.name)}</b>${m.mistake.tip ? `<br>${fmt(m.mistake.tip)}` : ""}</p>` : ""}</li>`).join("")}</ol>` : ""}
|
| 244 |
${reviewHtml(r)}
|
| 245 |
+
<p class="qz-actions">${r.missed.length ? `<a class="button primary" id="qz-retry" href="#/retry/${esc(r.id)}">🔁 חזרה על השאלות שטעיתי (${r.missed.length})</a> ` : ""}${names(r).again ? `<button type="button" class="${r.missed.length ? "" : "primary"}" id="qz-retake">${names(r).again}</button> ` : ""}<button type="button" data-close>${r.kind === "quiz" ? "חזרה למסלול" : "חזרה למבחן"}</button></p>`;
|
| 246 |
if (r.review?.state === "pending") waitForReview(r.id);
|
| 247 |
const best = r.kind === "quiz" ? api(`/api/lessons/${encodeURIComponent(r.lesson.id)}/quiz`).then(f => f.best === null ? "" : `הציון של השיעור: ${f.best}`)
|
| 248 |
: r.kind === "exercise_test" ? api(`/api/tests/${encodeURIComponent(r.test.id)}`).then(t => `הציון הטוב במבחן התרגול: ${t.exercise_tests[r.test.number - 1].best ?? ""}`) : Promise.resolve("");
|
mathly2/static/round.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
| 1 |
// A lesson round (סבב שיעור), opened from the home's map: a short round of the lesson's skills, one question at a time, with instant feedback
|
| 2 |
// like the exercise screen. A wrong answer names the mistake and never marks the right choice. The last answer shows the lesson's stars.
|
|
|
|
| 3 |
import { api, ApiError } from "./api.js";
|
| 4 |
import { figures } from "./figures.js";
|
| 5 |
import { esc, fmt } from "./math.js";
|
| 6 |
import { loadBoard } from "./exercise.js";
|
| 7 |
-
import { homeHref, shuffled } from "./ui.js";
|
| 8 |
import { sendWorkQuietly } from "./boardwork.js";
|
| 9 |
|
| 10 |
const COUNTDOWN = 2; // seconds before the next question after a right answer
|
|
@@ -20,6 +21,13 @@ export const isOpen = () => !!layer;
|
|
| 20 |
/** Start the lesson's round (or resume the open one). Returns the round, so the router knows its subject. */
|
| 21 |
export const start = lesson => api(`/api/lessons/${encodeURIComponent(lesson)}/round/start`, { method: "POST" });
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
/** Show a round from `start` as a sheet over the home. */
|
| 24 |
export async function show(data) {
|
| 25 |
stop();
|
|
@@ -45,7 +53,7 @@ export function close() {
|
|
| 45 |
layer = round = null;
|
| 46 |
}
|
| 47 |
|
| 48 |
-
const heading = () => `<div class="mx-shtop"><div><span class="ex-chip">סבב שיעור</span><h3 id="rd-title">${fmt(round.lesson.name)}</h3></div>
|
| 49 |
<div class="mx-shacts">${window.MathlyBoard?.button() || ""}<button type="button" class="mx-x" data-close aria-label="סגירה">✕</button></div></div>`;
|
| 50 |
|
| 51 |
const dots = () => Array.from({ length: round.total }, (_, i) => {
|
|
@@ -66,7 +74,21 @@ function questionHtml() {
|
|
| 66 |
}
|
| 67 |
|
| 68 |
/** The round's result: the stars big, the score, what was mastered, what is one good day away, and the mistakes (never the right answers). */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
function resultHtml() {
|
|
|
|
| 70 |
const r = round.result;
|
| 71 |
const names = list => list.map(s => fmt(s.name)).join(", ");
|
| 72 |
const title = r.stars === 3 ? "👑 השיעור מושלם!" : r.right === r.total ? "מושלם! כל התשובות נכונות" : r.right * 10 >= r.total * 8 ? "כל הכבוד!" : "סיימתם סבב!";
|
|
@@ -165,7 +187,7 @@ async function again() {
|
|
| 165 |
const button = q("#rd-again");
|
| 166 |
button.disabled = true;
|
| 167 |
try {
|
| 168 |
-
round = await start(round.lesson.id);
|
| 169 |
streak = 0;
|
| 170 |
render();
|
| 171 |
} catch (error) {
|
|
@@ -176,7 +198,7 @@ async function again() {
|
|
| 176 |
|
| 177 |
function onClick(event) {
|
| 178 |
const target = event.target;
|
| 179 |
-
if (target.closest("[data-close]")) { location.hash =
|
| 180 |
if (target.closest("[data-board-toggle]")) { window.MathlyBoard?.toggle(); return; }
|
| 181 |
if (target.closest("#rd-again")) { again(); return; }
|
| 182 |
const choice = target.closest(".ex-choice");
|
|
@@ -186,5 +208,5 @@ function onClick(event) {
|
|
| 186 |
window.addEventListener("mathly:leave", close);
|
| 187 |
|
| 188 |
document.addEventListener("keydown", event => {
|
| 189 |
-
if (event.key === "Escape" && layer && !document.querySelector(".mx-board.full")) location.hash =
|
| 190 |
});
|
|
|
|
| 1 |
// A lesson round (סבב שיעור), opened from the home's map: a short round of the lesson's skills, one question at a time, with instant feedback
|
| 2 |
// like the exercise screen. A wrong answer names the mistake and never marks the right choice. The last answer shows the lesson's stars.
|
| 3 |
+
// The same sheet plays a mistakes round (חזרה על הטעויות): the questions a handed-in quiz, exercise test or simulation got wrong, with no stars.
|
| 4 |
import { api, ApiError } from "./api.js";
|
| 5 |
import { figures } from "./figures.js";
|
| 6 |
import { esc, fmt } from "./math.js";
|
| 7 |
import { loadBoard } from "./exercise.js";
|
| 8 |
+
import { homeHref, pathHref, shuffled } from "./ui.js";
|
| 9 |
import { sendWorkQuietly } from "./boardwork.js";
|
| 10 |
|
| 11 |
const COUNTDOWN = 2; // seconds before the next question after a right answer
|
|
|
|
| 21 |
/** Start the lesson's round (or resume the open one). Returns the round, so the router knows its subject. */
|
| 22 |
export const start = lesson => api(`/api/lessons/${encodeURIComponent(lesson)}/round/start`, { method: "POST" });
|
| 23 |
|
| 24 |
+
/** Start (or resume) the mistakes round of a handed-in attempt. */
|
| 25 |
+
export const startRetry = attemptId => api(`/api/attempts/${encodeURIComponent(attemptId)}/retry/start`, { method: "POST" });
|
| 26 |
+
|
| 27 |
+
const isRetry = () => !!round?.source;
|
| 28 |
+
/** Where closing leads: the home's map, or for a mistakes round its test's card (a quiz: the path). */
|
| 29 |
+
const leaveTo = () => round?.source ? (round.source.test_id ? `#/test/${round.source.test_id}` : pathHref()) : homeHref();
|
| 30 |
+
|
| 31 |
/** Show a round from `start` as a sheet over the home. */
|
| 32 |
export async function show(data) {
|
| 33 |
stop();
|
|
|
|
| 53 |
layer = round = null;
|
| 54 |
}
|
| 55 |
|
| 56 |
+
const heading = () => `<div class="mx-shtop"><div><span class="ex-chip">${isRetry() ? "חזרה על הטעויות" : "סבב שיעור"}</span><h3 id="rd-title">${fmt(round.lesson.name)}</h3></div>
|
| 57 |
<div class="mx-shacts">${window.MathlyBoard?.button() || ""}<button type="button" class="mx-x" data-close aria-label="סגירה">✕</button></div></div>`;
|
| 58 |
|
| 59 |
const dots = () => Array.from({ length: round.total }, (_, i) => {
|
|
|
|
| 74 |
}
|
| 75 |
|
| 76 |
/** The round's result: the stars big, the score, what was mastered, what is one good day away, and the mistakes (never the right answers). */
|
| 77 |
+
function retryResultHtml() {
|
| 78 |
+
const r = round.result, all = r.right === r.total;
|
| 79 |
+
return `<section class="rd-result" id="rd-result" data-kind="retry">
|
| 80 |
+
<p class="rd-bigstars" aria-hidden="true">${all ? "🎉" : "💪"}</p>
|
| 81 |
+
<h4 class="rd-title">${all ? "כל הטעויות תוקנו!" : "סיימתם לחזור על הטעויות"}</h4>
|
| 82 |
+
<p class="ex-score" id="rd-score"><b>${r.right}</b> מתוך ${r.total} נכונות הפעם</p>
|
| 83 |
+
${r.mastered.length ? `<p class="rd-mastered" id="rd-mastered">🎉 נשלטו: ${r.mastered.map(s => fmt(s.name)).join(", ")}</p>` : ""}
|
| 84 |
+
${r.missed.length ? `<h4>עדיין כדאי לחזק</h4><ol class="ex-missed" id="rd-missed">${r.missed.map(m => `<li><p class="muted">${fmt(m.skill_name)}</p><p class="ex-text" dir="auto">${fmt(m.question)}</p>
|
| 85 |
+
${m.mistake ? `<p><b>${esc(m.mistake.name)}</b>${m.mistake.tip ? `<br>${fmt(m.mistake.tip)}` : ""}</p>` : ""}</li>`).join("")}</ol>` : ""}
|
| 86 |
+
<p class="muted">הציון במבחן לא משתנה. התרגול נספר לשליטה במיומנויות.</p>
|
| 87 |
+
<p class="ex-actions">${all ? "" : `<button type="button" class="primary" id="rd-again">שוב על הטעויות</button> `}<button type="button" data-close>${round.source.test_id ? "חזרה למבחן" : "חזרה למסלול"}</button></p></section>`;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
function resultHtml() {
|
| 91 |
+
if (isRetry()) return retryResultHtml();
|
| 92 |
const r = round.result;
|
| 93 |
const names = list => list.map(s => fmt(s.name)).join(", ");
|
| 94 |
const title = r.stars === 3 ? "👑 השיעור מושלם!" : r.right === r.total ? "מושלם! כל התשובות נכונות" : r.right * 10 >= r.total * 8 ? "כל הכבוד!" : "סיימתם סבב!";
|
|
|
|
| 187 |
const button = q("#rd-again");
|
| 188 |
button.disabled = true;
|
| 189 |
try {
|
| 190 |
+
round = await (isRetry() ? startRetry(round.source.id) : start(round.lesson.id));
|
| 191 |
streak = 0;
|
| 192 |
render();
|
| 193 |
} catch (error) {
|
|
|
|
| 198 |
|
| 199 |
function onClick(event) {
|
| 200 |
const target = event.target;
|
| 201 |
+
if (target.closest("[data-close]")) { location.hash = leaveTo(); return; }
|
| 202 |
if (target.closest("[data-board-toggle]")) { window.MathlyBoard?.toggle(); return; }
|
| 203 |
if (target.closest("#rd-again")) { again(); return; }
|
| 204 |
const choice = target.closest(".ex-choice");
|
|
|
|
| 208 |
window.addEventListener("mathly:leave", close);
|
| 209 |
|
| 210 |
document.addEventListener("keydown", event => {
|
| 211 |
+
if (event.key === "Escape" && layer && !document.querySelector(".mx-board.full")) location.hash = leaveTo();
|
| 212 |
});
|
mathly2/static/student.js
CHANGED
|
@@ -24,7 +24,7 @@ export async function show(me, view) {
|
|
| 24 |
phone.stopComputer(); // a page that was waiting for the phone's photo is left
|
| 25 |
const raw = location.hash.replace(/^#/, "");
|
| 26 |
const where = raw.startsWith("/") ? raw : "/"; // no address, or the sign-in service's "#access_token=…": the first screen
|
| 27 |
-
if (!where.startsWith("/round/")) lessonRound.close();
|
| 28 |
try {
|
| 29 |
if (where.startsWith("/round/")) { // a lesson's round over the home (a link or a reload: the home first)
|
| 30 |
exercise.close(); quiz.close();
|
|
@@ -32,6 +32,13 @@ export async function show(me, view) {
|
|
| 32 |
setSubject(data.subject_id);
|
| 33 |
if (!document.getElementById("lesson-map")) await home.show(view);
|
| 34 |
await lessonRound.show(data);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
} else if (where.startsWith("/skill/")) {
|
| 36 |
quiz.close();
|
| 37 |
lastSkill = decodeURIComponent(where.slice(7));
|
|
|
|
| 24 |
phone.stopComputer(); // a page that was waiting for the phone's photo is left
|
| 25 |
const raw = location.hash.replace(/^#/, "");
|
| 26 |
const where = raw.startsWith("/") ? raw : "/"; // no address, or the sign-in service's "#access_token=…": the first screen
|
| 27 |
+
if (!where.startsWith("/round/") && !where.startsWith("/retry/")) lessonRound.close();
|
| 28 |
try {
|
| 29 |
if (where.startsWith("/round/")) { // a lesson's round over the home (a link or a reload: the home first)
|
| 30 |
exercise.close(); quiz.close();
|
|
|
|
| 32 |
setSubject(data.subject_id);
|
| 33 |
if (!document.getElementById("lesson-map")) await home.show(view);
|
| 34 |
await lessonRound.show(data);
|
| 35 |
+
} else if (where.startsWith("/retry/")) { // the mistakes of a handed-in attempt, over its test's card (a quiz: the path)
|
| 36 |
+
exercise.close(); quiz.close();
|
| 37 |
+
const data = await lessonRound.startRetry(decodeURIComponent(where.slice(7)));
|
| 38 |
+
setSubject(data.subject_id);
|
| 39 |
+
if (data.source.test_id) { if (!document.getElementById("test-card")) await (await import("./testcard.js")).show(view, data.source.test_id); }
|
| 40 |
+
else if (!document.getElementById("path")) await path.show(view);
|
| 41 |
+
await lessonRound.show(data);
|
| 42 |
} else if (where.startsWith("/skill/")) {
|
| 43 |
quiz.close();
|
| 44 |
lastSkill = decodeURIComponent(where.slice(7));
|
mathly2/supabase/migrations/20261122090000_m2_mistakes_round.sql
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- Mathly 2: a mistakes round (חזרה על הטעויות) after a quiz, an exercise test or the simulation: the questions it got wrong, answered again
|
| 2 |
+
-- with instant feedback as practice. Kept as m2_attempts rows of kind mistakes_round (scope: the handed-in attempt's id). One widened check.
|
| 3 |
+
-- Rollback: delete from public.m2_attempts where kind = 'mistakes_round';
|
| 4 |
+
-- alter table public.m2_attempts drop constraint m2_attempts_kind_check;
|
| 5 |
+
-- alter table public.m2_attempts add constraint m2_attempts_kind_check check (kind in ('quiz', 'exercise_test', 'simulation', 'lesson_round'));
|
| 6 |
+
begin;
|
| 7 |
+
alter table public.m2_attempts drop constraint m2_attempts_kind_check;
|
| 8 |
+
alter table public.m2_attempts add constraint m2_attempts_kind_check check (kind in ('quiz', 'exercise_test', 'simulation', 'lesson_round', 'mistakes_round'));
|
| 9 |
+
commit;
|
mathly2/tests/test_home_browser.py
CHANGED
|
@@ -374,6 +374,34 @@ def test_the_simulation_with_nothing_practiced_offers_a_bypass_on_all_the_materi
|
|
| 374 |
assert len(store.attempts_of(KID_ID, "simulation")) == 1
|
| 375 |
|
| 376 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 377 |
# --- 3.5 the grades screen has exercise tests and simulations ---
|
| 378 |
|
| 379 |
def test_exercise_tests_and_the_simulation_appear_on_the_grades_screen(browser, bank):
|
|
|
|
| 374 |
assert len(store.attempts_of(KID_ID, "simulation")) == 1
|
| 375 |
|
| 376 |
|
| 377 |
+
def test_after_the_simulation_the_wrong_questions_are_answered_again_in_a_round(browser, bank):
|
| 378 |
+
app, store, klass, lessons = world(bank)
|
| 379 |
+
started_skills(store, bank, lessons)
|
| 380 |
+
test = add_test(store, klass, lessons, 4, questions=6, minutes=45)
|
| 381 |
+
with running(app) as url:
|
| 382 |
+
page = sign_in(browser, url, f"#/sim/{test['id']}")
|
| 383 |
+
page.wait_for_selector("#qz-q")
|
| 384 |
+
page.click("#qz-submit") # nothing answered: six mistakes
|
| 385 |
+
page.click("#qz-yes")
|
| 386 |
+
page.wait_for_selector("#qz-retry")
|
| 387 |
+
assert "(6)" in page.inner_text("#qz-retry")
|
| 388 |
+
page.click("#qz-retry")
|
| 389 |
+
page.wait_for_selector(".round-layer #ex-q")
|
| 390 |
+
assert "חזרה על הטעויות" in page.inner_text(".round-layer .ex-chip") and "מתוך 6" in page.inner_text("#rd-count")
|
| 391 |
+
for n in range(6):
|
| 392 |
+
page.locator(".round-layer .ex-choice").first.click()
|
| 393 |
+
page.wait_for_selector(".round-layer .ex-ok, .round-layer .ex-bad")
|
| 394 |
+
page.click("#rd-next")
|
| 395 |
+
if n < 5:
|
| 396 |
+
page.wait_for_function(f"document.querySelector('#rd-count').textContent.includes('{n + 2} מתוך')")
|
| 397 |
+
page.wait_for_selector("#rd-result[data-kind='retry']")
|
| 398 |
+
assert "הציון במבחן לא משתנה" in page.inner_text("#rd-result") and page.locator(".rd-bigstars span").count() == 0
|
| 399 |
+
assert store.attempts_of(KID_ID, "simulation")[0]["grade"] == 0
|
| 400 |
+
page.click("#rd-result [data-close]")
|
| 401 |
+
page.wait_for_selector("#test-card")
|
| 402 |
+
assert page.locator(".round-layer").count() == 0 and page.errors == []
|
| 403 |
+
|
| 404 |
+
|
| 405 |
# --- 3.5 the grades screen has exercise tests and simulations ---
|
| 406 |
|
| 407 |
def test_exercise_tests_and_the_simulation_appear_on_the_grades_screen(browser, bank):
|
mathly2/tests/test_permissions.py
CHANGED
|
@@ -54,6 +54,7 @@ NEEDS = { # (method, path) -> permission
|
|
| 54 |
("POST", "/api/teaching/work/{attempt_id}"): "class.teach",
|
| 55 |
("POST", "/api/teaching/work/{attempt_id}/review"): "class.teach",
|
| 56 |
("POST", "/api/rounds/{round_id}/answer"): "path.use",
|
|
|
|
| 57 |
("POST", "/api/tests/{test_id}/exercise/{number}/start"): "path.use",
|
| 58 |
("POST", "/api/tests/{test_id}/simulation/start"): "path.use",
|
| 59 |
("GET", "/api/tests/{test_id}"): "path.use",
|
|
|
|
| 54 |
("POST", "/api/teaching/work/{attempt_id}"): "class.teach",
|
| 55 |
("POST", "/api/teaching/work/{attempt_id}/review"): "class.teach",
|
| 56 |
("POST", "/api/rounds/{round_id}/answer"): "path.use",
|
| 57 |
+
("POST", "/api/attempts/{attempt_id}/retry/start"): "path.use",
|
| 58 |
("POST", "/api/tests/{test_id}/exercise/{number}/start"): "path.use",
|
| 59 |
("POST", "/api/tests/{test_id}/simulation/start"): "path.use",
|
| 60 |
("GET", "/api/tests/{test_id}"): "path.use",
|
mathly2/tests/test_realtests_api.py
CHANGED
|
@@ -451,3 +451,37 @@ def test_another_students_grades_are_not_mine_and_an_unfinished_attempt_is_not_l
|
|
| 451 |
start_ex(client, test["id"]) # mine is open, not finished
|
| 452 |
assert client.get("/api/grades", headers=auth(KID)).json()["exercise_tests"] == []
|
| 453 |
assert len(client.get("/api/grades", headers=auth(KID2)).json()["exercise_tests"]) == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 451 |
start_ex(client, test["id"]) # mine is open, not finished
|
| 452 |
assert client.get("/api/grades", headers=auth(KID)).json()["exercise_tests"] == []
|
| 453 |
assert len(client.get("/api/grades", headers=auth(KID2)).json()["exercise_tests"]) == 1
|
| 454 |
+
|
| 455 |
+
|
| 456 |
+
def test_after_the_simulation_its_wrong_questions_can_be_answered_again_without_changing_the_grade(world):
|
| 457 |
+
client, store, lessons = world
|
| 458 |
+
bank = client.app.state.bank
|
| 459 |
+
test = set_test(client, lessons, days=14).json()
|
| 460 |
+
client.set_time(MON + 12 * DAY)
|
| 461 |
+
sim = client.post(f"/api/tests/{test['id']}/simulation/start?all_skills=true", headers=auth(KID)).json()
|
| 462 |
+
early = client.post(f"/api/attempts/{sim['id']}/retry/start", headers=auth(KID))
|
| 463 |
+
assert early.status_code == 409 # not handed in yet
|
| 464 |
+
done = answer_all(client, bank, sim, [True] * 8 + [False] * 4)
|
| 465 |
+
assert done["grade"] == 67 and len(done["missed"]) == 4
|
| 466 |
+
rnd = client.post(f"/api/attempts/{sim['id']}/retry/start", headers=auth(KID)).json()
|
| 467 |
+
assert rnd["kind"] == "mistakes_round" and rnd["total"] == 4 and rnd["source"]["test_id"] == test["id"]
|
| 468 |
+
assert [q["key"] for q in store.get_attempt(rnd["id"])["questions"]] == [q["key"] for q in sim["questions"][8:]]
|
| 469 |
+
assert '"correct"' not in json.dumps(rnd)
|
| 470 |
+
assert client.post(f"/api/attempts/{sim['id']}/retry/start", headers=auth(KID)).json()["id"] == rnd["id"] # an open one resumes
|
| 471 |
+
for ok in [True, False, True, True]:
|
| 472 |
+
key = rnd["question"]["key"]
|
| 473 |
+
got = client.post(f"/api/rounds/{rnd['id']}/answer", headers=auth(KID), json={"key": key, "choice": right_index(bank, key, ok)})
|
| 474 |
+
assert got.status_code == 200 and got.json()["correct"] is ok
|
| 475 |
+
rnd = got.json()["round"]
|
| 476 |
+
assert rnd["state"] == "done" and rnd["result"]["right"] == 3 and rnd["result"]["stars"] is None and len(rnd["result"]["missed"]) == 1
|
| 477 |
+
assert store.get_attempt(sim["id"])["grade"] == 67 # the simulation's grade stays
|
| 478 |
+
assert client.post(f"/api/attempts/{sim['id']}/retry/start", headers=auth(KID)).json()["id"] != rnd["id"] # again: a new round
|
| 479 |
+
assert client.post(f"/api/attempts/{sim['id']}/retry/start", headers=auth(TEACHER)).status_code == 404 # only the student's own
|
| 480 |
+
|
| 481 |
+
|
| 482 |
+
def test_a_test_with_no_mistakes_has_nothing_to_retry(world):
|
| 483 |
+
client, store, lessons = world
|
| 484 |
+
test = set_test(client, lessons, days=14, questions=6).json()
|
| 485 |
+
started(client, store, lessons)
|
| 486 |
+
done = answer_all(client, client.app.state.bank, start_ex(client, test["id"]).json(), [True] * 6)
|
| 487 |
+
assert client.post(f"/api/attempts/{done['id']}/retry/start", headers=auth(KID)).status_code == 409
|