Fix widget attrs being dropped from rendered inputs, and redesign the referee PDF

field.html's "input" branch rendered type/class/name/value/placeholder only,
silently dropping every other widget attr -- so a NumberInput's step="any"
(added to let the per-km rate take values like 0.083) never reached the page,
and the browser fell back to whole-number-only validation. Pass widget attrs
through the same way the "select" branch already does.

Also gives the referee payment PDF a proper visual pass (accent header, an
info card for the game details, a real fee/km table with a grand-total row)
instead of the plain label/dotted-line layout, and lets the dashboard tile
show assigned referees with a "referee form" download button per tile.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 12:34:33 +02:00
parent 309bd4d83e
commit 0a9ac67b21
5 changed files with 140 additions and 57 deletions

View File

@@ -24,6 +24,7 @@
name="{{ field.html_name }}" name="{{ field.html_name }}"
value="{% if field.widget_type == "date" or field.widget_type == "datetime" %}{% if field.value|date:"c"|default:"" != "" %}{{ field.value|date:"c"|default:"" }}{% else %}{{ field.value|default:"" }}{% endif %}{% else %}{% if field.value == None %}{{ field.value|default:"" }}{% else %}{{ field.value }}{% endif %}{% endif %}" value="{% if field.widget_type == "date" or field.widget_type == "datetime" %}{% if field.value|date:"c"|default:"" != "" %}{{ field.value|date:"c"|default:"" }}{% else %}{{ field.value|default:"" }}{% endif %}{% else %}{% if field.value == None %}{{ field.value|default:"" }}{% else %}{{ field.value }}{% endif %}{% endif %}"
{% if show_placeholder %}placeholder="{{ field.label|capfirst }}"{% endif %} {% if show_placeholder %}placeholder="{{ field.label|capfirst }}"{% endif %}
{% for attr, value in field.field.widget.attrs.items %}{% if attr != "type" %}{{ attr }}="{{ value }}" {% endif %}{% endfor %}
/> />
{% elif field_type == "checkbox" %} {% elif field_type == "checkbox" %}

View File

@@ -11,6 +11,7 @@ from django.contrib.auth import get_user_model
from django.contrib.messages.storage.base import Message from django.contrib.messages.storage.base import Message
from django.contrib.messages.storage.fallback import FallbackStorage from django.contrib.messages.storage.fallback import FallbackStorage
from django.core.cache import cache from django.core.cache import cache
from django.template.loader import render_to_string
from django.test import RequestFactory, TestCase, override_settings from django.test import RequestFactory, TestCase, override_settings
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
@@ -50,7 +51,7 @@ from .services.statistics import (
teams_without_a_manager, teams_without_a_manager,
unrostered_members, unrostered_members,
) )
from .templatetags.ui import as_alert, daisy, excluded, field_icon from .templatetags.ui import as_alert, daisy, excluded, field_icon, form_field
User = get_user_model() User = get_user_model()
Flag = get_waffle_flag_model() Flag = get_waffle_flag_model()
@@ -691,6 +692,20 @@ class FieldRenderingTests(TestCase):
self.assertIn("input-error", rendered) self.assertIn("input-error", rendered)
def test_the_form_field_tag_carries_widget_attrs_onto_the_input(self):
# Regression: the "input" branch of templatetags/field.html rendered type/
# class/name/value/placeholder only, silently dropping every other widget
# attr (step, min, ...) -- e.g. NumberInput(attrs={"step": "any"}) never
# reached the page, so a decimal-only field like a per-km rate couldn't be
# typed at all. See form_field in controlpanel/templatetags/ui.py.
bound_field = self.field(forms.DecimalField(widget=forms.NumberInput(attrs={"step": "any", "min": "0"})), "rate")
html = render_to_string("templatetags/field.html", form_field(bound_field))
self.assertIn('step="any"', html)
self.assertIn('min="0"', html)
self.assertEqual(html.count('type="number"'), 1)
class LoginFormRenderingTests(TestCase): class LoginFormRenderingTests(TestCase):
def setUp(self): def setUp(self):

View File

@@ -1,14 +1,13 @@
{% load i18n %} {% load i18n %}
{% comment %} {% comment %}
Rendered by WeasyPrint -- modeled directly on the club's existing paper Rendered by WeasyPrint -- modeled on the club's existing paper referee
referee payment form ("Leeg Document RefereesCompetitie25-26.pdf"): a payment form ("Leeg Document RefereesCompetitie25-26.pdf"): club header,
3-line header (club name/address), match details, referee names, a match details, referee names, a payment sentence, a fee+km breakdown per
payment sentence, a fee+km breakdown per referee, and blank signature referee, and blank signature lines. Header uses club.official_name
lines. Header uses club.official_name (legal_name if set, else name); (legal_name if set, else name); the payment sentence uses the plain club
the payment sentence uses the plain club name -- the original document name -- the original document does the same, using a longer legal form up
does the same, using a longer legal form up top and a shorter one in the top and a shorter one in the body text.
body text.
{% endcomment %} {% endcomment %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
@@ -18,69 +17,135 @@
<style> <style>
@page { @page {
size: A4; size: A4;
margin: 25mm 20mm; margin: 20mm 18mm;
@bottom-center { @bottom-center {
content: "{{ club.name }} — {% trans "referee form" %} — " counter(page) " / " counter(pages); content: "{{ club.name }} — {% trans "referee form" %} — " counter(page) " / " counter(pages);
font-size: 8pt; font-size: 8pt;
color: #666; color: #8a8a8a;
} }
} }
body { font-family: sans-serif; font-size: 10.5pt; color: #111; line-height: 1.5; } :root { --accent: #3730a3; --accent-soft: #eef0fc; --ink: #16181d; --muted: #6b7280; --line: #d8dae0; }
.header { text-align: center; margin-bottom: 14mm; } * { box-sizing: border-box; }
.header div { margin: 0; } body { font-family: sans-serif; font-size: 10pt; color: var(--ink); line-height: 1.45; }
.field { margin-bottom: 4mm; }
.field .label { display: inline-block; min-width: 32mm; } .header { display: flex; justify-content: space-between; align-items: flex-start; padding-bottom: 5mm; border-bottom: 2.5pt solid var(--accent); margin-bottom: 8mm; }
.fill-line { display: block; border-bottom: 1px solid #333; min-height: 5mm; margin: 2mm 0 4mm; } .header .club-name { font-size: 16pt; font-weight: 700; color: var(--accent); margin: 0 0 1mm; }
.section-title { margin: 8mm 0 2mm; } .header .club-address { font-size: 9pt; color: var(--muted); }
.fee-row { border-bottom: 1px solid #333; padding-bottom: 1mm; margin-bottom: 4mm; min-height: 5mm; } .header .doc-title { text-align: right; }
.signature-block { margin-top: 12mm; } .header .doc-title .kicker { font-size: 8pt; letter-spacing: 1pt; text-transform: uppercase; color: var(--muted); }
.signature-line { border-bottom: 1px solid #333; min-height: 16mm; margin-top: 2mm; } .header .doc-title .title { font-size: 14pt; font-weight: 700; margin-top: 1mm; }
.collected-line { margin-top: 12mm; }
.collected-line .fill { display: inline-block; border-bottom: 1px solid #333; min-width: 90mm; } .info-card { background: var(--accent-soft); border-radius: 3mm; padding: 5mm 6mm; margin-bottom: 7mm; }
.info-grid { display: flex; flex-wrap: wrap; gap: 4mm 10mm; }
.info-grid .item { min-width: 45mm; }
.info-grid .item.wide { flex: 1 1 100%; min-width: 100%; }
.info-label { font-size: 7.5pt; letter-spacing: 0.5pt; text-transform: uppercase; color: var(--muted); margin-bottom: 0.5mm; }
.info-value { font-size: 10.5pt; font-weight: 600; }
.note { font-size: 9.5pt; color: var(--muted); margin: 0 0 7mm; }
.section-title { font-size: 8pt; letter-spacing: 0.5pt; text-transform: uppercase; color: var(--accent); font-weight: 700; margin: 0 0 2mm; }
table.fees { width: 100%; border-collapse: collapse; margin-bottom: 9mm; }
table.fees th { text-align: left; font-size: 7.5pt; letter-spacing: 0.5pt; text-transform: uppercase; color: var(--muted); border-bottom: 1pt solid var(--line); padding: 2mm 2mm 1.5mm 0; }
table.fees td { padding: 2.5mm 2mm 2.5mm 0; border-bottom: 0.75pt solid var(--line); vertical-align: top; }
table.fees td.amount, table.fees th.amount { text-align: right; }
table.fees tr.empty td { color: var(--muted); font-style: italic; }
.badge-external { display: inline-block; font-size: 7pt; color: var(--accent); background: var(--accent-soft); border-radius: 2mm; padding: 0.3mm 1.5mm; margin-left: 1.5mm; }
table.fees tr.total td { border-bottom: none; border-top: 1.5pt solid var(--ink); font-weight: 700; padding-top: 3mm; }
.signatures { display: flex; gap: 8mm; margin-top: 4mm; }
.sign-box { flex: 1; }
.sign-box .info-label { margin-bottom: 2mm; }
.sign-box .box { border: 0.75pt solid var(--line); border-radius: 2mm; min-height: 24mm; }
.collected-line { margin-top: 8mm; font-size: 9.5pt; }
.collected-line .fill { display: inline-block; border-bottom: 0.75pt solid var(--line); min-width: 90mm; }
</style> </style>
</head> </head>
<body> <body>
<div class="header"> <div class="header">
<div>{{ club.official_name }}</div> <div>
<div class="club-name">{{ club.official_name }}</div>
{% if home_location %} {% if home_location %}
<div>{{ home_location.address }}</div> <div class="club-address">{{ home_location.address }}</div>
<div>{{ home_location.zip_code }} {{ home_location.city }}</div> <div class="club-address">{{ home_location.zip_code }} {{ home_location.city }}</div>
{% endif %} {% endif %}
</div> </div>
<div class="doc-title">
<div class="kicker">{% trans "Referee payment form" %}</div>
<div class="title">{{ event.start|date:"j F Y" }}</div>
</div>
</div>
<div class="field"> <div class="info-card">
<span class="label">{% trans "Regarding: Game" %}</span> <div class="info-grid">
<div class="item wide">
<div class="info-label">{% trans "Game" %}</div>
<div class="info-value">
{% for team in event.teams.all %}{{ team.short_name }}{% if not forloop.last %}, {% endif %}{% endfor %} {% for team in event.teams.all %}{{ team.short_name }}{% if not forloop.last %}, {% endif %}{% endfor %}
{% if event.opponent %}{% trans "vs" %} {{ event.opponent }}{% endif %} {% if event.opponent %}{% trans "vs" %} {{ event.opponent }}{% endif %}
{% if event.external_game_id %}({% trans "ID" %}: {{ event.external_game_id }}){% endif %} {% if event.external_game_id %}<span style="color: var(--muted); font-weight: 400;">({% trans "ID" %}: {{ event.external_game_id }})</span>{% endif %}
</div> </div>
<div class="field"><span class="label">{% trans "Date" %}</span> {{ event.start|date:"j F Y" }}</div> </div>
<div class="field"><span class="label">{% trans "Time" %}</span> {{ event.start|date:"H:i" }}</div> <div class="item">
<div class="field"> <div class="info-label">{% trans "Date" %}</div>
<span class="label">{% trans "Referee names" %}</span> <div class="info-value">{{ event.start|date:"j F Y" }}</div>
</div>
<div class="item">
<div class="info-label">{% trans "Time" %}</div>
<div class="info-value">{{ event.start|date:"H:i" }}</div>
</div>
<div class="item wide">
<div class="info-label">{% trans "Referee names" %}</div>
<div class="info-value">
{% for referee in referees %}{{ referee.display_name }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %} {% for referee in referees %}{{ referee.display_name }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}
</div> </div>
<div class="fill-line"></div> </div>
</div>
</div>
<p>{% blocktrans with name=club.name %}{{ name }} pays the fee to the referee(s) for officiating the game mentioned above.{% endblocktrans %}</p> <p class="note">{% blocktrans with name=club.name %}{{ name }} pays the fee to the referee(s) for officiating the game mentioned above.{% endblocktrans %}</p>
<div class="section-title">{% trans "Referees and fee, plus any kilometers" %}</div> <div class="section-title">{% trans "Referees and fee, plus any kilometers" %}</div>
<table class="fees">
<thead>
<tr>
<th>{% trans "Referee" %}</th>
<th class="amount">{% trans "Fee" %}</th>
<th class="amount">{% trans "Kilometers" %}</th>
<th class="amount">{% trans "Rate" %}</th>
<th class="amount">{% trans "Total" %}</th>
</tr>
</thead>
<tbody>
{% for referee in referees %} {% for referee in referees %}
<div class="fee-row"> <tr>
{{ referee.display_name }} — <td>
{% blocktrans with fee=referee.fee %}fee: €{{ fee }}{% endblocktrans %} {{ referee.display_name }}
{% if referee.km %} {% if referee.is_external %}<span class="badge-external">{% trans "External" %}</span>{% endif %}
{% blocktrans with km=referee.km rate=referee.km_rate total=referee.km_total %}+ {{ km }} km × €{{ rate }}/km = €{{ total }}{% endblocktrans %} </td>
{% endif %} <td class="amount">€{{ referee.fee|floatformat:2 }}</td>
— {% blocktrans with total=referee.total_payable %}total: €{{ total }}{% endblocktrans %} <td class="amount">{% if referee.km %}{{ referee.km }} km{% else %}—{% endif %}</td>
</div> <td class="amount">{% if referee.km %}€{{ referee.km_rate }}{% else %}—{% endif %}</td>
<td class="amount">€{{ referee.total_payable|floatformat:2 }}</td>
</tr>
{% empty %} {% empty %}
<div class="fee-row"></div> <tr class="empty"><td colspan="5">{% trans "No referees assigned yet." %}</td></tr>
{% endfor %} {% endfor %}
{% if referees %}
<tr class="total">
<td colspan="4">{% trans "Total to pay" %}</td>
<td class="amount">€{{ grand_total|floatformat:2 }}</td>
</tr>
{% endif %}
</tbody>
</table>
<div class="signature-block"> <div class="signatures">
<div>{% trans "Referee signature" %}</div> <div class="sign-box">
<div class="signature-line"></div> <div class="info-label">{% trans "Referee signature" %}</div>
<div class="box"></div>
</div>
</div> </div>
<div class="collected-line"> <div class="collected-line">

View File

@@ -54,7 +54,7 @@
<div class="text-xs opacity-70">{{ game.start|date:"H:i" }}{% if game.location %} — {{ game.location }}{% endif %}</div> <div class="text-xs opacity-70">{{ game.start|date:"H:i" }}{% if game.location %} — {{ game.location }}{% endif %}</div>
<div class="text-xs"> <div class="text-xs">
{% for referee in game.referee_rows %} {% for referee in game.referee_rows %}
<span class="badge badge-ghost badge-sm">{{ referee.display_name }}{% if referee.is_external %} ({% trans "ext." %}){% endif %}</span> <span class="badge badge-primary badge-sm">{{ referee.display_name }}{% if referee.is_external %} ({% trans "ext." %}){% endif %}</span>
{% empty %} {% empty %}
<span class="opacity-60">{% trans "No referees assigned yet." %}</span> <span class="opacity-60">{% trans "No referees assigned yet." %}</span>
{% endfor %} {% endfor %}

View File

@@ -1,4 +1,5 @@
from datetime import timedelta from datetime import timedelta
from decimal import Decimal
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import IntegrityError, transaction from django.db import IntegrityError, transaction
@@ -2141,7 +2142,8 @@ class EventRefereeFormPdfView(ClubAdminRequiredMixin, View):
def get(self, request, pk): def get(self, request, pk):
event = get_object_or_404(Event.objects.filter(club=request.club).prefetch_related("teams", "referees__member"), pk=pk) event = get_object_or_404(Event.objects.filter(club=request.club).prefetch_related("teams", "referees__member"), pk=pk)
home_location = Location.objects.filter(club=request.club, is_home=True).first() home_location = Location.objects.filter(club=request.club, is_home=True).first()
context = {"club": request.club, "event": event, "referees": list(event.referees.all()), "home_location": home_location} referees = list(event.referees.all())
context = {"club": request.club, "event": event, "referees": referees, "home_location": home_location, "grand_total": sum((referee.total_payable for referee in referees), Decimal("0"))}
try: try:
pdf = event_referee_form_pdf(context) pdf = event_referee_form_pdf(context)