206 lines
7.2 KiB
Python
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
|