Capture a private reason when declining an event RSVP

Attendance.note already existed but nothing wrote to it. Picking "Out"
anywhere (Home's hero, Coach Today's "Also yours", event_detail's per-
person answers) now opens an optional reason field before submitting,
via an Alpine two-step within the same form rather than a separate
confirmation screen. EventDetailView.post stores it only when the status
is actually "absent", and clears it the moment someone flips back to
In/Maybe -- a stale "sick" note under a since-changed answer would just
be confusing.

Private by construction, not by a permission check: nothing anywhere
renders another member's own note -- the squad-response view stays
counts-only like it already was. The only two places that read it back
are this member/family's own "Your answers" card and Coach mode's bench
attendance screen, both already scoped to people the viewer has a real
claim on.

Also, on the shared hero In/Out buttons (Home + Coach Today): dropped the
green "In" styling -- nobody's answered yet at that point, so a green
default misleadingly read as an already-recorded answer -- and added
min-w-0 to both buttons so flex-1 actually splits the row evenly; a
longer label's own intrinsic width was winning it a bigger share
otherwise.

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 13:01:07 +02:00
parent 3426ea8447
commit 614c35861b
8 changed files with 160 additions and 57 deletions

View File

@@ -565,6 +565,43 @@ class EventDetailRsvpTests(TestCase):
self.attendance.refresh_from_db()
self.assertEqual(self.attendance.status, Attendance.AttendanceStatus.MAYBE)
def test_posting_absent_with_a_reason_stores_the_note(self):
self.client.force_login(self.user)
self._post(self.event, {"status": "absent", "note": "Sick this week"})
self.attendance.refresh_from_db()
self.assertEqual(self.attendance.status, Attendance.AttendanceStatus.ABSENT)
self.assertEqual(self.attendance.note, "Sick this week")
def test_note_is_stripped_of_surrounding_whitespace(self):
self.client.force_login(self.user)
self._post(self.event, {"status": "absent", "note": " Sick this week \n"})
self.attendance.refresh_from_db()
self.assertEqual(self.attendance.note, "Sick this week")
def test_posting_present_clears_a_previous_absent_reason(self):
self.attendance.status = Attendance.AttendanceStatus.ABSENT
self.attendance.note = "Sick this week"
self.attendance.save()
self.client.force_login(self.user)
self._post(self.event, {"status": "present"})
self.attendance.refresh_from_db()
self.assertEqual(self.attendance.status, Attendance.AttendanceStatus.PRESENT)
self.assertEqual(self.attendance.note, "")
def test_a_note_submitted_alongside_a_non_absent_status_is_ignored(self):
self.client.force_login(self.user)
self._post(self.event, {"status": "present", "note": "This shouldn't be saved"})
self.attendance.refresh_from_db()
self.assertEqual(self.attendance.note, "")
def test_rejects_an_unknown_status_value(self):
self.client.force_login(self.user)
@@ -880,6 +917,14 @@ class EventDetailScreenTests(TestCase):
self.assertContains(response, "#17")
self.assertContains(response, "No reply")
def test_your_answers_shows_your_own_absence_reason(self):
Attendance.objects.create(event=self.event, member=self.member, status=Attendance.AttendanceStatus.ABSENT, note="Sick this week")
self.client.force_login(self.user)
response = self._get()
self.assertContains(response, "Sick this week")
def test_your_answers_is_empty_when_nobody_managed_is_invited(self):
self.client.force_login(self.user)
@@ -905,6 +950,18 @@ class EventDetailScreenTests(TestCase):
self.assertEqual(summary["no_reply_count"], 1)
self.assertEqual(summary["total"], 3)
def test_squad_response_never_leaks_another_members_absence_reason(self):
# Squad response is counts-only by design -- a reason belongs to the
# member/family who wrote it and to Coach mode, never to the rest of
# the squad's own event page.
out_member = Member.objects.create(first_name="B", last_name="Out")
Attendance.objects.create(event=self.event, member=out_member, status=Attendance.AttendanceStatus.ABSENT, note="Family holiday")
self.client.force_login(self.user)
response = self._get()
self.assertNotContains(response, "Family holiday")
def test_squad_response_is_absent_for_an_event_with_no_teams(self):
club_wide_event = Event.objects.create(club=self.club, title="Club BBQ", start=timezone.now() + datetime.timedelta(days=3), club_wide=True)
self.client.force_login(self.user)