Skip to content

Instantly share code, notes, and snippets.

@dcramer
Created December 6, 2010 19:15
Show Gist options
  • Star 49 You must be signed in to star a gist
  • Fork 16 You must be signed in to fork a gist
  • Save dcramer/730765 to your computer and use it in GitHub Desktop.
Save dcramer/730765 to your computer and use it in GitHub Desktop.
Tracking changes on properties in Django
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
@cb109
Copy link

cb109 commented Jul 19, 2022

@bradreardon Thanks for the hint! Was running into lots of RecursionError when trying to upgrade Django from 2.2 to 3.2. Ignoring deferred fields inside the _store() function helped, but some were still left. I am still trying to figure out why.

EDIT: This ticket seems relevant https://code.djangoproject.com/ticket/31435 as does this commit that is mentioned in there django/django@f110de5.

As a temporary workaround, to get into a workable state, I used this decorator to stop infinite recursions (applied on top of the _store() function):

import inspect
import traceback
from typing import Callable

def stop_recursion(function: Callable):
    """Decorator to stop recursion of given function early.

    Avoids 'RecursionError: maximum recursion depth exceeded' if
    preventing the cause of the recursion is not possible.

    Based on: https://stackoverflow.com/a/7900380

    """

    def inner(*args, **kwargs):
        function_name = function.__name__
        function_did_call_itself = (
            len(
                [
                    stack_function_name
                    for (
                        filename,
                        lineno,
                        stack_function_name,
                        text,
                    ) in traceback.extract_stack()
                    if stack_function_name == function_name
                ]
            )
            > 0
        )
        if function_did_call_itself:
            module_name = inspect.getmodule(function).__name__
            print(
                f"Undesired recursion detected, stopping: "
                f"{module_name}::{function_name}()"
            )
            return  # Just do nothing to avoid RecursionError.
        return function(*args, **kwargs)

    return inner

UPDATE: I think I have found a slightly better solution at least for our setup. The problem in general seems to be that upon deletion of a model instance, Django may create a QuerySet of related objects using .only() aka with deferred fields. Evaluation that inside a boolean context will fetch that QuerySet from the database, triggering an infinite recursion of model instance initialization and track_data() calls. This can however be detected by looking at the args of the __init__() function, if those contain Deferred values, we likely don't want to try and store the current state (as on deletion we don't care about field changes anymore).

My approach now looks like this (overriding __init__() instead of handling the post_init signal):

from django.db.models.base import DEFERRED

def track_data(*fields):
    ...

    def inner(cls):
        ...

        def new_init(self, *args, **kwargs):
            has_deferred_fields = DEFERRED in args
            new_init._original_init(self, *args, **kwargs)
            if not has_deferred_fields:
                _store(self)

        new_init._original_init = cls.__init__
        cls.__init__ = new_init

        ...
    
    ...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment