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:
48
mobile/templates/mobile/_hero_rsvp.html
Normal file
48
mobile/templates/mobile/_hero_rsvp.html
Normal file
@@ -0,0 +1,48 @@
|
||||
{% load i18n %}
|
||||
{% comment %}
|
||||
Quick 2-way RSVP (In/Out) for a hero card -- Home's own (M1) and Coach
|
||||
Today's "Also yours" card share this exact markup. Picking "Out" first
|
||||
asks for an optional reason (Attendance.note) before submitting -- private
|
||||
by construction, not by a visibility flag: nothing anywhere renders
|
||||
another member's own note, so writing it here and reading it back in
|
||||
event_detail's "Your answers" (this same member/family) and Coach mode's
|
||||
bench attendance (mobile/templates/mobile/coach/attendance.html) is the
|
||||
entire access story, no extra permission check needed.
|
||||
|
||||
Expects ``hero_attendance`` in scope. ``cross_shell`` (optional, truthy)
|
||||
adds hx-boost="false" to both forms -- pass this when included from Coach
|
||||
mode, since mobile:event_detail is a Member-shell view (see coach/
|
||||
today.html's own comment on why that matters).
|
||||
|
||||
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
|
||||
a green default would misleadingly read as an already-recorded answer.
|
||||
Both are the same neutral steel tone and, via min-w-0, exactly the same
|
||||
width -- flex-1 alone doesn't guarantee that once one button's label is
|
||||
longer ("Out" vs "In"): flex items default to min-width:auto, so the
|
||||
longer label's own intrinsic width can win a bigger share of the row
|
||||
unless min-width is forced to 0 on both.
|
||||
{% endcomment %}
|
||||
<div class="mt-3" x-data="{ asking: false, reason: '{{ hero_attendance.note|default:""|escapejs }}' }">
|
||||
<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 %}>
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="member_id" value="{{ hero_attendance.member.pk }}">
|
||||
<input type="hidden" name="status" value="present">
|
||||
<button class="btn w-full bg-steel text-on-dark" type="submit">{% trans "In" %}</button>
|
||||
</form>
|
||||
<button class="btn w-full min-w-0 flex-1 bg-steel text-on-dark" type="button" @click="asking = true">{% trans "Out" %}</button>
|
||||
</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 %}>
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="member_id" value="{{ hero_attendance.member.pk }}">
|
||||
<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>
|
||||
<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>
|
||||
<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 btn-dark flex-1" type="submit">{% trans "Confirm" %}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -45,6 +45,9 @@
|
||||
<div class="min-w-0 flex-1">
|
||||
<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>
|
||||
{% if row.status == "absent" and row.note %}
|
||||
<div class="mt-0.5 truncate text-xs text-club-dark italic">“{{ row.note }}”</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if can_manage_active_team %}
|
||||
<input type="hidden" name="showed_up_{{ row.pk }}" :value="state">
|
||||
|
||||
@@ -81,26 +81,13 @@
|
||||
<span class="pill pill-neutral mt-3">{{ hero_attendance.get_status_display }}</span>
|
||||
{% else %}
|
||||
{% comment %}
|
||||
hx-boost="false" -- 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
|
||||
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 %}
|
||||
<div class="mt-3 flex gap-2">
|
||||
<form class="flex-1" method="post" action="{% url "mobile:event_detail" hero_attendance.event.pk %}" hx-boost="false">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="member_id" value="{{ hero_attendance.member.pk }}">
|
||||
<input type="hidden" name="status" value="present">
|
||||
<button class="btn btn-positive w-full" type="submit">{% trans "In" %}</button>
|
||||
</form>
|
||||
<form class="flex-1" method="post" action="{% url "mobile:event_detail" hero_attendance.event.pk %}" hx-boost="false">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="member_id" value="{{ hero_attendance.member.pk }}">
|
||||
<input type="hidden" name="status" value="absent">
|
||||
<button class="btn w-full bg-steel text-on-dark" type="submit">{% trans "Out" %}</button>
|
||||
</form>
|
||||
</div>
|
||||
{% include "mobile/_hero_rsvp.html" with cross_shell=True %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -78,34 +78,47 @@
|
||||
{% 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 %}">
|
||||
{% trans "In" as label_in %}
|
||||
{% trans "Maybe" as label_maybe %}
|
||||
{% trans "Out" as label_out %}
|
||||
{% with status=answer.attendance.status %}
|
||||
<div x-data="{ asking: false, reason: '{{ answer.attendance.note|default:""|escapejs }}' }">
|
||||
<div class="flex gap-1.5" x-show="!asking">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<form x-show="asking" x-cloak 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>
|
||||
<label class="mb-1 block text-xs font-semibold text-muted">{% trans "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>
|
||||
<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="submit" class="btn btn-dark h-9 flex-1 text-xs">{% trans "Confirm" %}</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if status == "absent" and answer.attendance.note %}
|
||||
<p class="mt-1.5 text-xs text-muted italic">“{{ answer.attendance.note }}”</p>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
@@ -59,20 +59,7 @@
|
||||
<span class="pill pill-neutral mt-2">{{ hero_attendance.get_status_display }}</span>
|
||||
{% else %}
|
||||
<p class="mt-3 font-display text-xs font-extrabold text-on-dark-dim uppercase tracking-wide">{% blocktrans with name=hero_attendance.member.first_name %}{{ name }} — are you in?{% endblocktrans %}</p>
|
||||
<div class="mt-2 flex gap-2">
|
||||
<form class="flex-1" method="post" action="{% url "mobile:event_detail" hero_attendance.event.pk %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="member_id" value="{{ hero_attendance.member.pk }}">
|
||||
<input type="hidden" name="status" value="present">
|
||||
<button class="btn btn-positive w-full" type="submit">{% trans "In" %}</button>
|
||||
</form>
|
||||
<form class="flex-1" method="post" action="{% url "mobile:event_detail" hero_attendance.event.pk %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="member_id" value="{{ hero_attendance.member.pk }}">
|
||||
<input type="hidden" name="status" value="absent">
|
||||
<button class="btn w-full bg-steel text-on-dark" type="submit">{% trans "Out" %}</button>
|
||||
</form>
|
||||
</div>
|
||||
{% include "mobile/_hero_rsvp.html" %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -444,7 +444,15 @@ class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
||||
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})
|
||||
# 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
|
||||
# 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 ""
|
||||
Attendance.objects.update_or_create(event=event, member=member, defaults={"status": status, "note": note})
|
||||
|
||||
if request.POST.get("next") == "event_detail":
|
||||
return HttpResponseRedirect(reverse("mobile:event_detail", kwargs={"pk": event.pk}))
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user