diff --git a/backend/app/routers/attempts.py b/backend/app/routers/attempts.py index f6abe6e..a78791c 100644 --- a/backend/app/routers/attempts.py +++ b/backend/app/routers/attempts.py @@ -182,8 +182,9 @@ def submit_attempt( except Exception: logger.warning("Failed to clear quiz progress from Redis", exc_info=True) - percentage = (score / attempt.total_questions * 100) if attempt.total_questions > 0 else 0 - + # Out of what was answered, not out of what was set. An unanswered + # question is not a wrong answer. + percentage = score_percent(score, answered_counts(db, [attempt.id]).get(attempt.id, 0)) return AttemptDetail( id=attempt.id, @@ -212,6 +213,8 @@ def list_attempts( if quiz_id: query = query.filter(QuizAttempt.quiz_id == quiz_id) attempts = query.order_by(QuizAttempt.started_at.desc()).all() + # One query for the lot rather than one per row. + answered = answered_counts(db, [a.id for a in attempts]) return [ AttemptResponse( @@ -219,7 +222,7 @@ def list_attempts( quiz_id=a.quiz_id, score=a.score, total_questions=a.total_questions, - percentage=round((a.score / a.total_questions * 100) if a.total_questions > 0 else 0, 1), + percentage=score_percent(a.score, answered.get(a.id, 0)), started_at=a.started_at, completed_at=a.completed_at, ) @@ -546,10 +549,11 @@ def get_quiz_history( # Group by quiz from collections import defaultdict + answered = answered_counts(db, [a.id for a in completed]) by_quiz: dict = defaultdict(list) quiz_titles: dict = {} for a in completed: - pct = round((a.score / a.total_questions * 100) if a.total_questions > 0 else 0, 1) + pct = score_percent(a.score, answered.get(a.id, 0)) by_quiz[a.quiz_id].append({ "attempt_id": a.id, "date": a.completed_at.isoformat(), @@ -599,9 +603,10 @@ def get_dashboard_stats( ) total_attempts = len(completed_attempts) + answered = answered_counts(db, [a.id for a in completed_attempts]) avg_score = 0.0 if completed_attempts: - scores = [(a.score / a.total_questions * 100) if a.total_questions > 0 else 0 for a in completed_attempts] + scores = [score_percent(a.score, answered.get(a.id, 0)) for a in completed_attempts] avg_score = round(sum(scores) / len(scores), 1) # Per-quiz stats — based on quizzes the user has attempted, not created @@ -611,7 +616,7 @@ def get_dashboard_stats( for quiz in quizzes: quiz_attempts = [a for a in completed_attempts if a.quiz_id == quiz.id] if quiz_attempts: - pcts = [(a.score / a.total_questions * 100) if a.total_questions > 0 else 0 for a in quiz_attempts] + pcts = [score_percent(a.score, answered.get(a.id, 0)) for a in quiz_attempts] quiz_stats.append(QuizStats( quiz_id=quiz.id, quiz_title=quiz.title, @@ -691,7 +696,8 @@ def get_attempt( figures=figures.get(q.id, []), )) - percentage = (attempt.score / attempt.total_questions * 100) if attempt.total_questions > 0 else 0 + percentage = score_percent(attempt.score, + answered_counts(db, [attempt.id]).get(attempt.id, 0)) return AttemptDetail( id=attempt.id, quiz_id=attempt.quiz_id, @@ -787,6 +793,30 @@ class _LiveAnswer: self.seconds_spent = None +def answered_counts(db: Session, attempt_ids: list[int]) -> dict[int, int]: + """How many questions were actually answered in each attempt.""" + if not attempt_ids: + return {} + rows = (db.query(AttemptAnswer.attempt_id, func.count(AttemptAnswer.id)) + .filter(AttemptAnswer.attempt_id.in_(attempt_ids), + AttemptAnswer.user_answer.isnot(None), + AttemptAnswer.user_answer != "") + .group_by(AttemptAnswer.attempt_id).all()) + return {attempt_id: count for attempt_id, count in rows} + + +def score_percent(score: int, answered: int) -> float: + """What you got right, out of what you answered. + + An unanswered question is not a wrong answer, it is not an answer — so it + is not in the denominator. Counting it as wrong made leaving an exam early + look like failing it, and made the figure say more about how far you got + than about how well you did. How far you got is the separate number + beside it. + """ + return round(score / answered * 100, 1) if answered else 0.0 + + def _rows_from_progress(db: Session, user_id: int, attempt: QuizAttempt) -> list: """Grade what is saved for a live attempt, so it can be analysed mid-session.""" try: @@ -927,7 +957,7 @@ def attempt_analysis( "total": len(rows), "answered": answered, "score": score, - "percent": (round(100 * score / len(rows)) if rows else 0) if graded else None, + "percent": (round(score_percent(score, answered)) if rows else 0) if graded else None, # False while an exam is still running: the interface shows progress # and says why there is no score yet, rather than showing a nought. "graded": graded, diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx index ed77a11..5b4be9c 100644 --- a/frontend/src/pages/QuizPage.jsx +++ b/frontend/src/pages/QuizPage.jsx @@ -1284,7 +1284,13 @@ const timerStarted = timeLeft !== null global note still lives on the dashboard. */} {tool && setTool(null)} />} {showReview && setShowReview(false)}> -

{answeredCount} of {totalCount} questions answered. Unanswered questions count as incorrect.

+ {/* They are not counted as wrong: the score is out of what you + answered. But they are questions you did not do, and that is worth + saying before you hand it in. */} +

{answeredCount} of {totalCount} questions answered.{' '} + {answeredCount < totalCount + ? `${totalCount - answeredCount} will be left unanswered — they are not marked wrong, but they are not marked.` + : 'Everything is answered.'}

{questions.map((question, index) => )}