-
-
Save dcramer/730765 to your computer and use it in GitHub Desktop.
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 |
FWIW, here is a post about this gist: http://justcramer.com/2010/12/06/tracking-changes-to-fields-in-django/
I had some problems with it using @classmethod
and post_save
as described above.
I got it to work by defining it as a non class function and registering it as a post_save
receiver on my model.
Also the line #52:
changed[k] = v
should be:
changed[k] = getattr(self, k)
in order to get the new value in the instance.whats_changed()
dictionary. The old one is available via instance.old_value(...)
.
in order to get the new value in the instance.whats_changed() dictionary. The old one is available via instance.old_value(...).
And the new value is available through instance.field
directly. So all you need, actually, are the key values of what changed. Anyhow, depends on how you use the method.
For anyone finding this years later, I ran into an issue where infinite recursion would occur when deleting instances of "tracked" models in the Django admin. I believe this was due to the fact that the Django admin uses deferred fields in this case, which causes an infinite loop during the post_init signal.
I've fixed that in my fork of the gist here by modifying _store to ignore deferred fields: https://gist.github.com/bradreardon/0427aec70733494976ff8b64c73a60ed
@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
...
...
It would be great to add to the decorator model inheritance support: