Dynamically add methods and properties to a model

I have an Exercise model that represents an exercise that can be included in a quiz inside of my LMS Django REST app.

Exercises encapsulate some logic such as:

  • what’s the highest grade attainable for this exercise?
  • how do you grade an answer to this exercise?
  • how do you determine if the exercise has a blank answer or not?

For example, in a multiple choice question/exercise, assume there is a Choice model with a score attribute. In this case, the above are implemented as follows:

  • the choice with the maximum score
  • the score of the selected choice
  • whether a choice is selected

I want to be able to make my app extensible by third parties and abstract away the Exercise model (intended as a data object) from the business logic of an exercise.

Exercises have an exercise_type attribute that identifies their type. Right now, methods such as get_max_score, grade_reponse, and has_answer reside inside of the model itself and contain a series of if - elif - else to apply the correct choices based on the value of the exercise_type attribute.

What I want to do is create an abstract base class ExerciseBusinessLogic and inherit from it for each exercise type and implement methods similar to those described above. ExerciseBusinessLogic would then have a staticmethod from_model_instance used to instantiate and return the correct subclass on which the methods would be called for an Exercise.

In order to keep backward compatibility and a simple interface, something I’m considering doing is automagically add the methods declared in ExerciseBusinessLogic to the class Exercise.

Let’s say ExerciseBusinessLogic has a method called get_max_score, then I’d want Exercise to have a method get_max_score that does something akin to this:

def get_max_score(self):
    return ExerciseBusinessLogic.from_model_instance(self).get_max_score()

I’ve read that Django does something similar using descriptors in order to add reverse relationships to models.

How would I achieve something similar?

Please correct me if I’m jumping to a very wrong conclusion, but I’m getting the impression from your description here that you’re coming to Python / Django from a different language and framework.

I’ll admit, I’m having a hard time understanding what you’re really trying to achieve here. This all reads to me like something I might have had to do in Java to work around the constraints in the language.

In Python, functions and methods are first-class objects. This means that they can be assigned directly like any other attribute.

You also have multiple inheritance available to you as well. Your Exercise model can directly inherit the get_max_score method from the ExerciseBusinessLogic class.

If my original assumption is correct, don’t handcuff yourself by trying to reimplement patterns that you’re used to working with in the past. Learn the strengths of Python and take advantage of the facilities available to you.

Now, it’s possible that I’m just not getting the problem or issue that you’re trying to address here - and if so, perhaps a more specific and code-oriented example would help.

I guess the best way to explain it is: I’m trying to delegate some operations that my model currently does to another class. The reason for that is, while it’s “hard” to extend a model (somebody wanting to develop an extension for my application wouldn’t just be able to inherit from my model, as it’s not abstract, and inheriting from it would cause the creation of a new db table which is not what I want), but I still want a model which has polymorphic, extensible behavior. So what I’m trying to do is design a hierarchy of “business logic” classes whose methods can be called by my model class. Somebody can subclass one of those classes, update some sort of “registry” that links a specific exercise_type to the correct subclass of the business logic class, and just obtain the new behavior for an Exercise model instance with that exercise_type calling the methods from the model instance itself.

I understand that, I guess what I’m trying to figure out is what’s the best way to automatically assign, for each method on my ExerciseBusinessLogic class, a method with the same name to the class Exercise which instantiates an ExerciseBusinessLogic and calls the same-name method on it.

That could work, the problem is that my Exercise shouldn’t just call a method from ExerciseBusinessLogic. Instead, the method has to be called on an instance of a subclass of ExerciseBusinessLogic, and what that subclass is depends on the value of an attribute (namely exercise_type) of the correponding Exercise instance. Essentially, I’m trying to implement some form of dynamic method dispatch, just not on the calling class but rather on some “related” hierarchy of classes.

I think I’m getting closer to understanding the intent here - but I’m still not sure I understand what the root objective is. (I’m trying to understand this from the perspective of the requirements and not the implementation.)

What I think I’m reading is that you are looking to provide a model in your package.

You want someone else to be able to extend your package such that your package uses functions from that extension.

You are going to define what the list of possible functions are to be extended.

The specific set (or subset) of functions being implemented is up to that other person.

Am I close?

Keep in mind that if exercise is an instance of some class, then exercise.get_max_score is the same as getattr(exercise, 'get_max_score') and therefore max_score = exercise.get_max_score() would be the same as max_score = getattr(exercise, 'get_max_score')()

Also note that you do have Proxy models available for changing behavior without the creation of additional tables.

Almost—I want someone else to be able to write a package and install it onto my application (of course that someone would run their own installation of my application), essentially, they’d be able to write a plug-in for my application.

The idea is the following:

  • each type of exercise defines (let’s just restrict ourselves to this specific method) a grade_answer method. For a MCQ, the method returns the score of the selected choice, for a coding exercise, it runs the exercise and counts the passed testcases… you get the idea
  • someone else decides to create a new exercise type, say an “equation exercise”. That type of exercise defines an equation, takes in a numerical answer, computes the equation, and checks if the given answer is correct, i.e. it’s the result of the equation
  • in order to integrate the new type of exercise, I want the plug-in developer to be able to inherit from ExerciseBusinessLogic, inherit the relevant method(s), and be done with it
  • exercises are to be treated uniformly by my application, so in a quiz containing mixed types of exercises, my system has to be able, in order to compute, say, the overall grade, to just iterate over the exercises and the given answers and call grade_answer on the Exercise instances, without having to look at the exercise type.

I hope I explained that more clearly than I did before—I understand this is a bit contrived.

My understanding of proxy models is you can use them to get certain behavior for your models, but you have to manually select the proxy you’re instantiating. As I explained above, I don’t want that layer of my application to have to decide what model to instantiate by looking at the exercise_type of the model instance.

It’s not your explanations - it’s me trying to wrap my head around a problem domain that I’m not familiar with.

I think my current reaction to this is that I’d be separating these “resolvers” from the model. You mention that you don’t want new tables created - you want all of these classes to work with the same model (different instances, but the same class).

I would then split those functions out into a separate class hierarchy. Your registration process can then consist of creating instances of those classes and assigning them to an “exercise type”. (And it may be as direct as a dict where the key is the exercise type and the value is the class.)

The __init__ method on the parent class can perform the registration logic, preventing the need for the implementation of the child class from doing so.

The model can then create the instance of that resolution class and pass itself as a parameter such that the functions have the model (or desired fields) available on self.

1 Like

That pretty much sums up what I want to do.

The only thing is the way you described the model would have to have a(n explicitly defined) method for each method on ExerciseBusinessLogic in which the instance instantiates the logic class and calls the same-name method passing itself as a paramter—I was wondering if there’s a way to automatize that and keep the exposed methods in sync.

I’m lost again here.

Why would “each method on ExerciseBusinessLogic” need identical or repeated code?

Because I want my Exercise’s method grade_answer to be able to call onto the grade_answer method of an instance of ExerciseBusinessLogic that would be instantiated inside the code of the method in the Exercise instance. Essentially, the methods on Exercise act as stubs for the real methods inside of the business logic class.

Yes, and? … I’m still not seeing the need for the complexity that you’re trying to describe here.

Assume some_class = SomeClass(model_instance=some_instance_of_a_model), where SomeClass is some class that will later be instantiated. (It can be a reference to a class from whatever you’re using as the “class registry”.) Then, getattr(some_class, ‘grade_answer’)() calls the grade_answer method of SomeClass.

If I’m still missing something, it might be easier at this point if you could post some sample code for what you’re describing.

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.

Fundamentally, there’s nothing wrong with what you’re doing here.

<opinion> I agree with you here - but not as much as you might think. </opinion>

  • I see no value or reason for this additional “logic layer” that you’re creating with the ExerciseSubmission class.

  • From what you’ve posted, these child classes don’t implement any new fields - they could all be implemented as Proxy classes to the base Exercise model.

  • You can save a little bit of work and effory by having get_exercise_logic_registry build its dict with references to the classes and not just as strings. Do the imports in the method and assign the class name to the constant.
    e.g.

from std_module import MultipleChoiceExerciseLogic
...
return {
    Exercise.MULTIPLE_CHOICE: MultipleChoiceExerciseLogic,
    ...
}

in this way, the import function only occurs once and you have a handy reference to the class in your registry.

1 Like