Skip to content

Commit 35b6028

Browse files
kseeverRoss Mechanic
authored andcommitted
Implement the ability to diff HistoricalRecords (#416)
* Implement the ability to diff HistoricalRecords (Credit goes to leportella for the initial commit) * Fixup documentation * More documentation sprucing * Remove vestigial operand terminology * Add to CHANGES.rst * Remove support for configurable modeL_delta_class * advanced.rst formatting * add test for unchanged fields in delta * documentation formatting * address linter issues
1 parent 174922a commit 35b6028

File tree

5 files changed

+105
-2
lines changed

5 files changed

+105
-2
lines changed

AUTHORS.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ Authors
3535
- Kris Neuharth
3636
- Maciej "RooTer" Urbański
3737
- Mark Davidoff
38+
- Leticia Portella
3839
- Martin Bachwerk
3940
- Marty Alchin
4041
- Mauricio de Abreu Antunes

CHANGES.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
Changes
22
=======
33

4+
Unreleased
5+
-----------------
6+
- Add ability to diff HistoricalRecords (gh-244)
7+
48
2.2.0 (2018-07-02)
59
------------------
610
- Add ability to specify alternative user_model for tracking (gh-371)

docs/advanced.rst

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,3 +406,26 @@ If you want to save a model without a historical record, you can use the followi
406406
407407
poll = Poll(question='something')
408408
poll.save_without_historical_record()
409+
410+
History Diffing
411+
-------------------
412+
413+
When you have two instances of the same ``HistoricalRecord`` (such as the ``HistoricalPoll`` example above),
414+
you can perform diffs to see what changed. This will result in a ``ModelDelta`` containing the following properties:
415+
416+
1. A list with each field changed between the two historical records
417+
2. A list with the names of all fields that incurred changes from one record to the other
418+
3. the old and new records.
419+
420+
This may be useful when you want to construct timelines and need to get only the model modifications.
421+
422+
.. code-block:: python
423+
424+
p = Poll.objects.create(question="what's up?")
425+
p.question = "what's up, man?"
426+
p.save()
427+
428+
new_record, old_record = p.history.all()
429+
delta = new_record.diff_against(old_record)
430+
for change in delta.changes:
431+
print("{} changed from {} to {}".format(change.field, change.old, change.new))

simple_history/models.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ def __init__(self, verbose_name=None, bases=(models.Model,),
5353
try:
5454
if isinstance(bases, six.string_types):
5555
raise TypeError
56-
self.bases = tuple(bases)
56+
self.bases = (HistoricalChanges,) + tuple(bases)
5757
except TypeError:
5858
raise TypeError("The `bases` option must be a list or a tuple.")
5959

@@ -412,3 +412,44 @@ def __get__(self, instance, owner):
412412
values = (getattr(instance, f.attname)
413413
for f in self.fields_included)
414414
return self.model(*values)
415+
416+
417+
class HistoricalChanges(object):
418+
def diff_against(self, old_history):
419+
if not isinstance(old_history, type(self)):
420+
raise TypeError(("unsupported type(s) for diffing: "
421+
"'{}' and '{}'").format(
422+
type(self),
423+
type(old_history)))
424+
425+
changes = []
426+
changed_fields = []
427+
for field in self._meta.fields:
428+
if hasattr(self.instance, field.name) and \
429+
hasattr(old_history.instance, field.name):
430+
old_value = getattr(old_history, field.name, '')
431+
new_value = getattr(self, field.name)
432+
if old_value != new_value:
433+
change = ModelChange(field.name, old_value, new_value)
434+
changes.append(change)
435+
changed_fields.append(field.name)
436+
437+
return ModelDelta(changes,
438+
changed_fields,
439+
old_history,
440+
self)
441+
442+
443+
class ModelChange(object):
444+
def __init__(self, field_name, old_value, new_value):
445+
self.field = field_name
446+
self.old = old_value
447+
self.new = new_value
448+
449+
450+
class ModelDelta(object):
451+
def __init__(self, changes, changed_fields, old_record, new_record):
452+
self.changes = changes
453+
self.changed_fields = changed_fields
454+
self.old_record = old_record
455+
self.new_record = new_record

simple_history/tests/tests/test_models.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@
1212
from django.db.models.fields.proxy import OrderWrt
1313
from django.test import TestCase
1414

15-
from simple_history.models import HistoricalRecords, convert_auto_field
15+
from simple_history.models import (
16+
HistoricalRecords,
17+
convert_auto_field,
18+
ModelChange
19+
)
1620
from simple_history.utils import update_change_reason
1721
from ..external.models import ExternalModel2, ExternalModel4
1822
from ..models import (
@@ -549,6 +553,36 @@ def test_get_next_record_none_if_most_recent(self):
549553
recent_record = poll.history.filter(question="ask questions?").get()
550554
self.assertIsNone(recent_record.next_record)
551555

556+
def test_history_diff_includes_changed_fields(self):
557+
p = Poll.objects.create(question="what's up?", pub_date=today)
558+
p.question = "what's up, man?"
559+
p.save()
560+
new_record, old_record = p.history.all()
561+
delta = new_record.diff_against(old_record)
562+
expected_change = ModelChange("question",
563+
"what's up?",
564+
"what's up, man")
565+
self.assertEqual(delta.changed_fields, ['question'])
566+
self.assertEqual(delta.old_record, old_record)
567+
self.assertEqual(delta.new_record, new_record)
568+
self.assertEqual(expected_change.field, delta.changes[0].field)
569+
570+
def test_history_diff_does_not_include_unchanged_fields(self):
571+
p = Poll.objects.create(question="what's up?", pub_date=today)
572+
p.question = "what's up, man?"
573+
p.save()
574+
new_record, old_record = p.history.all()
575+
delta = new_record.diff_against(old_record)
576+
self.assertNotIn('pub_date', delta.changed_fields)
577+
578+
def test_history_diff_with_incorrect_type(self):
579+
p = Poll.objects.create(question="what's up?", pub_date=today)
580+
p.question = "what's up, man?"
581+
p.save()
582+
new_record, old_record = p.history.all()
583+
with self.assertRaises(TypeError):
584+
new_record.diff_against('something')
585+
552586

553587
class CreateHistoryModelTests(unittest.TestCase):
554588

0 commit comments

Comments
 (0)