Guard against multi-line {# #} template comments

Django's {# #} is single-line only -- its lexer regex is not DOTALL -- so a
multi-line one is not a comment at all and renders to the page as text. It shipped
into the clubs list, where the archived row read:

    Probe Retired probe-retired {# An archived club's subdomain does not... #}

A test now walks every template and fails on a {# without a closing #} on the same
line, since this is an easy habit to fall back into and the failure is invisible
until someone looks at the rendered page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 01:24:19 +02:00
parent 192fe5ad0e
commit 3a6dc00e05
2 changed files with 109 additions and 0 deletions

View File

@@ -0,0 +1,62 @@
{% load lucide %}
{% comment %}
The club table, shared by the dashboard and the clubs list so the two cannot drift apart.
Health, not vanity: a member total says nothing you can act on, while "no coach",
"nothing scheduled", "€ owed" and "no admins" each name something somebody has to go and
fix. Every column is annotated by clubs_with_health() in a single query.
Expects: clubs (from clubs_with_health), and optionally empty_message.
{% endcomment %}
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Club</th>
<th class="text-right">Members</th>
<th class="text-right">Unpaid</th>
<th class="text-right">Owed</th>
<th class="text-right">Teams</th>
<th class="text-right">Upcoming</th>
<th class="text-right">Admins</th>
</tr>
</thead>
<tbody>
{% for club in clubs %}
<tr>
<td>
<a class="link link-hover font-medium" href="{% url 'controlpanel:club_detail' club.pk %}">{{ club.name }}</a>
<div class="mt-1 flex flex-wrap items-center gap-1">
<span class="text-xs opacity-60">{{ club.slug }}</span>
{% if club.is_archived %}
{% comment %}
An archived club's subdomain does not resolve, so "dormant" and
"no season" would be noise: of course nothing is scheduled.
{% endcomment %}
<span class="badge badge-warning badge-xs gap-1">{% lucide "archive" size=10 %} Archived</span>
{% else %}
{% if not club.has_season %}<span class="badge badge-warning badge-xs gap-1">{% lucide "calendar-x" size=10 %} No season</span>{% endif %}
{% if not club.upcoming_events %}<span class="badge badge-ghost badge-xs gap-1">{% lucide "moon-star" size=10 %} Dormant</span>{% endif %}
{% endif %}
</div>
</td>
<td class="text-right tabular-nums">{{ club.active_members }}</td>
<td class="text-right tabular-nums {% if club.unpaid_members %}text-warning{% endif %}">{{ club.unpaid_members }}</td>
<td class="text-right tabular-nums {% if club.outstanding %}font-semibold text-error{% endif %}">€{{ club.outstanding|floatformat:2 }}</td>
<td class="text-right tabular-nums">
{{ club.team_count }}
{% if club.teams_without_coach %}
<span class="badge badge-error badge-xs ml-1" title="Teams with nobody able to pick the squad">{{ club.teams_without_coach }} no coach</span>
{% endif %}
</td>
<td class="text-right tabular-nums">{{ club.upcoming_events }}</td>
<td class="text-right tabular-nums {% if not club.admin_count %}text-error{% endif %}">{{ club.admin_count }}</td>
</tr>
{% empty %}
<tr>
<td colspan="7" class="text-center opacity-60">{{ empty_message|default:"No clubs yet." }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>

View File

@@ -1,8 +1,10 @@
import datetime import datetime
import pathlib
from decimal import Decimal from decimal import Decimal
from allauth.mfa.models import Authenticator from allauth.mfa.models import Authenticator
from django import forms from django import forms
from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.messages.storage.base import Message from django.contrib.messages.storage.base import Message
@@ -1008,3 +1010,48 @@ class ClubHealthTableTests(TestCase):
self.assertContains(response, "Owed") self.assertContains(response, "Owed")
self.assertContains(response, "Upcoming") self.assertContains(response, "Upcoming")
self.assertContains(response, "Unpaid") self.assertContains(response, "Unpaid")
class ClubListHealthTests(ControlPanelTestBase):
def test_the_list_shows_the_same_health_columns_as_the_dashboard(self):
response = self.client.get(reverse("controlpanel:club_list"))
self.assertContains(response, "Owed")
self.assertContains(response, "Upcoming")
self.assertContains(response, "Unpaid")
self.assertTemplateUsed(response, "controlpanel/_club_health_table.html")
def test_an_archived_club_is_badged_archived_rather_than_dormant(self):
# Its subdomain does not resolve, so "nothing scheduled" is not news.
self.club.archive()
response = self.client.get(reverse("controlpanel:club_list"), {"archived": "1"})
self.assertContains(response, "Archived")
self.assertNotContains(response, "Dormant")
def test_searching_keeps_the_health_annotations(self):
response = self.client.get(reverse("controlpanel:club_list"), {"q": "Ajax"})
club = response.context["clubs"][0]
self.assertEqual(club.active_members, 0)
self.assertEqual(club.teams_without_coach, 0)
def test_the_list_does_not_fan_out_per_club(self):
for name in ("Feyenoord", "PSV", "Twente"):
Club.objects.create(name=name)
with self.assertNumQueries(1):
[(club.outstanding, club.upcoming_events) for club in clubs_with_health()]
class TemplateCommentTests(TestCase):
def test_no_template_uses_a_multiline_hash_comment(self):
"""Django's {# #} is single-line only — its lexer regex is not DOTALL, so a
multi-line one is not a comment at all: it renders to the page as text."""
templates = [path for path in pathlib.Path(settings.BASE_DIR).glob("**/templates/**/*.html") if ".venv" not in path.parts and "node_modules" not in path.parts]
offenders = [f"{path.relative_to(settings.BASE_DIR)}:{number}" for path in templates for number, line in enumerate(path.read_text().splitlines(), start=1) if "{#" in line and "#}" not in line]
self.assertTrue(templates) # the glob must actually be finding our templates
self.assertEqual(offenders, [], "use {% comment %} for multi-line comments")