From e23c15cb0b44de27e33991bd9b145cef9e6911a3 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 7 Oct 2026 23:34:32 -0500 Subject: [PATCH 1/2] anchors for resolutions --- apps/pages/templatetags/__init__.py | 1 + apps/pages/templatetags/pages.py | 39 +++++++++++++++++++++++++++ apps/pages/tests/test_templatetags.py | 32 ++++++++++++++++++++++ templates/psf/default.html | 3 ++- 4 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 apps/pages/templatetags/__init__.py create mode 100644 apps/pages/templatetags/pages.py create mode 100644 apps/pages/tests/test_templatetags.py 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..051e1c9c2 --- /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 vote line with its date, e.g. +# "Approved; 9-0-1, 2026-08-12", "Denied 3-4-0, December 2, 2014", "Approved ... 8 June 2015." +_RESOLUTION_RE = re.compile( + r"
(?P(?:(?!
).)*?
\s*

(?:Approved|Denied)\b[^<]*?)" + 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..04b7b0069 --- /dev/null +++ b/apps/pages/tests/test_templatetags.py @@ -0,0 +1,32 @@ +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
\n

Approved; 9-0-1, 2026-08-12

\n" + "
B
\n

Approved; 9-0-0, 2026-08-12

\n" + "
C
\n

Denied 3-4-0, December 2, 2014

\n" + "
D
\n

Approved, 10-0-0 by IRC vote, 8 June 2015.

" + ) + self.assertHTMLEqual( + resolution_anchors(html), + '
A

Approved; 9-0-1, 2026-08-12

' + '
B

Approved; 9-0-0, 2026-08-12

' + '
C

Denied 3-4-0, December 2, 2014

' + '
D
' + '

Approved, 10-0-0 by IRC vote, 8 June 2015.

', + ) + + def test_unmatched_content_unchanged(self): + """Vote lines without a full date and dated paragraphs that aren't vote lines are left alone.""" + html = ( + "
A

Approved, 10-0-0, May 2010.

" + "
B

Discussed on 2026-08-12

" + "

Approved; 9-0-0, 2026-08-12

" + "
C

Approved 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 %} From d09435f1fe2a0c7ee65659d27812fc240edc86e1 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 7 Oct 2026 23:44:06 -0500 Subject: [PATCH 2/2] anchor resolutions without an Approved/Denied prefix --- apps/pages/templatetags/pages.py | 6 +++--- apps/pages/tests/test_templatetags.py | 14 ++++++++------ 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/apps/pages/templatetags/pages.py b/apps/pages/templatetags/pages.py index 051e1c9c2..7fcf2e5cf 100644 --- a/apps/pages/templatetags/pages.py +++ b/apps/pages/templatetags/pages.py @@ -10,10 +10,10 @@ 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 vote line with its date, e.g. -# "Approved; 9-0-1, 2026-08-12", "Denied 3-4-0, December 2, 2014", "Approved ... 8 June 2015." +# 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*

(?:Approved|Denied)\b[^<]*?)" + 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, ) diff --git a/apps/pages/tests/test_templatetags.py b/apps/pages/tests/test_templatetags.py index 04b7b0069..da096b1ae 100644 --- a/apps/pages/tests/test_templatetags.py +++ b/apps/pages/tests/test_templatetags.py @@ -9,23 +9,25 @@ def test_ids_from_vote_dates(self): html = ( "

A
\n

Approved; 9-0-1, 2026-08-12

\n" "
B
\n

Approved; 9-0-0, 2026-08-12

\n" - "
C
\n

Denied 3-4-0, December 2, 2014

\n" - "
D
\n

Approved, 10-0-0 by IRC vote, 8 June 2015.

" + "
C
\n

Declined 3-4-0, December 2, 2014

\n" + "
D
\n

Approved, 10-0-0 by IRC vote, 8 June 2015.

\n" + "
E
\n

September 4, 2018

" ) self.assertHTMLEqual( resolution_anchors(html), '
A

Approved; 9-0-1, 2026-08-12

' '
B

Approved; 9-0-0, 2026-08-12

' - '
C

Denied 3-4-0, December 2, 2014

' + '
C

Declined 3-4-0, December 2, 2014

' '
D
' - '

Approved, 10-0-0 by IRC vote, 8 June 2015.

', + '

Approved, 10-0-0 by IRC vote, 8 June 2015.

' + '
E

September 4, 2018

', ) def test_unmatched_content_unchanged(self): - """Vote lines without a full date and dated paragraphs that aren't vote lines are left alone.""" + """Paragraphs after a quote without a full date, and dated paragraphs not after a quote, are left alone.""" html = ( "
A

Approved, 10-0-0, May 2010.

" - "
B

Discussed on 2026-08-12

" + "
B

with:

" "

Approved; 9-0-0, 2026-08-12

" "
C

Approved 5-0-0, Feb 30, 2014

" )