Fold Roster and Staff into Teams, with a per-season switcher and real CRUD

One "Teams" nav entry now, matching how Family has none of its own and
is only reached through Members -- Roster/Staff were pure club-wide
list stubs with no way to add/remove anyone except Django admin.
Opening a team shows its roster and staff for whichever season is
selected (?season=, defaulting to the current one, same pattern as the
Memberships page); adding/editing/removing either is gated to that
team's own manager or a club admin (TeamManagerRequiredMixin, defined
earlier but never actually wired up until now).
This commit is contained in:
2026-08-03 22:08:35 +02:00
parent 9a2a06180f
commit a3e413904f
7 changed files with 551 additions and 35 deletions

View File

@@ -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",

View File

@@ -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

View File

@@ -28,8 +28,6 @@
<li class="menu-title">{% trans "Teams" %}</li>
<li><a class="{% if nav == 'team_list' %}menu-active{% endif %}" href="{% url 'management:team_list' %}">{% lucide "shirt" size=16 %} {% trans "Teams" %}</a></li>
<li><a class="{% if nav == 'roster_list' %}menu-active{% endif %}" href="{% url 'management:roster_list' %}">{% lucide "clipboard-list" size=16 %} {% trans "Roster" %}</a></li>
<li><a class="{% if nav == 'staff_list' %}menu-active{% endif %}" href="{% url 'management:staff_list' %}">{% lucide "hard-hat" size=16 %} {% trans "Staff" %}</a></li>
<li class="menu-title">{% trans "News" %}</li>
<li><a class="{% if nav == 'news_list' %}menu-active{% endif %}" href="{% url 'management:news_list' %}">{% lucide "newspaper" size=16 %} {% trans "News" %}</a></li>

View File

@@ -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 %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<p class="opacity-70">{% trans "Roster and staff assignments for this team are managed from the Roster and Staff sections." %}</p>
<form method="get" class="mb-4">
<div class="flex flex-row items-center gap-2">
<select name="season" class="select select-bordered">
{% for season in seasons %}
<option value="{{ season.pk }}" {% if season.pk == selected_season.pk %}selected{% endif %}>{% trans "Season" %} {{ season.start_date|date:"Y" }} - {{ season.end_date|date:"Y" }}</option>
{% endfor %}
</select>
<button class="btn btn-outline btn-neutral gap-2" type="submit">{% lucide "filter" size=16 %} {% trans "View" %}</button>
</div>
</div>
</form>
{% if not selected_season %}
<div class="alert alert-warning mb-6">
{% lucide "calendar-x" size=20 %}
<span>{% trans "This club has no seasons yet, so there's no roster or staff to show." %}</span>
</div>
{% else %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-base">{% trans "Roster" %}</h2>
{% if can_manage %}
<button class="btn btn-outline btn-neutral btn-sm gap-2" type="button" onclick="document.getElementById('add_player_modal').showModal()">{% lucide "user-plus" size=14 %} {% trans "Add player" %}</button>
{% endif %}
</div>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>{% trans "Member" %}</th>
<th>{% trans "Position" %}</th>
<th>{% trans "Jersey #" %}</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{% for membership in roster %}
<tr>
<td>{{ membership.member }}</td>
<td>{{ membership.position }}</td>
<td>{{ membership.jersey_number|default:"—" }}</td>
<td>
{% if membership.is_captain %}<span class="badge badge-neutral badge-sm">{% trans "Captain" %}</span>{% endif %}
{% if membership.is_alternate_captain %}<span class="badge badge-neutral badge-sm">{% trans "Alternate captain" %}</span>{% endif %}
</td>
<td class="text-right">
{% if can_manage %}
<div class="flex justify-end gap-1">
<button class="btn btn-sm btn-outline btn-neutral" type="button" onclick="document.getElementById('{{ membership.pk|dom_id:"edit_player_modal" }}').showModal()" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</button>
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ membership.pk|dom_id:"remove_player_modal" }}').showModal()" aria-label="{% trans 'Remove' %}">{% lucide "user-minus" size=14 %} {% trans "Remove" %}</button>
</div>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="text-center opacity-60">{% trans "No one on the roster for this season yet." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="card bg-base-100 shadow mt-4">
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-base">{% trans "Staff" %}</h2>
{% if can_manage %}
<button class="btn btn-outline btn-neutral btn-sm gap-2" type="button" onclick="document.getElementById('add_staff_modal').showModal()">{% lucide "user-plus" size=14 %} {% trans "Assign staff" %}</button>
{% endif %}
</div>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>{% trans "Member" %}</th>
<th>{% trans "Position" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for assignment in staff %}
<tr>
<td>{{ assignment.member }}</td>
<td>{{ assignment.position }}</td>
<td class="text-right">
{% if can_manage %}
<div class="flex justify-end gap-1">
<button class="btn btn-sm btn-outline btn-neutral" type="button" onclick="document.getElementById('{{ assignment.pk|dom_id:"edit_staff_modal" }}').showModal()" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</button>
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ assignment.pk|dom_id:"remove_staff_modal" }}').showModal()" aria-label="{% trans 'Remove' %}">{% lucide "user-minus" size=14 %} {% trans "Remove" %}</button>
</div>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="3" class="text-center opacity-60">{% trans "No staff assigned for this season yet." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% 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 %}
<script src="{% static 'js/searchable-select.js' %}"></script>
{% endblock extra_body %}

View File

@@ -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()

View File

@@ -41,8 +41,12 @@ urlpatterns = [
path("teams/<uuid:pk>/", views.TeamDetailView.as_view(), name="team_detail"),
path("teams/<uuid:pk>/edit/", views.TeamUpdateView.as_view(), name="team_update"),
path("teams/<uuid:pk>/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/<uuid:pk>/roster/<uuid:season_pk>/add/", views.TeamRosterAddView.as_view(), name="team_roster_add"),
path("teams/<uuid:pk>/roster/<uuid:membership_pk>/edit/", views.TeamRosterUpdateView.as_view(), name="team_roster_update"),
path("teams/<uuid:pk>/roster/<uuid:membership_pk>/remove/", views.TeamRosterRemoveView.as_view(), name="team_roster_remove"),
path("teams/<uuid:pk>/staff/<uuid:season_pk>/add/", views.TeamStaffAddView.as_view(), name="team_staff_add"),
path("teams/<uuid:pk>/staff/<uuid:assignment_pk>/edit/", views.TeamStaffUpdateView.as_view(), name="team_staff_update"),
path("teams/<uuid:pk>/staff/<uuid:assignment_pk>/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"),

View File

@@ -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=<pk>``
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")