Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/agreements/templates/agreements/terms.html
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ <h1 class="page-title">{{ terms.title }}</h1>
{% else %}
<a href="{% url 'agreements:terms_version_download' terms.slug shown.version 'pdf' %}">PDF</a> · <a href="{% url 'agreements:terms_version_download' terms.slug shown.version 'docx' %}">DOCX</a>
{% endif %}
{% if can_edit %} · <a href="{% url 'agreements:terms_edit' terms.slug %}">Edit</a>{% endif %}
{% if can_edit %} · <a href="{% url 'agreements:terms_edit' terms.slug %}">Edit</a> · <a href="{% url 'agreements:terms_compare' terms.slug %}?to={{ shown.version }}">Compare versions</a>{% endif %}
</span>
</p>
<p class="terms-meta">
Expand Down
51 changes: 51 additions & 0 deletions apps/agreements/templates/agreements/terms_compare.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
{% extends "agreements/base.html" %}
{% block title %}Compare: {{ terms.title }}{% endblock %}
{% block crumbs %}
<li><a href="{% url 'agreements:terms_list' %}">Terms</a><span class="prompt">&gt;&gt;&gt;</span></li>
<li><a href="{% url 'agreements:terms_edit' terms.slug %}">{{ terms.title }}</a><span class="prompt">&gt;&gt;&gt;</span></li>
<li>Compare</li>
{% endblock %}

{% block content %}
<article class="text">
<header class="article-header">
<h1 class="page-title">Compare {{ terms.title }}</h1>
</header>

{% if versions|length < 2 %}
<p>There is nothing to compare until a second version is published.</p>
{% else %}
<form method="get" class="sign-form">
<div class="field-row">
<p>
<label for="compare-from">From version</label>
<select id="compare-from" name="from">
{% for version in versions %}<option value="{{ version.version }}"{% if version == old %} selected{% endif %}>{{ version.version }} ({{ version.published_at|date:"M j, Y" }})</option>{% endfor %}
</select>
</p>
<p>
<label for="compare-to">To version</label>
<select id="compare-to" name="to">
{% for version in versions %}<option value="{{ version.version }}"{% if version == new %} selected{% endif %}>{{ version.version }} ({{ version.published_at|date:"M j, Y" }})</option>{% endfor %}
</select>
</p>
</div>
<p class="form-actions"><button type="submit">Compare</button></p>
</form>

{% if old %}
<h2>Changes from version {{ old.version }} to version {{ new.version }}</h2>
{% if new.notes %}<p><strong>Notes for {{ new.version }}:</strong> {{ new.notes }}</p>{% endif %}
{% if diff %}
<p>Removed lines are marked with − and added lines with +.</p>
<pre class="diff">{% for tag, line in diff %}<span class="{{ tag }}">{{ line }}</span>
{% endfor %}</pre>
{% else %}
<p>These versions have identical text.</p>
{% endif %}
{% else %}
<p>Version {{ new.version }} is the first published version, so there is nothing before it. Choose another version to compare.</p>
{% endif %}
{% endif %}
</article>
{% endblock %}
3 changes: 2 additions & 1 deletion apps/agreements/templates/agreements/terms_edit.html
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,10 @@ <h2>Changes from version {{ current.version }}</h2>
{% endif %}

<h2>Published versions</h2>
{% if versions|length > 1 %}<p><a href="{% url 'agreements:terms_compare' terms.slug %}">Compare any two versions</a></p>{% endif %}
<ol class="history">
{% for version in versions %}
<li><a href="{{ version.get_absolute_url }}">Version {{ version.version }}</a>, {{ version.published_at|date:"M j, Y" }}{% if version.published_by %} by {{ version.published_by.email }}{% endif %}{% if version.notes %}: {{ version.notes }}{% endif %}</li>
<li><a href="{{ version.get_absolute_url }}">Version {{ version.version }}</a>, {{ 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 %} · <a href="{% url 'agreements:terms_compare' terms.slug %}?to={{ version.version }}">What changed</a>{% endif %}</li>
{% empty %}
<li>None yet.</li>
{% endfor %}
Expand Down
57 changes: 57 additions & 0 deletions apps/agreements/tests/test_terms_compare.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions apps/agreements/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
path("terms/", terms.terms_list, name="terms_list"),
path("terms/<slug:slug>/", terms.terms, name="terms"),
path("terms/<slug:slug>/edit/draft/", terms.terms_edit, name="terms_edit"),
path("terms/<slug:slug>/compare/versions/", terms.terms_compare, name="terms_compare"),
path("terms/<slug:slug>/download.<str:fmt>", terms.terms_download, name="terms_download"),
# Permanent: signed documents cite these addresses.
path("terms/<slug:slug>/<slug:version>/", terms.terms, name="terms_version"),
Expand Down
31 changes: 31 additions & 0 deletions apps/agreements/views/terms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
)
3 changes: 3 additions & 0 deletions docs/source/administration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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/<slug>/edit/draft/``; ``edit`` is also a valid version label.
Both agreement groups can compare any two published versions line by line at
``/agreements/terms/<slug>/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
Expand Down
Loading