From cc9daabcfdfd103bb6f421c70ee63d03ccd6ed4f Mon Sep 17 00:00:00 2001 From: Bernard Siebens Date: Sun, 23 Aug 2026 13:40:57 +0200 Subject: [PATCH] Add a "Remind silent" button to the bench attendance sheet On-demand version of send_deadline_reminders' own NO_RESPONSE nudge, same title/body shape, for a coach who doesn't want to wait for that once-a-day sweep. Shown only when there's someone silent to remind, and only to whoever manages the team. Co-Authored-By: Claude Sonnet 5 --- mobile/coach_views.py | 30 +++++++ mobile/templates/mobile/coach/attendance.html | 9 ++ mobile/tests.py | 84 +++++++++++++++++++ mobile/urls.py | 1 + 4 files changed, 124 insertions(+) diff --git a/mobile/coach_views.py b/mobile/coach_views.py index 6be5edb..08de162 100644 --- a/mobile/coach_views.py +++ b/mobile/coach_views.py @@ -27,8 +27,10 @@ from events.services.attendance import member_attendance_counts, record_check_in from events.services.lineup import UNAVAILABLE_STATUSES, cancel_scheduled_publish, publish_lineup, schedule_lineup_publish, toggle_selection from events.tasks import notify_new_event from management.forms import EventForm, NewsForm +from members.models import Member from news.models import News from news.services import notify_editors_of_pending_review +from notifications.services import notify_members from teams.models import Position, StaffAssignment, Team, TeamMembership from teams.services import eligible_roster_members @@ -253,6 +255,34 @@ class CoachAttendanceView(CoachScopeMixin, LoginRequiredMixin, TemplateView): return HttpResponseRedirect(reverse("mobile:coach_today")) +class CoachAttendanceRemindSilentView(CoachScopeMixin, LoginRequiredMixin, View): + """Attendance sheet's "Remind silent" button -- an on-demand version of + events.tasks.send_deadline_reminders' own NO_RESPONSE nudge, for a coach + who doesn't want to wait for that once-a-day sweep. Same title/body shape + as that task, so a player who gets both isn't looking at two differently + worded pushes for the same event.""" + + def post(self, request, *args, **kwargs): + if self.active_team is None: + raise Http404 + if not self.can_manage_active_team: + return HttpResponseForbidden() + + event = get_object_or_404(Event, pk=kwargs["event_id"], club=request.club, teams=self.active_team) + member_ids = Attendance.objects.filter(event=event, status=Attendance.AttendanceStatus.NO_RESPONSE).values_list("member_id", flat=True) + members = list(Member.objects.filter(id__in=member_ids)) + + if members: + when = timezone.localtime(event.start).strftime("%a %d %b, %H:%M") + body = _("Reminder: %(kind)s on %(when)s still needs your answer.") % {"kind": event.get_kind_display(), "when": when} + notify_members(members, club=event.club, title=event.title, body=body, source=event) + notify(request, f"s|{_('Reminder sent')}|" + ngettext("%(count)d player nudged.", "%(count)d players nudged.", len(members)) % {"count": len(members)}) + else: + notify(request, f"w|{_('Nobody to remind')}|{_('Everyone has already responded.')}") + + return HttpResponseRedirect(reverse("mobile:coach_attendance", kwargs={"event_id": event.pk})) + + class CoachCreateEventView(CoachScopeMixin, LoginRequiredMixin, TemplateView): """C4 -- reuses management.forms.EventForm as-is: its own __init__ already scopes ``teams`` to teams_managed_by(user, club) via EventAudienceFormMixin, diff --git a/mobile/templates/mobile/coach/attendance.html b/mobile/templates/mobile/coach/attendance.html index 972e86e..247b38e 100644 --- a/mobile/templates/mobile/coach/attendance.html +++ b/mobile/templates/mobile/coach/attendance.html @@ -36,6 +36,15 @@ + {% if can_manage_active_team and silent_count > 0 %} +
+ {% csrf_token %} + +
+ {% endif %} +
{% csrf_token %}
diff --git a/mobile/tests.py b/mobile/tests.py index 7693321..e6249f2 100644 --- a/mobile/tests.py +++ b/mobile/tests.py @@ -3211,6 +3211,90 @@ class CoachAttendanceViewTests(TestCase): self.assertEqual(response.status_code, 404) +@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"]) +class CoachAttendanceRemindSilentViewTests(TestCase): + """Attendance sheet's "Remind silent" button -- see + CoachAttendanceRemindSilentView's own docstring.""" + + @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="coach@example.com", password="pw-secret-123") + cls.member = Member.objects.create(first_name="Sam", last_name="Coach", email="coach@example.com", user=cls.user) + cls.team = Team.objects.create(club=cls.club, name="U16", short_name="U16") + cls.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.member, season=cls.season, position=cls.position) + + cls.silent_member = Member.objects.create(first_name="Ben", last_name="Silent") + TeamMembership.objects.create(team=cls.team, member=cls.silent_member, season=cls.season) + cls.event = Event.objects.create(club=cls.club, title="Practice", kind=Event.EventKind.TRAINING, start=timezone.now() + datetime.timedelta(hours=2)) + cls.event.teams.add(cls.team) + Attendance.objects.update_or_create(event=cls.event, member=cls.silent_member, defaults={"status": Attendance.AttendanceStatus.NO_RESPONSE}) + + def _post(self, event=None): + event = event or self.event + return self.client.post(reverse("mobile:coach_attendance_remind_silent", kwargs={"event_id": event.pk}), HTTP_HOST="ajax-united.rosterchief.app") + + def test_requires_login(self): + response = self._post() + + self.assertEqual(response.status_code, 302) + + def test_notifies_every_silent_member(self): + self.client.force_login(self.user) + + response = self._post() + + self.assertRedirects(response, reverse("mobile:coach_attendance", kwargs={"event_id": self.event.pk}), fetch_redirect_response=False) + self.assertTrue(Notification.objects.filter(club=self.club, member=self.silent_member, title=self.event.title).exists()) + + def test_does_not_notify_someone_who_already_responded(self): + responded_member = Member.objects.create(first_name="Anna", last_name="Player") + TeamMembership.objects.create(team=self.team, member=responded_member, season=self.season) + Attendance.objects.update_or_create(event=self.event, member=responded_member, defaults={"status": Attendance.AttendanceStatus.PRESENT}) + self.client.force_login(self.user) + + self._post() + + # Filtered by title (the reminder's own, matching self.event.title) rather + # than just member= -- creating the TeamMembership above also fires + # notify_newly_invited's own, differently-titled "new events" push, which + # a plain member= filter would otherwise conflate with this one. + self.assertFalse(Notification.objects.filter(member=responded_member, title=self.event.title).exists()) + + def test_no_silent_members_is_a_no_op(self): + Attendance.objects.filter(event=self.event).update(status=Attendance.AttendanceStatus.PRESENT) + self.client.force_login(self.user) + + self._post() + + self.assertFalse(Notification.objects.filter(club=self.club).exists()) + + def test_non_managing_staff_cannot_send_a_reminder(self): + physio_position = Position.objects.create(club=self.club, name="Physio", short_name="PHY", staff_position=True, management_position=False) + physio_user = User.objects.create_user(email="physio@example.com", password="pw-secret-123") + physio_member = Member.objects.create(first_name="Pat", last_name="Physio", user=physio_user) + StaffAssignment.objects.create(team=self.team, member=physio_member, season=self.season, position=physio_position) + self.client.force_login(physio_user) + + response = self._post() + + self.assertEqual(response.status_code, 403) + self.assertFalse(Notification.objects.filter(member=self.silent_member).exists()) + + def test_event_from_another_team_is_not_reachable(self): + other_team = Team.objects.create(club=self.club, name="U14", short_name="U14") + other_event = Event.objects.create(club=self.club, title="Other practice", kind=Event.EventKind.TRAINING, start=timezone.now() + datetime.timedelta(hours=2)) + other_event.teams.add(other_team) + self.client.force_login(self.user) + + response = self._post(other_event) + + self.assertEqual(response.status_code, 404) + + @override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"]) class CoachCreateEventViewTests(TestCase): """C4 -- reuses management.forms.EventForm as-is; see CoachCreateEventView's diff --git a/mobile/urls.py b/mobile/urls.py index be21207..7caa43e 100644 --- a/mobile/urls.py +++ b/mobile/urls.py @@ -30,6 +30,7 @@ urlpatterns = [ path("coach/squad//remove/", coach_views.CoachRosterRemoveView.as_view(), name="coach_roster_remove"), path("coach/schedule/", coach_views.CoachScheduleView.as_view(), name="coach_schedule"), path("coach/attendance//", coach_views.CoachAttendanceView.as_view(), name="coach_attendance"), + path("coach/attendance//remind-silent/", coach_views.CoachAttendanceRemindSilentView.as_view(), name="coach_attendance_remind_silent"), path("coach/events/new/", coach_views.CoachCreateEventView.as_view(), name="coach_create_event"), path("coach/news/new/", coach_views.CoachCreateNewsView.as_view(), name="coach_create_news"), path("coach/roster/add/", coach_views.CoachAddPlayerView.as_view(), name="coach_add_player"),