22

I have a signal callback in django:

@receiver(post_save, sender=MediumCategory)
def update_category_descendants(sender, **kwargs):
    
    def children_for(category):
        return MediumCategory.objects.filter(parent=category)
    
    def do_update_descendants(category):
        children = children_for(category)
        descendants = list() + list(children)
        
        for descendants_part in [do_update_descendants(child) for child in children]:
            descendants += descendants_part
        
        category.descendants.clear()
        for descendant in descendants:
            if category and not (descendant in category.descendants.all()):
                category.descendants.add(descendant)
                category.save()
        return list(descendants)
    
    # call it for update
    do_update_descendants(None)

...but in the signal handler's body I'm using .save() on the same model MediumCategory. This causes the signal to be dispatched again. How can I disable it?

The perfect solution would be a with statement with some 'magic' inside.

UPDATE: Here is my final solution, if anyone interested:

class MediumCategory(models.Model):
    name = models.CharField(max_length=100)
    slug = models.SlugField(blank=True)
    parent = models.ForeignKey('self', blank=True, null=True)
    parameters = models.ManyToManyField(AdvertisementDescriptonParameter, blank=True)
    count_mediums = models.PositiveIntegerField(default=0)
    count_ads = models.PositiveIntegerField(default=0)
    
    descendants = models.ManyToManyField('self', blank=True, null=True)
    
    def save(self, *args, **kwargs):
        self.slug = slugify(self.name)
        super(MediumCategory, self).save(*args, **kwargs)
    
    def __unicode__(self):
        return unicode(self.name)
(...)
@receiver(post_save, sender=MediumCategory)
def update_category_descendants(sender=None, **kwargs):
    def children_for(category):
        return MediumCategory.objects.filter(parent=category)
    
    def do_update_descendants(category):
        children = children_for(category)
        descendants = list() + list(children)
        
        for descendants_part in [do_update_descendants(child) for child in children]:
            descendants += descendants_part
        
        if category:
            category.descendants.clear()
            for descendant in descendants:
                category.descendants.add(descendant)
        return list(descendants)
    
    # call it for update
    do_update_descendants(None)
jboot
  • 701
  • 9
  • 17
bartek
  • 2,921
  • 5
  • 26
  • 30
  • I found an answer and I wrote [it here](https://stackoverflow.com/questions/18532539/want-to-disable-signals-in-django-testing/70848580#70848580). Hope can help someone :) – Alejandro Acho Jan 25 '22 at 12:49

4 Answers4

24

Perhaps I'm wrong, but I think that category.save() is not needed in your code, add() is enough because change is made in descendant but in category.

Also, to avoid signals you can:

  • Disconnect signal and reconnect.
  • Use update: Descendant.objects.filter( pk = descendant.pk ).update( category = category )
dani herrera
  • 48,760
  • 8
  • 117
  • 177
  • ok, that's what I was looking for: `disconnect` is the solution, putting it into `with` statement is a matter of purity :) But after removing `save()`, `disconnect` is not needed. Perfect. – bartek Jul 14 '12 at 20:44
  • and make disconnect + reconnect atomically consistent, no matter what exception appears after disconnection. Also, do you care about other threads? – Sławomir Lenart Oct 02 '20 at 16:27
  • Here's the guide: https://blog.theletstream.com/moneky-patching-save-and-bypassing-signals-in-django-4a28e38b3b7b – thisisayush Dec 10 '22 at 09:14
21

To disable a signal on your model, a simple way to go is to set an attribute on the current instance to prevent upcoming signals firing.

This can be done using a simple decorator that checks if the given instance has the 'skip_signal' attribute, and if so prevents the method from being called:

from functools import wraps

def skip_signal():
    def _skip_signal(signal_func):
        @wraps(signal_func)
        def _decorator(sender, instance, **kwargs):
            if hasattr(instance, 'skip_signal'):
                return None
            return signal_func(sender, instance, **kwargs)  
        return _decorator
    return _skip_signal

You can now use it this way:

from django.db.models.signals import post_save
from django.dispatch import receiver

@receiver(post_save, sender=MyModel)
@skip_signal()
def my_model_post_save(sender, instance, **kwargs):
    instance.some_field = my_value
    # Here we flag the instance with 'skip_signal'
    # and my_model_post_save won't be called again
    # thanks to our decorator, avoiding any signal recursion
    instance.skip_signal  = True
    instance.save()

Hope This helps.

Charlesthk
  • 9,394
  • 5
  • 43
  • 45
1

Here is solution to temporary disable signal receiver per instance which allows to use it on production (bc it is thread-safe)

[usage.py]

from django.db.models.signals import post_save

payment = Payment()
with mute_signals_for(payment, signals=[post_save]):
   payment.save()  # handle_payment signal receiver will be skipped

[code.py]

from contextlib import contextmanager
from functools import wraps

MUTE_SIGNALS_ATTR = '_mute_signals'


def mutable_signal_receiver(func):
    """Decorator for signals to allow to skip them by setting attr MUTE_SIGNALS_ATTR on instance,
    which can be done via mute_signals_for"""
    @wraps(func)
    def wrapper(sender, instance, signal, **kwargs):
        mute_signals = getattr(instance, MUTE_SIGNALS_ATTR, False)
        if mute_signals is True:
            pass  # skip all signals
        elif isinstance(mute_signals, list) and signal in mute_signals:
            pass  # skip user requested signal
        else:  # allow signal receiver
            return func(sender=sender, instance=instance, signal=signal, **kwargs)
    return wrapper


@contextmanager
def mute_signals_for(instance, signals):
    """Context manager to skip signals for @instance (django model), @signals can be
    True to skip all signals or list of specified signals, like [post_delete, post_save] """
    try:
        yield setattr(instance, MUTE_SIGNALS_ATTR, signals)
    finally:
        setattr(instance, MUTE_SIGNALS_ATTR, False)

[signals.py]

@receiver(post_save, sender=Payment, dispatch_uid='post_payment_signal')
@mutable_signal_receiver
def handle_payment(sender, instance, created, **kwargs):
    """called after payment is registered in the system."""
pymen
  • 5,737
  • 44
  • 35
  • Hi @pymen. Thank you for this idea of muting signals. I've been trying to make your code working and to me it seems like there's an issue in `mutable_signal_receiver` method, namely at condition `elif isinstance(mute_signals, list) and signal in mute_signals:` where is signal compared with items of passed list. It's comparison between python function reference and django.db.models.signals.ModelSignal and condition is never met. So for now I'm simply compairing `__name__` variables of both `func` parameter and items of `mute_signals`. Don't you have this issue? – Michal Půlpán Aug 03 '22 at 10:16
  • @MichalPůlpán you are right, i have fixed the usage example to pass `post_save` to signals which should be muted. Same approach could be applied to signal handlers, just need to use different attribute like MUTE_HANDLERS_ATTR – pymen Aug 03 '22 at 13:15
1

Where the previous answers demonstrate how to do this without external libraries, django-model-utils offers a clean way to do exactly this. The advantage of the library is that the code is explicit. Performing a bulk_create or filter followed by an update doesn't make it clear that you want to disable the signal. Another advantage is that the save() method may perform additional validation.

from model_utils.models import SaveSignalHandlingModel

class Film(SaveSignalHandlingModel):
    title = models.CharField(max_length=100)

film = Film(title="Cidade de Deus")
film.save(signals_to_disable=["post_save"])

See the post here: https://django.wtf/blog/disable-django-model-signals/

Eric Aya
  • 69,473
  • 35
  • 181
  • 253
danihodovic
  • 1,151
  • 3
  • 18
  • 28