Add referee assignment/eligibility system, team bulk-add, member Groups, and referee management dashboard with PDF export
Builds the referee workflow end to end: club-defined RefereeLevel/RefereeProfile eligibility tied to teams, EventReferee assignment (member or external, with fee/km payment tracking), an admin dashboard with KPI tiles, date-grouped game tiles and range filters, and a downloadable payment form PDF modeled on the club's existing paper document (using Club.legal_name when set). Also lands team roster bulk-add, member mass-upload with family linking, and the members.Group model, developed alongside this work. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,14 +15,22 @@ from django.utils.translation import gettext_lazy as _
|
||||
from openpyxl.worksheet.datavalidation import DataValidation
|
||||
|
||||
from club.models import ClubMembership
|
||||
from members.models import Member
|
||||
from members.models import FamilyMembership, Member
|
||||
|
||||
from .forms import MemberForm
|
||||
|
||||
TEMPLATE_COLUMNS = ["first_name", "last_name", "date_of_birth", "email", "phone", "emergency_phone", "license", "status", "fee_status"]
|
||||
TEMPLATE_COLUMNS = ["first_name", "last_name", "date_of_birth", "email", "phone", "emergency_phone", "license", "status", "fee_status", "family_group", "family_role"]
|
||||
REQUIRED_HEADER_COLUMNS = {"first_name", "last_name"}
|
||||
|
||||
TEMPLATE_EXAMPLE_ROW = ["Alex", "Morgan", date(2012, 5, 14), "alex.morgan@example.com", "+32470123456", "+32470654321", "", ClubMembership.StatusChoices.ACTIVE, ClubMembership.FeeStatus.UNPAID]
|
||||
TEMPLATE_EXAMPLE_ROWS = [
|
||||
# A standalone member -- no family link.
|
||||
["Alex", "Morgan", date(2012, 5, 14), "alex.morgan@example.com", "+32470123456", "+32470654321", "", ClubMembership.StatusChoices.ACTIVE, ClubMembership.FeeStatus.UNPAID, "", ""],
|
||||
# A parent and child linked together: same family_group value, one row each.
|
||||
# The child has no email of its own -- it gets a login only if given one via
|
||||
# the "Grant login" action later, same as adding a family by hand.
|
||||
["Taylor", "Doe", "", "taylor.doe@example.com", "+32470654322", "", "", ClubMembership.StatusChoices.ACTIVE, ClubMembership.FeeStatus.UNPAID, "Doe family", FamilyMembership.FamilyRole.PARENT],
|
||||
["Jamie", "Doe", date(2014, 3, 2), "", "", "+32470654322", "", ClubMembership.StatusChoices.ACTIVE, ClubMembership.FeeStatus.UNPAID, "Doe family", FamilyMembership.FamilyRole.CHILD],
|
||||
]
|
||||
|
||||
|
||||
def build_member_import_template():
|
||||
@@ -37,9 +45,10 @@ def build_member_import_template():
|
||||
cell.font = openpyxl.styles.Font(bold=True)
|
||||
sheet.freeze_panes = "A2"
|
||||
|
||||
sheet.append(TEMPLATE_EXAMPLE_ROW)
|
||||
for example_row in TEMPLATE_EXAMPLE_ROWS:
|
||||
sheet.append(example_row)
|
||||
|
||||
for column_name, choices in (("status", ClubMembership.StatusChoices), ("fee_status", ClubMembership.FeeStatus)):
|
||||
for column_name, choices in (("status", ClubMembership.StatusChoices), ("fee_status", ClubMembership.FeeStatus), ("family_role", FamilyMembership.FamilyRole)):
|
||||
column_index = TEMPLATE_COLUMNS.index(column_name) + 1
|
||||
column_letter = sheet.cell(row=1, column=column_index).column_letter
|
||||
options = ",".join(choices.values)
|
||||
@@ -128,7 +137,20 @@ def parse_member_import_rows(rows, club):
|
||||
membership_kwargs, status_fee_errors = _parse_membership_fields(raw)
|
||||
errors.extend(status_fee_errors)
|
||||
|
||||
results.append({"line_number": line_number, "raw": raw, "member": member if not errors else None, "membership_kwargs": membership_kwargs, "errors": errors})
|
||||
family_group, family_role, family_errors = _parse_family_fields(raw)
|
||||
errors.extend(family_errors)
|
||||
|
||||
results.append(
|
||||
{
|
||||
"line_number": line_number,
|
||||
"raw": raw,
|
||||
"member": member if not errors else None,
|
||||
"membership_kwargs": membership_kwargs,
|
||||
"family_group": family_group,
|
||||
"family_role": family_role,
|
||||
"errors": errors,
|
||||
}
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
@@ -156,6 +178,32 @@ def _parse_membership_fields(raw):
|
||||
return {"license": license_number, "status": status_value, "fee_status": fee_status_value}, errors
|
||||
|
||||
|
||||
def _parse_family_fields(raw):
|
||||
"""family_group is a freeform label -- rows sharing the same non-blank value
|
||||
(within this file only; it isn't matched against families already in the
|
||||
club) are linked into one family. family_role decides whether the row gets a
|
||||
login: PARENT/GUARDIAN do (via their email), CHILD/OTHER don't -- same as
|
||||
registering a family by hand (see members.services.family)."""
|
||||
errors = []
|
||||
family_group = raw.get("family_group", "").strip()
|
||||
family_role_raw = raw.get("family_role", "").strip()
|
||||
|
||||
if not family_group:
|
||||
if family_role_raw:
|
||||
errors.append(_("family_role given without a family_group."))
|
||||
return "", None, errors
|
||||
|
||||
if not family_role_raw:
|
||||
errors.append(_("family_role is required when family_group is set."))
|
||||
return family_group, None, errors
|
||||
|
||||
family_role = _match_choice(family_role_raw, FamilyMembership.FamilyRole)
|
||||
if family_role is None:
|
||||
errors.append(_("Invalid family_role '%(value)s'.") % {"value": family_role_raw})
|
||||
|
||||
return family_group, family_role, errors
|
||||
|
||||
|
||||
def _match_choice(value, choices):
|
||||
for choice_value in choices.values:
|
||||
if choice_value.lower() == value.lower():
|
||||
|
||||
@@ -29,6 +29,7 @@ _NAV_SECTIONS = {
|
||||
"member_delete": "member_list",
|
||||
"member_attach_family": "member_list",
|
||||
"member_grant_login": "member_list",
|
||||
"member_referee_eligibility_update": "member_list",
|
||||
"member_detach_family": "member_list",
|
||||
"family_create": "member_list",
|
||||
"family_detail": "member_list",
|
||||
@@ -46,12 +47,20 @@ _NAV_SECTIONS = {
|
||||
"role_list": "role_list",
|
||||
"role_create": "role_list",
|
||||
"role_revoke": "role_list",
|
||||
"group_list": "group_list",
|
||||
"group_create": "group_list",
|
||||
"group_detail": "group_list",
|
||||
"group_update": "group_list",
|
||||
"group_delete": "group_list",
|
||||
"group_bulk_add": "group_list",
|
||||
"group_member_remove": "group_list",
|
||||
"team_list": "team_list",
|
||||
"team_create": "team_list",
|
||||
"team_update": "team_list",
|
||||
"team_delete": "team_list",
|
||||
"team_detail": "team_list",
|
||||
"team_roster_add": "team_list",
|
||||
"team_bulk_add": "team_list",
|
||||
"team_roster_update": "team_list",
|
||||
"team_roster_remove": "team_list",
|
||||
"team_staff_add": "team_list",
|
||||
@@ -59,6 +68,11 @@ _NAV_SECTIONS = {
|
||||
"team_staff_remove": "team_list",
|
||||
"team_photo_set": "team_list",
|
||||
"team_photo_delete": "team_list",
|
||||
"referee_list": "referee_list",
|
||||
"referee_management": "referee_management",
|
||||
"referee_level_list": "referee_level_list",
|
||||
"referee_level_create": "referee_level_list",
|
||||
"referee_level_update": "referee_level_list",
|
||||
"news_list": "news_list",
|
||||
"news_create": "news_list",
|
||||
"news_detail": "news_list",
|
||||
@@ -76,6 +90,11 @@ _NAV_SECTIONS = {
|
||||
"event_delete": "event_list",
|
||||
"event_detach": "event_list",
|
||||
"event_fetch_game_info": "event_list",
|
||||
"event_referee_assign": "event_list",
|
||||
"event_referee_add_external": "event_list",
|
||||
"event_referee_remove": "event_list",
|
||||
"event_referee_fee_update": "event_list",
|
||||
"event_referee_form_pdf": "event_list",
|
||||
"rbihf_import": "event_list",
|
||||
"rbihf_import_confirm": "event_list",
|
||||
"event_series_create": "event_list",
|
||||
|
||||
@@ -6,14 +6,15 @@ from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from club.models import ClubMembership, ClubRole, FeePayment, Season, Sponsor
|
||||
from club.models import ClubMembership, ClubRole, FeePayment, Sponsor
|
||||
from club.services.access import is_club_admin, teams_managed_by
|
||||
from events.models import Competition, Event, EventSeries, Location, Opponent
|
||||
from events.models import Competition, Event, EventReferee, EventSeries, Location, Opponent
|
||||
from events.services.rbihf_import import RBIHFImportError, extract_team_id
|
||||
from members.models import Family, FamilyMembership, Member
|
||||
from members.models import Family, FamilyMembership, Group, Member
|
||||
from members.services.family import find_member_by_email
|
||||
from news.models import News
|
||||
from teams.models import Position, StaffAssignment, Team, TeamMembership, TeamPhoto
|
||||
from teams.models import Position, RefereeLevel, RefereeProfile, StaffAssignment, Team, TeamMembership, TeamPhoto
|
||||
from teams.services import eligible_roster_members
|
||||
|
||||
from .recurrence_ui import FREQUENCY_CHOICES, WEEKDAY_CHOICES, build_rrule, parse_rrule
|
||||
|
||||
@@ -30,7 +31,7 @@ class MemberForm(forms.ModelForm):
|
||||
class TeamForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Team
|
||||
fields = ["name", "short_name"]
|
||||
fields = ["name", "short_name", "referee_management"]
|
||||
|
||||
|
||||
class TeamPhotoForm(forms.ModelForm):
|
||||
@@ -43,6 +44,74 @@ class TeamPhotoForm(forms.ModelForm):
|
||||
fields = ["image"]
|
||||
|
||||
|
||||
class GroupForm(forms.ModelForm):
|
||||
"""A generic named collection of members -- nothing team- or
|
||||
referee-specific here; see MemberRefereeEligibilityForm for that."""
|
||||
|
||||
class Meta:
|
||||
model = Group
|
||||
fields = ["name"]
|
||||
|
||||
|
||||
class RefereeLevelForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = RefereeLevel
|
||||
fields = ["name", "ordering", "teams"]
|
||||
widgets = {"teams": forms.SelectMultiple(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a team to search...")})}
|
||||
|
||||
def __init__(self, *args, club=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["teams"].queryset = Team.objects.filter(club=club)
|
||||
|
||||
|
||||
class MemberRefereeEligibilityForm(forms.ModelForm):
|
||||
"""This member's referee level and how long it's valid for -- edited from
|
||||
the member's own page (teams.RefereeProfile), get-or-created on save.
|
||||
Which teams that translates to is derived entirely from the level
|
||||
(RefereeLevel.teams), not picked here -- see RefereeProfile.eligible_teams."""
|
||||
|
||||
class Meta:
|
||||
model = RefereeProfile
|
||||
fields = ["level", "valid_until"]
|
||||
widgets = {"valid_until": forms.DateInput(attrs={"type": "date"})}
|
||||
|
||||
def __init__(self, *args, club=None, member=None, **kwargs):
|
||||
instance = RefereeProfile.objects.filter(member=member).first() if member is not None else None
|
||||
super().__init__(*args, instance=instance, **kwargs)
|
||||
# UUIDModel PKs get their default at construction time (not at save()),
|
||||
# so a brand-new instance already has a non-None pk -- "is this new"
|
||||
# can't be read off self.instance.pk. Setting member here (rather than
|
||||
# in save()) means it's already correct before validation runs too.
|
||||
if instance is None:
|
||||
self.instance.member = member
|
||||
self.fields["level"].queryset = RefereeLevel.objects.filter(club=club)
|
||||
self.fields["level"].required = False
|
||||
self.fields["level"].empty_label = _("— no level (not eligible) —")
|
||||
|
||||
|
||||
class ExternalRefereeForm(forms.Form):
|
||||
"""Log a non-member referee (federation-appointed, most often) by name
|
||||
only -- see events.services.referees.add_external_referee."""
|
||||
|
||||
name = forms.CharField(label=_("Referee name"))
|
||||
|
||||
|
||||
class EventRefereeFeeForm(forms.ModelForm):
|
||||
"""One referee assignment's payment details -- see
|
||||
events.services.referees.set_referee_fee. `km`/`km_rate` are left blank
|
||||
(not defaulted to 0) whenever no travel was logged, so the PDF export can
|
||||
tell "no kilometers" from "zero kilometers claimed"."""
|
||||
|
||||
class Meta:
|
||||
model = EventReferee
|
||||
fields = ["fee", "km", "km_rate"]
|
||||
widgets = {
|
||||
"fee": forms.NumberInput(attrs={"step": "0.01", "min": "0"}),
|
||||
"km": forms.NumberInput(attrs={"step": "0.1", "min": "0"}),
|
||||
"km_rate": forms.NumberInput(attrs={"step": "any", "min": "0"}),
|
||||
}
|
||||
|
||||
|
||||
class TeamMembershipForm(forms.ModelForm):
|
||||
"""Add/edit one roster entry -- team and season come from the view (the URL
|
||||
already identifies both), never from the form itself."""
|
||||
@@ -56,13 +125,11 @@ class TeamMembershipForm(forms.ModelForm):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.team = team
|
||||
self.season = season
|
||||
# Eligible to be added regardless of which season's roster is being edited
|
||||
# (the team detail page's season switcher can be pointed at an older
|
||||
# season): active this season or the next one, not lapsed/pending/cancelled
|
||||
# or active only in some other season.
|
||||
today = timezone.localdate()
|
||||
eligible_seasons = [s for s in (Season.covering(club, today), Season.next_after(club, today)) if s is not None]
|
||||
members = Member.objects.filter(member_of__club=club, member_of__season__in=eligible_seasons, member_of__status=ClubMembership.StatusChoices.ACTIVE).distinct()
|
||||
# eligible_roster_members already covers "active this season or the next
|
||||
# one, not lapsed/pending/cancelled or active only in some other season" --
|
||||
# eligible regardless of which season's roster is being edited (the team
|
||||
# detail page's season switcher can be pointed at an older season).
|
||||
members = eligible_roster_members(club)
|
||||
if team is not None and season is not None:
|
||||
# Already on this team's roster this season -- offering them again
|
||||
# would just fail the unique_member_per_team_per_season constraint.
|
||||
@@ -99,9 +166,7 @@ class StaffAssignmentForm(forms.ModelForm):
|
||||
super().__init__(*args, **kwargs)
|
||||
# See TeamMembershipForm for why current-or-next-season: eligible to be
|
||||
# assigned regardless of which season's staff list is being edited.
|
||||
today = timezone.localdate()
|
||||
eligible_seasons = [s for s in (Season.covering(club, today), Season.next_after(club, today)) if s is not None]
|
||||
members = Member.objects.filter(member_of__club=club, member_of__season__in=eligible_seasons, member_of__status=ClubMembership.StatusChoices.ACTIVE).distinct()
|
||||
members = eligible_roster_members(club)
|
||||
if team is not None and season is not None:
|
||||
taken = StaffAssignment.objects.filter(team=team, season=season).exclude(pk=self.instance.pk).values_list("member_id", flat=True)
|
||||
members = members.exclude(pk__in=taken)
|
||||
@@ -194,7 +259,7 @@ _AUDIENCE_WIDGETS = {
|
||||
class EventForm(EventAudienceFormMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = Event
|
||||
fields = ["title", "kind", "teams", "invited_members", "excluded_members", "location", "opponent", "start", "end", "gathering", "deadline", "competition", "external_game_id", "score_for", "score_against", "is_live"]
|
||||
fields = ["title", "kind", "teams", "invited_members", "excluded_members", "location", "opponent", "start", "end", "gathering", "deadline", "competition", "external_game_id", "score_for", "score_against", "is_live", "max_referees"]
|
||||
widgets = {
|
||||
"start": forms.DateTimeInput(attrs={"type": "datetime-local"}),
|
||||
"end": forms.DateTimeInput(attrs={"type": "datetime-local"}),
|
||||
@@ -202,6 +267,7 @@ class EventForm(EventAudienceFormMixin, forms.ModelForm):
|
||||
"deadline": forms.DateTimeInput(attrs={"type": "datetime-local"}),
|
||||
"score_for": forms.NumberInput(attrs={"min": 0}),
|
||||
"score_against": forms.NumberInput(attrs={"min": 0}),
|
||||
"max_referees": forms.NumberInput(attrs={"min": 1}),
|
||||
**_AUDIENCE_WIDGETS,
|
||||
}
|
||||
|
||||
|
||||
@@ -28,3 +28,8 @@ def render_pdf(html: str) -> bytes:
|
||||
def membership_list_pdf(context: dict) -> bytes:
|
||||
html = render_to_string("management/membership_list_pdf.html", context)
|
||||
return render_pdf(html)
|
||||
|
||||
|
||||
def event_referee_form_pdf(context: dict) -> bytes:
|
||||
html = render_to_string("management/event_referee_form_pdf.html", context)
|
||||
return render_pdf(html)
|
||||
|
||||
@@ -19,11 +19,17 @@
|
||||
{% if is_club_admin %}
|
||||
<li><a class="{% if nav == 'membership_list' %}menu-active{% endif %}" href="{% url 'management:membership_list' %}">{% lucide "wallet" size=16 %} {% trans "Memberships" %}</a></li>
|
||||
<li><a class="{% if nav == 'role_list' %}menu-active{% endif %}" href="{% url 'management:role_list' %}">{% lucide "shield-check" size=16 %} {% trans "Roles" %}</a></li>
|
||||
<li><a class="{% if nav == 'group_list' %}menu-active{% endif %}" href="{% url 'management:group_list' %}">{% lucide "users-round" size=16 %} {% trans "Groups" %}</a></li>
|
||||
{% endif %}
|
||||
|
||||
<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 == 'position_list' %}menu-active{% endif %}" href="{% url 'management:position_list' %}">{% lucide "tags" size=16 %} {% trans "Positions" %}</a></li>
|
||||
<li><a class="{% if nav == 'referee_list' %}menu-active{% endif %}" href="{% url 'management:referee_list' %}">{% lucide "flag" size=16 %} {% trans "Referees" %}</a></li>
|
||||
{% if is_club_admin %}
|
||||
<li><a class="{% if nav == 'referee_management' %}menu-active{% endif %}" href="{% url 'management:referee_management' %}">{% lucide "calendar-check" size=16 %} {% trans "Referee management" %}</a></li>
|
||||
<li><a class="{% if nav == 'referee_level_list' %}menu-active{% endif %}" href="{% url 'management:referee_level_list' %}">{% lucide "badge-check" size=16 %} {% trans "Referee levels" %}</a></li>
|
||||
{% endif %}
|
||||
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
{% load i18n lucide ui %}
|
||||
{% comment %}
|
||||
Shared by the event detail page and the referee management dashboard --
|
||||
context: event, referees (EventReferee rows, each with a .fee_form when
|
||||
can_manage_referees), referee_candidates, referees_full,
|
||||
can_manage_referees, and an optional next_url to return to after a POST
|
||||
(defaults to the event detail page when blank).
|
||||
{% endcomment %}
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="card-title text-base">
|
||||
{% lucide "flag" size=18 %} {% trans "Referees" %}
|
||||
<span class="badge badge-sm {% if referees_full %}badge-neutral{% else %}badge-outline{% endif %}">
|
||||
{{ referees|length }} / {{ event.max_referees }}
|
||||
</span>
|
||||
</h2>
|
||||
{% if can_manage_referees %}
|
||||
<a class="btn btn-outline btn-sm gap-1" href="{% url 'management:event_referee_form_pdf' event.pk %}">{% lucide "file-down" size=12 %} {% trans "Referee form (PDF)" %}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<ul class="divide-y divide-base-200">
|
||||
{% for referee in referees %}
|
||||
<li class="flex items-center justify-between py-2 gap-2">
|
||||
<span>
|
||||
{{ referee.display_name }}
|
||||
{% if referee.is_external %}<span class="badge badge-neutral badge-xs">{% trans "External" %}</span>{% endif %}
|
||||
{% if referee.assigned_by %}<span class="text-xs opacity-60">— {% blocktrans with name=referee.assigned_by %}assigned by {{ name }}{% endblocktrans %}</span>{% endif %}
|
||||
{% if referee.total_payable %}<span class="text-xs opacity-60">— {% blocktrans with total=referee.total_payable %}€{{ total }} due{% endblocktrans %}</span>{% endif %}
|
||||
</span>
|
||||
{% if can_manage_referees %}
|
||||
<div class="flex gap-1 shrink-0">
|
||||
<button class="btn btn-xs btn-outline" type="button" onclick="document.getElementById('{{ referee.pk|dom_id:"referee_fee_modal" }}').showModal()">{% lucide "euro" size=12 %} {% trans "Fee" %}</button>
|
||||
<form method="post" action="{% url 'management:event_referee_remove' event.pk referee.pk %}">
|
||||
{% csrf_token %}
|
||||
{% if next_url %}<input type="hidden" name="next" value="{{ next_url }}">{% endif %}
|
||||
<button class="btn btn-xs btn-outline btn-error gap-1" type="submit">{% lucide "x" size=12 %} {% trans "Remove" %}</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% empty %}
|
||||
<li class="py-2 text-sm opacity-60">{% trans "No referees assigned yet." %}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
{% if can_manage_referees %}
|
||||
{% if referees_full %}
|
||||
<p class="text-sm opacity-60 mt-2">{% trans "This game already has its maximum number of referees." %}</p>
|
||||
{% else %}
|
||||
{% if referee_candidates %}
|
||||
<form method="post" action="{% url 'management:event_referee_assign' event.pk %}" class="mt-2 flex flex-wrap items-center gap-2">
|
||||
{% csrf_token %}
|
||||
{% if next_url %}<input type="hidden" name="next" value="{{ next_url }}">{% endif %}
|
||||
<select name="member" class="select select-bordered select-sm">
|
||||
{% for candidate in referee_candidates %}
|
||||
<option value="{{ candidate.pk }}" {% if candidate.has_conflict %}title="{% blocktrans with events=candidate.conflict_titles %}Also expected at: {{ events }}{% endblocktrans %}"{% endif %}>
|
||||
{{ candidate }}{% if candidate.has_conflict %} ⚠{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button class="btn btn-outline btn-sm gap-2" type="submit">{% lucide "user-plus" size=14 %} {% trans "Assign" %}</button>
|
||||
</form>
|
||||
<p class="text-xs opacity-60 mt-1">{% trans "⚠ = also expected at another event around this time -- shown as a warning, not blocked." %}</p>
|
||||
{% else %}
|
||||
<p class="text-sm opacity-60 mt-2">
|
||||
{% trans "No eligible referees for this team yet." %}
|
||||
<a class="link" href="{% url 'management:referee_level_list' %}">{% trans "Link a referee level to this team." %}</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{% url 'management:event_referee_add_external' event.pk %}" class="mt-2 flex flex-wrap items-center gap-2">
|
||||
{% csrf_token %}
|
||||
{% if next_url %}<input type="hidden" name="next" value="{{ next_url }}">{% endif %}
|
||||
<input type="text" name="name" class="input input-bordered input-sm" placeholder="{% trans 'External referee name' %}">
|
||||
<button class="btn btn-outline btn-sm gap-2" type="submit">{% lucide "user-plus" size=14 %} {% trans "Add external" %}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if can_manage_referees %}
|
||||
{% trans "Referee fee" as fee_title %}
|
||||
{% trans "Save" as save_label %}
|
||||
{% with encoded_next=next_url|urlencode %}
|
||||
{% for referee in referees %}
|
||||
{% url 'management:event_referee_fee_update' event.pk referee.pk as fee_action_url %}
|
||||
{% with fee_action_url=fee_action_url|add:"?next="|add:encoded_next %}
|
||||
{% include "controlpanel/_modal_form.html" with modal_id=referee.pk|dom_id:"referee_fee_modal" title=fee_title form=referee.fee_form action_url=fee_action_url submit_label=save_label submit_icon="save" %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
@@ -128,6 +128,21 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if event.is_home_game and not referee_management_needed %}
|
||||
<div class="alert alert-info mb-6">
|
||||
{% lucide "info" size=20 %}
|
||||
<span>{% trans "Referees for this game are managed by the federation, not the club." %}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if referee_management_needed %}
|
||||
<div class="mb-6 card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
{% include "management/_referee_assignment_panel.html" %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock panel %}
|
||||
|
||||
{% block extra_body %}
|
||||
|
||||
@@ -49,22 +49,22 @@
|
||||
<div id="game-details-section" class="game-only">
|
||||
<div class="divider"></div>
|
||||
<h3 class="text-lg font-semibold mb-3">{% trans "Game" %}</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{% form_field form.competition %}
|
||||
{% form_field form.external_game_id %}
|
||||
</div>
|
||||
{% if update_view %}
|
||||
{% comment %}
|
||||
{% form_field form.max_referees %}
|
||||
{% if update_view %}
|
||||
{% comment %}
|
||||
Score/live status only exist to record once a game has
|
||||
actually been created -- the add form leaves them out
|
||||
entirely (see EventForm's editing=False path).
|
||||
{% endcomment %}
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{% endcomment %}
|
||||
|
||||
{% form_field form.score_for %}
|
||||
{% form_field form.score_against %}
|
||||
{% form_field form.is_live %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-actions justify-start pt-2 mt-2">
|
||||
|
||||
90
management/templates/management/event_referee_form_pdf.html
Normal file
90
management/templates/management/event_referee_form_pdf.html
Normal file
@@ -0,0 +1,90 @@
|
||||
{% load i18n %}
|
||||
|
||||
{% comment %}
|
||||
Rendered by WeasyPrint -- modeled directly on the club's existing paper
|
||||
referee payment form ("Leeg Document RefereesCompetitie25-26.pdf"): a
|
||||
3-line header (club name/address), match details, referee names, a
|
||||
payment sentence, a fee+km breakdown per referee, and blank signature
|
||||
lines. Header uses club.official_name (legal_name if set, else name);
|
||||
the payment sentence uses the plain club name -- the original document
|
||||
does the same, using a longer legal form up top and a shorter one in the
|
||||
body text.
|
||||
{% endcomment %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{% trans "Referee form" %}</title>
|
||||
<style>
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 25mm 20mm;
|
||||
@bottom-center {
|
||||
content: "{{ club.name }} — {% trans "referee form" %} — " counter(page) " / " counter(pages);
|
||||
font-size: 8pt;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
body { font-family: sans-serif; font-size: 10.5pt; color: #111; line-height: 1.5; }
|
||||
.header { text-align: center; margin-bottom: 14mm; }
|
||||
.header div { margin: 0; }
|
||||
.field { margin-bottom: 4mm; }
|
||||
.field .label { display: inline-block; min-width: 32mm; }
|
||||
.fill-line { display: block; border-bottom: 1px solid #333; min-height: 5mm; margin: 2mm 0 4mm; }
|
||||
.section-title { margin: 8mm 0 2mm; }
|
||||
.fee-row { border-bottom: 1px solid #333; padding-bottom: 1mm; margin-bottom: 4mm; min-height: 5mm; }
|
||||
.signature-block { margin-top: 12mm; }
|
||||
.signature-line { border-bottom: 1px solid #333; min-height: 16mm; margin-top: 2mm; }
|
||||
.collected-line { margin-top: 12mm; }
|
||||
.collected-line .fill { display: inline-block; border-bottom: 1px solid #333; min-width: 90mm; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div>{{ club.official_name }}</div>
|
||||
{% if home_location %}
|
||||
<div>{{ home_location.address }}</div>
|
||||
<div>{{ home_location.zip_code }} {{ home_location.city }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="label">{% trans "Regarding: Game" %}</span>
|
||||
{% for team in event.teams.all %}{{ team.short_name }}{% if not forloop.last %}, {% endif %}{% endfor %}
|
||||
{% if event.opponent %}{% trans "vs" %} {{ event.opponent }}{% endif %}
|
||||
{% if event.external_game_id %}({% trans "ID" %}: {{ event.external_game_id }}){% endif %}
|
||||
</div>
|
||||
<div class="field"><span class="label">{% trans "Date" %}</span> {{ event.start|date:"j F Y" }}</div>
|
||||
<div class="field"><span class="label">{% trans "Time" %}</span> {{ event.start|date:"H:i" }}</div>
|
||||
<div class="field">
|
||||
<span class="label">{% trans "Referee names" %}</span>
|
||||
{% for referee in referees %}{{ referee.display_name }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}
|
||||
</div>
|
||||
<div class="fill-line"></div>
|
||||
|
||||
<p>{% blocktrans with name=club.name %}{{ name }} pays the fee to the referee(s) for officiating the game mentioned above.{% endblocktrans %}</p>
|
||||
|
||||
<div class="section-title">{% trans "Referees and fee, plus any kilometers" %}</div>
|
||||
{% for referee in referees %}
|
||||
<div class="fee-row">
|
||||
{{ referee.display_name }} —
|
||||
{% blocktrans with fee=referee.fee %}fee: €{{ fee }}{% endblocktrans %}
|
||||
{% if referee.km %}
|
||||
{% blocktrans with km=referee.km rate=referee.km_rate total=referee.km_total %}+ {{ km }} km × €{{ rate }}/km = €{{ total }}{% endblocktrans %}
|
||||
{% endif %}
|
||||
— {% blocktrans with total=referee.total_payable %}total: €{{ total }}{% endblocktrans %}
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="fee-row"></div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="signature-block">
|
||||
<div>{% trans "Referee signature" %}</div>
|
||||
<div class="signature-line"></div>
|
||||
</div>
|
||||
|
||||
<div class="collected-line">
|
||||
{% trans "Collected by team manager" %}: <span class="fill"> </span>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
65
management/templates/management/group_bulk_add.html
Normal file
65
management/templates/management/group_bulk_add.html
Normal file
@@ -0,0 +1,65 @@
|
||||
{% extends "management/base.html" %}
|
||||
{% load i18n lucide %}
|
||||
|
||||
{% block heading %}{% trans "Add multiple members" %}{% endblock heading %}
|
||||
{% block subheading %}{{ group.name }}{% endblock subheading %}
|
||||
|
||||
{% block panel %}
|
||||
<form method="get" class="mb-4">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<label class="input">
|
||||
<span class="opacity-50">{% lucide "search" size=16 %}</span>
|
||||
<input type="search" name="q" value="{{ search }}" placeholder="{% trans 'Search members ...' %}" class="input input-bordered">
|
||||
</label>
|
||||
<button class="btn btn-outline gap-2" type="submit">{% lucide "filter" size=16 %} {% trans "Filter" %}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm opacity-70 font-semibold">
|
||||
{% blocktrans count counter=members|length %}{{ counter }} member{% plural %}{{ counter }} members{% endblocktrans %}
|
||||
</span>
|
||||
<button class="btn btn-primary btn-sm gap-2" type="submit">{% lucide "users" size=14 %} {% trans "Add selected" %}</button>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>{% trans "Member" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for member in members %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if member.already_in_group %}
|
||||
<span class="badge badge-neutral badge-sm">{% trans "In group" %}</span>
|
||||
{% else %}
|
||||
<input type="checkbox" class="checkbox" name="member_{{ member.pk }}">
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ member }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="2" class="text-center opacity-60">{% trans "No members match this search." %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<a class="btn btn-outline gap-2" href="{% url 'management:group_detail' group.pk %}">{% lucide "arrow-left" size=16 %} {% trans "Back" %}</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock panel %}
|
||||
51
management/templates/management/group_detail.html
Normal file
51
management/templates/management/group_detail.html
Normal file
@@ -0,0 +1,51 @@
|
||||
{% extends "management/base.html" %}
|
||||
{% load i18n lucide ui %}
|
||||
|
||||
{% block heading %}{{ group.name }}{% endblock heading %}
|
||||
|
||||
{% block actions %}
|
||||
<a class="btn btn-outline gap-2" href="{% url 'management:group_update' group.pk %}">{% lucide "pencil" size=16 %} {% trans "Edit" %}</a>
|
||||
{% endblock actions %}
|
||||
|
||||
{% block panel %}
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="card-title text-base">{% trans "Members" %}</h2>
|
||||
<a class="btn btn-outline btn-sm gap-2" href="{% url 'management:group_bulk_add' group.pk %}">{% lucide "users" size=14 %} {% trans "Add multiple" %}</a>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Member" %}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for membership in memberships %}
|
||||
<tr>
|
||||
<td><a class="link link-hover" href="{% url 'management:member_detail' membership.member.pk %}">{{ membership.member }}</a></td>
|
||||
<td class="text-right">
|
||||
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ membership.pk|dom_id:"remove_member_modal" }}').showModal()">{% lucide "user-minus" size=14 %} {% trans "Remove" %}</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="2" class="text-center opacity-60">{% trans "No members yet." %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% trans "Remove from group" as remove_member_title %}
|
||||
{% trans "Remove" as remove_label %}
|
||||
{% for membership in memberships %}
|
||||
{% blocktrans with member=membership.member group_name=group.name asvar remove_member_body %}Remove {{ member }} from “{{ group_name }}”?{% endblocktrans %}
|
||||
{% url 'management:group_member_remove' group.pk membership.pk as remove_member_url %}
|
||||
{% include "controlpanel/_confirm_modal.html" with modal_id=membership.pk|dom_id:"remove_member_modal" title=remove_member_title body=remove_member_body action_url=remove_member_url submit_label=remove_label submit_icon="user-minus" %}
|
||||
{% endfor %}
|
||||
{% endblock panel %}
|
||||
35
management/templates/management/group_form.html
Normal file
35
management/templates/management/group_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 group" %}{% 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 gap-2" href="{% if update_view %}{% url "management:group_detail" object.pk %}{% else %}{% url "management:group_list" %}{% endif %}">{% 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 %}
|
||||
53
management/templates/management/group_list.html
Normal file
53
management/templates/management/group_list.html
Normal file
@@ -0,0 +1,53 @@
|
||||
{% extends "management/base.html" %}
|
||||
{% load i18n lucide ui %}
|
||||
|
||||
{% block heading %}{% trans "Groups" %}{% endblock heading %}
|
||||
{% block subheading %}{% trans "Named collections of members -- coaches, team managers, referee pools, committees..." %}{% endblock subheading %}
|
||||
|
||||
{% block actions %}
|
||||
<a class="btn btn-outline gap-2" href="{% url 'management:group_create' %}">{% lucide "plus" size=16 %} {% trans "New group" %}</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 "Members" %}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for group in groups %}
|
||||
<tr>
|
||||
<td><a class="link link-hover" href="{% url 'management:group_detail' group.pk %}">{{ group.name }}</a></td>
|
||||
<td>{{ group.member_count }}</td>
|
||||
<td class="text-right">
|
||||
<div class="flex justify-end gap-1">
|
||||
<a class="btn btn-outline btn-sm" href="{% url 'management:group_update' group.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('{{ group.pk|dom_id:"group_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 groups yet." %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% trans "Delete group" as delete_group_title %}
|
||||
{% trans "Delete" as delete_label %}
|
||||
{% for group in groups %}
|
||||
{% url 'management:group_delete' group.pk as group_delete_url %}
|
||||
{% blocktrans with name=group.name asvar delete_group_body %}Delete “{{ name }}”? Its members are removed from it, and any teams it's linked to as a referee source lose that link. This cannot be undone.{% endblocktrans %}
|
||||
{% include "controlpanel/_confirm_modal.html" with modal_id=group.pk|dom_id:"group_delete_modal" title=delete_group_title body=delete_group_body action_url=group_delete_url submit_label=delete_label submit_icon="trash-2" %}
|
||||
{% endfor %}
|
||||
{% endblock panel %}
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "management/base.html" %}
|
||||
{% load lucide ui i18n %}
|
||||
{% load lucide ui i18n static %}
|
||||
|
||||
{% block heading %}{{ member.get_full_name }}{% endblock heading %}
|
||||
{% block subheading %}{{ member.contact_email }}{% endblock subheading %}
|
||||
@@ -127,6 +127,66 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if referee_profile or is_club_admin %}
|
||||
<div class="card bg-base-100 shadow mt-4">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="card-title text-base">{% lucide "flag" size=18 %} {% trans "Referee eligibility" %}</h2>
|
||||
{% if is_club_admin %}
|
||||
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('referee_eligibility_modal').showModal()">{% lucide "pencil" size=14 %} {% trans "Edit" %}</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if referee_profile and referee_profile.level %}
|
||||
<dl class="divide-y divide-base-200">
|
||||
<div class="flex items-center justify-between py-2">
|
||||
<dt class="text-sm opacity-70">{% trans "Level" %}</dt>
|
||||
<dd class="font-semibold">{{ referee_profile.level }}</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2">
|
||||
<dt class="text-sm opacity-70">{% trans "Valid until" %}</dt>
|
||||
<dd class="font-semibold flex items-center gap-2">
|
||||
{{ referee_profile.valid_until|default:"—" }}
|
||||
{% if referee_profile.is_currently_valid %}
|
||||
<span class="badge badge-success badge-sm">{% trans "Valid" %}</span>
|
||||
{% elif referee_profile.valid_until %}
|
||||
<span class="badge badge-error badge-sm">{% trans "Expired" %}</span>
|
||||
{% else %}
|
||||
<span class="badge badge-warning badge-sm">{% trans "No validity set" %}</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{% if referee_profile.is_eligible %}
|
||||
<div class="text-xs font-semibold uppercase opacity-60 mt-2 mb-1">{% trans "Can referee for" %}</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% for team in referee_profile.eligible_teams %}
|
||||
<span class="badge badge-outline">{{ team.name }}</span>
|
||||
{% empty %}
|
||||
<span class="text-sm opacity-60">{% trans "This level has no teams linked yet." %}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-warning mt-2">
|
||||
{% lucide "triangle-alert" size=16 %}
|
||||
<span>{% trans "Not currently eligible to referee -- set or extend the validity date above to reinstate them." %}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="text-sm opacity-60">{% trans "Not eligible to referee for any team." %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if is_club_admin %}
|
||||
{% url 'management:member_referee_eligibility_update' member.pk as referee_eligibility_url %}
|
||||
{% trans "Referee eligibility" as referee_eligibility_title %}
|
||||
{% trans "Save" as save_label %}
|
||||
{% include "controlpanel/_modal_form.html" with modal_id="referee_eligibility_modal" title=referee_eligibility_title form=referee_eligibility_form action_url=referee_eligibility_url submit_label=save_label submit_icon="save" %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% trans "Add parent" as add_parent_label %}
|
||||
{% trans "Add child" as add_child_label %}
|
||||
{% trans "If this email has no account yet, one is created and they set a password via the reset link." as add_parent_blurb %}
|
||||
@@ -184,3 +244,7 @@
|
||||
{% include "controlpanel/_modal_form.html" with modal_id="attach_family_modal" title=add_to_family_title form=attach_to_family_form action_url=attach_family_url submit_label=add_label submit_icon="user-plus" blurb=attach_family_blurb %}
|
||||
{% endif %}
|
||||
{% endblock panel %}
|
||||
|
||||
{% block extra_body %}
|
||||
<script src="{% static 'js/searchable-select.js' %}"></script>
|
||||
{% endblock extra_body %}
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
here. Nothing is created yet: you'll see exactly what will be added
|
||||
before anything is saved.{% endblocktrans %}
|
||||
</p>
|
||||
<p class="text-sm opacity-70">
|
||||
{% blocktrans %}To register a family, give matching rows the same <code>family_group</code> value (any label works, e.g. a surname) and set each row's <code>family_role</code>. A parent/guardian row with an email gets a login; a child row doesn't — grant one later from their member page if they need it.{% endblocktrans %}
|
||||
</p>
|
||||
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
<th>{% trans "Last name" %}</th>
|
||||
<th>{% trans "First name" %}</th>
|
||||
<th>{% trans "Email" %}</th>
|
||||
<th>{% trans "Family" %}</th>
|
||||
<th>{% trans "Outcome" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -36,6 +37,14 @@
|
||||
<td>{{ result.raw.last_name }}</td>
|
||||
<td>{{ result.raw.first_name }}</td>
|
||||
<td>{{ result.raw.email }}</td>
|
||||
<td>
|
||||
{% if result.family_group %}
|
||||
{{ result.family_group }}
|
||||
{% if result.family_role %}<span class="badge badge-neutral badge-sm">{{ result.family_role|capfirst }}</span>{% endif %}
|
||||
{% else %}
|
||||
<span class="opacity-40">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if result.member %}
|
||||
<span class="badge badge-success badge-sm">{% trans "Will create" %}</span>
|
||||
@@ -47,7 +56,7 @@
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center opacity-60">{% trans "No rows found in the uploaded file." %}</td>
|
||||
<td colspan="6" class="text-center opacity-60">{% trans "No rows found in the uploaded file." %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
35
management/templates/management/referee_level_form.html
Normal file
35
management/templates/management/referee_level_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 referee level" %}{% 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 items-start">
|
||||
{% for field in form %}
|
||||
{% form_field field %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="card-actions justify-start pt-2 mt-2">
|
||||
<a class="btn btn-outline gap-2" href="{% url "management:referee_level_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 %}
|
||||
54
management/templates/management/referee_level_list.html
Normal file
54
management/templates/management/referee_level_list.html
Normal file
@@ -0,0 +1,54 @@
|
||||
{% extends "management/base.html" %}
|
||||
{% load i18n lucide %}
|
||||
|
||||
{% block heading %}{% trans "Referee levels" %}{% endblock heading %}
|
||||
{% block subheading %}{% trans "Which teams' home games each level qualifies a referee for." %}{% endblock subheading %}
|
||||
|
||||
{% block actions %}
|
||||
{% if is_club_admin %}
|
||||
<a class="btn btn-outline gap-2" href="{% url 'management:referee_level_create' %}">{% lucide "plus" size=16 %} {% trans "New level" %}</a>
|
||||
{% endif %}
|
||||
{% 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 "Ordering" %}</th>
|
||||
<th>{% trans "Name" %}</th>
|
||||
<th>{% trans "Qualifies for" %}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for level in levels %}
|
||||
<tr>
|
||||
<td>{{ level.ordering }}</td>
|
||||
<td>{{ level.name }}</td>
|
||||
<td>
|
||||
{% for team in level.teams.all %}
|
||||
<span class="badge badge-outline badge-sm">{{ team.name }}</span>
|
||||
{% empty %}
|
||||
<span class="opacity-40">—</span>
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
{% if is_club_admin %}
|
||||
<a class="btn btn-outline btn-sm gap-2" href="{% url 'management:referee_level_update' level.pk %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center opacity-60">{% trans "No referee levels yet." %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock panel %}
|
||||
60
management/templates/management/referee_list.html
Normal file
60
management/templates/management/referee_list.html
Normal file
@@ -0,0 +1,60 @@
|
||||
{% extends "management/base.html" %}
|
||||
{% load i18n lucide %}
|
||||
|
||||
{% block heading %}{% trans "Referees" %}{% endblock heading %}
|
||||
{% block subheading %}{% trans "Every club referee, their level, and whether their qualification is currently valid." %}{% endblock subheading %}
|
||||
|
||||
{% 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 "Referee" %}</th>
|
||||
<th>{% trans "Level" %}</th>
|
||||
<th>{% trans "Can referee for" %}</th>
|
||||
<th>{% trans "Valid until" %}</th>
|
||||
<th>{% trans "Status" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for referee in referees %}
|
||||
<tr>
|
||||
<td><a class="link link-hover" href="{% url 'management:member_detail' referee.pk %}">{{ referee }}</a></td>
|
||||
<td>{{ referee.referee_profile.level|default:"—" }}</td>
|
||||
<td>
|
||||
{% if referee.referee_profile.is_eligible %}
|
||||
{% for team in referee.referee_profile.eligible_teams %}
|
||||
<span class="badge badge-outline badge-sm">{{ team.name }}</span>
|
||||
{% empty %}
|
||||
<span class="opacity-40">—</span>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span class="opacity-40">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ referee.referee_profile.valid_until|default:"—" }}</td>
|
||||
<td>
|
||||
{% if referee.referee_profile.is_eligible %}
|
||||
<span class="badge badge-success badge-sm">{% trans "Valid" %}</span>
|
||||
{% elif not referee.referee_profile.level %}
|
||||
<span class="badge badge-warning badge-sm">{% trans "No level" %}</span>
|
||||
{% elif not referee.referee_profile.valid_until %}
|
||||
<span class="badge badge-warning badge-sm">{% trans "No validity set" %}</span>
|
||||
{% else %}
|
||||
<span class="badge badge-error badge-sm">{% trans "Expired" %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center opacity-60">{% trans "No referees yet -- set a level and validity from a member's own page." %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock panel %}
|
||||
99
management/templates/management/referee_management.html
Normal file
99
management/templates/management/referee_management.html
Normal file
@@ -0,0 +1,99 @@
|
||||
{% extends "management/base.html" %}
|
||||
{% load i18n lucide ui %}
|
||||
|
||||
{% block heading %}{% trans "Referee management" %}{% endblock heading %}
|
||||
{% block subheading %}{% trans "Every upcoming home game that needs a club-arranged referee." %}{% endblock subheading %}
|
||||
|
||||
{% block panel %}
|
||||
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="card bg-base-100 shadow border-l-4 border-info">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "calendar" size=16 %} {% trans "Games in view" %}</div>
|
||||
<div class="text-4xl font-bold tabular-nums font-mono">{{ kpi_total }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow border-l-4 {% if kpi_no_referee %}border-error{% else %}border-success{% endif %}">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "circle-alert" size=16 %} {% trans "Without a referee" %}</div>
|
||||
<div class="text-4xl font-bold tabular-nums font-mono">{{ kpi_no_referee }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow border-l-4 {% if kpi_understaffed %}border-warning{% else %}border-success{% endif %}">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "circle-dashed" size=16 %} {% trans "Partially staffed" %}</div>
|
||||
<div class="text-4xl font-bold tabular-nums font-mono">{{ kpi_understaffed }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow border-l-4 border-success">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "circle-check" size=16 %} {% trans "Fully staffed" %}</div>
|
||||
<div class="text-4xl font-bold tabular-nums font-mono">{{ kpi_fully_staffed }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="join mb-4 w-full flex-wrap">
|
||||
<a href="?range=week" class="btn join-item flex-1 {% if range_choice == "week" %}btn-primary{% endif %}">{% trans "This week" %}</a>
|
||||
<a href="?range=two_weeks" class="btn join-item flex-1 {% if range_choice == "two_weeks" %}btn-primary{% endif %}">{% trans "This + next week" %}</a>
|
||||
<a href="?range=10" class="btn join-item flex-1 {% if range_choice == "10" %}btn-primary{% endif %}">{% trans "Next 10" %}</a>
|
||||
<a href="?range=25" class="btn join-item flex-1 {% if range_choice == "25" %}btn-primary{% endif %}">{% trans "Next 25" %}</a>
|
||||
<a href="?range=50" class="btn join-item flex-1 {% if range_choice == "50" %}btn-primary{% endif %}">{% trans "Next 50" %}</a>
|
||||
</div>
|
||||
|
||||
{% regroup games by start.date as day_groups %}
|
||||
{% for day in day_groups %}
|
||||
<h3 class="font-semibold text-sm opacity-70 mt-4 mb-2">{{ day.grouper|date:"l j F" }}</h3>
|
||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 mb-2">
|
||||
{% for game in day.list %}
|
||||
<div class="card bg-base-100 shadow-sm border {% if not game.referee_rows %}border-error{% elif not game.referees_full %}border-warning{% else %}border-base-300{% endif %}">
|
||||
<div class="card-body p-4 gap-2">
|
||||
<a class="link link-hover font-semibold" href="{% url 'management:event_detail' game.pk %}">
|
||||
{% for team in game.teams.all %}{{ team.short_name }}{% if not forloop.last %}, {% endif %}{% endfor %}
|
||||
{% if game.opponent %}{% trans "vs" %} {{ game.opponent }}{% endif %}
|
||||
</a>
|
||||
<div class="text-xs opacity-70">{{ game.start|date:"H:i" }}{% if game.location %} — {{ game.location }}{% endif %}</div>
|
||||
<div class="text-xs">
|
||||
{% for referee in game.referee_rows %}
|
||||
<span class="badge badge-ghost badge-sm">{{ referee.display_name }}{% if referee.is_external %} ({% trans "ext." %}){% endif %}</span>
|
||||
{% empty %}
|
||||
<span class="opacity-60">{% trans "No referees assigned yet." %}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="badge badge-sm {% if not game.referee_rows %}badge-error{% elif not game.referees_full %}badge-warning{% else %}badge-success{% endif %}">
|
||||
{{ game.referee_rows|length }} / {{ game.max_referees }}
|
||||
</span>
|
||||
<div class="flex gap-1">
|
||||
<a class="btn btn-xs btn-outline" href="{% url 'management:event_referee_form_pdf' game.pk %}">
|
||||
{% lucide "file-down" size=12 %} {% trans "Referee form" %}
|
||||
</a>
|
||||
<button class="btn btn-xs btn-outline" type="button" onclick="document.getElementById('{{ game.pk|dom_id:"referee_game_modal" }}').showModal()">
|
||||
{% lucide "flag" size=12 %} {% trans "Manage" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog id="{{ game.pk|dom_id:"referee_game_modal" }}" class="modal">
|
||||
<div class="modal-box max-w-2xl">
|
||||
<form method="dialog"><button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button></form>
|
||||
<h3 class="font-bold text-lg mb-1">
|
||||
{% for team in game.teams.all %}{{ team.short_name }}{% if not forloop.last %}, {% endif %}{% endfor %}
|
||||
{% if game.opponent %}{% trans "vs" %} {{ game.opponent }}{% endif %}
|
||||
</h3>
|
||||
<div class="text-sm opacity-70 mb-4">{{ game.start|date:"j M Y H:i" }}</div>
|
||||
{% include "management/_referee_assignment_panel.html" with event=game referees=game.referee_rows referee_candidates=game.referee_candidates referees_full=game.referees_full can_manage_referees=True next_url=request.get_full_path %}
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
||||
</dialog>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<p class="text-center opacity-60">{% trans "No upcoming home games need a club-arranged referee." %}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endblock panel %}
|
||||
125
management/templates/management/team_bulk_add.html
Normal file
125
management/templates/management/team_bulk_add.html
Normal file
@@ -0,0 +1,125 @@
|
||||
{% extends "management/base.html" %}
|
||||
{% load i18n lucide %}
|
||||
|
||||
{% block heading %}{% trans "Add multiple people" %}{% endblock heading %}
|
||||
{% block subheading %}{{ team.name }}{% endblock subheading %}
|
||||
|
||||
{% block panel %}
|
||||
<form method="get" class="mb-4">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<label class="input">
|
||||
<span class="opacity-50">{% lucide "search" size=16 %}</span>
|
||||
<input type="search" name="q" value="{{ search }}" placeholder="{% trans 'Search members ...' %}" class="input input-bordered">
|
||||
</label>
|
||||
<button class="btn btn-outline gap-2" type="submit">{% lucide "filter" size=16 %} {% trans "Filter" %}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm opacity-70 font-semibold">
|
||||
{% blocktrans count counter=members|length %}{{ counter }} eligible member{% plural %}{{ counter }} eligible members{% endblocktrans %}
|
||||
</span>
|
||||
<button class="btn btn-primary btn-sm gap-2" type="submit">{% lucide "users" size=14 %} {% trans "Add selected" %}</button>
|
||||
</div>
|
||||
<p class="text-sm opacity-70 mb-2">
|
||||
{% blocktrans %}Only members who are active (paid up) for this club this season or next are eligible. Tick "Player" and/or "Staff" for anyone you want to add -- both at once works too (a playing coach, say).{% endblocktrans %}
|
||||
</p>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Member" %}</th>
|
||||
<th>{% trans "Player" %}</th>
|
||||
<th>{% trans "Position" %}</th>
|
||||
<th>{% trans "Jersey #" %}</th>
|
||||
<th>{% trans "Staff" %}</th>
|
||||
<th>{% trans "Position" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for member in members %}
|
||||
<tr>
|
||||
<td>{{ member }}</td>
|
||||
<td>
|
||||
{% if member.already_player %}
|
||||
<span class="badge badge-neutral badge-sm">{% trans "On roster" %}</span>
|
||||
{% else %}
|
||||
<input type="checkbox" class="checkbox player-checkbox" name="player_{{ member.pk }}" data-member="{{ member.pk }}">
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if not member.already_player %}
|
||||
<select name="player_position_{{ member.pk }}" class="select select-bordered select-sm player-position" data-member="{{ member.pk }}" disabled>
|
||||
<option value="">{% trans "—" %}</option>
|
||||
{% for position in player_positions %}
|
||||
<option value="{{ position.pk }}">{{ position.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if not member.already_player %}
|
||||
<input type="number" min="0" name="jersey_{{ member.pk }}" class="input input-bordered input-sm w-20 player-jersey" data-member="{{ member.pk }}" disabled>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if member.already_staff %}
|
||||
<span class="badge badge-neutral badge-sm">{% trans "On staff" %}</span>
|
||||
{% else %}
|
||||
<input type="checkbox" class="checkbox staff-checkbox" name="staff_{{ member.pk }}" data-member="{{ member.pk }}">
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if not member.already_staff %}
|
||||
<select name="staff_position_{{ member.pk }}" class="select select-bordered select-sm staff-position" data-member="{{ member.pk }}" disabled>
|
||||
<option value="">{% trans "—" %}</option>
|
||||
{% for position in staff_positions %}
|
||||
<option value="{{ position.pk }}">{{ position.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="6" class="text-center opacity-60">{% trans "No eligible members match this search." %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<a class="btn btn-outline gap-2" href="{% url 'management:team_detail' team.pk %}?season={{ season.pk }}">{% lucide "arrow-left" size=16 %} {% trans "Back" %}</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock panel %}
|
||||
|
||||
{% block extra_body %}
|
||||
<script>
|
||||
(() => {
|
||||
const pairs = [
|
||||
{checkboxClass: "player-checkbox", fieldClasses: ["player-position", "player-jersey"]},
|
||||
{checkboxClass: "staff-checkbox", fieldClasses: ["staff-position"]},
|
||||
];
|
||||
|
||||
pairs.forEach(({checkboxClass, fieldClasses}) => {
|
||||
document.querySelectorAll(`.${checkboxClass}`).forEach((checkbox) => {
|
||||
const memberId = checkbox.dataset.member;
|
||||
const fields = fieldClasses.flatMap((cls) => Array.from(document.querySelectorAll(`.${cls}[data-member="${memberId}"]`)));
|
||||
checkbox.addEventListener("change", () => {
|
||||
fields.forEach((field) => { field.disabled = !checkbox.checked; });
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock extra_body %}
|
||||
@@ -149,7 +149,10 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="card-title text-base">{% trans "Roster" %}</h2>
|
||||
{% if can_manage %}
|
||||
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('add_player_modal').showModal()">{% lucide "user-plus" size=14 %} {% trans "Add player" %}</button>
|
||||
<div class="flex gap-2">
|
||||
<a class="btn btn-outline btn-sm gap-2" href="{% url 'management:team_bulk_add' team.pk selected_season.pk %}">{% lucide "users" size=14 %} {% trans "Add multiple" %}</a>
|
||||
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('add_player_modal').showModal()">{% lucide "user-plus" size=14 %} {% trans "Add player" %}</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
@@ -198,7 +201,10 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="card-title text-base">{% trans "Staff" %}</h2>
|
||||
{% if can_manage %}
|
||||
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('add_staff_modal').showModal()">{% lucide "user-plus" size=14 %} {% trans "Assign staff" %}</button>
|
||||
<div class="flex gap-2">
|
||||
<a class="btn btn-outline btn-sm gap-2" href="{% url 'management:team_bulk_add' team.pk selected_season.pk %}">{% lucide "users" size=14 %} {% trans "Add multiple" %}</a>
|
||||
<button class="btn btn-outline btn-sm gap-2" type="button" onclick="document.getElementById('add_staff_modal').showModal()">{% lucide "user-plus" size=14 %} {% trans "Assign staff" %}</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
@@ -235,6 +241,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow mt-4">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">{% lucide "flag" size=18 %} {% trans "Eligible referees" %}</h2>
|
||||
{% if eligible_referees is None %}
|
||||
<p class="text-sm opacity-70">{% trans "Referees for this team's home games are managed by the federation, not the club -- nothing to configure here." %}</p>
|
||||
{% else %}
|
||||
<p class="text-sm opacity-70">{% trans "Members who can be assigned to referee this team's home games. Set from each member's own page." %}</p>
|
||||
{% if eligible_referees %}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% for referee in eligible_referees %}
|
||||
<a class="badge badge-outline hover:badge-neutral" href="{% url 'management:member_detail' referee.pk %}">{{ referee }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm opacity-60">{% trans "No one yet." %}</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow mt-4">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between">
|
||||
|
||||
1085
management/tests.py
1085
management/tests.py
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@ urlpatterns = [
|
||||
path("members/<uuid:pk>/delete/", views.MemberDeleteView.as_view(), name="member_delete"),
|
||||
path("members/<uuid:pk>/attach-family/", views.MemberAttachToFamilyView.as_view(), name="member_attach_family"),
|
||||
path("members/<uuid:pk>/grant-login/", views.MemberGrantLoginView.as_view(), name="member_grant_login"),
|
||||
path("members/<uuid:pk>/referee-eligibility/", views.MemberRefereeEligibilityUpdateView.as_view(), name="member_referee_eligibility_update"),
|
||||
path("members/<uuid:pk>/detach-family/<uuid:family_pk>/", views.MemberDetachFromFamilyView.as_view(), name="member_detach_family"),
|
||||
path("families/new/", views.FamilyCreateView.as_view(), name="family_create"),
|
||||
path("families/<uuid:pk>/", views.FamilyDetailView.as_view(), name="family_detail"),
|
||||
@@ -35,6 +36,13 @@ urlpatterns = [
|
||||
path("roles/", views.ClubRoleListView.as_view(), name="role_list"),
|
||||
path("roles/new/", views.ClubRoleCreateView.as_view(), name="role_create"),
|
||||
path("roles/<uuid:pk>/revoke/", views.ClubRoleRevokeView.as_view(), name="role_revoke"),
|
||||
path("groups/", views.GroupListView.as_view(), name="group_list"),
|
||||
path("groups/new/", views.GroupCreateView.as_view(), name="group_create"),
|
||||
path("groups/<uuid:pk>/", views.GroupDetailView.as_view(), name="group_detail"),
|
||||
path("groups/<uuid:pk>/edit/", views.GroupUpdateView.as_view(), name="group_update"),
|
||||
path("groups/<uuid:pk>/delete/", views.GroupDeleteView.as_view(), name="group_delete"),
|
||||
path("groups/<uuid:pk>/bulk-add/", views.GroupBulkAddView.as_view(), name="group_bulk_add"),
|
||||
path("groups/<uuid:pk>/members/<uuid:membership_pk>/remove/", views.GroupMemberRemoveView.as_view(), name="group_member_remove"),
|
||||
# Teams
|
||||
path("teams/", views.TeamListView.as_view(), name="team_list"),
|
||||
path("teams/new/", views.TeamCreateView.as_view(), name="team_create"),
|
||||
@@ -42,6 +50,7 @@ urlpatterns = [
|
||||
path("teams/<uuid:pk>/edit/", views.TeamUpdateView.as_view(), name="team_update"),
|
||||
path("teams/<uuid:pk>/delete/", views.TeamDeleteView.as_view(), name="team_delete"),
|
||||
path("teams/<uuid:pk>/roster/<uuid:season_pk>/add/", views.TeamRosterAddView.as_view(), name="team_roster_add"),
|
||||
path("teams/<uuid:pk>/roster/<uuid:season_pk>/bulk-add/", views.TeamBulkAddView.as_view(), name="team_bulk_add"),
|
||||
path("teams/<uuid:pk>/roster/<uuid:membership_pk>/edit/", views.TeamRosterUpdateView.as_view(), name="team_roster_update"),
|
||||
path("teams/<uuid:pk>/roster/<uuid:membership_pk>/remove/", views.TeamRosterRemoveView.as_view(), name="team_roster_remove"),
|
||||
path("teams/<uuid:pk>/staff/<uuid:season_pk>/add/", views.TeamStaffAddView.as_view(), name="team_staff_add"),
|
||||
@@ -49,6 +58,11 @@ urlpatterns = [
|
||||
path("teams/<uuid:pk>/staff/<uuid:assignment_pk>/remove/", views.TeamStaffRemoveView.as_view(), name="team_staff_remove"),
|
||||
path("teams/<uuid:pk>/photo/<uuid:season_pk>/set/", views.TeamPhotoSetView.as_view(), name="team_photo_set"),
|
||||
path("teams/<uuid:pk>/photo/<uuid:season_pk>/delete/", views.TeamPhotoDeleteView.as_view(), name="team_photo_delete"),
|
||||
path("referees/", views.RefereeListView.as_view(), name="referee_list"),
|
||||
path("referees/management/", views.RefereeManagementDashboardView.as_view(), name="referee_management"),
|
||||
path("referee-levels/", views.RefereeLevelListView.as_view(), name="referee_level_list"),
|
||||
path("referee-levels/new/", views.RefereeLevelCreateView.as_view(), name="referee_level_create"),
|
||||
path("referee-levels/<uuid:pk>/edit/", views.RefereeLevelUpdateView.as_view(), name="referee_level_update"),
|
||||
# News
|
||||
path("news/", views.NewsListView.as_view(), name="news_list"),
|
||||
path("news/new/", views.NewsCreateView.as_view(), name="news_create"),
|
||||
@@ -68,6 +82,11 @@ urlpatterns = [
|
||||
path("events/<uuid:pk>/delete/", views.EventDeleteView.as_view(), name="event_delete"),
|
||||
path("events/<uuid:pk>/detach/", views.EventDetachView.as_view(), name="event_detach"),
|
||||
path("events/<uuid:pk>/fetch-game-info/", views.EventFetchGameInfoView.as_view(), name="event_fetch_game_info"),
|
||||
path("events/<uuid:pk>/referees/assign/", views.EventRefereeAssignView.as_view(), name="event_referee_assign"),
|
||||
path("events/<uuid:pk>/referees/add-external/", views.EventRefereeAddExternalView.as_view(), name="event_referee_add_external"),
|
||||
path("events/<uuid:pk>/referees/<uuid:referee_pk>/remove/", views.EventRefereeRemoveView.as_view(), name="event_referee_remove"),
|
||||
path("events/<uuid:pk>/referees/<uuid:referee_pk>/fee/", views.EventRefereeFeeUpdateView.as_view(), name="event_referee_fee_update"),
|
||||
path("events/<uuid:pk>/referees/form-pdf/", views.EventRefereeFormPdfView.as_view(), name="event_referee_form_pdf"),
|
||||
path("events/rbihf-import/", views.RBIHFImportView.as_view(), name="rbihf_import"),
|
||||
path("events/rbihf-import/confirm/", views.RBIHFImportConfirmView.as_view(), name="rbihf_import_confirm"),
|
||||
path("event-series/new/", views.EventSeriesCreateView.as_view(), name="event_series_create"),
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.db.models import Count, ProtectedError, Q
|
||||
from django.http import HttpResponse
|
||||
@@ -27,18 +30,20 @@ from club.services.fees import mark_as_paid, record_payment, remaining_balance
|
||||
from controlpanel.messages import notify
|
||||
from controlpanel.mixins import RedirectOnInvalidMixin
|
||||
from controlpanel.services.statistics import club_attention, club_charts, club_statistics
|
||||
from events.models import Attendance, Event, EventSeries, Location, Opponent
|
||||
from events.models import Attendance, Event, EventReferee, EventSeries, Location, Opponent
|
||||
from events.services.attendance import player_attendance_rankings, players_who_missed_recent_practices, team_attendance_rate, team_no_shows
|
||||
from events.services.competitions import CompetitionFetchError, fetch_game_info
|
||||
from events.services.rbihf_import import RBIHFImportError, apply_plan, build_plan, extract_team_id, fetch_html
|
||||
from events.services.recurrence import cancel_occurrence, detach_occurrence, generate_occurrences, propagate_series
|
||||
from events.services.referees import RefereeAssignmentError, add_external_referee, assign_referee, conflicting_events, eligible_referees, needs_referee_management, remove_referee, set_referee_fee
|
||||
from formbuilder.models import Form as FormBuilderForm
|
||||
from formbuilder.models import Submission
|
||||
from members.models import Family, FamilyMembership, Member
|
||||
from members.services.family import add_child_to_family, add_parent_to_family, attach_to_family, detach_from_family, grant_login, register_family
|
||||
from members.models import Family, FamilyMembership, Group, GroupMembership, Member
|
||||
from members.services.family import add_child_to_family, add_parent_to_family, attach_to_family, detach_from_family, get_or_create_login_user, grant_login, register_family
|
||||
from news.models import News, NewsPhoto
|
||||
from shop.models import Discount, Invoice, Order, Product
|
||||
from teams.models import Position, StaffAssignment, Team, TeamMembership, TeamPhoto
|
||||
from teams.models import Position, RefereeLevel, RefereeProfile, StaffAssignment, Team, TeamMembership, TeamPhoto
|
||||
from teams.services import eligible_roster_members
|
||||
|
||||
from .bulk_import import build_member_import_template, parse_member_import_rows, read_member_import_workbook
|
||||
from .forms import (
|
||||
@@ -48,12 +53,16 @@ from .forms import (
|
||||
ClubMembershipForm,
|
||||
ClubRoleAssignForm,
|
||||
EventForm,
|
||||
EventRefereeFeeForm,
|
||||
EventSeriesForm,
|
||||
ExternalRefereeForm,
|
||||
FamilyCreateForm,
|
||||
GrantLoginForm,
|
||||
GroupForm,
|
||||
LocationForm,
|
||||
MemberForm,
|
||||
MemberImportUploadForm,
|
||||
MemberRefereeEligibilityForm,
|
||||
NewsForm,
|
||||
NewsPhotoUploadForm,
|
||||
NewsPublishForm,
|
||||
@@ -61,13 +70,14 @@ from .forms import (
|
||||
PositionForm,
|
||||
RBIHFImportForm,
|
||||
RecordFeePaymentForm,
|
||||
RefereeLevelForm,
|
||||
SponsorForm,
|
||||
StaffAssignmentForm,
|
||||
TeamForm,
|
||||
TeamMembershipForm,
|
||||
TeamPhotoForm,
|
||||
)
|
||||
from .pdf import PDFExportError, membership_list_pdf
|
||||
from .pdf import PDFExportError, event_referee_form_pdf, membership_list_pdf
|
||||
from .recurrence_ui import describe_rrule
|
||||
|
||||
|
||||
@@ -476,12 +486,31 @@ class MemberImportConfirmView(ClubAdminRequiredMixin, View):
|
||||
season = current_season(request.club)
|
||||
|
||||
created = 0
|
||||
families_by_group = {}
|
||||
with transaction.atomic():
|
||||
for result in results:
|
||||
member = result["member"]
|
||||
if member is None:
|
||||
continue
|
||||
|
||||
family_role = result["family_role"]
|
||||
if family_role in (FamilyMembership.FamilyRole.PARENT, FamilyMembership.FamilyRole.GUARDIAN) and member.email:
|
||||
# Give the parent/guardian a login before saving, same as
|
||||
# registering a family by hand -- get_or_create_login_user
|
||||
# reuses an existing account for that email rather than
|
||||
# risking a duplicate.
|
||||
member.user, _unused = get_or_create_login_user(member.email)
|
||||
|
||||
member.save()
|
||||
|
||||
family_group = result["family_group"]
|
||||
if family_group:
|
||||
family = families_by_group.get(family_group)
|
||||
if family is None:
|
||||
family = Family.objects.create()
|
||||
families_by_group[family_group] = family
|
||||
FamilyMembership.objects.create(family=family, member=member, role=family_role)
|
||||
|
||||
if season is not None:
|
||||
ClubMembership.objects.create(club=request.club, member=member, season=season, signed_up_at=timezone.localdate(), **result["membership_kwargs"])
|
||||
created += 1
|
||||
@@ -585,6 +614,9 @@ class MemberDetailView(ClubStaffRequiredMixin, DetailView):
|
||||
family_scoped_members = visible.filter(family_memberships__family_id__in=my_family_ids).distinct()
|
||||
family_groups, _ = group_by_family(family_scoped_members)
|
||||
|
||||
is_admin = is_club_admin(self.request.user, self.request.club)
|
||||
referee_profile = RefereeProfile.objects.filter(member=self.object).select_related("level").first()
|
||||
|
||||
return super().get_context_data(
|
||||
family_groups=family_groups,
|
||||
family_role_choices=FamilyMembership.FamilyRole.choices,
|
||||
@@ -597,6 +629,8 @@ class MemberDetailView(ClubStaffRequiredMixin, DetailView):
|
||||
# signal the Personal information card uses to decide whether to show parent
|
||||
# contact numbers at all.
|
||||
guardians=self.object.guardians,
|
||||
referee_profile=referee_profile,
|
||||
referee_eligibility_form=MemberRefereeEligibilityForm(club=self.request.club, member=self.object) if is_admin else None,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@@ -623,6 +657,31 @@ class MemberAttachToFamilyView(ClubAdminRequiredMixin, RedirectOnInvalidMixin, F
|
||||
return redirect("management:member_detail", pk=member.pk)
|
||||
|
||||
|
||||
class MemberRefereeEligibilityUpdateView(ClubAdminRequiredMixin, RedirectOnInvalidMixin, FormView):
|
||||
"""Reachable only via the "Referee eligibility" modal on a member's page --
|
||||
which teams' home games this member can be assigned to referee
|
||||
(teams.RefereeProfile). Admin-only, same as Roles/Positions/Groups."""
|
||||
|
||||
form_class = MemberRefereeEligibilityForm
|
||||
http_method_names = ["post"]
|
||||
invalid_redirect_url_name = "management:member_detail"
|
||||
|
||||
def get_invalid_redirect_kwargs(self):
|
||||
return {"pk": self.kwargs["pk"]}
|
||||
|
||||
def get_member(self):
|
||||
return get_object_or_404(members_visible_to(self.request.user, self.request.club), pk=self.kwargs["pk"])
|
||||
|
||||
def get_form_kwargs(self):
|
||||
return super().get_form_kwargs() | {"club": self.request.club, "member": self.get_member()}
|
||||
|
||||
def form_valid(self, form):
|
||||
member = self.get_member()
|
||||
form.save()
|
||||
notify(self.request, f"s|{_('Referee eligibility updated')}|" + _("Updated which teams “%(member)s” can referee for.") % {"member": member})
|
||||
return redirect("management:member_detail", pk=member.pk)
|
||||
|
||||
|
||||
class MemberGrantLoginView(ClubAdminRequiredMixin, RedirectOnInvalidMixin, FormView):
|
||||
"""Reachable only via the "Grant login" modal on a login-less child's row in
|
||||
_family_members_table.html. The form itself (management.forms.GrantLoginForm)
|
||||
@@ -847,6 +906,13 @@ class TeamDetailView(ClubStaffRequiredMixin, DetailView):
|
||||
bottom_attenders=bottom_attenders,
|
||||
missed_practices=missed_practices,
|
||||
no_shows=no_shows,
|
||||
# None (not an empty queryset) signals "federation-managed" to the
|
||||
# template, distinct from "club-managed, nobody eligible yet".
|
||||
eligible_referees=(
|
||||
Member.objects.filter(referee_profile__level__teams=team, referee_profile__valid_until__gte=timezone.localdate()).order_by("last_name", "first_name")
|
||||
if team.referee_management == Team.RefereeManagement.CLUB
|
||||
else None
|
||||
),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@@ -1027,6 +1093,131 @@ class TeamStaffRemoveView(TeamManagerRequiredMixin, View):
|
||||
return redirect(f"{reverse('management:team_detail', args=[pk])}?season={season_id}")
|
||||
|
||||
|
||||
class TeamBulkAddView(TeamManagerRequiredMixin, View):
|
||||
"""Add many people to a team's roster and/or staff in one go -- the one-by-one
|
||||
modals (TeamRosterAddView / TeamStaffAddView) don't scale past a handful of
|
||||
names. Every eligible member gets an independent "add as player" and "add as
|
||||
staff" pair of controls, so one person can be added as both in a single submit
|
||||
(a playing coach, most often).
|
||||
|
||||
Same eligibility rule as the single-add forms (eligible_roster_members: active
|
||||
-- i.e. paid -- for this club this season or next), enforced server-side
|
||||
regardless of what the client submits.
|
||||
"""
|
||||
|
||||
template_name = "management/team_bulk_add.html"
|
||||
|
||||
def get_team(self):
|
||||
return get_object_or_404(Team.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
|
||||
|
||||
def get_season(self):
|
||||
return get_object_or_404(Season.objects.filter(club=self.request.club), pk=self.kwargs["season_pk"])
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
team = self.get_team()
|
||||
season = self.get_season()
|
||||
club = self.request.club
|
||||
|
||||
members = eligible_roster_members(club).order_by("last_name", "first_name")
|
||||
search = self.request.GET.get("q", "").strip()
|
||||
if search:
|
||||
members = members.filter(Q(first_name__icontains=search) | Q(last_name__icontains=search))
|
||||
|
||||
rostered = set(TeamMembership.objects.filter(team=team, season=season).values_list("member_id", flat=True))
|
||||
staffed = set(StaffAssignment.objects.filter(team=team, season=season).values_list("member_id", flat=True))
|
||||
members = list(members)
|
||||
for member in members:
|
||||
member.already_player = member.pk in rostered
|
||||
member.already_staff = member.pk in staffed
|
||||
|
||||
return {
|
||||
"team": team,
|
||||
"season": season,
|
||||
"members": members,
|
||||
"search": search,
|
||||
"player_positions": Position.objects.filter(club=club, staff_position=False),
|
||||
"staff_positions": Position.objects.filter(club=club, staff_position=True),
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
return render(request, self.template_name, self.get_context_data())
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
team = self.get_team()
|
||||
season = self.get_season()
|
||||
club = self.request.club
|
||||
|
||||
rostered = set(TeamMembership.objects.filter(team=team, season=season).values_list("member_id", flat=True))
|
||||
staffed = set(StaffAssignment.objects.filter(team=team, season=season).values_list("member_id", flat=True))
|
||||
used_jerseys = set(TeamMembership.objects.filter(team=team, season=season, jersey_number__isnull=False).values_list("jersey_number", flat=True))
|
||||
player_positions = {str(position.pk): position for position in Position.objects.filter(club=club, staff_position=False)}
|
||||
staff_positions = {str(position.pk): position for position in Position.objects.filter(club=club, staff_position=True)}
|
||||
|
||||
players_added = staff_added = 0
|
||||
errors = []
|
||||
|
||||
# Recomputed server-side from eligible_roster_members, never from client
|
||||
# input -- a submitted member id that isn't actually eligible (lapsed
|
||||
# between page load and submit, say) is silently skipped rather than
|
||||
# trusted.
|
||||
for member in eligible_roster_members(club):
|
||||
key = str(member.pk)
|
||||
|
||||
if member.pk not in rostered and request.POST.get(f"player_{key}"):
|
||||
position = player_positions.get(request.POST.get(f"player_position_{key}", ""))
|
||||
if position is None:
|
||||
errors.append(_("%(member)s: choose a position to add them as a player.") % {"member": member})
|
||||
else:
|
||||
jersey_raw = request.POST.get(f"jersey_{key}", "").strip()
|
||||
jersey_number, jersey_error = None, False
|
||||
if jersey_raw:
|
||||
try:
|
||||
jersey_number = int(jersey_raw)
|
||||
except ValueError:
|
||||
jersey_error = True
|
||||
errors.append(_("%(member)s: jersey number must be a whole number.") % {"member": member})
|
||||
|
||||
if not jersey_error:
|
||||
if jersey_number is not None and jersey_number in used_jerseys:
|
||||
errors.append(_("%(member)s: jersey #%(number)s is already taken this season.") % {"member": member, "number": jersey_number})
|
||||
else:
|
||||
membership = TeamMembership(team=team, season=season, member=member, position=position, jersey_number=jersey_number)
|
||||
try:
|
||||
membership.full_clean()
|
||||
membership.save()
|
||||
except (ValidationError, IntegrityError):
|
||||
errors.append(_("%(member)s: could not be added as a player -- please check the details and try again.") % {"member": member})
|
||||
else:
|
||||
players_added += 1
|
||||
if jersey_number is not None:
|
||||
used_jerseys.add(jersey_number)
|
||||
|
||||
if member.pk not in staffed and request.POST.get(f"staff_{key}"):
|
||||
position = staff_positions.get(request.POST.get(f"staff_position_{key}", ""))
|
||||
if position is None:
|
||||
errors.append(_("%(member)s: choose a position to add them as staff.") % {"member": member})
|
||||
else:
|
||||
assignment = StaffAssignment(team=team, season=season, member=member, position=position)
|
||||
try:
|
||||
assignment.full_clean()
|
||||
assignment.save()
|
||||
except (ValidationError, IntegrityError):
|
||||
errors.append(_("%(member)s: could not be assigned as staff -- please check the details and try again.") % {"member": member})
|
||||
else:
|
||||
staff_added += 1
|
||||
|
||||
if players_added or staff_added:
|
||||
body = _("%(players)s added as player(s), %(staff)s added as staff.") % {"players": players_added, "staff": staff_added}
|
||||
notify(request, f"s|{_('Team updated')}|{body}")
|
||||
for error in errors:
|
||||
notify(request, f"e|{_('Could not add')}|{error}")
|
||||
if not players_added and not staff_added and not errors:
|
||||
notify(request, f"i|{_('Nothing to add')}|{_('No one was selected.')}")
|
||||
|
||||
return redirect(f"{reverse('management:team_detail', args=[team.pk])}?season={season.pk}")
|
||||
|
||||
|
||||
class TeamPhotoSetView(TeamManagerRequiredMixin, FormView):
|
||||
"""Reachable only via the "Upload"/"Replace" modal on the team page. Binds
|
||||
to the existing TeamPhoto for this team+season (if any) so re-uploading
|
||||
@@ -1315,6 +1506,200 @@ class PositionUpdateView(ClubAdminRequiredMixin, UpdateView):
|
||||
return super().get_context_data(update_view=True, **kwargs)
|
||||
|
||||
|
||||
class RefereeLevelListView(ClubStaffRequiredMixin, ListView):
|
||||
"""Visible to any staff, same reasoning as PositionListView; creating/
|
||||
editing a level is admin-only."""
|
||||
|
||||
template_name = "management/referee_level_list.html"
|
||||
context_object_name = "levels"
|
||||
|
||||
def get_queryset(self):
|
||||
return RefereeLevel.objects.filter(club=self.request.club).prefetch_related("teams")
|
||||
|
||||
|
||||
class RefereeLevelCreateView(ClubAdminRequiredMixin, CreateView):
|
||||
model = RefereeLevel
|
||||
form_class = RefereeLevelForm
|
||||
template_name = "management/referee_level_form.html"
|
||||
|
||||
def get_form_kwargs(self):
|
||||
return super().get_form_kwargs() | {"club": self.request.club}
|
||||
|
||||
def form_valid(self, form):
|
||||
form.instance.club = self.request.club
|
||||
response = super().form_valid(form)
|
||||
body = _("“%(level)s” created.") % {"level": self.object}
|
||||
notify(self.request, f"s|{_('Referee level created')}|{body}")
|
||||
return response
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse("management:referee_level_list")
|
||||
|
||||
|
||||
class RefereeLevelUpdateView(ClubAdminRequiredMixin, UpdateView):
|
||||
model = RefereeLevel
|
||||
form_class = RefereeLevelForm
|
||||
template_name = "management/referee_level_form.html"
|
||||
|
||||
def get_queryset(self):
|
||||
return RefereeLevel.objects.filter(club=self.request.club)
|
||||
|
||||
def get_form_kwargs(self):
|
||||
return super().get_form_kwargs() | {"club": self.request.club}
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form)
|
||||
body = _("“%(level)s” updated.") % {"level": self.object}
|
||||
notify(self.request, f"s|{_('Referee level updated')}|{body}")
|
||||
return response
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse("management:referee_level_list")
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(update_view=True, **kwargs)
|
||||
|
||||
|
||||
class RefereeListView(ClubStaffRequiredMixin, ListView):
|
||||
"""Every referee in the club, at a glance: level, eligible teams, validity
|
||||
-- see teams.RefereeProfile. Read-only; editing happens on the member's
|
||||
own page (MemberRefereeEligibilityUpdateView)."""
|
||||
|
||||
template_name = "management/referee_list.html"
|
||||
context_object_name = "referees"
|
||||
|
||||
def get_queryset(self):
|
||||
members = members_visible_to(self.request.user, self.request.club).filter(referee_profile__isnull=False)
|
||||
return members.select_related("referee_profile", "referee_profile__level").prefetch_related("referee_profile__level__teams").order_by("last_name", "first_name")
|
||||
|
||||
|
||||
# --- Groups: a generic named collection of members (all coaches, all team managers,
|
||||
# a referee pool, ...) -- admin-only, like Positions/Roles above -------------------
|
||||
|
||||
|
||||
class GroupListView(ClubAdminRequiredMixin, ListView):
|
||||
template_name = "management/group_list.html"
|
||||
context_object_name = "groups"
|
||||
|
||||
def get_queryset(self):
|
||||
return Group.objects.filter(club=self.request.club).annotate(member_count=Count("memberships", distinct=True))
|
||||
|
||||
|
||||
class GroupCreateView(ClubAdminRequiredMixin, CreateView):
|
||||
model = Group
|
||||
form_class = GroupForm
|
||||
template_name = "management/group_form.html"
|
||||
|
||||
def form_valid(self, form):
|
||||
form.instance.club = self.request.club
|
||||
response = super().form_valid(form)
|
||||
body = _("“%(group)s” created.") % {"group": self.object}
|
||||
notify(self.request, f"s|{_('Group created')}|{body}")
|
||||
return response
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse("management:group_detail", args=[self.object.pk])
|
||||
|
||||
|
||||
class GroupUpdateView(ClubAdminRequiredMixin, UpdateView):
|
||||
model = Group
|
||||
form_class = GroupForm
|
||||
template_name = "management/group_form.html"
|
||||
|
||||
def get_queryset(self):
|
||||
return Group.objects.filter(club=self.request.club)
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form)
|
||||
body = _("“%(group)s” updated.") % {"group": self.object}
|
||||
notify(self.request, f"s|{_('Group updated')}|{body}")
|
||||
return response
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse("management:group_detail", args=[self.object.pk])
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(update_view=True, **kwargs)
|
||||
|
||||
|
||||
class GroupDeleteView(ClubAdminRequiredMixin, View):
|
||||
def post(self, request, pk):
|
||||
group = get_object_or_404(Group.objects.filter(club=request.club), pk=pk)
|
||||
name = str(group)
|
||||
group.delete()
|
||||
notify(request, f"w|{_('Group deleted')}|" + _("“%(group)s” deleted.") % {"group": name})
|
||||
return redirect("management:group_list")
|
||||
|
||||
|
||||
class GroupDetailView(ClubAdminRequiredMixin, DetailView):
|
||||
template_name = "management/group_detail.html"
|
||||
context_object_name = "group"
|
||||
|
||||
def get_queryset(self):
|
||||
return Group.objects.filter(club=self.request.club)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(
|
||||
memberships=GroupMembership.objects.filter(group=self.object).select_related("member"),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
class GroupBulkAddView(ClubAdminRequiredMixin, View):
|
||||
"""Add many members to a group in one go -- mirrors TeamBulkAddView's
|
||||
checkbox-table pattern, minus the player/staff split (group membership has
|
||||
no per-member attributes)."""
|
||||
|
||||
template_name = "management/group_bulk_add.html"
|
||||
|
||||
def get_group(self):
|
||||
return get_object_or_404(Group.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
group = self.get_group()
|
||||
members = members_visible_to(self.request.user, self.request.club).order_by("last_name", "first_name")
|
||||
search = self.request.GET.get("q", "").strip()
|
||||
if search:
|
||||
members = members.filter(Q(first_name__icontains=search) | Q(last_name__icontains=search))
|
||||
|
||||
existing_ids = set(GroupMembership.objects.filter(group=group).values_list("member_id", flat=True))
|
||||
members = list(members)
|
||||
for member in members:
|
||||
member.already_in_group = member.pk in existing_ids
|
||||
|
||||
return {"group": group, "members": members, "search": search, **kwargs}
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
return render(request, self.template_name, self.get_context_data())
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
group = self.get_group()
|
||||
existing_ids = set(GroupMembership.objects.filter(group=group).values_list("member_id", flat=True))
|
||||
|
||||
added = 0
|
||||
for member in members_visible_to(request.user, request.club):
|
||||
if member.pk not in existing_ids and request.POST.get(f"member_{member.pk}"):
|
||||
GroupMembership.objects.create(group=group, member=member)
|
||||
added += 1
|
||||
|
||||
if added:
|
||||
notify(request, f"s|{_('Group updated')}|" + _("%(count)s member(s) added to “%(group)s”.") % {"count": added, "group": group})
|
||||
else:
|
||||
notify(request, f"i|{_('Nothing to add')}|{_('No one was selected.')}")
|
||||
|
||||
return redirect("management:group_detail", pk=group.pk)
|
||||
|
||||
|
||||
class GroupMemberRemoveView(ClubAdminRequiredMixin, View):
|
||||
def post(self, request, pk, membership_pk):
|
||||
group = get_object_or_404(Group.objects.filter(club=request.club), pk=pk)
|
||||
membership = get_object_or_404(GroupMembership.objects.filter(group=group), pk=membership_pk)
|
||||
member = membership.member
|
||||
membership.delete()
|
||||
notify(request, f"w|{_('Removed from group')}|" + _("“%(member)s” removed from “%(group)s”.") % {"member": member, "group": group})
|
||||
return redirect("management:group_detail", pk=group.pk)
|
||||
|
||||
|
||||
# --- News: draft/edit is broad (any coach_manager/editor/admin), but only EDITOR/ADMIN
|
||||
# may publish -- the release flow the news app exists for ---------------------------
|
||||
|
||||
@@ -1596,10 +1981,251 @@ class EventDetailView(ClubStaffRequiredMixin, DetailView):
|
||||
# per-status sections, so there's exactly one query, not two.
|
||||
attendance_groups = [{"value": value, "label": label, "rows": rows_by_status.get(value, [])} for value, label in Attendance.AttendanceStatus.choices]
|
||||
|
||||
# Admin-only for now, unlike can_manage's other actions (edit, fetch info, ...) --
|
||||
# a team manager/coach still sees the Referees panel (who's assigned, capacity),
|
||||
# just not the assign/remove controls. See EventRefereeAssignView/RemoveView.
|
||||
can_manage_referees = is_club_admin(user, club)
|
||||
|
||||
referee_management_needed = needs_referee_management(event)
|
||||
referees = []
|
||||
referee_candidates = []
|
||||
referees_full = False
|
||||
if referee_management_needed:
|
||||
referees = list(event.referees.select_related("member", "assigned_by").order_by("member__last_name", "member__first_name"))
|
||||
referees_full = len(referees) >= event.max_referees
|
||||
if can_manage_referees:
|
||||
for referee in referees:
|
||||
referee.fee_form = EventRefereeFeeForm(instance=referee)
|
||||
if can_manage_referees and not referees_full:
|
||||
for candidate in eligible_referees(event):
|
||||
conflicts = conflicting_events(candidate, event)
|
||||
candidate.has_conflict = bool(conflicts)
|
||||
candidate.conflict_titles = ", ".join(conflict.title for conflict in conflicts)
|
||||
referee_candidates.append(candidate)
|
||||
|
||||
return super().get_context_data(
|
||||
can_manage=can_manage,
|
||||
can_manage_referees=can_manage_referees,
|
||||
referee_management_needed=referee_management_needed,
|
||||
attendance_groups=attendance_groups,
|
||||
has_attendance_rows=any(group["rows"] for group in attendance_groups),
|
||||
referees=referees,
|
||||
referee_candidates=referee_candidates,
|
||||
referees_full=referees_full,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def _redirect_next_or(request, fallback_url):
|
||||
"""`next` (POST body, or the query string -- the fee-edit modal posts to a
|
||||
plain action_url with no room to inject a hidden field, so it carries
|
||||
`next` there instead) if it's safe to redirect to, else `fallback_url`.
|
||||
Lets the same assign/remove/fee endpoints be posted to from more than one
|
||||
page (the event detail page, and the referee management dashboard) and
|
||||
return the visitor to wherever they actually came from."""
|
||||
next_url = request.POST.get("next") or request.GET.get("next")
|
||||
if next_url and url_has_allowed_host_and_scheme(next_url, allowed_hosts={request.get_host()}, require_https=request.is_secure()):
|
||||
return redirect(next_url)
|
||||
return redirect(fallback_url)
|
||||
|
||||
|
||||
class EventRefereeAssignView(ClubAdminRequiredMixin, View):
|
||||
"""Reachable via the assign control on the event detail page's Referees
|
||||
panel, or the referee management dashboard -- POST-only, no standalone
|
||||
template. Admin-only for now (unlike most event actions, which a team
|
||||
manager can also do) -- see EventDetailView's can_manage_referees; team
|
||||
managers/coaches still see the panel, just not the assign/remove
|
||||
controls."""
|
||||
|
||||
def get_event(self):
|
||||
return get_object_or_404(Event.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
|
||||
|
||||
def post(self, request, pk):
|
||||
event = self.get_event()
|
||||
member = get_object_or_404(eligible_referees(event), pk=request.POST.get("member"))
|
||||
assigned_by = Member.objects.filter(user=request.user).first()
|
||||
|
||||
try:
|
||||
assign_referee(event, member, assigned_by=assigned_by)
|
||||
except RefereeAssignmentError as error:
|
||||
notify(request, f"e|{_('Could not assign referee')}|{error}")
|
||||
else:
|
||||
notify(request, f"s|{_('Referee assigned')}|" + _("“%(member)s” will referee this game.") % {"member": member})
|
||||
|
||||
return _redirect_next_or(request, reverse("management:event_detail", args=[event.pk]))
|
||||
|
||||
|
||||
class EventRefereeRemoveView(ClubAdminRequiredMixin, View):
|
||||
def get_event(self):
|
||||
return get_object_or_404(Event.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
|
||||
|
||||
def post(self, request, pk, referee_pk):
|
||||
event = self.get_event()
|
||||
referee = get_object_or_404(EventReferee.objects.filter(event=event), pk=referee_pk)
|
||||
name = referee.display_name
|
||||
remove_referee(referee)
|
||||
notify(request, f"w|{_('Referee removed')}|" + _("“%(name)s” is no longer refereeing this game.") % {"name": name})
|
||||
return _redirect_next_or(request, reverse("management:event_detail", args=[event.pk]))
|
||||
|
||||
|
||||
class EventRefereeAddExternalView(ClubAdminRequiredMixin, View):
|
||||
"""Log a non-member referee (federation-appointed, most often) against a
|
||||
game -- reachable from the same Referees panel as EventRefereeAssignView,
|
||||
on both the event detail page and the referee management dashboard."""
|
||||
|
||||
def get_event(self):
|
||||
return get_object_or_404(Event.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
|
||||
|
||||
def post(self, request, pk):
|
||||
event = self.get_event()
|
||||
form = ExternalRefereeForm(request.POST)
|
||||
fallback = _redirect_next_or(request, reverse("management:event_detail", args=[event.pk]))
|
||||
|
||||
if not form.is_valid():
|
||||
notify(request, f"e|{_('Could not add referee')}|{_('A name is required.')}")
|
||||
return fallback
|
||||
|
||||
assigned_by = Member.objects.filter(user=request.user).first()
|
||||
try:
|
||||
add_external_referee(event, form.cleaned_data["name"], assigned_by=assigned_by)
|
||||
except RefereeAssignmentError as error:
|
||||
notify(request, f"e|{_('Could not add referee')}|{error}")
|
||||
else:
|
||||
notify(request, f"s|{_('Referee added')}|" + _("“%(name)s” will referee this game.") % {"name": form.cleaned_data["name"]})
|
||||
|
||||
return fallback
|
||||
|
||||
|
||||
class EventRefereeFeeUpdateView(ClubAdminRequiredMixin, FormView):
|
||||
"""Set one referee assignment's fee/km/rate -- reachable via the "Fee"
|
||||
modal on the Referees panel. POST-only, no standalone template. Not
|
||||
RedirectOnInvalidMixin: that redirects to a fixed url name, but this view
|
||||
(like the assign/remove ones) needs to honour the next-aware fallback so
|
||||
it works from both the event detail page and the dashboard."""
|
||||
|
||||
form_class = EventRefereeFeeForm
|
||||
http_method_names = ["post"]
|
||||
|
||||
def get_event(self):
|
||||
return get_object_or_404(Event.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
|
||||
|
||||
def get_referee(self):
|
||||
return get_object_or_404(EventReferee.objects.filter(event=self.get_event()), pk=self.kwargs["referee_pk"])
|
||||
|
||||
def get_fallback(self):
|
||||
return _redirect_next_or(self.request, reverse("management:event_detail", args=[self.kwargs["pk"]]))
|
||||
|
||||
def get_form_kwargs(self):
|
||||
return super().get_form_kwargs() | {"instance": self.get_referee()}
|
||||
|
||||
def form_invalid(self, form):
|
||||
for error in form.errors.values():
|
||||
notify(self.request, f"e|{_('Could not update fee')}|{' '.join(error)}")
|
||||
return self.get_fallback()
|
||||
|
||||
def form_valid(self, form):
|
||||
referee = self.get_referee()
|
||||
set_referee_fee(referee, fee=form.cleaned_data["fee"], km=form.cleaned_data["km"], km_rate=form.cleaned_data["km_rate"])
|
||||
notify(self.request, f"s|{_('Fee updated')}|" + _("Updated the fee for “%(name)s”.") % {"name": referee.display_name})
|
||||
return self.get_fallback()
|
||||
|
||||
|
||||
class EventRefereeFormPdfView(ClubAdminRequiredMixin, View):
|
||||
"""Downloadable PDF of the referee payment form for one game, modeled on
|
||||
the club's existing paper form -- club header (legal name if set, else
|
||||
plain name; address from the club's home Location, not this specific
|
||||
event's, so the form still reads right even if called from a page where
|
||||
the event's own location happens to be blank) plus this game's details,
|
||||
referees and their fee/km breakdown, and blank signature lines."""
|
||||
|
||||
def get(self, request, pk):
|
||||
event = get_object_or_404(Event.objects.filter(club=request.club).prefetch_related("teams", "referees__member"), pk=pk)
|
||||
home_location = Location.objects.filter(club=request.club, is_home=True).first()
|
||||
context = {"club": request.club, "event": event, "referees": list(event.referees.all()), "home_location": home_location}
|
||||
|
||||
try:
|
||||
pdf = event_referee_form_pdf(context)
|
||||
except PDFExportError as error:
|
||||
notify(request, f"e|{_('PDF unavailable')}|{error}")
|
||||
return redirect(reverse("management:event_detail", args=[event.pk]))
|
||||
|
||||
response = HttpResponse(pdf, content_type="application/pdf")
|
||||
response["Content-Disposition"] = f'attachment; filename="referee-form-{event.pk}.pdf"'
|
||||
return response
|
||||
|
||||
|
||||
class RefereeManagementDashboardView(ClubAdminRequiredMixin, TemplateView):
|
||||
"""One-stop admin view of every upcoming home game that needs a
|
||||
club-arranged referee (federation-managed teams never appear here, see
|
||||
events.services.referees.needs_referee_management), with inline
|
||||
assign/remove -- posts to the same EventRefereeAssignView/RemoveView the
|
||||
event detail page uses, via the shared _referee_assignment_panel include,
|
||||
and returns here afterwards rather than to the event detail page.
|
||||
|
||||
The `range` GET param picks either a calendar window ("week"/"two_weeks",
|
||||
both anchored on the ISO week so "this week" always means Mon-Sun of the
|
||||
current week regardless of what weekday it is today) or a flat count of
|
||||
upcoming games -- buttons in the template, not a dropdown, since there
|
||||
are only a handful of sensible choices."""
|
||||
|
||||
template_name = "management/referee_management.html"
|
||||
RANGE_CHOICES = ["week", "two_weeks", "10", "25", "50"]
|
||||
DEFAULT_RANGE = "10"
|
||||
|
||||
def get_range(self):
|
||||
value = self.request.GET.get("range", self.DEFAULT_RANGE)
|
||||
return value if value in self.RANGE_CHOICES else self.DEFAULT_RANGE
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
club = self.request.club
|
||||
range_choice = self.get_range()
|
||||
|
||||
queryset = (
|
||||
Event.objects.filter(club=club, kind=Event.EventKind.GAME, cancelled=False, location__is_home=True, start__gte=timezone.now(), teams__referee_management=Team.RefereeManagement.CLUB)
|
||||
.distinct()
|
||||
.select_related("location", "opponent")
|
||||
.prefetch_related("teams", "referees__member", "referees__assigned_by")
|
||||
.order_by("start")
|
||||
)
|
||||
|
||||
if range_choice in ("week", "two_weeks"):
|
||||
today = timezone.localdate()
|
||||
end_of_this_week = today + timedelta(days=6 - today.weekday())
|
||||
end_date = end_of_this_week + timedelta(days=7) if range_choice == "two_weeks" else end_of_this_week
|
||||
games = list(queryset.filter(start__date__lte=end_date))
|
||||
else:
|
||||
games = list(queryset[: int(range_choice)])
|
||||
|
||||
kpi_no_referee = 0
|
||||
kpi_understaffed = 0
|
||||
kpi_fully_staffed = 0
|
||||
|
||||
for game in games:
|
||||
game.referee_rows = list(game.referees.all())
|
||||
for referee in game.referee_rows:
|
||||
referee.fee_form = EventRefereeFeeForm(instance=referee)
|
||||
game.referees_full = len(game.referee_rows) >= game.max_referees
|
||||
game.referee_candidates = []
|
||||
if not game.referee_rows:
|
||||
kpi_no_referee += 1
|
||||
elif not game.referees_full:
|
||||
kpi_understaffed += 1
|
||||
else:
|
||||
kpi_fully_staffed += 1
|
||||
if not game.referees_full:
|
||||
for candidate in eligible_referees(game):
|
||||
conflicts = conflicting_events(candidate, game)
|
||||
candidate.has_conflict = bool(conflicts)
|
||||
candidate.conflict_titles = ", ".join(conflict.title for conflict in conflicts)
|
||||
game.referee_candidates.append(candidate)
|
||||
|
||||
return super().get_context_data(
|
||||
games=games,
|
||||
range_choice=range_choice,
|
||||
kpi_total=len(games),
|
||||
kpi_no_referee=kpi_no_referee,
|
||||
kpi_understaffed=kpi_understaffed,
|
||||
kpi_fully_staffed=kpi_fully_staffed,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user