Fix option selection, submit failure, and slow load

Root causes:
1. joinedload(Quiz.questions) was SLOWER (129ms vs 75ms) with the custom
   secondary relationship — removed, back to lazy load
2. Redis progress could have stale attempt_id from a completed submission:
   GET /attempts/progress now validates the attempt exists and is not completed
   before returning; stale entries are auto-deleted
   POST /attempts/{id}/submit now clears Redis progress server-side on submit
3. Navbar jobs polling was running every 4s even with no active jobs,
   causing unnecessary requests — now polls every 30s when idle,
   every 3s when jobs are actively running

Also cleared 3 stale Redis progress entries that were pointing to
already-completed attempts (which caused silent submit failures).

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-04-01 15:29:38 +02:00
parent 1f95c4d1ea
commit 365aaf3df0
4 changed files with 41 additions and 11 deletions

View file

@ -100,7 +100,7 @@ def submit_attempt(
question = questions.get(ans.question_id) question = questions.get(ans.question_id)
if not question: if not question:
continue continue
is_correct = ans.user_answer.strip().lower() == question.correct_answer.strip().lower() is_correct = bool(question.correct_answer) and ans.user_answer.strip().lower() == question.correct_answer.strip().lower()
if is_correct: if is_correct:
score += 1 score += 1
db.add(AttemptAnswer( db.add(AttemptAnswer(
@ -114,7 +114,7 @@ def submit_attempt(
answer_details = [] answer_details = []
for q in get_quiz_questions(db, attempt.quiz_id): for q in get_quiz_questions(db, attempt.quiz_id):
user_answer = submitted.get(q.id, "") user_answer = submitted.get(q.id, "")
is_correct = bool(user_answer) and user_answer.strip().lower() == q.correct_answer.strip().lower() is_correct = bool(user_answer) and bool(q.correct_answer) and user_answer.strip().lower() == q.correct_answer.strip().lower()
answer_details.append(AnswerDetail( answer_details.append(AnswerDetail(
question_id=q.id, question_id=q.id,
question_text=q.question_text, question_text=q.question_text,
@ -130,6 +130,15 @@ def submit_attempt(
attempt.completed_at = datetime.utcnow() attempt.completed_at = datetime.utcnow()
db.commit() db.commit()
# Clear saved progress from Redis when submitted
try:
import redis as redis_lib
from app.config import settings
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
r.delete(f"quiz_progress:{current_user.id}:{attempt.quiz_id}")
except Exception:
pass
percentage = (score / attempt.total_questions * 100) if attempt.total_questions > 0 else 0 percentage = (score / attempt.total_questions * 100) if attempt.total_questions > 0 else 0
# Update reminder schedule # Update reminder schedule
@ -211,17 +220,31 @@ def save_progress(
@router.get("/progress") @router.get("/progress")
def get_progress( def get_progress(
quiz_id: int, quiz_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
"""Retrieve in-progress quiz answers from Redis.""" """Retrieve in-progress quiz answers from Redis.
Clears stale data if the saved attempt has already been submitted."""
try: try:
import redis as redis_lib, json as _json import redis as redis_lib, json as _json
from app.config import settings from app.config import settings
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True) r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
key = f"quiz_progress:{current_user.id}:{quiz_id}" key = f"quiz_progress:{current_user.id}:{quiz_id}"
data = r.get(key) data = r.get(key)
if data: if not data:
return _json.loads(data) return None
saved = _json.loads(data)
attempt_id = saved.get("attempt_id")
if attempt_id:
attempt = db.query(QuizAttempt).filter(
QuizAttempt.id == attempt_id,
QuizAttempt.user_id == current_user.id,
).first()
# Stale: attempt was submitted or doesn't exist
if not attempt or attempt.completed_at is not None:
r.delete(key)
return None
return saved
except Exception: except Exception:
pass pass
return None return None

View file

@ -9,15 +9,21 @@ function JobsBadge() {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
useEffect(() => { useEffect(() => {
let interval
const load = async () => { const load = async () => {
try { try {
const res = await api.get('/quizzes/jobs') const res = await api.get('/quizzes/jobs')
setAllJobs(res.data) const jobs = res.data || []
setActiveJobs(res.data.filter(j => j.status !== 'completed' && j.status !== 'failed')) setAllJobs(jobs)
const running = jobs.filter(j => j.status !== 'completed' && j.status !== 'failed')
setActiveJobs(running)
// Poll faster when jobs running, slower when idle
clearInterval(interval)
interval = setInterval(load, running.length > 0 ? 3000 : 30000)
} catch { } } catch { }
} }
load() load()
const interval = setInterval(load, 4000) interval = setInterval(load, 30000)
return () => clearInterval(interval) return () => clearInterval(interval)
}, []) }, [])

View file

@ -13,6 +13,7 @@ function greeting(name) {
export default function DashboardPage() { export default function DashboardPage() {
const { user } = useAuth() const { user } = useAuth()
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
const [documents, setDocuments] = useState([]) const [documents, setDocuments] = useState([])
const [stats, setStats] = useState(null) const [stats, setStats] = useState(null)
const [history, setHistory] = useState([]) const [history, setHistory] = useState([])
@ -103,8 +104,8 @@ export default function DashboardPage() {
<div className="card"> <div className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h2>Your Documents</h2> <h2>{isModerator ? 'Documents' : 'Your Documents'}</h2>
<Link to="/upload" className="btn btn-primary">Upload PDF</Link> {isModerator && <Link to="/upload" className="btn btn-primary">Upload PDF</Link>}
</div> </div>
{documents.length === 0 ? ( {documents.length === 0 ? (
<div className="empty-state"><p>No documents yet. Upload a PDF to get started!</p></div> <div className="empty-state"><p>No documents yet. Upload a PDF to get started!</p></div>

View file

@ -420,7 +420,7 @@ useEffect(() => {
{current.options.map((opt, i) => { {current.options.map((opt, i) => {
const isSelected = answers[current.id] === opt const isSelected = answers[current.id] === opt
const hasAnswered = isStudy && !!answers[current.id] const hasAnswered = isStudy && !!answers[current.id]
const isCorrectOpt = opt === current.correct_answer const isCorrectOpt = opt.trim().toLowerCase() === (current.correct_answer || '').trim().toLowerCase()
const showCorrect = hasAnswered && isCorrectOpt const showCorrect = hasAnswered && isCorrectOpt
const showWrong = hasAnswered && isSelected && !isCorrectOpt const showWrong = hasAnswered && isSelected && !isCorrectOpt
const letter = String.fromCharCode(65 + i) const letter = String.fromCharCode(65 + i)