5

I have 3 models:

class Product(TimeStampedModel):
    category = models.ForeignKey('Category', related_name='products', to_field='category_name')
    brand = models.ForeignKey('Brand', related_name='products', to_field='brand_name')

class Brand(models.Model):    
    brand_name = models.CharField(max_length=50)
    categories = models.ManyToManyField('Category', related_name='categories')

class Category(models.Model):
    category_name = models.CharField(max_length=128)

I want to update Brand-Category M2M relationship after I change Product.category.
I try to connect the signal m2m_changed, like it is described in the docs:

@receiver(m2m_changed, sender=Brand.categories.through)
def category_changed(sender, **kwargs):
    print("Signal connected!")

Also I have registered a signal in the apps.py in project_folder:

  def ready(self):
        from my_app.signals import category_changed

But the problem is - this code doesn't have any effect. I change the Product.category - and doesn't see any prints. How should I fix it to make it work?

Chiefir
  • 2,561
  • 1
  • 27
  • 46
  • But you are attaching the signal to `Brand.categories`. It will be triggered only when you add to/remove from `Brand.categories`. Why do you think changes of `Product.category` should trigger the signal? Use the plain `post_save` signal attached to `Category`. – hoefling May 01 '18 at 16:53
  • I thought about that option. But when i change: `@receiver(post_save, sender=Category)` - it also does nothing. Does `post_save` work on model `update`? – Chiefir May 01 '18 at 17:07
  • Depends on what changes you want to listen to - if you want to listen to changes like `project.category = Category.objects.first(); project.save()`, then attach to `Project`; if you want to listen to `project.category.category_name = 'spam'; project.category.save()`, then you need to attach to `Category`. – hoefling May 01 '18 at 17:09
  • No, this is operation kind of: `Product.objects.filter(category = 'cat_AA').update(category = 'cat_BB')` – Chiefir May 01 '18 at 17:11
  • Changed to: `@receiver(post_save, sender=Product)` - but still no any result – Chiefir May 01 '18 at 17:18

2 Answers2

8

This is the correct way to handle Many2Many field changes Django Docs

from django.db.models.signals import m2m_changed

m2m_changed.connect(category_changed,sender=Brand.categories.through)

EDIT: I did not realize Chiefir is asking for a signal on QuerySet update method, for which we cannot use the inbuild m2m_changed signal. As Ralf in previous answer points out, triggering a signal on update may cause performance issues, for example in case of listening to the through being saved. However, there is a solution that imho does not carry performance problems.

Essentially, you can override the queryset update method to trigger a custom signal every time a whole queryset is updated. This will be triggered only once per queryset update and you can specify it to only be triggered on one particular field update by checking the update arguments.

The signal now sends the queryset being updated and the value as a payload. Be aware that in this code, the signal is being sent before the category updates. To change that, save the super output as a variable, dispatch the signal and return the variable.

from django.dispatch import Signal, receiver

product_category_updated = Signal(providing_args=["queryset", "value"])


class ProductQuerySet(models.QuerySet):

    def update(self, *args, **kwargs):
        if 'category' in kwargs:
            product_category_updated.send(sender=self.__class__, queryset=self, value=kwargs.get('category'))
        return super(ProductQuerySet, self).update(*args, **kwargs)

class ProductManager(models.Manager):

    def get_queryset(self, show_hidden=False):
        return ProductQuerySet(self.model, using=self._db, hints=self._hints)

class Product(TimeStampedModel):
    objects = ProductManager()

    category = models.ForeignKey('Category', related_name='products', to_field='category_name', on_delete=models.deletion.CASCADE)
    brand = models.ForeignKey('Brand', related_name='products', to_field='brand_name', on_delete=models.deletion.CASCADE)

class Brand(models.Model):
    brand_name = models.CharField(max_length=50, unique=True)
    categories = models.ManyToManyField('Category', related_name='categories')

class Category(models.Model):
    category_name = models.CharField(max_length=128, unique=True)

@receiver(product_category_updated, sender=ProductQuerySet)
def category_changed(sender, **kwargs):
    print("Signal connected!")

I had to adjust the code to my Django 2.0.7 version by adding on_delete attributes on ForeignKey fields and making the brand_name and category_name unique.

Jura Brazdil
  • 970
  • 7
  • 15
  • did u test that in the similar situation as I described? the problem is that I am not `creating` instance, but `updating` it - what does not trigger `post_save` signal. – Chiefir Nov 08 '18 at 19:03
  • Thanks for clarifying! That is indeed a bit more interesting, albeit not impossible with signals. I edited the answer, hope it helps! – Jura Brazdil Nov 11 '18 at 10:08
  • thank you for your answer, very interesting approach. I will test it when I have an occasion. – Chiefir Nov 19 '18 at 09:43
  • Did you get a chance to try? I think this should be the accepted answer, as it is indeed possible. – Jura Brazdil Apr 29 '19 at 13:45
0

In a comment you said that you want a signal on the following update statement:

Product.objects.filter(category = 'cat_AA').update(category = 'cat_BB')

According to this question, the django signal docs and the django queryset docs, the update() method does NOT trigger the post_save signal.

So you will have to find another way to accomplish your goal, like for example iterating over each item and calling its save method (but it will be slower).

Ralf
  • 16,086
  • 4
  • 44
  • 68
  • Yep, I have read that info - just tested, what I was advised. Does that mean that I cant catch with `signals` those changes? – Chiefir May 01 '18 at 18:34
  • @Chiefir as far as I know, the `update` method does NOT trigger any signal, because it is a bulk update method for performance reason. Calling signals would undermine some of the speedup you gain from using the bulk operation. – Ralf May 01 '18 at 18:37