Show the published line-up on the member event view instead of RSVP buttons

Once a game's line-up is published, "Your answers" switches to a read-only
status pill and a new Line-up card shows every unit/slot; a SELECTED member
can still report they can no longer make it, which flips them to Absent and
notifies the team's managers immediately (the closed-deadline guard doesn't
apply here, since a published line-up is usually well past it). Also shows
the full date for the "Meet" time, not just the hour.

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 16:04:03 +02:00
parent a9bf98a3c9
commit 62c36f47b9
5 changed files with 263 additions and 15 deletions

View File

@@ -7,9 +7,11 @@ through rather than touching the models directly.
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from club.services.access import event_season
from events.models import Attendance, LineupSlot from events.models import Attendance, LineupSlot
from members.models import Member from members.models import Member
from notifications.services import notify_members from notifications.services import notify_members
from teams.models import StaffAssignment
#: Attendance statuses that mean "not actually available" -- these members #: Attendance statuses that mean "not actually available" -- these members
#: stay exactly as they are on publish, never flipped to NOT_SELECTED, and #: stay exactly as they are on publish, never flipped to NOT_SELECTED, and
@@ -56,3 +58,17 @@ def publish_lineup(lineup):
notify_members(selected_members, club=lineup.event.club, title=_("Line-up published"), body=body, source=lineup.event) notify_members(selected_members, club=lineup.event.club, title=_("Line-up published"), body=body, source=lineup.event)
return lineup return lineup
def notify_dropout(event, member, note):
"""A player who was SELECTED in a published line-up reporting, after the
fact, that they can no longer make it (mobile.views.EventDetailView.post's
"dropout" status) -- unlike an ordinary pre-deadline Out, the roster is
already locked in, so this goes straight to whoever can still act on it:
every current-season manager of the event's own teams (management
position, not every staffer -- a physio can't swap a line-up slot)."""
manager_ids = StaffAssignment.objects.filter(team__in=event.teams.all(), position__management_position=True, season=event_season(event)).values_list("member_id", flat=True).distinct()
managers = Member.objects.filter(pk__in=manager_ids)
if managers:
body = _("%(member)s can no longer make it to %(event)s: “%(note)s") % {"member": member.get_full_name(), "event": event.title, "note": note}
notify_members(managers, club=event.club, title=_("Line-up dropout"), body=body, source=event)

View File

@@ -16,7 +16,7 @@ from club.services.onboarding import mark_bypassed, mark_complete
from features.models import Maintenance from features.models import Maintenance
from members.models import Group, GroupMembership, Member from members.models import Group, GroupMembership, Member
from notifications.models import Notification from notifications.models import Notification
from teams.models import Position, RefereeLevel, RefereeProfile, Team, TeamMembership from teams.models import Position, RefereeLevel, RefereeProfile, StaffAssignment, Team, TeamMembership
from .admin import EventAdminForm from .admin import EventAdminForm
from .models import Attendance, Competition, Event, EventReferee, EventSeries, Lineup, LineupSlot, LineupUnit, Location, Opponent from .models import Attendance, Competition, Event, EventReferee, EventSeries, Lineup, LineupSlot, LineupUnit, Location, Opponent
@@ -34,7 +34,7 @@ from .services import (
team_no_shows, team_no_shows,
) )
from .services.calendar import add_months, month_bounds, month_grid, season_grid, week_bounds, week_grid from .services.calendar import add_months, month_bounds, month_grid, season_grid, week_bounds, week_grid
from .services.lineup import clear_slot, place_member, publish_lineup from .services.lineup import clear_slot, notify_dropout, place_member, publish_lineup
from .services.rbihf_import import RBIHFImportError, apply_plan, build_plan, extract_team_id, parse_fixtures, suggested_location, suggested_opponent from .services.rbihf_import import RBIHFImportError, apply_plan, build_plan, extract_team_id, parse_fixtures, suggested_location, suggested_opponent
from .services.referees import RefereeAssignmentError, add_external_referee, assign_referee, conflicting_events, eligible_referees, needs_referee_management, remove_referee, set_referee_fee from .services.referees import RefereeAssignmentError, add_external_referee, assign_referee, conflicting_events, eligible_referees, needs_referee_management, remove_referee, set_referee_fee
from .tasks import send_deadline_reminders from .tasks import send_deadline_reminders
@@ -466,6 +466,28 @@ class LineupServiceTests(EventsTestBase):
notified_member_ids = set(Notification.objects.filter(member__in=[self.alice, self.bob]).values_list("member_id", flat=True)) notified_member_ids = set(Notification.objects.filter(member__in=[self.alice, self.bob]).values_list("member_id", flat=True))
self.assertEqual(notified_member_ids, {self.alice.pk}) self.assertEqual(notified_member_ids, {self.alice.pk})
def test_notify_dropout_notifies_the_teams_managers(self):
event, _lineup, _unit, _slot = self.make_game_with_lineup()
manager = Member.objects.create(first_name="Cara", last_name="Coach")
management_position = Position.objects.create(club=self.club, name="Head coach", short_name="HC", staff_position=True, management_position=True)
StaffAssignment.objects.create(team=self.team, member=manager, season=self.season, position=management_position)
notify_dropout(event, self.alice, "Twisted an ankle")
notification = Notification.objects.get(member=manager)
self.assertIn(self.alice.get_full_name(), notification.body)
self.assertIn("Twisted an ankle", notification.body)
def test_notify_dropout_does_not_notify_non_management_staff(self):
event, _lineup, _unit, _slot = self.make_game_with_lineup()
physio = Member.objects.create(first_name="Pat", last_name="Physio")
physio_position = Position.objects.create(club=self.club, name="Physio", short_name="PH", staff_position=True, management_position=False)
StaffAssignment.objects.create(team=self.team, member=physio, season=self.season, position=physio_position)
notify_dropout(event, self.alice, "Twisted an ankle")
self.assertFalse(Notification.objects.filter(member=physio).exists())
class RosterChangeSyncTests(EventsTestBase): class RosterChangeSyncTests(EventsTestBase):
def test_adding_roster_member_syncs_future_events(self): def test_adding_roster_member_syncs_future_events(self):

View File

@@ -44,7 +44,7 @@
{% if event.gathering %} {% if event.gathering %}
<div class="flex items-center gap-3.5"> <div class="flex items-center gap-3.5">
<span class="w-20 shrink-0 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Meet" %}</span> <span class="w-20 shrink-0 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Meet" %}</span>
<span class="text-[15px] font-semibold text-ink">{{ event.gathering|date:"H:i" }}</span> <span class="text-[15px] font-semibold text-ink">{{ event.gathering|date:"D d M \a\t H:i" }}</span>
</div> </div>
{% endif %} {% endif %}
{% if event.location %} {% if event.location %}
@@ -58,6 +58,27 @@
{% endif %} {% endif %}
</div> </div>
{% if lineup %}
<div class="m-card p-4">
<div class="flex items-center justify-between">
<span class="font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Line-up" %}</span>
<span class="pill pill-info">{% trans "Published" %}</span>
</div>
<div class="mt-3 flex flex-col gap-3">
{% for unit in lineup.units.all %}
<div>
<div class="mb-1.5 font-display text-xs font-extrabold tracking-wide text-club uppercase">{{ unit.label }}</div>
<div class="flex flex-wrap gap-1.5">
{% for slot in unit.slots.all %}
{% if slot.member %}<span class="pill pill-neutral">{{ slot.member.get_full_name }}</span>{% endif %}
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% if your_answers %} {% if your_answers %}
<div class="m-card p-4"> <div class="m-card p-4">
<span class="font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Your answers" %}</span> <span class="font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Your answers" %}</span>
@@ -81,8 +102,31 @@
<span class="pill pill-warn shrink-0">{% trans "No reply" %}</span> <span class="pill pill-warn shrink-0">{% trans "No reply" %}</span>
{% endif %} {% endif %}
</div> </div>
{% if rsvp_closed %} {% if rsvp_closed or lineup %}
<span class="pill pill-neutral">{{ answer.attendance.get_status_display }}</span> <span class="pill pill-neutral">{{ answer.attendance.get_status_display }}</span>
{% if lineup and answer.attendance.status == "selected" %}
{% comment %}
Only a SELECTED player sees this -- once they report a
dropout, EventDetailView.post flips them to ABSENT, so a
reload naturally drops this button and falls through to
the shared note paragraph below instead.
{% endcomment %}
<div class="mt-2" x-data="{ asking: false, reason: '' }">
<button type="button" class="btn btn-secondary h-9 w-full text-xs" x-show="!asking" @click="asking = true">{% trans "Can't make it after all" %}</button>
<form x-show="asking" x-cloak method="post" action="{% url "mobile:event_detail" event.pk %}" hx-boost="false" class="rounded-lg border border-stroke bg-paper p-3">
{% csrf_token %}
<input type="hidden" name="member_id" value="{{ answer.member.pk }}">
<input type="hidden" name="status" value="dropout">
<input type="hidden" name="next" value="event_detail">
<label class="mb-1 block text-xs font-semibold text-muted">{% trans "Let the coach know why -- they'll be notified right away" %}</label>
<textarea class="h-16 w-full rounded-lg border border-stroke bg-white p-2 text-sm text-ink" name="note" x-model="reason" placeholder="{% trans "e.g. sick, injured, running late..." %}"></textarea>
<div class="mt-2 grid grid-cols-2 gap-2">
<button type="button" class="btn btn-secondary h-9 text-xs" @click="asking = false">{% trans "Cancel" %}</button>
<button type="submit" class="btn btn-dark h-9 text-xs">{% trans "Send" %}</button>
</div>
</form>
</div>
{% endif %}
{% else %} {% else %}
{% trans "In" as label_in %} {% trans "In" as label_in %}
{% trans "Maybe" as label_maybe %} {% trans "Maybe" as label_maybe %}

View File

@@ -708,6 +708,92 @@ class EventDetailRsvpTests(TestCase):
self.assertRedirects(response, reverse("mobile:home"), fetch_redirect_response=False) 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 EventDetailDropoutTests(TestCase):
"""The "Can't make it after all" action a SELECTED member sees on a
published line-up (event_detail.html) -- posts status=dropout, which
EventDetailView.post resolves to an ordinary ABSENT plus a manager
notification, since the closed-deadline guard doesn't apply here."""
@classmethod
def setUpTestData(cls):
cls.club = make_club()
today = timezone.localdate()
cls.season = Season.objects.create(club=cls.club, start_date=today - datetime.timedelta(days=30), end_date=today + datetime.timedelta(days=300))
cls.user = User.objects.create_user(email="parent@example.com", password="pw-secret-123")
cls.member = Member.objects.create(first_name="Lars", last_name="Bakker", email="parent@example.com", user=cls.user)
ClubMembership.objects.create(club=cls.club, member=cls.member, season=cls.season)
cls.team = Team.objects.create(club=cls.club, name="U16", short_name="U16")
# Deadline in the past -- a published line-up is typically well after it,
# and the dropout path specifically has to work despite this.
cls.event = Event.objects.create(club=cls.club, title="Away game", start=timezone.now() + datetime.timedelta(days=1), deadline=timezone.now() - datetime.timedelta(hours=1))
cls.event.teams.add(cls.team)
cls.manager = Member.objects.create(first_name="Cara", last_name="Coach")
management_position = Position.objects.create(club=cls.club, name="Head coach", short_name="HC", staff_position=True, management_position=True)
StaffAssignment.objects.create(team=cls.team, member=cls.manager, season=cls.season, position=management_position)
def _post(self, data):
return self.client.post(reverse("mobile:event_detail", kwargs={"pk": self.event.pk}), data=data, HTTP_HOST="ajax-united.rosterchief.app")
def test_dropout_flips_a_selected_member_to_absent_with_the_given_reason(self):
Attendance.objects.create(event=self.event, member=self.member, status=Attendance.AttendanceStatus.SELECTED)
self.client.force_login(self.user)
response = self._post({"status": "dropout", "note": "Twisted an ankle"})
self.assertEqual(response.status_code, 302)
attendance = Attendance.objects.get(event=self.event, member=self.member)
self.assertEqual(attendance.status, Attendance.AttendanceStatus.ABSENT)
self.assertEqual(attendance.note, "Twisted an ankle")
def test_dropout_bypasses_the_closed_deadline_guard(self):
# setUpTestData's event already has a deadline in the past -- an
# ordinary Out would 400 here (see EventDetailRsvpTests), dropout must not.
Attendance.objects.create(event=self.event, member=self.member, status=Attendance.AttendanceStatus.SELECTED)
self.client.force_login(self.user)
response = self._post({"status": "dropout", "note": "Twisted an ankle"})
self.assertEqual(response.status_code, 302)
def test_dropout_notifies_the_teams_managers(self):
Attendance.objects.create(event=self.event, member=self.member, status=Attendance.AttendanceStatus.SELECTED)
self.client.force_login(self.user)
self._post({"status": "dropout", "note": "Twisted an ankle"})
notification = Notification.objects.get(member=self.manager)
self.assertIn(self.member.get_full_name(), notification.body)
self.assertIn("Twisted an ankle", notification.body)
def test_dropout_without_a_reason_is_rejected(self):
Attendance.objects.create(event=self.event, member=self.member, status=Attendance.AttendanceStatus.SELECTED)
self.client.force_login(self.user)
response = self._post({"status": "dropout"})
self.assertEqual(response.status_code, 400)
attendance = Attendance.objects.get(event=self.event, member=self.member)
self.assertEqual(attendance.status, Attendance.AttendanceStatus.SELECTED)
def test_dropout_is_rejected_when_the_member_was_not_selected(self):
Attendance.objects.create(event=self.event, member=self.member, status=Attendance.AttendanceStatus.PRESENT)
self.client.force_login(self.user)
response = self._post({"status": "dropout", "note": "Twisted an ankle"})
self.assertEqual(response.status_code, 400)
attendance = Attendance.objects.get(event=self.event, member=self.member)
self.assertEqual(attendance.status, Attendance.AttendanceStatus.PRESENT)
def test_dropout_is_rejected_when_there_is_no_attendance_row_at_all(self):
self.client.force_login(self.user)
response = self._post({"status": "dropout", "note": "Twisted an ankle"})
self.assertEqual(response.status_code, 400)
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"]) @override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
class CalendarViewTests(TestCase): class CalendarViewTests(TestCase):
"""M3 -- design_handoff_rosterchief_platform/README.md's M3 section: a """M3 -- design_handoff_rosterchief_platform/README.md's M3 section: a
@@ -995,6 +1081,64 @@ class EventDetailScreenTests(TestCase):
self.assertEqual(list(response.context["your_answers"]), []) self.assertEqual(list(response.context["your_answers"]), [])
self.assertContains(response, "No one you manage is invited") self.assertContains(response, "No one you manage is invited")
def test_no_lineup_card_when_none_is_published(self):
self.client.force_login(self.user)
response = self._get()
self.assertIsNone(response.context["lineup"])
self.assertNotContains(response, "Line-up")
def test_unpublished_lineup_is_not_shown(self):
lineup = Lineup.objects.create(event=self.event, team=self.team)
unit = LineupUnit.objects.create(lineup=lineup, label="Line 1")
LineupSlot.objects.create(unit=unit, member=self.member)
self.client.force_login(self.user)
response = self._get()
self.assertIsNone(response.context["lineup"])
def test_published_lineup_shows_its_units_and_slotted_members(self):
lineup = Lineup.objects.create(event=self.event, team=self.team, published_at=timezone.now())
unit = LineupUnit.objects.create(lineup=lineup, label="Line 1")
LineupSlot.objects.create(unit=unit, member=self.member)
self.client.force_login(self.user)
response = self._get()
self.assertEqual(response.context["lineup"], lineup)
self.assertContains(response, "Line 1")
self.assertContains(response, self.member.get_full_name())
def test_rsvp_buttons_are_replaced_by_a_readonly_pill_once_the_lineup_is_published(self):
Lineup.objects.create(event=self.event, team=self.team, published_at=timezone.now())
Attendance.objects.create(event=self.event, member=self.member, status=Attendance.AttendanceStatus.SELECTED)
self.client.force_login(self.user)
response = self._get()
self.assertNotContains(response, 'name="status" value="present"')
def test_selected_member_sees_the_cant_make_it_button(self):
Lineup.objects.create(event=self.event, team=self.team, published_at=timezone.now())
Attendance.objects.create(event=self.event, member=self.member, status=Attendance.AttendanceStatus.SELECTED)
self.client.force_login(self.user)
response = self._get()
self.assertContains(response, "Can't make it after all")
self.assertContains(response, 'name="status" value="dropout"')
def test_not_selected_member_does_not_see_the_cant_make_it_button(self):
Lineup.objects.create(event=self.event, team=self.team, published_at=timezone.now())
Attendance.objects.create(event=self.event, member=self.member, status=Attendance.AttendanceStatus.NOT_SELECTED)
self.client.force_login(self.user)
response = self._get()
self.assertNotContains(response, 'name="status" value="dropout"')
def test_squad_response_counts_are_correct(self): def test_squad_response_counts_are_correct(self):
in_member = Member.objects.create(first_name="A", last_name="In") in_member = Member.objects.create(first_name="A", last_name="In")
out_member = Member.objects.create(first_name="B", last_name="Out") out_member = Member.objects.create(first_name="B", last_name="Out")

View File

@@ -27,8 +27,9 @@ from club.services.fees import open_dues_rows
from club.services.onboarding import checklist_for from club.services.onboarding import checklist_for
from club.services.sponsors import active_sponsors from club.services.sponsors import active_sponsors
from controlpanel.messages import notify from controlpanel.messages import notify
from events.models import Attendance, Event from events.models import Attendance, Event, Lineup
from events.services.calendar import week_bounds from events.services.calendar import week_bounds
from events.services.lineup import notify_dropout
from members.models import FamilyMembership, Member from members.models import FamilyMembership, Member
from members.views import ClubScopedPublicMixin from members.views import ClubScopedPublicMixin
from news.models import News from news.models import News
@@ -391,6 +392,10 @@ class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
event = get_object_or_404(Event.objects.select_related("location", "opponent"), pk=self.kwargs["pk"], club=self.request.club) 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) season = event.season or current_season(self.request.club)
rsvp_closed = event.deadline is not None and event.deadline < timezone.now() rsvp_closed = event.deadline is not None and event.deadline < timezone.now()
# A published line-up supersedes ordinary RSVP -- the roster's locked
# in, so "Your answers" below switches to read-only and the line-up
# itself gets its own card.
lineup = Lineup.objects.filter(event=event, published_at__isnull=False).prefetch_related("units__slots__member").first()
your_answers = [] your_answers = []
if self.managed_people: if self.managed_people:
@@ -430,11 +435,19 @@ class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
"no_reply_pct": round(100 * counts["no_reply_count"] / total), "no_reply_pct": round(100 * counts["no_reply_count"] / total),
} }
return super().get_context_data(screen_title=event.title, event=event, rsvp_closed=rsvp_closed, your_answers=your_answers, squad_summary=squad_summary, **kwargs) return super().get_context_data(screen_title=event.title, event=event, rsvp_closed=rsvp_closed, lineup=lineup, your_answers=your_answers, squad_summary=squad_summary, **kwargs)
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
status = request.POST.get("status") status = request.POST.get("status")
if status not in (Attendance.AttendanceStatus.PRESENT, Attendance.AttendanceStatus.ABSENT, Attendance.AttendanceStatus.MAYBE): # "dropout" isn't an AttendanceStatus -- it's this same form posting
# from the "Can't make it after all" button a SELECTED member sees
# once the line-up's published (event_detail.html). It resolves to
# ABSENT below, same as an ordinary Out, but skips the closed-deadline
# guard (the line-up is published well after most deadlines) and
# additionally pings the event's managers, since by this point only
# they can still act on it (swap the slot, warn the opponent, ...).
is_dropout = status == "dropout"
if not is_dropout and status not in (Attendance.AttendanceStatus.PRESENT, Attendance.AttendanceStatus.ABSENT, Attendance.AttendanceStatus.MAYBE):
return HttpResponseBadRequest(_("Unknown RSVP status.")) return HttpResponseBadRequest(_("Unknown RSVP status."))
# Every current caller (Home's hero, M2's per-person rows) always sends an # Every current caller (Home's hero, M2's per-person rows) always sends an
@@ -446,11 +459,16 @@ class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
return HttpResponseBadRequest(_("You can't RSVP for that person.")) return HttpResponseBadRequest(_("You can't RSVP for that person."))
event = get_object_or_404(Event, pk=kwargs["pk"], club=request.club) event = get_object_or_404(Event, pk=kwargs["pk"], club=request.club)
existing = Attendance.objects.filter(event=event, member=member).first()
if is_dropout:
if existing is None or existing.status != Attendance.AttendanceStatus.SELECTED:
return HttpResponseBadRequest(_("You're not in the published line-up for this event."))
# Mirrors the read-only treatment Home's hero and this same screen's own # 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 # "Your answers" card already show once the deadline has passed (see
# get_context_data's rsvp_closed) -- enforced here too, since a disabled # 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. # 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(): elif 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/Maybe -- clearing it # A reason is only ever meaningful attached to Out/Maybe -- clearing it
@@ -461,14 +479,14 @@ class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
# (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 = "" note = ""
if status == Attendance.AttendanceStatus.ABSENT: if is_dropout or status == Attendance.AttendanceStatus.ABSENT:
note = request.POST.get("note", "").strip() note = request.POST.get("note", "").strip()
# Rejects blank and punctuation-only "answers" (a bare ".", "-", # Rejects blank and punctuation-only "answers" (a bare ".", "-",
# "??") -- mandatory for Out specifically, unlike Maybe below. # "??") -- mandatory for Out/dropout specifically, unlike Maybe
# Backend-scoped, not the pretty inline-error UX this codebase # below. Backend-scoped, not the pretty inline-error UX this
# gives ModelForm submissions elsewhere -- matches this view's # codebase gives ModelForm submissions elsewhere -- matches this
# own existing style (see "Unknown RSVP status"/"Replies are # view's own existing style (see "Unknown RSVP status"/"Replies
# closed" above, both plain 400s a normal user should never # are closed" above, both plain 400s a normal user should never
# actually see, since the template only ever offers Out through # actually see, since the template only ever offers Out through
# the reason form to begin with). # the reason form to begin with).
if not any(char.isalnum() for char in note): if not any(char.isalnum() for char in note):
@@ -477,7 +495,11 @@ class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
# Optional here -- Maybe doesn't owe anyone an explanation the way # Optional here -- Maybe doesn't owe anyone an explanation the way
# a firm no does, but the same field carries it if given one. # a firm no does, but the same field carries it if given one.
note = request.POST.get("note", "").strip() note = request.POST.get("note", "").strip()
Attendance.objects.update_or_create(event=event, member=member, defaults={"status": status, "note": note})
final_status = Attendance.AttendanceStatus.ABSENT if is_dropout else status
Attendance.objects.update_or_create(event=event, member=member, defaults={"status": final_status, "note": note})
if is_dropout:
notify_dropout(event, member, note)
if request.POST.get("next") == "event_detail": if request.POST.get("next") == "event_detail":
return HttpResponseRedirect(reverse("mobile:event_detail", kwargs={"pk": event.pk})) return HttpResponseRedirect(reverse("mobile:event_detail", kwargs={"pk": event.pk}))