feat: an exam that runs out is a result; delete and reset go all the way down
An unsuspended exam keeps running. When its clock runs out it is
submitted with what was answered and the score counts — a learner who
ran out of time sat an exam, which is a result and not an accident to
hide. Previously it was graded, flagged expired=1, excluded from every
statistic, and the client was told the opposite ("submit manually").
- attempt_expiry.settle_if_expired: one path, used by resume and by the
sessions list, so an exam left open elsewhere shows its score rather
than "in progress" forever. Suspended attempts hold their clock and
never expire.
- resume returns {expired_submitted, attempt_id}; the client opens the
analysis. The suspend dialog and the leave warning now say what
actually happens.
- delete: saved progress and device lock cleared; a study-plan block
whose only completed attempt is deleted goes back to unfinished.
- POST /attempts/reset-all: typed RESET, removes attempts, answers,
in-progress state, plan progress, reading marks, saved questions and
question notes; leaves the account, authored content and AI chats.
Settings → Your data, with the counts reported afterwards.
Also fixed on the way: the first version of the sessions-list change
mutated the dict it was iterating; the test only passed because it had
one attempt. Now two.
Backend 223/223, frontend 258/258.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
3443d4a78a
commit
a677b4be23
10 changed files with 502 additions and 61 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
91
backend/app/services/attempt_expiry.py
Normal file
91
backend/app/services/attempt_expiry.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
190
backend/tests/test_session_lifecycle.py
Normal file
190
backend/tests/test_session_lifecycle.py
Normal file
|
|
@ -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()
|
||||
27
docs/TODO.md
27
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
|
||||
|
|
|
|||
|
|
@ -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
|
|||
<><br/><br/>
|
||||
<strong style={{ color: '#16a34a' }}>Timer will pause</strong> while you are away and resume when you return. <br/>
|
||||
<span style={{ fontSize: '0.8rem' }}>
|
||||
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.
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -1198,7 +1203,6 @@ const timerStarted = timeLeft !== null
|
|||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{timeLeft !== null && <TimerDisplay seconds={timeLeft} total={totalTime} />}
|
||||
{resumedExpired && <span className="quiz-timer-expired-note" role="status">Time expired while you were away — submit manually when ready.</span>}
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setLeaveTarget(returnTo || '/')} title="Save progress and exit">
|
||||
⏸ Suspend
|
||||
</button>
|
||||
|
|
@ -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</button>
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Section title="Your data">
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', margin: '0 0 12px' }}>
|
||||
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.
|
||||
</p>
|
||||
{done ? (
|
||||
<p role="status" style={{ fontSize: '0.85rem', margin: '0 0 12px' }}>
|
||||
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'}.{' '}
|
||||
<Link to="/sessions">Go to sessions</Link>
|
||||
</p>
|
||||
) : !open ? (
|
||||
<button type="button" className="btn btn-secondary"
|
||||
style={{ color: 'var(--wrong-fg)', borderColor: 'var(--wrong-bd)' }}
|
||||
onClick={() => setOpen(true)}>Reset all practice data</button>
|
||||
) : (
|
||||
<div style={{ border: '1px solid var(--wrong-bd)', background: 'var(--wrong-bg)', borderRadius: 10, padding: 14 }}>
|
||||
<p style={{ margin: '0 0 10px', fontSize: '0.88rem', color: 'var(--wrong-fg)', fontWeight: 600 }}>
|
||||
This cannot be undone. Type RESET to confirm.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<input value={word} onChange={e => 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)' }} />
|
||||
<button type="button" className="btn btn-primary" disabled={busy || word.trim().toUpperCase() !== 'RESET'}
|
||||
style={{ background: 'var(--wrong-fg)', borderColor: 'var(--wrong-fg)' }} onClick={reset}>
|
||||
{busy ? 'Resetting…' : 'Reset everything'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => { setOpen(false); setWord('') }}>Keep my data</button>
|
||||
</div>
|
||||
{error && <p role="alert" style={{ margin: '10px 0 0', fontSize: '0.84rem', color: 'var(--wrong-fg)' }}>{error}</p>}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { user } = useAuth()
|
||||
const isAdmin = user?.role === 'admin'
|
||||
|
|
@ -348,6 +415,7 @@ export default function SettingsPage() {
|
|||
<ProfileSection user={user} />
|
||||
<NotificationsSection />
|
||||
<AppearanceSection />
|
||||
<DataSection />
|
||||
{isModerator && <NextcloudSection />}
|
||||
{isModerator && <DocumentsSection />}
|
||||
{(isAdmin || isModerator) && <AdminSection />}
|
||||
|
|
|
|||
Loading…
Reference in a new issue