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
87 lines
3.6 KiB
Python
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
|