diff --git a/apps/pages/templatetags/__init__.py b/apps/pages/templatetags/__init__.py new file mode 100644 index 000000000..1dd9627e0 --- /dev/null +++ b/apps/pages/templatetags/__init__.py @@ -0,0 +1 @@ +"""Template tags for the pages app.""" diff --git a/apps/pages/templatetags/pages.py b/apps/pages/templatetags/pages.py new file mode 100644 index 000000000..7fcf2e5cf --- /dev/null +++ b/apps/pages/templatetags/pages.py @@ -0,0 +1,39 @@ +"""Template filters for rendering CMS page content.""" + +import re +from collections import Counter + +from dateutil import parser as date_parser +from django import template +from django.utils.html import mark_safe + +register = template.Library() + +_MONTH = r"(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?" +# A board resolution is a
followed by a paragraph with its vote date, e.g. +# "Approved; 9-0-1, 2026-08-12", "Declined 3-4-0, December 2, 2014", "September 4, 2018". +_RESOLUTION_RE = re.compile( + r"(?P(?:(?!).)*?\s*[^<]*?)" + rf"(?P
\d{{4}}-\d\d-\d\d|{_MONTH}\s+\d{{1,2}},?\s+\d{{4}}|\d{{1,2}}\s+{_MONTH},?\s+\d{{4}})", + re.DOTALL, +) + + +@register.filter +def resolution_anchors(content): + """Anchor each board resolution at its vote date and turn that date into a link to it. + + Ids are ISO dates; later resolutions on the same date get ``-2``, ``-3``, ... in page order. + """ + seen = Counter() + + def anchor(match): + try: + date = date_parser.parse(match["date"]).date().isoformat() + except ValueError: + return match[0] + seen[date] += 1 + anchor_id = date if seen[date] == 1 else f"{date}-{seen[date]}" + return f' {match["body"]}{match["date"]}' + + return mark_safe(_RESOLUTION_RE.sub(anchor, str(content))) # noqa: S308 diff --git a/apps/pages/tests/test_templatetags.py b/apps/pages/tests/test_templatetags.py new file mode 100644 index 000000000..da096b1ae --- /dev/null +++ b/apps/pages/tests/test_templatetags.py @@ -0,0 +1,34 @@ +from django.test import SimpleTestCase + +from apps.pages.templatetags.pages import resolution_anchors + + +class ResolutionAnchorsTests(SimpleTestCase): + def test_ids_from_vote_dates(self): + """Each resolution is keyed by its vote date, numbered in page order when a date repeats.""" + html = ( + "A\nApproved; 9-0-1, 2026-08-12
\n" + "B\nApproved; 9-0-0, 2026-08-12
\n" + "C\nDeclined 3-4-0, December 2, 2014
\n" + "D\nApproved, 10-0-0 by IRC vote, 8 June 2015.
\n" + "E\nSeptember 4, 2018
" + ) + self.assertHTMLEqual( + resolution_anchors(html), + 'AApproved; 9-0-1, 2026-08-12
' + 'BApproved; 9-0-0, 2026-08-12
' + 'CDeclined 3-4-0, December 2, 2014
' + 'D' + 'Approved, 10-0-0 by IRC vote, 8 June 2015.
' + 'E', + ) + + def test_unmatched_content_unchanged(self): + """Paragraphs after a quote without a full date, and dated paragraphs not after a quote, are left alone.""" + html = ( + "AApproved, 10-0-0, May 2010.
" + "Bwith:
" + "Approved; 9-0-0, 2026-08-12
" + "CApproved 5-0-0, Feb 30, 2014
" + ) + self.assertHTMLEqual(resolution_anchors(html), html) diff --git a/templates/psf/default.html b/templates/psf/default.html index 10cf0377d..6bc359055 100644 --- a/templates/psf/default.html +++ b/templates/psf/default.html @@ -3,6 +3,7 @@ {% extends "base.html" %} {% load boxes %} {% load banners %} +{% load pages %} {# TODO: Try to deduplicate this and templates/pages/default.html. #} {% block page_title %}{{ page.title }} | Python Software Foundation{% endblock %} @@ -52,7 +53,7 @@{{ page.title }}
- {{ page.content }} + {{ page.content|resolution_anchors }} {% endblock content %}