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:
@@ -48,8 +48,12 @@ _NAV_SECTIONS = {
|
|||||||
"team_update": "team_list",
|
"team_update": "team_list",
|
||||||
"team_delete": "team_list",
|
"team_delete": "team_list",
|
||||||
"team_detail": "team_list",
|
"team_detail": "team_list",
|
||||||
"roster_list": "roster_list",
|
"team_roster_add": "team_list",
|
||||||
"staff_list": "staff_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_list": "news_list",
|
||||||
"news_create": "news_list",
|
"news_create": "news_list",
|
||||||
"news_detail": "news_list",
|
"news_detail": "news_list",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from club.models import ClubMembership, ClubRole, FeePayment
|
|||||||
from members.models import Family, FamilyMembership, Member
|
from members.models import Family, FamilyMembership, Member
|
||||||
from members.services.family import find_member_by_email
|
from members.services.family import find_member_by_email
|
||||||
from news.models import News
|
from news.models import News
|
||||||
from teams.models import Position, Team
|
from teams.models import Position, StaffAssignment, Team, TeamMembership
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
@@ -27,6 +27,46 @@ class TeamForm(forms.ModelForm):
|
|||||||
fields = ["name", "short_name"]
|
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 PositionForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Position
|
model = Position
|
||||||
|
|||||||
@@ -28,8 +28,6 @@
|
|||||||
|
|
||||||
<li class="menu-title">{% trans "Teams" %}</li>
|
<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 == '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 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>
|
<li><a class="{% if nav == 'news_list' %}menu-active{% endif %}" href="{% url 'management:news_list' %}">{% lucide "newspaper" size=16 %} {% trans "News" %}</a></li>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "management/base.html" %}
|
{% extends "management/base.html" %}
|
||||||
{% load i18n lucide %}
|
{% load i18n lucide static ui %}
|
||||||
|
|
||||||
{% block heading %}{{ team.name }}{% endblock heading %}
|
{% block heading %}{{ team.name }}{% endblock heading %}
|
||||||
{% block subheading %}{{ team.short_name }}{% endblock subheading %}
|
{% block subheading %}{{ team.short_name }}{% endblock subheading %}
|
||||||
@@ -11,9 +11,150 @@
|
|||||||
{% endblock actions %}
|
{% endblock actions %}
|
||||||
|
|
||||||
{% block panel %}
|
{% block panel %}
|
||||||
|
<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>
|
||||||
|
</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 bg-base-100 shadow">
|
||||||
<div class="card-body">
|
<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>
|
<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>
|
||||||
|
</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 %}
|
{% endblock panel %}
|
||||||
|
|
||||||
|
{% block extra_body %}
|
||||||
|
<script src="{% static 'js/searchable-select.js' %}"></script>
|
||||||
|
{% endblock extra_body %}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from allauth.mfa.models import Authenticator
|
|||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
from django.test import TestCase, override_settings
|
from django.test import TestCase, override_settings
|
||||||
from django.urls import reverse
|
from django.urls import NoReverseMatch, reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from club.models import Club, ClubMembership, ClubRole, FeePayment, Season
|
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())
|
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):
|
class PositionManagementTests(ManagementTestBase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|||||||
@@ -41,8 +41,12 @@ urlpatterns = [
|
|||||||
path("teams/<uuid:pk>/", views.TeamDetailView.as_view(), name="team_detail"),
|
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>/edit/", views.TeamUpdateView.as_view(), name="team_update"),
|
||||||
path("teams/<uuid:pk>/delete/", views.TeamDeleteView.as_view(), name="team_delete"),
|
path("teams/<uuid:pk>/delete/", views.TeamDeleteView.as_view(), name="team_delete"),
|
||||||
path("roster/", views.RosterListView.as_view(), name="roster_list"),
|
path("teams/<uuid:pk>/roster/<uuid:season_pk>/add/", views.TeamRosterAddView.as_view(), name="team_roster_add"),
|
||||||
path("staff/", views.StaffListView.as_view(), name="staff_list"),
|
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
|
# News
|
||||||
path("news/", views.NewsListView.as_view(), name="news_list"),
|
path("news/", views.NewsListView.as_view(), name="news_list"),
|
||||||
path("news/new/", views.NewsCreateView.as_view(), name="news_create"),
|
path("news/new/", views.NewsCreateView.as_view(), name="news_create"),
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
from django.utils.translation import ngettext
|
from django.utils.translation import ngettext
|
||||||
from django.views.generic import CreateView, DetailView, FormView, ListView, TemplateView, UpdateView, View
|
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.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 club.services.fees import mark_as_paid, record_payment, remaining_balance
|
||||||
from controlpanel.messages import notify
|
from controlpanel.messages import notify
|
||||||
from controlpanel.mixins import RedirectOnInvalidMixin
|
from controlpanel.mixins import RedirectOnInvalidMixin
|
||||||
@@ -41,7 +41,9 @@ from .forms import (
|
|||||||
NewsPublishForm,
|
NewsPublishForm,
|
||||||
PositionForm,
|
PositionForm,
|
||||||
RecordFeePaymentForm,
|
RecordFeePaymentForm,
|
||||||
|
StaffAssignmentForm,
|
||||||
TeamForm,
|
TeamForm,
|
||||||
|
TeamMembershipForm,
|
||||||
)
|
)
|
||||||
from .pdf import PDFExportError, membership_list_pdf
|
from .pdf import PDFExportError, membership_list_pdf
|
||||||
|
|
||||||
@@ -154,6 +156,18 @@ class MemberListView(ClubStaffRequiredMixin, ListView):
|
|||||||
return context | {"members": members}
|
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):
|
class MembershipListView(ClubAdminRequiredMixin, ListView):
|
||||||
"""Who's paid for the current season, and who hasn't -- MemberListView's Status
|
"""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
|
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"
|
context_object_name = "memberships"
|
||||||
|
|
||||||
def get_selected_season(self):
|
def get_selected_season(self):
|
||||||
season_id = self.request.GET.get("season")
|
return selected_season_from_request(self.request, self.request.club)
|
||||||
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)
|
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
season = self.get_selected_season()
|
season = self.get_selected_season()
|
||||||
@@ -727,12 +736,197 @@ class TeamDeleteView(ClubAdminRequiredMixin, View):
|
|||||||
|
|
||||||
|
|
||||||
class TeamDetailView(ClubStaffRequiredMixin, DetailView):
|
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"
|
template_name = "management/team_detail.html"
|
||||||
context_object_name = "team"
|
context_object_name = "team"
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return Team.objects.filter(club=self.request.club)
|
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
|
# --- Club roles (full tier: assign / revoke, no update -- a role isn't edited, just
|
||||||
# granted or taken away) -------------------------------------------------------------
|
# granted or taken away) -------------------------------------------------------------
|
||||||
@@ -1150,20 +1344,6 @@ class NewsPhotoDeleteView(NewsEditRequiredMixin, View):
|
|||||||
return redirect("management:news_detail", pk=news_item.pk)
|
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):
|
class EventListView(ClubStaffRequiredMixin, StubListMixin, ListView):
|
||||||
page_title = _("Events")
|
page_title = _("Events")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user