Django.fun

m2m_changed signal with many-to-many through - save

I have these two basic models,

class Product(models.Model):
    title = models.CharField(max_length=40)
    description = models.TextField(blank=True)
    price = models.DecimalField(decimal_places=2, max_digits=7, default=0)
    ...

and

class Cart(models.Model):
    user = models.ForeignKey(
        User, null=True, blank=True, on_delete=models.CASCADE)
    products = models.ManyToManyField(
        Product, blank=True, through='CartItem')
    total = models.DecimalField(default=0.00, max_digits=7, decimal_places=2)

    def recalculate_and_save(self):
      print("recalculate_and_save running")
      total = 0
      for ci in self.cartitem_set.all():
          total += ci.product.price*ci.quantity
      self.total = total
      self.save()

, and a helper model for many-to-many relation above, to account for quantity:

class CartItem(models.Model):
    cart = models.ForeignKey(Cart, on_delete=models.CASCADE)
    product = models.ForeignKey(Product, on_delete=models.CASCADE)
    quantity = models.SmallIntegerField(default=1)

What I want to achieve is automatic calculation of Cart's total every time an item is added or removed. So,

@receiver(m2m_changed, sender=CartItem)
def m2m_changed_cart_receiver(sender, instance, action, *args, **kwargs):
    print(f"m2m_changed received; action={action}, instance={instance}")
    instance.recalculate_and_save()

Then I realized, seeing pre_save and post_save actions in logs, that I'm likely doing something wrong - calling save in the function which is called (twice) from parent's save, as per docs. The first question is, then - why doesn't it send me into an infinite loop? And the second (and probably more important) - why am I only seeing the receiver function executing on removing items from the cart, but not on adding them? Removing is done via

cart.products.remove(product_obj)

, but adding via

cart_obj.cartitem_set.add(cart_item_obj, bulk=False)

, which is probably related to why removal triggers the receiver, while adding does not. But that complicates the question even more - having sender set to be CartItem, I would expect removal, performed on product, to miss the receiver, not adding, which works on cartitems directly (though removal of products removes CartItems too, through the on_delete=CASCADE).

Answers: 1

Answered by Brian Destura, Oct. 13, 2021, 4:15 a.m.

The m2m_changed signal will be triggered when modifying a ManyToManyField (products in this case).

From the docs:

Sent when a ManyToManyField is changed on a model instance

So for your questions:


why doesn't it send me into an infinite loop?

This is because recalculate_and_save didn't do anything with the m2m field products, so it won't trigger the m2m_changed signal again avoiding the infinite loop.


why am I only seeing the receiver function executing on removing items from the cart, but not on adding them?

Since the signal will only come from when modifying products, doing things with cartitem_set will not trigger it.

So you need to manually call recalculate_and_save after modifying cartitem_set:

cart_obj.cartitem_set.add(cart_item_obj, bulk=False)
cart_obj.recalculate_and_save()