diff --git a/club/mixins.py b/club/mixins.py
index badfd3a..0435f5e 100644
--- a/club/mixins.py
+++ b/club/mixins.py
@@ -1,7 +1,7 @@
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.http import Http404
-from .services.access import can_add_news, can_edit_news, can_publish_news, has_management_access, is_club_admin, teams_managed_by
+from .services.access import can_add_news, can_edit_news, can_publish_news, has_management_access, is_club_admin, is_coach_manager, teams_managed_by
class ClubStaffRequiredMixin(LoginRequiredMixin, UserPassesTestMixin):
@@ -51,6 +51,16 @@ class TeamManagerRequiredMixin(ClubStaffRequiredMixin):
return teams_managed_by(user, club).filter(pk=self.get_team().pk).exists()
+class ManagementPositionRequiredMixin(ClubStaffRequiredMixin):
+ """ADMIN, or anyone with a current-season *management*-position
+ StaffAssignment on any team -- unlike ``TeamManagerRequiredMixin``, the
+ entity here (Location, Opponent, ...) isn't scoped to one team, so "manager
+ of this team" doesn't apply; any management position qualifies."""
+
+ def test_func(self):
+ return is_club_admin(self.request.user, self.request.club) or is_coach_manager(self.request.user, self.request.club)
+
+
class NewsAuthorRequiredMixin(ClubStaffRequiredMixin):
"""ADMIN, EDITOR, or a current-season coach_manager -- who's trusted to
author club content in the first place (creating a draft)."""
diff --git a/events/migrations/0011_location_is_home_and_more.py b/events/migrations/0011_location_is_home_and_more.py
new file mode 100644
index 0000000..4bf2339
--- /dev/null
+++ b/events/migrations/0011_location_is_home_and_more.py
@@ -0,0 +1,23 @@
+# Generated by Django 6.0.6 on 2026-08-04 09:37
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('club', '0017_club_season_duration_months_club_season_start'),
+ ('events', '0010_attendance_showed_up'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='location',
+ name='is_home',
+ field=models.BooleanField(default=False, help_text="The club's own ground, set from the control panel -- lets an event's location tell a home game from an away one.", verbose_name='home location'),
+ ),
+ migrations.AddConstraint(
+ model_name='location',
+ constraint=models.UniqueConstraint(condition=models.Q(('is_home', True)), fields=('club',), name='unique_home_location_per_club'),
+ ),
+ ]
diff --git a/events/migrations/0012_alter_location_country.py b/events/migrations/0012_alter_location_country.py
new file mode 100644
index 0000000..30aa30b
--- /dev/null
+++ b/events/migrations/0012_alter_location_country.py
@@ -0,0 +1,19 @@
+# Generated by Django 6.0.6 on 2026-08-04 09:45
+
+import django_countries.fields
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('events', '0011_location_is_home_and_more'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='location',
+ name='country',
+ field=django_countries.fields.CountryField(max_length=2, verbose_name='country'),
+ ),
+ ]
diff --git a/events/models.py b/events/models.py
index 19c242b..0841a7a 100644
--- a/events/models.py
+++ b/events/models.py
@@ -1,5 +1,7 @@
from django.db import models
+from django.db.models import Q
from django.utils.translation import gettext_lazy as _
+from django_countries.fields import CountryField
from club.models import Season
from members.models import Member
@@ -25,12 +27,20 @@ class Location(ClubScopedModel):
address = models.CharField(_("address"), max_length=255)
city = models.CharField(_("city"), max_length=255)
zip_code = models.CharField(_("zip code"), max_length=255)
- country = models.CharField(_("country"), max_length=255)
+ country = CountryField(_("country"))
+ is_home = models.BooleanField(
+ _("home location"),
+ default=False,
+ help_text=_("The club's own ground, set from the control panel -- lets an event's location tell a home game from an away one."),
+ )
class Meta:
verbose_name = _("location")
verbose_name_plural = _("locations")
ordering = ["name"]
+ constraints = [
+ models.UniqueConstraint(fields=["club"], condition=Q(is_home=True), name="unique_home_location_per_club"),
+ ]
def __str__(self):
return self.name
diff --git a/management/context_processors.py b/management/context_processors.py
index a6958c3..0d3fc30 100644
--- a/management/context_processors.py
+++ b/management/context_processors.py
@@ -7,7 +7,7 @@ Same reasoning for ``news_permissions`` below, gating just the "New news item"
action rather than the whole section (``NewsAuthorRequiredMixin``/``can_add_news``).
"""
-from club.services.access import can_add_news, has_management_access, is_club_admin
+from club.services.access import can_add_news, has_management_access, is_club_admin, is_coach_manager
#: Every management URL name, mapped to the nav item it should light up --
#: management/templates/management/_nav_items.html compares against this.
@@ -67,7 +67,13 @@ _NAV_SECTIONS = {
"event_list": "event_list",
"event_series_list": "event_series_list",
"location_list": "location_list",
+ "location_create": "location_list",
+ "location_update": "location_list",
+ "location_delete": "location_list",
"opponent_list": "opponent_list",
+ "opponent_create": "opponent_list",
+ "opponent_update": "opponent_list",
+ "opponent_delete": "opponent_list",
"product_list": "product_list",
"order_list": "order_list",
"discount_list": "discount_list",
@@ -96,6 +102,17 @@ def is_admin(request):
return {"is_club_admin": is_club_admin(request.user, club)}
+def management_position(request):
+ """Whether the signed-in user holds a management position (or is ADMIN) --
+ gates the nav's Locations/Opponents links, which ``ManagementPositionRequiredMixin``
+ restricts to exactly this group (unlike most staff-visible sections)."""
+ club = getattr(request, "club", None)
+ if club is None or not request.user.is_authenticated:
+ return {"has_management_position": False}
+
+ return {"has_management_position": is_club_admin(request.user, club) or is_coach_manager(request.user, club)}
+
+
def management_link(request):
"""Whether to show a "Management" link in the global navbar (templates/_base.html),
next to the Django admin one -- only on a club subdomain, and only for someone with
diff --git a/management/forms.py b/management/forms.py
index 3609887..3a1acbc 100644
--- a/management/forms.py
+++ b/management/forms.py
@@ -6,6 +6,7 @@ from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from club.models import ClubMembership, ClubRole, FeePayment
+from events.models import Location, Opponent
from members.models import Family, FamilyMembership, Member
from members.services.family import find_member_by_email
from news.models import News
@@ -97,6 +98,25 @@ class PositionForm(forms.ModelForm):
return cleaned
+class LocationForm(forms.ModelForm):
+ class Meta:
+ model = Location
+ fields = ["name", "address", "city", "zip_code", "country"]
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ # CountryField's own widget (a lazily-translated Select) must stay the widget
+ # class -- only the searchable-select JS hooks are added on top of it, the
+ # same progressive enhancement TeamMembershipForm uses for its member field.
+ self.fields["country"].widget.attrs.update({"data-searchable": "true", "data-search-placeholder": _("Type a country to search...")})
+
+
+class OpponentForm(forms.ModelForm):
+ class Meta:
+ model = Opponent
+ fields = ["name", "logo"]
+
+
class ClubRoleAssignForm(forms.ModelForm):
"""Grant a club-wide role to a member already affiliated with this club."""
diff --git a/management/templates/management/_nav_items.html b/management/templates/management/_nav_items.html
index 8b93074..99c9e3e 100644
--- a/management/templates/management/_nav_items.html
+++ b/management/templates/management/_nav_items.html
@@ -23,9 +23,7 @@
-{% if is_club_admin %}
-
-{% endif %}
+
@@ -33,8 +31,10 @@
-
-
+{% if has_management_position %}
+
+
+{% endif %}
{% if is_club_admin %}
diff --git a/management/templates/management/location_form.html b/management/templates/management/location_form.html
new file mode 100644
index 0000000..ad6c126
--- /dev/null
+++ b/management/templates/management/location_form.html
@@ -0,0 +1,35 @@
+{% extends "management/base.html" %}
+{% load i18n lucide static ui %}
+
+{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New location" %}{% endif %}{% endblock heading %}
+
+{% block panel %}
+
+{% endblock panel %}
+
+{% block extra_body %}
+
+{% endblock extra_body %}
diff --git a/management/templates/management/location_list.html b/management/templates/management/location_list.html
new file mode 100644
index 0000000..88fc40a
--- /dev/null
+++ b/management/templates/management/location_list.html
@@ -0,0 +1,59 @@
+{% extends "management/base.html" %}
+{% load i18n lucide ui %}
+
+{% block heading %}{% trans "Locations" %}{% endblock heading %}
+
+{% block actions %}
+ {% lucide "plus" size=16 %} {% trans "New location" %}
+{% endblock actions %}
+
+{% block panel %}
+
+
+
+
+
+
+ {% trans "Name" %}
+ {% trans "Address" %}
+ {% trans "City" %}
+ {% trans "Country" %}
+
+
+
+
+ {% for location in locations %}
+
+
+ {{ location.name }}
+ {% if location.is_home %}{% trans "Home" %} {% endif %}
+
+ {{ location.address }}
+ {{ location.city }}
+ {{ location.country }}
+
+
+
+
+ {% empty %}
+
+ {% trans "No locations yet." %}
+
+ {% endfor %}
+
+
+
+
+
+
+ {% trans "Delete location" as delete_location_title %}
+ {% trans "Delete" as delete_label %}
+ {% for location in locations %}
+ {% url 'management:location_delete' location.pk as location_delete_url %}
+ {% blocktrans with name=location.name asvar delete_location_body %}Delete “{{ name }}”? Any events at this location keep their history but lose the link. This cannot be undone.{% endblocktrans %}
+ {% include "controlpanel/_confirm_modal.html" with modal_id=location.pk|dom_id:"location_delete_modal" title=delete_location_title body=delete_location_body action_url=location_delete_url submit_label=delete_label %}
+ {% endfor %}
+{% endblock panel %}
diff --git a/management/templates/management/opponent_form.html b/management/templates/management/opponent_form.html
new file mode 100644
index 0000000..15ee75c
--- /dev/null
+++ b/management/templates/management/opponent_form.html
@@ -0,0 +1,31 @@
+{% extends "management/base.html" %}
+{% load i18n lucide ui %}
+
+{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New opponent" %}{% endif %}{% endblock heading %}
+
+{% block panel %}
+
+{% endblock panel %}
diff --git a/management/templates/management/opponent_list.html b/management/templates/management/opponent_list.html
new file mode 100644
index 0000000..c70e943
--- /dev/null
+++ b/management/templates/management/opponent_list.html
@@ -0,0 +1,56 @@
+{% extends "management/base.html" %}
+{% load i18n lucide ui %}
+
+{% block heading %}{% trans "Opponents" %}{% endblock heading %}
+
+{% block actions %}
+ {% lucide "plus" size=16 %} {% trans "New opponent" %}
+{% endblock actions %}
+
+{% block panel %}
+
+
+
+
+
+
+
+ {% trans "Name" %}
+
+
+
+
+ {% for opponent in opponents %}
+
+
+ {% if opponent.logo %}
+
+ {% endif %}
+
+ {{ opponent.name }}
+
+
+
+
+ {% empty %}
+
+ {% trans "No opponents yet." %}
+
+ {% endfor %}
+
+
+
+
+
+
+ {% trans "Delete opponent" as delete_opponent_title %}
+ {% trans "Delete" as delete_label %}
+ {% for opponent in opponents %}
+ {% url 'management:opponent_delete' opponent.pk as opponent_delete_url %}
+ {% blocktrans with name=opponent.name asvar delete_opponent_body %}Delete “{{ name }}”? Any events against this opponent keep their history but lose the link. This cannot be undone.{% endblocktrans %}
+ {% include "controlpanel/_confirm_modal.html" with modal_id=opponent.pk|dom_id:"opponent_delete_modal" title=delete_opponent_title body=delete_opponent_body action_url=opponent_delete_url submit_label=delete_label %}
+ {% endfor %}
+{% endblock panel %}
diff --git a/management/templates/management/position_list.html b/management/templates/management/position_list.html
index 5b164f0..d57913a 100644
--- a/management/templates/management/position_list.html
+++ b/management/templates/management/position_list.html
@@ -4,7 +4,9 @@
{% block heading %}{% trans "Positions" %}{% endblock heading %}
{% block actions %}
- {% lucide "plus" size=16 %} {% trans "New position" %}
+ {% if is_club_admin %}
+ {% lucide "plus" size=16 %} {% trans "New position" %}
+ {% endif %}
{% endblock actions %}
{% block panel %}
@@ -39,7 +41,9 @@
- {% lucide "pencil" size=14 %} {% trans "Edit" %}
+ {% if is_club_admin %}
+ {% lucide "pencil" size=14 %} {% trans "Edit" %}
+ {% endif %}
{% empty %}
diff --git a/management/templates/management/team_detail.html b/management/templates/management/team_detail.html
index 6a540b7..c08fce7 100644
--- a/management/templates/management/team_detail.html
+++ b/management/templates/management/team_detail.html
@@ -99,7 +99,7 @@
{% lucide "user-x" size=18 %} {% trans "No-shows" %}
-
{% trans "Said they'd attend, but were checked in as absent." %}
+
diff --git a/management/templates/management/team_list.html b/management/templates/management/team_list.html
index cbe82ee..2a718b3 100644
--- a/management/templates/management/team_list.html
+++ b/management/templates/management/team_list.html
@@ -18,6 +18,8 @@
{% trans "Name" %}
{% trans "Short name" %}
+ {% trans "Players" %}
+ {% trans "Staff" %}
@@ -26,6 +28,8 @@
{{ team.name }}
{{ team.short_name }}
+ {{ team.player_count }}
+ {{ team.staff_count }}
{% if is_club_admin %}
@@ -37,7 +41,7 @@
{% empty %}
- {% trans "No teams yet." %}
+ {% trans "No teams yet." %}
{% endfor %}
diff --git a/management/tests.py b/management/tests.py
index 17eb234..df62651 100644
--- a/management/tests.py
+++ b/management/tests.py
@@ -1,4 +1,5 @@
import datetime
+import os
import sys
from decimal import Decimal
from io import BytesIO
@@ -13,7 +14,7 @@ from django.urls import NoReverseMatch, reverse
from django.utils import timezone
from club.models import Club, ClubMembership, ClubRole, FeePayment, Season
-from events.models import Attendance, Event
+from events.models import Attendance, Event, Location, Opponent
from management.bulk_import import TEMPLATE_COLUMNS
from management.pdf import PDFExportError, render_pdf
from members.models import Family, FamilyMembership, Member
@@ -132,7 +133,9 @@ class AccessTests(ManagementTestBase):
StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position)
self.client.force_login(coach_user)
- self.assertEqual(self.club_get("position_list").status_code, 403)
+ # position_list itself is open to any staff (see TeamAndPositionAccessTests)
+ # -- creating and editing positions stays admin-only.
+ self.assertEqual(self.club_get("position_create").status_code, 403)
self.assertEqual(self.club_post("member_create", {"first_name": "X", "last_name": "Y"}).status_code, 403)
@@ -2110,11 +2113,34 @@ class NewsManagementTests(ManagementTestBase):
def test_deleting_a_photo_removes_it(self):
item = News.objects.create(club=self.club, title="Match report", body="Body.")
photo = NewsPhoto.objects.create(news_item=item, image=SimpleUploadedFile("one.jpg", b"one", content_type="image/jpeg"))
+ photo_path = photo.image.path
self.client.force_login(self.make_coach_manager())
self.club_post("news_photo_delete", {}, item.pk, photo.pk)
self.assertFalse(NewsPhoto.objects.filter(pk=photo.pk).exists())
+ self.assertFalse(os.path.exists(photo_path))
+
+ def test_deleting_the_main_photo_promotes_another_one(self):
+ item = News.objects.create(club=self.club, title="Match report", body="Body.")
+ main = NewsPhoto.objects.create(news_item=item, image=SimpleUploadedFile("one.jpg", b"one", content_type="image/jpeg"), is_main=True)
+ other = NewsPhoto.objects.create(news_item=item, image=SimpleUploadedFile("two.jpg", b"two", content_type="image/jpeg"), is_main=False)
+ self.client.force_login(self.make_coach_manager())
+
+ self.club_post("news_photo_delete", {}, item.pk, main.pk)
+
+ other.refresh_from_db()
+ self.assertTrue(other.is_main)
+
+ def test_deleting_the_only_photo_leaves_nothing_to_promote(self):
+ item = News.objects.create(club=self.club, title="Match report", body="Body.")
+ photo = NewsPhoto.objects.create(news_item=item, image=SimpleUploadedFile("one.jpg", b"one", content_type="image/jpeg"), is_main=True)
+ self.client.force_login(self.make_coach_manager())
+
+ response = self.club_post("news_photo_delete", {}, item.pk, photo.pk)
+
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(item.photos.count(), 0)
def test_a_coach_manager_can_delete_a_draft(self):
item = News.objects.create(club=self.club, title="Draft item", body="Body.")
@@ -2147,11 +2173,13 @@ class NewsManagementTests(ManagementTestBase):
def test_deleting_a_news_item_removes_its_photos(self):
item = News.objects.create(club=self.club, title="Match report", body="Body.")
photo = NewsPhoto.objects.create(news_item=item, image=SimpleUploadedFile("one.jpg", b"one", content_type="image/jpeg"))
+ photo_path = photo.image.path
self.client.force_login(self.make_coach_manager())
self.club_post("news_delete", {}, item.pk)
self.assertFalse(NewsPhoto.objects.filter(pk=photo.pk).exists())
+ self.assertFalse(os.path.exists(photo_path))
def test_the_edit_and_delete_buttons_are_hidden_once_published_for_a_coach_manager(self):
item = News.objects.create(club=self.club, title="Live item", body="Body.")
@@ -2209,3 +2237,253 @@ class TeamAttendancePanelTests(ManagementTestBase):
self.assertContains(response, "Peter Player")
self.assertContains(response, attendance.event.title)
self.assertNotContains(response, "None recorded.")
+
+
+class TeamAndPositionAccessTests(ManagementTestBase):
+ """Non-admin coaches/managers: scoped to their own teams, read-only on
+ positions -- see club.mixins.TeamManagerRequiredMixin and
+ management.views.TeamListView/PositionListView."""
+
+ def setUp(self):
+ super().setUp()
+ self.own_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.manager_position = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True)
+
+ self.coach_user = User.objects.create_user(email="coach3@example.com", password="pw-secret-123")
+ coach_member = Member.objects.create(user=self.coach_user, first_name="Cara", last_name="Coach")
+ StaffAssignment.objects.create(team=self.own_team, member=coach_member, season=self.season, position=self.manager_position)
+
+ def test_a_coach_only_sees_their_own_team_in_the_list(self):
+ self.client.force_login(self.coach_user)
+
+ response = self.club_get("team_list")
+
+ self.assertContains(response, "First Team")
+ self.assertNotContains(response, "Second Team")
+
+ def test_an_admin_sees_every_team_in_the_list(self):
+ self.client.force_login(self.admin_user)
+
+ response = self.club_get("team_list")
+
+ self.assertContains(response, "First Team")
+ self.assertContains(response, "Second Team")
+
+ def test_a_coach_does_not_see_the_new_team_button(self):
+ self.client.force_login(self.coach_user)
+
+ response = self.club_get("team_list")
+
+ self.assertNotContains(response, reverse("management:team_create"))
+
+ def test_a_coach_does_not_see_the_edit_button_on_their_team_page(self):
+ self.client.force_login(self.coach_user)
+
+ response = self.club_get("team_detail", self.own_team.pk)
+
+ self.assertNotContains(response, reverse("management:team_update", args=[self.own_team.pk]))
+
+ def test_a_coach_can_view_positions_but_not_edit_them(self):
+ self.client.force_login(self.coach_user)
+
+ response = self.club_get("position_list")
+
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, "Head Coach")
+ self.assertNotContains(response, reverse("management:position_create"))
+ self.assertNotContains(response, reverse("management:position_update", args=[self.manager_position.pk]))
+
+ def test_a_coach_cannot_create_or_edit_a_position(self):
+ self.client.force_login(self.coach_user)
+
+ self.assertEqual(self.club_get("position_create").status_code, 403)
+ self.assertEqual(self.club_get("position_update", self.manager_position.pk).status_code, 403)
+
+
+class TeamListCountsTests(ManagementTestBase):
+ """Player/staff counts on the team list -- see TeamListView.get_queryset."""
+
+ def setUp(self):
+ super().setUp()
+ self.team = Team.objects.create(club=self.club, name="First Team", short_name="1st")
+ 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="Coach", short_name="C", staff_position=True, management_position=True)
+ self.client.force_login(self.admin_user)
+
+ def test_counts_reflect_the_current_seasons_roster_and_staff(self):
+ peter = Member.objects.create(first_name="Peter", last_name="Player")
+ paula = Member.objects.create(first_name="Paula", last_name="Player")
+ cara = Member.objects.create(first_name="Cara", last_name="Coach")
+ TeamMembership.objects.create(team=self.team, season=self.season, member=peter, position=self.player_position)
+ TeamMembership.objects.create(team=self.team, season=self.season, member=paula, position=self.player_position)
+ StaffAssignment.objects.create(team=self.team, season=self.season, member=cara, position=self.coach_position)
+
+ response = self.club_get("team_list")
+
+ team = response.context["teams"].get(pk=self.team.pk)
+ self.assertEqual(team.player_count, 2)
+ self.assertEqual(team.staff_count, 1)
+
+ def test_counts_exclude_a_different_season(self):
+ other_season = Season.objects.create(club=self.club, start_date=datetime.date(2020, 1, 1), end_date=datetime.date(2020, 12, 31))
+ peter = Member.objects.create(first_name="Peter", last_name="Player")
+ TeamMembership.objects.create(team=self.team, season=other_season, member=peter, position=self.player_position)
+
+ response = self.club_get("team_list")
+
+ team = response.context["teams"].get(pk=self.team.pk)
+ self.assertEqual(team.player_count, 0)
+
+
+class LocationOpponentManagementTests(ManagementTestBase):
+ """Full CRUD for Location/Opponent -- restricted to ADMIN and anyone with a
+ current-season management position, see club.mixins.ManagementPositionRequiredMixin
+ and management.views.LocationListView/OpponentListView (and their Create/Update/Delete
+ siblings)."""
+
+ def setUp(self):
+ super().setUp()
+ self.team = Team.objects.create(club=self.club, name="First Team", short_name="1st")
+
+ def make_coach_manager(self, email="coach-loc@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")
+ position = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True)
+ StaffAssignment.objects.create(team=self.team, member=coach_member, season=self.season, position=position)
+ return coach_user
+
+ def make_plain_staff(self, email="physio-loc@example.com"):
+ staff_user = User.objects.create_user(email=email, password="pw-secret-123")
+ staff_member = Member.objects.create(user=staff_user, first_name="Pat", last_name="Physio")
+ position = Position.objects.create(club=self.club, name="Physio", short_name="PH", staff_position=True, management_position=False)
+ StaffAssignment.objects.create(team=self.team, member=staff_member, season=self.season, position=position)
+ return staff_user
+
+ # --- Locations ---------------------------------------------------------
+
+ def test_a_management_position_can_view_the_location_list(self):
+ Location.objects.create(club=self.club, name="Main Field", address="1 St", city="Town", zip_code="1000", country="BE")
+ self.client.force_login(self.make_coach_manager())
+
+ response = self.club_get("location_list")
+
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, "Main Field")
+
+ def test_the_location_form_renders_country_as_a_dropdown(self):
+ # Regression: CountryField's widget reports widget_type "lazyselect", which
+ # the form_field templatetag didn't recognise -- it fell through to the
+ # "input" case and rendered a plain (i.e. a
+ # broken text box), not a .
+ self.client.force_login(self.make_coach_manager())
+
+ response = self.club_get("location_create")
+
+ self.assertNotContains(response, 'type="lazyselect"')
+ self.assertContains(response, "Belgium")
+ self.assertContains(response, '/edit/", views.LocationUpdateView.as_view(), name="location_update"),
+ path("locations//delete/", views.LocationDeleteView.as_view(), name="location_delete"),
path("opponents/", views.OpponentListView.as_view(), name="opponent_list"),
+ path("opponents/new/", views.OpponentCreateView.as_view(), name="opponent_create"),
+ path("opponents//edit/", views.OpponentUpdateView.as_view(), name="opponent_update"),
+ path("opponents//delete/", views.OpponentDeleteView.as_view(), name="opponent_delete"),
# Shop (admin only)
path("shop/products/", views.ProductListView.as_view(), name="product_list"),
path("shop/orders/", views.OrderListView.as_view(), name="order_list"),
diff --git a/management/views.py b/management/views.py
index 9f80eef..69012fd 100644
--- a/management/views.py
+++ b/management/views.py
@@ -1,5 +1,5 @@
from django.db import IntegrityError, transaction
-from django.db.models import Count, ProtectedError
+from django.db.models import Count, ProtectedError, Q
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
@@ -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, TeamManagerRequiredMixin
+from club.mixins import ClubAdminRequiredMixin, ClubStaffRequiredMixin, ManagementPositionRequiredMixin, NewsAuthorRequiredMixin, NewsEditRequiredMixin, NewsPublisherRequiredMixin, TeamManagerRequiredMixin
from club.models import ClubMembership, ClubRole, Season
-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.access import can_edit_news, can_publish_news, current_season, is_club_admin, members_visible_to, teams_managed_by, teams_staffed_by
from club.services.fees import mark_as_paid, record_payment, remaining_balance
from controlpanel.messages import notify
from controlpanel.mixins import RedirectOnInvalidMixin
@@ -35,11 +35,13 @@ from .forms import (
ClubRoleAssignForm,
FamilyCreateForm,
GrantLoginForm,
+ LocationForm,
MemberForm,
MemberImportUploadForm,
NewsForm,
NewsPhotoUploadForm,
NewsPublishForm,
+ OpponentForm,
PositionForm,
RecordFeePaymentForm,
StaffAssignmentForm,
@@ -675,15 +677,25 @@ class MemberDeleteView(ClubAdminRequiredMixin, View):
class TeamListView(ClubStaffRequiredMixin, ListView):
+ """ADMIN sees every team; everyone else (coach, manager, other staff) only
+ the teams they're staffed on this season -- same visibility rule as
+ ``members_visible_to``, not the narrower management-only ``teams_managed_by``."""
+
template_name = "management/team_list.html"
context_object_name = "teams"
def get_queryset(self):
- teams = Team.objects.filter(club=self.request.club)
+ club = self.request.club
+ teams = Team.objects.filter(club=club) if is_club_admin(self.request.user, club) else teams_staffed_by(self.request.user, club)
search = self.request.GET.get("q", "").strip()
if search:
teams = teams.filter(name__icontains=search)
- return teams
+
+ season = current_season(club)
+ return teams.annotate(
+ player_count=Count("roster", filter=Q(roster__season=season), distinct=True),
+ staff_count=Count("staff_assignments", filter=Q(staff_assignments__season=season), distinct=True),
+ )
def get_context_data(self, **kwargs):
return super().get_context_data(search=self.request.GET.get("q", ""), **kwargs)
@@ -1160,7 +1172,11 @@ class FamilyAddParentView(ClubAdminRequiredMixin, RedirectOnInvalidMixin, FormVi
return redirect("management:family_detail", pk=family.pk)
-class PositionListView(ClubAdminRequiredMixin, ListView):
+class PositionListView(ClubStaffRequiredMixin, ListView):
+ """Visible to any staff (coaches need to see positions to make sense of a
+ roster); creating/editing positions is still ADMIN-only, gated in the
+ template and on PositionCreateView/PositionUpdateView themselves."""
+
template_name = "management/position_list.html"
context_object_name = "positions"
@@ -1381,7 +1397,15 @@ class NewsPhotoDeleteView(NewsEditRequiredMixin, View):
def post(self, request, pk, photo_pk):
news_item = self.get_news_item()
photo = get_object_or_404(NewsPhoto, pk=photo_pk, news_item=news_item)
- photo.delete()
+ was_main = photo.is_main
+
+ with transaction.atomic():
+ photo.delete()
+ if was_main:
+ replacement = news_item.photos.first()
+ if replacement is not None:
+ replacement.is_main = True
+ replacement.save(update_fields=["is_main"])
notify(request, f"w|{_('Photo removed')}|{_('The photo was removed.')}")
return redirect("management:news_detail", pk=news_item.pk)
@@ -1401,20 +1425,118 @@ class EventSeriesListView(ClubStaffRequiredMixin, StubListMixin, ListView):
return EventSeries.objects.filter(club=self.request.club)
-class LocationListView(ClubStaffRequiredMixin, StubListMixin, ListView):
- page_title = _("Locations")
+class LocationListView(ManagementPositionRequiredMixin, ListView):
+ template_name = "management/location_list.html"
+ context_object_name = "locations"
def get_queryset(self):
return Location.objects.filter(club=self.request.club)
-class OpponentListView(ClubStaffRequiredMixin, StubListMixin, ListView):
- page_title = _("Opponents")
+class LocationCreateView(ManagementPositionRequiredMixin, CreateView):
+ model = Location
+ form_class = LocationForm
+ template_name = "management/location_form.html"
+
+ def form_valid(self, form):
+ response = super().form_valid(form)
+ body = _("“%(location)s” created.") % {"location": self.object}
+ notify(self.request, f"s|{_('Location created')}|{body}")
+ return response
+
+ def get_success_url(self):
+ return reverse("management:location_list")
+
+
+class LocationUpdateView(ManagementPositionRequiredMixin, UpdateView):
+ model = Location
+ form_class = LocationForm
+ template_name = "management/location_form.html"
+
+ def get_queryset(self):
+ return Location.objects.filter(club=self.request.club)
+
+ def form_valid(self, form):
+ response = super().form_valid(form)
+ body = _("“%(location)s” updated.") % {"location": self.object}
+ notify(self.request, f"s|{_('Location updated')}|{body}")
+ return response
+
+ def get_success_url(self):
+ return reverse("management:location_list")
+
+ def get_context_data(self, **kwargs):
+ return super().get_context_data(update_view=True, **kwargs)
+
+
+class LocationDeleteView(ManagementPositionRequiredMixin, View):
+ def post(self, request, pk):
+ location = get_object_or_404(Location.objects.filter(club=request.club), pk=pk)
+ name = str(location)
+ # Event/EventSeries.location is SET_NULL -- no ProtectedError to catch.
+ location.delete()
+
+ body = _("“%(location)s” has been deleted.") % {"location": name}
+ notify(request, f"w|{_('Location deleted')}|{body}")
+ return redirect("management:location_list")
+
+
+class OpponentListView(ManagementPositionRequiredMixin, ListView):
+ template_name = "management/opponent_list.html"
+ context_object_name = "opponents"
def get_queryset(self):
return Opponent.objects.filter(club=self.request.club)
+class OpponentCreateView(ManagementPositionRequiredMixin, CreateView):
+ model = Opponent
+ form_class = OpponentForm
+ template_name = "management/opponent_form.html"
+
+ def form_valid(self, form):
+ response = super().form_valid(form)
+ body = _("“%(opponent)s” created.") % {"opponent": self.object}
+ notify(self.request, f"s|{_('Opponent created')}|{body}")
+ return response
+
+ def get_success_url(self):
+ return reverse("management:opponent_list")
+
+
+class OpponentUpdateView(ManagementPositionRequiredMixin, UpdateView):
+ model = Opponent
+ form_class = OpponentForm
+ template_name = "management/opponent_form.html"
+
+ def get_queryset(self):
+ return Opponent.objects.filter(club=self.request.club)
+
+ def form_valid(self, form):
+ response = super().form_valid(form)
+ body = _("“%(opponent)s” updated.") % {"opponent": self.object}
+ notify(self.request, f"s|{_('Opponent updated')}|{body}")
+ return response
+
+ def get_success_url(self):
+ return reverse("management:opponent_list")
+
+ def get_context_data(self, **kwargs):
+ return super().get_context_data(update_view=True, **kwargs)
+
+
+class OpponentDeleteView(ManagementPositionRequiredMixin, View):
+ def post(self, request, pk):
+ opponent = get_object_or_404(Opponent.objects.filter(club=request.club), pk=pk)
+ name = str(opponent)
+ # Event/EventSeries.opponent is SET_NULL -- no ProtectedError to catch.
+ opponent.delete()
+
+ body = _("“%(opponent)s” has been deleted.") % {"opponent": name}
+ notify(request, f"w|{_('Opponent deleted')}|{body}")
+ return redirect("management:opponent_list")
+
+
class ProductListView(ClubAdminRequiredMixin, StubListMixin, ListView):
page_title = _("Products")
diff --git a/news/apps.py b/news/apps.py
index 42c63ba..2c5b670 100644
--- a/news/apps.py
+++ b/news/apps.py
@@ -3,3 +3,6 @@ from django.apps import AppConfig
class NewsConfig(AppConfig):
name = "news"
+
+ def ready(self):
+ from . import signals # noqa: F401
diff --git a/news/signals.py b/news/signals.py
new file mode 100644
index 0000000..fc35865
--- /dev/null
+++ b/news/signals.py
@@ -0,0 +1,18 @@
+"""Keep NewsPhoto's file in sync with the row: deleting a NewsPhoto -- one at
+a time, or in bulk via a News item's cascade -- must also delete the image
+from storage, or it just accumulates orphaned files forever.
+
+Connecting a post_delete receiver also stops Django's fast-delete
+optimisation for a News cascade, so every NewsPhoto instance (and this
+signal) actually runs instead of being collapsed into one bulk SQL DELETE.
+"""
+
+from django.db.models.signals import post_delete
+from django.dispatch import receiver
+
+from .models import NewsPhoto
+
+
+@receiver(post_delete, sender=NewsPhoto)
+def delete_photo_file(sender, instance, **kwargs):
+ instance.image.delete(save=False)
diff --git a/pyproject.toml b/pyproject.toml
index eebf55e..35a205e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,6 +6,7 @@ dependencies = [
"dj-database-url>=3.1.2",
"django>=6.0.6",
"django-allauth[mfa]>=65.18.0",
+ "django-countries>=9.0.0",
"django-lucide",
"django-phonenumber-field[phonenumbers]>=8.4.0",
"django-redis>=7.0.0",
diff --git a/rosterchief/settings.py b/rosterchief/settings.py
index 112106b..b2f84b8 100644
--- a/rosterchief/settings.py
+++ b/rosterchief/settings.py
@@ -54,6 +54,7 @@ INSTALLED_APPS = [
# that page raises TemplateSyntaxError.
"django.contrib.humanize",
"phonenumber_field",
+ "django_countries",
"lucide",
# Auth: allauth deliberately WITHOUT django.contrib.sites — it is optional in
# allauth 65+, and ARCHITECTURE.md §2.4 rejects the Sites framework (Club is
@@ -188,6 +189,7 @@ TEMPLATES = [
"club.context_processors.branding",
"features.context_processors.maintenance",
"management.context_processors.is_admin",
+ "management.context_processors.management_position",
"management.context_processors.management_link",
"management.context_processors.active_nav_section",
"management.context_processors.news_permissions",
diff --git a/static/css/app.css b/static/css/app.css
index c52e3cd..0a1fdc1 100644
--- a/static/css/app.css
+++ b/static/css/app.css
@@ -1856,6 +1856,42 @@
}
}
}
+ .swap {
+ @layer daisyui.l1.l2 {
+ position: relative;
+ display: inline-grid;
+ cursor: pointer;
+ place-content: center;
+ vertical-align: middle;
+ webkit-user-select: none;
+ user-select: none;
+ input {
+ appearance: none;
+ border: none;
+ }
+ > * {
+ grid-column-start: 1;
+ grid-row-start: 1;
+ @media (prefers-reduced-motion: no-preference) {
+ transition-property: transform, rotate, opacity;
+ transition-duration: 0.2s;
+ transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
+ }
+ }
+ .swap-on, .swap-indeterminate, input:indeterminate ~ .swap-on {
+ opacity: 0%;
+ }
+ input:is(:checked, :indeterminate) {
+ & ~ .swap-off {
+ opacity: 0%;
+ }
+ }
+ input:checked ~ .swap-on, input:indeterminate ~ .swap-indeterminate {
+ opacity: 100%;
+ backface-visibility: visible;
+ }
+ }
+ }
.collapse-title {
@layer daisyui.l1.l2.l3 {
grid-column-start: 1;
@@ -3078,6 +3114,10 @@
.aspect-square {
aspect-ratio: 1 / 1;
}
+ .size-8 {
+ width: calc(var(--spacing) * 8);
+ height: calc(var(--spacing) * 8);
+ }
.h-12 {
height: calc(var(--spacing) * 12);
}
@@ -3867,6 +3907,12 @@
--badge-fg: var(--color-neutral-content);
}
}
+ .badge-primary {
+ @layer daisyui.l1.l2 {
+ --badge-color: var(--color-primary);
+ --badge-fg: var(--color-primary-content);
+ }
+ }
.badge-success {
@layer daisyui.l1.l2 {
--badge-color: var(--color-success);
diff --git a/uv.lock b/uv.lock
index 09e0762..199a9a1 100644
--- a/uv.lock
+++ b/uv.lock
@@ -306,6 +306,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/46/61/1b4a8c589652859995bcab87682286443eb9fdf2d7fd584975b9ffc1db33/django_browser_reload-1.21.0-py3-none-any.whl", hash = "sha256:0b2a86ab460774fa9bb142a121c70e75a72f18109f51a4f6de409cd633d3a70d", size = 12852, upload-time = "2025-09-22T17:00:33.479Z" },
]
+[[package]]
+name = "django-countries"
+version = "9.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "asgiref" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/6e/c1/1e8feb818164c3f23465c08cc3bc1cd7da7101506268a138a1b92ea40339/django_countries-9.0.0.tar.gz", hash = "sha256:a993416af08a8a4e6e866d56b71c7ce92351c81a2543da213cd7899917567a42", size = 614144, upload-time = "2026-06-10T00:39:28.841Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5c/bb/5a17b339852f6486d02b4dd0033132082976c71f049ee909ccb42917be52/django_countries-9.0.0-py3-none-any.whl", hash = "sha256:21fce461733c856355c487d1f24b71338599482505004a0c2e521fd574a59fb1", size = 931759, upload-time = "2026-06-10T00:39:26.851Z" },
+]
+
[[package]]
name = "django-lucide"
version = "1.3.1"
@@ -636,6 +649,7 @@ dependencies = [
{ name = "dj-database-url" },
{ name = "django" },
{ name = "django-allauth", extra = ["mfa"] },
+ { name = "django-countries" },
{ name = "django-lucide" },
{ name = "django-phonenumber-field", extra = ["phonenumbers"] },
{ name = "django-redis" },
@@ -663,6 +677,7 @@ requires-dist = [
{ name = "dj-database-url", specifier = ">=3.1.2" },
{ name = "django", specifier = ">=6.0.6" },
{ name = "django-allauth", extras = ["mfa"], specifier = ">=65.18.0" },
+ { name = "django-countries", specifier = ">=9.0.0" },
{ name = "django-lucide", git = "https://github.com/bsiebens/lucide" },
{ name = "django-phonenumber-field", extras = ["phonenumbers"], specifier = ">=8.4.0" },
{ name = "django-redis", specifier = ">=7.0.0" },
@@ -764,6 +779,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/52/48/01695a036b695f83fea7aef6955d735db0f517b1c8e25ddb399ac0bdbcbf/tinyhtml5-2.1.0-py3-none-any.whl", hash = "sha256:6e11cfff38515834268daf89d5f85bbde0b6dd02e8d9e212d1385c2289b89f0a", size = 39686, upload-time = "2026-03-05T17:06:28.498Z" },
]
+[[package]]
+name = "typing-extensions"
+version = "4.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
+]
+
[[package]]
name = "tzdata"
version = "2026.2"