diff --git a/mobile/templates/mobile/_calendar_referee_row.html b/mobile/templates/mobile/_calendar_referee_row.html index 34b2b3b..70dfdde 100644 --- a/mobile/templates/mobile/_calendar_referee_row.html +++ b/mobile/templates/mobile/_calendar_referee_row.html @@ -1,20 +1,22 @@ {% load i18n lucide %} {% comment %} One referee sign-up row on M3's Calendar -- a home game the signed-in - account is eligible (and, once responded, confirmed) to referee for. - Merged into the same chronological list as the account's own RSVP rows - (mobile/_calendar_row.html), but in the referee accent (assets/mobile. - css's --color-referee/.pill-referee) so it reads as a different kind of - commitment at a glance -- and scoped to self.me only (mobile/views.py's - CalendarView), never managed_people, since a referee is an adult acting - on their own behalf, not something a parent does for a child. + account (or a managed person -- a referee-eligible child is exactly as + real as a referee-eligible parent, mobile/views.py's CalendarView) is + eligible (and, once responded, confirmed) to referee for. Merged into + the same chronological list as the account's own RSVP rows (mobile/ + _calendar_row.html), but in the referee accent (assets/mobile.css's + --color-referee/.pill-referee) so it reads as a different kind of + commitment at a glance. - Expects ``row`` ({event, referee_signup}) in scope. Accept/Decline are - two small forms, not a single multi-button one -- and, unlike this - app's RSVP forms, boosted (no hx-boost="false"): nothing here is - Alpine-owned/toggled, so there's no htmx/Alpine conflict to dodge, and a - boosted POST avoids the jarring full-page reload a plain form submit - would cause on a page the user is mid-scroll on. + Expects ``row`` ({event, referee_signup, referee_member}) in scope -- + referee_member is only set once there's more than one managed person to + tell apart, same rule mobile/_calendar_row.html's own ``member`` uses. + Accept/Decline are two small forms, not a single multi-button one -- + and, unlike this app's RSVP forms, boosted (no hx-boost="false"): + nothing here is Alpine-owned/toggled, so there's no htmx/Alpine conflict + to dodge, and a boosted POST avoids the jarring full-page reload a plain + form submit would cause on a page the user is mid-scroll on. {% endcomment %}
@@ -29,6 +31,7 @@
{{ row.event.start|date:"H:i" }} + {% if row.referee_member %}· {{ row.referee_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 4b7379f..2a72a5d 100644 --- a/mobile/templates/mobile/base.html +++ b/mobile/templates/mobile/base.html @@ -70,7 +70,7 @@ {% block extra_head %}{% endblock extra_head %} - +
-
+
{% if messages %}
diff --git a/mobile/templates/mobile/event_detail.html b/mobile/templates/mobile/event_detail.html index 48a2a2d..9dc052b 100644 --- a/mobile/templates/mobile/event_detail.html +++ b/mobile/templates/mobile/event_detail.html @@ -58,31 +58,36 @@ {% endif %}
- {% if referee_signup %} + {% if referee_signups %}
-
- {% trans "Refereeing" %} - {% if referee_signup.status == "accepted" %}{% trans "Confirmed" %}{% endif %} + {% trans "Refereeing" %} +
+ {% for signup in referee_signups %} + {% if not forloop.first %}
{% endif %} +
+
+ {{ signup.member.get_full_name }} + {% if signup.status == "accepted" %}{% trans "Confirmed" %}{% endif %} +
+ {% if signup.status != "accepted" %} +
+
+ {% csrf_token %} + + + +
+
+ {% csrf_token %} + + + +
+
+ {% endif %} +
+ {% endfor %}
- {% if referee_signup.status == "accepted" %} -

{% trans "You're confirmed to referee this game." %}

- {% else %} -

{% trans "You're eligible to referee this game -- are you in?" %}

-
-
- {% csrf_token %} - - - -
-
- {% csrf_token %} - - - -
-
- {% endif %}
{% endif %} @@ -226,7 +231,7 @@
{% endif %} - {% if not your_answers and not squad_summary and not lineup and not referee_signup %} + {% if not your_answers and not squad_summary and not lineup and not referee_signups %}

{% trans "No one you manage is invited to this event." %}

diff --git a/mobile/tests.py b/mobile/tests.py index 7283750..0ed2148 100644 --- a/mobile/tests.py +++ b/mobile/tests.py @@ -1010,9 +1010,9 @@ class CalendarViewTests(TestCase): @override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"]) class CalendarRefereeSignupTests(TestCase): - """M3's Calendar merges in the signed-in account's own referee sign-ups - (self.me only, never managed_people) -- see mobile/_calendar_referee_row. - html and CalendarView's own docstring.""" + """M3's Calendar merges in every managed person's referee sign-ups, not + just self.me -- see mobile/_calendar_referee_row.html and CalendarView's + own docstring.""" @classmethod def setUpTestData(cls): @@ -1074,6 +1074,22 @@ class CalendarRefereeSignupTests(TestCase): self.assertNotContains(response, "I'll ref") + def test_a_managed_childs_invite_shows_up_and_names_them(self): + family = Family.objects.create(name="Eree") + FamilyMembership.objects.create(family=family, member=self.member, role=FamilyMembership.FamilyRole.PARENT) + child = Member.objects.create(first_name="Kid", last_name="Eree") + FamilyMembership.objects.create(family=family, member=child, role=FamilyMembership.FamilyRole.CHILD) + ClubMembership.objects.create(club=self.club, member=child, season=self.season) + RefereeProfile.objects.create(member=child, level=self.level, valid_until=timezone.localdate() + datetime.timedelta(days=30)) + other_game = Event.objects.create(club=self.club, title="Second game", kind=Event.EventKind.GAME, location=self.home_ground, start=timezone.now() + datetime.timedelta(days=2)) + other_game.teams.add(self.team) + self.client.force_login(self.user) + + response = self._get() + + self.assertTrue(RefereeSignup.objects.filter(event=other_game, member=child).exists()) + self.assertContains(response, "Kid") + @override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"]) class RefereeSignupRespondViewTests(TestCase): @@ -1153,6 +1169,21 @@ class RefereeSignupRespondViewTests(TestCase): self.assertEqual(response.status_code, 404) + def test_a_parent_can_respond_on_behalf_of_a_managed_child(self): + family = Family.objects.create(name="Eree") + FamilyMembership.objects.create(family=family, member=self.member, role=FamilyMembership.FamilyRole.PARENT) + child = Member.objects.create(first_name="Kid", last_name="Eree") + FamilyMembership.objects.create(family=family, member=child, role=FamilyMembership.FamilyRole.CHILD) + ClubMembership.objects.create(club=self.club, member=child, season=self.season) + RefereeProfile.objects.create(member=child, level=self.level, valid_until=timezone.localdate() + datetime.timedelta(days=30)) + child_signup = RefereeSignup.objects.create(event=self.game, member=child, status=RefereeSignup.Status.INVITED) + self.client.force_login(self.user) + + response = self.client.post(reverse("mobile:referee_signup_respond", kwargs={"signup_id": child_signup.pk}), {"response": "accept"}, HTTP_HOST="ajax-united.rosterchief.app") + + self.assertRedirects(response, reverse("mobile:calendar"), fetch_redirect_response=False) + self.assertTrue(EventReferee.objects.filter(event=self.game, member=child).exists()) + @override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"]) class EventDetailScreenTests(TestCase): @@ -1311,7 +1342,7 @@ class EventDetailScreenTests(TestCase): response = self._get() - self.assertIsNone(response.context["referee_signup"]) + self.assertEqual(list(response.context["referee_signups"]), []) self.assertNotContains(response, "Refereeing") def test_pending_referee_invite_shows_accept_decline(self): @@ -1339,9 +1370,24 @@ class EventDetailScreenTests(TestCase): response = self._get() - self.assertIsNone(response.context["referee_signup"]) + self.assertEqual(list(response.context["referee_signups"]), []) self.assertNotContains(response, "Refereeing") + def test_a_managed_childs_referee_invite_shows_up_too(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=self.season) + RefereeSignup.objects.create(event=self.event, member=child, status=RefereeSignup.Status.INVITED) + self.client.force_login(self.user) + + response = self._get() + + self.assertContains(response, "Refereeing") + self.assertContains(response, "Noor Bakker") + self.assertContains(response, "I'll ref") + 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") diff --git a/mobile/views.py b/mobile/views.py index 2464834..86055b7 100644 --- a/mobile/views.py +++ b/mobile/views.py @@ -349,28 +349,29 @@ class CalendarView(PersonScopeMixin, LoginRequiredMixin, TemplateView): for attendance in attendances ] - # Referee sign-ups are scoped to self.me only, never managed_people -- - # a referee is an adult with their own account, not something a - # parent does on a child's behalf, unlike every RSVP row above. - # Merged into the same chronological list (own dict shape, no - # pill_class/member) rather than a separate section, so it reads on - # the actual day it falls on -- distinct styling is what sets it - # apart (mobile/_calendar_referee_row.html), not a different place - # on the screen. Declined invites are dropped; accepted ones stay - # visible as a confirmed commitment. - if self.me is not None and kind_filter != "training": + # Referee sign-ups are scoped to every managed person, same as the + # RSVP rows above -- a referee-eligible child is exactly as real as + # a referee-eligible parent, and a parent signing a kid up to + # referee is no different from answering an RSVP on their behalf. + # Merged into the same chronological list (own dict shape) rather + # than a separate section, so it reads on the actual day it falls + # on -- distinct styling is what sets it apart (mobile/ + # _calendar_referee_row.html), not a different place on the screen. + # Declined invites are dropped; accepted ones stay visible as a + # confirmed commitment. + if self.managed_people and kind_filter != "training": signups = ( RefereeSignup.objects.filter( - member=self.me, + member__in=self.managed_people, event__club=self.request.club, event__cancelled=False, event__start__gte=now, status__in=[RefereeSignup.Status.INVITED, RefereeSignup.Status.ACCEPTED], ) - .select_related("event", "event__location", "event__opponent") + .select_related("event", "event__location", "event__opponent", "member") .prefetch_related("event__teams") ) - rows += [{"event": signup.event, "referee_signup": signup} for signup in signups] + rows += [{"event": signup.event, "referee_signup": signup, "referee_member": signup.member if show_member else None} for signup in signups] rows.sort(key=lambda row: row["event"].start) @@ -395,29 +396,33 @@ class CalendarView(PersonScopeMixin, LoginRequiredMixin, TemplateView): class RefereeSignupRespondView(PersonScopeMixin, LoginRequiredMixin, View): - """Accept/decline a referee invite -- from a Calendar row - (mobile/_calendar_referee_row.html) or the same event's own detail page - (event_detail.html's own "Refereeing" card, for the same signup). Routes - through events.services.referees.accept_referee_signup/ - decline_referee_signup, so capacity is enforced in the one place the - desktop admin flow already enforces it, and the referee-management - screen sees the result with no separate sync step. Boosted (no explicit - hx-boost="false") -- unlike event_detail's own RSVP forms, nothing here - is Alpine-owned/toggled, so there's no htmx/Alpine conflict to dodge.""" + """Accept/decline a referee invite, for self.me or any managed person + (a referee-eligible child is exactly as real as a referee-eligible + parent) -- from a Calendar row (mobile/_calendar_referee_row.html) or + the same event's own detail page (event_detail.html's own "Refereeing" + card, for the same signup). Routes through events.services.referees. + accept_referee_signup/decline_referee_signup, so capacity is enforced + in the one place the desktop admin flow already enforces it, and the + referee-management screen sees the result with no separate sync step. + Boosted (no explicit hx-boost="false") -- unlike event_detail's own + RSVP forms, nothing here is Alpine-owned/toggled, so there's no + htmx/Alpine conflict to dodge.""" def post(self, request, *args, **kwargs): - signup = get_object_or_404(RefereeSignup, pk=kwargs["signup_id"], member=self.me, event__club=request.club) + signup = get_object_or_404(RefereeSignup, pk=kwargs["signup_id"], member__in=self.managed_people, event__club=request.club) response = request.POST.get("response") if response == "accept": try: accept_referee_signup(signup) - notify(request, f"s|{_('Referee sign-up confirmed')}|{_('Thanks for signing up -- see you there.')}") + body = _("%(name)s is confirmed to referee -- see you there.") % {"name": signup.member.get_full_name()} + notify(request, f"s|{_('Referee sign-up confirmed')}|{body}") except RefereeAssignmentError as exc: - notify(request, f"e|{_('Could not sign you up')}|{exc}") + notify(request, f"e|{_('Could not sign up')}|{exc}") elif response == "decline": decline_referee_signup(signup) - notify(request, f"s|{_('Declined')}|{_('No problem -- thanks for letting us know.')}") + body = _("%(name)s won't be refereeing this one -- thanks for letting us know.") % {"name": signup.member.get_full_name()} + notify(request, f"s|{_('Declined')}|{body}") else: return HttpResponseBadRequest(_("Unknown response.")) @@ -461,13 +466,15 @@ class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView): lineup = Lineup.objects.filter(event=event, published_at__isnull=False).first() lineup_categories = selected_members_by_position(lineup) if lineup is not None else [] - # Same self.me-only scope as the Calendar row this mirrors - # (mobile/_calendar_referee_row.html) -- a referee is an adult - # acting on their own behalf, never something a parent does for a - # managed child. Declined signups are excluded -- nothing left to do. - referee_signup = None - if self.me is not None: - referee_signup = RefereeSignup.objects.filter(event=event, member=self.me, status__in=[RefereeSignup.Status.INVITED, RefereeSignup.Status.ACCEPTED]).first() + # Same managed_people scope as the Calendar row this mirrors (mobile/ + # _calendar_referee_row.html) -- a referee-eligible child is exactly + # as real as a referee-eligible parent. Declined signups are + # excluded -- nothing left to do. Rare in practice (a game usually + # has one eligible referee per family, if any), so this is a plain + # list rather than the "Your answers" card's counts-only aggregate. + referee_signups = [] + if self.managed_people: + referee_signups = list(RefereeSignup.objects.filter(event=event, member__in=self.managed_people, status__in=[RefereeSignup.Status.INVITED, RefereeSignup.Status.ACCEPTED]).select_related("member")) your_answers = [] if self.managed_people: @@ -513,7 +520,7 @@ class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView): rsvp_closed=rsvp_closed, lineup=lineup, lineup_categories=lineup_categories, - referee_signup=referee_signup, + referee_signups=referee_signups, your_answers=your_answers, squad_summary=squad_summary, **kwargs,