Rework Teams/Positions access control and add full Locations/Opponents CRUD

Team managers/coaches now only see their own teams, can't create teams,
and can view (but not edit) positions -- admins keep full rights.
Locations and Opponents move from read-only stubs to full CRUD, gated
to admins and management-position staff, with a country dropdown
(django-countries) instead of free text. Also: the team list shows
player/staff counts, and deleting a news item's main photo promotes
another one instead of leaving the item without one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1gj3J1QPfP38XWpnpbFpy
This commit is contained in:
2026-08-04 12:27:24 +02:00
parent 9a4da9b136
commit e6850232f0
23 changed files with 813 additions and 25 deletions

View File

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

View File

@@ -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'),
),
]

View File

@@ -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'),
),
]

View File

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

View File

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

View File

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

View File

@@ -23,9 +23,7 @@
<li class="menu-title">{% trans "Teams" %}</li>
<li><a class="{% if nav == 'team_list' %}menu-active{% endif %}" href="{% url 'management:team_list' %}">{% lucide "shirt" size=16 %} {% trans "Teams" %}</a></li>
{% if is_club_admin %}
<li><a class="{% if nav == 'position_list' %}menu-active{% endif %}" href="{% url 'management:position_list' %}">{% lucide "tags" size=16 %} {% trans "Positions" %}</a></li>
{% endif %}
<li><a class="{% if nav == 'position_list' %}menu-active{% endif %}" href="{% url 'management:position_list' %}">{% lucide "tags" size=16 %} {% trans "Positions" %}</a></li>
<li class="menu-title">{% trans "News" %}</li>
<li><a class="{% if nav == 'news_list' %}menu-active{% endif %}" href="{% url 'management:news_list' %}">{% lucide "newspaper" size=16 %} {% trans "News" %}</a></li>
@@ -33,8 +31,10 @@
<li class="menu-title">{% trans "Calendar" %}</li>
<li><a class="{% if nav == 'event_list' %}menu-active{% endif %}" href="{% url 'management:event_list' %}">{% lucide "calendar" size=16 %} {% trans "Events" %}</a></li>
<li><a class="{% if nav == 'event_series_list' %}menu-active{% endif %}" href="{% url 'management:event_series_list' %}">{% lucide "repeat" size=16 %} {% trans "Event series" %}</a></li>
<li><a class="{% if nav == 'location_list' %}menu-active{% endif %}" href="{% url 'management:location_list' %}">{% lucide "map-pin" size=16 %} {% trans "Locations" %}</a></li>
<li><a class="{% if nav == 'opponent_list' %}menu-active{% endif %}" href="{% url 'management:opponent_list' %}">{% lucide "swords" size=16 %} {% trans "Opponents" %}</a></li>
{% if has_management_position %}
<li><a class="{% if nav == 'location_list' %}menu-active{% endif %}" href="{% url 'management:location_list' %}">{% lucide "map-pin" size=16 %} {% trans "Locations" %}</a></li>
<li><a class="{% if nav == 'opponent_list' %}menu-active{% endif %}" href="{% url 'management:opponent_list' %}">{% lucide "swords" size=16 %} {% trans "Opponents" %}</a></li>
{% endif %}
{% if is_club_admin %}
<li class="menu-title">{% trans "Shop" %}</li>

View File

@@ -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 %}
<div class="card w-full bg-base-100 shadow">
<div class="card-body">
<form method="post">
{% csrf_token %}
{% for error in form.non_field_errors %}
<div class="alert alert-error my-2">
<span>{{ error }}</span>
</div>
{% endfor %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{% for field in form %}
{% form_field field %}
{% endfor %}
</div>
<div class="card-actions justify-start pt-2 mt-2">
<a class="btn btn-outline btn-neutral gap-2" href="{% url "management:location_list" %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
</div>
</form>
</div>
</div>
{% endblock panel %}
{% block extra_body %}
<script src="{% static 'js/searchable-select.js' %}"></script>
{% endblock extra_body %}

View File

@@ -0,0 +1,59 @@
{% extends "management/base.html" %}
{% load i18n lucide ui %}
{% block heading %}{% trans "Locations" %}{% endblock heading %}
{% block actions %}
<a class="btn btn-primary gap-2" href="{% url 'management:location_create' %}">{% lucide "plus" size=16 %} {% trans "New location" %}</a>
{% endblock actions %}
{% block panel %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Address" %}</th>
<th>{% trans "City" %}</th>
<th>{% trans "Country" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for location in locations %}
<tr>
<td>
{{ location.name }}
{% if location.is_home %}<span class="badge badge-sm badge-primary ml-1">{% trans "Home" %}</span>{% endif %}
</td>
<td>{{ location.address }}</td>
<td>{{ location.city }}</td>
<td>{{ location.country }}</td>
<td class="text-right">
<div class="flex justify-end gap-1">
<a class="btn btn-sm btn-outline btn-neutral" href="{% url 'management:location_update' location.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ location.pk|dom_id:"location_delete_modal" }}').showModal()" aria-label="{% trans 'Delete' %}">{% lucide "trash-2" size=14 %} {% trans "Delete" %}</button>
</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="text-center opacity-60">{% trans "No locations yet." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% 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 %}

View File

@@ -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 %}
<div class="card w-full bg-base-100 shadow">
<div class="card-body">
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{% for error in form.non_field_errors %}
<div class="alert alert-error my-2">
<span>{{ error }}</span>
</div>
{% endfor %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{% for field in form %}
{% form_field field %}
{% endfor %}
</div>
<div class="card-actions justify-start pt-2 mt-2">
<a class="btn btn-outline btn-neutral gap-2" href="{% url "management:opponent_list" %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
</div>
</form>
</div>
</div>
{% endblock panel %}

View File

@@ -0,0 +1,56 @@
{% extends "management/base.html" %}
{% load i18n lucide ui %}
{% block heading %}{% trans "Opponents" %}{% endblock heading %}
{% block actions %}
<a class="btn btn-primary gap-2" href="{% url 'management:opponent_create' %}">{% lucide "plus" size=16 %} {% trans "New opponent" %}</a>
{% endblock actions %}
{% block panel %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th></th>
<th>{% trans "Name" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for opponent in opponents %}
<tr>
<td>
{% if opponent.logo %}
<img src="{{ opponent.logo.url }}" alt="" class="size-8 rounded object-contain">
{% endif %}
</td>
<td>{{ opponent.name }}</td>
<td class="text-right">
<div class="flex justify-end gap-1">
<a class="btn btn-sm btn-outline btn-neutral" href="{% url 'management:opponent_update' opponent.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ opponent.pk|dom_id:"opponent_delete_modal" }}').showModal()" aria-label="{% trans 'Delete' %}">{% lucide "trash-2" size=14 %} {% trans "Delete" %}</button>
</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="3" class="text-center opacity-60">{% trans "No opponents yet." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% 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 %}

View File

@@ -4,7 +4,9 @@
{% block heading %}{% trans "Positions" %}{% endblock heading %}
{% block actions %}
<a class="btn btn-primary gap-2" href="{% url 'management:position_create' %}">{% lucide "plus" size=16 %} {% trans "New position" %}</a>
{% if is_club_admin %}
<a class="btn btn-primary gap-2" href="{% url 'management:position_create' %}">{% lucide "plus" size=16 %} {% trans "New position" %}</a>
{% endif %}
{% endblock actions %}
{% block panel %}
@@ -39,7 +41,9 @@
</span>
</td>
<td class="text-right">
<a class="btn btn-outline btn-neutral btn-sm gap-2" href="{% url 'management:position_update' position.pk %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
{% if is_club_admin %}
<a class="btn btn-outline btn-neutral btn-sm gap-2" href="{% url 'management:position_update' position.pk %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
{% endif %}
</td>
</tr>
{% empty %}

View File

@@ -99,7 +99,7 @@
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "user-x" size=18 %} {% trans "No-shows" %}</h2>
<p class="text-sm opacity-70">{% trans "Said they'd attend, but were checked in as absent." %}</p>
<!-- <p class="text-sm opacity-70">{% trans "Said they'd attend, but were checked in as absent." %}</p> -->
<div class="overflow-x-auto">
<table class="table">
<tbody>

View File

@@ -18,6 +18,8 @@
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Short name" %}</th>
<th>{% trans "Players" %}</th>
<th>{% trans "Staff" %}</th>
<th></th>
</tr>
</thead>
@@ -26,6 +28,8 @@
<tr>
<td><a class="link link-hover" href="{% url 'management:team_detail' team.pk %}">{{ team.name }}</a></td>
<td>{{ team.short_name }}</td>
<td>{{ team.player_count }}</td>
<td>{{ team.staff_count }}</td>
<td class="text-right">
{% if is_club_admin %}
<div class="flex justify-end gap-1">
@@ -37,7 +41,7 @@
</tr>
{% empty %}
<tr>
<td colspan="3" class="text-center opacity-60">{% trans "No teams yet." %}</td>
<td colspan="5" class="text-center opacity-60">{% trans "No teams yet." %}</td>
</tr>
{% endfor %}
</tbody>

View File

@@ -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 <input type="lazyselect"> (i.e. a
# broken text box), not a <select>.
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, '<select')
def test_plain_staff_cannot_view_the_location_list(self):
self.client.force_login(self.make_plain_staff())
self.assertEqual(self.club_get("location_list").status_code, 403)
def test_a_management_position_can_create_a_location(self):
self.client.force_login(self.make_coach_manager())
response = self.club_post("location_create", {"name": "New Field", "address": "2 St", "city": "Town", "zip_code": "1000", "country": "BE"})
self.assertRedirects(response, reverse("management:location_list"))
self.assertTrue(Location.objects.filter(club=self.club, name="New Field").exists())
def test_plain_staff_cannot_create_a_location(self):
self.client.force_login(self.make_plain_staff())
response = self.club_post("location_create", {"name": "New Field", "address": "2 St", "city": "Town", "zip_code": "1000", "country": "BE"})
self.assertEqual(response.status_code, 403)
self.assertFalse(Location.objects.filter(club=self.club, name="New Field").exists())
def test_a_management_position_can_edit_a_location(self):
location = Location.objects.create(club=self.club, name="Old name", address="1 St", city="Town", zip_code="1000", country="BE")
self.client.force_login(self.make_coach_manager())
self.club_post("location_update", {"name": "New name", "address": "1 St", "city": "Town", "zip_code": "1000", "country": "BE"}, location.pk)
location.refresh_from_db()
self.assertEqual(location.name, "New name")
def test_a_management_position_can_delete_a_location(self):
location = Location.objects.create(club=self.club, name="Doomed", address="1 St", city="Town", zip_code="1000", country="BE")
self.client.force_login(self.make_coach_manager())
response = self.club_post("location_delete", {}, location.pk)
self.assertRedirects(response, reverse("management:location_list"))
self.assertFalse(Location.objects.filter(pk=location.pk).exists())
def test_deleting_a_location_nulls_it_on_events_instead_of_erroring(self):
location = Location.objects.create(club=self.club, name="Doomed", address="1 St", city="Town", zip_code="1000", country="BE")
event = Event.objects.create(club=self.club, title="Match", start=timezone.now() + datetime.timedelta(days=1), location=location)
self.client.force_login(self.admin_user)
self.club_post("location_delete", {}, location.pk)
event.refresh_from_db()
self.assertIsNone(event.location)
def test_an_admin_has_full_rights_without_any_staff_assignment(self):
self.client.force_login(self.admin_user)
self.assertEqual(self.club_get("location_list").status_code, 200)
response = self.club_post("location_create", {"name": "Admin Field", "address": "3 St", "city": "Town", "zip_code": "1000", "country": "BE"})
self.assertRedirects(response, reverse("management:location_list"))
# --- Opponents -----------------------------------------------------------
def test_a_management_position_can_view_the_opponent_list(self):
Opponent.objects.create(club=self.club, name="Rivals FC")
self.client.force_login(self.make_coach_manager())
response = self.club_get("opponent_list")
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Rivals FC")
def test_plain_staff_cannot_view_the_opponent_list(self):
self.client.force_login(self.make_plain_staff())
self.assertEqual(self.club_get("opponent_list").status_code, 403)
def test_a_management_position_can_create_an_opponent_with_a_logo(self):
self.client.force_login(self.make_coach_manager())
# Opponent.logo is a real ImageField (unlike NewsPhoto.image, set outside any
# ModelForm) -- Django's ImageField.clean() runs it through Pillow, so this
# needs to actually decode as an image, not just carry an image/png header.
one_pixel_png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82"
logo = SimpleUploadedFile("logo.png", one_pixel_png, content_type="image/png")
response = self.club_post("opponent_create", {"name": "Rivals FC", "logo": logo})
self.assertRedirects(response, reverse("management:opponent_list"))
opponent = Opponent.objects.get(club=self.club, name="Rivals FC")
self.assertTrue(opponent.logo)
def test_a_management_position_can_delete_an_opponent(self):
opponent = Opponent.objects.create(club=self.club, name="Doomed FC")
self.client.force_login(self.make_coach_manager())
response = self.club_post("opponent_delete", {}, opponent.pk)
self.assertRedirects(response, reverse("management:opponent_list"))
self.assertFalse(Opponent.objects.filter(pk=opponent.pk).exists())
def test_plain_staff_cannot_delete_an_opponent(self):
opponent = Opponent.objects.create(club=self.club, name="Safe FC")
self.client.force_login(self.make_plain_staff())
response = self.club_post("opponent_delete", {}, opponent.pk)
self.assertEqual(response.status_code, 403)
self.assertTrue(Opponent.objects.filter(pk=opponent.pk).exists())

View File

@@ -62,7 +62,13 @@ urlpatterns = [
path("events/", views.EventListView.as_view(), name="event_list"),
path("event-series/", views.EventSeriesListView.as_view(), name="event_series_list"),
path("locations/", views.LocationListView.as_view(), name="location_list"),
path("locations/new/", views.LocationCreateView.as_view(), name="location_create"),
path("locations/<uuid:pk>/edit/", views.LocationUpdateView.as_view(), name="location_update"),
path("locations/<uuid:pk>/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/<uuid:pk>/edit/", views.OpponentUpdateView.as_view(), name="opponent_update"),
path("opponents/<uuid:pk>/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"),

View File

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

View File

@@ -3,3 +3,6 @@ from django.apps import AppConfig
class NewsConfig(AppConfig):
name = "news"
def ready(self):
from . import signals # noqa: F401

18
news/signals.py Normal file
View File

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

View File

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

View File

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

View File

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

24
uv.lock generated
View File

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