Skip to content

Added Model Delta and HistoricalChanges #277

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

Closed
wants to merge 1 commit into from
Closed
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 @@ -21,6 +21,7 @@ Authors
- Jonathan Sanchez
- Josh Fyne
- Klaas van Schelven
- Leticia Portella
- Martin Bachwerk
- Marty Alchin
- Mauricio de Abreu Antunes
Expand Down
19 changes: 19 additions & 0 deletions docs/advanced.rst
Original file line number Diff line number Diff line change
Expand Up @@ -213,3 +213,22 @@ You can use the ``table_name`` parameter with both ``HistoricalRecords()`` or
pub_date = models.DateTimeField('date published')

register(Question, table_name='polls_question_history')


History Subtraction
-------------------

When you have two instances of the same HistoricalRecord, such as HistoricalPoll example above,
you can make subtractions. This will result in a ModelDelta containing some properties such as a
dictionary with each field changed between this historical records, a list with all
fields that suffered changes from one record to another and the date and user from the last change.
This may be useful when you want 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 - old_record
45 changes: 44 additions & 1 deletion simple_history/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,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 @@ -311,3 +311,46 @@ def __get__(self, instance, owner):
values = (getattr(instance, f.attname)
for f in self.model._meta.fields)
return self.model(*values)


class HistoricalChanges(models.Model):
Copy link
Contributor

Choose a reason for hiding this comment

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

I think tuple(bases) will already inherit from models.Model, so maybe this should be more of a mixin that just inherits from object. I'm thinking something like this:

class HistoricalChangesMixin(object):

Then the class Meta: abstract = True wouldn't be needed.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Nice! I think this will be better as well


class Meta:
abstract = True

def __sub__(self, old_history):
Copy link
Contributor

Choose a reason for hiding this comment

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

My C++ background left me with a bad taste in my mouth for operator overloads. I tend to favor explicit method calls.

delta = current.changesSince(prior)

if not isinstance(old_history, type(self)):
raise TypeError(("unsupported operand type(s) for -: "
"'{}' and '{}'").format(
type(self),
type(old_history)))
model_delta_class = getattr(self, 'model_delta_class', ModelDelta)
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:
changes[field.name] = {'old_value': old_value,
Copy link
Contributor

Choose a reason for hiding this comment

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

I think the API might be a little easier to use if this were a namedtuple instead of a dictionary, so changes could be unpacked like this:

for old, new in changes:
    pass  # something

Not sure what the name and attributes should be. The name could be long but the attributes might be nice to keep short. Maybe namedtuple('HistoricalChange', ['old', 'new']). Or ModelChange.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Don't know what nametuple are. I will look into this and get back at you!

Copy link
Contributor

@treyhunner treyhunner Jun 13, 2017

Choose a reason for hiding this comment

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

Great!

namedtuple allows making a tuple-like class on the fly, like this:

>>> from collections import namedtuple
>>> HistoricalChange = namedtuple('HistoricalChange', ['old', 'new'])
>>> change = HistoricalChange(old=5, new=6)
>>> change.old
5
>>> change.new
6
>>> old, new = change
>>> old
5
>>> new
6

(I'm not saying we should necessarily use those names as the name/attributes... just an example)

'new_value': new_value}
changed_fields.append(field.name)
return model_delta_class(changes,
changed_fields=changed_fields,
date=self.history_date,
Copy link
Contributor

Choose a reason for hiding this comment

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

Should date by self.history_date or old_history.history_date? Is one date more significant than the other or should we represent them both? Or maybe represent neither since self and old_history can be found via the new_history and old_history attributes?

I haven't given this much thought, but I'd like to hear an argument for one of these variations.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Well, on the case I applied here in our company we used this subtractions to build timelines. Since the history is stored as soon as the model changes, I added the user and date from the last historical record. But indeed this was a very specific case. Do you think we should add both?

Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe we should add neither for now since new_history and old_history allow fetching each individually.

We can always add them later if we decide there's a good use case for copying them over to make them more easily accessible.

Copy link

@merwok merwok Nov 7, 2017

Choose a reason for hiding this comment

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

Random idea from a simple_history user: What about returning a timedelta?

user=self.history_user,
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure whether this should be the old or new user either (or whether one is more important than the other). Maybe this attribute isn't needed? I'm up for hearing arguments either way.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Well, as I said on the other comments, we used this to build timelines, so the user and date that the instance changed was very relevant. Maybe we should keep both.

Copy link
Contributor

Choose a reason for hiding this comment

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

I think maybe we should do neither here as well. If it becomes burdensome to type my_delta.new_record.user and my_delta.old_record.user we can make shortcuts later.

I'm open to re-considering and adding this and/or the historical date feature, but I'd like to not add them unless we're sure they're particularly helpful. One less feature to maintain? 😄

old_history=old_history,
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure what we should call these (old_history/new_history, old_record/new_record, old/new, etc.). I'd prefer to be consistent, but I'm not sure whether we've made variable names referring to these objects yet.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I prefer old_record/newrecord because we are considering each instance and just history is not detailed enough indeed. I think that just old/new can be quite confusing.

Copy link
Contributor

Choose a reason for hiding this comment

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

That makes sense to me. Let's go with old_record and new_record then.

new_history=self)


class ModelDelta:

def __init__(self, changes, changed_fields, date, user, old_history,
new_history):
self.changes = changes
self.changed_fields = changed_fields
self.date = date
self.user = user
self.old_history = old_history
self.new_history = new_history
20 changes: 20 additions & 0 deletions simple_history/tests/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,26 @@ def test_foreignkey_primarykey(self):
poll_info = PollInfo(poll=poll)
poll_info.save()

def test_history_subtraction(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 - old_record
expected_changes = {'old_value': "what's up?",
'new_value': "what's up, man?"}
self.assertEqual(delta.changed_fields, ['question'])
self.assertEqual(delta.old_history, old_record)
self.assertEqual(delta.new_history, new_record)
self.assertEqual(delta.changes['question'], expected_changes)

def test_history_subtraction_wrong_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):
delta = new_record - 'something'

class CreateHistoryModelTests(unittest.TestCase):

Expand Down