Fix the referee PDF's card background, make locations searchable, and add game end times to the API
Referee PDF: the info-card background used CSS color-mix(), which WeasyPrint doesn't support -- the rule was silently dropped, leaving the card with no background at all. Computed in Python instead (management/pdf.py) and baked into the template as a plain hex value; the tint is based on the club's primary_color, falling back to secondary_color when primary is itself (near) black or white, where a straight tint would be invisible or too harsh. Event forms: the location picker now shows "Name — City" (plus the country when it isn't Belgium) and is searchable by name or city, reusing the existing single-select searchable-select.js widget. Games API: GameOut now carries `end` (explicit, or start + 2h when a GAME was saved without one -- Event.save() sets this, never overwriting an explicit end; other event kinds are untouched). /games/upcoming/ now includes anything not yet finished rather than only things that haven't started, so a game already in progress keeps showing up until its window closes; `status` was adjusted to match so a game returned there never calls itself "finished". Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -226,6 +226,17 @@ class SponsorForm(forms.ModelForm):
|
||||
return cleaned
|
||||
|
||||
|
||||
def _location_label(location) -> str:
|
||||
""""Name — City" for the location picker, plus the country when it isn't
|
||||
Belgium (the club's home country and, in practice, nearly every location
|
||||
a club will ever add) -- lets an admin tell two same-named venues apart,
|
||||
or spot an away trip abroad, straight from the dropdown."""
|
||||
label = f"{location.name} — {location.city}"
|
||||
if location.country.code != "BE":
|
||||
label += f", {location.country.name}"
|
||||
return label
|
||||
|
||||
|
||||
class EventAudienceFormMixin:
|
||||
"""Shared club/user-scoped audience fields for EventForm and EventSeriesForm:
|
||||
teams restricted to the ones the requester manages (all of them for an
|
||||
@@ -238,6 +249,7 @@ class EventAudienceFormMixin:
|
||||
self.user = user
|
||||
self.fields["teams"].queryset = Team.objects.filter(club=club) if is_club_admin(user, club) else teams_managed_by(user, club)
|
||||
self.fields["location"].queryset = Location.objects.filter(club=club)
|
||||
self.fields["location"].label_from_instance = _location_label
|
||||
self.fields["opponent"].queryset = Opponent.objects.filter(club=club)
|
||||
members = Member.objects.filter(member_of__club=club).distinct()
|
||||
self.fields["invited_members"].queryset = members
|
||||
@@ -253,6 +265,7 @@ _AUDIENCE_WIDGETS = {
|
||||
"teams": forms.SelectMultiple(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a team to search...")}),
|
||||
"invited_members": forms.SelectMultiple(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a name to search...")}),
|
||||
"excluded_members": forms.SelectMultiple(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a name to search...")}),
|
||||
"location": forms.Select(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a name or city to search...")}),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -11,11 +11,54 @@ real benefit.
|
||||
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
#: Referee form defaults, used whenever a club hasn't set its own colours (see
|
||||
#: club.models.Club.primary_color/secondary_color) -- picked to match the
|
||||
#: app's own daisyUI theme accent/secondary so an unbranded club's form still
|
||||
#: looks intentional rather than grey.
|
||||
DEFAULT_ACCENT_COLOR = "#3730a3"
|
||||
DEFAULT_SECONDARY_COLOR = "#be185d"
|
||||
|
||||
|
||||
class PDFExportError(Exception):
|
||||
"""Raised when WeasyPrint's native libraries aren't available."""
|
||||
|
||||
|
||||
def _is_near_black_or_white(hex_color: str, threshold: float = 0.12) -> bool:
|
||||
"""Whether a #rrggbb colour reads as (near-)black or (near-)white -- a rough
|
||||
perceived-lightness check (plain channel average, not WCAG luminance: this
|
||||
doesn't need to be contrast-accurate, just good enough to say "too close to
|
||||
black or white to make a nice pale card background")."""
|
||||
red, green, blue = (int(hex_color[index : index + 2], 16) for index in (1, 3, 5))
|
||||
lightness = (red + green + blue) / (3 * 255)
|
||||
return lightness < threshold or lightness > 1 - threshold
|
||||
|
||||
|
||||
def _tint_with_white(hex_color: str, strength: float = 0.14) -> str:
|
||||
"""A pale, card-background-friendly tint of `hex_color` -- mixed `strength`
|
||||
of the colour into white. Computed here rather than left to CSS
|
||||
`color-mix()`: WeasyPrint doesn't support that function, so the rule was
|
||||
silently dropped and the card rendered with no background at all."""
|
||||
channels = (int(hex_color[index : index + 2], 16) for index in (1, 3, 5))
|
||||
mixed = (round(channel * strength + 255 * (1 - strength)) for channel in channels)
|
||||
return "#{:02x}{:02x}{:02x}".format(*mixed)
|
||||
|
||||
|
||||
def referee_form_colors(club) -> dict:
|
||||
"""Accent colour (the club's own primary_color, or the app default) and a
|
||||
pale info-card background tint for the referee payment form.
|
||||
|
||||
The card is tinted off the *secondary* colour instead when the primary
|
||||
colour is itself (near) black or white -- using it straight would make an
|
||||
all-but-invisible near-white-on-white or a harsh near-black card, so the
|
||||
secondary colour (meant for exactly this kind of highlight -- see
|
||||
Club.secondary_color's help text) stands in instead.
|
||||
"""
|
||||
accent = club.primary_color or DEFAULT_ACCENT_COLOR
|
||||
secondary = club.secondary_color or DEFAULT_SECONDARY_COLOR
|
||||
tint_source = secondary if _is_near_black_or_white(accent) else accent
|
||||
return {"accent_color": accent, "info_card_color": _tint_with_white(tint_source)}
|
||||
|
||||
|
||||
def render_pdf(html: str) -> bytes:
|
||||
try:
|
||||
from weasyprint import HTML
|
||||
|
||||
@@ -25,9 +25,8 @@
|
||||
}
|
||||
}
|
||||
:root {
|
||||
--accent: {{ club.primary_color|default:"#3730a3" }};
|
||||
--accent-secondary: {{ club.secondary_color|default:"#be185d" }};
|
||||
--accent-soft: color-mix(in srgb, var(--accent-secondary) 14%, white);
|
||||
--accent: {{ accent_color }};
|
||||
--info-card-bg: {{ info_card_color }};
|
||||
--ink: #16181d; --muted: #6b7280; --line: #d8dae0;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
@@ -40,7 +39,7 @@
|
||||
.header .doc-title .kicker { font-size: 8pt; letter-spacing: 1pt; text-transform: uppercase; color: var(--muted); }
|
||||
.header .doc-title .title { font-size: 14pt; font-weight: 700; margin-top: 1mm; }
|
||||
|
||||
.info-card { background: var(--accent-soft); border-radius: 3mm; padding: 5mm 6mm; margin-bottom: 7mm; }
|
||||
.info-card { background: var(--info-card-bg); 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%; }
|
||||
|
||||
@@ -23,7 +23,7 @@ from events.models import Attendance, Competition, Event, EventReferee, EventSer
|
||||
from events.services.rbihf_import import RBIHFImportError
|
||||
from events.services.recurrence import detach_occurrence, generate_occurrences
|
||||
from management.bulk_import import TEMPLATE_COLUMNS
|
||||
from management.pdf import PDFExportError, render_pdf
|
||||
from management.pdf import PDFExportError, _tint_with_white, referee_form_colors, render_pdf
|
||||
from management.recurrence_ui import build_rrule, describe_rrule, parse_rrule
|
||||
from members.models import Family, FamilyMembership, Group, GroupMembership, Member
|
||||
from news.models import News, NewsPhoto
|
||||
@@ -3526,6 +3526,30 @@ class EventManagementTests(ManagementTestBase):
|
||||
self.assertRedirects(response, reverse("management:event_detail", args=[event.pk]))
|
||||
self.assertEqual(event.location, location)
|
||||
|
||||
def test_the_location_dropdown_shows_the_city(self):
|
||||
Location.objects.create(club=self.club, name="Sportcentrum", address="Straat 1", city="Mechelen", zip_code="2800", country="BE")
|
||||
self.client.force_login(self.admin_user)
|
||||
|
||||
response = self.club_get("event_create")
|
||||
|
||||
self.assertContains(response, "Sportcentrum — Mechelen")
|
||||
|
||||
def test_the_location_dropdown_adds_the_country_when_not_belgium(self):
|
||||
Location.objects.create(club=self.club, name="Rival Hall", address="Rue 2", city="Lille", zip_code="59000", country="FR")
|
||||
self.client.force_login(self.admin_user)
|
||||
|
||||
response = self.club_get("event_create")
|
||||
|
||||
self.assertContains(response, "Rival Hall — Lille, France")
|
||||
|
||||
def test_the_location_field_is_searchable(self):
|
||||
self.client.force_login(self.admin_user)
|
||||
|
||||
response = self.club_get("event_create")
|
||||
|
||||
self.assertContains(response, 'name="location"')
|
||||
self.assertContains(response, "data-searchable")
|
||||
|
||||
def test_the_new_event_forms_competition_dropdown_shows_every_competition_regardless_of_flag(self):
|
||||
# Unlike the Django-admin form, this dropdown isn't filtered by whether the
|
||||
# competition's flag is active for the club -- see management.forms.EventForm
|
||||
@@ -4322,18 +4346,45 @@ class EventRefereeFormPdfTests(ManagementTestBase):
|
||||
self.club.save(update_fields=["primary_color", "secondary_color"])
|
||||
game = self.make_game()
|
||||
|
||||
html = render_to_string("management/event_referee_form_pdf.html", {"club": self.club, "event": game, "referees": [], "home_location": self.home_ground, "grand_total": Decimal("0")})
|
||||
html = render_to_string("management/event_referee_form_pdf.html", {"club": self.club, "event": game, "referees": [], "home_location": self.home_ground, "grand_total": Decimal("0"), **referee_form_colors(self.club)})
|
||||
|
||||
self.assertIn("--accent: #0f766e", html)
|
||||
self.assertIn("--accent-secondary: #f59e0b", html)
|
||||
|
||||
def test_the_pdf_falls_back_to_default_colours_when_unset(self):
|
||||
game = self.make_game()
|
||||
|
||||
html = render_to_string("management/event_referee_form_pdf.html", {"club": self.club, "event": game, "referees": [], "home_location": self.home_ground, "grand_total": Decimal("0")})
|
||||
html = render_to_string("management/event_referee_form_pdf.html", {"club": self.club, "event": game, "referees": [], "home_location": self.home_ground, "grand_total": Decimal("0"), **referee_form_colors(self.club)})
|
||||
|
||||
self.assertIn("--accent: #3730a3", html)
|
||||
self.assertIn("--accent-secondary: #be185d", html)
|
||||
|
||||
def test_the_info_card_background_is_not_left_to_unsupported_css(self):
|
||||
# WeasyPrint doesn't support color-mix() -- the background must be a
|
||||
# plain computed hex value baked into the template, or the card
|
||||
# silently renders with no background at all.
|
||||
game = self.make_game()
|
||||
|
||||
html = render_to_string("management/event_referee_form_pdf.html", {"club": self.club, "event": game, "referees": [], "home_location": self.home_ground, "grand_total": Decimal("0"), **referee_form_colors(self.club)})
|
||||
|
||||
self.assertNotIn("color-mix(", html)
|
||||
self.assertIn("--info-card-bg: #", html)
|
||||
|
||||
def test_the_info_card_falls_back_to_secondary_when_primary_is_near_white(self):
|
||||
self.club.primary_color = "#ffffff"
|
||||
self.club.secondary_color = "#f59e0b"
|
||||
self.club.save(update_fields=["primary_color", "secondary_color"])
|
||||
|
||||
colors = referee_form_colors(self.club)
|
||||
|
||||
self.assertEqual(colors["accent_color"], "#ffffff")
|
||||
self.assertNotEqual(colors["info_card_color"], "#ffffff")
|
||||
|
||||
def test_the_info_card_uses_primary_when_it_is_not_near_black_or_white(self):
|
||||
self.club.primary_color = "#0f766e"
|
||||
self.club.save(update_fields=["primary_color"])
|
||||
|
||||
colors = referee_form_colors(self.club)
|
||||
|
||||
self.assertEqual(colors["info_card_color"], _tint_with_white("#0f766e"))
|
||||
|
||||
def test_a_missing_pdf_library_is_reported_rather_than_a_500(self):
|
||||
game = self.make_game()
|
||||
|
||||
@@ -78,7 +78,7 @@ from .forms import (
|
||||
TeamMembershipForm,
|
||||
TeamPhotoForm,
|
||||
)
|
||||
from .pdf import PDFExportError, event_referee_form_pdf, membership_list_pdf
|
||||
from .pdf import PDFExportError, event_referee_form_pdf, membership_list_pdf, referee_form_colors
|
||||
from .recurrence_ui import describe_rrule
|
||||
|
||||
|
||||
@@ -2143,7 +2143,7 @@ class EventRefereeFormPdfView(ClubAdminRequiredMixin, View):
|
||||
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()
|
||||
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"))}
|
||||
context = {"club": request.club, "event": event, "referees": referees, "home_location": home_location, "grand_total": sum((referee.total_payable for referee in referees), Decimal("0"))} | referee_form_colors(request.club)
|
||||
|
||||
try:
|
||||
pdf = event_referee_form_pdf(context)
|
||||
|
||||
Reference in New Issue
Block a user