diff --git a/AUTHORS.rst b/AUTHORS.rst index 226d27a04..ba25a7aec 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -35,6 +35,7 @@ Authors - Kris Neuharth - Maciej "RooTer" UrbaƄski - Mark Davidoff +- Leticia Portella - Martin Bachwerk - Marty Alchin - Mauricio de Abreu Antunes diff --git a/CHANGES.rst b/CHANGES.rst index 05f46b626..affd5868c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -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) diff --git a/docs/advanced.rst b/docs/advanced.rst index 261f157f3..4d0ad7fb5 100644 --- a/docs/advanced.rst +++ b/docs/advanced.rst @@ -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) + for change in delta.changes: + print("{} changed from {} to {}".format(change.field, change.old, change.new)) \ No newline at end of file diff --git a/simple_history/models.py b/simple_history/models.py index 629fcaf14..3f427bcaa 100644 --- a/simple_history/models.py +++ b/simple_history/models.py @@ -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.") @@ -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 diff --git a/simple_history/tests/tests/test_models.py b/simple_history/tests/tests/test_models.py index dfa9ac17b..4ffd48597 100644 --- a/simple_history/tests/tests/test_models.py +++ b/simple_history/tests/tests/test_models.py @@ -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 ( @@ -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):