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:
@@ -7,9 +7,11 @@ through rather than touching the models directly.
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from club.services.access import event_season
|
||||
from events.models import Attendance, LineupSlot
|
||||
from members.models import Member
|
||||
from notifications.services import notify_members
|
||||
from teams.models import StaffAssignment
|
||||
|
||||
#: Attendance statuses that mean "not actually available" -- these members
|
||||
#: 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)
|
||||
|
||||
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)
|
||||
|
||||
@@ -16,7 +16,7 @@ from club.services.onboarding import mark_bypassed, mark_complete
|
||||
from features.models import Maintenance
|
||||
from members.models import Group, GroupMembership, Member
|
||||
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 .models import Attendance, Competition, Event, EventReferee, EventSeries, Lineup, LineupSlot, LineupUnit, Location, Opponent
|
||||
@@ -34,7 +34,7 @@ from .services import (
|
||||
team_no_shows,
|
||||
)
|
||||
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.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
|
||||
@@ -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))
|
||||
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):
|
||||
def test_adding_roster_member_syncs_future_events(self):
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
{% if event.gathering %}
|
||||
<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="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>
|
||||
{% endif %}
|
||||
{% if event.location %}
|
||||
@@ -58,6 +58,27 @@
|
||||
{% endif %}
|
||||
</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 %}
|
||||
<div class="m-card p-4">
|
||||
<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>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if rsvp_closed %}
|
||||
{% if rsvp_closed or lineup %}
|
||||
<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 %}
|
||||
{% trans "In" as label_in %}
|
||||
{% trans "Maybe" as label_maybe %}
|
||||
|
||||
144
mobile/tests.py
144
mobile/tests.py
@@ -708,6 +708,92 @@ class EventDetailRsvpTests(TestCase):
|
||||
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"])
|
||||
class CalendarViewTests(TestCase):
|
||||
"""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.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):
|
||||
in_member = Member.objects.create(first_name="A", last_name="In")
|
||||
out_member = Member.objects.create(first_name="B", last_name="Out")
|
||||
|
||||
@@ -27,8 +27,9 @@ from club.services.fees import open_dues_rows
|
||||
from club.services.onboarding import checklist_for
|
||||
from club.services.sponsors import active_sponsors
|
||||
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.lineup import notify_dropout
|
||||
from members.models import FamilyMembership, Member
|
||||
from members.views import ClubScopedPublicMixin
|
||||
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)
|
||||
season = event.season or current_season(self.request.club)
|
||||
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 = []
|
||||
if self.managed_people:
|
||||
@@ -430,11 +435,19 @@ class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
||||
"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):
|
||||
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."))
|
||||
|
||||
# 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."))
|
||||
|
||||
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
|
||||
# "Your answers" card already show once the deadline has passed (see
|
||||
# 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.
|
||||
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."))
|
||||
|
||||
# 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
|
||||
# (mobile/templates/mobile/coach/attendance.html) ever read it.
|
||||
note = ""
|
||||
if status == Attendance.AttendanceStatus.ABSENT:
|
||||
if is_dropout or 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
|
||||
# "??") -- mandatory for Out/dropout 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):
|
||||
@@ -477,7 +495,11 @@ class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
||||
# 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})
|
||||
|
||||
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":
|
||||
return HttpResponseRedirect(reverse("mobile:event_detail", kwargs={"pk": event.pk}))
|
||||
|
||||
Reference in New Issue
Block a user