From 3593ff8f27b0554a771eadef8adb8ac57bf38505 Mon Sep 17 00:00:00 2001
From: Alec Lofquist <alec.lofquist@affirm.com>
Date: Thu, 20 Sep 2018 09:24:36 -0700
Subject: [PATCH 1/2] Track revisions to services, status checks, and
 schedules.

Show last 3 changes on the service and status check pages.
---
 cabot/cabotapp/admin.py                       |  14 +-
 cabot/cabotapp/models.py                      |   7 +
 cabot/cabotapp/revision_utils.py              | 205 ++++++++++++++++++
 cabot/cabotapp/tests/test_revisions.py        | 193 +++++++++++++++++
 cabot/cabotapp/views.py                       |  35 ++-
 cabot/metricsapp/views/grafana_elastic.py     |  10 +-
 cabot/settings.py                             |   1 +
 cabot/templates/cabotapp/model_diff.html      |  29 +++
 cabot/templates/cabotapp/service_detail.html  |   5 +
 .../cabotapp/statuscheck_detail.html          |   9 +-
 requirements.txt                              |   1 +
 11 files changed, 493 insertions(+), 16 deletions(-)
 create mode 100644 cabot/cabotapp/revision_utils.py
 create mode 100644 cabot/cabotapp/tests/test_revisions.py
 create mode 100644 cabot/templates/cabotapp/model_diff.html

diff --git a/cabot/cabotapp/admin.py b/cabot/cabotapp/admin.py
index 85b7300f1..878a90c46 100644
--- a/cabot/cabotapp/admin.py
+++ b/cabot/cabotapp/admin.py
@@ -1,3 +1,4 @@
+import reversion
 from django.contrib import admin
 from .models import (
     UserProfile,
@@ -13,11 +14,20 @@
 )
 from .alert import AlertPluginUserData, AlertPlugin
 
+
+class ServiceAdmin(reversion.VersionAdmin):
+    pass
+
+
+class StatusCheckAdmin(reversion.VersionAdmin):
+    pass
+
+
 admin.site.register(UserProfile)
 admin.site.register(Shift)
-admin.site.register(Service)
+admin.site.register(Service, ServiceAdmin)
 admin.site.register(ServiceStatusSnapshot)
-admin.site.register(StatusCheck)
+admin.site.register(StatusCheck, StatusCheckAdmin)
 admin.site.register(StatusCheckResult)
 admin.site.register(ActivityCounter)
 admin.site.register(AlertPlugin)
diff --git a/cabot/cabotapp/models.py b/cabot/cabotapp/models.py
index 46e5db97f..642e00175 100644
--- a/cabot/cabotapp/models.py
+++ b/cabot/cabotapp/models.py
@@ -26,6 +26,7 @@
 import socket
 import time
 import yaml
+import reversion
 
 import requests
 from celery.utils.log import get_task_logger
@@ -76,6 +77,7 @@ def get_success_with_retries(recent_results, retries=0):
     return False
 
 
+@reversion.register
 class CheckGroupMixin(models.Model):
 
     class Meta:
@@ -351,6 +353,7 @@ def is_silenced(self, now=None):
         return self.silence_warnings_until is not None and self.silence_warnings_until > now
 
 
+@reversion.register
 class Service(CheckGroupMixin):
 
     def update_status(self):
@@ -401,6 +404,7 @@ def __unicode__(self):
         return u"%s: %s" % (self.service.name, self.overall_status)
 
 
+@reversion.register(exclude=['last_run', 'cached_health', 'calculated_status'])
 class StatusCheck(PolymorphicModel):
     """
     Base class for polymorphic models. We're going to use
@@ -665,6 +669,7 @@ def reset_and_save(self):
             self.save()
 
 
+@reversion.register(follow=['statuscheck_ptr'])
 class HttpStatusCheck(StatusCheck):
 
     @property
@@ -814,6 +819,7 @@ def _run(self):
         return result
 
 
+@reversion.register(follow=['statuscheck_ptr'])
 class JenkinsStatusCheck(StatusCheck):
 
     @property
@@ -903,6 +909,7 @@ def _run(self):
         return result
 
 
+@reversion.register(follow=['statuscheck_ptr'])
 class TCPStatusCheck(StatusCheck):
 
     @property
diff --git a/cabot/cabotapp/revision_utils.py b/cabot/cabotapp/revision_utils.py
new file mode 100644
index 000000000..abe13964c
--- /dev/null
+++ b/cabot/cabotapp/revision_utils.py
@@ -0,0 +1,205 @@
+from collections import defaultdict
+
+import reversion
+from django.contrib.auth.models import AnonymousUser
+from django.db import transaction
+from django.db.models import Manager, Model
+from django.db.models.query import QuerySet
+from django.utils.html import escape
+
+
+def add_to_revision(obj):
+    """Manually add the current version of obj to the current revision."""
+    # backport of reversion.add_to_revision() from django-reversion 2.x
+    manager = reversion.default_revision_manager
+    ctx_manager = reversion.revision_context_manager
+    adapter = manager.get_adapter(type(obj))
+    ctx_manager.add_to_context(manager, obj, adapter.get_version_data(obj, ctx_manager.get_db()))
+
+
+def get_revisions(obj, n_revisions=3):
+    """
+    Get the last N diffs for an object that is tracked by django-reversion, as (v1, v2) human-readable string tuples.
+    Foreign key fields will show their *current* string representation.
+    Deleted foreign key fields will show as 'deleted' or 'deleted: <last known object repr>'.
+    Diff strings may be HTML formatted. Any user-supplied values are first HTML-escaped.
+    :param obj: object to get a diff for
+    :param n_revisions: maximum number of revisions to get (default 3)
+    :return: list of (revision, {field: (v1_str, v2_str), ...}) tuples
+    """
+    # type: (Any, int) -> List[Tuple[reversion.models.Revision, Dict[str, Tuple[str, str]]]]
+    recent_versions = reversion.get_for_object(obj)[:n_revisions+1]
+
+    # grab fields on the object's model
+    fields = [f for f in obj._meta.fields]
+
+    # also grab many-to-many fields
+    concrete_model = obj._meta.concrete_model
+    fields += concrete_model._meta.many_to_many
+
+    revisions = []
+    for i in range(len(recent_versions) - 1):
+        cur = recent_versions[i]
+        prev = recent_versions[i+1]
+        changes = compare_versions(fields, prev, cur)
+        if len(changes) > 0:
+            revisions.append((cur.revision, changes))
+
+    return revisions
+
+
+def compare_versions(fields, v1, v2):
+    """
+    Return HTML-formatted diffs for a list of fields.
+    :param fields: list of Django field objects
+    :param v1: reversion.Version for the previous version of the object
+    :param v2: reversion.Version for the new version of the object
+    :return: dict of {'field_name': (v1_value_str, v2_value_str), ...}
+    """
+    # type: (List[Field], reversion.models.Version, reversion.models.Version) -> Dict[str, Tuple[str, str]]
+
+    class MutedStr:
+        def __init__(self, s):
+            self.msg = s
+
+        def __str__(self):
+            return '<span class="text-muted">{}</span>'.format(escape(self.msg))
+
+    def obj_to_str_escaped(obj):
+        # type: (Any) -> str
+        """
+        Converts an object to an HTML-escaped string (but note the return value may contain HTML for styling special
+        values like None).
+        """
+
+        if obj is None or obj == '':
+            return str(MutedStr('none'))
+        elif isinstance(obj, list):
+            return '[' + ', '.join([obj_to_str_escaped(o) for o in obj]) + ']'
+        elif isinstance(obj, MutedStr):
+            return str(obj)  # already escaped
+
+        try:
+            return escape(str(obj))
+        except:
+            try:
+                return escape(repr(obj))
+            except:
+                return str(MutedStr('<error: could not build string>'))
+
+    def field_to_str_escaped(field, version):
+        """
+        Converts data from django-reversion to a nice string, based on the given django fields. Most importantly,
+        handles looking up foreign keys and many-to-many relationships if they still exist in the DB.
+        :param field: list of django field objects
+        :param version: django-reversion Version object
+        :return: human-readable string representing field of version; may contain HTML
+        """
+        value = version.field_dict[field.name]
+        if field.get_internal_type() == 'ManyToManyField':
+            ids = [int(v) for v in value]
+            related_model = field.rel.to
+
+            related_objs = []
+            for related in related_model.objects.filter(id__in=ids):
+                related_objs.append(related)
+                ids.remove(related.pk)
+
+            # if it's not in the live set, try and find it in a "deleted" revision
+            for version in reversion.get_deleted(related_model).filter(object_id_int__in=ids):
+                # we use the object repr here because version.object is none for some reason
+                related_objs.append(MutedStr('deleted: {}'.format(version.object_repr)))
+                ids.remove(version.object_id_int)
+
+            # alternatively pull related objects from same revision
+            # value = version.revision.version_set.filter(
+            #     content_type=ContentType.objects.get_for_model(related_model),
+            #     object_id_int__in=ids
+            # )
+
+            # for anything we couldn't find, just give a fixed 'deleted' string
+            value = related_objs + [MutedStr('deleted') for _ in ids]
+        elif field.get_internal_type() == 'ForeignKey':
+            try:
+                value = value.rel.get()
+            except:
+                value = MutedStr('deleted')
+
+        return obj_to_str_escaped(value)
+
+    changes = {}
+    for field in fields:
+        v1_value = v1.field_dict[field.name]
+        v2_value = v2.field_dict[field.name]
+        if v1_value != v2_value:
+            v1_str = field_to_str_escaped(field, v1)
+            v2_str = field_to_str_escaped(field, v2)
+            changes[field.name] = (v1_str, v2_str)
+
+    return changes
+
+
+class RevisionMixin(object):
+    """
+    Backport of reversion.views.RevisionMixin since we are using a version of reversion from 2015.
+    Wraps HTTP requests (except for GET/HEAD/OPTIONS) in an atomic django-reversion revision, plus
+    some magic to follow reverse m2m fields in checks (which django-reversion should handle, but I
+    can't get it to work :shakes fist:).
+
+    Set 'revision_comment' if you would like this revision to be recorded as a fixed string instead
+    of a list of changed fields. Currently used for 'created' and 'deleted' messages.
+
+    Set 'revision_follow' to a list of attributes on self.object that return querysets or foreign keys
+    that we should include with any revisions (for example, 'service_set' for status checks).
+    """
+
+    revision_comment = ''
+    revision_follow = []
+
+    def dispatch(self, *args, **kwargs):
+        if self.request.method not in ('GET', 'HEAD', 'OPTIONS'):
+            with transaction.atomic(), reversion.create_revision():
+                # fill in the revision metadata
+                if not isinstance(self.request.user, AnonymousUser):
+                    reversion.set_user(self.request.user)
+                if self.revision_comment:
+                    reversion.set_comment(self.revision_comment)
+
+                related = self._gather_reverse_pks()
+                ret = super(RevisionMixin, self).dispatch(*args, **kwargs)
+                related = self._gather_reverse_pks(related)
+
+                for model, pks in related.items():
+                    for obj in model.objects.filter(pk__in=pks):
+                        add_to_revision(obj)
+
+                return ret
+        else:
+            return super(RevisionMixin, self).dispatch(*args, **kwargs)
+
+    def _gather_reverse_pks(self, model_to_pks=None):
+        model_to_pks = model_to_pks or defaultdict(list)
+
+        if not self.revision_follow:
+            return model_to_pks
+
+        obj = getattr(self, 'object', None)
+        if obj is None and 'pk' in getattr(self, 'kwargs', []) and hasattr(self, 'get_object'):
+            obj = self.get_object()
+        if obj is None or obj.pk is None:
+            return model_to_pks
+
+        for field_name in self.revision_follow:
+            value = getattr(obj, field_name, None)
+            if isinstance(value, (Manager, QuerySet)):
+                # m2m/o2m relationship
+                field_model = value.model
+                model_to_pks[field_model].extend(value.values_list('pk', flat=True))
+            elif isinstance(value, Model):
+                # foreign key
+                field_model = value._meta.model
+                model_to_pks[field_model].append(value.pk)
+            else:
+                raise RuntimeError('Could not follow field {} (missing? unsupported type?)'.format(field_name))
+
+        return model_to_pks
diff --git a/cabot/cabotapp/tests/test_revisions.py b/cabot/cabotapp/tests/test_revisions.py
new file mode 100644
index 000000000..7487271c8
--- /dev/null
+++ b/cabot/cabotapp/tests/test_revisions.py
@@ -0,0 +1,193 @@
+from urlparse import urlsplit
+
+from django.core.urlresolvers import reverse, resolve
+
+from cabot.cabotapp.models import Service, StatusCheck, HttpStatusCheck, TCPStatusCheck, JenkinsStatusCheck
+from cabot.cabotapp.tests.utils import LocalTestCase
+from cabot.cabotapp.revision_utils import get_revisions
+from cabot.cabotapp.views import ServiceForm, HttpStatusCheckForm, JenkinsStatusCheckForm, TCPStatusCheckForm
+
+import reversion
+
+
+def get_post_data(obj, form_class, changed_fields):
+    form = form_class(instance=obj)
+    old_fields = {}
+    for field_name in form.fields:
+        val = form[field_name].value()
+        if val is None:
+            val = ''
+        old_fields[field_name] = val
+    return dict(old_fields.items() + changed_fields.items())
+
+
+def _create_check_url(check_cls):
+    urls = {
+        HttpStatusCheck: reverse('create-http-check'),
+        JenkinsStatusCheck: reverse('create-jenkins-check'),
+        TCPStatusCheck: reverse('create-tcp-check'),
+    }
+    return urls[check_cls]
+
+
+def _edit_check_url(check):
+    if type(check) == HttpStatusCheck:
+        return reverse('update-http-check', kwargs={'pk': check.pk})
+    raise NotImplemented()
+
+
+_check_form_classes = {
+    HttpStatusCheck: HttpStatusCheckForm,
+    JenkinsStatusCheck: JenkinsStatusCheckForm,
+    TCPStatusCheck: TCPStatusCheckForm,
+}
+
+
+class TestRevisions(LocalTestCase):
+    def setUp(self):
+        # make sure we create initial revisions for everything
+        with reversion.create_revision():
+            super(TestRevisions, self).setUp()
+
+    def _update_service(self, service, changed_fields):
+        data = get_post_data(service, ServiceForm, changed_fields)
+        response = self.client.post(reverse('update-service', kwargs={'pk': service.pk}), data=data)
+        self.assertEqual(response.status_code, 302)
+
+        # return refreshed from db obj
+        return Service.objects.get(pk=self.service.pk)
+
+    def _delete_check(self, check):
+        response = self.client.post(reverse('delete-check', kwargs={'pk': check.pk}))
+        self.assertEqual(response.status_code, 302)
+        return None
+
+    def _create_check(self, model, fields):
+        data = get_post_data(None, _check_form_classes[model], fields)
+        response = self.client.post(_create_check_url(model), data=data)
+        self.assertEqual(response.status_code, 302)
+        pk = resolve(urlsplit(response.url).path).kwargs['pk']
+        return model.objects.get(pk=pk)
+
+    def _update_check(self, check, changed_fields):
+        model = type(check)
+        data = get_post_data(check, _check_form_classes[model], changed_fields)
+        response = self.client.post(_edit_check_url(check), data=data)
+        self.assertEqual(response.status_code, 302)
+        return model.objects.get(pk=check.pk)
+
+    def test_update_service(self):
+        self.service = self._update_service(self.service, {'name': 'cool service'})
+        self.assertEqual(self.service.name, 'cool service')
+
+    def test_delete_check(self):
+        self.assertTrue(StatusCheck.objects.filter(pk=self.jenkins_check.pk).exists())
+        self.assertTrue(StatusCheck.objects.filter(pk=self.http_check.pk).exists())
+
+        self._delete_check(self.jenkins_check)
+        self.assertFalse(StatusCheck.objects.filter(pk=self.jenkins_check.pk).exists())
+        self._delete_check(self.http_check)
+        self.assertFalse(StatusCheck.objects.filter(pk=self.http_check.pk).exists())
+
+    def test_service_revision_single_field(self):
+        self.service = self._update_service(self.service, {'name': 'cool service'})
+
+        revisions = get_revisions(self.service, 3)  # request more revisions than there actually are
+        self.assertEqual(len(revisions), 1)
+        changes = revisions[0][1]
+        self.assertEqual(changes['name'][0], 'Service')       # old name
+        self.assertEqual(changes['name'][1], 'cool service')  # new name
+
+    def test_service_revision_multiple_fields(self):
+        self.service = self._update_service(self.service, {
+            'name': 'cool service',
+            'users_to_notify': [self.user.pk],
+            'url': 'http://google.com',
+        })
+
+        revisions = get_revisions(self.service, 1)
+        self.assertEqual(len(revisions), 1)
+        changes = revisions[0][1]
+        self.assertEqual(changes['name'][0], 'Service')
+        self.assertEqual(changes['name'][1], 'cool service')
+        self.assertEqual(changes['users_to_notify'][0], '[]')
+        self.assertEqual(changes['users_to_notify'][1], '[testuser]')
+        self.assertEqual(changes['url'][0], '<span class="text-muted">none</span>')
+        self.assertEqual(changes['url'][1], 'http://google.com')
+
+    def test_service_multiple_revisions_and_fields(self):
+        self.service = self._update_service(self.service, {
+            'name': 'revision 1',
+        })
+        self.service = self._update_service(self.service, {
+            'name': 'revision 2',
+            'users_to_notify': [self.user.pk],
+        })
+        self.service = self._update_service(self.service, {
+            'name': 'revision 3',
+            'url': 'http://google.com',
+        })
+        self.service = self._update_service(self.service, {
+            'name': 'revision 4',
+        })
+
+        revisions = get_revisions(self.service, 3)  # request fewer revisions than there actually are
+        self.assertEqual(len(revisions), 3)
+
+        none_str = '<span class="text-muted">none</span>'
+        expected_changes = [
+            {
+                'name': ('revision 3', 'revision 4'),
+            },
+            {
+                'name': ('revision 2', 'revision 3'),
+                'url': (none_str, 'http://google.com')
+            },
+            {
+                'name': ('revision 1', 'revision 2'),
+                'users_to_notify': ('[]', '[testuser]')
+            }
+        ]
+
+        for expected, revision in zip(expected_changes, revisions):
+            actual_changes = revision[1]
+            for field_name, (expected_old, expected_new) in expected.items():
+                self.assertEqual(actual_changes[field_name][0], expected_old)
+                self.assertEqual(actual_changes[field_name][1], expected_new)
+
+    def test_service_check_deleted(self):
+        self.assertTrue(self.service.status_checks.filter(pk=self.http_check.pk).exists())
+        self._delete_check(self.http_check)
+        self.assertFalse(self.service.status_checks.filter(pk=self.http_check.pk).exists())
+
+        revisions = get_revisions(self.service, 1)
+        self.assertEqual(len(revisions), 1)
+        self.assertEqual(revisions[0][1]['status_checks'],
+                         ('[Jenkins Check, TCP Check, <span class="text-muted">deleted: Http Check</span>]',
+                          '[Jenkins Check, TCP Check]'))
+
+    def test_check_added_with_service(self):
+        """Check if a Service revision is saved when a check is created with some service(s) immediately specified"""
+        new_check = self._create_check(HttpStatusCheck, {
+            'name': 'New Check',
+            'endpoint': 'http://affirm.com',
+            'service_set': [self.service.pk]
+        })
+        self.assertTrue(new_check.service_set.filter(pk=self.service.pk).exists())
+
+        revisions = get_revisions(self.service, 1)
+        self.assertEqual(len(revisions), 1)
+        self.assertEqual(revisions[0][1]['status_checks'], ('[Http Check, Jenkins Check, TCP Check]',
+                                                            '[Http Check, Jenkins Check, New Check, TCP Check]'))
+
+    def test_change_service_from_check(self):
+        self.assertTrue(self.service.status_checks.filter(pk=self.http_check.pk).exists())
+        self.http_check = self._update_check(self.http_check, {
+            'service_set': []
+        })
+        self.assertFalse(self.service.status_checks.filter(pk=self.http_check.pk).exists())
+
+        revisions = get_revisions(self.service, 1)
+        self.assertEqual(len(revisions), 1)
+        self.assertEqual(revisions[0][1]['status_checks'], ('[Http Check, Jenkins Check, TCP Check]',
+                                                            '[Jenkins Check, TCP Check]'))
diff --git a/cabot/cabotapp/views.py b/cabot/cabotapp/views.py
index 40ed6fb87..61bf766bc 100644
--- a/cabot/cabotapp/views.py
+++ b/cabot/cabotapp/views.py
@@ -8,6 +8,7 @@
 from timezone_field import TimeZoneFormField
 
 from cabot.cabotapp.alert import AlertPlugin
+from cabot.cabotapp.revision_utils import get_revisions, RevisionMixin
 from models import (StatusCheck,
                     JenkinsStatusCheck,
                     HttpStatusCheck,
@@ -379,8 +380,10 @@ def get_report(self):
         return checks
 
 
-class CheckCreateView(LoginRequiredMixin, CreateView):
+class CheckCreateView(LoginRequiredMixin, RevisionMixin, CreateView):
     template_name = 'cabotapp/statuscheck_form.html'
+    revision_comment = 'created check'
+    revision_follow = ['service_set']
 
     def form_valid(self, form):
         if self.request.user is not None and not isinstance(self.request.user, AnonymousUser):
@@ -412,8 +415,9 @@ def get_success_url(self):
         return reverse('check', kwargs={'pk': self.object.id})
 
 
-class CheckUpdateView(LoginRequiredMixin, UpdateView):
+class CheckUpdateView(LoginRequiredMixin, RevisionMixin, UpdateView):
     template_name = 'cabotapp/statuscheck_form.html'
+    revision_follow = ['service_set']
 
     def get_success_url(self):
         return reverse('check', kwargs={'pk': self.object.id})
@@ -465,11 +469,13 @@ def get_queryset(self):
         return StatusCheck.objects.all().order_by('name').prefetch_related('service_set')
 
 
-class StatusCheckDeleteView(LoginRequiredMixin, DeleteView):
+class StatusCheckDeleteView(LoginRequiredMixin, RevisionMixin, DeleteView):
     model = StatusCheck
     success_url = reverse_lazy('checks')
     context_object_name = 'check'
     template_name = 'cabotapp/statuscheck_confirm_delete.html'
+    revision_comment = 'deleted check'
+    revision_follow = ['service_set']
 
 
 class StatusCheckDetailView(LoginRequiredMixin, DetailView):
@@ -482,6 +488,7 @@ def render_to_response(self, context, *args, **kwargs):
             context = {}
         context['checkresults'] = self.object.statuscheckresult_set.order_by(
             '-time_complete')[:100]
+        context['revisions'] = get_revisions(self.object)
         return super(StatusCheckDetailView, self).render_to_response(context, *args, **kwargs)
 
 
@@ -636,26 +643,30 @@ def get_context_data(self, **kwargs):
             'checks': self.object.status_checks.all(),
             'service': self.object,
             'date_from': date_from,
-            'date_to': date_from + relativedelta(months=1) - relativedelta(days=1)
+            'date_to': date_from + relativedelta(months=1) - relativedelta(days=1),
         })
+        context['revisions'] = get_revisions(self.object)
         return context
 
 
-class ServiceCreateView(LoginRequiredMixin, CreateView):
+class ServiceCreateView(LoginRequiredMixin, RevisionMixin, CreateView):
     model = Service
     form_class = ServiceForm
+    revision_comment = 'created service'
 
     def get_success_url(self):
         return reverse('service', kwargs={'pk': self.object.id})
 
 
-class ScheduleCreateView(LoginRequiredMixin, CreateView):
+class ScheduleCreateView(LoginRequiredMixin, RevisionMixin, CreateView):
     model = Schedule
     form_class = ScheduleForm
     success_url = reverse_lazy('shifts')
+    revision_comment = 'created schedule'
+    revision_follow = ['service_set']
 
 
-class ServiceUpdateView(LoginRequiredMixin, UpdateView):
+class ServiceUpdateView(LoginRequiredMixin, RevisionMixin, UpdateView):
     model = Service
     form_class = ServiceForm
 
@@ -663,27 +674,31 @@ def get_success_url(self):
         return reverse('service', kwargs={'pk': self.object.id})
 
 
-class ScheduleUpdateView(LoginRequiredMixin, UpdateView):
+class ScheduleUpdateView(LoginRequiredMixin, RevisionMixin, UpdateView):
     model = Schedule
     form_class = ScheduleForm
     context_object_name = 'schedules'
     success_url = reverse_lazy('shifts')
+    revision_follow = ['service_set']
 
 
-class ServiceDeleteView(LoginRequiredMixin, DeleteView):
+class ServiceDeleteView(LoginRequiredMixin, RevisionMixin, DeleteView):
     model = Service
     success_url = reverse_lazy('services')
     context_object_name = 'service'
     template_name = 'cabotapp/service_confirm_delete.html'
+    revision_comment = 'deleted service'
 
 
-class ScheduleDeleteView(LoginRequiredMixin, DeleteView):
+class ScheduleDeleteView(LoginRequiredMixin, RevisionMixin, DeleteView):
     model = Schedule
     form_class = ScheduleForm
 
     success_url = reverse_lazy('shifts')
     context_object_name = 'schedule'
     template_name = 'cabotapp/schedule_confirm_delete.html'
+    revision_comment = 'deleted schedule'
+    revision_follow = ['service_set']
 
 
 class ScheduleSnoozeWarningsView(LoginRequiredMixin, View):
diff --git a/cabot/metricsapp/views/grafana_elastic.py b/cabot/metricsapp/views/grafana_elastic.py
index a9a8b25fc..5e22a7f81 100644
--- a/cabot/metricsapp/views/grafana_elastic.py
+++ b/cabot/metricsapp/views/grafana_elastic.py
@@ -4,16 +4,19 @@
 from django.shortcuts import render
 from django.views.generic import UpdateView, CreateView
 from cabot.cabotapp.views import LoginRequiredMixin
+from cabot.cabotapp.revision_utils import RevisionMixin
 from cabot.metricsapp.api import get_es_status_check_fields, get_status_check_fields
 from cabot.metricsapp.forms import GrafanaElasticsearchStatusCheckForm, GrafanaElasticsearchStatusCheckUpdateForm
 from cabot.metricsapp.models import ElasticsearchStatusCheck, GrafanaDataSource
 from cabot.metricsapp.models.grafana import build_grafana_panel_from_session, set_grafana_panel_from_session
 
 
-class GrafanaElasticsearchStatusCheckCreateView(LoginRequiredMixin, CreateView):
+class GrafanaElasticsearchStatusCheckCreateView(LoginRequiredMixin, RevisionMixin, CreateView):
     model = ElasticsearchStatusCheck
     form_class = GrafanaElasticsearchStatusCheckForm
     template_name = 'metricsapp/grafana_create.html'
+    revision_comment = 'created check'
+    revision_follow = ['service_set']
 
     def get_form_kwargs(self):
         kwargs = super(GrafanaElasticsearchStatusCheckCreateView, self).get_form_kwargs()
@@ -53,10 +56,11 @@ def form_valid(self, form):
         return response
 
 
-class GrafanaElasticsearchStatusCheckUpdateView(LoginRequiredMixin, UpdateView):
+class GrafanaElasticsearchStatusCheckUpdateView(LoginRequiredMixin, RevisionMixin, UpdateView):
     model = ElasticsearchStatusCheck
     form_class = GrafanaElasticsearchStatusCheckUpdateForm
     template_name = 'metricsapp/grafana_create.html'
+    revision_follow = ['service_set']
 
     # note - this view also updates self.object.grafana_panel (via ElasticsearchStatusCheckUpdateForm)
 
@@ -94,6 +98,8 @@ class GrafanaElasticsearchStatusCheckRefreshView(GrafanaElasticsearchStatusCheck
     Note this requires the session vars by earlier Views in the "create Grafana check" flow to work
     (GrafanaDashboardSelectView, etc).
     """
+    revision_comment = "Refreshed from Grafana"
+
     def get_form_kwargs(self):
         """Add kwargs['data'] and fill it with the latest values from Grafana"""
         kwargs = super(GrafanaElasticsearchStatusCheckRefreshView, self).get_form_kwargs()
diff --git a/cabot/settings.py b/cabot/settings.py
index 2170052cf..477b3639c 100644
--- a/cabot/settings.py
+++ b/cabot/settings.py
@@ -142,6 +142,7 @@
     'rest_framework',
     'social_django',
     'timezone_field',
+    'reversion',
 )
 
 # Load additional apps from configuration file
diff --git a/cabot/templates/cabotapp/model_diff.html b/cabot/templates/cabotapp/model_diff.html
new file mode 100644
index 000000000..172451c9d
--- /dev/null
+++ b/cabot/templates/cabotapp/model_diff.html
@@ -0,0 +1,29 @@
+<ul class="list-unstyled">
+  {% for revision, changes in revisions %}
+    <li {% if not forloop.first %}class="collapse last-changed-detail"{% endif %}>
+      {{ revision.date_created }}
+      {% if revision.user %}<a href="mailto:{{ revision.user.email }}">{{ revision.user.username }}</a>{% endif %}
+      {% if revision.comment %}
+        {{ revision.comment }}
+      {% else %}
+        changed {{ changes.keys|join:", "|truncatechars:30 }}
+      {% endif %}
+      {% if forloop.first %}
+        <span class="glyphicon glyphicon-plus" data-toggle="collapse" data-target=".last-changed-detail"></span>
+      {% endif %}
+      <ul class="collapse last-changed-detail">
+        {% for field_name, change in changes.items %}
+          {% autoescape off %}
+            {# need to use the truncate_html version because change strings can be HTML (for styling...) #}
+            {# truncatechars_html doesn't exist in django 1.6 and backporting requires changing the Truncator class #}
+            {# :D :( :'( #}
+            <li>{{ field_name }}: {{ change.0|truncatewords_html:12 }}
+              <span class="glyphicon glyphicon-arrow-right"></span>
+              {{ change.1|truncatewords_html:6 }}
+            </li>
+          {% endautoescape %}
+        {% endfor %}
+      </ul>
+    </li>
+  {% endfor %}
+</ul>
diff --git a/cabot/templates/cabotapp/service_detail.html b/cabot/templates/cabotapp/service_detail.html
index 17cadc505..c9a62ff30 100644
--- a/cabot/templates/cabotapp/service_detail.html
+++ b/cabot/templates/cabotapp/service_detail.html
@@ -57,6 +57,11 @@ <h5>
       <div class="col-xs-8"><h5>None</h5></div>
       {% endif %}
     </div>
+    <div class="col-xs-12">
+      <div class="col-xs-1"><h5><i class="glyphicon glyphicon-flag"></i></h5></div>
+      <div class="col-xs-3"><h5><span class="break"></span>Last changed</h5></div>
+      <div class="col-xs-8">{% include 'cabotapp/model_diff.html' with revisions=revisions only %}</div>
+    </div>
   </div>
   <div class="col-xs-12 col-md-6">
     <div class="col-xs-1"><h3><i class="fa fa-bar-chart-o"></i></h3></div>
diff --git a/cabot/templates/cabotapp/statuscheck_detail.html b/cabot/templates/cabotapp/statuscheck_detail.html
index 3e466f31a..060e55f43 100644
--- a/cabot/templates/cabotapp/statuscheck_detail.html
+++ b/cabot/templates/cabotapp/statuscheck_detail.html
@@ -25,10 +25,15 @@
 </div>
 <hr>
 <div class="row">
-  <div class="col-xs-12">
-    <div class="col-xs-11"><h3>Runbook</h3></div>
+  <div class="col-xs-6">
+    <div class="col-xs-12"><h3>Runbook</h3></div>
     <div class="col-xs-12">{% autoescape off %}{{ check.runbook }}{% endautoescape %}</div>
   </div>
+  <div class="col-xs-6">
+    <div class="col-xs-12"><h4>Last Changed</h4></div>
+    <div class="col-xs-12">{% include 'cabotapp/model_diff.html' with revisions=revisions only %}</div>
+  </div>
+</div>
 <hr>
 <div class="row">
   <div class="col-xs-12">
diff --git a/requirements.txt b/requirements.txt
index 991b50d54..834ca36c9 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -48,3 +48,4 @@ unittest-xml-reporting==2.1.0
 PyMySQL==0.8.0
 jsondiff==1.1.2
 django-timezone-field==3.0
+django-reversion==1.8.7

From 642423a80c32405f92badcd0e49493ea8e3d7794 Mon Sep 17 00:00:00 2001
From: Alec Lofquist <alec.lofquist@affirm.com>
Date: Tue, 19 Feb 2019 14:55:55 -0800
Subject: [PATCH 2/2] Upgrade django-reversion now that we're on django 1.11

---
 cabot/cabotapp/admin.py                   |  6 +-
 cabot/cabotapp/revision_utils.py          | 92 ++---------------------
 cabot/cabotapp/views.py                   |  3 +-
 cabot/metricsapp/views/grafana_elastic.py |  2 +-
 requirements.txt                          |  2 +-
 5 files changed, 13 insertions(+), 92 deletions(-)

diff --git a/cabot/cabotapp/admin.py b/cabot/cabotapp/admin.py
index 878a90c46..91a1f4357 100644
--- a/cabot/cabotapp/admin.py
+++ b/cabot/cabotapp/admin.py
@@ -1,4 +1,4 @@
-import reversion
+from reversion.admin import VersionAdmin
 from django.contrib import admin
 from .models import (
     UserProfile,
@@ -15,11 +15,11 @@
 from .alert import AlertPluginUserData, AlertPlugin
 
 
-class ServiceAdmin(reversion.VersionAdmin):
+class ServiceAdmin(VersionAdmin):
     pass
 
 
-class StatusCheckAdmin(reversion.VersionAdmin):
+class StatusCheckAdmin(VersionAdmin):
     pass
 
 
diff --git a/cabot/cabotapp/revision_utils.py b/cabot/cabotapp/revision_utils.py
index abe13964c..7db51a9d1 100644
--- a/cabot/cabotapp/revision_utils.py
+++ b/cabot/cabotapp/revision_utils.py
@@ -1,22 +1,8 @@
-from collections import defaultdict
-
 import reversion
-from django.contrib.auth.models import AnonymousUser
-from django.db import transaction
-from django.db.models import Manager, Model
-from django.db.models.query import QuerySet
+from reversion.models import Version
 from django.utils.html import escape
 
 
-def add_to_revision(obj):
-    """Manually add the current version of obj to the current revision."""
-    # backport of reversion.add_to_revision() from django-reversion 2.x
-    manager = reversion.default_revision_manager
-    ctx_manager = reversion.revision_context_manager
-    adapter = manager.get_adapter(type(obj))
-    ctx_manager.add_to_context(manager, obj, adapter.get_version_data(obj, ctx_manager.get_db()))
-
-
 def get_revisions(obj, n_revisions=3):
     """
     Get the last N diffs for an object that is tracked by django-reversion, as (v1, v2) human-readable string tuples.
@@ -28,7 +14,7 @@ def get_revisions(obj, n_revisions=3):
     :return: list of (revision, {field: (v1_str, v2_str), ...}) tuples
     """
     # type: (Any, int) -> List[Tuple[reversion.models.Revision, Dict[str, Tuple[str, str]]]]
-    recent_versions = reversion.get_for_object(obj)[:n_revisions+1]
+    recent_versions = Version.objects.get_for_object(obj)[:n_revisions+1]
 
     # grab fields on the object's model
     fields = [f for f in obj._meta.fields]
@@ -95,7 +81,7 @@ def field_to_str_escaped(field, version):
         :param version: django-reversion Version object
         :return: human-readable string representing field of version; may contain HTML
         """
-        value = version.field_dict[field.name]
+        value = version.field_dict.get(field.name)
         if field.get_internal_type() == 'ManyToManyField':
             ids = [int(v) for v in value]
             related_model = field.rel.to
@@ -106,7 +92,7 @@ def field_to_str_escaped(field, version):
                 ids.remove(related.pk)
 
             # if it's not in the live set, try and find it in a "deleted" revision
-            for version in reversion.get_deleted(related_model).filter(object_id_int__in=ids):
+            for version in Version.objects.get_deleted(related_model).filter(object_id__in=ids):
                 # we use the object repr here because version.object is none for some reason
                 related_objs.append(MutedStr('deleted: {}'.format(version.object_repr)))
                 ids.remove(version.object_id_int)
@@ -129,77 +115,11 @@ def field_to_str_escaped(field, version):
 
     changes = {}
     for field in fields:
-        v1_value = v1.field_dict[field.name]
-        v2_value = v2.field_dict[field.name]
+        v1_value = v1.field_dict.get(field.name)
+        v2_value = v2.field_dict.get(field.name)
         if v1_value != v2_value:
             v1_str = field_to_str_escaped(field, v1)
             v2_str = field_to_str_escaped(field, v2)
             changes[field.name] = (v1_str, v2_str)
 
     return changes
-
-
-class RevisionMixin(object):
-    """
-    Backport of reversion.views.RevisionMixin since we are using a version of reversion from 2015.
-    Wraps HTTP requests (except for GET/HEAD/OPTIONS) in an atomic django-reversion revision, plus
-    some magic to follow reverse m2m fields in checks (which django-reversion should handle, but I
-    can't get it to work :shakes fist:).
-
-    Set 'revision_comment' if you would like this revision to be recorded as a fixed string instead
-    of a list of changed fields. Currently used for 'created' and 'deleted' messages.
-
-    Set 'revision_follow' to a list of attributes on self.object that return querysets or foreign keys
-    that we should include with any revisions (for example, 'service_set' for status checks).
-    """
-
-    revision_comment = ''
-    revision_follow = []
-
-    def dispatch(self, *args, **kwargs):
-        if self.request.method not in ('GET', 'HEAD', 'OPTIONS'):
-            with transaction.atomic(), reversion.create_revision():
-                # fill in the revision metadata
-                if not isinstance(self.request.user, AnonymousUser):
-                    reversion.set_user(self.request.user)
-                if self.revision_comment:
-                    reversion.set_comment(self.revision_comment)
-
-                related = self._gather_reverse_pks()
-                ret = super(RevisionMixin, self).dispatch(*args, **kwargs)
-                related = self._gather_reverse_pks(related)
-
-                for model, pks in related.items():
-                    for obj in model.objects.filter(pk__in=pks):
-                        add_to_revision(obj)
-
-                return ret
-        else:
-            return super(RevisionMixin, self).dispatch(*args, **kwargs)
-
-    def _gather_reverse_pks(self, model_to_pks=None):
-        model_to_pks = model_to_pks or defaultdict(list)
-
-        if not self.revision_follow:
-            return model_to_pks
-
-        obj = getattr(self, 'object', None)
-        if obj is None and 'pk' in getattr(self, 'kwargs', []) and hasattr(self, 'get_object'):
-            obj = self.get_object()
-        if obj is None or obj.pk is None:
-            return model_to_pks
-
-        for field_name in self.revision_follow:
-            value = getattr(obj, field_name, None)
-            if isinstance(value, (Manager, QuerySet)):
-                # m2m/o2m relationship
-                field_model = value.model
-                model_to_pks[field_model].extend(value.values_list('pk', flat=True))
-            elif isinstance(value, Model):
-                # foreign key
-                field_model = value._meta.model
-                model_to_pks[field_model].append(value.pk)
-            else:
-                raise RuntimeError('Could not follow field {} (missing? unsupported type?)'.format(field_name))
-
-        return model_to_pks
diff --git a/cabot/cabotapp/views.py b/cabot/cabotapp/views.py
index 61bf766bc..24b4c924d 100644
--- a/cabot/cabotapp/views.py
+++ b/cabot/cabotapp/views.py
@@ -8,7 +8,8 @@
 from timezone_field import TimeZoneFormField
 
 from cabot.cabotapp.alert import AlertPlugin
-from cabot.cabotapp.revision_utils import get_revisions, RevisionMixin
+from cabot.cabotapp.revision_utils import get_revisions
+from reversion.views import RevisionMixin
 from models import (StatusCheck,
                     JenkinsStatusCheck,
                     HttpStatusCheck,
diff --git a/cabot/metricsapp/views/grafana_elastic.py b/cabot/metricsapp/views/grafana_elastic.py
index 5e22a7f81..d3f5a1682 100644
--- a/cabot/metricsapp/views/grafana_elastic.py
+++ b/cabot/metricsapp/views/grafana_elastic.py
@@ -3,8 +3,8 @@
 from django.core.urlresolvers import reverse
 from django.shortcuts import render
 from django.views.generic import UpdateView, CreateView
+from reversion.views import RevisionMixin
 from cabot.cabotapp.views import LoginRequiredMixin
-from cabot.cabotapp.revision_utils import RevisionMixin
 from cabot.metricsapp.api import get_es_status_check_fields, get_status_check_fields
 from cabot.metricsapp.forms import GrafanaElasticsearchStatusCheckForm, GrafanaElasticsearchStatusCheckUpdateForm
 from cabot.metricsapp.models import ElasticsearchStatusCheck, GrafanaDataSource
diff --git a/requirements.txt b/requirements.txt
index 834ca36c9..7b8c5a12e 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -48,4 +48,4 @@ unittest-xml-reporting==2.1.0
 PyMySQL==0.8.0
 jsondiff==1.1.2
 django-timezone-field==3.0
-django-reversion==1.8.7
+django-reversion==3.0.3