Custom RelatedManager for polymorphic model

I have the following two models:

class Exercise(models.Model):
    MULTIPLE_CHOICE_SINGLE_POSSIBLE = 0
    MULTIPLE_CHOICE_MULTIPLE_POSSIBLE = 1

    EXERCISE_TYPES = (
        (MULTIPLE_CHOICE_SINGLE_POSSIBLE, "Multiple choice, single possible"),
        (MULTIPLE_CHOICE_MULTIPLE_POSSIBLE, "Multiple choice, multiple possible"),
    )
    parent = models.ForeignKey(
        "Exercise",
        null=True,
        blank=True,
        related_name="sub_exercises",
        on_delete=models.CASCADE,
    )
    exercise_type = models.PositiveSmallIntegerField(choices=EXERCISE_TYPES)
    text = models.TextField(blank=True)


class Choice(models.Model):
    exercise = models.ForeignKey(
        Exercise, related_name="choices", on_delete=models.CASCADE
    )
    text = models.TextField()
    correct = models.BooleanField()

MULTIPLE_CHOICE_SINGLE_POSSIBLE represents a multiple choice question where the choices are selectable via a radio button, i.e. only one is correct; MULTIPLE_CHOICE_MULTIPLE_POSSIBLE pretty intuitively represents a question where more than one choice can be selected, via a checkbox.

Under the hood, for a MULTIPLE_CHOICE_MULTIPLE_POSSIBLE exercise, my custom manager creates a sub-exercise that has type MULTIPLE_CHOICE_SINGLE_POSSIBLE and a single choice. This helps me in other areas of the application.

This test case should clear up what I’m saying,

def test_multiple_choice_multiple_possible_exercise_creation(self):
    choices = [
        {"text": "c1", "correct": True},
        {"text": "c2", "correct": False},
        {"text": "c3", "correct": False},
    ]

    e1 = Exercise.objects.create(
        text=self.e1_text,
        choices=choices,
        exercise_type=Exercise.MULTIPLE_CHOICE_MULTIPLE_POSSIBLE,
    )

    self.assertEqual(e1.text, self.e1_text)
    self.assertEqual(e1.exercise_type, Exercise.MULTIPLE_CHOICE_MULTIPLE_POSSIBLE)
    self.assertEqual(  # one sub-exercise is created for each choice
        e1.sub_exercises.count(),
        len(choices),
    )
    self.assertEqual(  # no choices are directly related to the parent exercise
        e1.choices.count(), 0
    )

    i = 0
    for sub_exercise in e1.sub_exercises.all():
        # the automatically created sub-exercises have empty text
        self.assertEqual(sub_exercise.text, "")

        # the automatically created sub-exercises have a single choice
        self.assertEqual(sub_exercise.choices.count(), 1)

        choice = sub_exercise.choices.first()
        # the created choices are the same ones supplied for the parent exercise
        self.assertEqual(
            {"text": choice.text, "correct": choice.correct}, choices[i]
        )
        i += 1

This works fine for me, but I have one issues: I want to be able to access an exercise’s choices with a uniform API, regardless of whether it’s of the first or second type.

With a single-possible choice exercise, I can just do my_exercise.choices.all(), whereas in the other case I need to build a list by iterating over the sub-exercises like this: [e.choices.first() for e in my_exercise.sub_exercises.all()].

I could just declare a property that returns the correct set of choices depending on the exercise type, but then I would still have the issue of having two different calls to add/delete choices depending on the type of exercise–In the first case, I can just call my_exercise.choices.add(**new_choice), whereas in the second case I need to manually create a new exercise with a choice and make it a sub-exercise of the one I’m trying to add a choice to.

Is there a place where I can put this logic into so that I can do stuff like this?

single_possible_choice_exercise.choices.add(**my_choice) # creates a Choice object
multiple_possible_choice_exercise.choices.add(**my_choice) # creates an Exercise object with a Choice object

I have read that accessing the reverse side of a one-to-many relationship returns a RelatedManager, but I haven’t found much on how to customize it. I feel like that would be the most appropriate place to put this logic.

I think you should code a Custom Manager, like this:

class ExerciseTypeManager(models.Manager):
    
    def custom_add(self):
        if exercise_type == 0:
            # logic here
        else:
            # other logic here

class Exercise(models.Model):
    ...
    custom_objects = ExerciseTypeManager
    ...

Then you can use it like this:

any_type_choice_exercise.choices(manager='custom_objects').custom_add(**my_choice)

I haven’t test this but I think You just have to work on the logic of how the choice is added to your exercise.

Care to elaborate on how this works?

By doing any_type_choice_exercise.choices I am accessing a RelatedManager, but by passing in the manager argument I’m specifying a manager that refers to the exercise, rather than the relation with Choice?

Almost. The relation with Choice still exists, but you use a Custom Manager to lookup the reverse relation, as you can see in the docs. I think you can even continue using the “add” method and override it in the custom Manager, but I have never tried something like this, I think is better to code a new method(custom_add) and use it in every place you need it.

1 Like