The results page showed a score and a wall of explanations. What a learner needs
afterwards is where the time went and what to go back to, so
/analysis/session/:attemptId gives them: a rail of recent sessions, the four
figures they act on — correct, completed, time per question, total time — a
donut, the weakest topics, and a paginated table of every question with its
status, difficulty, time and how peers did on it.
Time per question was not recorded at all, so it could not be reported. It is
now (`attempt_answers.seconds_spent`), banked when you leave a question and
including the one still open at submission — without that the last question of
every session would show nothing. Answers from before this read "—" rather than
claiming zero, and a question nobody else has answered has no peer rate rather
than 0%, which would read as everyone having failed it.
Also in this pass, from the review:
* quiz categories are gone from the library — a second taxonomy beside the
real one, putting a heading above every test;
* the board review sets are numbered rather than dated, in both the quizzes
and the study plans built from the same material, so a learner does not meet
2019 in one place and VII in another;
* the footer's standing note is one clause, and the gap above it no longer
looks like the page ended early.
Everything else asked for today is written down in docs/TODO.md rather than
half-built: resume instead of restart, an unsuspended exam that keeps running,
deleting a session's data, reset-all-data with a warning, recommendations split
by article/discipline/system, and the adaptive session. Two questions I owe
answers to are in there too.
208 backend, 249 frontend green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
41 lines
1.9 KiB
Python
41 lines
1.9 KiB
Python
from datetime import datetime
|
|
|
|
from sqlalchemy import Column, Integer, Boolean, String, DateTime, ForeignKey, JSON
|
|
from sqlalchemy.orm import relationship
|
|
|
|
from app.database import Base
|
|
|
|
|
|
class QuizAttempt(Base):
|
|
__tablename__ = "quiz_attempts"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
quiz_id = Column(Integer, ForeignKey("quizzes.id", ondelete="CASCADE"), nullable=False)
|
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
|
mode = Column(String(10), nullable=True) # legacy NULL resumes without exposing answers
|
|
score = Column(Integer, default=0)
|
|
total_questions = Column(Integer, default=0)
|
|
started_at = Column(DateTime, default=datetime.utcnow)
|
|
completed_at = Column(DateTime, nullable=True)
|
|
selected_question_ids = Column(JSON, nullable=True) # for question pool: which questions this attempt uses
|
|
expired = Column(Integer, default=0) # 1 = auto-submitted due to timer expiry (abandoned), excluded from history
|
|
|
|
quiz = relationship("Quiz", back_populates="attempts")
|
|
user = relationship("User", back_populates="attempts")
|
|
answers = relationship("AttemptAnswer", back_populates="attempt", cascade="all, delete-orphan")
|
|
|
|
|
|
class AttemptAnswer(Base):
|
|
__tablename__ = "attempt_answers"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
attempt_id = Column(Integer, ForeignKey("quiz_attempts.id", ondelete="CASCADE"), nullable=False)
|
|
question_id = Column(Integer, ForeignKey("questions.id", ondelete="CASCADE"), nullable=False)
|
|
user_answer = Column(String, nullable=False)
|
|
# Seconds on this question. Null means an answer from before this was
|
|
# measured, which is not the same claim as zero.
|
|
seconds_spent = Column(Integer, nullable=True)
|
|
is_correct = Column(Boolean, default=False)
|
|
|
|
attempt = relationship("QuizAttempt", back_populates="answers")
|
|
question = relationship("Question")
|