Files
training-software/quizzes/services.py
Paperclip CTO 01661bf5a8
Some checks failed
CI / lint (push) Successful in 6s
CI / test (push) Failing after 7s
CI / build-container (push) Has been skipped
fix: restore CI by fixing lint, test collection, and notification syntax
2026-05-18 14:53:29 +02:00

206 lines
7.2 KiB
Python

from __future__ import annotations
import random
import re
from datetime import timedelta
from decimal import Decimal
from typing import TYPE_CHECKING
from django.utils.timezone import now
if TYPE_CHECKING:
from .models import Question, Quiz, QuizAttempt
class AttemptLimitError(Exception):
"""Raised when a user has exhausted their allowed attempts on a quiz."""
class AttemptExpiredError(Exception):
"""Raised when a user tries to interact with an expired attempt."""
def shuffle_with_seed(items: list, seed: int) -> list:
"""Return a new list with items shuffled deterministically using seed."""
rng = random.Random(seed)
result = list(items)
rng.shuffle(result)
return result
def get_question_order(quiz: "Quiz", seed: int) -> list:
"""Return ordered question list for an attempt, randomized if configured."""
questions = list(quiz.questions.all())
if quiz.randomize_questions:
questions = shuffle_with_seed(questions, seed)
return questions
def get_choice_order(question: "Question", seed: int) -> list:
"""Return ordered choice list for a question, randomized if configured."""
choices = list(question.choices.all())
if question.quiz.randomize_choices:
# Use a derived seed per question to avoid correlation across questions
q_seed = seed ^ hash(str(question.id)) & 0xFFFFFFFF
choices = shuffle_with_seed(choices, q_seed)
return choices
def grade_sc_mc(question: "Question", selected_ids: list[str]) -> tuple[bool, int]:
"""
Grade a single- or multi-choice response.
Returns (is_correct, points_earned).
SC: correct iff exactly the one correct choice is selected.
MC: correct iff selected set exactly equals correct set (all-or-nothing per question).
"""
correct_ids = set(
str(c.id) for c in question.choices.filter(is_correct=True)
)
selected_set = set(str(s) for s in selected_ids)
is_correct = selected_set == correct_ids
return is_correct, question.points if is_correct else 0
def grade_short_answer(question: "Question", response_text: str) -> tuple[bool, int]:
"""
Grade a short-answer response by matching against sa_patterns.
Any pattern match (regex or keyword) counts as correct.
"""
if not question.sa_patterns:
return False, 0
text = response_text if question.sa_case_sensitive else response_text.lower()
flags = 0 if question.sa_case_sensitive else re.IGNORECASE
for pattern in question.sa_patterns:
try:
if re.search(pattern, text, flags):
return True, question.points
except re.error:
# Treat invalid regex as literal keyword match
needle = pattern if question.sa_case_sensitive else pattern.lower()
if needle in text:
return True, question.points
return False, 0
def grade_response(question: "Question", selected_choice_ids: list, text_response: str) -> tuple[bool, int]:
"""Dispatch to the appropriate grader."""
from .models import QuestionType
if question.question_type == QuestionType.SHORT_ANSWER:
return grade_short_answer(question, text_response)
return grade_sc_mc(question, selected_choice_ids)
def score_attempt(attempt: "QuizAttempt") -> tuple[Decimal, bool]:
"""
Compute score_percent and is_passed from all QuestionResponses on the attempt.
Returns (score_percent, is_passed).
"""
responses = list(attempt.responses.select_related("question").all())
if not responses:
return Decimal("0.00"), False
total_points = sum(r.question.points for r in responses)
earned_points = sum(r.points_earned for r in responses)
if total_points == 0:
score_pct = Decimal("0.00")
else:
score_pct = Decimal(str(round(earned_points / total_points * 100, 2)))
is_passed = score_pct >= attempt.quiz.pass_threshold
return score_pct, is_passed
def check_attempt_not_expired(attempt: "QuizAttempt") -> None:
"""Raise AttemptExpiredError if the attempt deadline has passed."""
from .models import AttemptStatus
if attempt.status in (AttemptStatus.TIMED_OUT, AttemptStatus.SUBMITTED, AttemptStatus.ABANDONED):
raise AttemptExpiredError("Attempt is already finalized.")
if attempt.expired_at and now() > attempt.expired_at:
# Auto-finalize before raising
finalize_timed_out_attempt(attempt)
raise AttemptExpiredError("Attempt time limit has been exceeded.")
def finalize_timed_out_attempt(attempt: "QuizAttempt") -> "QuizAttempt":
"""Grade and close an attempt that ran out of time."""
from .models import AttemptStatus
for response in attempt.responses.select_related("question").all():
is_correct, points = grade_response(
response.question,
response.selected_choice_ids,
response.text_response,
)
response.is_correct = is_correct
response.points_earned = points
response.save(update_fields=["is_correct", "points_earned"])
score_pct, is_passed = score_attempt(attempt)
attempt.score_percent = score_pct
attempt.is_passed = is_passed
attempt.status = AttemptStatus.TIMED_OUT
attempt.submitted_at = now()
attempt.save(update_fields=["score_percent", "is_passed", "status", "submitted_at"])
return attempt
def start_attempt(quiz: "Quiz", user) -> "QuizAttempt":
"""
Create a new QuizAttempt for the given user and quiz.
Raises AttemptLimitError if max_attempts is set and exhausted.
"""
from .models import AttemptStatus, QuizAttempt
# Check attempt limit
if quiz.max_attempts > 0:
used = QuizAttempt.objects.filter(
quiz=quiz, user=user
).exclude(status=AttemptStatus.IN_PROGRESS).count()
if used >= quiz.max_attempts:
raise AttemptLimitError(
f"Maximum of {quiz.max_attempts} attempt(s) already used."
)
attempt_number = (
QuizAttempt.objects.filter(quiz=quiz, user=user).count() + 1
)
seed = random.randint(0, 2**31 - 1)
deadline = None
if quiz.time_limit_seconds > 0:
deadline = now() + timedelta(seconds=quiz.time_limit_seconds)
return QuizAttempt.objects.create(
quiz=quiz,
user=user,
attempt_number=attempt_number,
seed=seed,
expired_at=deadline,
)
def submit_attempt(attempt: "QuizAttempt") -> "QuizAttempt":
"""Finalize an in-progress attempt: grade all responses, compute score, mark submitted."""
from .models import AttemptStatus
for response in attempt.responses.select_related("question").all():
is_correct, points = grade_response(
response.question,
response.selected_choice_ids,
response.text_response,
)
response.is_correct = is_correct
response.points_earned = points
response.save(update_fields=["is_correct", "points_earned"])
score_pct, is_passed = score_attempt(attempt)
attempt.score_percent = score_pct
attempt.is_passed = is_passed
attempt.status = AttemptStatus.SUBMITTED
attempt.submitted_at = now()
attempt.save(update_fields=["score_percent", "is_passed", "status", "submitted_at"])
return attempt