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 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")
|
||||||
|
|||||||
Reference in New Issue
Block a user