diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f7a1b36..68e0130 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 `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 `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 @@ -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 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 - explicit `end` is assumed to run `ASSUMED_EVENT_DURATION` (2 hours) for this check only, - never written back to the event. External referees have no conflict check (no member to - check a schedule against). + explicit `end` is assumed to run `events.models.ASSUMED_EVENT_DURATION` (2 hours) for this + check. External referees have no conflict check (no member to 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 sign-up would make it nullable to mean "the referee signed themself up" rather than adding a parallel model — see §7. diff --git a/api/tests.py b/api/tests.py index c831caf..e3e64f1 100644 --- a/api/tests.py +++ b/api/tests.py @@ -356,6 +356,32 @@ class GamesApiTests(ApiTestBase): 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): self.make_game(kind=Event.EventKind.TOURNAMENT) diff --git a/events/api.py b/events/api.py index f68987a..ed3ba11 100644 --- a/events/api.py +++ b/events/api.py @@ -16,7 +16,7 @@ from api.errors import require_club from club.services.access import current_season from teams.models import Team -from .models import Event +from .models import ASSUMED_EVENT_DURATION, Event router = Router(tags=["games"]) @@ -49,6 +49,7 @@ class TeamRefOut(Schema): class GameOut(Schema): id: uuid.UUID start: datetime + end: datetime location: LocationOut | None home_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_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: status = "live" - elif event.start > timezone.now(): + elif event.start > now: 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: status = "finished" @@ -96,6 +106,7 @@ def _to_game_out(event, request, club, team=None) -> GameOut: return GameOut( id=event.pk, start=event.start, + end=effective_end, location=location, home_team=home_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") 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) 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] diff --git a/events/models.py b/events/models.py index bdc8900..b18f987 100644 --- a/events/models.py +++ b/events/models.py @@ -1,3 +1,4 @@ +import datetime from decimal import Decimal from django.conf import settings @@ -11,6 +12,11 @@ from members.models import Member from rosterchief.base import ClubScopedModel, UUIDModel, validate_club_scope 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): name = models.CharField(_("name"), max_length=255) @@ -101,6 +107,11 @@ class Event(ClubScopedModel): def clean(self): 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 def is_home_game(self) -> bool: """Whether this game is being played at the club's own ground diff --git a/events/services/referees.py b/events/services/referees.py index cccb922..a371d25 100644 --- a/events/services/referees.py +++ b/events/services/referees.py @@ -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. """ -import datetime from decimal import Decimal from django.db import transaction from django.utils import timezone 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 members.models import Member 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): """A referee could not be assigned to a game.""" diff --git a/events/tests.py b/events/tests.py index 637b942..867a8c1 100644 --- a/events/tests.py +++ b/events/tests.py @@ -94,6 +94,32 @@ class EventModelTests(EventsTestBase): self.assertFalse(game_with_no_location.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): def test_sport_type_defaults_to_other(self): diff --git a/management/forms.py b/management/forms.py index a237901..0f33578 100644 --- a/management/forms.py +++ b/management/forms.py @@ -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...")}), } diff --git a/management/pdf.py b/management/pdf.py index fe6ac1e..2511923 100644 --- a/management/pdf.py +++ b/management/pdf.py @@ -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 diff --git a/management/templates/management/event_referee_form_pdf.html b/management/templates/management/event_referee_form_pdf.html index c5bd089..0ff38da 100644 --- a/management/templates/management/event_referee_form_pdf.html +++ b/management/templates/management/event_referee_form_pdf.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%; } diff --git a/management/tests.py b/management/tests.py index 760b073..136018c 100644 --- a/management/tests.py +++ b/management/tests.py @@ -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() diff --git a/management/views.py b/management/views.py index 380e73c..e87c694 100644 --- a/management/views.py +++ b/management/views.py @@ -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)