pdf-quiz-generator/backend/app/services/study_plan_context.py
Daniel a677b4be23 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
2026-09-11 04:47:11 +02:00

87 lines
3.6 KiB
Python

"""Which study-plan block a quiz belongs to, for the learner who started it.
A session that came out of a study plan is still a session — it sits in the
learner's list and has its own analysis — but it also has a place in a
sequence. Both the session list and the analysis need to say so, and to offer
the way back to the plan and on to the next block, so the lookup lives here
rather than in either router.
"""
from sqlalchemy.orm import Session
from app.models.study_plan import StudyPlan, StudyPlanBlock, StudyPlanBlockProgress
def plan_context_for_quizzes(db: Session, user_id: int, quiz_ids: list[int]) -> dict[int, dict]:
"""Map quiz id → plan context, for quizzes that a block produced for this learner.
Quizzes with no plan behind them are simply absent from the result.
"""
if not quiz_ids:
return {}
rows = (
db.query(StudyPlanBlockProgress, StudyPlanBlock, StudyPlan)
.join(StudyPlanBlock, StudyPlanBlock.id == StudyPlanBlockProgress.block_id)
.join(StudyPlan, StudyPlan.id == StudyPlanBlock.plan_id)
.filter(StudyPlanBlockProgress.user_id == user_id,
StudyPlanBlockProgress.quiz_id.in_(quiz_ids))
.all()
)
if not rows:
return {}
# Neighbours come from the plan's full block order, fetched once per plan.
plan_ids = {plan.id for _, _, plan in rows}
order: dict[int, list[StudyPlanBlock]] = {}
for block in (db.query(StudyPlanBlock)
.filter(StudyPlanBlock.plan_id.in_(plan_ids))
.order_by(StudyPlanBlock.plan_id, StudyPlanBlock.position).all()):
order.setdefault(block.plan_id, []).append(block)
out: dict[int, dict] = {}
for progress, block, plan in rows:
siblings = order.get(plan.id, [])
index = next((i for i, b in enumerate(siblings) if b.id == block.id), -1)
prev_block = siblings[index - 1] if index > 0 else None
next_block = siblings[index + 1] if 0 <= index < len(siblings) - 1 else None
out[progress.quiz_id] = {
"plan_id": plan.id,
"plan_name": plan.name,
"block_id": block.id,
"block_title": block.title,
"block_position": block.position,
"block_count": len(siblings),
"prev_block_id": prev_block.id if prev_block else None,
"next_block_id": next_block.id if next_block else None,
"completed": progress.completed_at is not None,
}
return out
def mark_block_complete(db: Session, user_id: int, quiz_id: int) -> None:
"""Record that the block behind `quiz_id` has been sat through.
Called when an attempt is submitted. Idempotent: a block done twice is
still done once, and the first completion is the date that counts.
"""
from datetime import datetime
row = (db.query(StudyPlanBlockProgress)
.filter(StudyPlanBlockProgress.user_id == user_id,
StudyPlanBlockProgress.quiz_id == quiz_id)
.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