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