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)
if not question:
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:
score += 1
db.add(AttemptAnswer(
@ -114,7 +114,7 @@ def submit_attempt(
answer_details = []
for q in get_quiz_questions(db, attempt.quiz_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(
question_id=q.id,
question_text=q.question_text,
@ -130,6 +130,15 @@ def submit_attempt(
attempt.completed_at = datetime.utcnow()
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
# Update reminder schedule
@ -211,17 +220,31 @@ def save_progress(
@router.get("/progress")
def get_progress(
quiz_id: int,
db: Session = Depends(get_db),
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:
import redis as redis_lib, json as _json
from app.config import settings
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
key = f"quiz_progress:{current_user.id}:{quiz_id}"
data = r.get(key)
if data:
return _json.loads(data)
if not 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:
pass
return None

View file

@ -9,15 +9,21 @@ function JobsBadge() {
const [open, setOpen] = useState(false)
useEffect(() => {
let interval
const load = async () => {
try {
const res = await api.get('/quizzes/jobs')
setAllJobs(res.data)
setActiveJobs(res.data.filter(j => j.status !== 'completed' && j.status !== 'failed'))
const jobs = res.data || []
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 { }
}
load()
const interval = setInterval(load, 4000)
interval = setInterval(load, 30000)
return () => clearInterval(interval)
}, [])

View file

@ -13,6 +13,7 @@ function greeting(name) {
export default function DashboardPage() {
const { user } = useAuth()
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
const [documents, setDocuments] = useState([])
const [stats, setStats] = useState(null)
const [history, setHistory] = useState([])
@ -103,8 +104,8 @@ export default function DashboardPage() {
<div className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h2>Your Documents</h2>
<Link to="/upload" className="btn btn-primary">Upload PDF</Link>
<h2>{isModerator ? 'Documents' : 'Your Documents'}</h2>
{isModerator && <Link to="/upload" className="btn btn-primary">Upload PDF</Link>}
</div>
{documents.length === 0 ? (
<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) => {
const isSelected = answers[current.id] === opt
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 showWrong = hasAnswered && isSelected && !isCorrectOpt
const letter = String.fromCharCode(65 + i)