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:
2026-08-23 11:17:46 +02:00
parent e621086f37
commit 1365dcf18e
23 changed files with 422 additions and 3029 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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