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.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)."""
|
||||
|
||||
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.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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
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 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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
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",
|
||||
"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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
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" },
|
||||
]
|
||||
|
||||
[[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"
|
||||
|
||||
Reference in New Issue
Block a user