From 385e2f3c382fb8c35a1402bda92d6b73a6f17c68 Mon Sep 17 00:00:00 2001 From: Bernard Siebens Date: Fri, 21 Aug 2026 17:39:23 +0200 Subject: [PATCH] Enforce the RSVP deadline server-side; fix the calendar window shrinking late in the week EventDetailView.post now rejects a POST once event.deadline has passed (400), matching the read-only treatment the UI already showed -- a disabled button is only a hint, not a guarantee against a direct POST. M2's own "Your answers" card now also shows a read-only status pill instead of the In/Maybe/Out buttons once closed, the same way Home's hero card already did. Also fixed Calendar's window: it was pinned to "through next calendar week's Sunday", which meant the effective lookahead shrank to as little as 8-9 days whenever today fell late in the week (e.g. a Friday), so an event 10 days out could silently not appear even though it's well within what "the next two weeks" should mean. The window is now always >= 14 days from today regardless of which weekday today is. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9 --- mobile/templates/mobile/event_detail.html | 60 ++++++++++++----------- mobile/tests.py | 45 +++++++++++++++++ mobile/views.py | 31 +++++++++--- 3 files changed, 101 insertions(+), 35 deletions(-) diff --git a/mobile/templates/mobile/event_detail.html b/mobile/templates/mobile/event_detail.html index d6bbb70..1afb7bc 100644 --- a/mobile/templates/mobile/event_detail.html +++ b/mobile/templates/mobile/event_detail.html @@ -65,34 +65,38 @@ {% trans "No reply" %} {% endif %} -
- {% trans "In" as label_in %} - {% trans "Maybe" as label_maybe %} - {% trans "Out" as label_out %} - {% with status=answer.attendance.status %} -
- {% csrf_token %} - - - - -
-
- {% csrf_token %} - - - - -
-
- {% csrf_token %} - - - - -
- {% endwith %} -
+ {% if rsvp_closed %} + {{ answer.attendance.get_status_display }} + {% else %} +
+ {% trans "In" as label_in %} + {% trans "Maybe" as label_maybe %} + {% trans "Out" as label_out %} + {% with status=answer.attendance.status %} +
+ {% csrf_token %} + + + + +
+
+ {% csrf_token %} + + + + +
+
+ {% csrf_token %} + + + + +
+ {% endwith %} +
+ {% endif %} {% endfor %} diff --git a/mobile/tests.py b/mobile/tests.py index 4cf77b9..745331b 100644 --- a/mobile/tests.py +++ b/mobile/tests.py @@ -551,6 +551,26 @@ class EventDetailRsvpTests(TestCase): self.assertEqual(response.status_code, 404) + def test_cannot_rsvp_once_the_deadline_has_passed(self): + closed_event = Event.objects.create(club=self.club, title="Cup final", start=timezone.now() + datetime.timedelta(days=3), deadline=timezone.now() - datetime.timedelta(hours=1)) + closed_attendance = Attendance.objects.create(event=closed_event, member=self.member, status=Attendance.AttendanceStatus.NO_RESPONSE) + self.client.force_login(self.user) + + response = self._post(closed_event, {"status": "present"}) + + self.assertEqual(response.status_code, 400) + closed_attendance.refresh_from_db() + self.assertEqual(closed_attendance.status, Attendance.AttendanceStatus.NO_RESPONSE) + + def test_can_still_rsvp_before_the_deadline(self): + open_event = Event.objects.create(club=self.club, title="Cup final", start=timezone.now() + datetime.timedelta(days=3), deadline=timezone.now() + datetime.timedelta(hours=1)) + Attendance.objects.create(event=open_event, member=self.member, status=Attendance.AttendanceStatus.NO_RESPONSE) + self.client.force_login(self.user) + + response = self._post(open_event, {"status": "present"}) + + self.assertRedirects(response, reverse("mobile:home"), fetch_redirect_response=False) + @override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"]) class CalendarViewTests(TestCase): @@ -697,6 +717,21 @@ class CalendarViewTests(TestCase): self.assertEqual(self._events_in_context(response), set()) + def test_window_covers_at_least_fourteen_days_regardless_of_which_weekday_today_is(self): + # Regression: the window used to be pinned to "through next calendar + # week's Sunday", which shrank to as little as 8-9 days whenever today + # fell late in the week -- an event 13 days out (a noon start, so + # today's own time-of-day can't push it across a date boundary) must + # always still show, whatever day the test happens to run on. + thirteen_days_out = timezone.make_aware(datetime.datetime.combine(timezone.localdate() + datetime.timedelta(days=13), datetime.time(12, 0))) + event = self.make_event(title="Two weeks out", start=thirteen_days_out) + Attendance.objects.create(event=event, member=self.member) + self.client.force_login(self.user) + + response = self._get() + + self.assertIn(event, self._events_in_context(response)) + @override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"]) class EventDetailScreenTests(TestCase): @@ -729,6 +764,16 @@ class EventDetailScreenTests(TestCase): self.assertEqual(response.status_code, 200) self.assertContains(response, "Home game") + def test_rsvp_buttons_are_replaced_by_a_readonly_pill_once_the_deadline_has_passed(self): + closed_event = Event.objects.create(club=self.club, title="Closed game", start=timezone.now() + datetime.timedelta(days=3), deadline=timezone.now() - datetime.timedelta(hours=1)) + Attendance.objects.create(event=closed_event, member=self.member, status=Attendance.AttendanceStatus.NO_RESPONSE) + self.client.force_login(self.user) + + response = self._get(closed_event) + + self.assertTrue(response.context["rsvp_closed"]) + self.assertNotContains(response, 'name="status" value="present"') + def test_your_answers_only_lists_managed_people_invited_to_this_event(self): # TeamMembership -> Event.teams (both already set up in setUpTestData) auto-creates # a NO_RESPONSE Attendance row via events/signals.py -- no need to create one by hand. diff --git a/mobile/views.py b/mobile/views.py index 8119a7a..51123ed 100644 --- a/mobile/views.py +++ b/mobile/views.py @@ -276,9 +276,13 @@ class CalendarView(PersonScopeMixin, LoginRequiredMixin, TemplateView): week/month grid events.services.calendar was built for) grouped under "This week"/"Next week". Browsing-window judgment call: the design doc doesn't specify month navigation for the mobile screen, so this only ever - shows *upcoming* events across the current and next calendar week (no - "Later"/past bucket, no ?month= paging) -- a simple, bounded agenda rather - than a full season browser. + shows *upcoming* events within the next 14 days (no "Later"/past bucket, + no ?month= paging) -- a simple, bounded agenda rather than a full season + browser. The window is always >= 14 days from today, not just "through + next calendar week's Sunday" -- pinning it to the calendar week alone + would shrink the effective lookahead to as little as 8-9 days whenever + today falls late in the week, silently dropping events a member would + expect to still see (see get_context_data's window_end_date). Always scoped to every one of ``self.managed_people`` -- unlike Home, this screen has no person switcher and no "every club event" toggle: it's @@ -311,9 +315,14 @@ class CalendarView(PersonScopeMixin, LoginRequiredMixin, TemplateView): def get_context_data(self, **kwargs): now = timezone.now() - _this_week_start, this_week_end = week_bounds(timezone.localdate()) - next_week_end = this_week_end + datetime.timedelta(days=7) - window_end = timezone.make_aware(datetime.datetime.combine(next_week_end, datetime.time.max)) + today = timezone.localdate() + _this_week_start, this_week_end = week_bounds(today) + # At least 14 days out from today, not just "through next calendar week's + # Sunday" -- that alone shrinks to as little as 8-9 days when today falls + # late in the week (e.g. today=Friday puts next_week_end only 9 days out), + # silently dropping events a member would reasonably expect to still see. + window_end_date = max(this_week_end + datetime.timedelta(days=7), today + datetime.timedelta(days=13)) + window_end = timezone.make_aware(datetime.datetime.combine(window_end_date, datetime.time.max)) kind_filter = self.request.GET.get("kind") rows = [] @@ -377,6 +386,7 @@ class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView): def get_context_data(self, **kwargs): event = get_object_or_404(Event.objects.select_related("location", "opponent"), pk=self.kwargs["pk"], club=self.request.club) season = event.season or current_season(self.request.club) + rsvp_closed = event.deadline is not None and event.deadline < timezone.now() your_answers = [] if self.managed_people: @@ -416,7 +426,7 @@ class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView): "no_reply_pct": round(100 * counts["no_reply_count"] / total), } - return super().get_context_data(screen_title=event.title, event=event, your_answers=your_answers, squad_summary=squad_summary, **kwargs) + return super().get_context_data(screen_title=event.title, event=event, rsvp_closed=rsvp_closed, your_answers=your_answers, squad_summary=squad_summary, **kwargs) def post(self, request, *args, **kwargs): status = request.POST.get("status") @@ -432,6 +442,13 @@ class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView): return HttpResponseBadRequest(_("You can't RSVP for that person.")) event = get_object_or_404(Event, pk=kwargs["pk"], club=request.club) + # Mirrors the read-only treatment Home's hero and this same screen's own + # "Your answers" card already show once the deadline has passed (see + # get_context_data's rsvp_closed) -- enforced here too, since a disabled + # button in the UI is only a hint, not a guarantee against a direct POST. + if event.deadline is not None and event.deadline < timezone.now(): + return HttpResponseBadRequest(_("Replies are closed for this event.")) + Attendance.objects.update_or_create(event=event, member=member, defaults={"status": status}) if request.POST.get("next") == "event_detail":