From c573b8c8819f3e559379a26f8f7f20fdd629d079 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Fri, 24 Jun 2022 08:47:23 +0200
Subject: [PATCH] snapshot: compare inspect of 2 versions (#66565)

---
 debian/control                                |   1 +
 setup.py                                      |   2 +
 tests/test_snapshots.py                       | 226 ++++++++++++------
 wcs/admin/blocks.py                           |   6 +-
 wcs/admin/forms.py                            |   6 +-
 wcs/admin/workflows.py                        |   6 +-
 wcs/backoffice/snapshots.py                   | 144 +++++++++--
 wcs/qommon/static/css/dc2/admin.scss          |  31 ++-
 .../wcs/backoffice/block-inspect.html         |   2 +-
 .../wcs/backoffice/formdef-inspect.html       |   6 +-
 .../wcs/backoffice/snapshots_compare.html     |  34 ++-
 .../wcs/backoffice/workflow-inspect.html      |  60 ++---
 12 files changed, 386 insertions(+), 138 deletions(-)

diff --git a/debian/control b/debian/control
index 1be2da0a6..0ea9d2a1d 100644
--- a/debian/control
+++ b/debian/control
@@ -24,6 +24,7 @@ Depends: graphviz,
          python3-dnspython,
          python3-hobo,
          python3-lasso,
+         python3-lxml,
          python3-pil,
          python3-psycopg2,
          python3-pyproj,
diff --git a/setup.py b/setup.py
index 23a2a2f85..e10e0259a 100644
--- a/setup.py
+++ b/setup.py
@@ -178,7 +178,9 @@ setup(
         'XStatic-Leaflet-GestureHandling',
         'XStatic-Select2',
         'pyproj',
+        'pyquery',
         'unidecode',
+        'lxml',
     ],
     package_dir={'wcs': 'wcs'},
     packages=find_packages(),
diff --git a/tests/test_snapshots.py b/tests/test_snapshots.py
index 3dcde6c7e..9a8c1ce48 100644
--- a/tests/test_snapshots.py
+++ b/tests/test_snapshots.py
@@ -149,7 +149,66 @@ def test_snapshot_basics(pub):
     assert [int(f.id) for f in snapshot6.instance.fields] == list(range(0, 12))
 
 
-def test_snapshot_diff(pub):
+def test_snapshot_instance(pub):
+    formdef = FormDef()
+    formdef.name = 'testform'
+    formdef.fields = []
+    formdef.store()
+
+    carddef = CardDef()
+    carddef.name = 'testcard'
+    carddef.fields = []
+    carddef.store()
+
+    # remove existing snapshots as they may be duplicated if table_name was
+    # generated in a different second.
+    pub.snapshot_class.wipe()
+
+    carddef.name = 'testcard2'
+    carddef.store()
+
+    for i in range(10):
+        formdef.name = 'testform %s' % i
+        formdef.store()
+
+    assert pub.snapshot_class.count() == 11
+
+    snapshots = pub.snapshot_class.select_object_history(formdef)
+    assert len(snapshots) == 10
+    for i in range(10):
+        assert snapshots[i].serialization is None  # not loaded
+        assert snapshots[i].patch is None  # not loaded
+        assert pub.snapshot_class.get(snapshots[i].id).instance.name == 'testform %s' % (9 - i)
+
+    snapshots = pub.snapshot_class.select_object_history(carddef)
+    assert len(snapshots) == 1
+
+
+def test_snapshot_user(pub):
+    user = pub.user_class()
+    user.name = 'User Name'
+    user.email = 'foo@localhost'
+    user.store()
+
+    carddef = CardDef()
+    carddef.name = 'testcard'
+    carddef.fields = []
+    carddef.store()
+    snapshot = pub.snapshot_class.select_object_history(carddef)[0]
+    assert snapshot.user is None
+
+    snapshot.user_id = user.id
+    snapshot.store()
+    snapshot = pub.snapshot_class.select_object_history(carddef)[0]
+    assert str(snapshot.user) == 'User Name'
+
+    snapshot.user_id = 'nope'
+    snapshot.store()
+    snapshot = pub.snapshot_class.select_object_history(carddef)[0]
+    assert str(snapshot.user) == 'unknown user'
+
+
+def test_form_snapshot_diff(pub):
     create_superuser(pub)
     create_role(pub)
 
@@ -187,6 +246,11 @@ def test_snapshot_diff(pub):
     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 = resp.click('Compare inspect')
+    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 'http://example.net/backoffice/forms/%s/fields/1/' % formdef.id in resp
+    assert 'http://example.net/backoffice/forms/%s/fields/2/' % formdef.id in resp
 
     resp = app.get(
         '/backoffice/forms/%s/history/compare?version1=%s&version2=%s'
@@ -220,80 +284,22 @@ def test_snapshot_diff(pub):
     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(
+    app.get('/backoffice/forms/%s/history/compare' % (formdef.id), status=404)
+    app.get('/backoffice/forms/%s/history/compare?version1=%s' % (formdef.id, snapshot4.id), status=404)
+    app.get('/backoffice/forms/%s/history/compare?version2=%s' % (formdef.id, snapshot4.id), status=404)
+    app.get(
         '/backoffice/forms/%s/history/compare?version1=%s&version2=%s' % (formdef.id, snapshot3.id, 0),
         status=404,
     )
-    resp = app.get(
+    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'
-    formdef.fields = []
-    formdef.store()
-
-    carddef = CardDef()
-    carddef.name = 'testcard'
-    carddef.fields = []
-    carddef.store()
-
-    # remove existing snapshots as they may be duplicated if table_name was
-    # generated in a different second.
-    pub.snapshot_class.wipe()
-
-    carddef.name = 'testcard2'
-    carddef.store()
-
-    for i in range(10):
-        formdef.name = 'testform %s' % i
-        formdef.store()
-
-    assert pub.snapshot_class.count() == 11
-
-    snapshots = pub.snapshot_class.select_object_history(formdef)
-    assert len(snapshots) == 10
-    for i in range(10):
-        assert snapshots[i].serialization is None  # not loaded
-        assert snapshots[i].patch is None  # not loaded
-        assert pub.snapshot_class.get(snapshots[i].id).instance.name == 'testform %s' % (9 - i)
-
-    snapshots = pub.snapshot_class.select_object_history(carddef)
-    assert len(snapshots) == 1
-
-
-def test_snapshot_user(pub):
-    user = pub.user_class()
-    user.name = 'User Name'
-    user.email = 'foo@localhost'
-    user.store()
-
-    carddef = CardDef()
-    carddef.name = 'testcard'
-    carddef.fields = []
-    carddef.store()
-    snapshot = pub.snapshot_class.select_object_history(carddef)[0]
-    assert snapshot.user is None
-
-    snapshot.user_id = user.id
-    snapshot.store()
-    snapshot = pub.snapshot_class.select_object_history(carddef)[0]
-    assert str(snapshot.user) == 'User Name'
-
-    snapshot.user_id = 'nope'
-    snapshot.store()
-    snapshot = pub.snapshot_class.select_object_history(carddef)[0]
-    assert str(snapshot.user) == 'unknown user'
+    app.get(
+        '/backoffice/forms/%s/history/compare?version1=%s&version2=%s&mode=foobar'
+        % (formdef.id, snapshot1.id, snapshot3.id),
+        status=404,
+    )
 
 
 def test_form_snapshot_comments(pub):
@@ -587,6 +593,70 @@ def test_form_snapshot_browse_with_import_error(pub):
     assert 'Can not display snapshot (Unknown referenced objects [Unknown field types: foobar])' in resp
 
 
+def test_workflow_snapshot_diff(pub):
+    create_superuser(pub)
+    create_role(pub)
+
+    Workflow.wipe()
+    workflow = Workflow(name='test')
+    workflow.store()
+    assert pub.snapshot_class.count() == 1
+    snapshot1 = pub.snapshot_class.get_latest('workflow', workflow.id)
+
+    workflow.add_status('Status1', 'st1')
+    workflow.store()
+    assert pub.snapshot_class.count() == 2
+    snapshot2 = pub.snapshot_class.get_latest('workflow', workflow.id)
+
+    ac1 = workflow.add_global_action('Action', 'ac1')
+    trigger = ac1.triggers[0]
+    assert trigger.key == 'manual'
+    trigger.roles = ['foobar']
+    workflow.store()
+    assert pub.snapshot_class.count() == 3
+    snapshot3 = pub.snapshot_class.get_latest('workflow', workflow.id)
+
+    workflow.global_actions = []
+    workflow.store()
+    assert pub.snapshot_class.count() == 4
+    snapshot4 = pub.snapshot_class.get_latest('workflow', workflow.id)
+
+    app = login(get_app(pub))
+    resp = app.get(
+        '/backoffice/workflows/%s/history/compare?version1=%s&version2=%s&mode=inspect'
+        % (workflow.id, snapshot1.id, snapshot2.id)
+    )
+    assert 'Snapshot <a href="%s/view/">%s</a>' % (snapshot1.id, snapshot1.id) in resp
+    assert 'Snapshot <a href="%s/view/">%s</a>' % (snapshot2.id, snapshot2.id) in resp
+    assert 'id="tab-statuses"' in resp
+    assert 'id="tab-global-actions"' not in resp
+
+    resp = app.get(
+        '/backoffice/workflows/%s/history/compare?version1=%s&version2=%s&mode=inspect'
+        % (workflow.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 'http://example.net/backoffice/workflows/%s/global-actions/ac1/' % (workflow.id) in resp
+    assert 'http://example.net/backoffice/workflows/%s/status/st1/' % workflow.id in resp
+    assert 'id="tab-statuses"' in resp
+    assert 'id="tab-global-actions"' in resp
+
+    resp = app.get(
+        '/backoffice/workflows/%s/history/compare?version1=%s&version2=%s&mode=inspect'
+        % (workflow.id, snapshot3.id, snapshot4.id)
+    )
+    assert 'id="tab-statuses"' in resp
+    assert 'id="tab-global-actions"' in resp
+
+    resp = app.get(
+        '/backoffice/workflows/%s/history/compare?version1=%s&version2=%s&mode=inspect'
+        % (workflow.id, snapshot1.id, snapshot4.id)
+    )
+    assert 'id="tab-statuses"' in resp
+    assert 'id="tab-global-actions"' not in resp
+
+
 def test_workflow_snapshot_browse(pub):
     create_superuser(pub)
     create_role(pub)
@@ -1003,6 +1073,24 @@ def test_mail_template_snapshot_restore(pub):
     mail_template2 = MailTemplate.get(resp.location.split('/')[-2])
     assert mail_template2.id == mail_template.id
 
+    snapshot1 = pub.snapshot_class.select_object_history(mail_template)[0]
+    snapshot2 = pub.snapshot_class.select_object_history(mail_template)[1]
+    app.get(
+        '/backoffice/workflows/mail-templates/%s/history/compare?version1=%s&version2=%s&mode=xml'
+        % (mail_template.id, snapshot1.id, snapshot2.id),
+        status=200,
+    )
+    app.get(
+        '/backoffice/workflows/mail-templates/%s/history/compare?version1=%s&version2=%s&mode=inspect'
+        % (mail_template.id, snapshot1.id, snapshot2.id),
+        status=404,
+    )
+    app.get(
+        '/backoffice/workflows/mail-templates/%s/history/compare?version1=%s&version2=%s&mode=foobar'
+        % (mail_template.id, snapshot1.id, snapshot2.id),
+        status=404,
+    )
+
 
 def test_mail_template_snapshot_browse(pub):
     create_superuser(pub)
diff --git a/wcs/admin/blocks.py b/wcs/admin/blocks.py
index b8e9ba625..6ca116287 100644
--- a/wcs/admin/blocks.py
+++ b/wcs/admin/blocks.py
@@ -58,7 +58,8 @@ class BlockDirectory(FieldsDirectory):
     fields_count_total_hard_limit = 60
 
     def __init__(self, section='forms', *args, **kwargs):
-        if kwargs.pop('component', None):  # snapshot
+        kwargs.pop('component', None)  # snapshot
+        if 'instance' in kwargs:
             kwargs['objectdef'] = kwargs.pop('instance')
         self.section = section
         super().__init__(*args, **kwargs)
@@ -211,6 +212,9 @@ class BlockDirectory(FieldsDirectory):
     def inspect(self):
         self.html_top(self.objectdef.name)
         get_response().breadcrumb.append(('inspect', _('Inspector')))
+        return self.render_inspect()
+
+    def render_inspect(self):
         context = {'blockdef': self.objectdef, 'view': self}
         return template.QommonTemplateResponse(
             templates=['wcs/backoffice/block-inspect.html'], context=context
diff --git a/wcs/admin/forms.py b/wcs/admin/forms.py
index b29d4cc98..9cb91aaef 100644
--- a/wcs/admin/forms.py
+++ b/wcs/admin/forms.py
@@ -647,7 +647,8 @@ class FormDefPage(Directory):
         except KeyError:
             raise TraversalError()
         self.formdefui = self.formdef_ui_class(self.formdef)
-        get_response().breadcrumb.append((component + '/', self.formdef.name))
+        if component:
+            get_response().breadcrumb.append((component + '/', self.formdef.name))
         self.fields = self.fields_directory_class(self.formdef)
         self.fields.html_top = self.html_top
         self.role = WorkflowRoleDirectory(self.formdef)
@@ -1647,6 +1648,9 @@ class FormDefPage(Directory):
     def inspect(self):
         self.html_top(self.formdef.name)
         get_response().breadcrumb.append(('inspect', _('Inspector')))
+        return self.render_inspect()
+
+    def render_inspect(self):
         context = {'formdef': self.formdef, 'view': self}
         if self.formdef.workflow.variables_formdef:
             context['workflow_options'] = {}
diff --git a/wcs/admin/workflows.py b/wcs/admin/workflows.py
index 8e69a312e..39cb7cc00 100644
--- a/wcs/admin/workflows.py
+++ b/wcs/admin/workflows.py
@@ -1602,7 +1602,8 @@ class WorkflowPage(Directory):
         self.criticality_levels_dir = CriticalityLevelsDirectory(self.workflow)
         self.logged_errors_dir = LoggedErrorsDirectory(parent_dir=self, workflow_id=self.workflow.id)
         self.snapshots_dir = SnapshotsDirectory(self.workflow)
-        get_response().breadcrumb.append((component + '/', self.workflow.name))
+        if component:
+            get_response().breadcrumb.append((component + '/', self.workflow.name))
 
     def html_top(self, title):
         return html_top('workflows', title)
@@ -1707,6 +1708,9 @@ class WorkflowPage(Directory):
     def inspect(self):
         self.html_top(self.workflow.name)
         get_response().breadcrumb.append(('inspect', _('Inspector')))
+        return self.render_inspect()
+
+    def render_inspect(self):
         context = {'workflow': self.workflow, 'view': self}
         return template.QommonTemplateResponse(
             templates=['wcs/backoffice/workflow-inspect.html'], context=context
diff --git a/wcs/backoffice/snapshots.py b/wcs/backoffice/snapshots.py
index d209ad675..24194cd8c 100644
--- a/wcs/backoffice/snapshots.py
+++ b/wcs/backoffice/snapshots.py
@@ -15,8 +15,11 @@
 # along with this program; if not, see <http://www.gnu.org/licenses/>.
 
 import difflib
+import re
 
 from django.utils.module_loading import import_string
+from lxml.html.diff import htmldiff  # pylint: disable=no-name-in-module
+from pyquery import PyQuery as pq
 from quixote import get_publisher, get_request, get_response, get_session, redirect
 from quixote.directory import Directory
 from quixote.html import TemplateIO, htmltext
@@ -70,10 +73,14 @@ class SnapshotsDirectory(Directory):
         get_response().breadcrumb.append(('compare/', _('Compare')))
         html_top('', _('Compare'))
 
+        mode = get_request().form.get('mode') or 'xml'
+
         id1 = get_request().form.get('version1')
         id2 = get_request().form.get('version2')
         if not id1 or not id2:
             raise errors.TraversalError()
+        if mode not in ['xml', 'inspect']:
+            raise errors.TraversalError()
 
         snapshot1 = get_publisher().snapshot_class.get(id1, ignore_errors=True)
         snapshot2 = get_publisher().snapshot_class.get(id2, ignore_errors=True)
@@ -82,37 +89,128 @@ class SnapshotsDirectory(Directory):
         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),
-            )
+        klass = snapshot1.get_object_class()
+        backoffice_class = import_string(klass.backoffice_class)
+        has_inspect = hasattr(backoffice_class, 'render_inspect')
+
+        if mode == 'inspect' and not has_inspect:
+            raise errors.TraversalError()
+
+        context = getattr(self, 'get_compare_%s_context' % mode)(snapshot1, snapshot2)
+        context.update(
+            {
+                'mode': mode,
+                'has_inspect': has_inspect,
+                'snapshot1': snapshot1,
+                'snapshot2': snapshot2,
+            }
+        )
+        return template.QommonTemplateResponse(
+            templates=['wcs/backoffice/snapshots_compare.html'],
+            context=context,
+        )
+
+    def snapshot_desc(self, 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> - {label_or_comment} ({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),
+        )
 
+    def get_compare_xml_context(self, snapshot1, snapshot2):
         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,
-            },
-        )
+        return {
+            'fromdesc': self.snapshot_desc(snapshot1),
+            'todesc': self.snapshot_desc(snapshot2),
+            'diff_serialization': diff_serialization,
+        }
+
+    def get_compare_inspect_context(self, snapshot1, snapshot2):
+        klass = snapshot1.get_object_class()
+        backoffice_class = import_string(klass.backoffice_class)
+
+        def clean_panel(tab):
+            panel = pq(tab)
+            # remove quicknavs
+            panel.find('.inspect--quicknav').remove()
+            # remove page & field counters, for formdef
+            panel.find('.page-field-counters').remove()
+            # remove status colors
+            panel.find('.inspect-status--colour').remove()
+            return panel.html().strip('\n')
+
+        def fix_result(panel_diff):
+            if not panel_diff:
+                return panel_diff
+            panel = pq(panel_diff)
+            # remove "Link" added by htmldiff
+            for link in panel.find('a'):
+                d = pq(link)
+                text = d.html()
+                new_text = re.sub(r' Link: .*$', '', text)
+                d.html(new_text)
+            # remove empty ins and del tags
+            for elem in panel.find('ins, del'):
+                d = pq(elem)
+                if not d.html().strip():
+                    d.remove()
+            # prevent auto-closing behaviour of pyquery .html() method
+            for elem in panel.find('span, ul, div'):
+                d = pq(elem)
+                if not d.html():
+                    d.html(' ')
+            # sometimes status section are misplaced by htmldiff, fix it
+            for elem in panel.find('div.section.status'):
+                d = pq(elem)
+                parents = d.parents('div.section.status')
+                if parents:
+                    pq(parents[0]).after(d.remove())
+            return panel.html()
+
+        inspect1 = backoffice_class(component=None, instance=snapshot1.instance).render_inspect()
+        inspect1 = template.render(inspect1.templates, inspect1.context)
+        d1 = pq(str(inspect1))
+        inspect2 = backoffice_class(component=None, instance=snapshot2.instance).render_inspect()
+        inspect2 = template.render(inspect2.templates, inspect2.context)
+        d2 = pq(str(inspect2))
+        panels_attrs = [tab.attrib for tab in d1('[role="tabpanel"]')]
+        panels1 = [clean_panel(tab) for tab in d1('[role="tabpanel"]')]
+        panels2 = [clean_panel(tab) for tab in d2('[role="tabpanel"]')]
+
+        # build tab list (merge version 1 and version2)
+        tabs1 = d1.find('[role="tab"]')
+        tabs2 = d2.find('[role="tab"]')
+        tabs_order = [t.get('id') for t in panels_attrs]
+        tabs = {}
+        for tab in tabs1 + tabs2:
+            tab_id = pq(tab).attr('aria-controls')
+            tabs[tab_id] = pq(tab).outer_html()
+        tabs = [tabs[k] for k in tabs_order if k in tabs]
+
+        # build diff of each panel
+        panels_diff = list(map(htmldiff, panels1, panels2))
+        panels_diff = [fix_result(t) for t in panels_diff]
+
+        return {
+            'fromdesc': self.snapshot_desc(snapshot1),
+            'todesc': self.snapshot_desc(snapshot2),
+            'tabs': tabs,
+            'panels': zip(panels_attrs, panels_diff),
+            'tab_class_names': d1('.pk-tabs').attr('class'),
+        }
 
     def snapshots(self):
         current_date = None
diff --git a/wcs/qommon/static/css/dc2/admin.scss b/wcs/qommon/static/css/dc2/admin.scss
index 4c84bbaa5..871093dc8 100644
--- a/wcs/qommon/static/css/dc2/admin.scss
+++ b/wcs/qommon/static/css/dc2/admin.scss
@@ -969,7 +969,7 @@ div.full-screen-link {
 	text-align: right;
 }
 
-p.last-modification {
+p.last-modification, p.snapshot-description {
 	font-size: 80%;
 	margin: 0;
 }
@@ -2374,8 +2374,8 @@ div.timetable-widget {
 	min-width: 4em;
 }
 
-.section.diff {
-	background: transparent;
+div.diff {
+	margin: 1em 0;
 }
 
 table.diff {
@@ -2396,6 +2396,7 @@ table.diff {
 		 */
 		overflow: hidden;
 		text-overflow: ellipsis;
+		vertical-align: top;
 	}
 	.diff_header {
 		background: #f7f7f7;
@@ -2419,6 +2420,30 @@ table.diff {
 	}
 }
 
+ins {
+	text-decoration: none;
+	background-color: #d4fcbc;
+}
+
+del {
+	text-decoration: line-through;
+	background-color: #fbb6c2;
+	color: #555;
+}
+
+.inspect-tabs h3 {
+	del, ins {
+		font-weight: bold;
+		background-color: transparent;
+	}
+	del, del a {
+		color: #fbb6c2 !important;
+	}
+	ins, ins a {
+		color: #d4fcbc !important;
+	}
+}
+
 #sidebar .operator-and-value-widget {
 	.title-and-operator {
 		display: flex;
diff --git a/wcs/templates/wcs/backoffice/block-inspect.html b/wcs/templates/wcs/backoffice/block-inspect.html
index 33f5bf930..4e3a9fcb2 100644
--- a/wcs/templates/wcs/backoffice/block-inspect.html
+++ b/wcs/templates/wcs/backoffice/block-inspect.html
@@ -22,7 +22,7 @@
 
 <div id="inspect-fields" role="tabpanel" tabindex="0" aria-labelledby="tab-fields" hidden>
 {% for field in blockdef.fields %}
-{% include "wcs/backoffice/includes/inspect-field.html" %}
+{% include "wcs/backoffice/includes/inspect-field.html" with path=blockdef.get_admin_url %}
 {% endfor %}
 </div>
 </div>
diff --git a/wcs/templates/wcs/backoffice/formdef-inspect.html b/wcs/templates/wcs/backoffice/formdef-inspect.html
index 9450caa27..fbe90a82d 100644
--- a/wcs/templates/wcs/backoffice/formdef-inspect.html
+++ b/wcs/templates/wcs/backoffice/formdef-inspect.html
@@ -29,7 +29,7 @@
 <li><span class="parameter">{% trans "Options" %}{% trans ":" %}</span> {% if not workflow_options %}-{% else %}<ul>
   {% for label, value in workflow_options.items %}
   {% if value == '__title__' or value == '__subtitle__' %}<li><strong>{{ label }}</strong></li>
-  {% elif value == '__comment__' %}<li><{{ label }}</li>
+  {% elif value == '__comment__' %}<li>{{ label }}</li>
   {% else %}
   <li>{{ label }} → {{ value|safe|default:"-" }}</li>
   {% endif %}
@@ -73,12 +73,12 @@
 </div>
 
 <div id="inspect-fields" role="tabpanel" tabindex="0" aria-labelledby="tab-fields" hidden>
-<div class="pk-information"><p>
+<div class="pk-information page-field-counters"><p>
 {% blocktrans count page_count=formdef.page_count %}{{ page_count }} page{% plural %}{{ page_count }} pages{% endblocktrans %},
 {% blocktrans count fields_count=formdef.fields|count %}{{ fields_count }} field{% plural %}{{ fields_count }} fields.{% endblocktrans %}
 </p></div>
 {% for field in formdef.fields %}
-{% include "wcs/backoffice/includes/inspect-field.html" %}
+{% include "wcs/backoffice/includes/inspect-field.html" with path=formdef.get_admin_url|add:"fields/" %}
 {% endfor %}
 </div>
 </div>
diff --git a/wcs/templates/wcs/backoffice/snapshots_compare.html b/wcs/templates/wcs/backoffice/snapshots_compare.html
index d4c971277..89b2aef43 100644
--- a/wcs/templates/wcs/backoffice/snapshots_compare.html
+++ b/wcs/templates/wcs/backoffice/snapshots_compare.html
@@ -1,11 +1,33 @@
+{% extends "wcs/backoffice/base.html" %}
 {% load i18n %}
 
-{% block body %}
-<div id="appbar">
-<h2>{% trans "Compare snapshots" %}</h2>
-</div>
+{% block appbar-title %}{% trans "Compare snapshots" %}{% if has_inspect %} ({% if mode == 'xml' %}{% trans "XML" %}{% else %}{% trans "Inspect" %}{% endif %}){% endif %}{% endblock %}
+{% block appbar-actions %}
+{% if has_inspect %}
+<a href="?version1={{ snapshot1.id }}&version2={{ snapshot2.id }}&mode=inspect">{% trans "Compare inspect" %}</a>
+<a href="?version1={{ snapshot1.id }}&version2={{ snapshot2.id }}&mode=xml">{% trans "Compare XML" %}</a>
+{% endif %}
+{% endblock %}
 
-<div class="section diff">
-  {{ diff_serialization|safe }}
+{% block content %}
+<p class="snapshot-description">{{ fromdesc|safe }} ➔ {{ todesc|safe }}</p>
+<div class="diff">
+{% if mode == 'xml' %}
+    {{ diff_serialization|safe }}
+{% else %}
+  <div class="{{ tab_class_names }}">
+    <div class="pk-tabs--tab-list" role="tablist">
+      {% for tab in tabs %}{{ tab|safe }}{% endfor %}
+      {{ tab_list|safe }}
+    </div>
+    <div class="pk-tabs--container">
+      {% for attrs, panel in panels %}
+        <div{% for k, v in attrs.items %} {{ k }}="{{ v }}"{% endfor %}>
+          {{ panel|safe }}
+        </div>
+      {% endfor %}
+    </div>
+  </div>
+{% endif %}
 </div>
 {% endblock %}
diff --git a/wcs/templates/wcs/backoffice/workflow-inspect.html b/wcs/templates/wcs/backoffice/workflow-inspect.html
index bc1d27be4..90353a014 100644
--- a/wcs/templates/wcs/backoffice/workflow-inspect.html
+++ b/wcs/templates/wcs/backoffice/workflow-inspect.html
@@ -24,32 +24,6 @@
  </div>
  <div class="pk-tabs--container">
 
- <div id="inspect-functions" role="tabpanel" tabindex="0" aria-labelledby="tab-functions" hidden>
-<ul>
-{% for label in workflow.roles.values %}
-<li>{{ label }}</li>
-{% endfor %}
-</ul>
-</div>
-
-<div id="inspect-variables" role="tabpanel" tabindex="0" aria-labelledby="tab-variables" hidden>
-{% for field in workflow.variables_formdef.fields %}
-{% include "wcs/backoffice/includes/inspect-field.html" with path="variables/fields/" %}
-{% endfor %}
-</div>
-
-<div id="inspect-fields" role="tabpanel" tabindex="0" aria-labelledby="tab-fields" hidden>
-{% for field in workflow.backoffice_fields_formdef.fields %}
-{% include "wcs/backoffice/includes/inspect-field.html" with path="backoffice-fields/fields/" %}
-{% endfor %}
-</div>
-
-<div id="inspect-criticality" role="tabpanel" tabindex="0" aria-labelledby="tab-criticality" hidden>
-<ul>
-{% for level in workflow.criticality_levels %}<li>{{ level.name }}</li>{% endfor %}
-</ul>
-</div>
-
 <div id="inspect-statuses" role="tabpanel" tabindex="0" aria-labelledby="tab-statuses">
 <nav class="inspect--quicknav"><span class="inspect--jumpto">{% trans "Jump to:" %}</span><ul class="inspect--quicklinks">
 {% for status in workflow.possible_status %}
@@ -60,12 +34,12 @@
 {% for status in workflow.possible_status %}
 <div class="section status">
 <h3 id="status-{{ status.id }}"
-    ><a href="status/{{ status.id }}/" class="inspect-status--link">
+    ><a href="{{ workflow.get_admin_url }}status/{{ status.id }}/" class="inspect-status--link">
     <span class="inspect-status--colour" style="background-color: #{{ status.colour|default:"fff" }}"></span>
     {{ status.name }}</a></h3>
 {% if status.backoffice_info_text %}<div>{{ status.backoffice_info_text|safe }}</div>{% endif %}
 {% for item in status.items %}
-<h4><a href="status/{{ status.id }}/items/{{ item.id }}/">{{ item.description }}</a></h4>
+<h4><a href="{{ workflow.get_admin_url }}status/{{ status.id }}/items/{{ item.id }}/">{{ item.description }}</a></h4>
 {{ item.get_parameters_view|safe }}
 {% empty %}
 <p>{% trans "No actions in this status." %}</p>
@@ -84,11 +58,11 @@
 <div class="expanded-statuses">
 {% for action in workflow.global_actions %}
 <div class="section global-action">
-<h3><a id="action-{{ action.id }}" href="global-actions/{{ action.id }}/">{{ action.name }}</a></h3>
+<h3><a id="action-{{ action.id }}" href="{{ workflow.get_admin_url }}global-actions/{{ action.id }}/">{{ action.name }}</a></h3>
 <h4>{% trans "Triggers" %}</h4>
 <ul>{% for trigger in action.triggers %}<li>{{ trigger.render_as_line }}</li>{% endfor %}</ul>
 {% for item in action.items %}
-<h4><a href="global-actions/{{ action.id }}/items/{{ item.id }}/">{{ item.description }}</a></h4>
+<h4><a href="{{ workflow.get_admin_url }}global-actions/{{ action.id }}/items/{{ item.id }}/">{{ item.description }}</a></h4>
 {{ item.get_parameters_view|safe }}
 {% endfor %}
 </div>
@@ -96,6 +70,32 @@
 </div>
 </div>
 
+<div id="inspect-functions" role="tabpanel" tabindex="0" aria-labelledby="tab-functions" hidden>
+<ul>
+{% for label in workflow.roles.values %}
+<li>{{ label }}</li>
+{% endfor %}
+</ul>
+</div>
+
+<div id="inspect-variables" role="tabpanel" tabindex="0" aria-labelledby="tab-variables" hidden>
+{% for field in workflow.variables_formdef.fields %}
+{% include "wcs/backoffice/includes/inspect-field.html" with path=workflow.get_admin_url|add:"variables/fields/" %}
+{% endfor %}
+</div>
+
+<div id="inspect-fields" role="tabpanel" tabindex="0" aria-labelledby="tab-fields" hidden>
+{% for field in workflow.backoffice_fields_formdef.fields %}
+{% include "wcs/backoffice/includes/inspect-field.html" with path=workflow.get_admin_url|add:"backoffice-fields/fields/" %}
+{% endfor %}
+</div>
+
+<div id="inspect-criticality" role="tabpanel" tabindex="0" aria-labelledby="tab-criticality" hidden>
+<ul>
+{% for level in workflow.criticality_levels %}<li>{{ level.name }}</li>{% endfor %}
+</ul>
+</div>
+
 </div> <!-- pk-tabs-container -->
  </div> <!-- pk-tabs -->
 {% endblock %}
-- 
2.35.1

