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_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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user