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:
@@ -1,7 +1,7 @@
|
|||||||
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
||||||
from django.http import Http404
|
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):
|
class ClubStaffRequiredMixin(LoginRequiredMixin, UserPassesTestMixin):
|
||||||
@@ -51,6 +51,16 @@ class TeamManagerRequiredMixin(ClubStaffRequiredMixin):
|
|||||||
return teams_managed_by(user, club).filter(pk=self.get_team().pk).exists()
|
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):
|
class NewsAuthorRequiredMixin(ClubStaffRequiredMixin):
|
||||||
"""ADMIN, EDITOR, or a current-season coach_manager -- who's trusted to
|
"""ADMIN, EDITOR, or a current-season coach_manager -- who's trusted to
|
||||||
author club content in the first place (creating a draft)."""
|
author club content in the first place (creating a draft)."""
|
||||||
|
|||||||
23
events/migrations/0011_location_is_home_and_more.py
Normal file
23
events/migrations/0011_location_is_home_and_more.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
19
events/migrations/0012_alter_location_country.py
Normal file
19
events/migrations/0012_alter_location_country.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.db.models import Q
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django_countries.fields import CountryField
|
||||||
|
|
||||||
from club.models import Season
|
from club.models import Season
|
||||||
from members.models import Member
|
from members.models import Member
|
||||||
@@ -25,12 +27,20 @@ class Location(ClubScopedModel):
|
|||||||
address = models.CharField(_("address"), max_length=255)
|
address = models.CharField(_("address"), max_length=255)
|
||||||
city = models.CharField(_("city"), max_length=255)
|
city = models.CharField(_("city"), max_length=255)
|
||||||
zip_code = models.CharField(_("zip code"), 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:
|
class Meta:
|
||||||
verbose_name = _("location")
|
verbose_name = _("location")
|
||||||
verbose_name_plural = _("locations")
|
verbose_name_plural = _("locations")
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(fields=["club"], condition=Q(is_home=True), name="unique_home_location_per_club"),
|
||||||
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|||||||
@@ -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``).
|
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 --
|
#: Every management URL name, mapped to the nav item it should light up --
|
||||||
#: management/templates/management/_nav_items.html compares against this.
|
#: management/templates/management/_nav_items.html compares against this.
|
||||||
@@ -67,7 +67,13 @@ _NAV_SECTIONS = {
|
|||||||
"event_list": "event_list",
|
"event_list": "event_list",
|
||||||
"event_series_list": "event_series_list",
|
"event_series_list": "event_series_list",
|
||||||
"location_list": "location_list",
|
"location_list": "location_list",
|
||||||
|
"location_create": "location_list",
|
||||||
|
"location_update": "location_list",
|
||||||
|
"location_delete": "location_list",
|
||||||
"opponent_list": "opponent_list",
|
"opponent_list": "opponent_list",
|
||||||
|
"opponent_create": "opponent_list",
|
||||||
|
"opponent_update": "opponent_list",
|
||||||
|
"opponent_delete": "opponent_list",
|
||||||
"product_list": "product_list",
|
"product_list": "product_list",
|
||||||
"order_list": "order_list",
|
"order_list": "order_list",
|
||||||
"discount_list": "discount_list",
|
"discount_list": "discount_list",
|
||||||
@@ -96,6 +102,17 @@ def is_admin(request):
|
|||||||
return {"is_club_admin": is_club_admin(request.user, club)}
|
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):
|
def management_link(request):
|
||||||
"""Whether to show a "Management" link in the global navbar (templates/_base.html),
|
"""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
|
next to the Django admin one -- only on a club subdomain, and only for someone with
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from django.utils import timezone
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from club.models import ClubMembership, ClubRole, FeePayment
|
from club.models import ClubMembership, ClubRole, FeePayment
|
||||||
|
from events.models import Location, Opponent
|
||||||
from members.models import Family, FamilyMembership, Member
|
from members.models import Family, FamilyMembership, Member
|
||||||
from members.services.family import find_member_by_email
|
from members.services.family import find_member_by_email
|
||||||
from news.models import News
|
from news.models import News
|
||||||
@@ -97,6 +98,25 @@ class PositionForm(forms.ModelForm):
|
|||||||
return cleaned
|
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):
|
class ClubRoleAssignForm(forms.ModelForm):
|
||||||
"""Grant a club-wide role to a member already affiliated with this club."""
|
"""Grant a club-wide role to a member already affiliated with this club."""
|
||||||
|
|
||||||
|
|||||||
@@ -23,9 +23,7 @@
|
|||||||
|
|
||||||
<li class="menu-title">{% trans "Teams" %}</li>
|
<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>
|
<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>
|
||||||
<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 class="menu-title">{% trans "News" %}</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>
|
<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 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_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 == '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>
|
{% if has_management_position %}
|
||||||
<li><a class="{% if nav == 'opponent_list' %}menu-active{% endif %}" href="{% url 'management:opponent_list' %}">{% lucide "swords" size=16 %} {% trans "Opponents" %}</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>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if is_club_admin %}
|
{% if is_club_admin %}
|
||||||
<li class="menu-title">{% trans "Shop" %}</li>
|
<li class="menu-title">{% trans "Shop" %}</li>
|
||||||
|
|||||||
35
management/templates/management/location_form.html
Normal file
35
management/templates/management/location_form.html
Normal 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 %}
|
||||||
59
management/templates/management/location_list.html
Normal file
59
management/templates/management/location_list.html
Normal 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 %}
|
||||||
31
management/templates/management/opponent_form.html
Normal file
31
management/templates/management/opponent_form.html
Normal 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 %}
|
||||||
56
management/templates/management/opponent_list.html
Normal file
56
management/templates/management/opponent_list.html
Normal 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 %}
|
||||||
@@ -4,7 +4,9 @@
|
|||||||
{% block heading %}{% trans "Positions" %}{% endblock heading %}
|
{% block heading %}{% trans "Positions" %}{% endblock heading %}
|
||||||
|
|
||||||
{% block actions %}
|
{% block actions %}
|
||||||
|
{% if is_club_admin %}
|
||||||
<a class="btn btn-primary gap-2" href="{% url 'management:position_create' %}">{% lucide "plus" size=16 %} {% trans "New position" %}</a>
|
<a class="btn btn-primary gap-2" href="{% url 'management:position_create' %}">{% lucide "plus" size=16 %} {% trans "New position" %}</a>
|
||||||
|
{% endif %}
|
||||||
{% endblock actions %}
|
{% endblock actions %}
|
||||||
|
|
||||||
{% block panel %}
|
{% block panel %}
|
||||||
@@ -39,7 +41,9 @@
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
|
{% 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>
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
|
|||||||
@@ -99,7 +99,7 @@
|
|||||||
<div class="card bg-base-100 shadow">
|
<div class="card bg-base-100 shadow">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title text-base">{% lucide "user-x" size=18 %} {% trans "No-shows" %}</h2>
|
<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">
|
<div class="overflow-x-auto">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>{% trans "Name" %}</th>
|
<th>{% trans "Name" %}</th>
|
||||||
<th>{% trans "Short name" %}</th>
|
<th>{% trans "Short name" %}</th>
|
||||||
|
<th>{% trans "Players" %}</th>
|
||||||
|
<th>{% trans "Staff" %}</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -26,6 +28,8 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td><a class="link link-hover" href="{% url 'management:team_detail' team.pk %}">{{ team.name }}</a></td>
|
<td><a class="link link-hover" href="{% url 'management:team_detail' team.pk %}">{{ team.name }}</a></td>
|
||||||
<td>{{ team.short_name }}</td>
|
<td>{{ team.short_name }}</td>
|
||||||
|
<td>{{ team.player_count }}</td>
|
||||||
|
<td>{{ team.staff_count }}</td>
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
{% if is_club_admin %}
|
{% if is_club_admin %}
|
||||||
<div class="flex justify-end gap-1">
|
<div class="flex justify-end gap-1">
|
||||||
@@ -37,7 +41,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr>
|
<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>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
@@ -13,7 +14,7 @@ from django.urls import NoReverseMatch, reverse
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from club.models import Club, ClubMembership, ClubRole, FeePayment, Season
|
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.bulk_import import TEMPLATE_COLUMNS
|
||||||
from management.pdf import PDFExportError, render_pdf
|
from management.pdf import PDFExportError, render_pdf
|
||||||
from members.models import Family, FamilyMembership, Member
|
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)
|
StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position)
|
||||||
self.client.force_login(coach_user)
|
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)
|
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):
|
def test_deleting_a_photo_removes_it(self):
|
||||||
item = News.objects.create(club=self.club, title="Match report", body="Body.")
|
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 = 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.client.force_login(self.make_coach_manager())
|
||||||
|
|
||||||
self.club_post("news_photo_delete", {}, item.pk, photo.pk)
|
self.club_post("news_photo_delete", {}, item.pk, photo.pk)
|
||||||
|
|
||||||
self.assertFalse(NewsPhoto.objects.filter(pk=photo.pk).exists())
|
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):
|
def test_a_coach_manager_can_delete_a_draft(self):
|
||||||
item = News.objects.create(club=self.club, title="Draft item", body="Body.")
|
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):
|
def test_deleting_a_news_item_removes_its_photos(self):
|
||||||
item = News.objects.create(club=self.club, title="Match report", body="Body.")
|
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 = 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.client.force_login(self.make_coach_manager())
|
||||||
|
|
||||||
self.club_post("news_delete", {}, item.pk)
|
self.club_post("news_delete", {}, item.pk)
|
||||||
|
|
||||||
self.assertFalse(NewsPhoto.objects.filter(pk=photo.pk).exists())
|
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):
|
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.")
|
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, "Peter Player")
|
||||||
self.assertContains(response, attendance.event.title)
|
self.assertContains(response, attendance.event.title)
|
||||||
self.assertNotContains(response, "None recorded.")
|
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())
|
||||||
|
|||||||
@@ -62,7 +62,13 @@ urlpatterns = [
|
|||||||
path("events/", views.EventListView.as_view(), name="event_list"),
|
path("events/", views.EventListView.as_view(), name="event_list"),
|
||||||
path("event-series/", views.EventSeriesListView.as_view(), name="event_series_list"),
|
path("event-series/", views.EventSeriesListView.as_view(), name="event_series_list"),
|
||||||
path("locations/", views.LocationListView.as_view(), name="location_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/", 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)
|
# Shop (admin only)
|
||||||
path("shop/products/", views.ProductListView.as_view(), name="product_list"),
|
path("shop/products/", views.ProductListView.as_view(), name="product_list"),
|
||||||
path("shop/orders/", views.OrderListView.as_view(), name="order_list"),
|
path("shop/orders/", views.OrderListView.as_view(), name="order_list"),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from django.db import IntegrityError, transaction
|
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.http import HttpResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.urls import reverse
|
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.utils.translation import ngettext
|
||||||
from django.views.generic import CreateView, DetailView, FormView, ListView, TemplateView, UpdateView, View
|
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.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 club.services.fees import mark_as_paid, record_payment, remaining_balance
|
||||||
from controlpanel.messages import notify
|
from controlpanel.messages import notify
|
||||||
from controlpanel.mixins import RedirectOnInvalidMixin
|
from controlpanel.mixins import RedirectOnInvalidMixin
|
||||||
@@ -35,11 +35,13 @@ from .forms import (
|
|||||||
ClubRoleAssignForm,
|
ClubRoleAssignForm,
|
||||||
FamilyCreateForm,
|
FamilyCreateForm,
|
||||||
GrantLoginForm,
|
GrantLoginForm,
|
||||||
|
LocationForm,
|
||||||
MemberForm,
|
MemberForm,
|
||||||
MemberImportUploadForm,
|
MemberImportUploadForm,
|
||||||
NewsForm,
|
NewsForm,
|
||||||
NewsPhotoUploadForm,
|
NewsPhotoUploadForm,
|
||||||
NewsPublishForm,
|
NewsPublishForm,
|
||||||
|
OpponentForm,
|
||||||
PositionForm,
|
PositionForm,
|
||||||
RecordFeePaymentForm,
|
RecordFeePaymentForm,
|
||||||
StaffAssignmentForm,
|
StaffAssignmentForm,
|
||||||
@@ -675,15 +677,25 @@ class MemberDeleteView(ClubAdminRequiredMixin, View):
|
|||||||
|
|
||||||
|
|
||||||
class TeamListView(ClubStaffRequiredMixin, ListView):
|
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"
|
template_name = "management/team_list.html"
|
||||||
context_object_name = "teams"
|
context_object_name = "teams"
|
||||||
|
|
||||||
def get_queryset(self):
|
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()
|
search = self.request.GET.get("q", "").strip()
|
||||||
if search:
|
if search:
|
||||||
teams = teams.filter(name__icontains=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):
|
def get_context_data(self, **kwargs):
|
||||||
return super().get_context_data(search=self.request.GET.get("q", ""), **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)
|
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"
|
template_name = "management/position_list.html"
|
||||||
context_object_name = "positions"
|
context_object_name = "positions"
|
||||||
|
|
||||||
@@ -1381,7 +1397,15 @@ class NewsPhotoDeleteView(NewsEditRequiredMixin, View):
|
|||||||
def post(self, request, pk, photo_pk):
|
def post(self, request, pk, photo_pk):
|
||||||
news_item = self.get_news_item()
|
news_item = self.get_news_item()
|
||||||
photo = get_object_or_404(NewsPhoto, pk=photo_pk, news_item=news_item)
|
photo = get_object_or_404(NewsPhoto, pk=photo_pk, news_item=news_item)
|
||||||
|
was_main = photo.is_main
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
photo.delete()
|
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.')}")
|
notify(request, f"w|{_('Photo removed')}|{_('The photo was removed.')}")
|
||||||
return redirect("management:news_detail", pk=news_item.pk)
|
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)
|
return EventSeries.objects.filter(club=self.request.club)
|
||||||
|
|
||||||
|
|
||||||
class LocationListView(ClubStaffRequiredMixin, StubListMixin, ListView):
|
class LocationListView(ManagementPositionRequiredMixin, ListView):
|
||||||
page_title = _("Locations")
|
template_name = "management/location_list.html"
|
||||||
|
context_object_name = "locations"
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return Location.objects.filter(club=self.request.club)
|
return Location.objects.filter(club=self.request.club)
|
||||||
|
|
||||||
|
|
||||||
class OpponentListView(ClubStaffRequiredMixin, StubListMixin, ListView):
|
class LocationCreateView(ManagementPositionRequiredMixin, CreateView):
|
||||||
page_title = _("Opponents")
|
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):
|
def get_queryset(self):
|
||||||
return Opponent.objects.filter(club=self.request.club)
|
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):
|
class ProductListView(ClubAdminRequiredMixin, StubListMixin, ListView):
|
||||||
page_title = _("Products")
|
page_title = _("Products")
|
||||||
|
|
||||||
|
|||||||
@@ -3,3 +3,6 @@ from django.apps import AppConfig
|
|||||||
|
|
||||||
class NewsConfig(AppConfig):
|
class NewsConfig(AppConfig):
|
||||||
name = "news"
|
name = "news"
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
from . import signals # noqa: F401
|
||||||
|
|||||||
18
news/signals.py
Normal file
18
news/signals.py
Normal 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)
|
||||||
@@ -6,6 +6,7 @@ dependencies = [
|
|||||||
"dj-database-url>=3.1.2",
|
"dj-database-url>=3.1.2",
|
||||||
"django>=6.0.6",
|
"django>=6.0.6",
|
||||||
"django-allauth[mfa]>=65.18.0",
|
"django-allauth[mfa]>=65.18.0",
|
||||||
|
"django-countries>=9.0.0",
|
||||||
"django-lucide",
|
"django-lucide",
|
||||||
"django-phonenumber-field[phonenumbers]>=8.4.0",
|
"django-phonenumber-field[phonenumbers]>=8.4.0",
|
||||||
"django-redis>=7.0.0",
|
"django-redis>=7.0.0",
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ INSTALLED_APPS = [
|
|||||||
# that page raises TemplateSyntaxError.
|
# that page raises TemplateSyntaxError.
|
||||||
"django.contrib.humanize",
|
"django.contrib.humanize",
|
||||||
"phonenumber_field",
|
"phonenumber_field",
|
||||||
|
"django_countries",
|
||||||
"lucide",
|
"lucide",
|
||||||
# Auth: allauth deliberately WITHOUT django.contrib.sites — it is optional in
|
# Auth: allauth deliberately WITHOUT django.contrib.sites — it is optional in
|
||||||
# allauth 65+, and ARCHITECTURE.md §2.4 rejects the Sites framework (Club is
|
# allauth 65+, and ARCHITECTURE.md §2.4 rejects the Sites framework (Club is
|
||||||
@@ -188,6 +189,7 @@ TEMPLATES = [
|
|||||||
"club.context_processors.branding",
|
"club.context_processors.branding",
|
||||||
"features.context_processors.maintenance",
|
"features.context_processors.maintenance",
|
||||||
"management.context_processors.is_admin",
|
"management.context_processors.is_admin",
|
||||||
|
"management.context_processors.management_position",
|
||||||
"management.context_processors.management_link",
|
"management.context_processors.management_link",
|
||||||
"management.context_processors.active_nav_section",
|
"management.context_processors.active_nav_section",
|
||||||
"management.context_processors.news_permissions",
|
"management.context_processors.news_permissions",
|
||||||
|
|||||||
@@ -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 {
|
.collapse-title {
|
||||||
@layer daisyui.l1.l2.l3 {
|
@layer daisyui.l1.l2.l3 {
|
||||||
grid-column-start: 1;
|
grid-column-start: 1;
|
||||||
@@ -3078,6 +3114,10 @@
|
|||||||
.aspect-square {
|
.aspect-square {
|
||||||
aspect-ratio: 1 / 1;
|
aspect-ratio: 1 / 1;
|
||||||
}
|
}
|
||||||
|
.size-8 {
|
||||||
|
width: calc(var(--spacing) * 8);
|
||||||
|
height: calc(var(--spacing) * 8);
|
||||||
|
}
|
||||||
.h-12 {
|
.h-12 {
|
||||||
height: calc(var(--spacing) * 12);
|
height: calc(var(--spacing) * 12);
|
||||||
}
|
}
|
||||||
@@ -3867,6 +3907,12 @@
|
|||||||
--badge-fg: var(--color-neutral-content);
|
--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 {
|
.badge-success {
|
||||||
@layer daisyui.l1.l2 {
|
@layer daisyui.l1.l2 {
|
||||||
--badge-color: var(--color-success);
|
--badge-color: var(--color-success);
|
||||||
|
|||||||
24
uv.lock
generated
24
uv.lock
generated
@@ -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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "django-lucide"
|
name = "django-lucide"
|
||||||
version = "1.3.1"
|
version = "1.3.1"
|
||||||
@@ -636,6 +649,7 @@ dependencies = [
|
|||||||
{ name = "dj-database-url" },
|
{ name = "dj-database-url" },
|
||||||
{ name = "django" },
|
{ name = "django" },
|
||||||
{ name = "django-allauth", extra = ["mfa"] },
|
{ name = "django-allauth", extra = ["mfa"] },
|
||||||
|
{ name = "django-countries" },
|
||||||
{ name = "django-lucide" },
|
{ name = "django-lucide" },
|
||||||
{ name = "django-phonenumber-field", extra = ["phonenumbers"] },
|
{ name = "django-phonenumber-field", extra = ["phonenumbers"] },
|
||||||
{ name = "django-redis" },
|
{ name = "django-redis" },
|
||||||
@@ -663,6 +677,7 @@ requires-dist = [
|
|||||||
{ name = "dj-database-url", specifier = ">=3.1.2" },
|
{ name = "dj-database-url", specifier = ">=3.1.2" },
|
||||||
{ name = "django", specifier = ">=6.0.6" },
|
{ name = "django", specifier = ">=6.0.6" },
|
||||||
{ name = "django-allauth", extras = ["mfa"], specifier = ">=65.18.0" },
|
{ 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-lucide", git = "https://github.com/bsiebens/lucide" },
|
||||||
{ name = "django-phonenumber-field", extras = ["phonenumbers"], specifier = ">=8.4.0" },
|
{ name = "django-phonenumber-field", extras = ["phonenumbers"], specifier = ">=8.4.0" },
|
||||||
{ name = "django-redis", specifier = ">=7.0.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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "tzdata"
|
name = "tzdata"
|
||||||
version = "2026.2"
|
version = "2026.2"
|
||||||
|
|||||||
Reference in New Issue
Block a user