91

I have a django model, and I need to compare old and new values of field BEFORE saving.

I've tried the save() inheritance, and pre_save signal. It was triggered correctly, but I can't find the list of actually changed fields and can't compare old and new values. Is there a way? I need it for optimization of pre-save actions.

Thank you!

djvg
  • 11,722
  • 5
  • 72
  • 103
Y.N
  • 4,989
  • 7
  • 34
  • 61

11 Answers11

90

There is very simple django way for doing it.

"Memorise" the values in model init like this:

def __init__(self, *args, **kwargs):
    super(MyClass, self).__init__(*args, **kwargs)
    self.initial_parametername = self.parametername
    ---
    self.initial_parameternameX = self.parameternameX

Real life example:

At class:

def __init__(self, *args, **kwargs):
    super(MyClass, self).__init__(*args, **kwargs)
    self.__important_fields = ['target_type', 'target_id', 'target_object', 'number', 'chain', 'expiration_date']
    for field in self.__important_fields:
        setattr(self, '__original_%s' % field, getattr(self, field))

def has_changed(self):
    for field in self.__important_fields:
        orig = '__original_%s' % field
        if getattr(self, orig) != getattr(self, field):
            return True
    return False

And then in modelform save method:

def save(self, force_insert=False, force_update=False, commit=True):
    # Prep the data
    obj = super(MyClassForm, self).save(commit=False)

    if obj.has_changed():

        # If we're down with commitment, save this shit
        if commit:
            obj.save(force_insert=True)

    return obj
Odif Yltsaeb
  • 5,575
  • 12
  • 49
  • 80
  • 8
    I prefer the Odif's way, because I need to trigger the actions for model without forms (after changes comes from api or from admin site) – Y.N Apr 29 '14 at 16:07
  • 1
    when is `__init__` called? will it work only for initial creation or subsequent updates as well? – wasabigeek Apr 29 '17 at 08:26
  • 1
    Init is called every time model instance is created. If instance is updated serveral times over it's lifetime, then `__init__` is only called in the beginning. – Odif Yltsaeb May 02 '17 at 14:07
  • 1
    this will not cover if the model is saved in other places with `save` or `bulk_create` – Julio Marins May 12 '18 at 14:39
  • No it wont. But model save almost never happens out of the blue, with no previous interaction. The has_changed method can be used in all of those places. And when you are creating the object you do not need to check if it has changed anyway... – Odif Yltsaeb May 14 '18 at 09:47
  • This works great, as long as you access these from the `save` method of a model in models.py. If you try to access these from a admin.py's `save_model` method, it will thrown an exception that it's actually trying to access `_MyModelAdmin__original_%s`. You can fix it for a specific model admin by doing adding `setattr(self, '_MyModelAdmin__original_%s' % field, getattr(self, field))` to the for loop of `__init__`, but I'm not sure how to fix it so these `__original_` values can be accessed from any ModelAdmin. This code will only work if your ModelAdmin is called `MyModelAdmin`. – John Jun 10 '19 at 15:06
  • Did you create has_changed method for the MyModelAdmin? If yes, then there's your problem. has_changed is supposed to be a method of model class not modeladmin class. – Odif Yltsaeb Jun 17 '19 at 06:42
  • I'm voting both these answer up as Sahil and Odif are right, and if you're not using a form then you have the choice this way – AppHandwerker Aug 14 '19 at 13:58
  • I am voting this answer down because of the huge performance impact it may have. If field "self.parametername" is a related field or a foreign key, then you will query the database every time the object is constructed, i.e. for every row, which is very slow. – Ilya Kharlamov Aug 26 '19 at 13:56
  • @IlyaKharlamov in cases like this, you need to be smart enough to use self.parametername_id, not self.parametername, and you are still just fine. Works the same for the generic relation fields and so on. – Odif Yltsaeb Jul 03 '21 at 10:10
  • 1
    Be careful with this approach. I find a lot of issues (including python crashing or recursion limits reached) when trying to do i.e. `Model.objects.delete()` if field I want to cache this is foreign key (even if you try to store `self._old__id` as integer – DimmuR Jan 15 '22 at 23:59
  • You can fall into an infinite recursion when you use defer or only QuerySet API. https://stackoverflow.com/a/65557252/6577636 – Youngkwang Kim Apr 05 '22 at 04:51
62

It is better to do this at ModelForm level.

There you get all the Data that you need for comparison in save method:

  1. self.data : Actual Data passed to the Form.
  2. self.cleaned_data : Data cleaned after validations, Contains Data eligible to be saved in the Model
  3. self.changed_data : List of Fields which have changed. This will be empty if nothing has changed

If you want to do this at Model level then you can follow the method specified in Odif's answer.

rinat.io
  • 3,168
  • 2
  • 21
  • 25
Sahil kalra
  • 8,344
  • 4
  • 23
  • 29
  • 1
    I agree with your answer, also self.instance can be of use in this issue. – lehins Apr 29 '14 at 11:57
  • @AlexeyKuleshevich agreed, but only *before* a form's `_post_clean` (`is_valid->errors->full_clean->_post_clean`), after which the instance will be updated to include the new values. accessing in `form.clean_fieldname()` and `form.clean()` seems ok provided it's their first call. – jozxyqk Jul 17 '15 at 09:39
  • 4
    Well that works, but ONLY if you're saving with a form, which isn't always the case. – gdvalderrama Jan 23 '17 at 16:33
  • Yeah, True. If you are not using a Form then you can't do this. But using a Form is the ideal way. – Sahil kalra Jan 24 '17 at 12:43
  • `self.changed_data` is a new for me – Mohammed Shareef C Feb 27 '18 at 09:56
  • I'd love to hear Sahil or other folks thoughts on limits of "better to do this". Is ModelForm is still your preferred approach if you not only have new data coming in through forms, admin as well as consumer, and even through pipelines such as importing? In those cases, I like save hooks as the best single place to put such logic. – LisaD Nov 22 '19 at 21:23
44

Also you can use FieldTracker from django-model-utils for this:

  1. Just add tracker field to your model:

    tracker = FieldTracker()
    
  2. Now in pre_save and post_save you can use:

    instance.tracker.previous('modelfield')     # get the previous value
    instance.tracker.has_changed('modelfield')  # just check if it is changed
    
psl
  • 906
  • 6
  • 6
  • 6
    Yeah I just love how clean this is... Another line to requirements! – Kevin Parker Apr 01 '16 at 21:43
  • But this tracker field is a real column in the table? Or is just a fake field? – toscanelli Apr 07 '16 at 15:59
  • 3
    @toscanelli, it does not add a column to the table. – texnic Dec 19 '16 at 21:10
  • 1
    Just a reminder to make sure to makemigrations and migrate again otherwise there will be an attribute error like: 'tracker' not found. – Amoroso Jun 27 '18 at 04:04
  • 4
    This one is so tempting but someone report a performance issue [here](https://github.com/jazzband/django-model-utils/issues/323). And there is no update nor follow up from the team. So, checkout the source code of `tracker.py`. It looks like a lot of works and signaling. So, it comes to if it worth - or the use case was too limited that you only need to track one field or two. – John Pang Sep 24 '18 at 16:52
36

Django's documentation contains an example showing exactly how to do this:

Django 1.8+ and above (Including Django 2.x and 3.x), there is a from_db classmethod, which can be used to customize model instance creation when loading from the database.

Note: There is NO additional database query if you use this method.

from django.db import Model

class MyClass(models.Model):
    
    @classmethod
    def from_db(cls, db, field_names, values):
        instance = super().from_db(db, field_names, values)
        
        # save original values, when model is loaded from database,
        # in a separate attribute on the model
        instance._loaded_values = dict(zip(field_names, values))
        
        return instance

So now the original values are available in the _loaded_values attribute on the model. You can access this attribute inside your save method to check if some value is being updated.

class MyClass(models.Model):
    field_1 = models.CharField(max_length=1)

    @classmethod
    def from_db(cls, db, field_names, values):
        ...
        # use code from above

    def save(self, *args, **kwargs):

        # check if a new db row is being added
        # When this happens the `_loaded_values` attribute will not be available
        if not self._state.adding:

            # check if field_1 is being updated
            if self._loaded_values['field_1'] != self.field_1:
                # do something

        super().save(*args, **kwargs)
            
            
djvg
  • 11,722
  • 5
  • 72
  • 103
GunnerFan
  • 3,576
  • 3
  • 25
  • 38
  • 1
    This is pretty cool, but it won't give you the M2M relations. For example if you are trying to track changes to what groups a User is associated with, there doesn't appear to be any way to do it with this technique. – shacker Dec 08 '20 at 01:52
5

Something like this also works:

class MyModel(models.Model):
    my_field = fields.IntegerField()

    def save(self, *args, **kwargs):
       # Compare old vs new
       if self.pk:
           obj = MyModel.objects.values('my_value').get(pk=self.pk)
           if obj['my_value'] != self.my_value:
               # Do stuff...
               pass
       super().save(*args, **kwargs)
Slipstream
  • 13,455
  • 3
  • 59
  • 45
  • 12
    Performing a lookup prior to every save doesn't seem very performant. – Ian E Jan 23 '19 at 00:39
  • 1
    "Performing a lookup prior to every save doesn't seem very performant" I agree. But it depends on the context. In any case, what do you propose? – Akhorus Mar 24 '20 at 17:44
  • 2
    @IanE I have added an answer which avoids a DB lookup https://stackoverflow.com/a/64116052/3446669 – GunnerFan Dec 08 '20 at 14:55
4

My use case for this was that I needed to set a denormalized value in the model whenever some field changed its value. However, as the field being monitored was a m2m relation, I didn't want to have to do that DB lookup whenever save was called in order to check whether the denormalized field needed updating. So, instead I wrote this little mixin (using @Odif Yitsaeb's answer as inspiration) in order to only update the denormalized field when necessary.

class HasChangedMixin(object):
    """ this mixin gives subclasses the ability to set fields for which they want to monitor if the field value changes """
    monitor_fields = []

    def __init__(self, *args, **kwargs):
        super(HasChangedMixin, self).__init__(*args, **kwargs)
        self.field_trackers = {}

    def __setattr__(self, key, value):
        super(HasChangedMixin, self).__setattr__(key, value)
        if key in self.monitor_fields and key not in self.field_trackers:
            self.field_trackers[key] = value

    def changed_fields(self):
        """
        :return: `list` of `str` the names of all monitor_fields which have changed
        """
        changed_fields = []
        for field, initial_field_val in self.field_trackers.items():
            if getattr(self, field) != initial_field_val:
                changed_fields.append(field)

        return changed_fields
Bobby
  • 6,840
  • 1
  • 22
  • 25
  • Loving this implementation, efficient and simple to add to any model which may required this :) – Hassek Feb 07 '23 at 14:27
4

In modern Django, there is a matter of great importance to add to the content of the answer accepted among the above answers. You can fall into an infinite recursion when you use defer or only QuerySet API.

__get__() method of django.db.models.query_utils.DeferredAttribute calls refresh_from_db() method of django.db.models.Model. There is a line db_instance = db_instance_qs.get() in refresh_from_db(), and this line calls __init__() method of the instance recursively.

So, it is necessary to add ensuring that the target attributes are not deferred.

def __init__(self, *args, **kwargs):
    super(MyClass, self).__init__(*args, **kwargs)

    deferred_fields = self.get_deferred_fields()
    important_fields = ['target_type', 'target_id', 'target_object', 'number', 'chain', 'expiration_date']

    self.__important_fields = list(filter(lambda x: x not in deferred_fields, important_fields))
    for field in self.__important_fields:
        setattr(self, '__original_%s' % field, getattr(self, field))
Youngkwang Kim
  • 168
  • 1
  • 1
  • 6
2

Here is an app that gives you access to previous and current value of a field right before model will be saved: django-smartfields

Here is how this problem can be solved in a nice declarative may:

from django.db import models
from smartfields import fields, processors
from smartfields.dependencies import Dependency

class ConditionalProcessor(processors.BaseProcessor):

    def process(self, value, stashed_value=None, **kwargs):
        if value != stashed_value:
            # do any necessary modifications to new value
            value = ... 
        return value

class MyModel(models.Model):
    my_field = fields.CharField(max_length=10, dependencies=[
        Dependency(processor=ConditionalProcessor())
    ])

Moreover, this processor will be invoked, only in case that field's value was replaced

lehins
  • 9,642
  • 2
  • 35
  • 49
2

I agree with Sahil that it is better and easier to do this with ModelForm. However, you would customize the ModelForm's clean method and perform validation there. In my case, I wanted to prevent updates to a model's instance if a field on the model is set.

My code looked like this:

from django.forms import ModelForm

class ExampleForm(ModelForm):
    def clean(self):
        cleaned_data = super(ExampleForm, self).clean()
        if self.instance.field:
            raise Exception
        return cleaned_data
erika_dike
  • 251
  • 4
  • 7
1

Another way to achieve this is using the post_init and post_save signals to store the initial state of the model.

@receiver(models.signals.post_init)
@receiver(models.signals.post_save)
def _set_initial_state(
    sender: Type[Any],
    instance: Optional[models.Model] = None,
    **kwargs: Any,
) -> None:
    """
    Store the initial state of the model
    """

    if isinstance(instance, MyModel):
        instance._initial_state = instance.state

Where state is the name of a field in MyModel, with _initial_state being the initial version, copied when the modal is initialised/saved.

Be aware if state is a container type (e.g. a dict), you may wish to use deepcopy as appropriate.

Danielle Madeley
  • 2,616
  • 1
  • 19
  • 26
  • I just tried this approach, but I got an error saying that instance has no `state` attribute. Did you mean `instance._state`? Either way, how do you then access the initial field values? `instance._state` doesn't appear to store these. – JGC Dec 29 '21 at 20:38
  • `state` is the name of the variable you wish to save. `_initial_state` is the saved copy. Replace with whatever variable name is appropriate. – Danielle Madeley Dec 31 '21 at 02:39
  • I get an error with your `instance._initial_state = instance.state` because `instance.state` does not exist. I get an error saying "instance has no state attribute". – JGC Jan 02 '22 at 21:23
  • Is `state` a field in your model? – Danielle Madeley Jan 03 '22 at 22:03
  • No. Do you mean your example assumes `state` is the model field, and `_initial_state` is the copy for the initial value? That explains everything. I assumed that `state` was a Django built-in variable containing the state of the model. – JGC Jan 04 '22 at 21:39
  • 1
    Correct, `state` is the name of a field in your model. – Danielle Madeley Jan 05 '22 at 02:56
0

Here is how I do. comparing field 'state' for example. and checking permission against user.

admin.py

    def save_model(self, request, obj, form, change):
    if change is False:
        obj.created_by = request.user
    else:
        # check if field_1 is being updated
        if obj._loaded_values['state'] != obj.state and not request.user.has_perm('mtasks.change_status', obj):
            messages.set_level(request, messages.ERROR)
            messages.error(request, "You don't have permission to change state")
            return
        
    super().save_model(request, obj, form, change)

in models.py

class ClassName
    ...    
    @classmethod
    def from_db(cls, db, field_names, values):
        instance = super().from_db(db, field_names, values)
        # save original values, when model is loaded from database,
        instance._loaded_values = dict(zip(field_names, values))    
        return instance
AndyC
  • 109
  • 2
  • 4