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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
2026-08-21 17:39:23 +02:00
parent 17b1c0220a
commit 385e2f3c38
3 changed files with 101 additions and 35 deletions

View File

@@ -65,34 +65,38 @@
<span class="pill pill-warn shrink-0">{% trans "No reply" %}</span>
{% endif %}
</div>
<div class="flex gap-1.5">
{% trans "In" as label_in %}
{% trans "Maybe" as label_maybe %}
{% trans "Out" as label_out %}
{% with status=answer.attendance.status %}
<form class="flex-1" method="post" action="{% url "mobile:event_detail" event.pk %}">
{% csrf_token %}
<input type="hidden" name="member_id" value="{{ answer.member.pk }}">
<input type="hidden" name="status" value="present">
<input type="hidden" name="next" value="event_detail">
<button type="submit" class="btn h-11 w-full text-[15px] {% if status == "present" %}btn-positive{% else %}bg-paper border border-stroke text-muted{% endif %}">{{ label_in }}</button>
</form>
<form class="flex-1" method="post" action="{% url "mobile:event_detail" event.pk %}">
{% csrf_token %}
<input type="hidden" name="member_id" value="{{ answer.member.pk }}">
<input type="hidden" name="status" value="maybe">
<input type="hidden" name="next" value="event_detail">
<button type="submit" class="btn h-11 w-full text-[15px] {% if status == "maybe" %}bg-steel text-white{% else %}bg-paper border border-stroke text-muted{% endif %}">{{ label_maybe }}</button>
</form>
<form class="flex-1" method="post" action="{% url "mobile:event_detail" event.pk %}">
{% csrf_token %}
<input type="hidden" name="member_id" value="{{ answer.member.pk }}">
<input type="hidden" name="status" value="absent">
<input type="hidden" name="next" value="event_detail">
<button type="submit" class="btn h-11 w-full text-[15px] {% if status == "absent" %}bg-club text-white{% else %}bg-paper border border-stroke text-muted{% endif %}">{{ label_out }}</button>
</form>
{% endwith %}
</div>
{% if rsvp_closed %}
<span class="pill pill-neutral">{{ answer.attendance.get_status_display }}</span>
{% else %}
<div class="flex gap-1.5">
{% trans "In" as label_in %}
{% trans "Maybe" as label_maybe %}
{% trans "Out" as label_out %}
{% with status=answer.attendance.status %}
<form class="flex-1" method="post" action="{% url "mobile:event_detail" event.pk %}">
{% csrf_token %}
<input type="hidden" name="member_id" value="{{ answer.member.pk }}">
<input type="hidden" name="status" value="present">
<input type="hidden" name="next" value="event_detail">
<button type="submit" class="btn h-11 w-full text-[15px] {% if status == "present" %}btn-positive{% else %}bg-paper border border-stroke text-muted{% endif %}">{{ label_in }}</button>
</form>
<form class="flex-1" method="post" action="{% url "mobile:event_detail" event.pk %}">
{% csrf_token %}
<input type="hidden" name="member_id" value="{{ answer.member.pk }}">
<input type="hidden" name="status" value="maybe">
<input type="hidden" name="next" value="event_detail">
<button type="submit" class="btn h-11 w-full text-[15px] {% if status == "maybe" %}bg-steel text-white{% else %}bg-paper border border-stroke text-muted{% endif %}">{{ label_maybe }}</button>
</form>
<form class="flex-1" method="post" action="{% url "mobile:event_detail" event.pk %}">
{% csrf_token %}
<input type="hidden" name="member_id" value="{{ answer.member.pk }}">
<input type="hidden" name="status" value="absent">
<input type="hidden" name="next" value="event_detail">
<button type="submit" class="btn h-11 w-full text-[15px] {% if status == "absent" %}bg-club text-white{% else %}bg-paper border border-stroke text-muted{% endif %}">{{ label_out }}</button>
</form>
{% endwith %}
</div>
{% endif %}
</div>
{% endfor %}
</div>

View File

@@ -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.

View File

@@ -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":