How to use Django model inheritance with signals?
Asked Answered
S

10

50

I have a few model inheritance levels in Django:

class WorkAttachment(models.Model):
    """ Abstract class that holds all fields that are required in each attachment """
    work            = models.ForeignKey(Work)
    added           = models.DateTimeField(default=datetime.datetime.now)
    views           = models.IntegerField(default=0)

    class Meta:
        abstract = True


class WorkAttachmentFileBased(WorkAttachment):
    """ Another base class, but for file based attachments """
    description     = models.CharField(max_length=500, blank=True)
    size            = models.IntegerField(verbose_name=_('size in bytes'))

    class Meta:
        abstract = True


class WorkAttachmentPicture(WorkAttachmentFileBased):
    """ Picture attached to work """
    image           = models.ImageField(upload_to='works/images', width_field='width', height_field='height')
    width           = models.IntegerField()
    height          = models.IntegerField()

There are many different models inherited from WorkAttachmentFileBased and WorkAttachment. I want to create a signal, which would update an attachment_count field for parent work, when attachment is created. It would be logical, to think that signal made for parent sender (WorkAttachment) would run for all inherited models too, but it does not. Here is my code:

@receiver(post_save, sender=WorkAttachment, dispatch_uid="att_post_save")
def update_attachment_count_on_save(sender, instance, **kwargs):
    """ Update file count for work when attachment was saved."""
    instance.work.attachment_count += 1
    instance.work.save()

Is there a way to make this signal work for all models inherited from WorkAttachment?

Python 2.7, Django 1.4 pre-alpha

P.S. I've tried one of the solutions I found on the net, but it did not work for me.

Snowberry answered 17/10, 2011 at 10:14 Comment(1)
I've found the solution page in web archives. There's one drawback of the solution - you should declare signal after all subclasses, otherwise it won't find them.Jobi
C
17

You could try something like:

model_classes = [WorkAttachment, WorkAttachmentFileBased, WorkAttachmentPicture, ...]

def update_attachment_count_on_save(sender, instance, **kwargs):
    instance.work.attachment_count += 1
    instance.work.save()

for model_class in model_classes:
    post_save.connect(update_attachment_count_on_save, 
                      sender=model_class, 
                      dispatch_uid="att_post_save_"+model_class.__name__)

(Disclaimer: I have not tested the above)

Cyclostyle answered 17/10, 2011 at 10:45 Comment(1)
Just FYI, you can find blog post by @SilverLight at: web.archive.org/web/20120715042306/http://codeblogging.net/…Geanticlinal
L
55

You could register the connection handler without sender specified. And filter the needed models inside it.

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


@receiver(post_save)
def my_handler(sender, **kwargs):
    # Returns false if 'sender' is NOT a subclass of AbstractModel
    if not issubclass(sender, AbstractModel):
       return
    ...

Ref: https://groups.google.com/d/msg/django-users/E_u9pHIkiI0/YgzA1p8XaSMJ

Lafrance answered 18/6, 2013 at 16:3 Comment(1)
This works but has a slight drawback in that the receiver function gets called for every model that calls save().Beachcomber
A
30

The simplest solution is to not restrict on the sender, but to check in the signal handler whether the respective instance is a subclass:

@receiver(post_save)
def update_attachment_count_on_save(sender, instance, **kwargs):
    if isinstance(instance, WorkAttachment):
        ...

However, this may incur a significant performance overhead as every time any model is saved, the above function is called.

I think I've found the most Django-way of doing this: Recent versions of Django suggest to connect signal handlers in a file called signals.py. Here's the necessary wiring code:

your_app/__init__.py:

default_app_config = 'your_app.apps.YourAppConfig'

your_app/apps.py:

import django.apps

class YourAppConfig(django.apps.AppConfig):
    name = 'your_app'
    def ready(self):
        import your_app.signals

your_app/signals.py:

def get_subclasses(cls):
    result = [cls]
    classes_to_inspect = [cls]
    while classes_to_inspect:
        class_to_inspect = classes_to_inspect.pop()
        for subclass in class_to_inspect.__subclasses__():
            if subclass not in result:
                result.append(subclass)
                classes_to_inspect.append(subclass)
    return result

def update_attachment_count_on_save(sender, instance, **kwargs):
    instance.work.attachment_count += 1
    instance.work.save()

for subclass in get_subclasses(WorkAttachment):
    post_save.connect(update_attachment_count_on_save, subclass)

I think this works for all subclasses, because they will all be loaded by the time YourAppConfig.ready is called (and thus signals is imported).

Ammonate answered 17/3, 2015 at 17:53 Comment(1)
good answer. Note that result in get_subclassed contains the parent class, which matches this question. If your parent class is an abstract Model, you would want result to be an empty list initially.Meyeroff
P
18

I just did this using python's (relatively) new __init_subclass__ method:

from django.db import models

def perform_on_save(*args, **kw):
    print("Doing something important after saving.")

class ParentClass(models.Model):
    class Meta:
        abstract = True

    @classmethod
    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)
        models.signals.post_save.connect(perform_on_save, sender=cls)

class MySubclass(ParentClass):
    pass  # signal automatically gets connected.

This requires django 2.1 and python 3.6 or better. Note that the @classmethod line seems to be required when working with the django model and associated metaclass even though it's not required according to the official python docs.

Preface answered 11/2, 2019 at 19:45 Comment(4)
It's __init_subclass__.Problem
very nice solutionPym
This is a great solution! I configured this to work to connect to the m2m_changed signal instead. I'll add my answer below.Mapp
This is perfect as it avoid triggering on save() multiple timesBromberg
C
17

You could try something like:

model_classes = [WorkAttachment, WorkAttachmentFileBased, WorkAttachmentPicture, ...]

def update_attachment_count_on_save(sender, instance, **kwargs):
    instance.work.attachment_count += 1
    instance.work.save()

for model_class in model_classes:
    post_save.connect(update_attachment_count_on_save, 
                      sender=model_class, 
                      dispatch_uid="att_post_save_"+model_class.__name__)

(Disclaimer: I have not tested the above)

Cyclostyle answered 17/10, 2011 at 10:45 Comment(1)
Just FYI, you can find blog post by @SilverLight at: web.archive.org/web/20120715042306/http://codeblogging.net/…Geanticlinal
C
8
post_save.connect(my_handler, ParentClass)
# connect all subclasses of base content item too
for subclass in ParentClass.__subclasses__():
    post_save.connect(my_handler, subclass)

have a nice day!

Chaeta answered 8/7, 2014 at 6:14 Comment(1)
You need to make sure this is run after all possible subclasses have been defined or else they'll get skipped (though, I haven't tested that assertion, I think that's what would happen).Shoddy
E
7

Michael Herrmann's solution is definitively the most Django-way of doing this. And yes it works for all subclasses as they are loaded at the ready() call.

I would like to contribute with the documentation references :

In practice, signal handlers are usually defined in a signals submodule of the application they relate to. Signal receivers are connected in the ready() method of your application configuration class. If you’re using the receiver() decorator, simply import the signals submodule inside ready().

https://docs.djangoproject.com/en/dev/topics/signals/#connecting-receiver-functions

And add a warning :

The ready() method may be executed more than once during testing, so you may want to guard your signals from duplication, especially if you’re planning to send them within tests.

https://docs.djangoproject.com/en/dev/topics/signals/#connecting-receiver-functions

So you might want to prevent duplicate signals with a dispatch_uid parameter on the connect function.

post_save.connect(my_callback, dispatch_uid="my_unique_identifier")

In this context I'll do :

for subclass in get_subclasses(WorkAttachment):
    post_save.connect(update_attachment_count_on_save, subclass, dispatch_uid=subclass.__name__)

https://docs.djangoproject.com/en/dev/topics/signals/#preventing-duplicate-signals

Exemplificative answered 14/7, 2015 at 9:1 Comment(0)
B
2

This solution resolves the problem when not all modules imported into memory.

def inherited_receiver(signal, sender, **kwargs):
    """
    Decorator connect receivers and all receiver's subclasses to signals.

        @inherited_receiver(post_save, sender=MyModel)
        def signal_receiver(sender, **kwargs):
            ...

    """
    parent_cls = sender

    def wrapper(func):
        def childs_receiver(sender, **kw):
            """
            the receiver detect that func will execute for child 
            (and same parent) classes only.
            """
            child_cls = sender
            if issubclass(child_cls, parent_cls):
                func(sender=child_cls, **kw)

        signal.connect(childs_receiver, **kwargs)
        return childs_receiver
    return wrapper
Baccalaureate answered 18/4, 2014 at 11:35 Comment(1)
this is built on the https://mcmap.net/q/347128/-how-to-use-django-model-inheritance-with-signals and without the defects which SilverLight mentioned in the accepted answer.Trappist
C
0

It's also possible to use content types to discover subclasses - assuming you have the base class and subclasses packaged in the same app. Something like this would work:

from django.contrib.contenttypes.models import ContentType
content_types = ContentType.objects.filter(app_label="your_app")
for content_type in content_types:
    model = content_type.model_class()
    post_save.connect(update_attachment_count_on_save, sender=model)
Camp answered 18/5, 2013 at 22:3 Comment(0)
M
0

In addition to @clwainwright answer, I configured his answer to instead work for the m2m_changed signal. I had to post it as an answer for the code formatting to make sense:

@classmethod
    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)
        for m2m_field in cls._meta.many_to_many:
            if hasattr(cls, m2m_field.attname) and hasattr(getattr(cls, m2m_field.attname), 'through'):
                models.signals.m2m_changed.connect(m2m_changed_receiver, weak=False, sender=getattr(cls, m2m_field.attname).through)

It does a couple of checks to ensure it doesn't break if anything changes in future Django versions.

Mapp answered 18/1, 2021 at 21:8 Comment(0)
D
0

m2m signals will not be installed in the __init_subclass__() method as here we do not have an access to model's fields - model is not instantiated yet

Dissident answered 15/5 at 11:59 Comment(1)
Your answer could be improved with additional supporting information. Please edit to add further details, such as citations or documentation, so that others can confirm that your answer is correct. You can find more information on how to write good answers in the help center.Cretin

© 2022 - 2024 — McMap. All rights reserved.