Last active
January 30, 2023 11:45
-
-
Save jurrian/80d71ac998a6a301b455d730d6199722 to your computer and use it in GitHub Desktop.
DiffMixin for Django models
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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