- Quiz, Question, Choice, QuizAttempt, QuestionResponse models with migrations - seed-based deterministic randomization for questions and choices per attempt - SC/MC grading (all-or-nothing per question), short-answer regex/keyword matching - submit_attempt: grades all responses, computes score_percent, marks passed/failed - DRF API: start attempt, submit response, submit (finalize) attempt, quiz detail - 25 unit + integration tests covering grading correctness, boundary conditions, randomization reproducibility, API flows, and access control Co-Authored-By: Paperclip <noreply@paperclip.ing>
142 lines
5.4 KiB
Python
142 lines
5.4 KiB
Python
import uuid
|
|
|
|
from django.conf import settings
|
|
from django.db import models
|
|
|
|
|
|
class QuestionType(models.TextChoices):
|
|
SINGLE_CHOICE = "sc", "Single Choice"
|
|
MULTI_CHOICE = "mc", "Multiple Choice"
|
|
SHORT_ANSWER = "short_answer", "Short Answer"
|
|
|
|
|
|
class FeedbackMode(models.TextChoices):
|
|
NONE = "none", "No Feedback"
|
|
AFTER_QUESTION = "after_question", "After Each Question"
|
|
AFTER_QUIZ = "after_quiz", "After Quiz Submission"
|
|
|
|
|
|
class AttemptStatus(models.TextChoices):
|
|
IN_PROGRESS = "in_progress", "In Progress"
|
|
SUBMITTED = "submitted", "Submitted"
|
|
TIMED_OUT = "timed_out", "Timed Out"
|
|
ABANDONED = "abandoned", "Abandoned"
|
|
|
|
|
|
class Quiz(models.Model):
|
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
|
# Optional FK to a Page; set for inline quizzes. Standalone quizzes may be null.
|
|
page = models.OneToOneField(
|
|
"courses.Page", null=True, blank=True, on_delete=models.SET_NULL, related_name="quiz"
|
|
)
|
|
title = models.CharField(max_length=255)
|
|
randomize_questions = models.BooleanField(default=False)
|
|
randomize_choices = models.BooleanField(default=False)
|
|
pass_threshold = models.PositiveSmallIntegerField(default=70) # percent 0-100
|
|
max_attempts = models.PositiveSmallIntegerField(default=0) # 0 = unlimited
|
|
time_limit_seconds = models.PositiveIntegerField(default=0) # 0 = no limit
|
|
feedback_mode = models.CharField(
|
|
max_length=20, choices=FeedbackMode.choices, default=FeedbackMode.AFTER_QUIZ
|
|
)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
db_table = "quizzes_quiz"
|
|
|
|
def __str__(self):
|
|
return self.title
|
|
|
|
|
|
class Question(models.Model):
|
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
|
quiz = models.ForeignKey(Quiz, on_delete=models.CASCADE, related_name="questions")
|
|
order = models.PositiveIntegerField(default=0)
|
|
question_type = models.CharField(max_length=20, choices=QuestionType.choices)
|
|
text = models.TextField()
|
|
explanation = models.TextField(blank=True)
|
|
points = models.PositiveIntegerField(default=1)
|
|
# Short-answer matching config
|
|
sa_patterns = models.JSONField(default=list) # list of regex/keyword strings
|
|
sa_case_sensitive = models.BooleanField(default=False)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
class Meta:
|
|
db_table = "quizzes_question"
|
|
ordering = ["order"]
|
|
constraints = [
|
|
models.UniqueConstraint(fields=["quiz", "order"], name="unique_quiz_question_order"),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.quiz.title} / Q{self.order}"
|
|
|
|
|
|
class Choice(models.Model):
|
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
|
question = models.ForeignKey(Question, on_delete=models.CASCADE, related_name="choices")
|
|
order = models.PositiveIntegerField(default=0)
|
|
text = models.CharField(max_length=1000)
|
|
is_correct = models.BooleanField(default=False)
|
|
|
|
class Meta:
|
|
db_table = "quizzes_choice"
|
|
ordering = ["order"]
|
|
constraints = [
|
|
models.UniqueConstraint(
|
|
fields=["question", "order"], name="unique_question_choice_order"
|
|
),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.question} / choice {self.order}"
|
|
|
|
|
|
class QuizAttempt(models.Model):
|
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
|
quiz = models.ForeignKey(Quiz, on_delete=models.CASCADE, related_name="attempts")
|
|
user = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="quiz_attempts"
|
|
)
|
|
attempt_number = models.PositiveIntegerField()
|
|
seed = models.IntegerField()
|
|
started_at = models.DateTimeField(auto_now_add=True)
|
|
submitted_at = models.DateTimeField(null=True, blank=True)
|
|
expired_at = models.DateTimeField(null=True, blank=True)
|
|
score_percent = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True)
|
|
is_passed = models.BooleanField(null=True, blank=True)
|
|
status = models.CharField(
|
|
max_length=20, choices=AttemptStatus.choices, default=AttemptStatus.IN_PROGRESS
|
|
)
|
|
|
|
class Meta:
|
|
db_table = "quizzes_attempt"
|
|
constraints = [
|
|
models.UniqueConstraint(
|
|
fields=["quiz", "user", "attempt_number"],
|
|
name="unique_quiz_user_attempt_number",
|
|
),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.user_id} attempt#{self.attempt_number} on {self.quiz.title}"
|
|
|
|
|
|
class QuestionResponse(models.Model):
|
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
|
attempt = models.ForeignKey(QuizAttempt, on_delete=models.CASCADE, related_name="responses")
|
|
question = models.ForeignKey(Question, on_delete=models.CASCADE, related_name="responses")
|
|
selected_choice_ids = models.JSONField(default=list) # list of UUID strings (SC/MC)
|
|
text_response = models.TextField(blank=True) # short_answer
|
|
is_correct = models.BooleanField(null=True, blank=True)
|
|
points_earned = models.PositiveIntegerField(default=0)
|
|
answered_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
class Meta:
|
|
db_table = "quizzes_question_response"
|
|
constraints = [
|
|
models.UniqueConstraint(
|
|
fields=["attempt", "question"], name="unique_attempt_question_response"
|
|
),
|
|
]
|