Suspend pauses timer; hide timer-expired attempts from history
Suspend now pauses the timer instead of letting it run out: - 'Suspend & Leave' sends suspended=true with time_left to backend - On resume, backend re-anchors started_at to now with held time_left - Closing tab without suspending continues to run the timer (unchanged) Timer-expired auto-submits are marked with expired=1 and excluded from: - Attempt history (GET /attempts/history) - Dashboard stats (quiz count, total attempts, average score) - Attempt list (GET /attempts) - DDL: ALTER TABLE quiz_attempts ADD COLUMN expired INTEGER DEFAULT 0 Course-quiz decoupling is preserved — these changes only touch non-course quizzes (Quiz.course_id IS NULL). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c44014c787
commit
5398342e3d
4 changed files with 57 additions and 10 deletions
|
|
@ -218,6 +218,10 @@ def setup_pgvector():
|
|||
ALTER TABLE quiz_attempts
|
||||
ADD COLUMN IF NOT EXISTS selected_question_ids JSON
|
||||
"""))
|
||||
conn.execute(text("""
|
||||
ALTER TABLE quiz_attempts
|
||||
ADD COLUMN IF NOT EXISTS expired INTEGER DEFAULT 0
|
||||
"""))
|
||||
|
||||
# Question ownership
|
||||
conn.execute(text("""
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ class QuizAttempt(Base):
|
|||
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")
|
||||
|
|
|
|||
|
|
@ -198,7 +198,10 @@ def list_attempts(
|
|||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
query = db.query(QuizAttempt).filter(QuizAttempt.user_id == current_user.id)
|
||||
query = db.query(QuizAttempt).filter(
|
||||
QuizAttempt.user_id == current_user.id,
|
||||
(QuizAttempt.expired == 0) | (QuizAttempt.expired.is_(None)),
|
||||
)
|
||||
if quiz_id:
|
||||
query = query.filter(QuizAttempt.quiz_id == quiz_id)
|
||||
attempts = query.order_by(QuizAttempt.started_at.desc()).all()
|
||||
|
|
@ -224,9 +227,10 @@ class ProgressSave(BaseModel):
|
|||
current_idx: int
|
||||
mode: str
|
||||
voice: str | None = None
|
||||
time_left: int | None = None # remaining seconds for timed mode (informational, not authoritative)
|
||||
time_left: int | None = None # remaining seconds for timed mode (authoritative when suspended=True)
|
||||
started_at: str | None = None # ISO timestamp when quiz started (for timer calculation)
|
||||
total_time: int | None = None # original time limit in seconds
|
||||
suspended: bool = False # when True, timer is paused; time_left is preserved authoritatively
|
||||
|
||||
|
||||
@router.post("/progress")
|
||||
|
|
@ -260,6 +264,7 @@ def save_progress(
|
|||
"time_left": data.time_left,
|
||||
"started_at": data.started_at,
|
||||
"total_time": data.total_time,
|
||||
"suspended": data.suspended,
|
||||
}))
|
||||
except Exception:
|
||||
logger.warning("Redis unavailable for progress save", exc_info=True)
|
||||
|
|
@ -310,7 +315,19 @@ def get_progress(
|
|||
|
||||
saved = _json.loads(data)
|
||||
|
||||
# Check if timer expired for timed quiz (auto-submit)
|
||||
# If the quiz was suspended, timer is paused — re-anchor on resume so
|
||||
# the held time_left becomes the new total_time starting now.
|
||||
if saved.get("suspended"):
|
||||
time_left = saved.get("time_left")
|
||||
if time_left is not None and time_left > 0:
|
||||
saved["started_at"] = datetime.now(timezone.utc).isoformat()
|
||||
saved["total_time"] = int(time_left)
|
||||
saved["suspended"] = False
|
||||
# Persist the re-anchored progress so timer continues correctly
|
||||
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:
|
||||
|
|
@ -337,6 +354,7 @@ def get_progress(
|
|||
))
|
||||
attempt.score = score
|
||||
attempt.completed_at = datetime.utcnow()
|
||||
attempt.expired = 1 # mark as timer-expired; exclude from history
|
||||
db.commit()
|
||||
r.delete(key)
|
||||
return None # no progress to resume — already submitted
|
||||
|
|
@ -455,7 +473,7 @@ def get_quiz_history(
|
|||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return per-quiz attempt history for the performance line graph (excludes course quizzes)."""
|
||||
"""Return per-quiz attempt history for the performance line graph (excludes course quizzes and expired attempts)."""
|
||||
completed = (
|
||||
db.query(QuizAttempt)
|
||||
.join(Quiz, QuizAttempt.quiz_id == Quiz.id)
|
||||
|
|
@ -463,6 +481,7 @@ def get_quiz_history(
|
|||
QuizAttempt.user_id == current_user.id,
|
||||
QuizAttempt.completed_at.isnot(None),
|
||||
Quiz.course_id.is_(None),
|
||||
(QuizAttempt.expired == 0) | (QuizAttempt.expired.is_(None)),
|
||||
)
|
||||
.order_by(QuizAttempt.completed_at)
|
||||
.all()
|
||||
|
|
@ -497,7 +516,7 @@ def get_dashboard_stats(
|
|||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
total_docs = db.query(PDFDocument).filter(PDFDocument.user_id == current_user.id).count()
|
||||
# Count distinct quizzes the user has attempted (excludes course quizzes)
|
||||
# Count distinct quizzes the user has attempted (excludes course quizzes and expired attempts)
|
||||
total_quizzes = (
|
||||
db.query(QuizAttempt.quiz_id)
|
||||
.join(Quiz, QuizAttempt.quiz_id == Quiz.id)
|
||||
|
|
@ -505,6 +524,7 @@ def get_dashboard_stats(
|
|||
QuizAttempt.user_id == current_user.id,
|
||||
QuizAttempt.completed_at.isnot(None),
|
||||
Quiz.course_id.is_(None),
|
||||
(QuizAttempt.expired == 0) | (QuizAttempt.expired.is_(None)),
|
||||
)
|
||||
.distinct().count()
|
||||
)
|
||||
|
|
@ -516,6 +536,7 @@ def get_dashboard_stats(
|
|||
QuizAttempt.user_id == current_user.id,
|
||||
QuizAttempt.completed_at.isnot(None),
|
||||
Quiz.course_id.is_(None),
|
||||
(QuizAttempt.expired == 0) | (QuizAttempt.expired.is_(None)),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -451,16 +451,37 @@ const timerStarted = timeLeft !== null
|
|||
Your progress is saved — you can resume from where you left off.
|
||||
{timeLeft !== null && (
|
||||
<><br/><br/>
|
||||
<strong style={{ color: '#f59e0b' }}>⚠️ Timer is still running.</strong> If time expires before you return, the quiz will be auto-submitted with your current answers.
|
||||
{returnTo && quiz?.max_attempts && (
|
||||
<> This will count as one of your {quiz.max_attempts} allowed attempt{quiz.max_attempts !== 1 ? 's' : ''}.</>
|
||||
)}
|
||||
<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 leaves the timer running, and it may auto-submit if time runs out.
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 10, justifyContent: 'center' }}>
|
||||
<button className="btn btn-secondary" onClick={() => setLeaveTarget(null)}>Stay</button>
|
||||
<button className="btn btn-primary" onClick={() => { setLeaveTarget(null); navigate(leaveTarget) }}>Suspend & Leave</button>
|
||||
<button className="btn btn-primary" onClick={async () => {
|
||||
// Save with suspended=true so the timer pauses on the server side
|
||||
if (attemptId && quizMode) {
|
||||
try {
|
||||
await api.post('/attempts/progress', {
|
||||
quiz_id: parseInt(id),
|
||||
attempt_id: attemptId,
|
||||
answers,
|
||||
current_idx: currentIdx,
|
||||
mode: quizMode,
|
||||
voice: selectedVoice || null,
|
||||
time_left: timeLeft,
|
||||
started_at: startedAt,
|
||||
total_time: totalTime,
|
||||
suspended: true,
|
||||
}, { headers: { 'x-quiz-session': SESSION_ID } })
|
||||
} catch { }
|
||||
}
|
||||
const target = leaveTarget
|
||||
setLeaveTarget(null)
|
||||
navigate(target)
|
||||
}}>Suspend & Leave</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue