Refactored versioning.py (and associated templates) to improve readability and testability.

Takes inspiration from, but does not use, django-reversion-compare. We do a lot of RIGS-specific stuff that requires a lot of hacking to get working nicely with django-reversion-compare. The main example of this is event-item “many-to-one” fields. The performance difference of my code compared to django-reversion-compare was found to be negligible.
This commit is contained in:
David Taylor
2017-06-21 00:50:09 +01:00
parent 3fa9191150
commit 22c520e841
5 changed files with 221 additions and 259 deletions

View File

@@ -33,13 +33,13 @@
{% endif %} {% endif %}
<p> <p>
<small> <small>
{% if version.old == None %} {% if version.changes.old == None %}
Created Created
{% else %} {% else %}
Changed {% include 'RIGS/version_changes.html' %} in Changed {% include 'RIGS/version_changes.html' %} in
{% endif %} {% endif %}
{% include 'RIGS/object_button.html' with object=version.new %} {% include 'RIGS/object_button.html' with object=version.changes.new %}
{% if version.revision.comment %} {% if version.revision.comment %}
({{ version.revision.comment }}) ({{ version.revision.comment }})
{% endif %} {% endif %}

View File

@@ -67,16 +67,16 @@
<tr> <tr>
<td>{{ version.revision.date_created }}</td> <td>{{ version.revision.date_created }}</td>
<td><a href="{{ version.new.get_absolute_url }}">{{version.new|to_class_name}} {{ version.new.pk|stringformat:"05d" }}</a></td> <td><a href="{{ version.changes.new.get_absolute_url }}">{{version.changes.new|to_class_name}} {{ version.changes.new.pk|stringformat:"05d" }}</a></td>
<td>{{ version.version.pk }}|{{ version.revision.pk }}</td> <td>{{ version.pk }}|{{ version.revision.pk }}</td>
<td>{{ version.revision.user.name }}</td> <td>{{ version.revision.user.name }}</td>
<td> <td>
{% if version.old == None %} {% if version.changes.old == None %}
{{version.new|to_class_name}} Created {{version.changes.new|to_class_name}} Created
{% else %} {% else %}
{% include 'RIGS/version_changes.html' %} {% include 'RIGS/version_changes.html' %}
{% endif %} </td> {% endif %} </td>
<td>{{ version.revision.comment }}</td> <td>{{ version.changes.revision.comment }}</td>
</tr> </tr>
{% endfor %} {% endfor %}

View File

@@ -1,4 +1,6 @@
{% for change in version.field_changes %} {% if version.changes.item_changes or version.changes.field_changes or version.changes.old == None %}
{% for change in version.changes.field_changes %}
<button title="Changes to {{ change.field.verbose_name }}" type="button" class="btn btn-default btn-xs" data-container="body" data-html="true" data-trigger='hover' data-toggle="popover" data-content='{% spaceless %} <button title="Changes to {{ change.field.verbose_name }}" type="button" class="btn btn-default btn-xs" data-container="body" data-html="true" data-trigger='hover' data-toggle="popover" data-content='{% spaceless %}
{% include "RIGS/version_changes_change.html" %} {% include "RIGS/version_changes_change.html" %}
@@ -6,10 +8,10 @@
{% endfor %} {% endfor %}
{% for itemChange in version.item_changes %} {% for itemChange in version.changes.item_changes %}
<button title="Changes to item '{% if itemChange.new %}{{ itemChange.new.name }}{% else %}{{ itemChange.old.name }}{% endif %}'" type="button" class="btn btn-default btn-xs" data-container="body" data-html="true" data-trigger='hover' data-toggle="popover" data-content='{% spaceless %} <button title="Changes to item '{% if itemChange.new %}{{ itemChange.new.name }}{% else %}{{ itemChange.old.name }}{% endif %}'" type="button" class="btn btn-default btn-xs" data-container="body" data-html="true" data-trigger='hover' data-toggle="popover" data-content='{% spaceless %}
<ul class="list-group"> <ul class="list-group">
{% for change in itemChange.changes %} {% for change in itemChange.field_changes %}
<li class="list-group-item"> <li class="list-group-item">
<h4 class="list-group-item-heading">{{ change.field.verbose_name }}</h4> <h4 class="list-group-item-heading">{{ change.field.verbose_name }}</h4>
{% include "RIGS/version_changes_change.html" %} {% include "RIGS/version_changes_change.html" %}
@@ -19,3 +21,6 @@
{% endspaceless %}'>item '{% if itemChange.new %}{{ itemChange.new.name }}{% else %}{{ itemChange.old.name }}{% endif %}'</button> {% endspaceless %}'>item '{% if itemChange.new %}{{ itemChange.new.name }}{% else %}{{ itemChange.old.name }}{% endif %}'</button>
{% endfor %} {% endfor %}
{% else %}
nothing useful
{% endif %}

View File

@@ -40,13 +40,13 @@
</thead> </thead>
<tbody> <tbody>
{% for version in object_list %} {% for version in object_list %}
{% if version.item_changes or version.field_changes or version.old == None %}
<tr> <tr>
<td>{{ version.revision.date_created }}</td> <td>{{ version.revision.date_created }}</td>
<td>{{ version.version.pk }}|{{ version.revision.pk }}</td> <td>{{ version.pk }}|{{ version.revision.pk }}</td>
<td>{{ version.revision.user.name }}</td> <td>{{ version.revision.user.name }}</td>
<td> <td>
{% if version.old == None %} {% if version.changes.old is None %}
{{object|to_class_name}} Created {{object|to_class_name}} Created
{% else %} {% else %}
{% include 'RIGS/version_changes.html' %} {% include 'RIGS/version_changes.html' %}
@@ -56,7 +56,8 @@
{{ version.revision.comment }} {{ version.revision.comment }}
</td> </td>
</tr> </tr>
{% endif %}
{% endfor %} {% endfor %}
</tbody> </tbody>

View File

@@ -1,30 +1,24 @@
from __future__ import unicode_literals
import logging import logging
import datetime
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.views import generic from django.views import generic
from django.utils.functional import cached_property
# Versioning
import reversion
from reversion.models import Version
from django.contrib.contenttypes.models import ContentType # Used to lookup the content_type
from django.db.models import IntegerField, EmailField, TextField from django.db.models import IntegerField, EmailField, TextField
from django.contrib.contenttypes.models import ContentType
from reversion.models import Version, VersionQuerySet
from diff_match_patch import diff_match_patch from diff_match_patch import diff_match_patch
from RIGS import models from RIGS import models
import datetime
logger = logging.getLogger('tec.pyrigs') logger = logging.getLogger('tec.pyrigs')
def model_compare(oldObj, newObj, excluded_keys=[]): class FieldComparison(object):
# recieves two objects of the same model, and compares them. Returns an array of FieldCompare objects
try:
theFields = oldObj._meta.fields # This becomes deprecated in Django 1.8!!!!!!!!!!!!! (but an alternative becomes available)
except AttributeError:
theFields = newObj._meta.fields
class FieldCompare(object):
def __init__(self, field=None, old=None, new=None): def __init__(self, field=None, old=None, new=None):
self.field = field self.field = field
self._old = old self._old = old
@@ -74,230 +68,192 @@ def model_compare(oldObj, newObj, excluded_keys=[]):
outputDiffs.append({'type': 'equal', 'text': data}) outputDiffs.append({'type': 'equal', 'text': data})
return outputDiffs return outputDiffs
class ModelComparison(object):
def __init__(self, old=None, new=None, version=None, excluded_keys=[]):
# recieves two objects of the same model, and compares them. Returns an array of FieldCompare objects
try:
self.fields = old._meta.get_fields()
except AttributeError:
self.fields = new._meta.get_fields()
self.old = old
self.new = new
self.excluded_keys = excluded_keys
self.version = version
@cached_property
def revision(self):
return self.version.revision
@cached_property
def field_changes(self):
changes = [] changes = []
for field in self.fields:
field_name = field.name
for thisField in theFields: if field_name in self.excluded_keys:
name = thisField.name
if name in excluded_keys:
continue # if we're excluding this field, skip over it continue # if we're excluding this field, skip over it
try: try:
oldValue = getattr(oldObj, name, None) oldValue = getattr(self.old, field_name, None)
except ObjectDoesNotExist: except ObjectDoesNotExist:
oldValue = None oldValue = None
try: try:
newValue = getattr(newObj, name, None) newValue = getattr(self.new, field_name, None)
except ObjectDoesNotExist: except ObjectDoesNotExist:
newValue = None newValue = None
try:
bothBlank = (not oldValue) and (not newValue) bothBlank = (not oldValue) and (not newValue)
if oldValue != newValue and not bothBlank: if oldValue != newValue and not bothBlank:
compare = FieldCompare(thisField, oldValue, newValue) comparison = FieldComparison(field, oldValue, newValue)
changes.append(compare) changes.append(comparison)
except TypeError: # logs issues with naive vs tz-aware datetimes
logger.error('TypeError when comparing models')
return changes return changes
@cached_property
def fields_changed(self):
return len(self.field_changes) > 0
def compare_event_items(old, new): @cached_property
def item_changes(self):
# Recieves two event version objects and compares their items, returns an array of ItemCompare objects # Recieves two event version objects and compares their items, returns an array of ItemCompare objects
item_type = ContentType.objects.get_for_model(models.EventItem) item_type = ContentType.objects.get_for_model(models.EventItem)
old_item_versions = old.revision.version_set.filter(content_type=item_type) old_item_versions = self.version.parent.revision.version_set.filter(content_type=item_type)
new_item_versions = new.revision.version_set.filter(content_type=item_type) new_item_versions = self.version.revision.version_set.filter(content_type=item_type)
class ItemCompare(object): comparisonParams = {'excluded_keys': ['id', 'event', 'order']}
def __init__(self, old=None, new=None, changes=None):
self.old = old
self.new = new
self.changes = changes
# Build some dicts of what we have # Build some dicts of what we have
item_dict = {} # build a list of items, key is the item_pk item_dict = {} # build a list of items, key is the item_pk
for version in old_item_versions: # put all the old versions in a list for version in old_item_versions: # put all the old versions in a list
if version.field_dict["event_id"] == int(old.object_id): if version.field_dict["event_id"] == int(self.new.pk):
compare = ItemCompare(old=version._object_version.object) compare = ModelComparison(old=version._object_version.object, **comparisonParams)
item_dict[version.object_id] = compare item_dict[version.object_id] = compare
for version in new_item_versions: # go through the new versions for version in new_item_versions: # go through the new versions
if version.field_dict["event_id"] == int(new.object_id): if version.field_dict["event_id"] == int(self.new.pk):
try: try:
compare = item_dict[version.object_id] # see if there's a matching old version compare = item_dict[version.object_id] # see if there's a matching old version
compare.new = version._object_version.object # then add the new version to the dictionary compare.new = version._object_version.object # then add the new version to the dictionary
except KeyError: # there's no matching old version, so add this item to the dictionary by itself except KeyError: # there's no matching old version, so add this item to the dictionary by itself
compare = ItemCompare(new=version._object_version.object) compare = ModelComparison(new=version._object_version.object, **comparisonParams)
item_dict[version.object_id] = compare # update the dictionary with the changes item_dict[version.object_id] = compare # update the dictionary with the changes
changes = [] changes = []
for (_, compare) in item_dict.items(): for (_, compare) in item_dict.items():
compare.changes = model_compare(compare.old, compare.new, ['id', 'event', 'order']) # see what's changed if compare.fields_changed:
if len(compare.changes) >= 1: changes.append(compare)
changes.append(compare) # transfer into a sequential array to make it easier to deal with later
return changes return changes
@cached_property
def items_changed(self):
return len(self.item_changes) > 0
def get_versions_for_model(models): @cached_property
def anything_changed(self):
return self.fields_changed or self.items_changed
class RIGSVersionManager(VersionQuerySet):
def get_for_multiple_models(self, model_array):
content_types = [] content_types = []
for model in models: for model in model_array:
content_types.append(ContentType.objects.get_for_model(model)) content_types.append(ContentType.objects.get_for_model(model))
versions = reversion.models.Version.objects.filter( return self.filter(content_type__in=content_types).select_related("revision").order_by("-pk")
content_type__in=content_types,
).select_related("revision").order_by("-pk")
return versions
def get_previous_version(version): class RIGSVersion(Version):
thisId = version.object_id class Meta:
thisVersionId = version.pk proxy = True
versions = Version.objects.get_for_object_reference(version.content_type.model_class(), thisId).all() objects = RIGSVersionManager.as_manager()
@cached_property
def parent(self):
thisId = self.object_id
versions = RIGSVersion.objects.get_for_object_reference(self.content_type.model_class(), thisId).select_related("revision", "revision__user").all()
try: try:
previousVersions = versions.filter(revision_id__lt=version.revision_id).latest( previousVersion = versions.filter(revision_id__lt=self.revision_id).latest(
field_name='revision__date_created') field_name='revision__date_created')
except ObjectDoesNotExist: except ObjectDoesNotExist:
return False return False
return previousVersions return previousVersion
@cached_property
def get_changes_for_version(newVersion, oldVersion=None): def changes(self):
# Pass in a previous version if you already know it (for efficiancy) return ModelComparison(
# if not provided then it will be looked up in the database version=self,
new=self._object_version.object,
if oldVersion == None: old=self.parent._object_version.object if self.parent else None
oldVersion = get_previous_version(newVersion) )
modelClass = newVersion.content_type.model_class()
compare = {
'revision': newVersion.revision,
'new': newVersion._object_version.object,
'current': modelClass.objects.filter(pk=newVersion.pk).first(),
'version': newVersion,
# Old things that may not be used
'old': None,
'field_changes': None,
'item_changes': None,
}
if oldVersion:
compare['old'] = oldVersion._object_version.object
compare['field_changes'] = model_compare(compare['old'], compare['new'])
compare['item_changes'] = compare_event_items(oldVersion, newVersion)
return compare
class VersionHistory(generic.ListView): class VersionHistory(generic.ListView):
model = Version model = RIGSVersion
template_name = "RIGS/version_history.html" template_name = "RIGS/version_history.html"
paginate_by = 25 paginate_by = 25
def get_queryset(self, **kwargs): def get_queryset(self, **kwargs):
thisModel = self.kwargs['model'] thisModel = self.kwargs['model']
# thisObject = get_object_or_404(thisModel, pk=self.kwargs['pk']) versions = RIGSVersion.objects.get_for_object_reference(thisModel, self.kwargs['pk']).select_related("revision", "revision__user").all()
versions = Version.objects.get_for_object_reference(thisModel, self.kwargs['pk']).all()
return versions return versions
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
thisModel = self.kwargs['model'] thisModel = self.kwargs['model']
context = super(VersionHistory, self).get_context_data(**kwargs) context = super(VersionHistory, self).get_context_data(**kwargs)
versions = context['object_list']
thisObject = get_object_or_404(thisModel, pk=self.kwargs['pk']) thisObject = get_object_or_404(thisModel, pk=self.kwargs['pk'])
items = []
for versionNo, thisVersion in enumerate(versions):
if versionNo >= len(versions) - 1:
thisItem = get_changes_for_version(thisVersion, None)
else:
thisItem = get_changes_for_version(thisVersion, versions[versionNo + 1])
items.append(thisItem)
context['object_list'] = items
context['object'] = thisObject context['object'] = thisObject
return context return context
class ActivityTable(generic.ListView): class ActivityTable(generic.ListView):
model = Version model = RIGSVersion
template_name = "RIGS/activity_table.html" template_name = "RIGS/activity_table.html"
paginate_by = 25 paginate_by = 25
def get_queryset(self): def get_queryset(self):
versions = get_versions_for_model([models.Event, models.Venue, models.Person, models.Organisation, models.EventAuthorisation]) versions = RIGSVersion.objects.get_for_multiple_models([models.Event, models.Venue, models.Person, models.Organisation, models.EventAuthorisation])
return versions return versions
def get_context_data(self, **kwargs):
# Call the base implementation first to get a context
context = super(ActivityTable, self).get_context_data(**kwargs)
items = []
for thisVersion in context['object_list']:
thisItem = get_changes_for_version(thisVersion, None)
items.append(thisItem)
context['object_list'] = items
return context
class ActivityFeed(generic.ListView): class ActivityFeed(generic.ListView):
model = Version model = RIGSVersion
template_name = "RIGS/activity_feed_data.html" template_name = "RIGS/activity_feed_data.html"
paginate_by = 25 paginate_by = 25
def get_queryset(self): def get_queryset(self):
versions = get_versions_for_model([models.Event, models.Venue, models.Person, models.Organisation, models.EventAuthorisation]) versions = RIGSVersion.objects.get_for_multiple_models([models.Event, models.Venue, models.Person, models.Organisation, models.EventAuthorisation])
return versions return versions
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
maxTimeDelta = []
maxTimeDelta.append({'maxAge': datetime.timedelta(days=1), 'group': datetime.timedelta(hours=1)})
maxTimeDelta.append({'maxAge': None, 'group': datetime.timedelta(days=1)})
# Call the base implementation first to get a context # Call the base implementation first to get a context
context = super(ActivityFeed, self).get_context_data(**kwargs) context = super(ActivityFeed, self).get_context_data(**kwargs)
maxTimeDelta = datetime.timedelta(hours=1)
items = [] items = []
for thisVersion in context['object_list']: for thisVersion in context['object_list']:
thisItem = get_changes_for_version(thisVersion, None) thisVersion.withPrevious = False
if thisItem['item_changes'] or thisItem['field_changes'] or thisItem['old'] == None:
thisItem['withPrevious'] = False
if len(items) >= 1: if len(items) >= 1:
timeAgo = datetime.datetime.now(thisItem['revision'].date_created.tzinfo) - thisItem[ timeDiff = items[-1].revision.date_created - thisVersion.revision.date_created
'revision'].date_created timeTogether = timeDiff < maxTimeDelta
timeDiff = items[-1]['revision'].date_created - thisItem['revision'].date_created sameUser = thisVersion.revision.user_id == items[-1].revision.user_id
timeTogether = False thisVersion.withPrevious = timeTogether & sameUser
for params in maxTimeDelta:
if params['maxAge'] is None or timeAgo <= params['maxAge']:
timeTogether = timeDiff < params['group']
break
sameUser = thisItem['revision'].user == items[-1]['revision'].user items.append(thisVersion)
thisItem['withPrevious'] = timeTogether & sameUser
items.append(thisItem)
context['object_list'] = items
return context return context