Skip to content

Implement the ability to diff HistoricalRecords #416

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

Merged
merged 10 commits into from
Jul 11, 2018
Merged
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 @@ -35,6 +35,7 @@ Authors
- Kris Neuharth
- Maciej "RooTer" Urbański
- Mark Davidoff
- Leticia Portella
- Martin Bachwerk
- Marty Alchin
- Mauricio de Abreu Antunes
Expand Down
4 changes: 4 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
Changes
=======

Unreleased
-----------------
- Add ability to diff HistoricalRecords (gh-244)

2.2.0 (2018-07-02)
------------------
- Add ability to specify alternative user_model for tracking (gh-371)
Expand Down
23 changes: 23 additions & 0 deletions docs/advanced.rst
Original file line number Diff line number Diff line change
Expand Up @@ -406,3 +406,26 @@ If you want to save a model without a historical record, you can use the followi

poll = Poll(question='something')
poll.save_without_historical_record()

History Diffing
-------------------

When you have two instances of the same ``HistoricalRecord`` (such as the ``HistoricalPoll`` example above),
you can perform diffs to see what changed. This will result in a ``ModelDelta`` containing the following properties:

1. A list with each field changed between the two historical records
2. A list with the names of all fields that incurred changes from one record to the other
3. the old and new records.

This may be useful when you want to construct timelines and need to get only the model modifications.

.. code-block:: python

p = Poll.objects.create(question="what's up?")
p.question = "what's up, man?"
p.save()

new_record, old_record = p.history.all()
delta = new_record.diff_against(old_record)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might want to add some more code examples that show what that delta object contains – I wouldn't know what it has without looking through the code or inspecting the instance

for change in delta.changes:
print("{} changed from {} to {}".format(change.field, change.old, change.new))
43 changes: 42 additions & 1 deletion simple_history/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ def __init__(self, verbose_name=None, bases=(models.Model,),
try:
if isinstance(bases, six.string_types):
raise TypeError
self.bases = tuple(bases)
self.bases = (HistoricalChanges,) + tuple(bases)
except TypeError:
raise TypeError("The `bases` option must be a list or a tuple.")

Expand Down Expand Up @@ -412,3 +412,44 @@ def __get__(self, instance, owner):
values = (getattr(instance, f.attname)
for f in self.fields_included)
return self.model(*values)


class HistoricalChanges(object):
def diff_against(self, old_history):
if not isinstance(old_history, type(self)):
raise TypeError(("unsupported type(s) for diffing: "
"'{}' and '{}'").format(
type(self),
type(old_history)))

changes = []
changed_fields = []
for field in self._meta.fields:
if hasattr(self.instance, field.name) and \
hasattr(old_history.instance, field.name):
old_value = getattr(old_history, field.name, '')
new_value = getattr(self, field.name)
if old_value != new_value:
change = ModelChange(field.name, old_value, new_value)
changes.append(change)
changed_fields.append(field.name)

return ModelDelta(changes,
changed_fields,
old_history,
self)


class ModelChange(object):
def __init__(self, field_name, old_value, new_value):
self.field = field_name
self.old = old_value
self.new = new_value


class ModelDelta(object):
def __init__(self, changes, changed_fields, old_record, new_record):
self.changes = changes
self.changed_fields = changed_fields
self.old_record = old_record
self.new_record = new_record
36 changes: 35 additions & 1 deletion simple_history/tests/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@
from django.db.models.fields.proxy import OrderWrt
from django.test import TestCase

from simple_history.models import HistoricalRecords, convert_auto_field
from simple_history.models import (
HistoricalRecords,
convert_auto_field,
ModelChange
)
from simple_history.utils import update_change_reason
from ..external.models import ExternalModel2, ExternalModel4
from ..models import (
Expand Down Expand Up @@ -549,6 +553,36 @@ def test_get_next_record_none_if_most_recent(self):
recent_record = poll.history.filter(question="ask questions?").get()
self.assertIsNone(recent_record.next_record)

def test_history_diff_includes_changed_fields(self):
p = Poll.objects.create(question="what's up?", pub_date=today)
p.question = "what's up, man?"
p.save()
new_record, old_record = p.history.all()
delta = new_record.diff_against(old_record)
expected_change = ModelChange("question",
"what's up?",
"what's up, man")
self.assertEqual(delta.changed_fields, ['question'])
self.assertEqual(delta.old_record, old_record)
self.assertEqual(delta.new_record, new_record)
self.assertEqual(expected_change.field, delta.changes[0].field)

def test_history_diff_does_not_include_unchanged_fields(self):
p = Poll.objects.create(question="what's up?", pub_date=today)
p.question = "what's up, man?"
p.save()
new_record, old_record = p.history.all()
delta = new_record.diff_against(old_record)
self.assertNotIn('pub_date', delta.changed_fields)

def test_history_diff_with_incorrect_type(self):
p = Poll.objects.create(question="what's up?", pub_date=today)
p.question = "what's up, man?"
p.save()
new_record, old_record = p.history.all()
with self.assertRaises(TypeError):
new_record.diff_against('something')


class CreateHistoryModelTests(unittest.TestCase):

Expand Down