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