diff --git a/RIGS/templates/RIGS/activity_feed_data.html b/RIGS/templates/RIGS/activity_feed_data.html index e99baf8e..4de8bf7b 100644 --- a/RIGS/templates/RIGS/activity_feed_data.html +++ b/RIGS/templates/RIGS/activity_feed_data.html @@ -33,13 +33,13 @@ {% endif %}

- {% if version.old == None %} + {% if version.changes.old == None %} Created {% else %} Changed {% include 'RIGS/version_changes.html' %} in {% endif %} - {% include 'RIGS/object_button.html' with object=version.new %} + {% include 'RIGS/object_button.html' with object=version.changes.new %} {% if version.revision.comment %} ({{ version.revision.comment }}) {% endif %} diff --git a/RIGS/templates/RIGS/activity_table.html b/RIGS/templates/RIGS/activity_table.html index 1a1c8c1d..e12dfd7a 100644 --- a/RIGS/templates/RIGS/activity_table.html +++ b/RIGS/templates/RIGS/activity_table.html @@ -67,16 +67,16 @@ {{ version.revision.date_created }} - {{version.new|to_class_name}} {{ version.new.pk|stringformat:"05d" }} - {{ version.version.pk }}|{{ version.revision.pk }} + {{version.changes.new|to_class_name}} {{ version.changes.new.pk|stringformat:"05d" }} + {{ version.pk }}|{{ version.revision.pk }} {{ version.revision.user.name }} - {% if version.old == None %} - {{version.new|to_class_name}} Created + {% if version.changes.old == None %} + {{version.changes.new|to_class_name}} Created {% else %} {% include 'RIGS/version_changes.html' %} {% endif %} - {{ version.revision.comment }} + {{ version.changes.revision.comment }} {% endfor %} diff --git a/RIGS/templates/RIGS/version_changes.html b/RIGS/templates/RIGS/version_changes.html index ca4e1569..32685d3a 100644 --- a/RIGS/templates/RIGS/version_changes.html +++ b/RIGS/templates/RIGS/version_changes.html @@ -1,21 +1,26 @@ -{% 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 %} -{% endfor %} + -{% for itemChange in version.item_changes %} - -{% endfor %} \ No newline at end of file + {% endfor %} + + {% for itemChange in version.changes.item_changes %} + + {% endfor %} +{% else %} + nothing useful +{% endif %} \ No newline at end of file diff --git a/RIGS/templates/RIGS/version_history.html b/RIGS/templates/RIGS/version_history.html index 3261c5df..18ff22d4 100644 --- a/RIGS/templates/RIGS/version_history.html +++ b/RIGS/templates/RIGS/version_history.html @@ -40,13 +40,13 @@ {% for version in object_list %} - {% if version.item_changes or version.field_changes or version.old == None %} + {{ version.revision.date_created }} - {{ version.version.pk }}|{{ version.revision.pk }} + {{ version.pk }}|{{ version.revision.pk }} {{ version.revision.user.name }} - {% if version.old == None %} + {% if version.changes.old is None %} {{object|to_class_name}} Created {% else %} {% include 'RIGS/version_changes.html' %} @@ -56,7 +56,8 @@ {{ version.revision.comment }} - {% endif %} + + {% endfor %} diff --git a/RIGS/versioning.py b/RIGS/versioning.py index ca4f863b..d055a12d 100644 --- a/RIGS/versioning.py +++ b/RIGS/versioning.py @@ -1,303 +1,259 @@ +from __future__ import unicode_literals + import logging +import datetime from django.core.exceptions import ObjectDoesNotExist from django.shortcuts import get_object_or_404 from django.views import generic - -# Versioning -import reversion -from reversion.models import Version -from django.contrib.contenttypes.models import ContentType # Used to lookup the content_type +from django.utils.functional import cached_property 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 RIGS import models -import datetime logger = logging.getLogger('tec.pyrigs') -def model_compare(oldObj, newObj, excluded_keys=[]): - # 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 FieldComparison(object): + def __init__(self, field=None, old=None, new=None): + self.field = field + self._old = old + self._new = new - class FieldCompare(object): - def __init__(self, field=None, old=None, new=None): - self.field = field - self._old = old - self._new = new + def display_value(self, value): + if isinstance(self.field, IntegerField) and len(self.field.choices) > 0: + return [x[1] for x in self.field.choices if x[0] == value][0] + return value - def display_value(self, value): - if isinstance(self.field, IntegerField) and len(self.field.choices) > 0: - return [x[1] for x in self.field.choices if x[0] == value][0] - return value + @property + def old(self): + return self.display_value(self._old) - @property - def old(self): - return self.display_value(self._old) + @property + def new(self): + return self.display_value(self._new) - @property - def new(self): - return self.display_value(self._new) - - @property - def long(self): - if isinstance(self.field, EmailField): - return True - return False - - @property - def linebreaks(self): - if isinstance(self.field, TextField): - return True - return False - - @property - def diff(self): - oldText = unicode(self.display_value(self._old)) or "" - newText = unicode(self.display_value(self._new)) or "" - dmp = diff_match_patch() - diffs = dmp.diff_main(oldText, newText) - dmp.diff_cleanupSemantic(diffs) - - outputDiffs = [] - - for (op, data) in diffs: - if op == dmp.DIFF_INSERT: - outputDiffs.append({'type': 'insert', 'text': data}) - elif op == dmp.DIFF_DELETE: - outputDiffs.append({'type': 'delete', 'text': data}) - elif op == dmp.DIFF_EQUAL: - outputDiffs.append({'type': 'equal', 'text': data}) - return outputDiffs - - changes = [] - - for thisField in theFields: - name = thisField.name - - if name in excluded_keys: - continue # if we're excluding this field, skip over it - - try: - oldValue = getattr(oldObj, name, None) - except ObjectDoesNotExist: - oldValue = None - - try: - newValue = getattr(newObj, name, None) - except ObjectDoesNotExist: - newValue = None - - try: - bothBlank = (not oldValue) and (not newValue) - if oldValue != newValue and not bothBlank: - compare = FieldCompare(thisField, oldValue, newValue) - changes.append(compare) - except TypeError: # logs issues with naive vs tz-aware datetimes - logger.error('TypeError when comparing models') - - return changes - - -def compare_event_items(old, new): - # Recieves two event version objects and compares their items, returns an array of ItemCompare objects - - item_type = ContentType.objects.get_for_model(models.EventItem) - old_item_versions = old.revision.version_set.filter(content_type=item_type) - new_item_versions = new.revision.version_set.filter(content_type=item_type) - - class ItemCompare(object): - 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 - 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 - if version.field_dict["event_id"] == int(old.object_id): - compare = ItemCompare(old=version._object_version.object) - item_dict[version.object_id] = compare - - for version in new_item_versions: # go through the new versions - if version.field_dict["event_id"] == int(new.object_id): - try: - 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 - except KeyError: # there's no matching old version, so add this item to the dictionary by itself - compare = ItemCompare(new=version._object_version.object) - - item_dict[version.object_id] = compare # update the dictionary with the changes - - changes = [] - for (_, compare) in item_dict.items(): - compare.changes = model_compare(compare.old, compare.new, ['id', 'event', 'order']) # see what's changed - if len(compare.changes) >= 1: - changes.append(compare) # transfer into a sequential array to make it easier to deal with later - - return changes - - -def get_versions_for_model(models): - content_types = [] - for model in models: - content_types.append(ContentType.objects.get_for_model(model)) - - versions = reversion.models.Version.objects.filter( - content_type__in=content_types, - ).select_related("revision").order_by("-pk") - - return versions - - -def get_previous_version(version): - thisId = version.object_id - thisVersionId = version.pk - - versions = Version.objects.get_for_object_reference(version.content_type.model_class(), thisId).all() - - try: - previousVersions = versions.filter(revision_id__lt=version.revision_id).latest( - field_name='revision__date_created') - except ObjectDoesNotExist: + @property + def long(self): + if isinstance(self.field, EmailField): + return True return False - return previousVersions + @property + def linebreaks(self): + if isinstance(self.field, TextField): + return True + return False + + @property + def diff(self): + oldText = unicode(self.display_value(self._old)) or "" + newText = unicode(self.display_value(self._new)) or "" + dmp = diff_match_patch() + diffs = dmp.diff_main(oldText, newText) + dmp.diff_cleanupSemantic(diffs) + + outputDiffs = [] + + for (op, data) in diffs: + if op == dmp.DIFF_INSERT: + outputDiffs.append({'type': 'insert', 'text': data}) + elif op == dmp.DIFF_DELETE: + outputDiffs.append({'type': 'delete', 'text': data}) + elif op == dmp.DIFF_EQUAL: + outputDiffs.append({'type': 'equal', 'text': data}) + return outputDiffs -def get_changes_for_version(newVersion, oldVersion=None): - # Pass in a previous version if you already know it (for efficiancy) - # if not provided then it will be looked up in the database +class ModelComparison(object): - if oldVersion == None: - oldVersion = get_previous_version(newVersion) + 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() - modelClass = newVersion.content_type.model_class() + self.old = old + self.new = new + self.excluded_keys = excluded_keys + self.version = version - compare = { - 'revision': newVersion.revision, - 'new': newVersion._object_version.object, - 'current': modelClass.objects.filter(pk=newVersion.pk).first(), - 'version': newVersion, + @cached_property + def revision(self): + return self.version.revision - # Old things that may not be used - 'old': None, - 'field_changes': None, - 'item_changes': None, - } + @cached_property + def field_changes(self): + changes = [] + for field in self.fields: + field_name = field.name - 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) + if field_name in self.excluded_keys: + continue # if we're excluding this field, skip over it - return compare + try: + oldValue = getattr(self.old, field_name, None) + except ObjectDoesNotExist: + oldValue = None + + try: + newValue = getattr(self.new, field_name, None) + except ObjectDoesNotExist: + newValue = None + + bothBlank = (not oldValue) and (not newValue) + if oldValue != newValue and not bothBlank: + comparison = FieldComparison(field, oldValue, newValue) + changes.append(comparison) + + return changes + + @cached_property + def fields_changed(self): + return len(self.field_changes) > 0 + + @cached_property + def item_changes(self): + # Recieves two event version objects and compares their items, returns an array of ItemCompare objects + + item_type = ContentType.objects.get_for_model(models.EventItem) + old_item_versions = self.version.parent.revision.version_set.filter(content_type=item_type) + new_item_versions = self.version.revision.version_set.filter(content_type=item_type) + + comparisonParams = {'excluded_keys': ['id', 'event', 'order']} + + # Build some dicts of what we have + 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 + if version.field_dict["event_id"] == int(self.new.pk): + compare = ModelComparison(old=version._object_version.object, **comparisonParams) + item_dict[version.object_id] = compare + + for version in new_item_versions: # go through the new versions + if version.field_dict["event_id"] == int(self.new.pk): + try: + 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 + except KeyError: # there's no matching old version, so add this item to the dictionary by itself + compare = ModelComparison(new=version._object_version.object, **comparisonParams) + + item_dict[version.object_id] = compare # update the dictionary with the changes + + changes = [] + for (_, compare) in item_dict.items(): + if compare.fields_changed: + changes.append(compare) + + return changes + + @cached_property + def items_changed(self): + return len(self.item_changes) > 0 + + @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 = [] + for model in model_array: + content_types.append(ContentType.objects.get_for_model(model)) + + return self.filter(content_type__in=content_types).select_related("revision").order_by("-pk") + + +class RIGSVersion(Version): + class Meta: + proxy = True + + 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: + previousVersion = versions.filter(revision_id__lt=self.revision_id).latest( + field_name='revision__date_created') + except ObjectDoesNotExist: + return False + + return previousVersion + + @cached_property + def changes(self): + return ModelComparison( + version=self, + new=self._object_version.object, + old=self.parent._object_version.object if self.parent else None + ) class VersionHistory(generic.ListView): - model = Version + model = RIGSVersion template_name = "RIGS/version_history.html" paginate_by = 25 def get_queryset(self, **kwargs): thisModel = self.kwargs['model'] - # thisObject = get_object_or_404(thisModel, pk=self.kwargs['pk']) - versions = Version.objects.get_for_object_reference(thisModel, self.kwargs['pk']).all() + versions = RIGSVersion.objects.get_for_object_reference(thisModel, self.kwargs['pk']).select_related("revision", "revision__user").all() return versions def get_context_data(self, **kwargs): thisModel = self.kwargs['model'] - context = super(VersionHistory, self).get_context_data(**kwargs) - - versions = context['object_list'] 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 return context class ActivityTable(generic.ListView): - model = Version + model = RIGSVersion template_name = "RIGS/activity_table.html" paginate_by = 25 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 - 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): - model = Version + model = RIGSVersion template_name = "RIGS/activity_feed_data.html" paginate_by = 25 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 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 context = super(ActivityFeed, self).get_context_data(**kwargs) + maxTimeDelta = datetime.timedelta(hours=1) + items = [] for thisVersion in context['object_list']: - thisItem = get_changes_for_version(thisVersion, None) - if thisItem['item_changes'] or thisItem['field_changes'] or thisItem['old'] == None: - thisItem['withPrevious'] = False - if len(items) >= 1: - timeAgo = datetime.datetime.now(thisItem['revision'].date_created.tzinfo) - thisItem[ - 'revision'].date_created - timeDiff = items[-1]['revision'].date_created - thisItem['revision'].date_created - timeTogether = False - for params in maxTimeDelta: - if params['maxAge'] is None or timeAgo <= params['maxAge']: - timeTogether = timeDiff < params['group'] - break + thisVersion.withPrevious = False + if len(items) >= 1: + timeDiff = items[-1].revision.date_created - thisVersion.revision.date_created + timeTogether = timeDiff < maxTimeDelta + sameUser = thisVersion.revision.user_id == items[-1].revision.user_id + thisVersion.withPrevious = timeTogether & sameUser - sameUser = thisItem['revision'].user == items[-1]['revision'].user - thisItem['withPrevious'] = timeTogether & sameUser - - items.append(thisItem) - - context['object_list'] = items + items.append(thisVersion) return context