Skip to content

Commit 050442e

Browse files
committed
Add admin action to diff selected form definitions
- New 'Diff selected forms' admin action on FormDefinitionAdmin - Side-by-side JSON diff view using jsdiff library (diff v5.2.0) - Three view modes: Side by Side, Inline, Raw JSON - Supplemental summary panel highlighting key differences: fields added/removed/modified, workflow changes, metadata diffs, permission group changes, post-action count - Serializes forms using the sync_api serializer for complete coverage - Supports comparing 2+ forms (first form is the base) - Bump version to 0.25.0
1 parent 03bbd5d commit 050442e

4 files changed

Lines changed: 374 additions & 3 deletions

File tree

django_forms_workflows/admin.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,7 @@ class FormDefinitionAdmin(admin.ModelAdmin):
307307
inlines = [FormFieldInline, WorkflowDefinitionInline]
308308
filter_horizontal = ("submit_groups", "view_groups", "admin_groups")
309309
change_form_template = "admin/django_forms_workflows/formdef_change_form.html"
310-
actions = ["clone_forms", "export_as_json", "push_forms_to_remote"]
310+
actions = ["clone_forms", "diff_forms", "export_as_json", "push_forms_to_remote"]
311311

312312
fieldsets = (
313313
(
@@ -566,6 +566,20 @@ def clone_forms(self, request, queryset):
566566

567567
clone_forms.short_description = "Clone selected forms"
568568

569+
@admin.action(description="Diff selected forms")
570+
def diff_forms(self, request, queryset):
571+
"""Admin action: compare selected FormDefinitions side-by-side."""
572+
if queryset.count() < 2:
573+
self.message_user(
574+
request, "Select at least 2 forms to diff.", level="error"
575+
)
576+
return
577+
pks = ",".join(str(pk) for pk in queryset.values_list("pk", flat=True))
578+
from django.urls import reverse
579+
580+
url = reverse("admin:form_diff") + f"?pks={pks}"
581+
return HttpResponseRedirect(url)
582+
569583
@admin.action(description="Export selected forms as JSON")
570584
def export_as_json(self, request, queryset):
571585
"""Admin action: download selected FormDefinitions as a JSON file."""
@@ -847,9 +861,15 @@ def sync_push_admin_view(self, request):
847861
def get_urls(self):
848862
"""Add custom URLs for the form builder and workflow builder"""
849863
urls = super().get_urls()
850-
from . import form_builder_views, workflow_builder_views
864+
from . import diff_views, form_builder_views, workflow_builder_views
851865

852866
custom_urls = [
867+
# Diff view
868+
path(
869+
"diff/",
870+
self.admin_site.admin_view(diff_views.diff_forms_view),
871+
name="form_diff",
872+
),
853873
# Form Builder URLs
854874
path(
855875
"builder/new/",
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
"""
2+
Views for diffing FormDefinition objects side-by-side.
3+
"""
4+
5+
import json
6+
7+
from django.http import HttpResponseBadRequest
8+
from django.shortcuts import render
9+
10+
from .models import FormDefinition
11+
from .sync_api import build_export_payload
12+
13+
14+
def _build_summary(forms_data):
15+
"""Build a supplemental summary of key differences between serialized forms."""
16+
if len(forms_data) < 2:
17+
return []
18+
19+
summaries = []
20+
base = forms_data[0]
21+
for other in forms_data[1:]:
22+
diffs = []
23+
b_form, o_form = base.get("form", {}), other.get("form", {})
24+
25+
# Field count
26+
b_fields = base.get("fields", [])
27+
o_fields = other.get("fields", [])
28+
if len(b_fields) != len(o_fields):
29+
diffs.append(f"Field count: {len(b_fields)}{len(o_fields)}")
30+
31+
# Fields added/removed
32+
b_names = {f["field_name"] for f in b_fields}
33+
o_names = {f["field_name"] for f in o_fields}
34+
added = o_names - b_names
35+
removed = b_names - o_names
36+
if added:
37+
diffs.append(f"Fields added: {', '.join(sorted(added))}")
38+
if removed:
39+
diffs.append(f"Fields removed: {', '.join(sorted(removed))}")
40+
41+
# Changed fields
42+
common = b_names & o_names
43+
b_field_map = {f["field_name"]: f for f in b_fields}
44+
o_field_map = {f["field_name"]: f for f in o_fields}
45+
changed_fields = []
46+
for name in sorted(common):
47+
if b_field_map[name] != o_field_map[name]:
48+
changed_fields.append(name)
49+
if changed_fields:
50+
diffs.append(f"Fields modified: {', '.join(changed_fields)}")
51+
52+
# Workflow differences
53+
b_wf = base.get("workflow")
54+
o_wf = other.get("workflow")
55+
if (b_wf is None) != (o_wf is None):
56+
diffs.append(
57+
f"Workflow: {'present' if b_wf else 'absent'}"
58+
f" → {'present' if o_wf else 'absent'}"
59+
)
60+
elif b_wf and o_wf:
61+
b_stages = b_wf.get("stages", [])
62+
o_stages = o_wf.get("stages", [])
63+
if len(b_stages) != len(o_stages):
64+
diffs.append(f"Workflow stages: {len(b_stages)}{len(o_stages)}")
65+
wf_setting_keys = [
66+
"requires_approval",
67+
"notify_on_submission",
68+
"notify_on_approval",
69+
"notify_on_rejection",
70+
"hide_approval_history",
71+
"approval_deadline_days",
72+
]
73+
for key in wf_setting_keys:
74+
if b_wf.get(key) != o_wf.get(key):
75+
diffs.append(f"Workflow {key}: {b_wf.get(key)}{o_wf.get(key)}")
76+
77+
# Form metadata
78+
meta_keys = [
79+
"name",
80+
"is_active",
81+
"allow_save_draft",
82+
"allow_withdrawal",
83+
"requires_login",
84+
"enable_multi_step",
85+
"pdf_generation",
86+
]
87+
for key in meta_keys:
88+
if b_form.get(key) != o_form.get(key):
89+
diffs.append(f"{key}: {b_form.get(key)!r}{o_form.get(key)!r}")
90+
91+
# Permission groups
92+
for g in ("submit_groups", "view_groups", "admin_groups"):
93+
bg = set(b_form.get(g, []))
94+
og = set(o_form.get(g, []))
95+
if bg != og:
96+
added_g = og - bg
97+
removed_g = bg - og
98+
parts = []
99+
if added_g:
100+
parts.append(f"+{', '.join(sorted(added_g))}")
101+
if removed_g:
102+
parts.append(f"-{', '.join(sorted(removed_g))}")
103+
diffs.append(f"{g}: {'; '.join(parts)}")
104+
105+
# Post actions
106+
b_actions = base.get("post_actions", [])
107+
o_actions = other.get("post_actions", [])
108+
if len(b_actions) != len(o_actions):
109+
diffs.append(f"Post actions: {len(b_actions)}{len(o_actions)}")
110+
111+
summaries.append(
112+
{
113+
"left": b_form.get("name", "Form A"),
114+
"right": o_form.get("name", "Form B"),
115+
"diffs": diffs,
116+
"identical": len(diffs) == 0,
117+
}
118+
)
119+
120+
return summaries
121+
122+
123+
def diff_forms_view(request):
124+
"""Render a side-by-side JSON diff of selected FormDefinitions."""
125+
pks = request.GET.get("pks", "")
126+
if not pks:
127+
return HttpResponseBadRequest("No form IDs provided.")
128+
129+
pk_list = [int(pk) for pk in pks.split(",") if pk.strip().isdigit()]
130+
if len(pk_list) < 2:
131+
return HttpResponseBadRequest("Select at least 2 forms to diff.")
132+
133+
qs = FormDefinition.objects.filter(pk__in=pk_list)
134+
payload = build_export_payload(qs)
135+
forms_data = payload.get("forms", [])
136+
137+
# Build per-form JSON strings for the diff viewer
138+
forms_json = []
139+
for fd in forms_data:
140+
# Remove schema_version from each form (it's the same for all)
141+
fd.pop("schema_version", None)
142+
forms_json.append(
143+
{
144+
"name": fd.get("form", {}).get("name", "Unknown"),
145+
"slug": fd.get("form", {}).get("slug", ""),
146+
"json": json.dumps(fd, indent=2, default=str),
147+
}
148+
)
149+
150+
summary = _build_summary(forms_data)
151+
152+
return render(
153+
request,
154+
"admin/django_forms_workflows/diff_forms.html",
155+
{
156+
"title": "Form Definition Diff",
157+
"forms_json": forms_json,
158+
"forms_json_escaped": json.dumps(
159+
[f["json"] for f in forms_json], default=str
160+
),
161+
"form_names": json.dumps([f["name"] for f in forms_json]),
162+
"summary": summary,
163+
},
164+
)
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
{% extends "admin/base_site.html" %}
2+
{% load i18n %}
3+
4+
{% block title %}{{ title }}{% endblock %}
5+
6+
{% block extrahead %}
7+
{{ block.super }}
8+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css">
9+
<style>
10+
.diff-container { display: flex; gap: 0; font-family: monospace; font-size: 13px; border: 1px solid #ccc; border-radius: 4px; overflow: hidden; }
11+
.diff-panel { flex: 1; overflow: auto; max-height: 75vh; }
12+
.diff-panel-header { position: sticky; top: 0; z-index: 10; background: #f0f0f0; padding: 8px 12px; font-weight: bold; border-bottom: 1px solid #ccc; font-family: -apple-system, sans-serif; }
13+
.diff-panel:not(:last-child) { border-right: 1px solid #ccc; }
14+
.diff-line { display: flex; padding: 0 8px; min-height: 20px; line-height: 20px; white-space: pre; }
15+
.diff-line-num { color: #999; width: 40px; text-align: right; padding-right: 8px; flex-shrink: 0; user-select: none; }
16+
.diff-line-content { flex: 1; }
17+
.diff-added { background: #e6ffed; }
18+
.diff-removed { background: #ffeef0; }
19+
.diff-changed { background: #fff8c5; }
20+
21+
.summary-panel { background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 6px; padding: 16px; margin-bottom: 20px; }
22+
.summary-panel h3 { margin: 0 0 12px; font-size: 16px; }
23+
.summary-item { padding: 4px 0; font-size: 14px; }
24+
.summary-item .badge { font-size: 11px; padding: 3px 8px; border-radius: 3px; margin-right: 6px; }
25+
.badge-added { background: #28a745; color: #fff; }
26+
.badge-removed { background: #dc3545; color: #fff; }
27+
.badge-changed { background: #ffc107; color: #333; }
28+
.badge-identical { background: #6c757d; color: #fff; }
29+
.badge-meta { background: #17a2b8; color: #fff; }
30+
.diff-toggle-bar { margin: 12px 0; display: flex; gap: 8px; }
31+
.diff-toggle-bar button { padding: 6px 14px; border: 1px solid #ccc; border-radius: 4px; cursor: pointer; background: #fff; font-size: 13px; }
32+
.diff-toggle-bar button.active { background: #007bff; color: #fff; border-color: #007bff; }
33+
</style>
34+
{% endblock %}
35+
36+
{% block content %}
37+
<h2 style="margin-bottom: 16px;">{{ title }}</h2>
38+
39+
{% if summary %}
40+
{% for s in summary %}
41+
<div class="summary-panel">
42+
<h3>
43+
{% if s.identical %}
44+
<span class="badge badge-identical">Identical</span>
45+
{% endif %}
46+
{{ s.left }} ↔ {{ s.right }}
47+
</h3>
48+
{% if s.diffs %}
49+
<div>
50+
{% for d in s.diffs %}
51+
<div class="summary-item">
52+
{% if "added" in d|lower %}
53+
<span class="badge badge-added">+</span>
54+
{% elif "removed" in d|lower %}
55+
<span class="badge badge-removed"></span>
56+
{% elif "modified" in d|lower or "→" in d %}
57+
<span class="badge badge-changed">Δ</span>
58+
{% else %}
59+
<span class="badge badge-meta"></span>
60+
{% endif %}
61+
{{ d }}
62+
</div>
63+
{% endfor %}
64+
</div>
65+
{% else %}
66+
<p style="color: #6c757d; margin: 0;">The two forms are identical.</p>
67+
{% endif %}
68+
</div>
69+
{% endfor %}
70+
{% endif %}
71+
72+
<div class="diff-toggle-bar">
73+
<button id="btn-side" class="active" onclick="setMode('side')">Side by Side</button>
74+
<button id="btn-inline" onclick="setMode('inline')">Inline</button>
75+
<button id="btn-raw" onclick="setMode('raw')">Raw JSON</button>
76+
</div>
77+
78+
<div id="diff-output"></div>
79+
80+
<!-- Raw JSON panels (hidden by default) -->
81+
<div id="raw-panels" style="display:none;">
82+
<div class="diff-container">
83+
{% for f in forms_json %}
84+
<div class="diff-panel">
85+
<div class="diff-panel-header">{{ f.name }} ({{ f.slug }})</div>
86+
<pre style="margin:0; padding:8px; font-size:12px; white-space:pre-wrap;">{{ f.json }}</pre>
87+
</div>
88+
{% endfor %}
89+
</div>
90+
</div>
91+
92+
{% endblock %}
93+
94+
{% block extrajs %}
95+
{{ block.super }}
96+
<script src="https://cdn.jsdelivr.net/npm/diff@5.2.0/dist/diff.min.js"></script>
97+
<script>
98+
const formsJson = {{ forms_json_escaped|safe }};
99+
const formNames = {{ form_names|safe }};
100+
let currentMode = 'side';
101+
102+
function escapeHtml(s) {
103+
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
104+
}
105+
106+
function renderSideBySide(a, b, nameA, nameB) {
107+
const diff = Diff.diffLines(a, b);
108+
let leftLines = [], rightLines = [];
109+
let leftNum = 0, rightNum = 0;
110+
111+
diff.forEach(part => {
112+
const lines = part.value.replace(/\n$/, '').split('\n');
113+
lines.forEach(line => {
114+
if (part.added) {
115+
rightNum++;
116+
leftLines.push({num: '', text: '', cls: 'diff-removed'});
117+
rightLines.push({num: rightNum, text: line, cls: 'diff-added'});
118+
} else if (part.removed) {
119+
leftNum++;
120+
leftLines.push({num: leftNum, text: line, cls: 'diff-removed'});
121+
rightLines.push({num: '', text: '', cls: 'diff-added'});
122+
} else {
123+
leftNum++; rightNum++;
124+
leftLines.push({num: leftNum, text: line, cls: ''});
125+
rightLines.push({num: rightNum, text: line, cls: ''});
126+
}
127+
});
128+
});
129+
130+
let html = '<div class="diff-container">';
131+
html += '<div class="diff-panel"><div class="diff-panel-header">' + escapeHtml(nameA) + '</div>';
132+
leftLines.forEach(l => {
133+
html += '<div class="diff-line ' + l.cls + '"><span class="diff-line-num">' + l.num + '</span><span class="diff-line-content">' + escapeHtml(l.text) + '</span></div>';
134+
});
135+
html += '</div>';
136+
html += '<div class="diff-panel"><div class="diff-panel-header">' + escapeHtml(nameB) + '</div>';
137+
rightLines.forEach(l => {
138+
html += '<div class="diff-line ' + l.cls + '"><span class="diff-line-num">' + l.num + '</span><span class="diff-line-content">' + escapeHtml(l.text) + '</span></div>';
139+
});
140+
html += '</div></div>';
141+
return html;
142+
}
143+
144+
function renderInline(a, b, nameA, nameB) {
145+
const diff = Diff.diffLines(a, b);
146+
let html = '<div style="border:1px solid #ccc; border-radius:4px; overflow:auto; max-height:75vh; font-family:monospace; font-size:13px;">';
147+
html += '<div style="position:sticky;top:0;z-index:10;background:#f0f0f0;padding:8px 12px;border-bottom:1px solid #ccc;font-weight:bold;font-family:-apple-system,sans-serif;">' + escapeHtml(nameA) + ' ↔ ' + escapeHtml(nameB) + '</div>';
148+
let num = 0;
149+
diff.forEach(part => {
150+
const lines = part.value.replace(/\n$/, '').split('\n');
151+
const cls = part.added ? 'diff-added' : part.removed ? 'diff-removed' : '';
152+
const prefix = part.added ? '+' : part.removed ? '-' : ' ';
153+
lines.forEach(line => {
154+
if (!part.added && !part.removed) num++;
155+
html += '<div class="diff-line ' + cls + '"><span class="diff-line-num">' + (cls ? '' : num) + '</span><span class="diff-line-content">' + prefix + ' ' + escapeHtml(line) + '</span></div>';
156+
});
157+
});
158+
html += '</div>';
159+
return html;
160+
}
161+
162+
function setMode(mode) {
163+
currentMode = mode;
164+
document.getElementById('btn-side').classList.toggle('active', mode === 'side');
165+
document.getElementById('btn-inline').classList.toggle('active', mode === 'inline');
166+
document.getElementById('btn-raw').classList.toggle('active', mode === 'raw');
167+
document.getElementById('raw-panels').style.display = mode === 'raw' ? '' : 'none';
168+
const out = document.getElementById('diff-output');
169+
if (mode === 'raw') { out.innerHTML = ''; return; }
170+
171+
let html = '';
172+
for (let i = 1; i < formsJson.length; i++) {
173+
if (mode === 'side') {
174+
html += renderSideBySide(formsJson[0], formsJson[i], formNames[0], formNames[i]);
175+
} else {
176+
html += renderInline(formsJson[0], formsJson[i], formNames[0], formNames[i]);
177+
}
178+
if (i < formsJson.length - 1) html += '<hr>';
179+
}
180+
out.innerHTML = html;
181+
}
182+
183+
// Initial render
184+
document.addEventListener('DOMContentLoaded', () => setMode('side'));
185+
</script>
186+
{% endblock %}
187+

0 commit comments

Comments
 (0)