Skip to content

Add FieldTracker.diff #635

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS.rst
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
| João Amaro <[email protected]>
| Karl WnW <[email protected]>
| Keryn Knight <[email protected]>
| Lucas Rangel Cezimbra <[email protected]>
| Lucas Wiman <[email protected]>
| Martey Dodoo <[email protected]>
| Matthew Schinckel <[email protected]>
Expand Down
1 change: 1 addition & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ Changelog

To be released
------------------
- Add `FieldTracker.diff` by @lucasrcezimbra
- Add support for `Python 3.13` (GH-#628)

5.0.0 (2024-09-01)
Expand Down
16 changes: 16 additions & 0 deletions docs/utilities.rst
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,22 @@ and the values of the fields during the last save:
The ``changed`` method relies on ``has_changed`` to determine which fields
have changed.

diff
~~~~~~~
Returns a dictionary of all fields that have been changed since the last save
with the previous and current values:

.. code-block:: pycon

>>> a = Post.objects.create(title='First Post')
>>> a.title = 'Welcome'
>>> a.body = 'First post!'
>>> a.tracker.diff()
{'title': ('First Post', 'Welcome'), 'body': ('', 'First post!')}

The ``diff`` method relies on ``has_changed`` to determine which fields
have changed.


Tracking specific fields
------------------------
Expand Down
8 changes: 8 additions & 0 deletions model_utils/tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,14 @@ def changed(self) -> dict[str, Any]:
if self.has_changed(field)
}

def diff(self):
"""Returns dict with the diff of the fields that changed since save"""
return {
field: (self.previous(field), self.get_field_value(field))
for field in self.fields
if self.has_changed(field)
}


class FieldTracker:

Expand Down
45 changes: 45 additions & 0 deletions tests/test_fields/test_field_tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ def assertChanged(self, *, tracker: FieldInstanceTracker | None = None, **kwargs
tracker = self.tracker
self.assertEqual(tracker.changed(), kwargs)

def assertDiff(self, *, tracker: FieldInstanceTracker | None = None, **kwargs: Any) -> None:
if tracker is None:
tracker = self.tracker
self.assertEqual(tracker.diff(), kwargs)

def assertCurrent(self, *, tracker: FieldInstanceTracker | None = None, **kwargs: Any) -> None:
if tracker is None:
tracker = self.tracker
Expand Down Expand Up @@ -110,6 +115,17 @@ def test_pre_save_changed(self) -> None:
self.instance.mutable = [1, 2, 3]
self.assertChanged(name=None, number=None, mutable=None)

def test_pre_save_diff(self) -> None:
self.assertDiff(name=(None, ''))
self.instance.name = 'new age'
self.assertDiff(name=(None, 'new age'))
self.instance.number = 8
self.assertDiff(name=(None, 'new age'), number=(None, 8))
self.instance.name = ''
self.assertDiff(name=(None, ''), number=(None, 8))
self.instance.mutable = [1, 2, 3]
self.assertDiff(name=(None, ''), number=(None, 8), mutable=(None, [1, 2, 3]))

def test_pre_save_has_changed(self) -> None:
self.assertHasChanged(name=True, number=False, mutable=False)
self.instance.name = 'new age'
Expand All @@ -123,25 +139,29 @@ def test_save_with_args(self) -> None:
self.instance.number = 1
self.instance.save(False, False, None, None)
self.assertChanged()
self.assertDiff()

def test_first_save(self) -> None:
self.assertHasChanged(name=True, number=False, mutable=False)
self.assertPrevious(name=None, number=None, mutable=None)
self.assertCurrent(name='', number=None, id=None, mutable=None)
self.assertChanged(name=None)
self.assertDiff(name=(None, ''))
self.instance.name = 'retro'
self.instance.number = 4
self.instance.mutable = [1, 2, 3]
self.assertHasChanged(name=True, number=True, mutable=True)
self.assertPrevious(name=None, number=None, mutable=None)
self.assertCurrent(name='retro', number=4, id=None, mutable=[1, 2, 3])
self.assertChanged(name=None, number=None, mutable=None)
self.assertDiff(name=(None, 'retro'), number=(None, 4), mutable=(None, [1, 2, 3]))

self.instance.save(update_fields=[])
self.assertHasChanged(name=True, number=True, mutable=True)
self.assertPrevious(name=None, number=None, mutable=None)
self.assertCurrent(name='retro', number=4, id=None, mutable=[1, 2, 3])
self.assertChanged(name=None, number=None, mutable=None)
self.assertDiff(name=(None, 'retro'), number=(None, 4), mutable=(None, [1, 2, 3]))
with self.assertRaises(ValueError):
self.instance.save(update_fields=['number'])

Expand Down Expand Up @@ -178,6 +198,20 @@ def test_post_save_changed(self) -> None:
self.instance.mutable = [1, 2, 3]
self.assertChanged(number=4)

def test_post_save_diff(self) -> None:
self.update_instance(name='retro', number=4, mutable=[1, 2, 3])
self.assertDiff()
self.instance.name = 'new age'
self.assertDiff(name=('retro', 'new age'))
self.instance.number = 8
self.assertDiff(name=('retro', 'new age'), number=(4, 8))
self.instance.name = 'retro'
self.assertDiff(number=(4, 8))
self.instance.mutable[1] = 4
self.assertDiff(number=(4, 8), mutable=([1, 2, 3], [1, 4, 3]))
self.instance.mutable = [1, 2, 3]
self.assertDiff(number=(4, 8))

def test_current(self) -> None:
self.assertCurrent(id=None, name='', number=None, mutable=None)
self.instance.name = 'new age'
Expand All @@ -194,21 +228,27 @@ def test_current(self) -> None:
def test_update_fields(self) -> None:
self.update_instance(name='retro', number=4, mutable=[1, 2, 3])
self.assertChanged()
self.assertDiff()
self.instance.name = 'new age'
self.instance.number = 8
self.instance.mutable = [4, 5, 6]
self.assertChanged(name='retro', number=4, mutable=[1, 2, 3])
self.assertDiff(name=('retro', 'new age'), number=(4, 8), mutable=([1, 2, 3], [4, 5, 6]))
self.instance.save(update_fields=[])
self.assertChanged(name='retro', number=4, mutable=[1, 2, 3])
self.assertDiff(name=('retro', 'new age'), number=(4, 8), mutable=([1, 2, 3], [4, 5, 6]))
self.instance.save(update_fields=['name'])
in_db = self.tracked_class.objects.get(id=self.instance.id)
self.assertEqual(in_db.name, self.instance.name)
self.assertNotEqual(in_db.number, self.instance.number)
self.assertChanged(number=4, mutable=[1, 2, 3])
self.assertDiff(number=(4, 8), mutable=([1, 2, 3], [4, 5, 6]))
self.instance.save(update_fields=['number'])
self.assertChanged(mutable=[1, 2, 3])
self.assertDiff(mutable=([1, 2, 3], [4, 5, 6]))
self.instance.save(update_fields=['mutable'])
self.assertChanged()
self.assertDiff()
in_db = self.tracked_class.objects.get(id=self.instance.id)
self.assertEqual(in_db.name, self.instance.name)
self.assertEqual(in_db.number, self.instance.number)
Expand All @@ -219,16 +259,21 @@ def test_refresh_from_db(self) -> None:
self.tracked_class.objects.filter(pk=self.instance.pk).update(
name='new age', number=8, mutable=[3, 2, 1])
self.assertChanged()
self.assertDiff()
self.instance.name = 'like in db'
self.instance.number = 8
self.instance.mutable = [3, 2, 1]
self.assertChanged(name='retro', number=4, mutable=[1, 2, 3])
self.assertDiff(name=('retro', 'like in db'), number=(4, 8), mutable=([1, 2, 3], [3, 2, 1]))
self.instance.refresh_from_db(fields=('name',))
self.assertChanged(number=4, mutable=[1, 2, 3])
self.assertDiff(number=(4, 8), mutable=([1, 2, 3], [3, 2, 1]))
self.instance.refresh_from_db(fields={'mutable'})
self.assertChanged(number=4)
self.assertDiff(number=(4, 8))
self.instance.refresh_from_db()
self.assertChanged()
self.assertDiff()

def test_with_deferred(self) -> None:
self.instance.name = 'new age'
Expand Down