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:
2026-08-09 21:39:19 +02:00
parent 86e28c317f
commit 309bd4d83e
54 changed files with 4574 additions and 88 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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">&nbsp;</span>
</div>
</body>
</html>

View 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 %}

View 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 %}

View File

@@ -0,0 +1,35 @@
{% extends "management/base.html" %}
{% load i18n lucide static ui %}
{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New 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 %}

View 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 %}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,35 @@
{% extends "management/base.html" %}
{% load i18n lucide static ui %}
{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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