From 2913378a12785773f58c9f099778ea0cc2eafb03 Mon Sep 17 00:00:00 2001
From: Jacob Coffee
Date: Wed, 7 Oct 2026 14:21:29 -0500
Subject: [PATCH 1/4] let staff compare terms versions
---
.../templates/agreements/terms_compare.html | 51 +++++++++++++++++
apps/agreements/tests/test_terms_compare.py | 57 +++++++++++++++++++
apps/agreements/urls.py | 1 +
apps/agreements/views/terms.py | 31 ++++++++++
4 files changed, 140 insertions(+)
create mode 100644 apps/agreements/templates/agreements/terms_compare.html
create mode 100644 apps/agreements/tests/test_terms_compare.py
diff --git a/apps/agreements/templates/agreements/terms_compare.html b/apps/agreements/templates/agreements/terms_compare.html
new file mode 100644
index 000000000..9408dbab7
--- /dev/null
+++ b/apps/agreements/templates/agreements/terms_compare.html
@@ -0,0 +1,51 @@
+{% extends "agreements/base.html" %}
+{% block title %}Compare: {{ terms.title }}{% endblock %}
+{% block crumbs %}
+Terms>>>
+{{ terms.title }}>>>
+Compare
+{% endblock %}
+
+{% block content %}
+
+
+ Compare {{ terms.title }}
+
+
+ {% if versions|length < 2 %}
+ There is nothing to compare until a second version is published.
+ {% else %}
+
+
+ {% if old %}
+ Changes from version {{ old.version }} to version {{ new.version }}
+ {% if new.notes %}Notes for {{ new.version }}: {{ new.notes }}
{% endif %}
+ {% if diff %}
+ Removed lines are marked with − and added lines with +.
+ {% for tag, line in diff %}{{ line }}
+{% endfor %}
+ {% else %}
+ These versions have identical text.
+ {% endif %}
+ {% else %}
+ Version {{ new.version }} is the first published version, so there is nothing before it. Choose another version to compare.
+ {% endif %}
+ {% endif %}
+
+{% endblock %}
diff --git a/apps/agreements/tests/test_terms_compare.py b/apps/agreements/tests/test_terms_compare.py
new file mode 100644
index 000000000..6369e4dc3
--- /dev/null
+++ b/apps/agreements/tests/test_terms_compare.py
@@ -0,0 +1,57 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from django.contrib.auth import get_user_model
+from django.test import TestCase
+from django.urls import reverse
+
+from apps.agreements.models import Terms, TermsVersion
+from apps.agreements.tests.test_agreements import make_officer
+
+if TYPE_CHECKING:
+ from apps.users.models import User
+
+
+class TermsCompareTests(TestCase):
+ terms: Terms
+ officer: User
+ url: str
+
+ @classmethod
+ def setUpTestData(cls) -> None:
+ cls.terms = Terms.objects.create(slug="fictional-compare-terms", title="Fictional terms", is_public=True)
+ for label, text in (
+ ("v1", "Fee is one.\nShared clause."),
+ ("v2", "Fee is two.\nShared clause."),
+ ("v3", "Fee is three.\nShared clause."),
+ ):
+ TermsVersion.objects.create(terms=cls.terms, version=label, markdown=text)
+ cls.officer = make_officer()
+ cls.url = reverse("agreements:terms_compare", args=[cls.terms.slug])
+
+ def lines(self, query: str = "") -> list[tuple[str, str]]:
+ self.client.force_login(self.officer)
+ return [(tag, line) for tag, line in self.client.get(self.url + query).context["diff"] if tag in {"add", "del"}]
+
+ def test_defaults_to_what_the_chosen_version_changed(self) -> None:
+ self.assertEqual(self.lines(), [("del", "-Fee is two."), ("add", "+Fee is three.")])
+ self.assertEqual(self.lines("?to=v2"), [("del", "-Fee is one."), ("add", "+Fee is two.")])
+ self.assertEqual(self.lines("?from=v1&to=v3"), [("del", "-Fee is one."), ("add", "+Fee is three.")])
+
+ def test_first_version_has_nothing_before_it(self) -> None:
+ self.client.force_login(self.officer)
+ response = self.client.get(self.url + "?to=v1")
+ self.assertIsNone(response.context["diff"])
+ self.assertContains(response, "first published version")
+
+ def test_only_agreements_staff_can_compare(self) -> None:
+ self.assertEqual(self.client.get(self.url).status_code, 302)
+ self.client.force_login(get_user_model().objects.create_user("reader", "reader@example.org"))
+ self.assertEqual(self.client.get(self.url).status_code, 403)
+
+ def test_unknown_versions_are_not_found(self) -> None:
+ self.client.force_login(self.officer)
+ for query in ("?to=v9", "?from=v9&to=v2"):
+ with self.subTest(query=query):
+ self.assertEqual(self.client.get(self.url + query).status_code, 404)
diff --git a/apps/agreements/urls.py b/apps/agreements/urls.py
index 995fe0558..bf7e45207 100644
--- a/apps/agreements/urls.py
+++ b/apps/agreements/urls.py
@@ -31,6 +31,7 @@
path("terms/", terms.terms_list, name="terms_list"),
path("terms//", terms.terms, name="terms"),
path("terms//edit/draft/", terms.terms_edit, name="terms_edit"),
+ path("terms//compare/versions/", terms.terms_compare, name="terms_compare"),
path("terms//download.", terms.terms_download, name="terms_download"),
# Permanent: signed documents cite these addresses.
path("terms///", terms.terms, name="terms_version"),
diff --git a/apps/agreements/views/terms.py b/apps/agreements/views/terms.py
index 299c80539..5915b8410 100644
--- a/apps/agreements/views/terms.py
+++ b/apps/agreements/views/terms.py
@@ -177,3 +177,34 @@ def terms_edit(request: HttpRequest, slug: str) -> HttpResponse:
},
status=400 if form.errors else 200,
)
+
+
+@never_cache
+@preparer_required
+def terms_compare(request: HttpRequest, slug: str) -> HttpResponse:
+ """Show what changed between two published versions; by default, what the newest version changed."""
+ terms = get_object_or_404(Terms, slug=slug)
+ versions = list(terms.versions.select_related("published_by")) # newest first
+ by_label = {version.version: version for version in versions}
+ new = by_label.get(request.GET["to"]) if "to" in request.GET else next(iter(versions), None)
+ if new is None and "to" in request.GET:
+ raise Http404
+ if "from" in request.GET:
+ old = by_label.get(request.GET["from"])
+ if old is None:
+ raise Http404
+ else:
+ older = versions[versions.index(new) + 1 :] if new else []
+ old = older[0] if older else None
+ return render(
+ request,
+ "agreements/terms_compare.html",
+ {
+ "terms": terms,
+ "versions": versions,
+ "old": old,
+ "new": new,
+ "diff": _diff(old.markdown, new.markdown) if old and new else None,
+ "nav": "terms",
+ },
+ )
From bc815741dedc2c25f890922c8d2fee441e805b8e Mon Sep 17 00:00:00 2001
From: Jacob Coffee
Date: Wed, 7 Oct 2026 14:21:44 -0500
Subject: [PATCH 2/4] link vers comps from terms editor
---
apps/agreements/templates/agreements/terms_edit.html | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/apps/agreements/templates/agreements/terms_edit.html b/apps/agreements/templates/agreements/terms_edit.html
index 6961afdf1..6e37555c9 100644
--- a/apps/agreements/templates/agreements/terms_edit.html
+++ b/apps/agreements/templates/agreements/terms_edit.html
@@ -53,9 +53,10 @@ Changes from version {{ current.version }}
{% endif %}
Published versions
+ {% if versions|length > 1 %}Compare any two versions
{% endif %}
{% for version in versions %}
- - Version {{ version.version }}, {{ version.published_at|date:"M j, Y" }}{% if version.published_by %} by {{ version.published_by.email }}{% endif %}{% if version.notes %}: {{ version.notes }}{% endif %}
+ - Version {{ version.version }}, {{ version.published_at|date:"M j, Y" }}{% if version.published_by %} by {{ version.published_by.email }}{% endif %}{% if version.notes %}: {{ version.notes }}{% endif %}{% if not forloop.last %} · What changed{% endif %}
{% empty %}
- None yet.
{% endfor %}
From 261648bcd1fea86eb4888afcc3a1ad1b8218f70d Mon Sep 17 00:00:00 2001
From: Jacob Coffee
Date: Wed, 7 Oct 2026 14:21:58 -0500
Subject: [PATCH 3/4] link vers comps from published terms page
---
apps/agreements/templates/agreements/terms.html | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/agreements/templates/agreements/terms.html b/apps/agreements/templates/agreements/terms.html
index 7a49f4f76..4d5655c0b 100644
--- a/apps/agreements/templates/agreements/terms.html
+++ b/apps/agreements/templates/agreements/terms.html
@@ -24,7 +24,7 @@ {{ terms.title }}
{% else %}
PDF · DOCX
{% endif %}
- {% if can_edit %} · Edit{% endif %}
+ {% if can_edit %} · Edit · Compare versions{% endif %}
From da6c8662c479871b59788c9e3f520e4fae4579e0 Mon Sep 17 00:00:00 2001
From: Jacob Coffee
Date: Wed, 7 Oct 2026 14:22:06 -0500
Subject: [PATCH 4/4] add doc on comparing
---
docs/source/administration.rst | 3 +++
1 file changed, 3 insertions(+)
diff --git a/docs/source/administration.rst b/docs/source/administration.rst
index a736db0aa..99252509b 100644
--- a/docs/source/administration.rst
+++ b/docs/source/administration.rst
@@ -199,6 +199,9 @@ after session or role revocation. Anonymous public terms remain publicly cacheab
Once a version is published, its terms slug is read-only in the admin so cited
addresses remain permanent. The draft editor is at
``/agreements/terms//edit/draft/``; ``edit`` is also a valid version label.
+ Both agreement groups can compare any two published versions line by line at
+ ``/agreements/terms//compare/versions/``, linked from the editor's version list
+ and from each terms page. By default it shows what the newest version changed.
Publishing a version does not make it public: the separate *Make published versions
public* setting controls that. Private versions are readable only by agreement group
members and linked parties to documents citing them. An emailed signatory can review the cited versions through