Add an "All" scope to the person switcher, default once there's a family

A lone member (or a parent of exactly one child) still lands straight on
their own record and never sees the switcher, same as before. The moment
there's more than one managed person, an "All" chip appears first and is
selected by default -- Home's cards (hero, needs-your-answer, dues) now
aggregate across everyone in scope instead of just one person, and
Calendar's own "My schedule" default does the same for its agenda.

PersonScopeMixin gains ``scope_everyone`` (bool) and ``people_in_scope``
(the effective list to filter by, regardless of which mode is active) --
kept separate from Calendar's own, unrelated ``?scope=all`` toggle
("every club event" vs. this "every person I manage"). Every RSVP form
now names its target member explicitly (``member_id``) rather than
relying on the old implicit "whoever is currently scoped" fallback, which
stops working the moment "All" -- not one person -- is the default.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
2026-08-21 14:22:40 +02:00
parent 59d287e978
commit 397b9cf687
7 changed files with 261 additions and 62 deletions

View File

@@ -21,14 +21,24 @@ class PersonScopeMixin(ClubScopedPublicMixin):
``?as=<member-id>`` re-scopes the current screen to one managed person,
same as the design doc's horizontally-scrolling chip row -- it re-scopes in
place rather than navigating. Falls back to the account's own Member, then
to the first managed child (e.g. a parent with no Member record of their own).
place rather than navigating. ``?as=all`` (or, once there's more than one
managed person, no ``?as=`` at all) scopes to *every* managed person --
that's the default the moment there's actually something to aggregate; a
member on their own, or a parent of exactly one child, has nothing to
aggregate and just lands on that one record, same as before this existed.
Screens that only ever operate on one person at a time keep using
``scope_person`` (``None`` in "everyone" mode); screens that can
meaningfully show several people at once (Home's cards, Calendar's own
"my schedule" listing) should use ``people_in_scope`` instead, which is
always the right list to filter by regardless of which mode is active.
"""
def dispatch(self, request, *args, **kwargs):
self.me = Member.objects.filter(user=request.user).first() if request.user.is_authenticated else None
self.managed_people = self._managed_people(request)
self.scope_person = self._resolve_scope_person(request)
self.scope_everyone, self.scope_person = self._resolve_scope(request)
self.people_in_scope = self.managed_people if self.scope_everyone else ([self.scope_person] if self.scope_person else [])
return super().dispatch(request, *args, **kwargs)
def _managed_people(self, request):
@@ -44,13 +54,19 @@ class PersonScopeMixin(ClubScopedPublicMixin):
)
return [self.me, *children]
def _resolve_scope_person(self, request):
def _resolve_scope(self, request) -> tuple[bool, Member | None]:
"""Returns ``(scope_everyone, scope_person)`` -- exactly one of the
two is ever meaningful at a time (the other is ``False``/``None``)."""
requested_id = request.GET.get("as")
if requested_id:
if requested_id and requested_id != "all":
for person in self.managed_people:
if str(person.pk) == requested_id:
return person
return self.managed_people[0] if self.managed_people else None
return False, person
if requested_id == "all":
return True, None
if len(self.managed_people) > 1:
return True, None
return False, (self.managed_people[0] if self.managed_people else None)
def get_context_data(self, **kwargs):
unread_notification_count = 0
@@ -61,6 +77,7 @@ class PersonScopeMixin(ClubScopedPublicMixin):
me=self.me,
managed_people=self.managed_people,
scope_person=self.scope_person,
scope_everyone=self.scope_everyone,
has_staff_access=self.me is not None and has_management_access(self.request.user, self.request.club),
unread_notification_count=unread_notification_count,
season=current_season(self.request.club),

View File

@@ -18,6 +18,7 @@
<div class="text-sm font-semibold text-ink">{{ row.event.title }}</div>
<div class="truncate text-xs text-muted">
{{ row.event.start|date:"H:i" }}
{% if row.member %}&middot; {{ row.member.first_name }}{% endif %}
{% for team in row.event.teams.all %}&middot; {{ team.name }}{% endfor %}
{% if row.event.location %}&middot; {{ row.event.location.name }}{% endif %}
</div>

View File

@@ -73,8 +73,14 @@
{% if managed_people|length > 1 %}
<div class="mt-3 flex gap-2 overflow-x-auto pb-0.5">
<a class="person-chip {% if scope_everyone %}person-chip-active{% endif %}" href="?as=all">
<span class="person-chip-avatar">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="3"/><circle cx="17" cy="9" r="2.6"/><path d="M2.5 20c0-3.3 2.5-5.6 5.5-5.6s5.5 2.3 5.5 5.6"/><path d="M14 15c2.6.3 4.5 2.3 4.5 5"/></svg>
</span>
<span class="person-chip-label">{% trans "All" %}</span>
</a>
{% for person in managed_people %}
<a class="person-chip {% if person == scope_person %}person-chip-active{% endif %}" href="?as={{ person.pk }}">
<a class="person-chip {% if not scope_everyone and person == scope_person %}person-chip-active{% endif %}" href="?as={{ person.pk }}">
<span class="person-chip-avatar">{{ person.first_name|slice:":1" }}</span>
<span class="person-chip-label">{% if person == me %}{% trans "Me" %}{% else %}{{ person.first_name }}{% endif %}</span>
</a>

View File

@@ -22,7 +22,7 @@
</a>
</div>
{% if not scope_all and not scope_person %}
{% if not scope_all and not managed_people %}
<div class="m-card p-6 text-center">
<p class="font-display text-lg font-extrabold text-ink uppercase">{% trans "No one to show yet" %}</p>
<p class="mt-1 text-sm text-muted">{% trans "Once you're linked to a member record, their schedule will show up here." %}</p>

View File

@@ -3,13 +3,16 @@
{% comment %}
M1 -- design_handoff_rosterchief_platform/README.md's M1 section. Four
independently-optional cards, all scoped to scope_person (mobile/mixins.py):
a hero for the soonest upcoming event with a quick In/Out RSVP, a "needs
your answer" list, a season-dues card, and a news teaser.
independently-optional cards, scoped to people_in_scope (mobile/mixins.py)
-- everyone the account manages once there's more than one (the header's
"All" chip, on by default), or just the one person picked from the chip
row. A hero for the soonest upcoming event with a quick In/Out RSVP, a
"needs your answer" list, a season-dues card per person who owes money,
and a news teaser.
{% endcomment %}
{% block content %}
{% if scope_person %}
{% if managed_people %}
{% if hero_attendance %}
<div class="m-card-dark overflow-hidden">
<div class="p-4 pb-0">
@@ -29,15 +32,17 @@
<p class="mt-3 font-display text-xs font-extrabold text-on-dark-dim uppercase tracking-wide">{% trans "Replies are closed for this event" %}</p>
<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=scope_person.first_name %}{{ name }} &mdash; are you in?{% endblocktrans %}</p>
<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 }} &mdash; 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>
@@ -63,7 +68,7 @@
</div>
<div class="min-w-0 flex-1">
<div class="text-sm font-semibold text-ink">{{ attendance.event.title }}</div>
<div class="text-xs text-muted">{{ scope_person.first_name }} &middot; {{ attendance.event.start|date:"H:i" }}</div>
<div class="text-xs text-muted">{{ attendance.member.first_name }} &middot; {{ attendance.event.start|date:"H:i" }}</div>
</div>
<span class="pill pill-warn shrink-0">{% trans "Reply" %}</span>
</a>
@@ -72,22 +77,26 @@
</div>
{% endif %}
{% if dues_membership %}
<div class="m-card flex items-center gap-3 p-4">
<div class="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-danger-bg">
<span class="font-display text-lg font-extrabold text-club">&euro;</span>
</div>
<div class="min-w-0 flex-1">
<div class="text-sm font-semibold text-ink">{% blocktrans with name=scope_person.first_name %}Season dues &mdash; {{ name }}{% endblocktrans %}</div>
<div class="text-xs text-muted">
{% if dues_invoice %}
{% blocktrans with amount=dues_balance|floatformat:2 due=dues_invoice.due_date|date:"d M" %}&euro; {{ amount }} &middot; due {{ due }}{% endblocktrans %}
{% else %}
{% blocktrans with amount=dues_balance|floatformat:2 %}&euro; {{ amount }}{% endblocktrans %}
{% endif %}
{% if dues_rows %}
<div class="flex flex-col gap-2.5">
{% for row in dues_rows %}
<div class="m-card flex items-center gap-3 p-4">
<div class="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-danger-bg">
<span class="font-display text-lg font-extrabold text-club">&euro;</span>
</div>
<div class="min-w-0 flex-1">
<div class="text-sm font-semibold text-ink">{% blocktrans with name=row.membership.member.first_name %}Season dues &mdash; {{ name }}{% endblocktrans %}</div>
<div class="text-xs text-muted">
{% if row.invoice %}
{% blocktrans with amount=row.balance|floatformat:2 due=row.invoice.due_date|date:"d M" %}&euro; {{ amount }} &middot; due {{ due }}{% endblocktrans %}
{% else %}
{% blocktrans with amount=row.balance|floatformat:2 %}&euro; {{ amount }}{% endblocktrans %}
{% endif %}
</div>
</div>
<span class="btn btn-dark shrink-0">{% trans "Pay" %}</span>
</div>
</div>
<span class="btn btn-dark shrink-0">{% trans "Pay" %}</span>
{% endfor %}
</div>
{% endif %}
@@ -108,7 +117,7 @@
</div>
{% endif %}
{% if not hero_attendance and not needs_answer and not dues_membership and not news_item %}
{% if not hero_attendance and not needs_answer and not dues_rows and not news_item %}
<div class="m-card p-6 text-center">
<p class="text-sm text-muted">{% trans "Nothing to show right now." %}</p>
</div>

View File

@@ -95,6 +95,26 @@ class MobileShellTests(TestCase):
self.assertEqual(response.context["scope_person"], child)
def test_all_chip_only_appears_once_theres_more_than_one_managed_person(self):
self.client.force_login(self.user)
response = self._get("home")
self.assertNotContains(response, 'href="?as=all"')
def test_all_chip_appears_and_is_selected_by_default_with_a_child(self):
family = Family.objects.create(name="Bakker")
FamilyMembership.objects.create(family=family, member=self.member, role=FamilyMembership.FamilyRole.PARENT)
child = Member.objects.create(first_name="Noor", last_name="Bakker")
FamilyMembership.objects.create(family=family, member=child, role=FamilyMembership.FamilyRole.CHILD)
ClubMembership.objects.create(club=self.club, member=child, season=Season.objects.first())
self.client.force_login(self.user)
response = self._get("home")
self.assertContains(response, 'href="?as=all"')
self.assertTrue(response.context["scope_everyone"])
@override_settings(
VAPID_PRIVATE_KEY="",
@@ -231,7 +251,7 @@ class HomeViewTests(TestCase):
response = self._get("home")
self.assertEqual(response.context["dues_balance"], Decimal("420.00"))
self.assertEqual(response.context["dues_rows"][0]["balance"], Decimal("420.00"))
self.assertContains(response, "420")
def test_dues_card_is_absent_when_nothing_is_owed(self):
@@ -239,7 +259,7 @@ class HomeViewTests(TestCase):
response = self._get("home")
self.assertIsNone(response.context["dues_membership"])
self.assertEqual(response.context["dues_rows"], [])
def test_news_teaser_shows_the_latest_published_item(self):
News.objects.create(club=self.club, title="Old news", body="Body.", status=News.Status.PUBLISHED, published_at=timezone.now() - datetime.timedelta(days=5))
@@ -262,6 +282,83 @@ class HomeViewTests(TestCase):
self.assertIsNone(response.context["scope_person"])
self.assertContains(response, "No one to show yet")
def add_child(self, first_name="Noor"):
family = Family.objects.create(name="Bakker")
FamilyMembership.objects.create(family=family, member=self.member, role=FamilyMembership.FamilyRole.PARENT)
child = Member.objects.create(first_name=first_name, last_name="Bakker")
FamilyMembership.objects.create(family=family, member=child, role=FamilyMembership.FamilyRole.CHILD)
ClubMembership.objects.create(club=self.club, member=child, season=self.season)
return child
def test_all_is_the_default_scope_once_theres_more_than_one_managed_person(self):
self.add_child()
self.client.force_login(self.user)
response = self._get("home")
self.assertTrue(response.context["scope_everyone"])
self.assertIsNone(response.context["scope_person"])
def test_a_lone_member_never_defaults_to_all(self):
# No child added -- managed_people is just [self.member].
self.client.force_login(self.user)
response = self._get("home")
self.assertFalse(response.context["scope_everyone"])
self.assertEqual(response.context["scope_person"], self.member)
def test_all_scope_aggregates_the_hero_across_every_managed_person(self):
child = self.add_child()
mine = self.make_event(title="Lars's practice", start=self.future)
theirs = self.make_event(title="Noor's game", start=self.future - datetime.timedelta(hours=1))
Attendance.objects.create(event=mine, member=self.member)
Attendance.objects.create(event=theirs, member=child)
self.client.force_login(self.user)
response = self._get("home")
self.assertEqual(response.context["hero_attendance"].event, theirs)
self.assertContains(response, "Noor")
def test_all_scope_combines_needs_answer_and_dues_across_everyone(self):
child = self.add_child()
mine = self.make_event(title="Lars's practice", start=self.future)
theirs = self.make_event(title="Noor's game", start=self.future + datetime.timedelta(days=1))
Attendance.objects.create(event=mine, member=self.member, status=Attendance.AttendanceStatus.NO_RESPONSE)
Attendance.objects.create(event=theirs, member=child, status=Attendance.AttendanceStatus.NO_RESPONSE)
child_membership = ClubMembership.objects.get(club=self.club, member=child, season=self.season)
child_membership.fee_amount = Decimal("100.00")
child_membership.save(update_fields=["fee_amount"])
self.client.force_login(self.user)
response = self._get("home")
needs_answer_events = {a.event for a in response.context["needs_answer"]}
self.assertEqual(needs_answer_events, {theirs}) # mine is the hero, excluded from the list
self.assertEqual(len(response.context["dues_rows"]), 1)
self.assertEqual(response.context["dues_rows"][0]["membership"].member, child)
def test_selecting_one_specific_person_narrows_back_to_just_them(self):
child = self.add_child()
theirs = self.make_event(title="Noor's game", start=self.future)
Attendance.objects.create(event=theirs, member=child, status=Attendance.AttendanceStatus.NO_RESPONSE)
self.client.force_login(self.user)
response = self._get(url=reverse("mobile:home") + f"?as={self.member.pk}")
self.assertFalse(response.context["scope_everyone"])
self.assertEqual(response.context["scope_person"], self.member)
self.assertEqual(list(response.context["needs_answer"]), [])
def test_as_all_explicitly_selects_everyone(self):
self.add_child()
self.client.force_login(self.user)
response = self._get(url=reverse("mobile:home") + "?as=all")
self.assertTrue(response.context["scope_everyone"])
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
class EventDetailRsvpTests(TestCase):
@@ -305,6 +402,25 @@ class EventDetailRsvpTests(TestCase):
response = self._post(self.event, {"status": "present", "member_id": str(stranger.pk)})
self.assertEqual(response.status_code, 400)
def test_explicit_member_id_still_works_once_all_is_the_default_scope(self):
# Home's hero form always sends an explicit member_id -- this must keep
# working once a second managed person makes "All" the default scope
# (scope_person is None in that case, so the old implicit fallback alone
# would no longer resolve who's RSVPing).
family = Family.objects.create(name="Bakker")
FamilyMembership.objects.create(family=family, member=self.member, role=FamilyMembership.FamilyRole.PARENT)
child = Member.objects.create(first_name="Noor", last_name="Bakker")
FamilyMembership.objects.create(family=family, member=child, role=FamilyMembership.FamilyRole.CHILD)
ClubMembership.objects.create(club=self.club, member=child, season=self.season)
child_attendance = Attendance.objects.create(event=self.event, member=child, status=Attendance.AttendanceStatus.NO_RESPONSE)
self.client.force_login(self.user)
response = self._post(self.event, {"status": "present", "member_id": str(child.pk)})
self.assertRedirects(response, reverse("mobile:home"), fetch_redirect_response=False)
child_attendance.refresh_from_db()
self.assertEqual(child_attendance.status, Attendance.AttendanceStatus.PRESENT)
self.attendance.refresh_from_db()
self.assertEqual(self.attendance.status, Attendance.AttendanceStatus.NO_RESPONSE)
@@ -414,6 +530,48 @@ class CalendarViewTests(TestCase):
self.assertNotIn(cancelled, self._events_in_context(response))
def add_child(self, first_name="Noor"):
family = Family.objects.create(name="Bakker")
FamilyMembership.objects.create(family=family, member=self.member, role=FamilyMembership.FamilyRole.PARENT)
child = Member.objects.create(first_name=first_name, last_name="Bakker")
FamilyMembership.objects.create(family=family, member=child, role=FamilyMembership.FamilyRole.CHILD)
ClubMembership.objects.create(club=self.club, member=child, season=self.season)
return child
def test_my_schedule_aggregates_across_every_managed_person_once_all_is_the_default(self):
child = self.add_child()
mine = self.make_event(title="Lars's practice")
theirs = self.make_event(title="Noor's game")
Attendance.objects.create(event=mine, member=self.member)
Attendance.objects.create(event=theirs, member=child)
self.client.force_login(self.user)
response = self._get()
self.assertEqual(self._events_in_context(response), {mine, theirs})
self.assertContains(response, "Noor")
def test_selecting_one_person_narrows_my_schedule_back_to_just_them(self):
child = self.add_child()
mine = self.make_event(title="Lars's practice")
theirs = self.make_event(title="Noor's game")
Attendance.objects.create(event=mine, member=self.member)
Attendance.objects.create(event=theirs, member=child)
self.client.force_login(self.user)
response = self._get(**{"as": self.member.pk})
self.assertEqual(self._events_in_context(response), {mine})
def test_my_schedule_with_no_managed_people_shows_the_empty_state_not_a_500(self):
bare_user = User.objects.create_user(email="new@example.com", password="pw-secret-123")
self.client.force_login(bare_user)
response = self._get()
self.assertEqual(response.status_code, 200)
self.assertContains(response, "No one to show yet")
def test_events_outside_the_two_week_window_are_excluded(self):
far_future = self.make_event(title="Far future game", start=timezone.now() + datetime.timedelta(days=30))
past = self.make_event(title="Past practice", start=timezone.now() - datetime.timedelta(days=1))

View File

@@ -139,11 +139,18 @@ class _PlaceholderScreen(PersonScopeMixin, LoginRequiredMixin, TemplateView):
class HomeView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
"""M1 -- design_handoff_rosterchief_platform/README.md's M1 section: a
hero card for scope_person's soonest upcoming event (with a quick In/Out
RSVP -- see EventDetailView.post below), a "needs your answer" list of
upcoming events still NO_RESPONSE/MAYBE, a season-dues card when money is
owed, and a news teaser. Every card is independently optional -- an
empty-state screen is just the four `if`s below all being falsy.
hero card for the soonest upcoming event across everyone currently in
scope (with a quick In/Out RSVP -- see EventDetailView.post below), a
"needs your answer" list of upcoming events still NO_RESPONSE/MAYBE, a
season-dues card per person who owes money, and a news teaser. Every
card is independently optional -- an empty-state screen is just the
four `if`s below all being falsy.
Scoped to ``self.people_in_scope`` (mobile/mixins.py), not just
``scope_person`` -- with the header's "All" chip now the default the
moment there's more than one managed person, this screen aggregates
across everyone in that case rather than showing only one person's
cards.
"""
template_name = "mobile/home.html"
@@ -151,24 +158,22 @@ class HomeView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
active_tab = "home"
def get_context_data(self, **kwargs):
scope_person = self.scope_person
people = self.people_in_scope
now = timezone.now()
hero_attendance = None
rsvp_closed = False
needs_answer = []
dues_membership = None
dues_balance = None
dues_invoice = None
dues_rows = []
news_item = None
if scope_person is not None:
if people:
upcoming = Attendance.objects.filter(
member=scope_person,
member__in=people,
event__club=self.request.club,
event__cancelled=False,
event__start__gte=now,
).select_related("event", "event__location")
).select_related("event", "event__location", "member")
hero_attendance = upcoming.order_by("event__start").first()
if hero_attendance is not None:
@@ -182,20 +187,17 @@ class HomeView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
season = current_season(self.request.club)
if season is not None:
membership = (
ClubMembership.objects.filter(club=self.request.club, member=scope_person, season=season)
memberships = (
ClubMembership.objects.filter(club=self.request.club, member__in=people, season=season)
.exclude(fee_status=ClubMembership.FeeStatus.WAIVED)
.select_related("dues_invoice")
.first()
.select_related("dues_invoice", "member")
)
if membership is not None:
for membership in memberships:
balance = remaining_balance(membership)
if balance > 0:
dues_membership = membership
dues_balance = balance
dues_invoice = getattr(membership, "dues_invoice", None)
dues_rows.append({"membership": membership, "balance": balance, "invoice": getattr(membership, "dues_invoice", None)})
team_ids = list(TeamMembership.objects.filter(member=scope_person, season=season).values_list("team_id", flat=True))
team_ids = list(TeamMembership.objects.filter(member__in=people, season=season).values_list("team_id", flat=True))
else:
team_ids = []
@@ -211,9 +213,7 @@ class HomeView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
hero_attendance=hero_attendance,
rsvp_closed=rsvp_closed,
needs_answer=needs_answer,
dues_membership=dues_membership,
dues_balance=dues_balance,
dues_invoice=dues_invoice,
dues_rows=dues_rows,
news_item=news_item,
news_team=news_item.teams.first() if news_item is not None else None,
**kwargs,
@@ -266,20 +266,25 @@ class CalendarView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
.order_by("start")
)
rows = [{"event": event, "pill_class": "pill-info", "pill_label": event.get_kind_display()} for event in events]
elif self.scope_person is not None:
elif self.people_in_scope:
attendances = (
Attendance.objects.filter(
member=self.scope_person,
member__in=self.people_in_scope,
event__club=self.request.club,
event__cancelled=False,
event__start__gte=now,
event__start__lte=window_end,
)
.select_related("event", "event__location", "event__opponent")
.select_related("event", "event__location", "event__opponent", "member")
.prefetch_related("event__teams")
.order_by("event__start")
)
rows = [{"event": attendance.event, "pill_class": self.STATUS_PILL_CLASSES.get(attendance.status, "pill-neutral"), "pill_label": attendance.get_status_display()} for attendance in attendances]
# Only worth naming whose row it is once "everyone" is aggregating more
# than one person -- a single scoped person's own agenda doesn't need it.
rows = [
{"event": attendance.event, "pill_class": self.STATUS_PILL_CLASSES.get(attendance.status, "pill-neutral"), "pill_label": attendance.get_status_display(), "member": attendance.member if self.scope_everyone else None}
for attendance in attendances
]
this_week, next_week = [], []
for row in rows:
@@ -363,7 +368,10 @@ class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
if status not in (Attendance.AttendanceStatus.PRESENT, Attendance.AttendanceStatus.ABSENT, Attendance.AttendanceStatus.MAYBE):
return HttpResponseBadRequest(_("Unknown RSVP status."))
member_id = request.POST.get("member_id") or (str(self.scope_person.pk) if self.scope_person else None)
# Every current caller (Home's hero, M2's per-person rows) always sends an
# explicit member_id -- this is just a defensive fallback for one that doesn't.
fallback_member = self.scope_person or self.me
member_id = request.POST.get("member_id") or (str(fallback_member.pk) if fallback_member else None)
member = next((person for person in self.managed_people if str(person.pk) == member_id), None) if member_id else None
if member is None:
return HttpResponseBadRequest(_("You can't RSVP for that person."))