diff --git a/mobile/templates/mobile/event_detail.html b/mobile/templates/mobile/event_detail.html
index 23aca96..dc3fdb8 100644
--- a/mobile/templates/mobile/event_detail.html
+++ b/mobile/templates/mobile/event_detail.html
@@ -11,6 +11,12 @@
under a dark gradient -- individual events have no photo of their own, so
this is the one club-wide stand-in -- falling back to a plain dark
background when the club hasn't uploaded one.
+
+ The per-person In/Maybe/Out forms below are hx-boost="false" -- Alpine
+ owns the toggle between this row's two sibling forms (buttons vs. the
+ Out reason prompt), and having htmx *also* intercept the submit broke
+ both. A real navigation for a write action is a fine trade; hx-boost's
+ value is in link-to-link browsing, not every POST on the page.
{% endcomment %}
{% block content %}
@@ -82,40 +88,44 @@
{% trans "Maybe" as label_maybe %}
{% trans "Out" as label_out %}
{% with status=answer.attendance.status %}
-
+
-
-
- {{ label_out }}
+ {{ label_maybe }}
+ {{ label_out }}
-
- {% if status == "absent" and answer.attendance.note %}
+ {% if answer.attendance.note %}
+ {# Only ever set alongside absent/maybe -- EventDetailView.post clears it for present. #}
“{{ answer.attendance.note }}”
{% endif %}
{% endwith %}
diff --git a/mobile/templates/mobile/notifications.html b/mobile/templates/mobile/notifications.html
index f180a5c..783a39d 100644
--- a/mobile/templates/mobile/notifications.html
+++ b/mobile/templates/mobile/notifications.html
@@ -28,7 +28,7 @@
{% if today or earlier_this_week or older %}
{% if unread_notification_count %}
-
+
{% csrf_token %}
@@ -36,7 +36,7 @@
{% else %}
{% endif %}
-
+
{% csrf_token %}
diff --git a/mobile/tests.py b/mobile/tests.py
index 35ecd7d..15cf068 100644
--- a/mobile/tests.py
+++ b/mobile/tests.py
@@ -289,6 +289,24 @@ class HomeViewTests(TestCase):
needs_answer_events = {attendance.event for attendance in response.context["needs_answer"]}
self.assertEqual(needs_answer_events, {awaiting, maybe})
+ def test_needs_your_answer_excludes_events_with_a_closed_registration_deadline(self):
+ # A distinct, already-answered earlier event so it becomes the hero --
+ # otherwise the closed-deadline event below would become the hero
+ # itself (still shown there, just read-only) rather than reaching
+ # needs_answer's own exclusion at all.
+ hero_event = self.make_event(title="Soonest", start=self.future)
+ Attendance.objects.create(event=hero_event, member=self.member, status=Attendance.AttendanceStatus.PRESENT)
+ closed = self.make_event(title="Deadline passed", start=self.future + datetime.timedelta(days=2), deadline=timezone.now() - datetime.timedelta(hours=1))
+ open_deadline = self.make_event(title="Deadline still open", start=self.future + datetime.timedelta(days=3), deadline=timezone.now() + datetime.timedelta(hours=1))
+ Attendance.objects.create(event=closed, member=self.member, status=Attendance.AttendanceStatus.NO_RESPONSE)
+ Attendance.objects.create(event=open_deadline, member=self.member, status=Attendance.AttendanceStatus.NO_RESPONSE)
+ self.client.force_login(self.user)
+
+ response = self._get("home")
+
+ needs_answer_events = {attendance.event for attendance in response.context["needs_answer"]}
+ self.assertEqual(needs_answer_events, {open_deadline})
+
def test_needs_your_answer_is_capped_at_five_with_a_remaining_count(self):
# A distinct, already-answered earlier event so it becomes the hero and
# none of the seven "Practice N" events below get excluded as the hero.
@@ -524,7 +542,7 @@ class EventDetailRsvpTests(TestCase):
other_event = Event.objects.create(club=self.club, title="Away game", start=timezone.now() + datetime.timedelta(days=8))
self.client.force_login(self.user)
- self._post(other_event, {"status": "absent"})
+ self._post(other_event, {"status": "absent", "note": "Sick"})
self.assertEqual(Attendance.objects.get(event=other_event, member=self.member).status, Attendance.AttendanceStatus.ABSENT)
@@ -565,6 +583,25 @@ class EventDetailRsvpTests(TestCase):
self.attendance.refresh_from_db()
self.assertEqual(self.attendance.status, Attendance.AttendanceStatus.MAYBE)
+ def test_posting_maybe_without_a_reason_is_allowed(self):
+ # Unlike Out, a reason is optional for Maybe -- no 400 without one.
+ self.client.force_login(self.user)
+
+ response = self._post(self.event, {"status": "maybe"})
+
+ self.assertRedirects(response, reverse("mobile:home"), fetch_redirect_response=False)
+ self.attendance.refresh_from_db()
+ self.assertEqual(self.attendance.note, "")
+
+ def test_posting_maybe_with_a_reason_stores_the_note(self):
+ self.client.force_login(self.user)
+
+ self._post(self.event, {"status": "maybe", "note": "Might have to leave early"})
+
+ self.attendance.refresh_from_db()
+ self.assertEqual(self.attendance.status, Attendance.AttendanceStatus.MAYBE)
+ self.assertEqual(self.attendance.note, "Might have to leave early")
+
def test_posting_absent_with_a_reason_stores_the_note(self):
self.client.force_login(self.user)
@@ -602,6 +639,31 @@ class EventDetailRsvpTests(TestCase):
self.attendance.refresh_from_db()
self.assertEqual(self.attendance.note, "")
+ def test_absent_without_a_reason_is_rejected(self):
+ self.client.force_login(self.user)
+
+ response = self._post(self.event, {"status": "absent"})
+
+ self.assertEqual(response.status_code, 400)
+ self.attendance.refresh_from_db()
+ self.assertEqual(self.attendance.status, Attendance.AttendanceStatus.NO_RESPONSE)
+
+ def test_absent_with_a_whitespace_only_reason_is_rejected(self):
+ self.client.force_login(self.user)
+
+ response = self._post(self.event, {"status": "absent", "note": " \n "})
+
+ self.assertEqual(response.status_code, 400)
+
+ def test_absent_with_a_punctuation_only_reason_is_rejected(self):
+ self.client.force_login(self.user)
+
+ response = self._post(self.event, {"status": "absent", "note": "..."})
+
+ self.assertEqual(response.status_code, 400)
+ self.attendance.refresh_from_db()
+ self.assertEqual(self.attendance.status, Attendance.AttendanceStatus.NO_RESPONSE)
+
def test_rejects_an_unknown_status_value(self):
self.client.force_login(self.user)
@@ -2158,6 +2220,16 @@ class CoachAttendanceViewTests(TestCase):
self.assertContains(response, "Anna Player")
self.assertContains(response, "9")
+ def test_shows_a_maybe_reason_alongside_an_absent_one(self):
+ self.attendance.status = Attendance.AttendanceStatus.MAYBE
+ self.attendance.note = "Might be a few minutes late"
+ self.attendance.save()
+ self.client.force_login(self.user)
+
+ response = self._get()
+
+ self.assertContains(response, "Might be a few minutes late")
+
def test_save_records_check_ins_via_record_check_in(self):
self.client.force_login(self.user)
diff --git a/mobile/views.py b/mobile/views.py
index 43e06cb..4823c5d 100644
--- a/mobile/views.py
+++ b/mobile/views.py
@@ -221,7 +221,16 @@ class HomeView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
deadline = hero_attendance.event.deadline
rsvp_closed = deadline is not None and deadline < now
- needs_answer_qs = upcoming.filter(status__in=[Attendance.AttendanceStatus.NO_RESPONSE, Attendance.AttendanceStatus.MAYBE]).order_by("event__start")
+ # Deadline already passed -> replying is no longer possible (see
+ # EventDetailView.post's own deadline check), so it doesn't belong
+ # in a "still needs a reply" list -- unlike hero_attendance above,
+ # which always shows the true next event regardless of RSVP state
+ # and falls back to a read-only pill once its own deadline closes.
+ needs_answer_qs = (
+ upcoming.filter(status__in=[Attendance.AttendanceStatus.NO_RESPONSE, Attendance.AttendanceStatus.MAYBE])
+ .filter(Q(event__deadline__isnull=True) | Q(event__deadline__gte=now))
+ .order_by("event__start")
+ )
if hero_attendance is not None:
needs_answer_qs = needs_answer_qs.exclude(pk=hero_attendance.pk)
needs_answer_total = needs_answer_qs.count()
@@ -444,14 +453,30 @@ class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
if event.deadline is not None and event.deadline < timezone.now():
return HttpResponseBadRequest(_("Replies are closed for this event."))
- # A reason is only ever meaningful attached to "Out" -- clearing it the
- # moment someone flips back to In/Maybe avoids a stale "sick" note
- # hanging around under an answer it no longer explains. Private by
+ # A reason is only ever meaningful attached to Out/Maybe -- clearing it
+ # the moment someone flips to In avoids a stale "sick" note hanging
+ # around under an answer it no longer explains. Private by
# construction, not by a visibility flag: nothing renders another
# member's own note anywhere -- only this member/family's own screens
# (event_detail's "Your answers") and Coach mode's bench attendance
# (mobile/templates/mobile/coach/attendance.html) ever read it.
- note = request.POST.get("note", "").strip() if status == Attendance.AttendanceStatus.ABSENT else ""
+ note = ""
+ if status == Attendance.AttendanceStatus.ABSENT:
+ note = request.POST.get("note", "").strip()
+ # Rejects blank and punctuation-only "answers" (a bare ".", "-",
+ # "??") -- mandatory for Out specifically, unlike Maybe below.
+ # Backend-scoped, not the pretty inline-error UX this codebase
+ # gives ModelForm submissions elsewhere -- matches this view's
+ # own existing style (see "Unknown RSVP status"/"Replies are
+ # closed" above, both plain 400s a normal user should never
+ # actually see, since the template only ever offers Out through
+ # the reason form to begin with).
+ if not any(char.isalnum() for char in note):
+ return HttpResponseBadRequest(_("Please enter a reason."))
+ elif status == Attendance.AttendanceStatus.MAYBE:
+ # Optional here -- Maybe doesn't owe anyone an explanation the way
+ # a firm no does, but the same field carries it if given one.
+ note = request.POST.get("note", "").strip()
Attendance.objects.update_or_create(event=event, member=member, defaults={"status": status, "note": note})
if request.POST.get("next") == "event_detail":