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
+ 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. +
+{error}
} +