Highlight Squad on the Add player/staff screens, add staff removal

- CoachAddPlayerView/CoachAddStaffView now set active_tab = "coach_squad"
  instead of "coach_today" -- both are reached from Squad's own "Add"
  buttons, so that's the tab that should stay highlighted while there.
- Squad screen: each staff row (other than your own) gets a remove button,
  mirroring the roster's own. Self-removal stays a desktop-only action
  (management.views.TeamStaffRemoveView) -- doing it from here would strand
  a coach off a team they're actively viewing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 13:56:55 +02:00
parent 6e3de13fd8
commit 499f65ef5b
4 changed files with 120 additions and 2 deletions

View File

@@ -457,7 +457,7 @@ class CoachAddPlayerView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
template_name = "mobile/coach/add_player.html" template_name = "mobile/coach/add_player.html"
screen_title = _("Add players") screen_title = _("Add players")
active_tab = "coach_today" active_tab = "coach_squad"
#: ?filter= values this screen understands -- anything else (including no #: ?filter= values this screen understands -- anything else (including no
#: param at all) means "All". #: param at all) means "All".
@@ -540,7 +540,7 @@ class CoachAddStaffView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
template_name = "mobile/coach/add_staff.html" template_name = "mobile/coach/add_staff.html"
screen_title = _("Add staff") screen_title = _("Add staff")
active_tab = "coach_today" active_tab = "coach_squad"
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
if not self.can_manage_active_team: if not self.can_manage_active_team:
@@ -794,6 +794,30 @@ class CoachRosterRemoveView(CoachScopeMixin, LoginRequiredMixin, View):
return HttpResponseRedirect(reverse("mobile:coach_squad")) return HttpResponseRedirect(reverse("mobile:coach_squad"))
class CoachStaffRemoveView(CoachScopeMixin, LoginRequiredMixin, View):
"""Squad screen's per-staff-row remove action. A manager can't remove
their own StaffAssignment this way -- self-removal would strand them off
a team they're actively viewing, with no one obviously left to undo it;
that stays a desktop-only action (management.views.TeamStaffRemoveView),
which any *other* admin/manager can still reach."""
def post(self, request, *args, **kwargs):
if self.active_team is None:
raise Http404
if not self.can_manage_active_team:
return HttpResponseForbidden()
assignment = get_object_or_404(StaffAssignment.objects.filter(team=self.active_team), pk=kwargs["assignment_pk"])
if self.me is not None and assignment.member_id == self.me.pk:
return HttpResponseForbidden()
member = assignment.member
assignment.delete()
notify(request, f"w|{_('Staff removed')}|" + _("%(member)s” removed from staff.") % {"member": member})
return HttpResponseRedirect(reverse("mobile:coach_squad"))
class CoachScheduleView(CoachScopeMixin, LoginRequiredMixin, TemplateView): class CoachScheduleView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
"""Bottom-tab "Schedule" -- every upcoming event for the active team, full """Bottom-tab "Schedule" -- every upcoming event for the active team, full
stop (not Today's own "just the next session" scope). Each row jumps stop (not Today's own "just the next session" scope). Each row jumps

View File

@@ -60,6 +60,15 @@
<div class="text-[15px] font-semibold text-ink">{{ assignment.member.get_full_name }}</div> <div class="text-[15px] font-semibold text-ink">{{ assignment.member.get_full_name }}</div>
<div class="text-xs text-muted">{{ assignment.position }}</div> <div class="text-xs text-muted">{{ assignment.position }}</div>
</div> </div>
{% if can_manage_active_team and assignment.member_id != me.pk %}
{% trans "Remove this staff member?" as remove_staff_confirm_text %}
<form method="post" action="{% url "mobile:coach_staff_remove" assignment.pk %}" hx-boost="false" onsubmit="return confirm('{{ remove_staff_confirm_text|escapejs }}')">
{% csrf_token %}
<button class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-club" type="submit" aria-label="{% trans "Remove" %}">
{% lucide "trash-2" size=16 %}
</button>
</form>
{% endif %}
</div> </div>
{% empty %} {% empty %}
<div class="p-6 text-center text-sm text-muted">{% trans "No staff assigned for this season yet." %}</div> <div class="p-6 text-center text-sm text-muted">{% trans "No staff assigned for this season yet." %}</div>

View File

@@ -2879,6 +2879,76 @@ class CoachRosterRemoveViewTests(TestCase):
self.assertTrue(TeamMembership.objects.filter(pk=self.membership.pk).exists()) self.assertTrue(TeamMembership.objects.filter(pk=self.membership.pk).exists())
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
class CoachStaffRemoveViewTests(TestCase):
"""Squad screen's per-staff-row remove action -- see
CoachStaffRemoveView's own docstring for why self-removal is refused."""
@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.coach_position = Position.objects.create(club=cls.club, name="Head coach", short_name="HC", staff_position=True, management_position=True)
cls.own_assignment = StaffAssignment.objects.create(team=cls.team, member=cls.member, season=cls.season, position=cls.coach_position)
cls.assistant_position = Position.objects.create(club=cls.club, name="Assistant coach", short_name="AC", staff_position=True, management_position=False)
cls.assistant = Member.objects.create(first_name="Ali", last_name="Assistant")
cls.assignment = StaffAssignment.objects.create(team=cls.team, member=cls.assistant, season=cls.season, position=cls.assistant_position)
def _post(self, assignment):
return self.client.post(reverse("mobile:coach_staff_remove", kwargs={"assignment_pk": assignment.pk}), HTTP_HOST="ajax-united.rosterchief.app")
def test_removes_another_staff_member(self):
self.client.force_login(self.user)
response = self._post(self.assignment)
self.assertRedirects(response, reverse("mobile:coach_squad"), fetch_redirect_response=False)
self.assertFalse(StaffAssignment.objects.filter(pk=self.assignment.pk).exists())
def test_cannot_remove_yourself(self):
self.client.force_login(self.user)
response = self._post(self.own_assignment)
self.assertEqual(response.status_code, 403)
self.assertTrue(StaffAssignment.objects.filter(pk=self.own_assignment.pk).exists())
def test_non_managing_staff_cannot_remove_anyone(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.assignment)
self.assertEqual(response.status_code, 403)
self.assertTrue(StaffAssignment.objects.filter(pk=self.assignment.pk).exists())
def test_the_remove_button_is_hidden_on_your_own_row(self):
self.client.force_login(self.user)
response = self.client.get(reverse("mobile:coach_squad"), HTTP_HOST="ajax-united.rosterchief.app")
self.assertNotContains(response, reverse("mobile:coach_staff_remove", kwargs={"assignment_pk": self.own_assignment.pk}))
self.assertContains(response, reverse("mobile:coach_staff_remove", kwargs={"assignment_pk": self.assignment.pk}))
def test_an_assignment_from_another_team_is_not_reachable(self):
other_team = Team.objects.create(club=self.club, name="U14", short_name="U14")
other_position = Position.objects.create(club=self.club, name="Coach", short_name="C", staff_position=True, management_position=True)
other_assignment = StaffAssignment.objects.create(team=other_team, member=Member.objects.create(first_name="Other", last_name="Team"), season=self.season, position=other_position)
self.client.force_login(self.user)
response = self._post(other_assignment)
self.assertEqual(response.status_code, 404)
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"]) @override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
class CoachAddStaffViewTests(TestCase): class CoachAddStaffViewTests(TestCase):
"""Squad screen's staff "Add" entry point -- see CoachAddStaffView's own """Squad screen's staff "Add" entry point -- see CoachAddStaffView's own
@@ -2906,6 +2976,13 @@ class CoachAddStaffViewTests(TestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
def test_the_squad_tab_is_highlighted(self):
self.client.force_login(self.user)
response = self.client.get(reverse("mobile:coach_add_staff"), HTTP_HOST="ajax-united.rosterchief.app")
self.assertEqual(response.context["active_tab"], "coach_squad")
def test_get_redirects_a_non_managing_staffer(self): def test_get_redirects_a_non_managing_staffer(self):
physio_position = Position.objects.create(club=self.club, name="Physio", short_name="PHY", staff_position=True, management_position=False) 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_user = User.objects.create_user(email="physio@example.com", password="pw-secret-123")
@@ -3487,6 +3564,13 @@ class CoachAddPlayerViewTests(TestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
def test_the_squad_tab_is_highlighted(self):
self.client.force_login(self.user)
response = self.client.get(reverse("mobile:coach_add_player"), HTTP_HOST="ajax-united.rosterchief.app")
self.assertEqual(response.context["active_tab"], "coach_squad")
def test_get_redirects_a_non_managing_staffer(self): def test_get_redirects_a_non_managing_staffer(self):
physio_position = Position.objects.create(club=self.club, name="Physio", short_name="PHY", staff_position=True, management_position=False) 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_user = User.objects.create_user(email="physio@example.com", password="pw-secret-123")

View File

@@ -35,6 +35,7 @@ urlpatterns = [
path("coach/news/new/", coach_views.CoachCreateNewsView.as_view(), name="coach_create_news"), 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"), path("coach/roster/add/", coach_views.CoachAddPlayerView.as_view(), name="coach_add_player"),
path("coach/staff/add/", coach_views.CoachAddStaffView.as_view(), name="coach_add_staff"), path("coach/staff/add/", coach_views.CoachAddStaffView.as_view(), name="coach_add_staff"),
path("coach/staff/<uuid:assignment_pk>/remove/", coach_views.CoachStaffRemoveView.as_view(), name="coach_staff_remove"),
path("coach/lineup/<uuid:event_id>/", coach_views.CoachLineupView.as_view(), name="coach_lineup"), path("coach/lineup/<uuid:event_id>/", coach_views.CoachLineupView.as_view(), name="coach_lineup"),
path("coach/lineup/<uuid:event_id>/publish/", coach_views.CoachLineupPublishView.as_view(), name="coach_lineup_publish"), path("coach/lineup/<uuid:event_id>/publish/", coach_views.CoachLineupPublishView.as_view(), name="coach_lineup_publish"),
] ]