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:
2026-08-10 23:44:11 +02:00
parent 5dcffefb28
commit e737de9140
11 changed files with 222 additions and 24 deletions

View File

@@ -541,6 +541,18 @@ row today — there's no check-in UI yet, only Django admin); a "no-show" is
a missing check-in. See `events/services/attendance.py::record_check_in` and a missing check-in. See `events/services/attendance.py::record_check_in` and
`management/views.py::TeamDetailView`'s attendance panel. `management/views.py::TeamDetailView`'s attendance panel.
**As built, a GAME-kind `Event` defaults its own `end`**`Event.save()` sets
`end = start + events.models.ASSUMED_EVENT_DURATION` (2 hours) whenever a game is saved
with no explicit `end`, and never overwrites one that's already set. Other event kinds are
untouched — `end` stays blank for them unless explicitly given one. The public games API
(`events/api.py`, `GET /games/upcoming/`) reads through this: it returns every non-cancelled
game/tournament that **hasn't finished yet** (`end` — explicit, defaulted, or, for the rare
un-saved-since / non-GAME row still lacking one, `start` within the assumed window — is at or
after now), not just ones that haven't started, so a game already in progress keeps showing up
until its window closes; `GameOut.end` is always populated the same way, and `status` treats
"started but before its (assumed) end, not flagged `is_live`" as `"live"` too, so a game
`/games/upcoming/` still lists never turns around and calls itself `"finished"`.
**As built, `Event` also carries `max_referees`** (`PositiveSmallIntegerField`, default **As built, `Event` also carries `max_referees`** (`PositiveSmallIntegerField`, default
`2`) and **`EventReferee`** *(built)* — referee sign-up/assignment for a **home game** `2`) and **`EventReferee`** *(built)* — referee sign-up/assignment for a **home game**
only (`Event.is_home_game`), staff-assigned for now (self-service subscribe is a planned only (`Event.is_home_game`), staff-assigned for now (self-service subscribe is a planned
@@ -597,9 +609,11 @@ EventReferee(UUIDModel) # club implied by event
event)` finds other events overlapping this one's time window where the member is part of event)` finds other events overlapping this one's time window where the member is part of
the expected audience (`effective_members`, reused from the attendance service above) — the the expected audience (`effective_members`, reused from the attendance service above) — the
UI shows it (⚠ + tooltip on the assign control) but a human decides; an event with no UI shows it (⚠ + tooltip on the assign control) but a human decides; an event with no
explicit `end` is assumed to run `ASSUMED_EVENT_DURATION` (2 hours) for this check only, explicit `end` is assumed to run `events.models.ASSUMED_EVENT_DURATION` (2 hours) for this
never written back to the event. External referees have no conflict check (no member to check. External referees have no conflict check (no member to check a schedule against).
check a schedule against). `ASSUMED_EVENT_DURATION` is also what `Event.save()` writes into `end` for a GAME with none
set (below) — the *other* event kinds still leave `end` blank rather than defaulting it, so
this read-time fallback still matters for them.
- **`assigned_by` is required for now** (admin-only assignment). A future self-service - **`assigned_by` is required for now** (admin-only assignment). A future self-service
sign-up would make it nullable to mean "the referee signed themself up" rather than adding sign-up would make it nullable to mean "the referee signed themself up" rather than adding
a parallel model — see §7. a parallel model — see §7.

View File

@@ -356,6 +356,32 @@ class GamesApiTests(ApiTestBase):
self.assertEqual(self.api_get("/games/upcoming/").json(), []) self.assertEqual(self.api_get("/games/upcoming/").json(), [])
def test_upcoming_includes_a_game_already_in_progress(self):
# Started 30 minutes ago, no explicit end -- the assumed 2h window
# means it isn't finished yet, so it must still show up.
self.make_game(start=timezone.now() - datetime.timedelta(minutes=30))
games = self.api_get("/games/upcoming/").json()
self.assertEqual(len(games), 1)
self.assertEqual(games[0]["status"], "live")
def test_upcoming_excludes_a_game_past_its_explicit_end(self):
game = self.make_game(start=timezone.now() - datetime.timedelta(hours=3))
game.end = timezone.now() - datetime.timedelta(hours=1)
game.save()
self.assertEqual(self.api_get("/games/upcoming/").json(), [])
def test_the_response_includes_an_end_time(self):
game = self.make_game()
end = self.api_get("/games/upcoming/").json()[0]["end"]
# JSON round-trips to millisecond precision -- compare with a small
# tolerance rather than an exact microsecond match.
self.assertLess(abs((datetime.datetime.fromisoformat(end) - game.end).total_seconds()), 1)
def test_upcoming_includes_tournaments(self): def test_upcoming_includes_tournaments(self):
self.make_game(kind=Event.EventKind.TOURNAMENT) self.make_game(kind=Event.EventKind.TOURNAMENT)

View File

@@ -16,7 +16,7 @@ from api.errors import require_club
from club.services.access import current_season from club.services.access import current_season
from teams.models import Team from teams.models import Team
from .models import Event from .models import ASSUMED_EVENT_DURATION, Event
router = Router(tags=["games"]) router = Router(tags=["games"])
@@ -49,6 +49,7 @@ class TeamRefOut(Schema):
class GameOut(Schema): class GameOut(Schema):
id: uuid.UUID id: uuid.UUID
start: datetime start: datetime
end: datetime
location: LocationOut | None location: LocationOut | None
home_team: TeamRefOut | None home_team: TeamRefOut | None
away_team: TeamRefOut | None away_team: TeamRefOut | None
@@ -82,10 +83,19 @@ def _to_game_out(event, request, club, team=None) -> GameOut:
home_team, away_team = opponent_ref, team_ref home_team, away_team = opponent_ref, team_ref
home_score, away_score = event.score_against, event.score_for home_score, away_score = event.score_against, event.score_for
now = timezone.now()
effective_end = event.end or (event.start + ASSUMED_EVENT_DURATION)
if event.is_live: if event.is_live:
status = "live" status = "live"
elif event.start > timezone.now(): elif event.start > now:
status = "upcoming" status = "upcoming"
elif effective_end > now:
# Started, not manually flagged live, but our own assumed/explicit
# window says it isn't over yet -- matches list_upcoming_games'
# "not finished" inclusion below, so a game returned there never
# turns around and calls itself "finished".
status = "live"
else: else:
status = "finished" status = "finished"
@@ -96,6 +106,7 @@ def _to_game_out(event, request, club, team=None) -> GameOut:
return GameOut( return GameOut(
id=event.pk, id=event.pk,
start=event.start, start=event.start,
end=effective_end,
location=location, location=location,
home_team=home_team, home_team=home_team,
away_team=away_team, away_team=away_team,
@@ -109,11 +120,20 @@ def _to_game_out(event, request, club, team=None) -> GameOut:
@router.get("/games/upcoming/", response=list[GameOut], summary="Upcoming games") @router.get("/games/upcoming/", response=list[GameOut], summary="Upcoming games")
def list_upcoming_games(request, count: int = DEFAULT_UPCOMING_COUNT): def list_upcoming_games(request, count: int = DEFAULT_UPCOMING_COUNT):
"""The next `count` non-cancelled upcoming games and tournaments, club-wide.""" """The next `count` non-cancelled games and tournaments that aren't finished
yet, club-wide -- a game already in progress (started, not yet past its
explicit or assumed end) still counts, not just ones that haven't started."""
club = require_club(request) club = require_club(request)
count = max(1, min(count, MAX_UPCOMING_COUNT)) count = max(1, min(count, MAX_UPCOMING_COUNT))
now = timezone.now()
events = Event.objects.filter(club=club, kind__in=UPCOMING_KINDS, cancelled=False, start__gte=timezone.now()).select_related("opponent", "location").prefetch_related("teams").order_by("start")[:count] events = (
Event.objects.filter(club=club, kind__in=UPCOMING_KINDS, cancelled=False)
.filter(Q(end__gte=now) | Q(end__isnull=True, start__gte=now - ASSUMED_EVENT_DURATION))
.select_related("opponent", "location")
.prefetch_related("teams")
.order_by("start")[:count]
)
return [_to_game_out(event, request, club) for event in events] return [_to_game_out(event, request, club) for event in events]

View File

@@ -1,3 +1,4 @@
import datetime
from decimal import Decimal from decimal import Decimal
from django.conf import settings from django.conf import settings
@@ -11,6 +12,11 @@ from members.models import Member
from rosterchief.base import ClubScopedModel, UUIDModel, validate_club_scope from rosterchief.base import ClubScopedModel, UUIDModel, validate_club_scope
from teams.models import Team from teams.models import Team
#: How long a game is assumed to run when no explicit `end` is given -- set on
#: GAME events at save time (Event.save() below), and reused as a read-time-only
#: fallback for other event kinds by events.services.referees.event_window().
ASSUMED_EVENT_DURATION = datetime.timedelta(hours=2)
class Opponent(ClubScopedModel): class Opponent(ClubScopedModel):
name = models.CharField(_("name"), max_length=255) name = models.CharField(_("name"), max_length=255)
@@ -101,6 +107,11 @@ class Event(ClubScopedModel):
def clean(self): def clean(self):
validate_club_scope(self, self.club_id, same_club_fields=("season", "location", "opponent")) validate_club_scope(self, self.club_id, same_club_fields=("season", "location", "opponent"))
def save(self, *args, **kwargs):
if self.kind == self.EventKind.GAME and self.end is None:
self.end = self.start + ASSUMED_EVENT_DURATION
super().save(*args, **kwargs)
@property @property
def is_home_game(self) -> bool: def is_home_game(self) -> bool:
"""Whether this game is being played at the club's own ground """Whether this game is being played at the club's own ground

View File

@@ -21,22 +21,17 @@ only (still counts against Event.max_referees, still capacity-checked), for
e.g. a federation-appointed referee the club still needs to pay. e.g. a federation-appointed referee the club still needs to pay.
""" """
import datetime
from decimal import Decimal from decimal import Decimal
from django.db import transaction from django.db import transaction
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from events.models import Event, EventReferee from events.models import ASSUMED_EVENT_DURATION, Event, EventReferee
from events.services.attendance import effective_members from events.services.attendance import effective_members
from members.models import Member from members.models import Member
from teams.models import Team from teams.models import Team
# Used only to give an event with no explicit `end` a time window for the
# overlap check below -- never written back to the event itself.
ASSUMED_EVENT_DURATION = datetime.timedelta(hours=2)
class RefereeAssignmentError(Exception): class RefereeAssignmentError(Exception):
"""A referee could not be assigned to a game.""" """A referee could not be assigned to a game."""

View File

@@ -94,6 +94,32 @@ class EventModelTests(EventsTestBase):
self.assertFalse(game_with_no_location.is_home_game) self.assertFalse(game_with_no_location.is_home_game)
self.assertFalse(training_at_home_ground.is_home_game) self.assertFalse(training_at_home_ground.is_home_game)
def test_a_game_with_no_end_gets_a_two_hour_default_on_save(self):
game = self.make_event(kind=Event.EventKind.GAME, start=self.future)
self.assertEqual(game.end, self.future + timedelta(hours=2))
def test_an_explicit_end_is_not_overridden(self):
explicit_end = self.future + timedelta(hours=5)
game = self.make_event(kind=Event.EventKind.GAME, start=self.future, end=explicit_end)
self.assertEqual(game.end, explicit_end)
def test_a_non_game_event_is_left_with_no_end(self):
training = self.make_event(kind=Event.EventKind.TRAINING, start=self.future)
self.assertIsNone(training.end)
def test_re_saving_a_game_does_not_move_its_end(self):
game = self.make_event(kind=Event.EventKind.GAME, start=self.future)
original_end = game.end
game.title = "Renamed"
game.save()
self.assertEqual(game.end, original_end)
class CompetitionModelTests(EventsTestBase): class CompetitionModelTests(EventsTestBase):
def test_sport_type_defaults_to_other(self): def test_sport_type_defaults_to_other(self):

View File

@@ -226,6 +226,17 @@ class SponsorForm(forms.ModelForm):
return cleaned 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: class EventAudienceFormMixin:
"""Shared club/user-scoped audience fields for EventForm and EventSeriesForm: """Shared club/user-scoped audience fields for EventForm and EventSeriesForm:
teams restricted to the ones the requester manages (all of them for an teams restricted to the ones the requester manages (all of them for an
@@ -238,6 +249,7 @@ class EventAudienceFormMixin:
self.user = user 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["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"].queryset = Location.objects.filter(club=club)
self.fields["location"].label_from_instance = _location_label
self.fields["opponent"].queryset = Opponent.objects.filter(club=club) self.fields["opponent"].queryset = Opponent.objects.filter(club=club)
members = Member.objects.filter(member_of__club=club).distinct() members = Member.objects.filter(member_of__club=club).distinct()
self.fields["invited_members"].queryset = members 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...")}), "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...")}), "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...")}), "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...")}),
} }

View File

@@ -11,11 +11,54 @@ real benefit.
from django.template.loader import render_to_string 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): class PDFExportError(Exception):
"""Raised when WeasyPrint's native libraries aren't available.""" """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: def render_pdf(html: str) -> bytes:
try: try:
from weasyprint import HTML from weasyprint import HTML

View File

@@ -25,9 +25,8 @@
} }
} }
:root { :root {
--accent: {{ club.primary_color|default:"#3730a3" }}; --accent: {{ accent_color }};
--accent-secondary: {{ club.secondary_color|default:"#be185d" }}; --info-card-bg: {{ info_card_color }};
--accent-soft: color-mix(in srgb, var(--accent-secondary) 14%, white);
--ink: #16181d; --muted: #6b7280; --line: #d8dae0; --ink: #16181d; --muted: #6b7280; --line: #d8dae0;
} }
* { box-sizing: border-box; } * { 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 .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; } .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 { display: flex; flex-wrap: wrap; gap: 4mm 10mm; }
.info-grid .item { min-width: 45mm; } .info-grid .item { min-width: 45mm; }
.info-grid .item.wide { flex: 1 1 100%; min-width: 100%; } .info-grid .item.wide { flex: 1 1 100%; min-width: 100%; }

View File

@@ -23,7 +23,7 @@ from events.models import Attendance, Competition, Event, EventReferee, EventSer
from events.services.rbihf_import import RBIHFImportError from events.services.rbihf_import import RBIHFImportError
from events.services.recurrence import detach_occurrence, generate_occurrences from events.services.recurrence import detach_occurrence, generate_occurrences
from management.bulk_import import TEMPLATE_COLUMNS 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 management.recurrence_ui import build_rrule, describe_rrule, parse_rrule
from members.models import Family, FamilyMembership, Group, GroupMembership, Member from members.models import Family, FamilyMembership, Group, GroupMembership, Member
from news.models import News, NewsPhoto from news.models import News, NewsPhoto
@@ -3526,6 +3526,30 @@ class EventManagementTests(ManagementTestBase):
self.assertRedirects(response, reverse("management:event_detail", args=[event.pk])) self.assertRedirects(response, reverse("management:event_detail", args=[event.pk]))
self.assertEqual(event.location, location) 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): 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 # 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 # 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"]) self.club.save(update_fields=["primary_color", "secondary_color"])
game = self.make_game() 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: #0f766e", html)
self.assertIn("--accent-secondary: #f59e0b", html)
def test_the_pdf_falls_back_to_default_colours_when_unset(self): def test_the_pdf_falls_back_to_default_colours_when_unset(self):
game = self.make_game() 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: #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): def test_a_missing_pdf_library_is_reported_rather_than_a_500(self):
game = self.make_game() game = self.make_game()

View File

@@ -78,7 +78,7 @@ from .forms import (
TeamMembershipForm, TeamMembershipForm,
TeamPhotoForm, 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 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) 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()
referees = list(event.referees.all()) 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: try:
pdf = event_referee_form_pdf(context) pdf = event_referee_form_pdf(context)