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 %}
-
-
-
- {% 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 %}
+
+
+
+ {% 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":