diff --git a/mobile/mixins.py b/mobile/mixins.py index 3be406f..fd9d974 100644 --- a/mobile/mixins.py +++ b/mobile/mixins.py @@ -21,14 +21,24 @@ class PersonScopeMixin(ClubScopedPublicMixin): ``?as=`` 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), diff --git a/mobile/templates/mobile/_calendar_row.html b/mobile/templates/mobile/_calendar_row.html index 606cc70..ffe731a 100644 --- a/mobile/templates/mobile/_calendar_row.html +++ b/mobile/templates/mobile/_calendar_row.html @@ -18,6 +18,7 @@
{{ row.event.title }}
{{ row.event.start|date:"H:i" }} + {% if row.member %}· {{ row.member.first_name }}{% endif %} {% for team in row.event.teams.all %}· {{ team.name }}{% endfor %} {% if row.event.location %}· {{ row.event.location.name }}{% endif %}
diff --git a/mobile/templates/mobile/base.html b/mobile/templates/mobile/base.html index 7203342..2cf714b 100644 --- a/mobile/templates/mobile/base.html +++ b/mobile/templates/mobile/base.html @@ -73,8 +73,14 @@ {% if managed_people|length > 1 %}
+ + + + + {% trans "All" %} + {% for person in managed_people %} - + {{ person.first_name|slice:":1" }} {% if person == me %}{% trans "Me" %}{% else %}{{ person.first_name }}{% endif %} diff --git a/mobile/templates/mobile/calendar.html b/mobile/templates/mobile/calendar.html index c9e375c..3defe8f 100644 --- a/mobile/templates/mobile/calendar.html +++ b/mobile/templates/mobile/calendar.html @@ -22,7 +22,7 @@
- {% if not scope_all and not scope_person %} + {% if not scope_all and not managed_people %}

{% trans "No one to show yet" %}

{% trans "Once you're linked to a member record, their schedule will show up here." %}

diff --git a/mobile/templates/mobile/home.html b/mobile/templates/mobile/home.html index 99c93f4..2672ee1 100644 --- a/mobile/templates/mobile/home.html +++ b/mobile/templates/mobile/home.html @@ -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 %}
@@ -29,15 +32,17 @@

{% trans "Replies are closed for this event" %}

{{ hero_attendance.get_status_display }} {% else %} -

{% blocktrans with name=scope_person.first_name %}{{ name }} — are you in?{% endblocktrans %}

+

{% blocktrans with name=hero_attendance.member.first_name %}{{ name }} — are you in?{% endblocktrans %}

{% csrf_token %} +
{% csrf_token %} +
@@ -63,7 +68,7 @@
{{ attendance.event.title }}
-
{{ scope_person.first_name }} · {{ attendance.event.start|date:"H:i" }}
+
{{ attendance.member.first_name }} · {{ attendance.event.start|date:"H:i" }}
{% trans "Reply" %} @@ -72,22 +77,26 @@
{% endif %} - {% if dues_membership %} -
-
- -
-
-
{% blocktrans with name=scope_person.first_name %}Season dues — {{ name }}{% endblocktrans %}
-
- {% if dues_invoice %} - {% blocktrans with amount=dues_balance|floatformat:2 due=dues_invoice.due_date|date:"d M" %}€ {{ amount }} · due {{ due }}{% endblocktrans %} - {% else %} - {% blocktrans with amount=dues_balance|floatformat:2 %}€ {{ amount }}{% endblocktrans %} - {% endif %} + {% if dues_rows %} +
+ {% for row in dues_rows %} +
+
+ +
+
+
{% blocktrans with name=row.membership.member.first_name %}Season dues — {{ name }}{% endblocktrans %}
+
+ {% if row.invoice %} + {% blocktrans with amount=row.balance|floatformat:2 due=row.invoice.due_date|date:"d M" %}€ {{ amount }} · due {{ due }}{% endblocktrans %} + {% else %} + {% blocktrans with amount=row.balance|floatformat:2 %}€ {{ amount }}{% endblocktrans %} + {% endif %} +
+
+ {% trans "Pay" %}
-
- {% trans "Pay" %} + {% endfor %}
{% endif %} @@ -108,7 +117,7 @@
{% 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 %}

{% trans "Nothing to show right now." %}

diff --git a/mobile/tests.py b/mobile/tests.py index 0b863ad..a995a49 100644 --- a/mobile/tests.py +++ b/mobile/tests.py @@ -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)) diff --git a/mobile/views.py b/mobile/views.py index 447a189..9f57f3b 100644 --- a/mobile/views.py +++ b/mobile/views.py @@ -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."))