diff --git a/mobile/coach_views.py b/mobile/coach_views.py index fe0c5cc..6be5edb 100644 --- a/mobile/coach_views.py +++ b/mobile/coach_views.py @@ -5,8 +5,11 @@ club/season plumbing already factored into club.services.access -- see mobile/coach_mixins.py's CoachScopeMixin for the shared scaffolding. """ +import datetime + from django import forms from django.contrib.auth.mixins import LoginRequiredMixin +from django.db.models import Case, F, When from django.http import Http404, HttpResponseForbidden, HttpResponseRedirect from django.shortcuts import get_object_or_404 from django.urls import reverse @@ -40,6 +43,25 @@ IN_STATUSES = [Attendance.AttendanceStatus.PRESENT, Attendance.AttendanceStatus. #: Distinct from NO_RESPONSE ("silent"), which is a non-answer rather than a no. OUT_STATUSES = [Attendance.AttendanceStatus.ABSENT, Attendance.AttendanceStatus.EXCUSED, Attendance.AttendanceStatus.NOT_SELECTED] +#: How long an event stays "current" (CoachTodayView's session card, and the +#: missing-line-up nudge) past the moment it starts -- events.start__gte=now +#: alone would flip to the next session the instant this one begins, while +#: the coach is still mid-practice/mid-game. Past end (when set) plus a grace +#: window; past start plus an assumed length when it isn't (most training +#: events carry no end time -- see Event.end's own help text). +STILL_CURRENT_GRACE = datetime.timedelta(minutes=30) +STILL_CURRENT_ASSUMED_DURATION = datetime.timedelta(minutes=90) + + +def _still_current_events(team): + """Events for ``team`` that haven't yet reached their "still current" + cutoff -- see STILL_CURRENT_GRACE/STILL_CURRENT_ASSUMED_DURATION above.""" + cutoff = Case( + When(end__isnull=False, then=F("end") + STILL_CURRENT_GRACE), + default=F("start") + STILL_CURRENT_ASSUMED_DURATION, + ) + return Event.objects.filter(teams=team, cancelled=False).annotate(still_current_cutoff=cutoff).filter(still_current_cutoff__gte=timezone.now()).order_by("start") + class CoachTodayView(CoachScopeMixin, LoginRequiredMixin, TemplateView): """C1 -- three stat tiles (Squad/In/Silent) for the active team's next @@ -86,7 +108,7 @@ class CoachTodayView(CoachScopeMixin, LoginRequiredMixin, TemplateView): if team is not None: squad_count = TeamMembership.objects.filter(team=team, season=season).count() if season is not None else 0 - upcoming = Event.objects.filter(teams=team, cancelled=False, start__gte=now).order_by("start") + upcoming = _still_current_events(team) tonight_event = upcoming.filter(start__date=today).first() session_event = tonight_event or upcoming.first() diff --git a/mobile/tests.py b/mobile/tests.py index 3c54cbe..7693321 100644 --- a/mobile/tests.py +++ b/mobile/tests.py @@ -2419,6 +2419,62 @@ class CoachTodayViewTests(TestCase): self.assertContains(response, "Next up") self.assertContains(response, "Next week") + def test_a_session_with_an_end_time_stays_current_until_30_minutes_past_it(self): + ongoing = Event.objects.create( + club=self.club, + title="Ongoing game", + kind=Event.EventKind.GAME, + start=timezone.now() - datetime.timedelta(hours=2), + end=timezone.now() - datetime.timedelta(minutes=20), + ) + ongoing.teams.add(self.team) + later = Event.objects.create(club=self.club, title="Later practice", kind=Event.EventKind.TRAINING, start=timezone.now() + datetime.timedelta(days=1)) + later.teams.add(self.team) + self.client.force_login(self.user) + + response = self._get() + + self.assertEqual(response.context["session_event"], ongoing) + + def test_a_session_with_an_end_time_switches_30_minutes_past_it(self): + finished = Event.objects.create( + club=self.club, + title="Finished game", + kind=Event.EventKind.GAME, + start=timezone.now() - datetime.timedelta(hours=2), + end=timezone.now() - datetime.timedelta(minutes=40), + ) + finished.teams.add(self.team) + later = Event.objects.create(club=self.club, title="Later practice", kind=Event.EventKind.TRAINING, start=timezone.now() + datetime.timedelta(days=1)) + later.teams.add(self.team) + self.client.force_login(self.user) + + response = self._get() + + self.assertEqual(response.context["session_event"], later) + + def test_a_session_with_no_end_time_stays_current_until_90_minutes_past_start(self): + ongoing = Event.objects.create(club=self.club, title="Ongoing practice", kind=Event.EventKind.TRAINING, start=timezone.now() - datetime.timedelta(minutes=80)) + ongoing.teams.add(self.team) + later = Event.objects.create(club=self.club, title="Later practice", kind=Event.EventKind.TRAINING, start=timezone.now() + datetime.timedelta(days=1)) + later.teams.add(self.team) + self.client.force_login(self.user) + + response = self._get() + + self.assertEqual(response.context["session_event"], ongoing) + + def test_a_session_with_no_end_time_switches_90_minutes_past_start(self): + finished = Event.objects.create(club=self.club, title="Finished practice", kind=Event.EventKind.TRAINING, start=timezone.now() - datetime.timedelta(minutes=100)) + finished.teams.add(self.team) + later = Event.objects.create(club=self.club, title="Later practice", kind=Event.EventKind.TRAINING, start=timezone.now() + datetime.timedelta(days=1)) + later.teams.add(self.team) + self.client.force_login(self.user) + + response = self._get() + + self.assertEqual(response.context["session_event"], later) + def test_silent_players_are_counted_and_listed_in_needs_you(self): other_member = Member.objects.create(first_name="Anna", last_name="Player") TeamMembership.objects.create(team=self.team, member=other_member, season=self.season)