Coach lineup polish, referee level/position delete, feature-flag nav gating, per-job pause
- Coach line-up screen: more breathing room above the sheet, brighter event subtitle, and the "Schedule" button now matches the date input's height. - Positions and referee levels can now be deleted from Settings (blocked with a friendly message if still in use on a roster/referee profile/inheritance chain). - The Evaluations nav placeholder is now gated on the formbuilder flag, same as Forms itself, since the design reuses formbuilder underneath. - Control panel: each scheduled platform job can now be paused/resumed individually (features.models.JobToggle), independent of the platform-wide Maintenance lock. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -34,12 +34,13 @@
|
||||
<a class="nav-subitem {% if nav == 'group_list' %}active{% endif %}" href="{% url 'management:group_list' %}">{% trans "Groups" %}</a>
|
||||
{% comment %}
|
||||
Placeholder -- nothing else is built yet (see ARCHITECTURE.md §5.8 and
|
||||
management.views.EvaluationsComingSoonView). Deliberately not behind a
|
||||
waffle flag like Forms below -- this is a standing "not built yet"
|
||||
reminder, so it stays visible rather than disappearing until someone
|
||||
remembers to flip a flag on.
|
||||
management.views.EvaluationsComingSoonView). Gated on forms_enabled,
|
||||
same as Forms below: the design (ARCHITECTURE.md §5.8) reuses formbuilder
|
||||
underneath, so a club without that flag shouldn't see it either.
|
||||
{% endcomment %}
|
||||
<a class="nav-subitem flex items-center gap-1.5 {% if nav == 'evaluations' %}active{% endif %}" href="{% url 'management:evaluations' %}">{% trans "Evaluations" %} <span class="badge badge-neutral badge-xs">{% trans "Soon" %}</span></a>
|
||||
{% if forms_enabled %}
|
||||
<a class="nav-subitem flex items-center gap-1.5 {% if nav == 'evaluations' %}active{% endif %}" href="{% url 'management:evaluations' %}">{% trans "Evaluations" %} <span class="badge badge-neutral badge-xs">{% trans "Soon" %}</span></a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "management/base.html" %}
|
||||
{% load i18n lucide %}
|
||||
{% load i18n lucide ui %}
|
||||
|
||||
{% block heading %}{% trans "Positions" %}{% endblock heading %}
|
||||
|
||||
@@ -40,7 +40,10 @@
|
||||
</td>
|
||||
<td class="text-right">
|
||||
{% if is_club_admin %}
|
||||
<a class="btn btn-outline btn-sm gap-2" href="{% url 'management:position_update' position.pk %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
|
||||
<div class="flex justify-end gap-1">
|
||||
<a class="btn btn-outline btn-sm gap-2" href="{% url 'management:position_update' position.pk %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
|
||||
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ position.pk|dom_id:"position_delete_modal" }}').showModal()" aria-label="{% trans 'Delete' %}">{% lucide "trash-2" size=14 %}</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -52,4 +55,14 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if is_club_admin %}
|
||||
{% trans "Delete position" as delete_position_title %}
|
||||
{% trans "Delete" as delete_label %}
|
||||
{% for position in positions %}
|
||||
{% url 'management:position_delete' position.pk as position_delete_url %}
|
||||
{% blocktrans with name=position.name asvar delete_position_body %}Delete “{{ name }}”? Positions still assigned on a team roster can't be deleted. This cannot be undone.{% endblocktrans %}
|
||||
{% include "controlpanel/_confirm_modal.html" with modal_id=position.pk|dom_id:"position_delete_modal" title=delete_position_title body=delete_position_body action_url=position_delete_url submit_label=delete_label %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endblock panel %}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "management/base.html" %}
|
||||
{% load i18n lucide %}
|
||||
{% load i18n lucide ui %}
|
||||
|
||||
{% block heading %}{% trans "Referee levels" %}{% endblock heading %}
|
||||
{% block topbar_context %}<span class="text-sm text-muted">{% trans "Which teams' home games each level qualifies a referee for." %}</span>{% endblock topbar_context %}
|
||||
@@ -44,7 +44,10 @@
|
||||
</td>
|
||||
<td class="text-right">
|
||||
{% if is_club_admin %}
|
||||
<a class="btn btn-outline btn-sm gap-2" href="{% url 'management:referee_level_update' level.pk %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
|
||||
<div class="flex justify-end gap-1">
|
||||
<a class="btn btn-outline btn-sm gap-2" href="{% url 'management:referee_level_update' level.pk %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
|
||||
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ level.pk|dom_id:"referee_level_delete_modal" }}').showModal()" aria-label="{% trans 'Delete' %}">{% lucide "trash-2" size=14 %}</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -56,4 +59,14 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if is_club_admin %}
|
||||
{% trans "Delete referee level" as delete_level_title %}
|
||||
{% trans "Delete" as delete_label %}
|
||||
{% for level in levels %}
|
||||
{% url 'management:referee_level_delete' level.pk as level_delete_url %}
|
||||
{% blocktrans with name=level.name asvar delete_level_body %}Delete “{{ name }}”? Levels still held by a referee or inherited by another level can't be deleted. This cannot be undone.{% endblocktrans %}
|
||||
{% include "controlpanel/_confirm_modal.html" with modal_id=level.pk|dom_id:"referee_level_delete_modal" title=delete_level_title body=delete_level_body action_url=level_delete_url submit_label=delete_label %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endblock panel %}
|
||||
|
||||
@@ -1262,6 +1262,39 @@ class PositionManagementTests(ManagementTestBase):
|
||||
self.assertFalse(Position.objects.filter(club=self.club, name="Bad").exists())
|
||||
self.assertFormError(response.context["form"], "management_position", "A management position must also be a staff position.")
|
||||
|
||||
def test_deleting_an_unused_position(self):
|
||||
position = Position.objects.create(club=self.club, name="Physio", short_name="PH")
|
||||
|
||||
response = self.club_post("position_delete", {}, position.pk)
|
||||
|
||||
self.assertRedirects(response, reverse("management:position_list"))
|
||||
self.assertFalse(Position.objects.filter(pk=position.pk).exists())
|
||||
|
||||
def test_deleting_a_position_still_on_a_roster_is_refused(self):
|
||||
team = Team.objects.create(club=self.club, name="First Team", short_name="1st")
|
||||
member = Member.objects.create(first_name="Roster", last_name="Player")
|
||||
position = Position.objects.create(club=self.club, name="Goalkeeper", short_name="GK")
|
||||
TeamMembership.objects.create(team=team, member=member, season=self.season, position=position)
|
||||
|
||||
response = self.club_post("position_delete", {}, position.pk)
|
||||
|
||||
self.assertRedirects(response, reverse("management:position_list"))
|
||||
self.assertTrue(Position.objects.filter(pk=position.pk).exists())
|
||||
|
||||
def test_deleting_a_position_is_admin_only(self):
|
||||
position = Position.objects.create(club=self.club, name="Physio", short_name="PH")
|
||||
coach_user = User.objects.create_user(email="coach-position-delete@example.com", password="pw-secret-123")
|
||||
coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach")
|
||||
team = Team.objects.create(club=self.club, name="Delete Test Team", short_name="DTT")
|
||||
coach_position = Position.objects.create(club=self.club, name="Coach", short_name="C", staff_position=True, management_position=True)
|
||||
StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=coach_position)
|
||||
self.client.force_login(coach_user)
|
||||
|
||||
response = self.club_post("position_delete", {}, position.pk)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertTrue(Position.objects.filter(pk=position.pk).exists())
|
||||
|
||||
|
||||
class RefereeLevelManagementTests(ManagementTestBase):
|
||||
"""Admin-managed referee qualification tiers -- see
|
||||
@@ -1363,6 +1396,46 @@ class RefereeLevelManagementTests(ManagementTestBase):
|
||||
self.assertContains(response, "Regional")
|
||||
self.assertContains(response, "everything")
|
||||
|
||||
def test_deleting_an_unused_level(self):
|
||||
level = RefereeLevel.objects.create(club=self.club, name="Regional")
|
||||
self.client.force_login(self.admin_user)
|
||||
|
||||
response = self.club_post("referee_level_delete", {}, level.pk)
|
||||
|
||||
self.assertRedirects(response, reverse("management:referee_level_list"))
|
||||
self.assertFalse(RefereeLevel.objects.filter(pk=level.pk).exists())
|
||||
|
||||
def test_deleting_a_level_held_by_a_referee_is_refused(self):
|
||||
level = RefereeLevel.objects.create(club=self.club, name="Regional")
|
||||
member = Member.objects.create(first_name="Ref", last_name="Eree")
|
||||
ClubMembership.objects.create(club=self.club, member=member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
|
||||
RefereeProfile.objects.create(member=member, level=level, valid_until=timezone.localdate() + datetime.timedelta(days=30))
|
||||
self.client.force_login(self.admin_user)
|
||||
|
||||
response = self.club_post("referee_level_delete", {}, level.pk)
|
||||
|
||||
self.assertRedirects(response, reverse("management:referee_level_list"))
|
||||
self.assertTrue(RefereeLevel.objects.filter(pk=level.pk).exists())
|
||||
|
||||
def test_deleting_a_level_inherited_by_another_is_refused(self):
|
||||
regional = RefereeLevel.objects.create(club=self.club, name="Regional")
|
||||
RefereeLevel.objects.create(club=self.club, name="National", inherits_from=regional)
|
||||
self.client.force_login(self.admin_user)
|
||||
|
||||
response = self.club_post("referee_level_delete", {}, regional.pk)
|
||||
|
||||
self.assertRedirects(response, reverse("management:referee_level_list"))
|
||||
self.assertTrue(RefereeLevel.objects.filter(pk=regional.pk).exists())
|
||||
|
||||
def test_deleting_a_level_is_admin_only(self):
|
||||
level = RefereeLevel.objects.create(club=self.club, name="Regional")
|
||||
self.client.force_login(self.make_non_admin_coach())
|
||||
|
||||
response = self.club_post("referee_level_delete", {}, level.pk)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertTrue(RefereeLevel.objects.filter(pk=level.pk).exists())
|
||||
|
||||
|
||||
class RefereeListViewTests(ManagementTestBase):
|
||||
"""The club-wide referee overview -- see management.views.RefereeListView."""
|
||||
@@ -6762,7 +6835,20 @@ class EvaluationsComingSoonViewTests(ManagementTestBase):
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_nav_shows_the_placeholder_unconditionally_no_flag_needed(self):
|
||||
def test_nav_hides_the_placeholder_when_the_forms_flag_is_off(self):
|
||||
# Evaluations is designed to reuse formbuilder underneath (ARCHITECTURE.md
|
||||
# §5.8), so its nav entry is gated on that same flag, like Forms itself.
|
||||
self.client.force_login(self.admin_user)
|
||||
|
||||
response = self.club_get("member_list")
|
||||
|
||||
self.assertNotContains(response, "Evaluations")
|
||||
|
||||
def test_nav_shows_the_placeholder_once_the_forms_flag_is_active(self):
|
||||
cache.clear()
|
||||
self.addCleanup(cache.clear)
|
||||
flag = get_waffle_flag_model().objects.create(name="formbuilder")
|
||||
flag.clubs.add(self.club)
|
||||
self.client.force_login(self.admin_user)
|
||||
|
||||
response = self.club_get("member_list")
|
||||
|
||||
@@ -48,6 +48,7 @@ urlpatterns = [
|
||||
path("positions/", views.PositionListView.as_view(), name="position_list"),
|
||||
path("positions/new/", views.PositionCreateView.as_view(), name="position_create"),
|
||||
path("positions/<uuid:pk>/edit/", views.PositionUpdateView.as_view(), name="position_update"),
|
||||
path("positions/<uuid:pk>/delete/", views.PositionDeleteView.as_view(), name="position_delete"),
|
||||
path("roles/", views.ClubRoleListView.as_view(), name="role_list"),
|
||||
path("roles/new/", views.ClubRoleCreateView.as_view(), name="role_create"),
|
||||
path("roles/<uuid:pk>/revoke/", views.ClubRoleRevokeView.as_view(), name="role_revoke"),
|
||||
@@ -81,6 +82,7 @@ urlpatterns = [
|
||||
path("referee-levels/", views.RefereeLevelListView.as_view(), name="referee_level_list"),
|
||||
path("referee-levels/new/", views.RefereeLevelCreateView.as_view(), name="referee_level_create"),
|
||||
path("referee-levels/<uuid:pk>/edit/", views.RefereeLevelUpdateView.as_view(), name="referee_level_update"),
|
||||
path("referee-levels/<uuid:pk>/delete/", views.RefereeLevelDeleteView.as_view(), name="referee_level_delete"),
|
||||
# News
|
||||
path("news/", views.NewsListView.as_view(), name="news_list"),
|
||||
path("news/new/", views.NewsCreateView.as_view(), name="news_create"),
|
||||
|
||||
@@ -1861,6 +1861,23 @@ class PositionUpdateView(ClubAdminRequiredMixin, UpdateView):
|
||||
return super().get_context_data(update_view=True, **kwargs)
|
||||
|
||||
|
||||
class PositionDeleteView(ClubAdminRequiredMixin, View):
|
||||
def post(self, request, pk):
|
||||
position = get_object_or_404(Position.objects.filter(club=request.club), pk=pk)
|
||||
name = str(position)
|
||||
try:
|
||||
position.delete()
|
||||
except ProtectedError:
|
||||
title = _("Can't delete")
|
||||
body = _("“%(position)s” is still assigned on a team roster, and can't be deleted.") % {"position": name}
|
||||
notify(request, f"e|{title}|{body}")
|
||||
return redirect("management:position_list")
|
||||
|
||||
body = _("“%(position)s” has been deleted.") % {"position": name}
|
||||
notify(request, f"w|{_('Position deleted')}|{body}")
|
||||
return redirect("management:position_list")
|
||||
|
||||
|
||||
class RefereeLevelListView(ClubStaffRequiredMixin, ListView):
|
||||
"""Visible to any staff, same reasoning as PositionListView; creating/
|
||||
editing a level is admin-only."""
|
||||
@@ -1915,6 +1932,23 @@ class RefereeLevelUpdateView(MemberAdminRequiredMixin, UpdateView):
|
||||
return super().get_context_data(update_view=True, **kwargs)
|
||||
|
||||
|
||||
class RefereeLevelDeleteView(MemberAdminRequiredMixin, View):
|
||||
def post(self, request, pk):
|
||||
level = get_object_or_404(RefereeLevel.objects.filter(club=request.club), pk=pk)
|
||||
name = str(level)
|
||||
try:
|
||||
level.delete()
|
||||
except ProtectedError:
|
||||
title = _("Can't delete")
|
||||
body = _("“%(level)s” is still held by a referee or inherited by another level, and can't be deleted.") % {"level": name}
|
||||
notify(request, f"e|{title}|{body}")
|
||||
return redirect("management:referee_level_list")
|
||||
|
||||
body = _("“%(level)s” has been deleted.") % {"level": name}
|
||||
notify(request, f"w|{_('Referee level deleted')}|{body}")
|
||||
return redirect("management:referee_level_list")
|
||||
|
||||
|
||||
class RefereeListView(ClubStaffRequiredMixin, ListView):
|
||||
"""Every referee in the club, at a glance: level, eligible teams, validity
|
||||
-- see teams.RefereeProfile. Read-only; editing happens on the member's
|
||||
|
||||
Reference in New Issue
Block a user