From e54211deeec54d2a136b6039559f5dc32970f143 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Sat, 20 Nov 2021 18:27:53 +0100
Subject: [PATCH] snapshot: show differences between 2 versions (#58798)

---
 tests/test_snapshots.py                       | 90 ++++++++++++++++++-
 wcs/backoffice/snapshots.py                   | 54 ++++++++++-
 wcs/qommon/static/css/dc2/admin.scss          | 25 ++++++
 wcs/snapshots.py                              |  9 +-
 wcs/templates/wcs/backoffice/snapshots.html   | 20 ++++-
 .../wcs/backoffice/snapshots_compare.html     | 11 +++
 6 files changed, 201 insertions(+), 8 deletions(-)
 create mode 100644 wcs/templates/wcs/backoffice/snapshots_compare.html

diff --git a/tests/test_snapshots.py b/tests/test_snapshots.py
index 6e60eeca..e7568368 100644
--- a/tests/test_snapshots.py
+++ b/tests/test_snapshots.py
@@ -152,6 +152,94 @@ def test_snapshot_basics(pub):
     assert [int(f.id) for f in snapshot6.instance.fields] == list(range(0, 11))
 
 
+def test_snapshot_diff(pub):
+    create_superuser(pub)
+    create_role(pub)
+
+    formdef = FormDef()
+    formdef.name = 'testform'
+    formdef.fields = []
+    formdef.store()
+    assert pub.snapshot_class.count() == 1
+    snapshot1 = pub.snapshot_class.get_latest('formdef', formdef.id)
+
+    formdef.fields = [StringField(id=1, label='Test', type='string')]
+    formdef.store()
+    assert pub.snapshot_class.count() == 2
+    snapshot2 = pub.snapshot_class.get_latest('formdef', formdef.id)
+
+    formdef.fields += [StringField(id=2, label='Test bis', type='string')]
+    formdef.store()
+    assert pub.snapshot_class.count() == 3
+    snapshot3 = pub.snapshot_class.get_latest('formdef', formdef.id)
+
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/forms/%s/history/' % formdef.id)
+    assert 'name="version1" value="%s"' % snapshot3.id in resp
+    assert 'name="version2" value="%s"' % snapshot3.id not in resp
+    assert 'name="version1" value="%s"' % snapshot2.id in resp
+    assert 'name="version2" value="%s"' % snapshot2.id in resp
+    assert 'name="version1" value="%s"' % snapshot1.id not in resp
+    assert 'name="version2" value="%s"' % snapshot1.id in resp
+
+    resp = app.get(
+        '/backoffice/forms/%s/history/compare?version1=%s&version2=%s'
+        % (formdef.id, snapshot1.id, snapshot3.id)
+    )
+    assert 'Snapshot <a href="%s/view/">%s</a>' % (snapshot1.id, snapshot1.id) in resp
+    assert 'Snapshot <a href="%s/view/">%s</a>' % (snapshot3.id, snapshot3.id) in resp
+    assert resp.text.count('diff_sub') == 1
+    assert resp.text.count('diff_add') == 24
+
+    resp = app.get(
+        '/backoffice/forms/%s/history/compare?version1=%s&version2=%s'
+        % (formdef.id, snapshot3.id, snapshot1.id)
+    )
+    assert 'Snapshot <a href="%s/view/">%s</a>' % (snapshot1.id, snapshot1.id) in resp
+    assert 'Snapshot <a href="%s/view/">%s</a>' % (snapshot3.id, snapshot3.id) in resp
+    assert resp.text.count('diff_sub') == 1
+    assert resp.text.count('diff_add') == 24
+
+    resp = app.get(
+        '/backoffice/forms/%s/history/compare?version1=%s&version2=%s'
+        % (formdef.id, snapshot2.id, snapshot3.id)
+    )
+    assert 'Snapshot <a href="%s/view/">%s</a>' % (snapshot2.id, snapshot2.id) in resp
+    assert 'Snapshot <a href="%s/view/">%s</a>' % (snapshot3.id, snapshot3.id) in resp
+    assert resp.text.count('diff_sub') == 0
+    assert resp.text.count('diff_add') == 11
+
+    formdef.fields = [StringField(id=1, label='Test', type='string')]
+    formdef.store()
+    assert pub.snapshot_class.count() == 4
+    snapshot4 = pub.snapshot_class.get_latest('formdef', formdef.id)
+
+    resp = app.get(
+        '/backoffice/forms/%s/history/compare?version1=%s&version2=%s'
+        % (formdef.id, snapshot3.id, snapshot4.id)
+    )
+    assert 'Snapshot <a href="%s/view/">%s</a>' % (snapshot3.id, snapshot3.id) in resp
+    assert 'Snapshot <a href="%s/view/">%s</a>' % (snapshot4.id, snapshot4.id) in resp
+    assert resp.text.count('diff_sub') == 11
+    assert resp.text.count('diff_add') == 0
+
+    resp = app.get('/backoffice/forms/%s/history/compare' % (formdef.id), status=404)
+    resp = app.get(
+        '/backoffice/forms/%s/history/compare?version1=%s' % (formdef.id, snapshot4.id), status=404
+    )
+    resp = app.get(
+        '/backoffice/forms/%s/history/compare?version2=%s' % (formdef.id, snapshot4.id), status=404
+    )
+    resp = app.get(
+        '/backoffice/forms/%s/history/compare?version1=%s&version2=%s' % (formdef.id, snapshot3.id, 0),
+        status=404,
+    )
+    resp = app.get(
+        '/backoffice/forms/%s/history/compare?version1=%s&version2=%s' % (formdef.id, 0, snapshot4.id),
+        status=404,
+    )
+
+
 def test_snapshot_instance(pub):
     formdef = FormDef()
     formdef.name = 'testform'
@@ -806,7 +894,7 @@ def test_snaphost_workflow_status_item_comments(pub):
     resp = app.get('/backoffice/workflows/%s/history/' % workflow.id)
     comments = [
         x.text[18 : x.text.find('\n')]
-        for x in resp.html.find('ul', {'class': 'snapshots-list'}).find_all('li')
+        for x in resp.html.find('ul', {'class': 'snapshots-list'}).find_all('span', {'class': 'label'})
     ]
     assert comments == [
         'Deletion of action "Webservice (foo)" in status "baz"',
diff --git a/wcs/backoffice/snapshots.py b/wcs/backoffice/snapshots.py
index bea03965..a59dc32e 100644
--- a/wcs/backoffice/snapshots.py
+++ b/wcs/backoffice/snapshots.py
@@ -14,7 +14,9 @@
 # You should have received a copy of the GNU General Public License
 # along with this program; if not, see <http://www.gnu.org/licenses/>.
 
-from quixote import get_publisher, get_response, get_session, redirect
+import difflib
+
+from quixote import get_publisher, get_request, get_response, get_session, redirect
 from quixote.directory import Directory
 from quixote.html import TemplateIO, htmltext
 
@@ -31,7 +33,7 @@ from wcs.wscalls import NamedWsCall
 
 
 class SnapshotsDirectory(Directory):
-    _q_exports = ['', 'save']
+    _q_exports = ['', 'save', 'compare']
     do_not_call_in_templates = True
 
     def __init__(self, instance):
@@ -68,6 +70,54 @@ class SnapshotsDirectory(Directory):
         r += form.render()
         return r.getvalue()
 
+    def compare(self):
+        get_response().breadcrumb.append(('compare/', _('Compare')))
+        html_top('', _('Compare'))
+
+        id1 = get_request().form.get('version1')
+        id2 = get_request().form.get('version2')
+        if not id1 or not id2:
+            raise errors.TraversalError()
+
+        snapshot1 = get_publisher().snapshot_class.get(id1, ignore_errors=True)
+        snapshot2 = get_publisher().snapshot_class.get(id2, ignore_errors=True)
+        if not snapshot1 or not snapshot2:
+            raise errors.TraversalError()
+        if snapshot1.timestamp > snapshot2.timestamp:
+            snapshot1, snapshot2 = snapshot2, snapshot1
+
+        def snapshot_desc(snapshot):
+            label_or_comment = ''
+            if snapshot.label:
+                label_or_comment = snapshot.label
+            elif snapshot.comment:
+                label_or_comment = snapshot.comment
+            return '{name} <a href="{pk}/view/">{pk}</a><br />{label_or_comment}<br />({user}{timestamp})'.format(
+                name=_('Snapshot'),
+                pk=snapshot.id,
+                label_or_comment=label_or_comment,
+                user='%s ' % snapshot.user if snapshot.user_id else '',
+                timestamp=misc.strftime(misc.datetime_format(), snapshot.timestamp),
+            )
+
+        serialization1 = snapshot1.get_serialization(indented=True)
+        serialization2 = snapshot2.get_serialization(indented=True)
+        diff_serialization = difflib.HtmlDiff(wrapcolumn=160).make_table(
+            fromlines=serialization1.splitlines(True),
+            tolines=serialization2.splitlines(True),
+            fromdesc=snapshot_desc(snapshot1),
+            todesc=snapshot_desc(snapshot2),
+        )
+
+        return template.QommonTemplateResponse(
+            templates=['wcs/backoffice/snapshots_compare.html'],
+            context={
+                'snapshot1': snapshot1,
+                'snapshot2': snapshot2,
+                'diff_serialization': diff_serialization,
+            },
+        )
+
     def snapshots(self):
         current_date = None
         snapshots = get_publisher().snapshot_class.select_object_history(self.obj)
diff --git a/wcs/qommon/static/css/dc2/admin.scss b/wcs/qommon/static/css/dc2/admin.scss
index f50d0aec..54dd1126 100644
--- a/wcs/qommon/static/css/dc2/admin.scss
+++ b/wcs/qommon/static/css/dc2/admin.scss
@@ -2209,3 +2209,28 @@ div.timetable-widget {
 		box-shadow: -0.5em -0.5em 0 .5em hsla(0, 0%, 0%, 0.05);
 	}
 }
+
+table.diff {
+	border: 1px solid black;
+	width: 100%;
+	.diff_header {
+		background-color: #e0e0e0;
+	}
+	td.diff_header {
+		text-align: right;
+		padding-right: 10px;
+		color: #606060;
+	}
+	.diff_next {
+		display: none;
+	}
+	.diff_add {
+		background-color: #aaffaa;
+	}
+	.diff_chg {
+		background-color: #ffff77;
+	}
+	.diff_sub {
+		background-color: #ffaaaa;
+	}
+}
diff --git a/wcs/snapshots.py b/wcs/snapshots.py
index 0c162f56..3a6cde28 100644
--- a/wcs/snapshots.py
+++ b/wcs/snapshots.py
@@ -196,10 +196,15 @@ class Snapshot:
                 return klass
         raise KeyError('no class for object type: %s' % self.object_type)
 
-    def get_serialization(self):
+    def get_serialization(self, indented=True):
         # there is a complete serialization
         if self.serialization:
-            return self.serialization
+            if not indented:
+                return self.serialization
+
+            tree = ET.fromstring(self.serialization)
+            indent(tree)
+            return ET.tostring(tree).decode('utf-8')
 
         # get latest version with serialization
         latest_complete = self.__class__.get_latest(
diff --git a/wcs/templates/wcs/backoffice/snapshots.html b/wcs/templates/wcs/backoffice/snapshots.html
index 8f376e64..9a3a494c 100644
--- a/wcs/templates/wcs/backoffice/snapshots.html
+++ b/wcs/templates/wcs/backoffice/snapshots.html
@@ -10,11 +10,20 @@
 {% with snapshots=view.snapshots %}
 {% if snapshots %}
 <div class="section">
+<form action="compare" method="get">
+{% if snapshots|length > 1 %}
+<input type="submit" value="{% trans "Show differences" %}"/>
+{% endif %}
 <ul class="objects-list snapshots-list">
 {% for snapshot in snapshots %}
-<li data-day="{{ snapshot.timestamp|date:"Y-m-d" }}" class="{% if snapshot.new_day %}new-day{% elif snapshot.label %}has-label{% else %}collapsed{% endif %}"
-        >{{ snapshot.timestamp }}{% if snapshot.label %} <strong>{{ snapshot.label }}</strong>{% elif snapshot.comment %}, {{ snapshot.comment }}{% endif %}
-    {% if snapshot.user_id %}({{ snapshot.user }}){% endif %}
+<li data-day="{{ snapshot.timestamp|date:"Y-m-d" }}" class="{% if snapshot.new_day %}new-day{% elif snapshot.label %}has-label{% else %}collapsed{% endif %}">
+    <span class="counter">#{{ snapshot.id }}</span>
+    {% if snapshots|length > 1 %}
+      {% if not forloop.last %}<input type="radio" name="version1" value="{{ snapshot.id }}" {% if forloop.first %}checked="checked"{% endif %} />{% else %}&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;{% endif %}
+      {% if not forloop.first %}<input type="radio" name="version2" value="{{ snapshot.id }}" {% if forloop.counter == 2 %}checked="checked"{% endif %}/>{% else %}&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;{% endif %}
+    {% endif %}
+    <span class="label">{{ snapshot.timestamp }}{% if snapshot.label %} <strong>{{ snapshot.label }}</strong>{% elif snapshot.comment %}, {{ snapshot.comment }}{% endif %}
+        {% if snapshot.user_id %}({{ snapshot.user }}){% endif %}</span>
     {% if snapshot.new_day and snapshot.day_other_count %}(<a class="reveal"
             href="#day-{{ snapshot.timestamp|date:"Y-m-d"}}">
     {% if snapshot.day_other_count >= 50 %}<strong>{% endif %}
@@ -33,6 +42,7 @@
 </li>
 {% endfor %}
 </ul>
+</form>
 </div>
 {% else %}
 <div class="infonotice"><p>{% trans "No changes history" %}</p></div>
@@ -47,6 +57,10 @@ $(function() {
     $('.snapshots-list li[data-day="' + day + '"]').toggleClass('collapsed');
     return false;
   });
+  $('input[name="version1"]').on('click', function() {
+    var next = $(this).parent('li').next();
+    $('input[name="version2"]', next).prop('checked', true);
+  });
 });
 </script>
 
diff --git a/wcs/templates/wcs/backoffice/snapshots_compare.html b/wcs/templates/wcs/backoffice/snapshots_compare.html
new file mode 100644
index 00000000..ff9fd1a6
--- /dev/null
+++ b/wcs/templates/wcs/backoffice/snapshots_compare.html
@@ -0,0 +1,11 @@
+{% load i18n %}
+
+{% block body %}
+<div id="appbar">
+<h2>{% trans "Compare snapshots" %}</h2>
+</div>
+
+<div class="section">
+  {{ diff_serialization|safe }}
+</div>
+{% endblock %}
-- 
2.33.0

