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 @@
  • {% lucide "shirt" size=16 %} {% trans "Teams" %}
  • -{% if is_club_admin %} -
  • {% lucide "tags" size=16 %} {% trans "Positions" %}
  • -{% endif %} +
  • {% lucide "tags" size=16 %} {% trans "Positions" %}
  • {% lucide "newspaper" size=16 %} {% trans "News" %}
  • @@ -33,8 +31,10 @@
  • {% lucide "calendar" size=16 %} {% trans "Events" %}
  • {% lucide "repeat" size=16 %} {% trans "Event series" %}
  • -
  • {% lucide "map-pin" size=16 %} {% trans "Locations" %}
  • -
  • {% lucide "swords" size=16 %} {% trans "Opponents" %}
  • +{% if has_management_position %} +
  • {% lucide "map-pin" size=16 %} {% trans "Locations" %}
  • +
  • {% lucide "swords" size=16 %} {% trans "Opponents" %}
  • +{% 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 %} +
    +
    +
    + {% csrf_token %} + + {% for error in form.non_field_errors %} +
    + {{ error }} +
    + {% endfor %} + +
    + {% for field in form %} + {% form_field field %} + {% endfor %} +
    + +
    + {% lucide "arrow-left" size=16 %} {% trans "Cancel" %} + +
    +
    +
    +
    +{% 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 %} +
    +
    +
    + + + + + + + + + + + + {% for location in locations %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
    {% trans "Name" %}{% trans "Address" %}{% trans "City" %}{% trans "Country" %}
    + {{ location.name }} + {% if location.is_home %}{% trans "Home" %}{% endif %} + {{ location.address }}{{ location.city }}{{ location.country }} +
    + {% lucide "pencil" size=14 %} {% trans "Edit" %} + +
    +
    {% trans "No locations yet." %}
    +
    +
    +
    + + {% 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 %} +
    +
    +
    + {% csrf_token %} + + {% for error in form.non_field_errors %} +
    + {{ error }} +
    + {% endfor %} + +
    + {% for field in form %} + {% form_field field %} + {% endfor %} +
    + +
    + {% lucide "arrow-left" size=16 %} {% trans "Cancel" %} + +
    +
    +
    +
    +{% 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 %} +
    +
    +
    + + + + + + + + + + {% for opponent in opponents %} + + + + + + {% empty %} + + + + {% endfor %} + +
    {% trans "Name" %}
    + {% if opponent.logo %} + + {% endif %} + {{ opponent.name }} +
    + {% lucide "pencil" size=14 %} {% trans "Edit" %} + +
    +
    {% trans "No opponents yet." %}
    +
    +
    +
    + + {% 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 @@ + + @@ -26,6 +28,8 @@ + + {% empty %} - + {% 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
    {% trans "Name" %} {% trans "Short name" %}{% trans "Players" %}{% trans "Staff" %}
    {{ team.name }} {{ team.short_name }}{{ team.player_count }}{{ team.staff_count }} {% if is_club_admin %}
    @@ -37,7 +41,7 @@
    {% trans "No teams yet." %}{% trans "No teams yet." %}