Skip to content

Instantly share code, notes, and snippets.

@jurrian
Last active January 30, 2023 11:45
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jurrian/80d71ac998a6a301b455d730d6199722 to your computer and use it in GitHub Desktop.
Save jurrian/80d71ac998a6a301b455d730d6199722 to your computer and use it in GitHub Desktop.
DiffMixin for Django models
class DiffMixin:
"""Detect changes on a model instance after saving or deleting.
The original will be retrieved from the db, so any unrelated added attributes are not taken into account.
It uses save() and delete() as "hooks", but can also be triggered manually using copy_original():
```
class SomeModel(DiffMixin, models.Model):
some_attribute = models.IntegerField()
instance = SomeModel() # Should inherit DiffMixin
instance.some_attribute = 1
instance.copy_original()
instance.some_attribute = 2
assert instance.is_changed('some_attribute') is True
```
"""
def __init__(self, *args, **kwargs):
"""When initializing a model instance, marking _original=None means copy_original
did not run yet, making any comparison at this point is futile.
"""
self._original = None
super().__init__(*args, **kwargs)
def save(self, *args, **kwargs):
"""Keep the original object dict before saving.
"""
self.copy_original()
return super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
"""Keep the original object dict before deleting.
"""
self.copy_original()
return super().delete(*args, **kwargs)
def copy_original(self):
"""Get the original from db and keep is as _original.
This is the only reliable way to know what the original was when self
already contains the new values.
"""
try:
self._original = self.__class__._default_manager.db_manager().get(pk=self.pk)
except ObjectDoesNotExist:
# Some instances are saved for first time or do never exist in db, like AnonymousUser
self._original = object() # Use generic object so getattr will still work
def is_changed(self, attribute_name):
"""Returns True if attribute_name differs between _original and _dict.
This *only works after* copy_original is called, else it will return None.
Returns:
True or False: if the attribute_name has changed.
None: when no _original exists, because copy_original was not called
or when both current_value and original_value do not exist.
"""
if self._original is None:
return None
empty_flag = object() # Values could actually be None so use a flag instead
current_value = getattr(self, attribute_name, empty_flag)
original_value = getattr(self._original, attribute_name, empty_flag)
if current_value == empty_flag and original_value == empty_flag:
return None
return current_value != original_value
# pylint: disable=protected-access
from datetime import datetime
import pytest
from django.utils import timezone
# These factoryboy factories are not included in gist!
# Replace them by your own Model.objects.create()
from products.factories.products import ArticleFactory
from profiles.factories.user import UserFactory
def test_diffmixin__save(db):
"""User initializes with _original=None and after save() it should be empty dict as it didn't exist before.
"""
_ = db # db used for its side-effects - quash unused argument complaints
user = UserFactory.build() # User inherits WMModel which inherits DiffMixin
assert user._original is None
user.save()
assert type(user._original) == object
user.save()
assert user._original.pk == user.pk
def test_diffmixin__delete(db):
"""When model instance is deleted the original `id` is maintained in _original.
"""
_ = db # db used for its side-effects - quash unused argument complaints
user = UserFactory.create(first_name='test')
user_pk = user.pk
assert user._original.pk == user_pk
user.delete()
user.first_name = ''
assert user._original.pk == user_pk
assert user._original.first_name == 'test'
assert user.pk is None
def test_diffmixin__copy_original(db):
"""Calling copy_original directly should do same as save or delete.
"""
_ = db # db used for its side-effects - quash unused argument complaints
user = UserFactory.create()
user._original = None # Simulate save didn't happen yet
user.copy_original()
assert user._original.pk == user.pk
@pytest.mark.parametrize(
'before, after', [
('123', '123'),
('123', '456'),
('123', ''),
('123', None),
('', ''),
(None, None),
]
)
def test_diffmixin__is_changed(db, before, after):
"""Compare different values that differ in db and local.
"""
_ = db # db used for its side-effects - quash unused argument complaints
user = ArticleFactory.create(gtin_consumer_unit=before)
user.gtin_consumer_unit = after
user.save()
assert user.is_changed('gtin_consumer_unit') is (before != after)
def test_diffmixin__is_changed__non_field(db):
"""Non-field some_attribute does not exist in db but is None on instance.
Even when None this is a difference as it does not exist in db.
"""
_ = db # db used for its side-effects - quash unused argument complaints
user = UserFactory.create()
user.some_attribute = None
assert user.is_changed('some_attribute') is True
def test_diffmixin__is_changed__none(db):
"""If copy_original has not been called should return None.
"""
_ = db # db used for its side-effects - quash unused argument complaints
user = UserFactory.build(first_name='john')
user.first_name = 'jack'
assert user.is_changed('first_name') is None
def test_diffmixin__is_changed__empty_flag(db):
"""If the attribute in is_changed does not exist both on the current object
and original it should return None.
"""
_ = db # db used for its side-effects - quash unused argument complaints
user = UserFactory.create()
assert user.is_changed('not_existing') is None
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment