Actions triggered by field change in Django

Question:

How do I have actions occur when a field gets changed in one of my models? In this particular case, I have this model:

class Game(models.Model):
    STATE_CHOICES = (
        ('S', 'Setup'),
        ('A', 'Active'),
        ('P', 'Paused'),
        ('F', 'Finished')
        )
    name = models.CharField(max_length=100)
    owner = models.ForeignKey(User)
    created = models.DateTimeField(auto_now_add=True)
    started = models.DateTimeField(null=True)
    state = models.CharField(max_length=1, choices=STATE_CHOICES, default='S')

and I would like to have Units created, and the ‘started’ field populated with the current datetime (among other things), when the state goes from Setup to Active.

I suspect that a model instance method is needed, but the docs don’t seem to have much to say about using them in this manner.

Update: I’ve added the following to my Game class:

    def __init__(self, *args, **kwargs):
        super(Game, self).__init__(*args, **kwargs)
        self.old_state = self.state

    def save(self, force_insert=False, force_update=False):
        if self.old_state == 'S' and self.state == 'A':
            self.started = datetime.datetime.now()
        super(Game, self).save(force_insert, force_update)
        self.old_state = self.state
Asked By: Jeff Bradberry

||

Answers:

Django has a nifty feature called signals, which are effectively triggers that are set off at specific times:

  • Before/after a model’s save method is called
  • Before/after a model’s delete method is called
  • Before/after an HTTP request is made

Read the docs for full info, but all you need to do is create a receiver function and register it as a signal. This is usually done in models.py.

from django.core.signals import request_finished

def my_callback(sender, **kwargs):
    print "Request finished!"

request_finished.connect(my_callback)

Simple, eh?

Answered By: c_harm

Basically, you need to override the save method, check if the state field was changed, set started if needed and then let the model base class finish persisting to the database.

The tricky part is figuring out if the field was changed. Check out the mixins and other solutions in this question to help you out with this:

Answered By: ars

One way is to add a setter for the state. It’s just a normal method, nothing special.

class Game(models.Model):
   # ... other code

    def set_state(self, newstate):
        if self.state != newstate:
            oldstate = self.state
            self.state = newstate
            if oldstate == 'S' and newstate == 'A':
                self.started = datetime.now()
                # create units, etc.

Update: If you want this to be triggered whenever a change is made to a model instance, you can (instead of set_state above) use a __setattr__ method in Game which is something like this:

def __setattr__(self, name, value):
    if name != "state":
        object.__setattr__(self, name, value)
    else:
        if self.state != value:
            oldstate = self.state
            object.__setattr__(self, name, value) # use base class setter
            if oldstate == 'S' and value == 'A':
                self.started = datetime.now()
                # create units, etc.

Note that you wouldn’t especially find this in the Django docs, as it (__setattr__) is a standard Python feature, documented here, and is not Django-specific.

note: Don’t know about versions of django older than 1.2, but this code using __setattr__ won’t work, it’ll fail just after the second if, when trying to access self.state.

I tried something similar, and I tried to fix this problem by forcing the initialization of state (first in __init__ then ) in __new__ but this will lead to nasty unexpected behaviour.

I’m editing instead of commenting for obvious reasons, also: I’m not deleting this piece of code since maybe it could work with older (or future?) versions of django, and there may be another workaround to the self.state problem that i’m unaware of

Answered By: Vinay Sajip

@dcramer came up with a more elegant solution (in my opinion) for this issue.

https://gist.github.com/730765

from django.db.models.signals import post_init

def track_data(*fields):
    """
    Tracks property changes on a model instance.

    The changed list of properties is refreshed on model initialization
    and save.

    >>> @track_data('name')
    >>> class Post(models.Model):
    >>>     name = models.CharField(...)
    >>> 
    >>>     @classmethod
    >>>     def post_save(cls, sender, instance, created, **kwargs):
    >>>         if instance.has_changed('name'):
    >>>             print "Hooray!"
    """

    UNSAVED = dict()

    def _store(self):
        "Updates a local copy of attributes values"
        if self.id:
            self.__data = dict((f, getattr(self, f)) for f in fields)
        else:
            self.__data = UNSAVED

    def inner(cls):
        # contains a local copy of the previous values of attributes
        cls.__data = {}

        def has_changed(self, field):
            "Returns ``True`` if ``field`` has changed since initialization."
            if self.__data is UNSAVED:
                return False
            return self.__data.get(field) != getattr(self, field)
        cls.has_changed = has_changed

        def old_value(self, field):
            "Returns the previous value of ``field``"
            return self.__data.get(field)
        cls.old_value = old_value

        def whats_changed(self):
            "Returns a list of changed attributes."
            changed = {}
            if self.__data is UNSAVED:
                return changed
            for k, v in self.__data.iteritems():
                if v != getattr(self, k):
                    changed[k] = v
            return changed
        cls.whats_changed = whats_changed

        # Ensure we are updating local attributes on model init
        def _post_init(sender, instance, **kwargs):
            _store(instance)
        post_init.connect(_post_init, sender=cls, weak=False)

        # Ensure we are updating local attributes on model save
        def save(self, *args, **kwargs):
            save._original(self, *args, **kwargs)
            _store(self)
        save._original = cls.save
        cls.save = save
        return cls
    return inner
Answered By: lucmult

It has been answered, but here’s an example of using signals, post_init and post_save.

from django.db.models.signals import post_save, post_init

class MyModel(models.Model):
    state = models.IntegerField()
    previous_state = None

    @staticmethod
    def post_save(sender, instance, created, **kwargs):
        if instance.previous_state != instance.state or created:
            do_something_with_state_change()

    @staticmethod
    def remember_state(sender, instance, **kwargs):
        instance.previous_state = instance.state

post_save.connect(MyModel.post_save, sender=MyModel)
post_init.connect(MyModel.remember_state, sender=MyModel)
Answered By: Daniel Backman

My solution is to put the following code to app’s __init__.py:

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


@receiver(signals.pre_save)
def models_pre_save(sender, instance, **_):
    if not sender.__module__.startswith('myproj.myapp.models'):
        # ignore models of other apps
        return

    if instance.pk:
        old = sender.objects.get(pk=instance.pk)
        fields = sender._meta.local_fields

        for field in fields:
            try:
                func = getattr(sender, field.name + '_changed', None)  # class function or static function
                if func and callable(func) and getattr(old, field.name, None) != getattr(instance, field.name, None):
                    # field has changed
                    func(old, instance)
            except:
                pass

and add <field_name>_changed static method to my model class:

class Product(models.Model):
    sold = models.BooleanField(default=False, verbose_name=_('Product|sold'))
    sold_dt = models.DateTimeField(null=True, blank=True, verbose_name=_('Product|sold datetime'))

    @staticmethod
    def sold_changed(old_obj, new_obj):
        if new_obj.sold is True:
            new_obj.sold_dt = timezone.now()
        else:
            new_obj.sold_dt = None

then the sold_dt field will change when sold field changes.

Any changes of any field defined in the model will trigger the <field_name>_changed method, with old and new object as parameters.

Answered By: Richard Chien

Using Dirty to detect changes and over-writing save method
dirty field

My prev ans: Actions triggered by field change in Django

class Game(DirtyFieldsMixin, models.Model):
    STATE_CHOICES = (
        ('S', 'Setup'),
        ('A', 'Active'),
        ('P', 'Paused'),
        ('F', 'Finished')
        )
    state = models.CharField(max_length=1, choices=STATE_CHOICES, default='S')

    def save(self, *args, **kwargs):
        if self.is_dirty():
            dirty_fields = self.get_dirty_fields()
            if 'state' in dirty_fields:
                Do_some_action()
        super().save(*args, **kwargs)
Answered By: Amrit Prasad

If you use PostgreSQL you can create a trigger:

https://www.postgresql.org/docs/current/sql-createtrigger.html

Example:

CREATE TRIGGER check_update
    BEFORE UPDATE ON accounts
    FOR EACH ROW
    WHEN (OLD.balance IS DISTINCT FROM NEW.balance)
    EXECUTE FUNCTION check_account_update();
Answered By: guettli
Categories: questions Tags: , ,
Answers are sorted by their score. The answer accepted by the question owner as the best is marked with
at the top-right corner.