Files
training-software/quizzes/models.py
Paperclip CTO b59d06fddc
Some checks failed
CI / Tests (Python 3.12) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / OpenAPI Schema (push) Has been cancelled
feat(TRA-239): implement Quiz Engine — SC/MC + short answer with deterministic randomization
- 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>
2026-05-07 09:15:18 +02:00

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"
),
]