Fix RSVP buttons broken by hx-boost; require a real Out reason; add optional Maybe reason

The In/Out hero buttons (and Out's reason confirm) stopped working: Alpine
owns the toggle between a row's two sibling forms (buttons vs. the reason
prompt), and hx-boost="true" on <body> had htmx *also* intercepting the
same submit -- both ended up fighting over it. Fixed by marking every
write-action <form> across the mobile app hx-boost="false" (link
navigation, where the smooth-navigation feature actually matters, is
untouched). The one exception worth calling out: coach/lineup.html's form
uses three submit buttons sharing one <form> via formaction overrides --
htmx's boost reads the form's own action rather than the submitter's
formaction override, so a boosted click there would always have posted to
the wrong endpoint regardless of the Alpine conflict.

Also:
- A reason for Out is now mandatory, not just captured -- empty and
  punctuation-only "answers" (a bare ".", "-", "??") are rejected
  server-side (the authoritative check) with textarea required/minlength
  as a client-side nudge on top.
- Maybe can now carry an optional reason too, visible to the same audience
  as Out's (this member/family, and Coach mode's bench attendance) -- one
  shared reason form in event_detail.html's per-person row, its hidden
  status input following whichever of Maybe/Out was tapped.
- The 3-way In/Maybe/Out row (and the 2-way hero In/Out) now use min-w-0 on
  every button so flex-1 actually splits the row evenly -- a longer
  label's own intrinsic width was winning it a bigger share otherwise.
- HomeView's "Needs your answer" list now excludes events whose
  registration deadline has already passed -- replying is no longer
  possible there (same rule EventDetailView.post already enforces), so it
  doesn't belong in a "still needs a reply" list. hero_attendance is
  unaffected -- it always shows the true next event, falling back to a
  read-only pill once its own deadline closes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
2026-08-22 14:59:23 +02:00
parent 614c35861b
commit 366239e60b
14 changed files with 160 additions and 49 deletions

View File

@@ -9,10 +9,14 @@
bench attendance (mobile/templates/mobile/coach/attendance.html) is the bench attendance (mobile/templates/mobile/coach/attendance.html) is the
entire access story, no extra permission check needed. entire access story, no extra permission check needed.
Expects ``hero_attendance`` in scope. ``cross_shell`` (optional, truthy) Expects ``hero_attendance`` in scope. ``cross_shell`` no longer changes
adds hx-boost="false" to both forms -- pass this when included from Coach anything here (kept as a no-op param for call-site compatibility) -- both
mode, since mobile:event_detail is a Member-shell view (see coach/ forms are unconditionally hx-boost="false" regardless of shell. Boosting
today.html's own comment on why that matters). a form that Alpine also owns (x-show toggling between this pair of
sibling forms) had htmx and Alpine both trying to intercept the same
submit, which was breaking both In and Out. A real navigation for a
write action is a fine, standard trade -- hx-boost's value is in link-to-
link browsing, not swallowing every POST on the page.
Neither button is colour-coded (no green "In") -- nobody has answered yet Neither button is colour-coded (no green "In") -- nobody has answered yet
at this point (that's the whole reason for the "are you in?" prompt), so at this point (that's the whole reason for the "are you in?" prompt), so
@@ -25,7 +29,7 @@
{% endcomment %} {% endcomment %}
<div class="mt-3" x-data="{ asking: false, reason: '{{ hero_attendance.note|default:""|escapejs }}' }"> <div class="mt-3" x-data="{ asking: false, reason: '{{ hero_attendance.note|default:""|escapejs }}' }">
<div class="flex gap-2" x-show="!asking"> <div class="flex gap-2" x-show="!asking">
<form class="min-w-0 flex-1" method="post" action="{% url "mobile:event_detail" hero_attendance.event.pk %}" {% if cross_shell %}hx-boost="false"{% endif %}> <form class="min-w-0 flex-1" method="post" action="{% url "mobile:event_detail" hero_attendance.event.pk %}" hx-boost="false">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="member_id" value="{{ hero_attendance.member.pk }}"> <input type="hidden" name="member_id" value="{{ hero_attendance.member.pk }}">
<input type="hidden" name="status" value="present"> <input type="hidden" name="status" value="present">
@@ -34,12 +38,12 @@
<button class="btn w-full min-w-0 flex-1 bg-steel text-on-dark" type="button" @click="asking = true">{% trans "Out" %}</button> <button class="btn w-full min-w-0 flex-1 bg-steel text-on-dark" type="button" @click="asking = true">{% trans "Out" %}</button>
</div> </div>
<form x-show="asking" x-cloak method="post" action="{% url "mobile:event_detail" hero_attendance.event.pk %}" {% if cross_shell %}hx-boost="false"{% endif %}> <form x-show="asking" x-cloak method="post" action="{% url "mobile:event_detail" hero_attendance.event.pk %}" hx-boost="false">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="member_id" value="{{ hero_attendance.member.pk }}"> <input type="hidden" name="member_id" value="{{ hero_attendance.member.pk }}">
<input type="hidden" name="status" value="absent"> <input type="hidden" name="status" value="absent">
<label class="mb-1 block text-xs font-semibold text-on-dark-dim">{% trans "Reason (optional -- only you and the coach can see this)" %}</label> <label class="mb-1 block text-xs font-semibold text-on-dark-dim">{% trans "Reason -- only you and the coach can see this" %}</label>
<textarea class="h-16 w-full rounded-lg border border-steel bg-steel p-2 text-sm text-white placeholder:text-on-dark-dim" name="note" x-model="reason" placeholder="{% trans "e.g. sick, family event..." %}"></textarea> <textarea class="h-16 w-full rounded-lg border border-steel bg-steel p-2 text-sm text-white placeholder:text-on-dark-dim" name="note" x-model="reason" required minlength="2" placeholder="{% trans "e.g. sick, family event..." %}"></textarea>
<div class="mt-2 flex gap-2"> <div class="mt-2 flex gap-2">
<button class="btn flex-1 bg-steel text-on-dark" type="button" @click="asking = false">{% trans "Cancel" %}</button> <button class="btn flex-1 bg-steel text-on-dark" type="button" @click="asking = false">{% trans "Cancel" %}</button>
<button class="btn btn-dark flex-1" type="submit">{% trans "Confirm" %}</button> <button class="btn btn-dark flex-1" type="submit">{% trans "Confirm" %}</button>

View File

@@ -10,7 +10,7 @@
treatment as management/templates/management/home.html's own treatment as management/templates/management/home.html's own
notifications card. notifications card.
{% endcomment %} {% endcomment %}
<form method="post" action="{% url "mobile:notifications" %}"> <form method="post" action="{% url "mobile:notifications" %}" hx-boost="false">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="action" value="mark_read"> <input type="hidden" name="action" value="mark_read">
<input type="hidden" name="notification_id" value="{{ row.notification.pk }}"> <input type="hidden" name="notification_id" value="{{ row.notification.pk }}">

View File

@@ -39,7 +39,7 @@
<div class="m-card p-4"> <div class="m-card p-4">
<div class="text-sm font-semibold text-ink">{% trans "Reset link" %}</div> <div class="text-sm font-semibold text-ink">{% trans "Reset link" %}</div>
<p class="mt-1 text-xs text-muted">{% trans "If you've shared this link and want to take it back, reset it -- the old one stops working immediately and you'll need to re-subscribe with the new one." %}</p> <p class="mt-1 text-xs text-muted">{% trans "If you've shared this link and want to take it back, reset it -- the old one stops working immediately and you'll need to re-subscribe with the new one." %}</p>
<form class="mt-3" method="post" action="{% url "mobile:calendar_feed_settings" %}"> <form class="mt-3" method="post" action="{% url "mobile:calendar_feed_settings" %}" hx-boost="false">
{% csrf_token %} {% csrf_token %}
<button type="submit" class="btn btn-secondary w-full">{% trans "Reset my calendar link" %}</button> <button type="submit" class="btn btn-secondary w-full">{% trans "Reset my calendar link" %}</button>
</form> </form>

View File

@@ -28,7 +28,7 @@
</a> </a>
</div> </div>
<form method="post" action="{% url "mobile:coach_add_player" %}" x-data="{ count: 0 }"> <form method="post" action="{% url "mobile:coach_add_player" %}" x-data="{ count: 0 }" hx-boost="false">
{% csrf_token %} {% csrf_token %}
<div class="m-card flex flex-col overflow-hidden"> <div class="m-card flex flex-col overflow-hidden">
{% for candidate in candidates %} {% for candidate in candidates %}

View File

@@ -36,7 +36,7 @@
</a> </a>
</div> </div>
<form method="post" action="{% url "mobile:coach_attendance" event.pk %}"> <form method="post" action="{% url "mobile:coach_attendance" event.pk %}" hx-boost="false">
{% csrf_token %} {% csrf_token %}
<div class="m-card flex flex-col overflow-hidden"> <div class="m-card flex flex-col overflow-hidden">
{% for row in rows %} {% for row in rows %}
@@ -45,7 +45,8 @@
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="text-sm font-semibold text-ink">{{ row.member.get_full_name }}</div> <div class="text-sm font-semibold text-ink">{{ row.member.get_full_name }}</div>
<div class="text-xs text-muted">{{ row.membership.position|default:"—" }}</div> <div class="text-xs text-muted">{{ row.membership.position|default:"—" }}</div>
{% if row.status == "absent" and row.note %} {% if row.note %}
{# Only ever set alongside absent/maybe -- EventDetailView.post clears it for present. #}
<div class="mt-0.5 truncate text-xs text-club-dark italic">&ldquo;{{ row.note }}&rdquo;</div> <div class="mt-0.5 truncate text-xs text-club-dark italic">&ldquo;{{ row.note }}&rdquo;</div>
{% endif %} {% endif %}
</div> </div>

View File

@@ -24,7 +24,7 @@
</div> </div>
{% endif %} {% endif %}
<form id="coach-event-form" method="post" action="{% url "mobile:coach_create_event" %}"> <form id="coach-event-form" method="post" action="{% url "mobile:coach_create_event" %}" hx-boost="false">
{% csrf_token %} {% csrf_token %}
<div class="grid grid-cols-3 gap-2"> <div class="grid grid-cols-3 gap-2">

View File

@@ -11,6 +11,12 @@
throughout rather than overriding the shared .coach-sheet's own light throughout rather than overriding the shared .coach-sheet's own light
background -- a real per-screen shell hook is more infrastructure than background -- a real per-screen shell hook is more infrastructure than
one screen justifies. one screen justifies.
Both forms are hx-boost="false" -- the first has three submit buttons
sharing one <form> via formaction overrides (Save/+Add slot/+Add line),
and htmx's boost reads the form's own action rather than the actual
submitter's formaction override, so a boosted click would always post
to the wrong endpoint. A plain navigation sidesteps that entirely.
{% endcomment %} {% endcomment %}
{% block header_extra %} {% block header_extra %}
@@ -22,7 +28,7 @@
{% endblock header_extra %} {% endblock header_extra %}
{% block content %} {% block content %}
<form method="post" action="{% url "mobile:coach_lineup" event.pk %}"> <form method="post" action="{% url "mobile:coach_lineup" event.pk %}" hx-boost="false">
{% csrf_token %} {% csrf_token %}
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
{% for unit in units %} {% for unit in units %}
@@ -67,7 +73,7 @@
{% endif %} {% endif %}
{% if can_manage_active_team and not lineup.published_at %} {% if can_manage_active_team and not lineup.published_at %}
<form class="mt-2" method="post" action="{% url "mobile:coach_lineup_publish" event.pk %}"> <form class="mt-2" method="post" action="{% url "mobile:coach_lineup_publish" event.pk %}" hx-boost="false">
{% csrf_token %} {% csrf_token %}
<button class="btn w-full bg-ice text-ice-ink" type="submit">{% trans "Publish" %}</button> <button class="btn w-full bg-ice text-ice-ink" type="submit">{% trans "Publish" %}</button>
</form> </form>

View File

@@ -24,7 +24,7 @@
</div> </div>
{% endif %} {% endif %}
<form id="coach-news-form" method="post" action="{% url "mobile:coach_create_news" %}"> <form id="coach-news-form" method="post" action="{% url "mobile:coach_create_news" %}" hx-boost="false">
{% csrf_token %} {% csrf_token %}
<div class="m-card flex flex-col p-4"> <div class="m-card flex flex-col p-4">

View File

@@ -80,14 +80,7 @@
{% if rsvp_closed %} {% if rsvp_closed %}
<span class="pill pill-neutral mt-3">{{ hero_attendance.get_status_display }}</span> <span class="pill pill-neutral mt-3">{{ hero_attendance.get_status_display }}</span>
{% else %} {% else %}
{% comment %} {% include "mobile/_hero_rsvp.html" %}
hx-boost="false" (passed as cross_shell below) -- mobile:event_detail
is a Member-shell view (bg-paper, not this page's bg-ink); boosting
would swap its response into this page's <body> without updating the
<body> tag's own class, leaving the wrong background. See base.html's
own comment for the full reasoning.
{% endcomment %}
{% include "mobile/_hero_rsvp.html" with cross_shell=True %}
{% endif %} {% endif %}
</div> </div>
</div> </div>

View File

@@ -12,7 +12,7 @@
{% endcomment %} {% endcomment %}
{% block content %} {% block content %}
<form method="post" class="flex flex-col gap-4"> <form method="post" class="flex flex-col gap-4" hx-boost="false">
{% csrf_token %} {% csrf_token %}
<div class="-mx-4 -mt-4 flex items-center gap-1 border-b border-line bg-white px-4 py-3"> <div class="-mx-4 -mt-4 flex items-center gap-1 border-b border-line bg-white px-4 py-3">

View File

@@ -11,6 +11,12 @@
under a dark gradient -- individual events have no photo of their own, so 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 this is the one club-wide stand-in -- falling back to a plain dark
background when the club hasn't uploaded one. 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 %} {% endcomment %}
{% block content %} {% block content %}
@@ -82,40 +88,44 @@
{% trans "Maybe" as label_maybe %} {% trans "Maybe" as label_maybe %}
{% trans "Out" as label_out %} {% trans "Out" as label_out %}
{% with status=answer.attendance.status %} {% with status=answer.attendance.status %}
<div x-data="{ asking: false, reason: '{{ answer.attendance.note|default:""|escapejs }}' }"> <div x-data="{ asking: null, reason: '{{ answer.attendance.note|default:""|escapejs }}' }">
<div class="flex gap-1.5" x-show="!asking"> <div class="flex gap-1.5" x-show="!asking">
<form class="flex-1" method="post" action="{% url "mobile:event_detail" event.pk %}"> <form class="min-w-0 flex-1" method="post" action="{% url "mobile:event_detail" event.pk %}" hx-boost="false">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="member_id" value="{{ answer.member.pk }}"> <input type="hidden" name="member_id" value="{{ answer.member.pk }}">
<input type="hidden" name="status" value="present"> <input type="hidden" name="status" value="present">
<input type="hidden" name="next" value="event_detail"> <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> <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>
<form class="flex-1" method="post" action="{% url "mobile:event_detail" event.pk %}"> <button type="button" class="btn h-11 w-full min-w-0 flex-1 text-[15px] {% if status == "maybe" %}bg-steel text-white{% else %}bg-paper border border-stroke text-muted{% endif %}" @click="asking = 'maybe'">{{ label_maybe }}</button>
{% csrf_token %} <button type="button" class="btn h-11 w-full min-w-0 flex-1 text-[15px] {% if status == "absent" %}bg-club text-white{% else %}bg-paper border border-stroke text-muted{% endif %}" @click="asking = 'absent'">{{ label_out }}</button>
<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>
<button type="button" class="btn h-11 w-full flex-1 text-[15px] {% if status == "absent" %}bg-club text-white{% else %}bg-paper border border-stroke text-muted{% endif %}" @click="asking = true">{{ label_out }}</button>
</div> </div>
<form x-show="asking" x-cloak method="post" action="{% url "mobile:event_detail" event.pk %}"> {% comment %}
One shared reason form for both Maybe (optional) and Out
(mandatory -- enforced server-side, EventDetailView.post) --
:value on the hidden status input follows whichever button
was tapped. No required/minlength attrs here: mixing that
with a dynamic per-status requirement got fiddly for what
the backend already enforces authoritatively regardless.
{% endcomment %}
<form x-show="asking" x-cloak method="post" action="{% url "mobile:event_detail" event.pk %}" hx-boost="false">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="member_id" value="{{ answer.member.pk }}"> <input type="hidden" name="member_id" value="{{ answer.member.pk }}">
<input type="hidden" name="status" value="absent"> <input type="hidden" name="status" :value="asking">
<input type="hidden" name="next" value="event_detail"> <input type="hidden" name="next" value="event_detail">
<label class="mb-1 block text-xs font-semibold text-muted">{% trans "Reason (optional -- only you and the coach can see this)" %}</label> <label class="mb-1 block text-xs font-semibold text-muted" x-show="asking === 'absent'">{% trans "Reason -- only you and the coach can see this" %}</label>
<label class="mb-1 block text-xs font-semibold text-muted" x-show="asking === 'maybe'">{% trans "Add a reason (optional) -- only you and the coach can see this" %}</label>
<textarea class="h-16 w-full rounded-lg border border-stroke bg-paper p-2 text-sm text-ink" name="note" x-model="reason" placeholder="{% trans "e.g. sick, family event..." %}"></textarea> <textarea class="h-16 w-full rounded-lg border border-stroke bg-paper p-2 text-sm text-ink" name="note" x-model="reason" placeholder="{% trans "e.g. sick, family event..." %}"></textarea>
<div class="mt-2 flex gap-2"> <div class="mt-2 flex gap-2">
<button type="button" class="btn btn-secondary h-9 flex-1 text-xs" @click="asking = false">{% trans "Cancel" %}</button> <button type="button" class="btn btn-secondary h-9 flex-1 text-xs" @click="asking = null">{% trans "Cancel" %}</button>
<button type="submit" class="btn btn-dark h-9 flex-1 text-xs">{% trans "Confirm" %}</button> <button type="submit" class="btn btn-dark h-9 flex-1 text-xs">{% trans "Confirm" %}</button>
</div> </div>
</form> </form>
</div> </div>
{% if status == "absent" and answer.attendance.note %} {% if answer.attendance.note %}
{# Only ever set alongside absent/maybe -- EventDetailView.post clears it for present. #}
<p class="mt-1.5 text-xs text-muted italic">&ldquo;{{ answer.attendance.note }}&rdquo;</p> <p class="mt-1.5 text-xs text-muted italic">&ldquo;{{ answer.attendance.note }}&rdquo;</p>
{% endif %} {% endif %}
{% endwith %} {% endwith %}

View File

@@ -28,7 +28,7 @@
{% if today or earlier_this_week or older %} {% if today or earlier_this_week or older %}
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
{% if unread_notification_count %} {% if unread_notification_count %}
<form method="post" action="{% url "mobile:notifications" %}"> <form method="post" action="{% url "mobile:notifications" %}" hx-boost="false">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="action" value="mark_all_read"> <input type="hidden" name="action" value="mark_all_read">
<button class="font-display text-xs font-extrabold tracking-wide text-club uppercase" type="submit">{% trans "Mark all read" %}</button> <button class="font-display text-xs font-extrabold tracking-wide text-club uppercase" type="submit">{% trans "Mark all read" %}</button>
@@ -36,7 +36,7 @@
{% else %} {% else %}
<span></span> <span></span>
{% endif %} {% endif %}
<form method="post" action="{% url "mobile:notifications" %}"> <form method="post" action="{% url "mobile:notifications" %}" hx-boost="false">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="action" value="clear_all"> <input type="hidden" name="action" value="clear_all">
<button class="font-display text-xs font-extrabold tracking-wide text-muted uppercase" type="submit">{% trans "Clear all" %}</button> <button class="font-display text-xs font-extrabold tracking-wide text-muted uppercase" type="submit">{% trans "Clear all" %}</button>

View File

@@ -289,6 +289,24 @@ class HomeViewTests(TestCase):
needs_answer_events = {attendance.event for attendance in response.context["needs_answer"]} needs_answer_events = {attendance.event for attendance in response.context["needs_answer"]}
self.assertEqual(needs_answer_events, {awaiting, maybe}) 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): 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 # 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. # 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)) 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.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) 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.attendance.refresh_from_db()
self.assertEqual(self.attendance.status, Attendance.AttendanceStatus.MAYBE) 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): def test_posting_absent_with_a_reason_stores_the_note(self):
self.client.force_login(self.user) self.client.force_login(self.user)
@@ -602,6 +639,31 @@ class EventDetailRsvpTests(TestCase):
self.attendance.refresh_from_db() self.attendance.refresh_from_db()
self.assertEqual(self.attendance.note, "") 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): def test_rejects_an_unknown_status_value(self):
self.client.force_login(self.user) self.client.force_login(self.user)
@@ -2158,6 +2220,16 @@ class CoachAttendanceViewTests(TestCase):
self.assertContains(response, "Anna Player") self.assertContains(response, "Anna Player")
self.assertContains(response, "9") 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): def test_save_records_check_ins_via_record_check_in(self):
self.client.force_login(self.user) self.client.force_login(self.user)

View File

@@ -221,7 +221,16 @@ class HomeView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
deadline = hero_attendance.event.deadline deadline = hero_attendance.event.deadline
rsvp_closed = deadline is not None and deadline < now 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: if hero_attendance is not None:
needs_answer_qs = needs_answer_qs.exclude(pk=hero_attendance.pk) needs_answer_qs = needs_answer_qs.exclude(pk=hero_attendance.pk)
needs_answer_total = needs_answer_qs.count() 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(): if event.deadline is not None and event.deadline < timezone.now():
return HttpResponseBadRequest(_("Replies are closed for this event.")) return HttpResponseBadRequest(_("Replies are closed for this event."))
# A reason is only ever meaningful attached to "Out" -- clearing it the # A reason is only ever meaningful attached to Out/Maybe -- clearing it
# moment someone flips back to In/Maybe avoids a stale "sick" note # the moment someone flips to In avoids a stale "sick" note hanging
# hanging around under an answer it no longer explains. Private by # around under an answer it no longer explains. Private by
# construction, not by a visibility flag: nothing renders another # construction, not by a visibility flag: nothing renders another
# member's own note anywhere -- only this member/family's own screens # member's own note anywhere -- only this member/family's own screens
# (event_detail's "Your answers") and Coach mode's bench attendance # (event_detail's "Your answers") and Coach mode's bench attendance
# (mobile/templates/mobile/coach/attendance.html) ever read it. # (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}) Attendance.objects.update_or_create(event=event, member=member, defaults={"status": status, "note": note})
if request.POST.get("next") == "event_detail": if request.POST.get("next") == "event_detail":