diff --git a/backend/app/routers/attempts.py b/backend/app/routers/attempts.py index efa40d8..59d85de 100644 --- a/backend/app/routers/attempts.py +++ b/backend/app/routers/attempts.py @@ -9,7 +9,8 @@ from pydantic import BaseModel from sqlalchemy.orm import Session from sqlalchemy import case, func -from app.services.study_plan_context import mark_block_complete, plan_context_for_quizzes +from app.services.attempt_expiry import active_key, progress_key, settle_if_expired +from app.services.study_plan_context import mark_block_complete, plan_context_for_quizzes, unmark_block_complete from app.database import get_db from app.models.quiz import Quiz from app.models.question import Question @@ -340,38 +341,11 @@ def get_progress( r.setex(key, 7 * 24 * 3600, _json.dumps(saved)) return saved - # Check if timer expired for timed quiz (auto-submit) — only for unsuspended attempts - total_time = saved.get("total_time") - started_at_str = saved.get("started_at") - if total_time is not None and started_at_str: - try: - started_at = datetime.fromisoformat(started_at_str.replace('Z', '+00:00')) - elapsed = (datetime.now(timezone.utc) - started_at).total_seconds() - if elapsed >= total_time: - # Serialize expiry with explicit submission and use identical grading. - db.refresh(attempt, with_for_update=True) - if attempt.completed_at: - r.delete(key) - return None - grades = grade_quiz_answers(get_quiz_questions(db, quiz_id), - [(int(qid), answer) for qid, answer in saved.get("answers", {}).items()], - attempt.selected_question_ids) - for question, answer, correct in grades: - db.add(AttemptAnswer(attempt_id=attempt.id, question_id=question.id, - user_answer=answer, is_correct=correct)) - attempt.score = sum(correct for _, _, correct in grades) - attempt.total_questions = len(grades) - attempt.completed_at = datetime.utcnow() - # A block behind this quiz is now done. Nothing else ever set this; - # plans showed every block as unfinished however many times it was sat. - mark_block_complete(db, current_user.id, attempt.quiz_id) - attempt.expired = 1 # mark as timer-expired; exclude from history - db.commit() - r.delete(key) - return None # no progress to resume — already submitted - except Exception: - db.rollback() - logger.warning("Failed to check quiz timer expiration", exc_info=True) + # An unsuspended exam whose clock ran out is submitted with what was + # answered, and the score counts. The client is told so it can show the + # result rather than an empty resume. + if settle_if_expired(db, r, current_user.id, attempt, saved): + return {"expired_submitted": True, "attempt_id": attempt.id, "quiz_id": quiz_id} return saved except Exception: @@ -395,6 +369,60 @@ def clear_progress( logger.warning("Redis unavailable for progress clear", exc_info=True) +class ResetConfirm(BaseModel): + # The word typed into the confirmation box. Required in the body, not a + # query flag, so nothing can reset a learner's history from a bare link. + confirm: str + + +@router.post("/reset-all", status_code=200) +def reset_all_practice_data( + data: ResetConfirm, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Remove everything this learner has practised, and nothing they have made. + + Goes: attempts and their answers (course quizzes excepted — those belong to + the course's record), saved in-progress sessions, study-plan progress and + reading marks, saved questions and per-question notes. Stays: the account, + anything authored (tests, questions, articles, plans), AI conversations. + Returns the counts so the page can say what went. + """ + if data.confirm.strip().upper() != "RESET": + raise HTTPException(400, "Type RESET to confirm") + from app.models.favorite import Favorite + from app.models.study_plan import StudyPlanArticleRead, StudyPlanBlockProgress + from app.models.user_note import QuestionNote + + uid = current_user.id + attempts = (db.query(QuizAttempt).join(Quiz, Quiz.id == QuizAttempt.quiz_id) + .filter(QuizAttempt.user_id == uid, Quiz.course_id.is_(None)).all()) + attempt_ids = [a.id for a in attempts] + removed = {"attempts": len(attempt_ids)} + if attempt_ids: + removed["answers"] = db.query(AttemptAnswer).filter( + AttemptAnswer.attempt_id.in_(attempt_ids)).delete(synchronize_session=False) + db.query(QuizAttempt).filter(QuizAttempt.id.in_(attempt_ids)).delete(synchronize_session=False) + removed["plan_blocks"] = db.query(StudyPlanBlockProgress).filter( + StudyPlanBlockProgress.user_id == uid).delete(synchronize_session=False) + removed["reading_marks"] = db.query(StudyPlanArticleRead).filter( + StudyPlanArticleRead.user_id == uid).delete(synchronize_session=False) + removed["saved_questions"] = db.query(Favorite).filter(Favorite.user_id == uid).delete(synchronize_session=False) + removed["question_notes"] = db.query(QuestionNote).filter(QuestionNote.user_id == uid).delete(synchronize_session=False) + db.commit() + try: + import redis as redis_lib + from app.config import settings + r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True) + keys = [k for pattern in (f"quiz_progress:{uid}:*", f"quiz_active:{uid}:*") for k in r.scan_iter(pattern)] + removed["in_progress"] = r.delete(*keys) if keys else 0 + except Exception: + logger.warning("Redis unavailable during reset for user %s", uid, exc_info=True) + removed["in_progress"] = None + return {"removed": removed} + + @router.delete("/{attempt_id}", status_code=204) def delete_attempt( attempt_id: int, @@ -415,9 +443,24 @@ def delete_attempt( raise HTTPException(status_code=403, detail="Cannot delete course quiz attempts") quiz_id = attempt.quiz_id - # Delete all attempts for this quiz by this user (wipe history) db.delete(attempt) + db.flush() + # A block is done only while a completed attempt says so. Deleting the one + # that completed it puts the block back to unfinished. + still_done = db.query(QuizAttempt.id).filter( + QuizAttempt.quiz_id == quiz_id, QuizAttempt.user_id == current_user.id, + QuizAttempt.completed_at.isnot(None)).first() + if not still_done: + unmark_block_complete(db, current_user.id, quiz_id) db.commit() + # Any saved in-progress state and device lock go with it. + try: + import redis as redis_lib + from app.config import settings + r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True) + r.delete(progress_key(current_user.id, attempt_id), active_key(current_user.id, attempt_id)) + except Exception: + logger.warning("Redis unavailable when deleting attempt %s", attempt_id, exc_info=True) @router.get("/quiz/{quiz_id}/in-progress") diff --git a/backend/app/routers/quizzes.py b/backend/app/routers/quizzes.py index 71cb955..df68939 100644 --- a/backend/app/routers/quizzes.py +++ b/backend/app/routers/quizzes.py @@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import cast, String, or_, and_, func from sqlalchemy.orm import Session +from app.services.attempt_expiry import settle_if_expired from app.services.study_plan_context import plan_context_for_quizzes from app.utils.upload_access import validate_image_attachments from app.utils.quiz_questions import validate_option_explanations @@ -322,10 +323,22 @@ def list_quiz_sessions( from app.config import settings r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True) - keys = [f"quiz_progress:{current_user.id}:{a.id}" for a in active.values()] - for attempt, raw in zip(active.values(), r.mget(keys)): - if raw: - answered[attempt.id] = len(_json.loads(raw).get("answers", {}) or {}) + live_attempts = list(active.values()) + keys = [f"quiz_progress:{current_user.id}:{a.id}" for a in live_attempts] + settled = [] + for attempt, raw in zip(live_attempts, r.mget(keys)): + if not raw: + continue + saved = _json.loads(raw) + # An unsuspended exam whose time is up is a finished exam. + if settle_if_expired(db, r, current_user.id, attempt, saved): + settled.append(attempt) + continue + answered[attempt.id] = len(saved.get("answers", {}) or {}) + # Moved after the loop: `active` must not change while it is read. + for attempt in settled: + active.pop(attempt.quiz_id, None) + finished.setdefault(attempt.quiz_id, []).append(attempt) except Exception: logger.warning("Redis unavailable for session progress", exc_info=True) diff --git a/backend/app/services/attempt_expiry.py b/backend/app/services/attempt_expiry.py new file mode 100644 index 0000000..22b3b81 --- /dev/null +++ b/backend/app/services/attempt_expiry.py @@ -0,0 +1,91 @@ +"""Settling a timed attempt whose clock has run out. + +An exam that was closed without being suspended keeps running. When its time +is up it is submitted with whatever was answered, and the score counts — the +learner sat an exam and ran out of time, which is a result, not an accident to +be hidden. Previously such attempts were graded and then flagged `expired=1`, +which every statistic excluded, so the exam vanished as if never sat. + +Two places notice that a clock has run out — resuming the attempt, and listing +sessions — and both call this so the outcome is the same whichever comes first. +""" +import json +import logging +from datetime import datetime, timezone + +from sqlalchemy.orm import Session + +from app.models.attempt import AttemptAnswer, QuizAttempt +from app.services.study_plan_context import mark_block_complete +from app.utils.quiz_questions import get_quiz_questions, grade_quiz_answers + +logger = logging.getLogger(__name__) + + +def progress_key(user_id: int, attempt_id: int) -> str: + return f"quiz_progress:{user_id}:{attempt_id}" + + +def active_key(user_id: int, attempt_id: int) -> str: + return f"quiz_active:{user_id}:{attempt_id}" + + +def seconds_remaining(saved: dict) -> float | None: + """Time left on an unsuspended timed attempt, or None when there is no clock. + + A suspended attempt holds its `time_left` and has no running clock, so it + never expires while suspended. + """ + if not saved or saved.get("suspended"): + return None + total = saved.get("total_time") + started = saved.get("started_at") + if total is None or not started: + return None + try: + started_at = datetime.fromisoformat(str(started).replace("Z", "+00:00")) + except ValueError: + return None + if started_at.tzinfo is None: + started_at = started_at.replace(tzinfo=timezone.utc) + return float(total) - (datetime.now(timezone.utc) - started_at).total_seconds() + + +def settle_if_expired(db: Session, redis_client, user_id: int, attempt: QuizAttempt, saved: dict) -> bool: + """Submit `attempt` if its clock has run out. Returns True if it did. + + Grades exactly as an explicit submission would, so the two paths cannot + disagree about a score. Serialised against a concurrent manual submit by + re-reading the attempt under lock. + """ + remaining = seconds_remaining(saved) + if remaining is None or remaining > 0: + return False + try: + db.refresh(attempt, with_for_update=True) + if attempt.completed_at: + redis_client.delete(progress_key(user_id, attempt.id)) + return True + answers = [(int(qid), answer) for qid, answer in (saved.get("answers") or {}).items()] + grades = grade_quiz_answers(get_quiz_questions(db, attempt.quiz_id), answers, + attempt.selected_question_ids) + for question, answer, correct in grades: + db.add(AttemptAnswer(attempt_id=attempt.id, question_id=question.id, + user_answer=answer, is_correct=correct)) + attempt.score = sum(correct for _, _, correct in grades) + attempt.total_questions = len(grades) + attempt.completed_at = datetime.utcnow() + mark_block_complete(db, user_id, attempt.quiz_id) + db.commit() + redis_client.delete(progress_key(user_id, attempt.id)) + redis_client.delete(active_key(user_id, attempt.id)) + return True + except Exception: + db.rollback() + logger.warning("Could not settle expired attempt %s", attempt.id, exc_info=True) + return False + + +def load_saved(redis_client, user_id: int, attempt_id: int) -> dict | None: + raw = redis_client.get(progress_key(user_id, attempt_id)) + return json.loads(raw) if raw else None diff --git a/backend/app/services/study_plan_context.py b/backend/app/services/study_plan_context.py index 58a4dc9..9c0464a 100644 --- a/backend/app/services/study_plan_context.py +++ b/backend/app/services/study_plan_context.py @@ -71,3 +71,17 @@ def mark_block_complete(db: Session, user_id: int, quiz_id: int) -> None: .first()) if row is not None and row.completed_at is None: row.completed_at = datetime.utcnow() + + +def unmark_block_complete(db: Session, user_id: int, quiz_id: int) -> None: + """Put the block behind `quiz_id` back to unfinished. + + For when the attempt that completed it is deleted. The quiz link stays, so + the learner resumes the same session rather than being given a new one. + """ + row = (db.query(StudyPlanBlockProgress) + .filter(StudyPlanBlockProgress.user_id == user_id, + StudyPlanBlockProgress.quiz_id == quiz_id) + .first()) + if row is not None: + row.completed_at = None diff --git a/backend/tests/test_quiz_builder.py b/backend/tests/test_quiz_builder.py index 023ef45..68d7136 100644 --- a/backend/tests/test_quiz_builder.py +++ b/backend/tests/test_quiz_builder.py @@ -365,8 +365,11 @@ class BuilderTests(unittest.TestCase): with patch.dict(sys.modules, {"redis": redis}): response = self.client.get(f"/attempts/progress?quiz_id={saved}") self.assertEqual(response.status_code, 200, response.text) - self.assertIsNone(response.json()) - self.assertEqual(self.db.get(QuizAttempt, aid).expired, 1) + # Time ran out: submitted, and the client is told so. It is a completed + # attempt that counts, not an "expired" one hidden from history. + self.assertEqual(response.json(), {"expired_submitted": True, "attempt_id": aid, "quiz_id": saved}) + self.assertIsNotNone(self.db.get(QuizAttempt, aid).completed_at) + self.assertFalse(self.db.get(QuizAttempt, aid).expired) self.assertEqual(self.db.query(AttemptAnswer).filter_by(attempt_id=aid).count(), 1) self.assertFalse(self.db.query(AttemptAnswer).filter_by(attempt_id=aid).one().is_correct) diff --git a/backend/tests/test_session_lifecycle.py b/backend/tests/test_session_lifecycle.py new file mode 100644 index 0000000..c5340e2 --- /dev/null +++ b/backend/tests/test_session_lifecycle.py @@ -0,0 +1,190 @@ +"""What happens to a session when its clock runs out, when it is deleted, and +when a learner resets everything. + +Disposable SQLite; Redis is a Mock. The rule under test for expiry: an exam +closed without being suspended keeps running, and when time is up it is +submitted with what was answered and the score counts. +""" +import json +import sys +import unittest +from datetime import datetime +from types import ModuleType +from unittest.mock import Mock, patch + +import test_quiz_builder as fixtures +from app.models.attempt import AttemptAnswer, QuizAttempt +from app.models.favorite import Favorite +from app.models.quiz import Quiz +from app.models.study_plan import StudyPlan, StudyPlanBlock, StudyPlanBlockProgress +from app.models.user_note import QuestionNote +from app.routers import attempts, study_plans +from app.services.attempt_expiry import seconds_remaining + +with patch.dict(sys.modules, {"app.services.quiz_service": ModuleType("app.services.quiz_service")}): + from app.routers import quizzes + +LONG_AGO = "2000-01-01T00:00:00+00:00" + + +def fake_redis(store): + """Just enough of a Redis client: get / setex / delete / mget / scan_iter.""" + client = Mock() + client.get.side_effect = lambda k: store.get(k) + client.setex.side_effect = lambda k, ttl, v: store.__setitem__(k, v) + client.mget.side_effect = lambda keys: [store.get(k) for k in keys] + client.scan_iter.side_effect = lambda pattern: [k for k in list(store) if k.startswith(pattern.rstrip("*"))] + + def delete(*keys): + n = sum(1 for k in keys if store.pop(k, None) is not None) + return n + client.delete.side_effect = delete + module = Mock() + module.from_url.return_value = client + return module + + +class SessionLifecycleTests(unittest.TestCase): + def setUp(self): + self.bank = fixtures.BuilderTests() + self.bank.setUp() + self.client = self.bank.client + self.db = self.bank.db + for router, prefix in [(quizzes.router, "/quizzes"), (attempts.router, "/attempts"), + (study_plans.router, "/study-plans")]: + self.client.app.include_router(router, prefix=prefix) + self.bank.user = self.bank.owner + self.store = {} + self.redis = fake_redis(self.store) + + def tearDown(self): + self.bank.tearDown() + + def timed_quiz(self): + quiz_id = self.bank.generate(is_shared=True, category_ids=[1], count=2, mode="timed").json()["id"] + with patch.dict(sys.modules, {"redis": self.redis}): + aid = self.client.post(f"/attempts/start?quiz_id={quiz_id}&fresh=true").json()["id"] + return quiz_id, aid + + def save(self, uid, aid, **extra): + blob = {"answers": {"1": "yes"}, "total_time": 600, "started_at": LONG_AGO, **extra} + self.store[f"quiz_progress:{uid}:{aid}"] = json.dumps(blob) + + # ── expiry ──────────────────────────────────────────────────────────────── + + def test_a_suspended_exam_never_expires(self): + self.assertIsNone(seconds_remaining({"total_time": 1, "started_at": LONG_AGO, "suspended": True})) + self.assertIsNone(seconds_remaining({"answers": {}})) + self.assertLess(seconds_remaining({"total_time": 1, "started_at": LONG_AGO}), 0) + + def test_an_unsuspended_exam_that_ran_out_is_a_finished_exam_in_the_session_list(self): + quiz_id, aid = self.timed_quiz() + self.save(self.bank.owner.id, aid) + # A second one, also out of time: settling the first must not disturb + # the pass over the rest (it once mutated the dict being iterated). + other_quiz, other_aid = self.timed_quiz() + self.save(self.bank.owner.id, other_aid) + with patch.dict(sys.modules, {"redis": self.redis}): + rows = {row["quiz_id"]: row for row in self.client.get("/quizzes/sessions").json()} + self.assertEqual(rows[other_quiz]["state"], "completed") + row = rows[quiz_id] + self.assertEqual(row["state"], "completed") + self.assertEqual(row["last_attempt_id"], aid) + self.assertEqual(row["attempts_count"], 1) + attempt = self.db.get(QuizAttempt, aid) + self.assertIsNotNone(attempt.completed_at) + self.assertFalse(attempt.expired) + # Graded on what was answered; the unanswered question counts against. + self.assertEqual(attempt.total_questions, 2) + self.assertNotIn(f"quiz_progress:{self.bank.owner.id}:{aid}", self.store) + + def test_resuming_an_expired_exam_reports_the_submission(self): + quiz_id, aid = self.timed_quiz() + self.save(self.bank.owner.id, aid) + with patch.dict(sys.modules, {"redis": self.redis}): + response = self.client.get(f"/attempts/progress?quiz_id={quiz_id}") + self.assertEqual(response.json(), {"expired_submitted": True, "attempt_id": aid, "quiz_id": quiz_id}) + # And the analysis exists for it straight away. + self.assertEqual(self.client.get(f"/attempts/{aid}/analysis").status_code, 200) + + def test_a_suspended_exam_resumes_with_its_clock_held(self): + quiz_id, aid = self.timed_quiz() + self.save(self.bank.owner.id, aid, suspended=True, time_left=120) + with patch.dict(sys.modules, {"redis": self.redis}): + saved = self.client.get(f"/attempts/progress?quiz_id={quiz_id}").json() + self.assertEqual(saved["total_time"], 120) + self.assertFalse(saved["suspended"]) + self.assertIsNone(self.db.get(QuizAttempt, aid).completed_at) + + # ── delete ──────────────────────────────────────────────────────────────── + + def test_deleting_the_attempt_that_completed_a_block_makes_the_block_unfinished(self): + self.db.add(StudyPlan(id=1, slug="p", name="Plan", kind="set", is_published=1)) + self.db.flush() + self.db.add(StudyPlanBlock(id=10, plan_id=1, position=1, title="Block 1", question_ids=[1, 2])) + self.db.commit() + quiz_id = self.client.post("/study-plans/blocks/10/start", params={"mode": "timed"}).json()["id"] + self.db.add(QuizAttempt(id=800, quiz_id=quiz_id, user_id=self.bank.owner.id, mode="exam", + total_questions=2, score=2, started_at=datetime(2026, 1, 1), + completed_at=datetime(2026, 1, 1, 1))) + self.db.add(AttemptAnswer(attempt_id=800, question_id=1, user_answer="A", is_correct=True)) + progress = self.db.query(StudyPlanBlockProgress).filter_by(quiz_id=quiz_id).one() + progress.completed_at = datetime(2026, 1, 1, 1) + self.db.commit() + self.store[f"quiz_progress:{self.bank.owner.id}:800"] = "{}" + + with patch.dict(sys.modules, {"redis": self.redis}): + self.assertEqual(self.client.delete("/attempts/800").status_code, 204) + self.db.expire_all() + self.assertIsNone(self.db.get(QuizAttempt, 800)) + self.assertEqual(self.db.query(AttemptAnswer).filter_by(attempt_id=800).count(), 0) + self.assertIsNone(self.db.get(StudyPlanBlockProgress, progress.id).completed_at) + self.assertEqual(self.db.get(StudyPlanBlockProgress, progress.id).quiz_id, quiz_id) + self.assertNotIn(f"quiz_progress:{self.bank.owner.id}:800", self.store) + + def test_someone_elses_attempt_cannot_be_deleted(self): + quiz_id, aid = self.timed_quiz() + self.bank.user = self.bank.peer + with patch.dict(sys.modules, {"redis": self.redis}): + self.assertEqual(self.client.delete(f"/attempts/{aid}").status_code, 404) + + # ── reset ───────────────────────────────────────────────────────────────── + + def test_reset_removes_what_was_practised_and_keeps_what_was_made(self): + quiz_id, aid = self.timed_quiz() + uid = self.bank.owner.id + self.db.add_all([ + AttemptAnswer(attempt_id=aid, question_id=1, user_answer="A", is_correct=True), + Favorite(user_id=uid, question_id=1), + QuestionNote(user_id=uid, question_id=1, content="remember this"), + ]) + # Another learner's data is untouched. + self.db.add(QuizAttempt(id=901, quiz_id=quiz_id, user_id=self.bank.peer.id, mode="exam", + total_questions=2, score=1, started_at=datetime(2026, 1, 1), + completed_at=datetime(2026, 1, 1))) + self.db.commit() + self.store[f"quiz_progress:{uid}:{aid}"] = "{}" + self.store[f"quiz_active:{uid}:{aid}"] = "dev" + self.store[f"quiz_progress:{self.bank.peer.id}:901"] = "{}" + + with patch.dict(sys.modules, {"redis": self.redis}): + refused = self.client.post("/attempts/reset-all", json={"confirm": "yes"}) + self.assertEqual(refused.status_code, 400) + response = self.client.post("/attempts/reset-all", json={"confirm": "reset"}) + self.assertEqual(response.status_code, 200, response.text) + removed = response.json()["removed"] + self.assertEqual((removed["attempts"], removed["answers"], removed["saved_questions"], + removed["question_notes"], removed["in_progress"]), (1, 1, 1, 1, 2)) + + self.db.expire_all() + self.assertEqual(self.db.query(QuizAttempt).filter_by(user_id=uid).count(), 0) + self.assertIsNotNone(self.db.get(QuizAttempt, 901)) + self.assertIn(f"quiz_progress:{self.bank.peer.id}:901", self.store) + # The test they built is still theirs. + self.assertIsNotNone(self.db.get(Quiz, quiz_id)) + self.assertEqual(self.db.query(Favorite).filter_by(user_id=uid).count(), 0) + self.assertEqual(self.db.query(QuestionNote).filter_by(user_id=uid).count(), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/TODO.md b/docs/TODO.md index e88e58c..c4efd34 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -23,12 +23,27 @@ Captured so nothing is lost while the article writing runs. click. It now shows the session — mode, length, what the clock does — and starts when asked. Session rows and the analysis rail open the session's analysis rather than the raw answer list. -- [ ] **An unsuspended exam keeps running** — closing an exam-mode session - should let the clock continue and show the score when it expires, rather - than quietly pausing. -- [ ] **Deleting a session removes its data** — so it no longer counts towards - any statistic. Check the existing delete does this fully. -- [ ] **Reset all data**, with a warning that says plainly what goes. +- [x] **An unsuspended exam keeps running** — done 2026-09-11. When the clock + runs out the exam is submitted with what was answered and the score + counts; resuming lands on the result. Settled by whichever notices first + (resume, or the sessions list). Previously such attempts were flagged + `expired` and hidden from every statistic. Suspending still holds the clock. +- [x] **Deleting a session removes its data** — done 2026-09-11. Answers cascade; + saved progress and the device lock are cleared; a study-plan block whose + only completed attempt is deleted goes back to unfinished. +- [x] **Reset all data** — done 2026-09-11. Settings → Your data. Typed RESET, + states what goes (sessions, answers, saved progress, plan progress, + reading marks, saved questions, question notes) and what stays (account, + anything authored, AI conversations); reports the counts afterwards. + +### Articles — prose and links +- [ ] **Rewrite article prose in the third person, impersonal** — "diagnosis is + made by", "response to treatment is assessed by". One agent pass per + article: keep every fact and reference, keep the `[[id|Title]]` markers. +- [ ] **Clinical view in MDM order** — presentation → differential → diagnosis → + management → prognosis/outcome where it adds something. +- [ ] **Smart cross-linking** — decide the rule for which mentions become links + (see the note under "From the 11 Sep recordings"). ### Reading and study - [ ] **Study recommendations by Articles / Disciplines / Systems** — currently diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx index d85ad65..af112f7 100644 --- a/frontend/src/pages/QuizPage.jsx +++ b/frontend/src/pages/QuizPage.jsx @@ -432,7 +432,6 @@ export default function QuizPage() { }) const [submitError, setSubmitError] = useState('') const [resumeError, setResumeError] = useState('') - const [resumedExpired, setResumedExpired] = useState(false) const [resumeRetry, setResumeRetry] = useState(0) const [progressError, setProgressError] = useState('') const [restartConfirm, setRestartConfirm] = useState(false) @@ -592,10 +591,6 @@ export default function QuizPage() { if (saved.total_time && saved.started_at) { const elapsed = Math.floor((new Date() - new Date(saved.started_at)) / 1000) const remaining = Math.max(0, saved.total_time - elapsed) - if (saved.total_time > 0 && remaining <= 0) { - // An exam left open in another tab/browser must not submit by surprise. - setResumedExpired(true) - } setTimeLeft(remaining) setTotalTime(saved.total_time) } @@ -605,7 +600,7 @@ export default function QuizPage() { useEffect(() => { if (!attemptId) return const msg = progressError || (timeLeft !== null - ? 'Your quiz is timed. Closing the tab leaves its timer running. Progress is saved while connected.' + ? 'This exam is timed. Closing the tab leaves the clock running; when it runs out the exam is submitted with what you have answered. Suspend it to pause the clock.' : 'You have an in-progress quiz. Progress is saved while connected.') const handler = (e) => { e.preventDefault(); e.returnValue = msg } window.addEventListener('beforeunload', handler) @@ -640,6 +635,12 @@ export default function QuizPage() { params: { quiz_id: id }, headers: { 'x-quiz-session': SESSION_ID }, }) + if (progressRes.data?.expired_submitted) { + // The clock ran out while this was closed; the server submitted it + // with what was answered. The result is the thing to show. + navigate(`/sessions/${progressRes.data.attempt_id}`, { replace: true }) + return + } if (progressRes.data) { await resumeQuiz(progressRes.data, voicesRes.data) return @@ -677,6 +678,10 @@ export default function QuizPage() { const aid = attemptRes.data.id // A reused attempt may have newer progress from another tab/device. const saved = await api.get('/attempts/progress', { params: { quiz_id: id }, headers: { 'x-quiz-session': SESSION_ID } }) + if (saved.data?.expired_submitted) { + navigate(`/sessions/${saved.data.attempt_id}`, { replace: true }) + return + } if (saved.data) { await resumeQuiz(saved.data, voices) return @@ -735,7 +740,7 @@ const timerStarted = timeLeft !== null // Auto-submit when the timer expires during an active session — never right after resume. useEffect(() => { - if (timeLeft === 0 && !resumedExpired) handleSubmit(true) + if (timeLeft === 0) handleSubmit(true) }, [timeLeft]) const saveProgressNow = useCallback((overrides = {}) => { @@ -1129,7 +1134,7 @@ const timerStarted = timeLeft !== null <>

Timer will pause while you are away and resume when you return.
- Note: closing the tab without suspending keeps the timer running. If it expires while you are away, it will not submit by itself — you can submit manually when you return. + Closing the tab without suspending keeps the clock running. When it runs out, the exam is submitted with what you have answered and you will see your score. )} @@ -1198,7 +1203,6 @@ const timerStarted = timeLeft !== null
{timeLeft !== null && } - {resumedExpired && Time expired while you were away — submit manually when ready.} @@ -1208,7 +1212,6 @@ const timerStarted = timeLeft !== null setRestartConfirm(false) setAnswers({}); setCurrentIdx(0); setDraftAnswer(''); setStartedAt(null) setTimeLeft(null); setTotalTime(null); setResponseStats(null); setStatsError('') - setResumedExpired(false) hasStarted.current = false await startAttempt(quizMode || (quiz?.mode === 'timed' ? 'exam' : 'study'), selectedVoice || null, quiz?.time_limit_minutes || null, true) }}>Yes, restart diff --git a/frontend/src/pages/QuizPage.test.jsx b/frontend/src/pages/QuizPage.test.jsx index 0a3b1b7..71d3367 100644 --- a/frontend/src/pages/QuizPage.test.jsx +++ b/frontend/src/pages/QuizPage.test.jsx @@ -233,19 +233,20 @@ describe('quiz player', () => { await waitFor(() => expect(api.post).toHaveBeenCalledWith('/favorites', { question_id: 1 })) }) - it('does not auto-submit an exam whose time expired while away', async () => { + it('an exam that ran out while away was submitted by the server, and opens on its result', async () => { mode = 'exam' quizModeVar = 'timed' const originalGet = api.get.getMockImplementation() - const started = new Date(Date.now() - 30 * 60 * 1000).toISOString() api.get.mockImplementation((url, ...args) => { - if (url === '/attempts/progress') return Promise.resolve({ data: { attempt_id: 50, mode: 'exam', current_idx: 0, answers: { 1: 'First answer' }, started_at: started, total_time: 600 } }) + // The server settled it: what comes back is the fact, not the saved answers. + if (url === '/attempts/progress') return Promise.resolve({ data: { expired_submitted: true, attempt_id: 50, quiz_id: 10 } }) return originalGet(url, ...args) }) mount() - expect(await screen.findByText(/Time expired while you were away/)).toBeInTheDocument() + expect(await screen.findByText('Submitted results')).toBeInTheDocument() + // Nothing is submitted twice, and no question is shown as if still open. expect(api.post.mock.calls.some(([url]) => url === '/attempts/50/submit')).toBe(false) - expect(inCard().getByText('Full first clinical question.')).toBeInTheDocument() + expect(screen.queryByText('Full first clinical question.')).not.toBeInTheDocument() }) it('shows per-option explanations in study feedback behind a toggle', async () => { diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index 9c5511a..c1ce3ed 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -335,6 +335,73 @@ function DocumentsSection() { ) } +/** + * Everything a learner has practised, and the one button that removes it. + * + * Confirmation is a typed word rather than a second click: a second click is + * pressed by reflex, and there is no undo. The page says what goes and what + * stays before asking, and reports the counts afterwards so the result is a + * fact rather than a hope. + */ +function DataSection() { + const [open, setOpen] = useState(false) + const [word, setWord] = useState('') + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + const [done, setDone] = useState(null) + + const reset = async () => { + setBusy(true); setError('') + try { + const res = await api.post('/attempts/reset-all', { confirm: word }) + setDone(res.data.removed) + setOpen(false); setWord('') + } catch (err) { + setError(err?.response?.data?.detail || 'Could not reset your data') + } finally { setBusy(false) } + } + + return ( +
+

+ Start again from nothing. This removes every session you have sat and its answers, + anything saved part-way, your study-plan progress and reading marks, saved questions + and your notes on questions. Your account, anything you have created, and your AI + conversations stay. +

+ {done ? ( +

+ Done. Removed {done.attempts} session{done.attempts === 1 ? '' : 's'}, {done.answers || 0} answers, + {' '}{done.saved_questions} saved question{done.saved_questions === 1 ? '' : 's'} and + {' '}{done.question_notes} note{done.question_notes === 1 ? '' : 's'}.{' '} + Go to sessions +

+ ) : !open ? ( + + ) : ( +
+

+ This cannot be undone. Type RESET to confirm. +

+
+ setWord(e.target.value)} aria-label="Type RESET to confirm" + placeholder="RESET" autoComplete="off" + style={{ flex: 1, minWidth: 140, padding: '8px 10px', fontSize: 16, border: '1px solid var(--border)', borderRadius: 8, background: 'var(--input-bg)', color: 'var(--text)' }} /> + + +
+ {error &&

{error}

} +
+ )} +
+ ) +} + export default function SettingsPage() { const { user } = useAuth() const isAdmin = user?.role === 'admin' @@ -348,6 +415,7 @@ export default function SettingsPage() { + {isModerator && } {isModerator && } {(isAdmin || isModerator) && }