So, I decided to withhold my answer until I would have enough time to implement a sketch of a solution, as code would probably speak more unambiguously than words.
Let’s recap the issue at hand:
- I have a model
Exercise
which models a question/problem/exercise to be inserted into a quiz. There are different exercise types, such as multiple choice questions, programming exercises, open answer questions, and so on.
- I want to be able to uniformly manage my exercises, for example I want to be able to query all exercise types together and insert different types of exercises in the same quiz
- At the same time, there are certain thing about exercises that need to be handled differently depending upon the type of the exercise. For example, how to grade the answer to an exercise? What’s the maximum attainable score for an exercise? Given a submission object (I’ll go into more detail about this in a second), how do I check if the submission contains a valid answer to the exercise, or if it was left blank?
- Third parties might want to create more exercise types in the future, and I don’t want them to have to add if-else branches to existing code. I want a solution that’s easily extended.
Here’s what I did. The Exercise
model looks like this:
class Exercise(models.Model):
"""
An Exercise represents a question, coding problem, or other element that
can appear inside of an exam.
"""
MULTIPLE_CHOICE = 0
OPEN_ANSWER = 1
JS = 2
ATTACHMENT = 3
PYTHON = 4
EXERCISE_TYPES = (
(MULTIPLE_CHOICE, "Multiple choice question"),
(OPEN_ANSWER, "Open answer"),
(JS, "JavaScript"),
(ATTACHMENT, "Attachment"),
(PYTHON, "Python"),
)
course = models.ForeignKey(
Course,
on_delete=models.PROTECT,
related_name="exercises",
)
exercise_type = models.PositiveSmallIntegerField(choices=EXERCISE_TYPES)
text = models.TextField(blank=True)
objects = ExerciseManager()
def get_logic(self):
from courses.logic.exercise_logic import ExerciseLogic
return ExerciseLogic.from_exercise_instance(self)
Now, let’s define a dataclass that models the submission to a question. There is a concrete model in my application, that represents an assigned exercise to a user AND the submission to it AND the assessment of a teacher (score, feedback, etc.).
Since we’re going to work inside of a “logic layer”, I decided not to pass the whole model instance as it is, and instead create a class that just encapsulates the relevant fields:
@dataclass
class ExerciseSubmission:
answer_text: str
selected_choices: QuerySet[ExerciseChoice]
execution_results # this is a dict returned by the code runner component which describes how many test cases a piece of user-submitted code passes for a programming exercise: it's used to grade programming exercises
attachment: Optional[FieldFile] # used for attachment exercises
@classmethod
def from_event_participation_slot(cls, slot: EventParticipationSlot):
return cls(
answer_text=slot.answer_text,
selected_choices=slot.selected_choices,
execution_results=slot.execution_results,
attachment=slot.attachment,
)
@property
def has_answer_text(self):
return bool(self.has_answer_text)
@property
def has_selected_choices(self):
return self.selected_choices.exists()
@property
def has_attachment(self):
return bool(self.attachment)
@property
def has_execution_results(self):
return self.execution_results is not None
So this class created a layer of indirection between the underlying model and only represents the submission of a student relative to an exercise.
Now onto the meaty stuff.
class ExerciseLogic(ABC):
exercise: Exercise
def __init__(self, exercise: Exercise) -> None:
self.exercise = exercise
@staticmethod
def get_exercise_logic_class(exercise_type):
cls_name = get_exercise_logic_registry()[exercise_type]
return import_string(cls_name)
@staticmethod
def from_exercise_instance(exercise: Exercise) -> "ExerciseLogic":
cls = ExerciseLogic.get_exercise_logic_class(exercise.exercise_type)
return cls(exercise=exercise)
@abstractmethod
def get_max_score(self) -> Decimal:
...
@abstractmethod
def get_grade(self, submission: ExerciseSubmission) -> Optional[Decimal]:
...
@abstractmethod
def has_answer(self, submission: ExerciseSubmission) -> bool:
...
This is the base class that encapsulates the logic for an exercise. I’ll get into how from_exercise_instance
method works in a bit.
Let’s take a look at a couple of the subclasses of this class:
class MultipleChoiceExerciseLogic(ExerciseLogic):
def has_answer(self, submission: ExerciseSubmission) -> bool:
return submission.has_selected_choices
def get_grade(self, submission: ExerciseSubmission) -> Optional[Decimal]:
selected_choices = submission.selected_choices.all()
return Decimal(sum([c.correctness for c in selected_choices]))
def get_max_score(self) -> Decimal:
correct_choices = self.exercise.choices.filter(correctness__gt=0)
return Decimal(sum([c.correctness for c in correct_choices]))
class ManuallyGradedExerciseLogic(ExerciseLogic):
def get_grade(self, submission: ExerciseSubmission) -> Optional[Decimal]:
return None if self.has_answer(submission) else Decimal(0)
class OpenAnswerExerciseLogic(ManuallyGradedExerciseLogic):
def has_answer(self, submission: ExerciseSubmission) -> bool:
return submission.has_answer_text
class ProgrammingExerciseLogic(ExerciseLogic):
def has_answer(self, submission: ExerciseSubmission) -> bool:
return submission.has_answer_text
def get_grade(self, submission: ExerciseSubmission) -> Optional[Decimal]:
# inspects the execution_results dict and does some counting ...
...
def get_max_score(self) -> Decimal:
return self.exercise.testcases.count()
So each subclass in the hierarchy implements the logic differently. Now the only thing left is to dynamically get the correct subclass depending on the exercise type.
I defined the following function:
def get_exercise_logic_registry():
std_module = "courses.logic.exercise_logic."
return {
Exercise.MULTIPLE_CHOICE: std_module
+ "MultipleChoiceExerciseLogic",
Exercise.OPEN_ANSWER: std_module + "OpenAnswerExerciseLogic",
Exercise.JS: std_module + "ProgrammingExerciseLogic",
Exercise.PYTHON: std_module + "ProgrammingExerciseLogic",
Exercise.ATTACHMENT: std_module + "AttachmentExerciseLogic",
}
For now it returns a dict, but I didn’t want to hard code it because in the future it could do something else like auto-discover the logic classes.
So, from an Exercise
instance, whenever we want for example to get the maximum possible score, we’d do exercise_instance.get_logic().get_max_score()
. This would look up the fully qualified name of the correct ExerciseLogic
subclass for the exercise type, dynamically import it, and instantiate it, passing the exercise instance.
What do you think of this approach? Could it pose potential performance problems? Does it seem to achieve the extensibility I’m after?
I personally have mixed feelings about it—it seems to do what I want it to, but I’m afraid it could be a bit too complicated and I might be overfitting for my current needs and not thinking of possible future use cases which wouldn’t fit well with this approach.
As always, feedback is very welcome.