diff --git a/management/context_processors.py b/management/context_processors.py index b52b7da..a6958c3 100644 --- a/management/context_processors.py +++ b/management/context_processors.py @@ -48,8 +48,12 @@ _NAV_SECTIONS = { "team_update": "team_list", "team_delete": "team_list", "team_detail": "team_list", - "roster_list": "roster_list", - "staff_list": "staff_list", + "team_roster_add": "team_list", + "team_roster_update": "team_list", + "team_roster_remove": "team_list", + "team_staff_add": "team_list", + "team_staff_update": "team_list", + "team_staff_remove": "team_list", "news_list": "news_list", "news_create": "news_list", "news_detail": "news_list", diff --git a/management/forms.py b/management/forms.py index bf50e65..3fe5f60 100644 --- a/management/forms.py +++ b/management/forms.py @@ -9,7 +9,7 @@ from club.models import ClubMembership, ClubRole, FeePayment from members.models import Family, FamilyMembership, Member from members.services.family import find_member_by_email from news.models import News -from teams.models import Position, Team +from teams.models import Position, StaffAssignment, Team, TeamMembership User = get_user_model() @@ -27,6 +27,46 @@ class TeamForm(forms.ModelForm): fields = ["name", "short_name"] +class TeamMembershipForm(forms.ModelForm): + """Add/edit one roster entry -- team and season come from the view (the URL + already identifies both), never from the form itself.""" + + class Meta: + model = TeamMembership + fields = ["member", "position", "jersey_number", "is_captain", "is_alternate_captain"] + widgets = {"member": forms.Select(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a name to search...")})} + + def __init__(self, *args, club=None, team=None, season=None, **kwargs): + super().__init__(*args, **kwargs) + members = Member.objects.filter(member_of__club=club).distinct() + if team is not None and season is not None: + # Already on this team's roster this season -- offering them again + # would just fail the unique_member_per_team_per_season constraint. + taken = TeamMembership.objects.filter(team=team, season=season).exclude(pk=self.instance.pk).values_list("member_id", flat=True) + members = members.exclude(pk__in=taken) + self.fields["member"].queryset = members + self.fields["position"].queryset = Position.objects.filter(club=club, staff_position=False) + + +class StaffAssignmentForm(forms.ModelForm): + """Assign/edit one staff assignment -- team and season come from the view, + same reasoning as TeamMembershipForm.""" + + class Meta: + model = StaffAssignment + fields = ["member", "position"] + widgets = {"member": forms.Select(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a name to search...")})} + + def __init__(self, *args, club=None, team=None, season=None, **kwargs): + super().__init__(*args, **kwargs) + members = Member.objects.filter(member_of__club=club).distinct() + if team is not None and season is not None: + taken = StaffAssignment.objects.filter(team=team, season=season).exclude(pk=self.instance.pk).values_list("member_id", flat=True) + members = members.exclude(pk__in=taken) + self.fields["member"].queryset = members + self.fields["position"].queryset = Position.objects.filter(club=club, staff_position=True) + + class PositionForm(forms.ModelForm): class Meta: model = Position diff --git a/management/templates/management/_nav_items.html b/management/templates/management/_nav_items.html index 47950d6..dc7e5c2 100644 --- a/management/templates/management/_nav_items.html +++ b/management/templates/management/_nav_items.html @@ -28,8 +28,6 @@
  • {% lucide "shirt" size=16 %} {% trans "Teams" %}
  • -
  • {% lucide "clipboard-list" size=16 %} {% trans "Roster" %}
  • -
  • {% lucide "hard-hat" size=16 %} {% trans "Staff" %}
  • {% lucide "newspaper" size=16 %} {% trans "News" %}
  • diff --git a/management/templates/management/team_detail.html b/management/templates/management/team_detail.html index db51ea6..1a3406f 100644 --- a/management/templates/management/team_detail.html +++ b/management/templates/management/team_detail.html @@ -1,5 +1,5 @@ {% extends "management/base.html" %} -{% load i18n lucide %} +{% load i18n lucide static ui %} {% block heading %}{{ team.name }}{% endblock heading %} {% block subheading %}{{ team.short_name }}{% endblock subheading %} @@ -11,9 +11,150 @@ {% endblock actions %} {% block panel %} -
    -
    -

    {% trans "Roster and staff assignments for this team are managed from the Roster and Staff sections." %}

    +
    +
    + +
    -
    + + + {% if not selected_season %} +
    + {% lucide "calendar-x" size=20 %} + {% trans "This club has no seasons yet, so there's no roster or staff to show." %} +
    + {% else %} +
    +
    +
    +

    {% trans "Roster" %}

    + {% if can_manage %} + + {% endif %} +
    +
    + + + + + + + + + + + + {% for membership in roster %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
    {% trans "Member" %}{% trans "Position" %}{% trans "Jersey #" %}
    {{ membership.member }}{{ membership.position }}{{ membership.jersey_number|default:"—" }} + {% if membership.is_captain %}{% trans "Captain" %}{% endif %} + {% if membership.is_alternate_captain %}{% trans "Alternate captain" %}{% endif %} + + {% if can_manage %} +
    + + +
    + {% endif %} +
    {% trans "No one on the roster for this season yet." %}
    +
    +
    +
    + +
    +
    +
    +

    {% trans "Staff" %}

    + {% if can_manage %} + + {% endif %} +
    +
    + + + + + + + + + + {% for assignment in staff %} + + + + + + {% empty %} + + + + {% endfor %} + +
    {% trans "Member" %}{% trans "Position" %}
    {{ assignment.member }}{{ assignment.position }} + {% if can_manage %} +
    + + +
    + {% endif %} +
    {% trans "No staff assigned for this season yet." %}
    +
    +
    +
    + + {% if can_manage %} + {% trans "Add player" as add_player_label %} + {% url 'management:team_roster_add' team.pk selected_season.pk as add_player_url %} + {% include "controlpanel/_modal_form.html" with modal_id="add_player_modal" title=add_player_label form=roster_form action_url=add_player_url submit_label=add_player_label submit_icon="user-plus" %} + + {% trans "Assign staff" as add_staff_label %} + {% url 'management:team_staff_add' team.pk selected_season.pk as add_staff_url %} + {% include "controlpanel/_modal_form.html" with modal_id="add_staff_modal" title=add_staff_label form=staff_form action_url=add_staff_url submit_label=add_staff_label submit_icon="user-plus" %} + + {% trans "Edit player" as edit_player_label %} + {% trans "Save" as save_label %} + {% for membership in roster %} + {% url 'management:team_roster_update' team.pk membership.pk as edit_player_url %} + {% include "controlpanel/_modal_form.html" with modal_id=membership.pk|dom_id:"edit_player_modal" title=edit_player_label form=membership.edit_form action_url=edit_player_url submit_label=save_label submit_icon="save" %} + + {% blocktrans asvar remove_player_body with member=membership.member %}Remove {{ member }} from the roster?{% endblocktrans %} + {% trans "Remove player" as remove_player_title %} + {% trans "Remove" as remove_label %} + {% url 'management:team_roster_remove' team.pk membership.pk as remove_player_url %} + {% include "controlpanel/_confirm_modal.html" with modal_id=membership.pk|dom_id:"remove_player_modal" title=remove_player_title body=remove_player_body action_url=remove_player_url submit_label=remove_label submit_icon="user-minus" %} + {% endfor %} + + {% trans "Edit staff assignment" as edit_staff_label %} + {% for assignment in staff %} + {% url 'management:team_staff_update' team.pk assignment.pk as edit_staff_url %} + {% include "controlpanel/_modal_form.html" with modal_id=assignment.pk|dom_id:"edit_staff_modal" title=edit_staff_label form=assignment.edit_form action_url=edit_staff_url submit_label=save_label submit_icon="save" %} + + {% blocktrans asvar remove_staff_body with member=assignment.member %}Remove {{ member }} from staff?{% endblocktrans %} + {% trans "Remove staff" as remove_staff_title %} + {% url 'management:team_staff_remove' team.pk assignment.pk as remove_staff_url %} + {% include "controlpanel/_confirm_modal.html" with modal_id=assignment.pk|dom_id:"remove_staff_modal" title=remove_staff_title body=remove_staff_body action_url=remove_staff_url submit_label=remove_label submit_icon="user-minus" %} + {% endfor %} + {% endif %} + {% endif %} {% endblock panel %} + +{% block extra_body %} + +{% endblock extra_body %} diff --git a/management/tests.py b/management/tests.py index f9f58c5..d3440d5 100644 --- a/management/tests.py +++ b/management/tests.py @@ -9,7 +9,7 @@ from allauth.mfa.models import Authenticator from django.contrib.auth import get_user_model from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase, override_settings -from django.urls import reverse +from django.urls import NoReverseMatch, reverse from django.utils import timezone from club.models import Club, ClubMembership, ClubRole, FeePayment, Season @@ -288,6 +288,155 @@ class TeamManagementTests(ManagementTestBase): self.assertTrue(Team.objects.filter(pk=team.pk).exists()) +class TeamRosterStaffTests(ManagementTestBase): + """Roster/staff management folded into the team page -- see + management.views.TeamDetailView and the TeamRoster*/TeamStaff* views.""" + + def setUp(self): + super().setUp() + self.team = Team.objects.create(club=self.club, name="First Team", short_name="1st") + self.other_team = Team.objects.create(club=self.club, name="Second Team", short_name="2nd") + self.player_position = Position.objects.create(club=self.club, name="Forward", short_name="FW", staff_position=False) + self.coach_position = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True) + + self.player = Member.objects.create(first_name="Peter", last_name="Player") + ClubMembership.objects.create(club=self.club, member=self.player, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + + def make_team_coach(self, team, email="coach-roster@example.com"): + coach_user = User.objects.create_user(email=email, password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=self.coach_position) + return coach_user + + def test_nav_no_longer_lists_roster_or_staff(self): + # Not a bare "Roster"/"Staff" substring check -- "RosterChief" branding is + # on every page regardless. The nav's old icons are a safe, specific proxy. + self.client.force_login(self.admin_user) + + response = self.club_get("team_list") + + self.assertNotContains(response, "clipboard-list") + self.assertNotContains(response, "hard-hat") + + def test_roster_and_staff_urls_no_longer_resolve(self): + with self.assertRaises(NoReverseMatch): + reverse("management:roster_list") + with self.assertRaises(NoReverseMatch): + reverse("management:staff_list") + + def test_season_switcher_defaults_to_the_current_season(self): + self.client.force_login(self.admin_user) + TeamMembership.objects.create(team=self.team, season=self.season, member=self.player, position=self.player_position) + + response = self.club_get("team_detail", self.team.pk) + + self.assertContains(response, "Peter Player") + + def test_season_switcher_honours_the_query_param(self): + other_season = Season.objects.create(club=self.club, start_date=datetime.date(2020, 1, 1), end_date=datetime.date(2020, 12, 31)) + TeamMembership.objects.create(team=self.team, season=other_season, member=self.player, position=self.player_position) + self.client.force_login(self.admin_user) + + default_response = self.club_get("team_detail", self.team.pk) + other_response = self.client.get(f"{reverse('management:team_detail', args=[self.team.pk])}?season={other_season.pk}", HTTP_HOST="ajax-united.rosterchief.app") + + # Not assertNotContains("Peter Player") on the default response -- he's + # still a valid pick in the "Add player" combobox even when he isn't on + # *this* season's roster, so his name legitimately appears there too. + self.assertContains(default_response, "No one on the roster for this season yet.") + self.assertContains(other_response, "Peter Player") + + def test_a_teams_own_coach_can_add_a_player(self): + self.client.force_login(self.make_team_coach(self.team)) + + response = self.club_post("team_roster_add", {"member": str(self.player.pk), "position": str(self.player_position.pk), "jersey_number": "9"}, self.team.pk, self.season.pk) + + self.assertRedirects(response, f"{reverse('management:team_detail', args=[self.team.pk])}?season={self.season.pk}") + membership = TeamMembership.objects.get(team=self.team, season=self.season, member=self.player) + self.assertEqual(membership.jersey_number, 9) + + def test_a_different_teams_coach_cannot_add_a_player(self): + self.client.force_login(self.make_team_coach(self.other_team)) + + response = self.club_post("team_roster_add", {"member": str(self.player.pk), "position": str(self.player_position.pk)}, self.team.pk, self.season.pk) + + self.assertEqual(response.status_code, 403) + self.assertFalse(TeamMembership.objects.filter(team=self.team, season=self.season).exists()) + + def test_a_different_teams_coach_can_still_view_the_team(self): + self.client.force_login(self.make_team_coach(self.other_team)) + + response = self.club_get("team_detail", self.team.pk) + + self.assertEqual(response.status_code, 200) + + def test_admin_can_add_a_player_to_any_team(self): + self.client.force_login(self.admin_user) + + self.club_post("team_roster_add", {"member": str(self.player.pk), "position": str(self.player_position.pk)}, self.team.pk, self.season.pk) + + self.assertTrue(TeamMembership.objects.filter(team=self.team, season=self.season, member=self.player).exists()) + + def test_adding_the_same_member_twice_fails_with_a_form_error_not_a_500(self): + self.client.force_login(self.admin_user) + TeamMembership.objects.create(team=self.team, season=self.season, member=self.player, position=self.player_position) + + response = self.club_post("team_roster_add", {"member": str(self.player.pk), "position": str(self.player_position.pk)}, self.team.pk, self.season.pk) + + self.assertEqual(response.status_code, 302) + self.assertEqual(TeamMembership.objects.filter(team=self.team, season=self.season).count(), 1) + + def test_editing_a_roster_entry_updates_it(self): + membership = TeamMembership.objects.create(team=self.team, season=self.season, member=self.player, position=self.player_position, jersey_number=9) + self.client.force_login(self.admin_user) + + self.club_post( + "team_roster_update", + {"member": str(self.player.pk), "position": str(self.player_position.pk), "jersey_number": "10", "is_captain": "on"}, + self.team.pk, + membership.pk, + ) + + membership.refresh_from_db() + self.assertEqual(membership.jersey_number, 10) + self.assertTrue(membership.is_captain) + + def test_removing_a_roster_entry_deletes_it(self): + membership = TeamMembership.objects.create(team=self.team, season=self.season, member=self.player, position=self.player_position) + self.client.force_login(self.admin_user) + + self.club_post("team_roster_remove", {}, self.team.pk, membership.pk) + + self.assertFalse(TeamMembership.objects.filter(pk=membership.pk).exists()) + + def test_a_teams_own_coach_can_assign_staff(self): + physio_position = Position.objects.create(club=self.club, name="Physio", short_name="PH", staff_position=True) + physio = Member.objects.create(first_name="Pat", last_name="Physio") + ClubMembership.objects.create(club=self.club, member=physio, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + self.client.force_login(self.make_team_coach(self.team)) + + self.club_post("team_staff_add", {"member": str(physio.pk), "position": str(physio_position.pk)}, self.team.pk, self.season.pk) + + self.assertTrue(StaffAssignment.objects.filter(team=self.team, season=self.season, member=physio).exists()) + + def test_a_different_teams_coach_cannot_assign_staff(self): + physio_position = Position.objects.create(club=self.club, name="Physio", short_name="PH", staff_position=True) + physio = Member.objects.create(first_name="Pat", last_name="Physio") + self.client.force_login(self.make_team_coach(self.other_team)) + + response = self.club_post("team_staff_add", {"member": str(physio.pk), "position": str(physio_position.pk)}, self.team.pk, self.season.pk) + + self.assertEqual(response.status_code, 403) + + def test_removing_a_staff_assignment_deletes_it(self): + assignment = StaffAssignment.objects.create(team=self.team, season=self.season, member=self.player, position=self.coach_position) + self.client.force_login(self.admin_user) + + self.club_post("team_staff_remove", {}, self.team.pk, assignment.pk) + + self.assertFalse(StaffAssignment.objects.filter(pk=assignment.pk).exists()) + + class PositionManagementTests(ManagementTestBase): def setUp(self): super().setUp() diff --git a/management/urls.py b/management/urls.py index 21feb81..f5194ff 100644 --- a/management/urls.py +++ b/management/urls.py @@ -41,8 +41,12 @@ urlpatterns = [ path("teams//", views.TeamDetailView.as_view(), name="team_detail"), path("teams//edit/", views.TeamUpdateView.as_view(), name="team_update"), path("teams//delete/", views.TeamDeleteView.as_view(), name="team_delete"), - path("roster/", views.RosterListView.as_view(), name="roster_list"), - path("staff/", views.StaffListView.as_view(), name="staff_list"), + path("teams//roster//add/", views.TeamRosterAddView.as_view(), name="team_roster_add"), + path("teams//roster//edit/", views.TeamRosterUpdateView.as_view(), name="team_roster_update"), + path("teams//roster//remove/", views.TeamRosterRemoveView.as_view(), name="team_roster_remove"), + path("teams//staff//add/", views.TeamStaffAddView.as_view(), name="team_staff_add"), + path("teams//staff//edit/", views.TeamStaffUpdateView.as_view(), name="team_staff_update"), + path("teams//staff//remove/", views.TeamStaffRemoveView.as_view(), name="team_staff_remove"), # News path("news/", views.NewsListView.as_view(), name="news_list"), path("news/new/", views.NewsCreateView.as_view(), name="news_create"), diff --git a/management/views.py b/management/views.py index 20c92f5..defbd05 100644 --- a/management/views.py +++ b/management/views.py @@ -9,9 +9,9 @@ from django.utils.translation import gettext_lazy as _ from django.utils.translation import ngettext from django.views.generic import CreateView, DetailView, FormView, ListView, TemplateView, UpdateView, View -from club.mixins import ClubAdminRequiredMixin, ClubStaffRequiredMixin, NewsAuthorRequiredMixin, NewsEditRequiredMixin, NewsPublisherRequiredMixin +from club.mixins import ClubAdminRequiredMixin, ClubStaffRequiredMixin, NewsAuthorRequiredMixin, NewsEditRequiredMixin, NewsPublisherRequiredMixin, TeamManagerRequiredMixin from club.models import ClubMembership, ClubRole, Season -from club.services.access import can_edit_news, can_publish_news, current_season, members_visible_to +from club.services.access import can_edit_news, can_publish_news, current_season, is_club_admin, members_visible_to, teams_managed_by from club.services.fees import mark_as_paid, record_payment, remaining_balance from controlpanel.messages import notify from controlpanel.mixins import RedirectOnInvalidMixin @@ -41,7 +41,9 @@ from .forms import ( NewsPublishForm, PositionForm, RecordFeePaymentForm, + StaffAssignmentForm, TeamForm, + TeamMembershipForm, ) from .pdf import PDFExportError, membership_list_pdf @@ -154,6 +156,18 @@ class MemberListView(ClubStaffRequiredMixin, ListView): return context | {"members": members} +def selected_season_from_request(request, club): + """Which season a page showing season-scoped data should use: ``?season=`` + if given (and it's actually one of this club's own seasons), else whichever + season covers today. Shared by every page with a season switcher.""" + season_id = request.GET.get("season") + if season_id: + season = Season.objects.filter(club=club, pk=season_id).first() + if season is not None: + return season + return current_season(club) + + class MembershipListView(ClubAdminRequiredMixin, ListView): """Who's paid for the current season, and who hasn't -- MemberListView's Status column can only show this one row at a time. Financial data, so admin-only @@ -163,12 +177,7 @@ class MembershipListView(ClubAdminRequiredMixin, ListView): context_object_name = "memberships" def get_selected_season(self): - season_id = self.request.GET.get("season") - if season_id: - season = Season.objects.filter(club=self.request.club, pk=season_id).first() - if season is not None: - return season - return current_season(self.request.club) + return selected_season_from_request(self.request, self.request.club) def get_queryset(self): season = self.get_selected_season() @@ -727,12 +736,197 @@ class TeamDeleteView(ClubAdminRequiredMixin, View): class TeamDetailView(ClubStaffRequiredMixin, DetailView): + """Team, roster and staff for one season, all in one place -- viewing is open + to any staff (visibility, not authority); managing the roster/staff of *this* + team is gated by can_manage (TeamManagerRequiredMixin's own rule, computed + here too since the template needs it to show/hide the add/edit/remove UI).""" + template_name = "management/team_detail.html" context_object_name = "team" def get_queryset(self): return Team.objects.filter(club=self.request.club) + def get_context_data(self, **kwargs): + club = self.request.club + team = self.object + season = selected_season_from_request(self.request, club) + can_manage = is_club_admin(self.request.user, club) or teams_managed_by(self.request.user, club).filter(pk=team.pk).exists() + + roster = TeamMembership.objects.none() + staff = StaffAssignment.objects.none() + if season is not None: + roster = list(TeamMembership.objects.filter(team=team, season=season).select_related("member", "position").order_by("position__ordering", "member__last_name")) + staff = list(StaffAssignment.objects.filter(team=team, season=season).select_related("member", "position").order_by("position__ordering", "member__last_name")) + if can_manage: + for membership in roster: + membership.edit_form = TeamMembershipForm(instance=membership, club=club, team=team, season=season) + for assignment in staff: + assignment.edit_form = StaffAssignmentForm(instance=assignment, club=club, team=team, season=season) + + return super().get_context_data( + seasons=Season.objects.filter(club=club).order_by("-start_date"), + selected_season=season, + roster=roster, + staff=staff, + can_manage=can_manage, + roster_form=TeamMembershipForm(club=club, team=team, season=season) if can_manage and season else None, + staff_form=StaffAssignmentForm(club=club, team=team, season=season) if can_manage and season else None, + **kwargs, + ) + + +class TeamRosterAddView(TeamManagerRequiredMixin, FormView): + """Reachable only via the "Add player" modal on the team page. Not + RedirectOnInvalidMixin: that can't carry ?season= through a plain + redirect(view_name, **kwargs), and losing the season on a failed add would + land the admin back looking at a different one than they were editing.""" + + form_class = TeamMembershipForm + http_method_names = ["post"] + + def get_team(self): + return get_object_or_404(Team.objects.filter(club=self.request.club), pk=self.kwargs["pk"]) + + def get_season(self): + return get_object_or_404(Season.objects.filter(club=self.request.club), pk=self.kwargs["season_pk"]) + + def get_form_kwargs(self): + # instance carries team/season *before* validation runs -- TeamMembership.clean() + # (validate_club_scope) needs self.team_id set to check season/position are the + # same club's, and form_valid() runs only after that validation already passed. + return super().get_form_kwargs() | {"club": self.request.club, "team": self.get_team(), "season": self.get_season(), "instance": TeamMembership(team=self.get_team(), season=self.get_season())} + + def team_detail_url(self): + return f"{reverse('management:team_detail', args=[self.kwargs['pk']])}?season={self.kwargs['season_pk']}" + + def form_invalid(self, form): + for error in form.errors.values(): + notify(self.request, f"e|{_('Could not add player')}|{' '.join(error)}") + return redirect(self.team_detail_url()) + + def form_valid(self, form): + form.save() + body = _("“%(member)s” added to the roster.") % {"member": form.instance.member} + notify(self.request, f"s|{_('Player added')}|{body}") + return redirect(self.team_detail_url()) + + +class TeamRosterUpdateView(TeamManagerRequiredMixin, FormView): + form_class = TeamMembershipForm + http_method_names = ["post"] + + def get_object(self): + return get_object_or_404(TeamMembership.objects.filter(team__club=self.request.club, team__pk=self.kwargs["pk"]), pk=self.kwargs["membership_pk"]) + + def get_team(self): + return self.get_object().team + + def get_form_kwargs(self): + membership = self.get_object() + return super().get_form_kwargs() | {"instance": membership, "club": self.request.club, "team": membership.team, "season": membership.season} + + def team_detail_url(self): + return f"{reverse('management:team_detail', args=[self.kwargs['pk']])}?season={self.get_object().season_id}" + + def form_invalid(self, form): + for error in form.errors.values(): + notify(self.request, f"e|{_('Could not update player')}|{' '.join(error)}") + return redirect(self.team_detail_url()) + + def form_valid(self, form): + form.save() + body = _("“%(member)s” updated.") % {"member": form.instance.member} + notify(self.request, f"s|{_('Player updated')}|{body}") + return redirect(self.team_detail_url()) + + +class TeamRosterRemoveView(TeamManagerRequiredMixin, View): + def get_team(self): + return get_object_or_404(Team.objects.filter(club=self.request.club), pk=self.kwargs["pk"]) + + def post(self, request, pk, membership_pk): + membership = get_object_or_404(TeamMembership.objects.filter(team__club=request.club, team__pk=pk), pk=membership_pk) + season_id, member = membership.season_id, membership.member + membership.delete() + + body = _("“%(member)s” removed from the roster.") % {"member": member} + notify(request, f"w|{_('Player removed')}|{body}") + return redirect(f"{reverse('management:team_detail', args=[pk])}?season={season_id}") + + +class TeamStaffAddView(TeamManagerRequiredMixin, FormView): + form_class = StaffAssignmentForm + http_method_names = ["post"] + + def get_team(self): + return get_object_or_404(Team.objects.filter(club=self.request.club), pk=self.kwargs["pk"]) + + def get_season(self): + return get_object_or_404(Season.objects.filter(club=self.request.club), pk=self.kwargs["season_pk"]) + + def get_form_kwargs(self): + # See TeamRosterAddView -- StaffAssignment.clean() needs team_id set before + # validation runs, not after (form_valid() only runs once already valid). + return super().get_form_kwargs() | {"club": self.request.club, "team": self.get_team(), "season": self.get_season(), "instance": StaffAssignment(team=self.get_team(), season=self.get_season())} + + def team_detail_url(self): + return f"{reverse('management:team_detail', args=[self.kwargs['pk']])}?season={self.kwargs['season_pk']}" + + def form_invalid(self, form): + for error in form.errors.values(): + notify(self.request, f"e|{_('Could not assign staff')}|{' '.join(error)}") + return redirect(self.team_detail_url()) + + def form_valid(self, form): + form.save() + body = _("“%(member)s” assigned as staff.") % {"member": form.instance.member} + notify(self.request, f"s|{_('Staff assigned')}|{body}") + return redirect(self.team_detail_url()) + + +class TeamStaffUpdateView(TeamManagerRequiredMixin, FormView): + form_class = StaffAssignmentForm + http_method_names = ["post"] + + def get_object(self): + return get_object_or_404(StaffAssignment.objects.filter(team__club=self.request.club, team__pk=self.kwargs["pk"]), pk=self.kwargs["assignment_pk"]) + + def get_team(self): + return self.get_object().team + + def get_form_kwargs(self): + assignment = self.get_object() + return super().get_form_kwargs() | {"instance": assignment, "club": self.request.club, "team": assignment.team, "season": assignment.season} + + def team_detail_url(self): + return f"{reverse('management:team_detail', args=[self.kwargs['pk']])}?season={self.get_object().season_id}" + + def form_invalid(self, form): + for error in form.errors.values(): + notify(self.request, f"e|{_('Could not update staff assignment')}|{' '.join(error)}") + return redirect(self.team_detail_url()) + + def form_valid(self, form): + form.save() + body = _("“%(member)s” updated.") % {"member": form.instance.member} + notify(self.request, f"s|{_('Staff assignment updated')}|{body}") + return redirect(self.team_detail_url()) + + +class TeamStaffRemoveView(TeamManagerRequiredMixin, View): + def get_team(self): + return get_object_or_404(Team.objects.filter(club=self.request.club), pk=self.kwargs["pk"]) + + def post(self, request, pk, assignment_pk): + assignment = get_object_or_404(StaffAssignment.objects.filter(team__club=request.club, team__pk=pk), pk=assignment_pk) + season_id, member = assignment.season_id, assignment.member + assignment.delete() + + body = _("“%(member)s” removed from staff.") % {"member": member} + notify(request, f"w|{_('Staff removed')}|{body}") + return redirect(f"{reverse('management:team_detail', args=[pk])}?season={season_id}") + # --- Club roles (full tier: assign / revoke, no update -- a role isn't edited, just # granted or taken away) ------------------------------------------------------------- @@ -1150,20 +1344,6 @@ class NewsPhotoDeleteView(NewsEditRequiredMixin, View): return redirect("management:news_detail", pk=news_item.pk) -class RosterListView(ClubStaffRequiredMixin, StubListMixin, ListView): - page_title = _("Roster") - - def get_queryset(self): - return TeamMembership.objects.filter(team__club=self.request.club) - - -class StaffListView(ClubStaffRequiredMixin, StubListMixin, ListView): - page_title = _("Staff") - - def get_queryset(self): - return StaffAssignment.objects.filter(team__club=self.request.club) - - class EventListView(ClubStaffRequiredMixin, StubListMixin, ListView): page_title = _("Events")