Checking many-to-many relation inside transaction doesn't behave as expected

I have the following models (I’m leaving out all fields but the ones relevant to the issue):

class Exercise(models.Model):
    # ...

class Choice(models.Model):
    # ...

class AssignedExercise(models.Model):
    quiz_instance = models.ForeignKey(...)
    exercise = models.ForeignKey(Exercise)
    answered_at = models.DateTimeField(null=True, blank=True)
    selected_choices = models.ManyToManyField(
        Choice,
        blank=True,
    )

When a user participates into a quiz, some AssignedExercises are created and assigned to that instance of the quiz.

For each AssignedExercise, the selected choices for that exercise are stored in the selected_choice field. I’m looking for a way to update answered_at with the timestamp of the first time the user selects a choice. The selected_choice field gets updated via a PATCH request to the relative AssignedExercise (I’m using DRF), and atomic requests are active.

This works in local (sqlite):

 # save method of AssignedExercise
 def save(self, *args, **kwargs):
        self.full_clean()
        super().save(*args, **kwargs)

        # run on transaction commit because this checks whether a
        # selected choice exists but the record won't be visible yet
        # as this method is executed while still inside a transaction
        def update_answered_at_if_answer_exists():
            if self.selected_choices.exists():
                now = timezone.localtime(timezone.now())
                self.answered_at = now
                self.save(update_fields=["answered_at"])

        if self.answered_at is None:
            transaction.on_commit(update_answered_at_if_answer_exists)

However, in production (postgres), this doesn’t work as expected. The first time an answer is selected, none is detected. The field answered_at only ever gets a value if the user changes their mind and selects another choice (I’m assuming this time exists will correclty detect the previous selected choice).

I have a feeling this has something to do with transactions, and I was confident running the method on commit would solve the issue, but this isn’t the case.

Any ideas?

Keep in mind that within the database, the selected_choices field does not represent a field in the AssignedExercise table.

When you add an entry to selected_choices, you’re not updating AssignedExercise - you’re creating an entry in the join table that exists to connect AssignedExercise and Choice.

What this means to me is that your save method in AssignedExercise isn’t necessarily going to be called if you’re only adding an entry to that join field. Whether or not it is, would actually depend upon the other fields that you’re not showing.

I understand that, but my understanding is that the serializer calls save on the model whenever a PATCH request is issued that targets the model instance, regardless of whether the changes directly affect the model instance.

The offending requests are PATCH requests whose only affected field is selected_choices. That’s all there is in the request body.

A subsequent PATCH request which also only contains selected_choices in its payload will correctly trigger the desired behavior.

I was very surprised seeing this work in local and not in production, as the idea of doing the checking upon transaction commit seemed solid.