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 %}

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/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 %} -
    1. 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 %}
    2. +
    3. 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 %}
    4. {% empty %}
    5. None yet.
    6. {% endfor %} 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", + }, + ) 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