How can I avoid re-triggering a django signal?

117 Views Asked by At

I have this signal and I want it to not run once it has already checked each instance once, currently it falls into an infinite recursion loop since it triggers itself each time it runs.

from django.db.models.signals import (
    post_save,
)
from django.dispatch import receiver
from app.models import (
    QuestionForm,
    Question,
)


@receiver(post_save, sender=form)
def get_max_score(
        sender: form,
        instance: form,
        **kwargs: dict,
) -> None:
    forms = form.objects.all()
    for form in forms.iterator():
        total = 0
        questions = form.questions.all()
        for item in questions.iterator():
            total += item.points
        form.set_score(total)
        form.save()

Any help is appreciated, bonus points if the answer is less complex than n^2.

Edit: this is the form model itself:

class QuestionForm(models.Model):

    id = models.AutoField(primary_key=True)

    name = models.CharField(max_length=100)

    questions = models.ManyToManyField(
        Question,
        related_name='questions'
    )

    created_at = models.DateTimeField(
        auto_now_add=True,
        editable=False,
    )

    updated_at = models.DateTimeField(
        auto_now=True,
        editable=False,
    )

    max_score = models.IntegerField(
        default=0,
    )

    def __str__(self):
        return self.name

    def get_score(self):
        return self.max_score

    def set_score(self, score):
        self.max_score = score
1

There are 1 best solutions below

3
willeM_ Van Onsem On

I would strongly advise not to store the score in the form objects. Indeed, not only will this avoid the problem with the signals: we can do this more efficient at the database side, and only when we need the score, and also make it more robust.

Signals are often a bad idea. Indeed, signals can be circumventing, for example with a .bulk_create(…) [Django-doc] that will not trigger signals for created objects. There are also a lot of scenarios where the data can change: creating records, updating records, removing records. It turns out that keeping the same data in sync, even on the same database, is not easy. I summarized some problems with signals in this article [django-antipatterns].

Therefore it might be better to just omit the score:

class QuestionForm(models.Model):
    questions = models.ManyToManyField(
        Question,
        related_name='questions'
    )
    # …
    # no score field
    pass


class Question(models.Model):
    points = models.IntegerField()

Now if we want the Forms with the corresponding score for the related Questions, we can use:

from django.db.models import Sum

QuestionForm.objects.annotate(score=Sum('questions__points'))

The Forms that arise from this QuerySet will have an extra attribute .score that will contain the sum of the .points of the related Questions. If the queryset is filtered down, it will also not aggregate the ones we don't need, and since the aggregate is done by the database, that is usually quite efficient.


Note: The related_name=… parameter [Django-doc] is the name of the relation in reverse, so from the Question model to the QuestionForm model in this case. Therefore it (often) makes not much sense to name it the same as the forward relation. You thus might want to consider renaming the questions relation to forms.