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:
62
controlpanel/templates/controlpanel/_club_health_table.html
Normal file
62
controlpanel/templates/controlpanel/_club_health_table.html
Normal 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>
|
||||
@@ -1,8 +1,10 @@
|
||||
import datetime
|
||||
import pathlib
|
||||
from decimal import Decimal
|
||||
|
||||
from allauth.mfa.models import Authenticator
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.messages.storage.base import Message
|
||||
@@ -1008,3 +1010,48 @@ class ClubHealthTableTests(TestCase):
|
||||
self.assertContains(response, "Owed")
|
||||
self.assertContains(response, "Upcoming")
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user