Search code examples
pythondjangodjango-admindjango-ormdjango-signals

When does ManyToMany field get saved to the database?


Given the (simplified) model, used within a Django Admin app:

models.py

class Appointment(models.Model):
    customer = models.ForeignKey(
        Customer, blank=False, null=True, on_delete=models.CASCADE
    )
    pianos = models.ManyToManyField(Piano, blank=True)

    def save(self, *args, **kwargs): 
        # Is it a new Appointment, not an existing one being re-saved?
        newAppointment = self.id is None
        try:
            super().save(*args, **kwargs)  # Call the "real" save() method.
            if newAppointment:
                # Returns empty queryset
                for piano in self.pianos.all():
                    print(piano)
        except:
            pass

I want to access 'pianos'. If it's a newly created Appointment, self.pianos returns

<django.db.models.fields.related_descriptors.create_forward_many_to_many_manager..ManyRelatedManager object at 0x7f6f875209a0>

and self.pianos.all() returns an empty queryset, even though the pianos are displayed in the template form that was submitted to initiate the save.

However, if it's an updating of an existing Appointment, 'pianos' returns the data as expected.

Apparently that ManyToMany field doesn't get saved to the db immediately when save() is called. So how can I access the data as shown below? Note that 'pianos' are not instantiated here, they already exist in the database, and only need Appointment to point to one or more of them in its m2m field, as directed from a horizontal_filter defined in admin.py.

I also tried the alternative method of using a post_save signal, with exactly the same result:

@receiver(signals.post_save, sender=Appointment)
def createServiceHistory(sender, instance, created, **kwargs):
    if created:
        for piano in instance.pianos.all():  #empty queryset
            print(piano)

Update: modified to catch m2m_changed instead of post_save:

@receiver(signals.m2m_changed, sender=Appointment)
def createServiceHistory(sender, instance, action, **kwargs):
    print(action)

But this signal isn't received.

The docs say that ManyToMany fields are saved using add() rather than save(), but I don't see how that would be applied in this case.


Solution

  • A ManyToMany association can not be created until both instances are saved, that's why it is not yet created while you are in the Appointment.save() method.

    The Form.save() method (or the Save button, if you are using the Django Admin Interface) saves the Appointment, then associates it with the Piano instance(s) using .add(). In this case, you can use the m2m_changed signal as @IainShelvington suggested:

    Sent when a ManyToManyField is changed on a model instance. Strictly speaking, this is not a model signal since it is sent by the ManyToManyField, but since it complements the pre_save/post_save and pre_delete/post_delete when it comes to tracking changes to models, it is included here.

    Note that the sender for this signal won't be Appointment anymore. You should use Appointment.pianos.through as mentioned in the docs:

    Arguments sent with this signal:

    sender

    The intermediate model class describing the ManyToManyField. This class is automatically created when a many-to-many field is defined; you can access it using the through attribute on the many-to-many field.

    A simplified example would be:

    @receiver(signals.m2m_changed, sender=Appointment.pianos.through)
    def associateAppointmentWithPiano(sender, instance, action, **kwargs):
        print(f"{sender=} {instance=} {action=})
    

    Additional comments from the OP:

    For the benefit of later readers, the action will be pre_add first, then a second signal arrives with action being post_add. Only at that point is the data in this example available via: Appointment.pianos.all().