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 @@
-
-
{% 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 %}
+ {% lucide "user-plus" size=14 %} {% trans "Add player" %}
+ {% endif %}
+
+
+
+
+
+ {% trans "Member" %}
+ {% trans "Position" %}
+ {% trans "Jersey #" %}
+
+
+
+
+
+ {% for membership in roster %}
+
+ {{ 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 %}
+
+ {% lucide "pencil" size=14 %} {% trans "Edit" %}
+ {% lucide "user-minus" size=14 %} {% trans "Remove" %}
+
+ {% endif %}
+
+
+ {% empty %}
+
+ {% trans "No one on the roster for this season yet." %}
+
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
{% trans "Staff" %}
+ {% if can_manage %}
+ {% lucide "user-plus" size=14 %} {% trans "Assign staff" %}
+ {% endif %}
+
+
+
+
+
+ {% trans "Member" %}
+ {% trans "Position" %}
+
+
+
+
+ {% for assignment in staff %}
+
+ {{ assignment.member }}
+ {{ assignment.position }}
+
+ {% if can_manage %}
+
+ {% lucide "pencil" size=14 %} {% trans "Edit" %}
+ {% lucide "user-minus" size=14 %} {% trans "Remove" %}
+
+ {% endif %}
+
+
+ {% empty %}
+
+ {% trans "No staff assigned for this season yet." %}
+
+ {% endfor %}
+
+
+
+
+
+
+ {% 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")