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

@@ -296,6 +296,27 @@ a `FamilyRole` (`parent` / `child` / `guardian` / `other`), `unique_together (fa
member)`; `Family.guardians` / `Family.children` are role-derived querysets. Powers the member)`; `Family.guardians` / `Family.children` are role-derived querysets. Powers the
"parents see their children's data" object-scope (§3.1). "parents see their children's data" object-scope (§3.1).
**`Group`** *(built)* + **`GroupMembership`** — a generic, tenant-scoped, **opaque** named
collection of members: "all coaches", "all team managers", an ad-hoc committee. Deliberately
minimal (`name` + a through-membership, same shape as `Family`/`FamilyMembership`) — it
carries **no knowledge of any specific consumer** (not team-scoped, not referee-scoped, not
anything-scoped). Any feature wanting to use "a named set of people" for something specific
builds its own connective model elsewhere rather than teaching `Group` about that use case —
see `teams.RefereeProfile` (§5.2), which deliberately does **not** go through `Group` even
though an earlier draft of that feature did; referee eligibility is a fact about a *member*,
not about group membership.
```
Group(ClubScopedModel) # -> carries `club`
name CharField
Meta: UniqueConstraint(club, name)
GroupMembership(UUIDModel) # club implied by group
group FK Group (CASCADE, related_name="memberships")
member FK Member (CASCADE, related_name="group_memberships")
Meta: UniqueConstraint(group, member)
```
### `club` ### `club`
**`Club`** — **the tenant root** (§2.4). Currently just `name`; extend with `slug` (unique, **`Club`** — **the tenant root** (§2.4). Currently just `name`; extend with `slug` (unique,
@@ -436,6 +457,56 @@ number — modeled by `TeamMembership`, exactly matching the domain note.
- `StaffAssignment` drives the coach/manager object-scope (§3.13.2) — it *is* the "is a - `StaffAssignment` drives the coach/manager object-scope (§3.13.2) — it *is* the "is a
coach of this team" fact; no `ClubRole` mirrors it. coach of this team" fact; no `ClubRole` mirrors it.
**As built, `Team` also carries `referee_management`** (`TextChoices`: `club` | `federation`,
default `club`) — whether the *club* arranges referees for this team's home games, or the
*federation* does. A federation-managed team is left out of the referee tools **entirely**:
no eligibility, no assignment, no entry on the referee management dashboard (§5.3) — see
`events/services/referees.py::needs_referee_management(event)`, the single gate every
referee-facing screen reads through.
**`RefereeLevel`** *(built)* — a club-defined referee qualification tier ("Regional",
"National", ...), admin-managed like `Position` (own name, own ordering, no fixed list).
**Owns which teams it qualifies for** — eligibility is a property of the *level*, not of the
individual referee: a club configures a handful of levels once, each unlocking a tier of
teams, rather than hand-picking teams per referee.
```
RefereeLevel(ClubScopedModel) # -> carries `club`
name CharField
ordering PositiveSmallIntegerField (default=0)
teams M2M Team (blank=True, related_name="referee_levels")
Meta: UniqueConstraint(club, name); ordering = ["ordering", "name"]
```
**`RefereeProfile`** *(built)* — a **member-level** fact: which level this member holds and
how long it's valid for. Which teams that translates to is *derived* (`eligible_teams`),
never picked per member. Managed from the member's own page (`management`), read (not
edited) from the team's own page too. Deliberately **not** routed through `members.Group`
eligibility is a property of a person, not of a group they might belong to; see the note on
`Group` above for why an earlier draft that did this was reworked. It also deliberately does
**not** put `teams` directly on the profile — a later draft of this feature did that too,
before the levels-own-the-teams shape replaced it, matching how real officiating
qualifications actually work (a certification tier unlocks a tier of competitions).
```
RefereeProfile(UUIDModel) # club reachable via member -- Member itself has no club FK
member OneToOneField members.Member (CASCADE, related_name="referee_profile")
level FK RefereeLevel (PROTECT, null=True, blank=True, related_name="referees")
valid_until DateField (null=True, blank=True)
```
- **`is_currently_valid`** (property): `valid_until` is set and hasn't passed — a pure date
check, independent of whether a level is even set.
- **`is_eligible`** (property): the full gate every consumer reads through (the event assign
panel, the team page, the referees list) — `level` is set **and** `is_currently_valid`.
Once `valid_until` passes, `is_eligible` flips to `False` and the referee drops out of
every eligibility query until the date is extended; nothing else needs to change.
- **`eligible_teams`** (property): `level.teams.all()` when `is_eligible`, else empty.
- One `RefereeProfile` per member (`OneToOneField`) rather than a field bag on `Member`
itself, matching this file's general pattern of keeping `Member` a plain identity record
and hanging every role-specific fact off its own small table (`ClubMembership`,
`StaffAssignment`, `TeamMembership`, and now this).
### 5.3 `events` ### 5.3 `events`
``` ```
@@ -470,6 +541,78 @@ row today — there's no check-in UI yet, only Django admin); a "no-show" is
a missing check-in. See `events/services/attendance.py::record_check_in` and a missing check-in. See `events/services/attendance.py::record_check_in` and
`management/views.py::TeamDetailView`'s attendance panel. `management/views.py::TeamDetailView`'s attendance panel.
**As built, `Event` also carries `max_referees`** (`PositiveSmallIntegerField`, default
`2`) and **`EventReferee`** *(built)* — referee sign-up/assignment for a **home game**
only (`Event.is_home_game`), staff-assigned for now (self-service subscribe is a planned
extension, §7). A referee row is either a club member **or** an externally-logged name
(e.g. a federation-appointed referee the club still needs to pay), never both/neither, and
carries its own payment snapshot:
```
EventReferee(UUIDModel) # club implied by event
event FK Event (CASCADE, related_name="referees")
member FK Member (CASCADE, null=True, blank=True, related_name="referee_assignments")
external_name CharField (blank=True) # set instead of member for a non-member referee
assigned_by FK Member (SET_NULL, null=True, related_name="+")
fee DecimalField (default 0.00)
km DecimalField (null=True, blank=True)
km_rate DecimalField (null=True, blank=True) # snapshotted per assignment, not a
# live club-wide setting
Meta: unique_together (event, member); CheckConstraint XOR(member, external_name)
display_name / is_external / km_total / total_payable # computed properties
```
- **Eligibility** comes from `teams.RefereeProfile.is_eligible`/`eligible_teams` (§5.2): a
member is eligible to referee an event if their profile is currently eligible (a level is
set and its validity hasn't passed) and that level qualifies for one of the event's
`teams`. `events/services/referees.py::eligible_referees(event)` computes this, and is
empty for anything `needs_referee_management(event)` says no to — not a home game, or a
home game whose team(s) are all federation-managed (§5.2). External referees bypass
eligibility entirely (`add_external_referee`) — they're logged by name only, not vetted
against a level.
- **Assignment is admin-only for now**, stricter than most event actions (a team
manager/coach can edit the event itself, but not the referee panel's assign/remove/fee
controls) — see `EventRefereeAssignView`/`EventRefereeRemoveView`/
`EventRefereeAddExternalView`/`EventRefereeFeeUpdateView` (all `ClubAdminRequiredMixin`)
and `EventDetailView`'s separate `can_manage_referees` flag. A team manager still **sees**
the panel (who's assigned, capacity, fees) — visibility and authority are deliberately
split here, same reasoning as §3's "coach visibility ≠ coach authority" for team rosters.
- **The referee management dashboard** (`management:referee_management`, admin-only) is the
one-stop alternative to hunting through individual events: every upcoming home game
`needs_referee_management`, with inline assign/remove/add-external/fee-editing (posting to
the same views the event detail page uses, returning to the dashboard via a `next` param
rather than the event detail page). It leads with KPI tiles (games in view, without a
referee, partially staffed, fully staffed) and a button-based range filter (this
week/this+next week/next 10/25/50 — an ISO-week window for the calendar options, a flat
slice for the count ones), then lists games grouped by date as compact tiles; each tile's
"Manage" button opens a `<dialog>` with the full assign/remove/external/fee panel so the
list itself stays scannable. Both the dashboard and the event detail page share one
`_referee_assignment_panel.html` include so this UI never drifts out of sync between them.
- **`max_referees` is a hard ceiling everywhere** — staff and external assignment included.
Enforced in `_lock_and_check_capacity()` (shared by `assign_referee()` and
`add_external_referee()`), which locks the `Event` row (`select_for_update`) for the
duration of the count-check + write so two admins assigning at the same moment can't both
squeeze past the ceiling.
- **Schedule conflicts are a soft warning, never a block.** `conflicting_events(member,
event)` finds other events overlapping this one's time window where the member is part of
the expected audience (`effective_members`, reused from the attendance service above) — the
UI shows it (⚠ + tooltip on the assign control) but a human decides; an event with no
explicit `end` is assumed to run `ASSUMED_EVENT_DURATION` (2 hours) for this check only,
never written back to the event. External referees have no conflict check (no member to
check a schedule against).
- **`assigned_by` is required for now** (admin-only assignment). A future self-service
sign-up would make it nullable to mean "the referee signed themself up" rather than adding
a parallel model — see §7.
- **The referee payment form is a downloadable PDF** (`event_referee_form_pdf`,
`EventRefereeFormPdfView`, admin-only, WeasyPrint via `management/pdf.py`'s lazy-import
pattern), modeled directly on the club's existing paper form: game details, referee names,
a fee+km breakdown per referee, and blank signature lines (referee always; team manager
left blank — not reliably known at print time). The header uses `Club.official_name`
(`legal_name` if the club has set one, else plain `name` — §2.2) and the club's home
`Location` address; the body's payment sentence uses the plain `name` — mirroring the
original paper form, which itself uses a longer legal form up top and a shorter one in the
body text.
### 5.4 `news`, `pages`, `home` (public site / editorial) ### 5.4 `news`, `pages`, `home` (public site / editorial)
**`news` is built** (as of the coach_manager-authoring / editor-release-flow work) — **`news` is built** (as of the coach_manager-authoring / editor-release-flow work) —
@@ -789,9 +932,13 @@ User 1───< Member (FK, unique per club) # User is GLOBAL — no clu
├───< TeamMembership >─── Team ───> Season ├───< TeamMembership >─── Team ───> Season
├───< StaffAssignment >─── Team (= "coach of this team", §3.2) ├───< StaffAssignment >─── Team (= "coach of this team", §3.2)
├───< GroupMembership >─── Group (opaque -- no team/referee link)
├─1:1─ RefereeProfile ──> RefereeLevel >──< Team
│ (profile's valid_until gates eligibility; level owns teams)
├───< Attendance >─── Event ───> Season ├───< Attendance >─── Event ───> Season
│ └───> Team (nullable) │ └───> Team (nullable)
├───< EventReferee >─── Event (assigned_by another Member; home games only)
├───< Submission >─── Form ───< Field (Submission ──< Answer >── Field) ├───< Submission >─── Form ───< Field (Submission ──< Answer >── Field)
@@ -863,6 +1010,12 @@ specified in **§8**.
(checkout-date anchor, recommended, frozen total) or by *paying* before it (payment-date (checkout-date anchor, recommended, frozen total) or by *paying* before it (payment-date
anchor, mutable total)? Doc implements checkout-date; confirm no club needs the literal anchor, mutable total)? Doc implements checkout-date; confirm no club needs the literal
"paid before date" semantics (§5.7.1). "paid before date" semantics (§5.7.1).
- **Referee self-service sign-up** — `EventReferee` (§5.3) is admin-assigned only for now
(a team manager/coach can see the panel but not use it); a referee cannot yet subscribe
themself to a game. Adding it later means making `assigned_by` nullable (null =
self-subscribed) and a permission mixin scoping a referee to their own eligible games — no
new model needed. Not built because this app has no self-service (member-facing) surface
of any kind yet; the first one deserves its own pass rather than riding along here.
--- ---

Binary file not shown.

View File

@@ -52,7 +52,7 @@
<div class="parties"> <div class="parties">
<div> <div>
<h2>Billed to</h2> <h2>Billed to</h2>
<div><strong>{{ club.name }}</strong></div> <div><strong>{{ club.official_name }}</strong></div>
<div class="muted">{{ club.slug }}.rosterchief.app</div> <div class="muted">{{ club.slug }}.rosterchief.app</div>
</div> </div>
<div class="right"> <div class="right">

View File

@@ -334,6 +334,17 @@ class InvoiceTests(BillingTestBase):
self.assertIn("200.00", html) # paid self.assertIn("200.00", html) # paid
self.assertIn("300.00", html) # balance self.assertIn("300.00", html) # balance
def test_the_invoice_is_billed_to_the_clubs_legal_name_when_set(self):
self.club.legal_name = "Ajax United VZW"
self.club.save(update_fields=["legal_name"])
due = self.bill()
with mock.patch("billing.services.invoices.render_pdf", return_value=b"%PDF-fake") as renderer:
invoice_pdf(due.invoice)
html = renderer.call_args.args[0]
self.assertIn("Ajax United VZW", html)
def test_the_pdf_library_is_only_needed_when_a_pdf_is_asked_for(self): def test_the_pdf_library_is_only_needed_when_a_pdf_is_asked_for(self):
# WeasyPrint binds to native pango/cairo. The app, the tests and every other page must # WeasyPrint binds to native pango/cairo. The app, the tests and every other page must
# run without them; only this call may fail. # run without them; only this call may fail.

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.6 on 2026-08-09 18:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0020_sponsor_logo_height_sponsor_logo_width'),
]
operations = [
migrations.AddField(
model_name='club',
name='legal_name',
field=models.CharField(blank=True, help_text='Full registered name (e.g. including a legal form like VZW/ASBL), used on official documents. Falls back to the name above when left blank.', max_length=255, verbose_name='legal name'),
),
]

View File

@@ -39,6 +39,7 @@ class Club(UUIDModel):
OTHER = "other", _("Other") OTHER = "other", _("Other")
name = models.CharField(_("name"), max_length=255) name = models.CharField(_("name"), max_length=255)
legal_name = models.CharField(_("legal name"), max_length=255, blank=True, help_text=_("Full registered name (e.g. including a legal form like VZW/ASBL), used on official documents. Falls back to club name if blank."))
slug = models.SlugField(_("slug"), max_length=255, unique=True, blank=True, help_text=_("Drives subdomain / path resolution (e.g. ajax-united.rosterchief.app).")) slug = models.SlugField(_("slug"), max_length=255, unique=True, blank=True, help_text=_("Drives subdomain / path resolution (e.g. ajax-united.rosterchief.app)."))
logo = models.FileField( logo = models.FileField(
@@ -107,6 +108,12 @@ class Club(UUIDModel):
def is_archived(self) -> bool: def is_archived(self) -> bool:
return self.archived_at is not None return self.archived_at is not None
@property
def official_name(self) -> str:
"""The name official documents (e.g. the referee payment form) should
show -- `legal_name` when the club has set one, else the everyday `name`."""
return self.legal_name or self.name
@property @property
def initials(self) -> str: def initials(self) -> str:
"""Stand-in for a missing logo. Never the RosterChief mark — that would """Stand-in for a missing logo. Never the RosterChief mark — that would

View File

@@ -14,7 +14,7 @@ from .services.admins import find_member_by_email
class ClubForm(forms.ModelForm): class ClubForm(forms.ModelForm):
class Meta: class Meta:
model = Club model = Club
fields = ["name", "slug", "sport_type", "logo", "primary_color", "secondary_color", "season_start", "season_duration_months"] fields = ["name", "legal_name", "slug", "sport_type", "logo", "primary_color", "secondary_color", "season_start", "season_duration_months"]
help_texts = {"slug": _("Drives the club's subdomain. Left blank, it is derived from the name.")} help_texts = {"slug": _("Drives the club's subdomain. Left blank, it is derived from the name.")}
# Deliberately a text input, not <input type="color">: a colour picker cannot # Deliberately a text input, not <input type="color">: a colour picker cannot
# express "no colour" -- it would submit #000000 for every club that never # express "no colour" -- it would submit #000000 for every club that never

View File

@@ -27,7 +27,7 @@
{% block heading %}{{ club.name }}{% endblock heading %} {% block heading %}{{ club.name }}{% endblock heading %}
{% block subheading %} {% block subheading %}
{{ club.slug }}.rosterchief.app &middot; {{ club.get_sport_type_display }} {{ club.slug }}.rosterchief.app &middot; {{ club.get_sport_type_display }}{% if club.legal_name %} &middot; {{ club.legal_name }}{% endif %}
{% if club.is_archived %} {% if club.is_archived %}
<span class="badge badge-warning badge-sm ml-2">Archived</span> <span class="badge badge-warning badge-sm ml-2">Archived</span>
{% endif %} {% endif %}

View File

@@ -15,8 +15,9 @@
</div> </div>
{% endfor %} {% endfor %}
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{% form_field form.name %} {% form_field form.name %}
{% form_field form.legal_name %}
{% form_field form.slug %} {% form_field form.slug %}
{% form_field form.sport_type %} {% form_field form.sport_type %}
</div> </div>

View File

@@ -1,6 +1,6 @@
{% load i18n %} {% load i18n %}
<div class="w-full max-w-6xl {% if size_modifier %}lg:w-fit{% endif %}"> <div class="w-full max-w-6xl {% if field_type == "checkbox" %}h-full flex flex-col justify-center{% endif %} {% if size_modifier %}lg:w-fit{% endif %}">
<div class="flex flex-col gap-y-2"> <div class="flex flex-col gap-y-2">
{% if show_label %} {% if show_label %}
<div class="flex flex-row items-end min-h-6"> <div class="flex flex-row items-end min-h-6">

View File

@@ -2,7 +2,7 @@ from django import forms
from django.contrib import admin from django.contrib import admin
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from .models import Attendance, Competition, Event, EventSeries, Location, Opponent from .models import Attendance, Competition, Event, EventReferee, EventSeries, Location, Opponent
@admin.register(Opponent) @admin.register(Opponent)
@@ -25,6 +25,12 @@ class AttendanceInline(admin.TabularInline):
raw_id_fields = ["member"] raw_id_fields = ["member"]
class EventRefereeInline(admin.TabularInline):
model = EventReferee
extra = 0
raw_id_fields = ["member", "assigned_by"]
@admin.register(EventSeries) @admin.register(EventSeries)
class EventSeriesAdmin(admin.ModelAdmin): class EventSeriesAdmin(admin.ModelAdmin):
list_display = ["title", "kind", "rrule", "dtstart", "until", "generated_until", "club"] list_display = ["title", "kind", "rrule", "dtstart", "until", "generated_until", "club"]
@@ -52,7 +58,7 @@ class EventAdminForm(forms.ModelForm):
fields = [ fields = [
"title", "kind", "season", "series", "detached", "cancelled", "teams", "invited_members", "excluded_members", "title", "kind", "season", "series", "detached", "cancelled", "teams", "invited_members", "excluded_members",
"start", "end", "gathering", "deadline", "location", "opponent", "start", "end", "gathering", "deadline", "location", "opponent",
"competition", "external_game_id", "score_for", "score_against", "is_live", "competition", "external_game_id", "score_for", "score_against", "is_live", "max_referees",
] ]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -77,14 +83,14 @@ class EventAdmin(admin.ModelAdmin):
search_fields = ["title"] search_fields = ["title"]
date_hierarchy = "start" date_hierarchy = "start"
autocomplete_fields = ["season", "series", "location", "opponent", "teams", "invited_members", "excluded_members"] autocomplete_fields = ["season", "series", "location", "opponent", "teams", "invited_members", "excluded_members"]
inlines = [AttendanceInline] inlines = [AttendanceInline, EventRefereeInline]
fieldsets = [ fieldsets = [
[None, {"fields": ["title", "kind", "season"]}], [None, {"fields": ["title", "kind", "season"]}],
[_("Series"), {"fields": ["series", "detached", "cancelled"]}], [_("Series"), {"fields": ["series", "detached", "cancelled"]}],
[_("Audience"), {"fields": ["teams", "invited_members", "excluded_members"]}], [_("Audience"), {"fields": ["teams", "invited_members", "excluded_members"]}],
[_("When"), {"fields": ["start", "end", "gathering", "deadline"]}], [_("When"), {"fields": ["start", "end", "gathering", "deadline"]}],
[_("Where"), {"fields": ["location", "opponent"]}], [_("Where"), {"fields": ["location", "opponent"]}],
[_("Game"), {"fields": ["competition", "external_game_id", "score_for", "score_against", "is_live"]}], [_("Game"), {"fields": ["competition", "external_game_id", "score_for", "score_against", "is_live", "max_referees"]}],
] ]
@@ -102,3 +108,18 @@ class AttendanceAdmin(admin.ModelAdmin):
list_filter = ["status", "showed_up", "event__kind"] list_filter = ["status", "showed_up", "event__kind"]
search_fields = ["event__title", "member__first_name", "member__last_name"] search_fields = ["event__title", "member__first_name", "member__last_name"]
raw_id_fields = ["event", "member"] raw_id_fields = ["event", "member"]
@admin.register(EventReferee)
class EventRefereeAdmin(admin.ModelAdmin):
list_display = ["event", "display_name", "fee", "km", "total_payable", "assigned_by"]
search_fields = ["event__title", "member__first_name", "member__last_name", "external_name"]
raw_id_fields = ["event", "member", "assigned_by"]
@admin.display(description=_("referee"))
def display_name(self, obj):
return obj.display_name
@admin.display(description=_("total payable"))
def total_payable(self, obj):
return obj.total_payable

View File

@@ -0,0 +1,38 @@
# Generated by Django 6.0.6 on 2026-08-08 21:55
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('events', '0019_competition_sport_type'),
('members', '0004_group_groupmembership_and_more'),
]
operations = [
migrations.AddField(
model_name='event',
name='max_referees',
field=models.PositiveSmallIntegerField(default=2, help_text='How many referees can be assigned to this game. Only meaningful for home games -- ignored otherwise.', verbose_name='max referees'),
),
migrations.CreateModel(
name='EventReferee',
fields=[
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('assigned_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='members.member', verbose_name='assigned by')),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='referees', to='events.event', verbose_name='event')),
('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='referee_assignments', to='members.member', verbose_name='member')),
],
options={
'verbose_name': 'event referee',
'verbose_name_plural': 'event referees',
'ordering': ['event', 'member__last_name', 'member__first_name'],
'constraints': [models.UniqueConstraint(fields=('event', 'member'), name='unique_referee_per_event')],
},
),
]

View File

@@ -0,0 +1,45 @@
# Generated by Django 6.0.6 on 2026-08-09 18:21
import django.db.models.deletion
from decimal import Decimal
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('events', '0020_event_max_referees_eventreferee'),
('members', '0004_group_groupmembership_and_more'),
]
operations = [
migrations.AddField(
model_name='eventreferee',
name='external_name',
field=models.CharField(blank=True, help_text="For a referee who isn't a club member (e.g. federation-appointed) -- logged by name only.", max_length=255, verbose_name='external referee name'),
),
migrations.AddField(
model_name='eventreferee',
name='fee',
field=models.DecimalField(blank=True, decimal_places=2, default=Decimal('0.00'), max_digits=8, verbose_name='fee'),
),
migrations.AddField(
model_name='eventreferee',
name='km',
field=models.DecimalField(blank=True, decimal_places=1, max_digits=6, null=True, verbose_name='kilometers'),
),
migrations.AddField(
model_name='eventreferee',
name='km_rate',
field=models.DecimalField(blank=True, decimal_places=4, help_text='Reimbursement rate per kilometer, e.g. 0.4230.', max_digits=6, null=True, verbose_name='rate per km'),
),
migrations.AlterField(
model_name='eventreferee',
name='member',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='referee_assignments', to='members.member', verbose_name='member'),
),
migrations.AddConstraint(
model_name='eventreferee',
constraint=models.CheckConstraint(condition=models.Q(models.Q(('member__isnull', False), ('external_name', '')), models.Q(('member__isnull', True), models.Q(('external_name', ''), _negated=True)), _connector='OR'), name='event_referee_member_xor_external_name'),
),
]

View File

@@ -1,3 +1,5 @@
from decimal import Decimal
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
from django.db.models import Q from django.db.models import Q
@@ -86,6 +88,8 @@ class Event(ClubScopedModel):
score_against = models.PositiveSmallIntegerField(_("score (opponent)"), null=True, blank=True) score_against = models.PositiveSmallIntegerField(_("score (opponent)"), null=True, blank=True)
is_live = models.BooleanField(_("live"), default=False, help_text=_("The game is currently in progress.")) is_live = models.BooleanField(_("live"), default=False, help_text=_("The game is currently in progress."))
max_referees = models.PositiveSmallIntegerField(_("max referees"), default=2, help_text=_("How many referees can be assigned to this game. Only meaningful for home games -- ignored otherwise."))
class Meta: class Meta:
verbose_name = _("event") verbose_name = _("event")
verbose_name_plural = _("events") verbose_name_plural = _("events")
@@ -172,6 +176,65 @@ class Attendance(UUIDModel):
return f"{self.event} - {self.member}" return f"{self.event} - {self.member}"
class EventReferee(UUIDModel):
"""One referee assigned to one (home) game -- either a club member
(``member`` set) or an external referee logged by name only
(``external_name`` set, e.g. a federation-appointed referee the club
still needs to pay/log) -- never both, never neither. ``assigned_by`` is
required for now -- assignment is admin-only; a future self-service
sign-up would make it nullable to mean "the referee signed themself up"
rather than adding a parallel model. See events.services.referees for the
home-game gate, the ``Event.max_referees`` capacity ceiling, and the
(non-blocking) schedule conflict check.
``fee``/``km``/``km_rate`` back the referee payment form (PDF export):
what the club owes this referee for this game, and the mileage rate used
to compute the travel portion -- snapshotted per assignment (not read
from a live club-wide setting) so a rate change later doesn't rewrite an
already-issued form's numbers.
"""
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="referees", verbose_name=_("event"))
member = models.ForeignKey(Member, on_delete=models.CASCADE, null=True, blank=True, related_name="referee_assignments", verbose_name=_("member"))
external_name = models.CharField(_("external referee name"), max_length=255, blank=True, help_text=_("For a referee who isn't a club member (e.g. federation-appointed) -- logged by name only."))
assigned_by = models.ForeignKey(Member, on_delete=models.SET_NULL, null=True, related_name="+", verbose_name=_("assigned by"))
fee = models.DecimalField(_("fee"), max_digits=8, decimal_places=2, default=Decimal("0.00"), blank=True)
km = models.DecimalField(_("kilometers"), max_digits=6, decimal_places=1, null=True, blank=True)
km_rate = models.DecimalField(_("rate per km"), max_digits=6, decimal_places=4, null=True, blank=True, help_text=_("Reimbursement rate per kilometer, e.g. 0.4230."))
class Meta:
verbose_name = _("event referee")
verbose_name_plural = _("event referees")
ordering = ["event", "member__last_name", "member__first_name"]
constraints = [
models.UniqueConstraint(fields=["event", "member"], name="unique_referee_per_event"),
models.CheckConstraint(
condition=(Q(member__isnull=False) & Q(external_name="")) | (Q(member__isnull=True) & ~Q(external_name="")),
name="event_referee_member_xor_external_name",
),
]
def __str__(self):
return f"{self.event} - {self.display_name}"
@property
def display_name(self) -> str:
return str(self.member) if self.member_id else self.external_name
@property
def is_external(self) -> bool:
return self.member_id is None
@property
def km_total(self) -> Decimal:
return (self.km or Decimal("0")) * (self.km_rate or Decimal("0"))
@property
def total_payable(self) -> Decimal:
return (self.fee or Decimal("0")) + self.km_total
class Competition(models.Model): class Competition(models.Model):
"""A competition has a name with a specific URL to fetch data from. These are managed centrally.""" """A competition has a name with a specific URL to fetch data from. These are managed centrally."""

147
events/services/referees.py Normal file
View File

@@ -0,0 +1,147 @@
"""Assigning referees to home games.
Eligibility comes from teams.RefereeProfile -- a per-member level + validity
(unrelated to members.Group), with the *level* (teams.RefereeLevel) owning
which teams it qualifies for. A profile with no level, or one whose validity
has lapsed, is not eligible for anything -- see RefereeProfile.eligible_teams,
the single definition this module reads through. Availability conflicts (the
member is expected elsewhere at an overlapping time) are surfaced as a
warning only -- never a hard block, since a human may know the two
commitments don't actually clash (enough travel time, one is optional).
Capacity (Event.max_referees) *is* a hard ceiling, enforced here for both
staff-assignment and any future self-service sign-up.
A team can opt out of all of this entirely (Team.referee_management =
FEDERATION): its home games never need club-arranged referees, so they're
excluded from eligibility, assignment, and every referee-facing screen --
see needs_referee_management().
Not every referee is a club member -- add_external_referee logs one by name
only (still counts against Event.max_referees, still capacity-checked), for
e.g. a federation-appointed referee the club still needs to pay.
"""
import datetime
from decimal import Decimal
from django.db import transaction
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from events.models import Event, EventReferee
from events.services.attendance import effective_members
from members.models import Member
from teams.models import Team
# Used only to give an event with no explicit `end` a time window for the
# overlap check below -- never written back to the event itself.
ASSUMED_EVENT_DURATION = datetime.timedelta(hours=2)
class RefereeAssignmentError(Exception):
"""A referee could not be assigned to a game."""
def event_window(event):
"""(start, end) for overlap purposes -- events.end may be blank."""
return event.start, event.end or (event.start + ASSUMED_EVENT_DURATION)
def needs_referee_management(event) -> bool:
"""Whether this event is one the club should be arranging referees for at
all: a home game with at least one club-managed team. A federation-managed
team's home games are entirely out of scope for the referee tools -- the
federation appoints referees for those, not the club."""
return event.is_home_game and event.teams.filter(referee_management=Team.RefereeManagement.CLUB).exists()
def eligible_referees(event):
"""Members who could referee `event`: their RefereeProfile has a level
qualifying for one of its club-managed teams, and is currently valid,
minus whoever is already assigned. Empty unless needs_referee_management."""
if not needs_referee_management(event):
return Member.objects.none()
team_ids = list(event.teams.filter(referee_management=Team.RefereeManagement.CLUB).values_list("id", flat=True))
assigned_ids = event.referees.values_list("member_id", flat=True)
today = timezone.localdate()
return (
Member.objects.filter(referee_profile__level__teams__id__in=team_ids, referee_profile__valid_until__gte=today)
.exclude(pk__in=assigned_ids)
.distinct()
)
def conflicting_events(member, event):
"""Other events in this club overlapping `event`'s time window where
`member` is part of the expected audience -- informational only, never
blocks an assignment."""
start, end = event_window(event)
candidates = Event.objects.filter(club=event.club).exclude(pk=event.pk).filter(start__lt=end)
conflicts = []
for candidate in candidates:
_candidate_start, candidate_end = event_window(candidate)
if candidate_end > start and effective_members(candidate).filter(pk=member.pk).exists():
conflicts.append(candidate)
return conflicts
def _lock_and_check_capacity(event):
"""Row-locks `event` and raises RefereeAssignmentError if it's not a
club-managed home game or is already at Event.max_referees. Shared by
assign_referee/add_external_referee so two admins acting at the same
moment can't both squeeze past the ceiling."""
event = Event.objects.select_for_update().get(pk=event.pk)
if not needs_referee_management(event):
raise RefereeAssignmentError(_("Referees can only be assigned to home games for club-managed teams."))
if event.referees.count() >= event.max_referees:
raise RefereeAssignmentError(_("This game already has its maximum of %(max)s referee(s).") % {"max": event.max_referees})
return event
@transaction.atomic
def assign_referee(event, member, *, assigned_by):
"""Assign `member` to referee `event`. Raises RefereeAssignmentError if
it's not a club-managed home game, the game is already at
Event.max_referees, or `member` is already assigned."""
event = _lock_and_check_capacity(event)
if event.referees.filter(member=member).exists():
raise RefereeAssignmentError(_("%(member)s is already assigned to this game.") % {"member": member})
return EventReferee.objects.create(event=event, member=member, assigned_by=assigned_by)
@transaction.atomic
def add_external_referee(event, name, *, assigned_by):
"""Log a non-member referee (e.g. federation-appointed) by name only.
Same capacity/home-game rules as assign_referee -- an external slot still
counts against Event.max_referees."""
name = name.strip()
if not name:
raise RefereeAssignmentError(_("A name is required for an external referee."))
event = _lock_and_check_capacity(event)
return EventReferee.objects.create(event=event, external_name=name, assigned_by=assigned_by)
def remove_referee(referee):
referee.delete()
def set_referee_fee(referee, *, fee=None, km=None, km_rate=None):
"""Update one referee assignment's payment details -- what the club owes
for this game, split into a flat fee and a mileage component. Any
argument left as None clears that field rather than leaving it
unchanged, matching how the edit form always submits all three."""
referee.fee = fee if fee is not None else Decimal("0.00")
referee.km = km
referee.km_rate = km_rate
referee.save(update_fields=["fee", "km", "km_rate"])
return referee

View File

@@ -1,4 +1,5 @@
from datetime import timedelta from datetime import timedelta
from decimal import Decimal
from io import StringIO from io import StringIO
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
@@ -10,10 +11,10 @@ from waffle import get_waffle_flag_model
from club.models import Club, Season from club.models import Club, Season
from members.models import Member from members.models import Member
from teams.models import Position, Team, TeamMembership from teams.models import Position, RefereeLevel, RefereeProfile, Team, TeamMembership
from .admin import EventAdminForm from .admin import EventAdminForm
from .models import Attendance, Competition, Event, EventSeries, Location, Opponent from .models import Attendance, Competition, Event, EventReferee, EventSeries, Location, Opponent
from .services import ( from .services import (
cancel_occurrence, cancel_occurrence,
detach_occurrence, detach_occurrence,
@@ -28,6 +29,7 @@ from .services import (
team_no_shows, team_no_shows,
) )
from .services.rbihf_import import RBIHFImportError, apply_plan, build_plan, extract_team_id, parse_fixtures, suggested_location, suggested_opponent from .services.rbihf_import import RBIHFImportError, apply_plan, build_plan, extract_team_id, parse_fixtures, suggested_location, suggested_opponent
from .services.referees import RefereeAssignmentError, add_external_referee, assign_referee, conflicting_events, eligible_referees, needs_referee_management, remove_referee, set_referee_fee
class EventsTestBase(TestCase): class EventsTestBase(TestCase):
@@ -893,3 +895,273 @@ class RBIHFImportPlanTests(EventsTestBase):
self.assertEqual(result, {"created": 0, "updated": 0, "deleted": 1}) self.assertEqual(result, {"created": 0, "updated": 0, "deleted": 1})
self.assertFalse(Event.objects.filter(club=self.club, external_game_id="5002").exists()) self.assertFalse(Event.objects.filter(club=self.club, external_game_id="5002").exists())
class EventRefereeModelTests(EventsTestBase):
def test_str(self):
event = self.make_event(title="Cup Final")
referee = Member.objects.create(first_name="Ref", last_name="Eree")
assignment = EventReferee.objects.create(event=event, member=referee, assigned_by=self.alice)
self.assertEqual(str(assignment), "Cup Final - Ref Eree")
def test_member_unique_per_event(self):
event = self.make_event()
referee = Member.objects.create(first_name="Ref", last_name="Eree")
EventReferee.objects.create(event=event, member=referee, assigned_by=self.alice)
with self.assertRaises(IntegrityError):
EventReferee.objects.create(event=event, member=referee, assigned_by=self.alice)
def test_max_referees_defaults_to_two(self):
event = self.make_event()
self.assertEqual(event.max_referees, 2)
def test_deleting_the_assigner_keeps_the_assignment(self):
event = self.make_event()
referee = Member.objects.create(first_name="Ref", last_name="Eree")
assignment = EventReferee.objects.create(event=event, member=referee, assigned_by=self.alice)
self.alice.delete()
assignment.refresh_from_db()
self.assertIsNone(assignment.assigned_by)
self.assertEqual(EventReferee.objects.filter(pk=assignment.pk).count(), 1)
class RefereeServiceTests(EventsTestBase):
"""events.services.referees -- eligibility (level + validity, derived via
RefereeProfile.eligible_teams), conflict detection (a soft warning, never
a block), and the max_referees hard ceiling."""
def setUp(self):
super().setUp()
self.home_ground = Location.objects.create(club=self.club, name="Home Ground", address="1 St", city="Town", zip_code="1000", country="BE", is_home=True)
self.away_ground = Location.objects.create(club=self.club, name="Away Ground", address="2 St", city="Town", zip_code="1000", country="BE")
self.level = RefereeLevel.objects.create(club=self.club, name="Regional")
self.level.teams.add(self.team)
self.referee = Member.objects.create(first_name="Ref", last_name="Eree")
self.referee_profile = self.make_eligible_profile(self.referee)
def make_eligible_profile(self, member, level=None):
return RefereeProfile.objects.create(member=member, level=level or self.level, valid_until=timezone.localdate() + timedelta(days=30))
def make_home_game(self, **kwargs):
kwargs.setdefault("kind", Event.EventKind.GAME)
kwargs.setdefault("location", self.home_ground)
kwargs.setdefault("teams", None)
teams = kwargs.pop("teams")
event = self.make_event(**kwargs)
event.teams.add(teams or self.team)
return event
def test_eligible_referees_returns_qualified_members_for_a_home_game(self):
game = self.make_home_game()
self.assertEqual(set(eligible_referees(game)), {self.referee})
def test_eligible_referees_empty_for_an_away_game(self):
game = self.make_home_game(location=self.away_ground)
self.assertEqual(set(eligible_referees(game)), set())
def test_needs_referee_management_true_for_a_club_managed_home_game(self):
game = self.make_home_game()
self.assertTrue(needs_referee_management(game))
def test_needs_referee_management_false_for_an_away_game(self):
game = self.make_home_game(location=self.away_ground)
self.assertFalse(needs_referee_management(game))
def test_needs_referee_management_false_for_a_federation_managed_team(self):
self.team.referee_management = Team.RefereeManagement.FEDERATION
self.team.save(update_fields=["referee_management"])
game = self.make_home_game()
self.assertFalse(needs_referee_management(game))
def test_eligible_referees_empty_for_a_federation_managed_team(self):
self.team.referee_management = Team.RefereeManagement.FEDERATION
self.team.save(update_fields=["referee_management"])
game = self.make_home_game()
self.assertEqual(set(eligible_referees(game)), set())
def test_assign_referee_rejects_a_federation_managed_team(self):
self.team.referee_management = Team.RefereeManagement.FEDERATION
self.team.save(update_fields=["referee_management"])
game = self.make_home_game()
with self.assertRaises(RefereeAssignmentError):
assign_referee(game, self.referee, assigned_by=self.alice)
def test_eligible_referees_empty_for_a_non_game_kind(self):
practice = self.make_event(kind=Event.EventKind.TRAINING, location=self.home_ground)
practice.teams.add(self.team)
self.assertEqual(set(eligible_referees(practice)), set())
def test_eligible_referees_excludes_someone_already_assigned(self):
game = self.make_home_game()
EventReferee.objects.create(event=game, member=self.referee, assigned_by=self.alice)
self.assertEqual(set(eligible_referees(game)), set())
def test_eligible_referees_ignores_someone_not_eligible_for_this_team(self):
other_team = Team.objects.create(club=self.club, name="Second Team", short_name="2nd")
unrelated_referee = Member.objects.create(first_name="Not", last_name="Eligible")
RefereeProfile.objects.create(member=unrelated_referee) # no level set
game = self.make_home_game(teams=other_team)
self.assertEqual(set(eligible_referees(game)), set())
def test_eligible_referees_ignores_an_expired_profile(self):
expired_referee = Member.objects.create(first_name="Ex", last_name="Pired")
RefereeProfile.objects.create(member=expired_referee, level=self.level, valid_until=timezone.localdate() - timedelta(days=1))
game = self.make_home_game()
self.assertNotIn(expired_referee, set(eligible_referees(game)))
def test_conflicting_events_finds_an_overlapping_expected_attendance(self):
# Alice is on self.team's roster (EventsTestBase.setUp), so she's part
# of any event's effective audience that includes self.team.
game = self.make_home_game(start=self.future)
other_training = self.make_event(title="Other training", start=self.future, end=self.future + timedelta(hours=1))
other_training.teams.add(self.team)
conflicts = conflicting_events(self.alice, game)
self.assertIn(other_training, conflicts)
def test_conflicting_events_empty_when_nothing_overlaps(self):
game = self.make_home_game(start=self.future)
later = self.make_event(title="Much later", start=self.future + timedelta(days=5))
later.teams.add(self.team)
self.assertEqual(conflicting_events(self.alice, game), [])
def test_conflicting_events_assumes_a_duration_when_end_is_blank(self):
# Neither event has an explicit `end` -- the 2-hour assumed window
# (events.services.referees.ASSUMED_EVENT_DURATION) is what makes these
# two (30 minutes apart) overlap.
game = self.make_home_game(start=self.future)
nearby = self.make_event(title="Nearby", start=self.future + timedelta(minutes=30))
nearby.teams.add(self.team)
self.assertIn(nearby, conflicting_events(self.alice, game))
def test_conflicting_events_does_not_block_assignment(self):
# Soft warning only -- assign_referee succeeds regardless of conflicts.
game = self.make_home_game(start=self.future)
other_training = self.make_event(title="Other training", start=self.future, end=self.future + timedelta(hours=1))
other_training.teams.add(self.team)
self.assertTrue(conflicting_events(self.alice, game))
self.make_eligible_profile(self.alice)
assign_referee(game, self.alice, assigned_by=self.bob)
self.assertTrue(EventReferee.objects.filter(event=game, member=self.alice).exists())
def test_assign_referee_creates_the_row(self):
game = self.make_home_game()
assignment = assign_referee(game, self.referee, assigned_by=self.alice)
self.assertEqual(assignment.event, game)
self.assertEqual(assignment.member, self.referee)
self.assertEqual(assignment.assigned_by, self.alice)
def test_assign_referee_rejects_an_away_game(self):
game = self.make_home_game(location=self.away_ground)
with self.assertRaises(RefereeAssignmentError):
assign_referee(game, self.referee, assigned_by=self.alice)
def test_assign_referee_rejects_a_non_game(self):
practice = self.make_event(kind=Event.EventKind.TRAINING, location=self.home_ground)
practice.teams.add(self.team)
with self.assertRaises(RefereeAssignmentError):
assign_referee(practice, self.referee, assigned_by=self.alice)
def test_assign_referee_rejects_once_at_capacity(self):
game = self.make_home_game(max_referees=1)
assign_referee(game, self.referee, assigned_by=self.alice)
second_referee = Member.objects.create(first_name="Second", last_name="Ref")
self.make_eligible_profile(second_referee)
with self.assertRaises(RefereeAssignmentError):
assign_referee(game, second_referee, assigned_by=self.alice)
self.assertEqual(game.referees.count(), 1)
def test_assign_referee_rejects_the_same_member_twice(self):
game = self.make_home_game()
assign_referee(game, self.referee, assigned_by=self.alice)
with self.assertRaises(RefereeAssignmentError):
assign_referee(game, self.referee, assigned_by=self.alice)
def test_remove_referee_deletes_the_assignment(self):
game = self.make_home_game()
assignment = assign_referee(game, self.referee, assigned_by=self.alice)
remove_referee(assignment)
self.assertFalse(EventReferee.objects.filter(event=game, member=self.referee).exists())
def test_add_external_referee_creates_a_memberless_row(self):
game = self.make_home_game()
assignment = add_external_referee(game, "Guest Referee", assigned_by=self.alice)
self.assertIsNone(assignment.member)
self.assertEqual(assignment.external_name, "Guest Referee")
self.assertTrue(assignment.is_external)
self.assertEqual(assignment.display_name, "Guest Referee")
def test_add_external_referee_strips_the_name(self):
game = self.make_home_game()
assignment = add_external_referee(game, " Guest Referee ", assigned_by=self.alice)
self.assertEqual(assignment.external_name, "Guest Referee")
def test_add_external_referee_rejects_a_blank_name(self):
game = self.make_home_game()
with self.assertRaises(RefereeAssignmentError):
add_external_referee(game, " ", assigned_by=self.alice)
def test_add_external_referee_counts_against_capacity(self):
game = self.make_home_game(max_referees=1)
add_external_referee(game, "Guest Referee", assigned_by=self.alice)
with self.assertRaises(RefereeAssignmentError):
assign_referee(game, self.referee, assigned_by=self.alice)
def test_add_external_referee_rejects_an_away_game(self):
game = self.make_home_game(location=self.away_ground)
with self.assertRaises(RefereeAssignmentError):
add_external_referee(game, "Guest Referee", assigned_by=self.alice)
def test_set_referee_fee_computes_totals(self):
game = self.make_home_game()
assignment = assign_referee(game, self.referee, assigned_by=self.alice)
set_referee_fee(assignment, fee=Decimal("25.00"), km=Decimal("40"), km_rate=Decimal("0.35"))
assignment.refresh_from_db()
self.assertEqual(assignment.km_total, Decimal("14.00"))
self.assertEqual(assignment.total_payable, Decimal("39.00"))
def test_set_referee_fee_without_km_has_no_km_total(self):
game = self.make_home_game()
assignment = assign_referee(game, self.referee, assigned_by=self.alice)
set_referee_fee(assignment, fee=Decimal("25.00"))
assignment.refresh_from_db()
self.assertEqual(assignment.km_total, Decimal("0"))
self.assertEqual(assignment.total_payable, Decimal("25.00"))

View File

@@ -15,14 +15,22 @@ from django.utils.translation import gettext_lazy as _
from openpyxl.worksheet.datavalidation import DataValidation from openpyxl.worksheet.datavalidation import DataValidation
from club.models import ClubMembership from club.models import ClubMembership
from members.models import Member from members.models import FamilyMembership, Member
from .forms import MemberForm 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"} 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(): def build_member_import_template():
@@ -37,9 +45,10 @@ def build_member_import_template():
cell.font = openpyxl.styles.Font(bold=True) cell.font = openpyxl.styles.Font(bold=True)
sheet.freeze_panes = "A2" 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_index = TEMPLATE_COLUMNS.index(column_name) + 1
column_letter = sheet.cell(row=1, column=column_index).column_letter column_letter = sheet.cell(row=1, column=column_index).column_letter
options = ",".join(choices.values) options = ",".join(choices.values)
@@ -128,7 +137,20 @@ def parse_member_import_rows(rows, club):
membership_kwargs, status_fee_errors = _parse_membership_fields(raw) membership_kwargs, status_fee_errors = _parse_membership_fields(raw)
errors.extend(status_fee_errors) 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 return results
@@ -156,6 +178,32 @@ def _parse_membership_fields(raw):
return {"license": license_number, "status": status_value, "fee_status": fee_status_value}, errors 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): def _match_choice(value, choices):
for choice_value in choices.values: for choice_value in choices.values:
if choice_value.lower() == value.lower(): if choice_value.lower() == value.lower():

View File

@@ -29,6 +29,7 @@ _NAV_SECTIONS = {
"member_delete": "member_list", "member_delete": "member_list",
"member_attach_family": "member_list", "member_attach_family": "member_list",
"member_grant_login": "member_list", "member_grant_login": "member_list",
"member_referee_eligibility_update": "member_list",
"member_detach_family": "member_list", "member_detach_family": "member_list",
"family_create": "member_list", "family_create": "member_list",
"family_detail": "member_list", "family_detail": "member_list",
@@ -46,12 +47,20 @@ _NAV_SECTIONS = {
"role_list": "role_list", "role_list": "role_list",
"role_create": "role_list", "role_create": "role_list",
"role_revoke": "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_list": "team_list",
"team_create": "team_list", "team_create": "team_list",
"team_update": "team_list", "team_update": "team_list",
"team_delete": "team_list", "team_delete": "team_list",
"team_detail": "team_list", "team_detail": "team_list",
"team_roster_add": "team_list", "team_roster_add": "team_list",
"team_bulk_add": "team_list",
"team_roster_update": "team_list", "team_roster_update": "team_list",
"team_roster_remove": "team_list", "team_roster_remove": "team_list",
"team_staff_add": "team_list", "team_staff_add": "team_list",
@@ -59,6 +68,11 @@ _NAV_SECTIONS = {
"team_staff_remove": "team_list", "team_staff_remove": "team_list",
"team_photo_set": "team_list", "team_photo_set": "team_list",
"team_photo_delete": "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_list": "news_list",
"news_create": "news_list", "news_create": "news_list",
"news_detail": "news_list", "news_detail": "news_list",
@@ -76,6 +90,11 @@ _NAV_SECTIONS = {
"event_delete": "event_list", "event_delete": "event_list",
"event_detach": "event_list", "event_detach": "event_list",
"event_fetch_game_info": "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": "event_list",
"rbihf_import_confirm": "event_list", "rbihf_import_confirm": "event_list",
"event_series_create": "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 import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from club.models import ClubMembership, ClubRole, FeePayment, Season, Sponsor from club.models import ClubMembership, ClubRole, FeePayment, Sponsor
from club.services.access import is_club_admin, teams_managed_by 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 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 members.services.family import find_member_by_email
from news.models import News 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 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 TeamForm(forms.ModelForm):
class Meta: class Meta:
model = Team model = Team
fields = ["name", "short_name"] fields = ["name", "short_name", "referee_management"]
class TeamPhotoForm(forms.ModelForm): class TeamPhotoForm(forms.ModelForm):
@@ -43,6 +44,74 @@ class TeamPhotoForm(forms.ModelForm):
fields = ["image"] 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): class TeamMembershipForm(forms.ModelForm):
"""Add/edit one roster entry -- team and season come from the view (the URL """Add/edit one roster entry -- team and season come from the view (the URL
already identifies both), never from the form itself.""" already identifies both), never from the form itself."""
@@ -56,13 +125,11 @@ class TeamMembershipForm(forms.ModelForm):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.team = team self.team = team
self.season = season self.season = season
# Eligible to be added regardless of which season's roster is being edited # eligible_roster_members already covers "active this season or the next
# (the team detail page's season switcher can be pointed at an older # one, not lapsed/pending/cancelled or active only in some other season" --
# season): active this season or the next one, not lapsed/pending/cancelled # eligible regardless of which season's roster is being edited (the team
# or active only in some other season. # detail page's season switcher can be pointed at an older season).
today = timezone.localdate() members = eligible_roster_members(club)
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()
if team is not None and season is not None: if team is not None and season is not None:
# Already on this team's roster this season -- offering them again # Already on this team's roster this season -- offering them again
# would just fail the unique_member_per_team_per_season constraint. # would just fail the unique_member_per_team_per_season constraint.
@@ -99,9 +166,7 @@ class StaffAssignmentForm(forms.ModelForm):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# See TeamMembershipForm for why current-or-next-season: eligible to be # See TeamMembershipForm for why current-or-next-season: eligible to be
# assigned regardless of which season's staff list is being edited. # assigned regardless of which season's staff list is being edited.
today = timezone.localdate() members = eligible_roster_members(club)
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()
if team is not None and season is not None: 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) 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) members = members.exclude(pk__in=taken)
@@ -194,7 +259,7 @@ _AUDIENCE_WIDGETS = {
class EventForm(EventAudienceFormMixin, forms.ModelForm): class EventForm(EventAudienceFormMixin, forms.ModelForm):
class Meta: class Meta:
model = Event 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 = { widgets = {
"start": forms.DateTimeInput(attrs={"type": "datetime-local"}), "start": forms.DateTimeInput(attrs={"type": "datetime-local"}),
"end": 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"}), "deadline": forms.DateTimeInput(attrs={"type": "datetime-local"}),
"score_for": forms.NumberInput(attrs={"min": 0}), "score_for": forms.NumberInput(attrs={"min": 0}),
"score_against": forms.NumberInput(attrs={"min": 0}), "score_against": forms.NumberInput(attrs={"min": 0}),
"max_referees": forms.NumberInput(attrs={"min": 1}),
**_AUDIENCE_WIDGETS, **_AUDIENCE_WIDGETS,
} }

View File

@@ -28,3 +28,8 @@ def render_pdf(html: str) -> bytes:
def membership_list_pdf(context: dict) -> bytes: def membership_list_pdf(context: dict) -> bytes:
html = render_to_string("management/membership_list_pdf.html", context) html = render_to_string("management/membership_list_pdf.html", context)
return render_pdf(html) 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 %} {% 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 == '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 == '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 %} {% endif %}
<li class="menu-title">{% trans "Teams" %}</li> <li class="menu-title">{% trans "Teams" %}</li>
<li><a class="{% if nav == 'team_list' %}menu-active{% endif %}" href="{% url 'management:team_list' %}">{% lucide "shirt" size=16 %} {% trans "Teams" %}</a></li> <li><a class="{% if nav == 'team_list' %}menu-active{% endif %}" href="{% url 'management:team_list' %}">{% lucide "shirt" size=16 %} {% trans "Teams" %}</a></li>
<li><a class="{% if nav == 'position_list' %}menu-active{% endif %}" href="{% url 'management:position_list' %}">{% lucide "tags" size=16 %} {% trans "Positions" %}</a></li> <li><a class="{% if nav == 'position_list' %}menu-active{% endif %}" href="{% url 'management:position_list' %}">{% lucide "tags" size=16 %} {% trans "Positions" %}</a></li>
<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 class="menu-title">{% trans "News" %}</li>
<li><a class="{% if nav == 'news_list' %}menu-active{% endif %}" href="{% url 'management:news_list' %}">{% lucide "newspaper" size=16 %} {% trans "News" %}</a></li> <li><a class="{% if nav == 'news_list' %}menu-active{% endif %}" href="{% url 'management:news_list' %}">{% lucide "newspaper" size=16 %} {% trans "News" %}</a></li>

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>
</div> </div>
{% endif %} {% 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 %} {% endblock panel %}
{% block extra_body %} {% block extra_body %}

View File

@@ -49,22 +49,22 @@
<div id="game-details-section" class="game-only"> <div id="game-details-section" class="game-only">
<div class="divider"></div> <div class="divider"></div>
<h3 class="text-lg font-semibold mb-3">{% trans "Game" %}</h3> <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.competition %}
{% form_field form.external_game_id %} {% form_field form.external_game_id %}
</div> {% form_field form.max_referees %}
{% if update_view %} {% if update_view %}
{% comment %} {% comment %}
Score/live status only exist to record once a game has Score/live status only exist to record once a game has
actually been created -- the add form leaves them out actually been created -- the add form leaves them out
entirely (see EventForm's editing=False path). entirely (see EventForm's editing=False path).
{% endcomment %} {% endcomment %}
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
{% form_field form.score_for %} {% form_field form.score_for %}
{% form_field form.score_against %} {% form_field form.score_against %}
{% form_field form.is_live %} {% form_field form.is_live %}
</div> {% endif %}
{% endif %} </div>
</div> </div>
<div class="card-actions justify-start pt-2 mt-2"> <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" %} {% extends "management/base.html" %}
{% load lucide ui i18n %} {% load lucide ui i18n static %}
{% block heading %}{{ member.get_full_name }}{% endblock heading %} {% block heading %}{{ member.get_full_name }}{% endblock heading %}
{% block subheading %}{{ member.contact_email }}{% endblock subheading %} {% block subheading %}{{ member.contact_email }}{% endblock subheading %}
@@ -127,6 +127,66 @@
</div> </div>
</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 parent" as add_parent_label %}
{% trans "Add child" as add_child_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 %} {% 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 %} {% 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 %} {% endif %}
{% endblock panel %} {% 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 here. Nothing is created yet: you'll see exactly what will be added
before anything is saved.{% endblocktrans %} before anything is saved.{% endblocktrans %}
</p> </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"> <form method="post" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}

View File

@@ -26,6 +26,7 @@
<th>{% trans "Last name" %}</th> <th>{% trans "Last name" %}</th>
<th>{% trans "First name" %}</th> <th>{% trans "First name" %}</th>
<th>{% trans "Email" %}</th> <th>{% trans "Email" %}</th>
<th>{% trans "Family" %}</th>
<th>{% trans "Outcome" %}</th> <th>{% trans "Outcome" %}</th>
</tr> </tr>
</thead> </thead>
@@ -36,6 +37,14 @@
<td>{{ result.raw.last_name }}</td> <td>{{ result.raw.last_name }}</td>
<td>{{ result.raw.first_name }}</td> <td>{{ result.raw.first_name }}</td>
<td>{{ result.raw.email }}</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> <td>
{% if result.member %} {% if result.member %}
<span class="badge badge-success badge-sm">{% trans "Will create" %}</span> <span class="badge badge-success badge-sm">{% trans "Will create" %}</span>
@@ -47,7 +56,7 @@
</tr> </tr>
{% empty %} {% empty %}
<tr> <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> </tr>
{% endfor %} {% endfor %}
</tbody> </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"> <div class="flex items-center justify-between">
<h2 class="card-title text-base">{% trans "Roster" %}</h2> <h2 class="card-title text-base">{% trans "Roster" %}</h2>
{% if can_manage %} {% 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 %} {% endif %}
</div> </div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
@@ -198,7 +201,10 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h2 class="card-title text-base">{% trans "Staff" %}</h2> <h2 class="card-title text-base">{% trans "Staff" %}</h2>
{% if can_manage %} {% 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 %} {% endif %}
</div> </div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
@@ -235,6 +241,26 @@
</div> </div>
</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 bg-base-100 shadow mt-4">
<div class="card-body"> <div class="card-body">
<div class="flex items-center justify-between"> <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>/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>/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>/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("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/new/", views.FamilyCreateView.as_view(), name="family_create"),
path("families/<uuid:pk>/", views.FamilyDetailView.as_view(), name="family_detail"), 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/", views.ClubRoleListView.as_view(), name="role_list"),
path("roles/new/", views.ClubRoleCreateView.as_view(), name="role_create"), path("roles/new/", views.ClubRoleCreateView.as_view(), name="role_create"),
path("roles/<uuid:pk>/revoke/", views.ClubRoleRevokeView.as_view(), name="role_revoke"), 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 # Teams
path("teams/", views.TeamListView.as_view(), name="team_list"), path("teams/", views.TeamListView.as_view(), name="team_list"),
path("teams/new/", views.TeamCreateView.as_view(), name="team_create"), 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>/edit/", views.TeamUpdateView.as_view(), name="team_update"),
path("teams/<uuid:pk>/delete/", views.TeamDeleteView.as_view(), name="team_delete"), 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>/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>/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>/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"), 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>/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>/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("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 # News
path("news/", views.NewsListView.as_view(), name="news_list"), path("news/", views.NewsListView.as_view(), name="news_list"),
path("news/new/", views.NewsCreateView.as_view(), name="news_create"), 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>/delete/", views.EventDeleteView.as_view(), name="event_delete"),
path("events/<uuid:pk>/detach/", views.EventDetachView.as_view(), name="event_detach"), 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>/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/", views.RBIHFImportView.as_view(), name="rbihf_import"),
path("events/rbihf-import/confirm/", views.RBIHFImportConfirmView.as_view(), name="rbihf_import_confirm"), 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"), 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 import IntegrityError, transaction
from django.db.models import Count, ProtectedError, Q from django.db.models import Count, ProtectedError, Q
from django.http import HttpResponse 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.messages import notify
from controlpanel.mixins import RedirectOnInvalidMixin from controlpanel.mixins import RedirectOnInvalidMixin
from controlpanel.services.statistics import club_attention, club_charts, club_statistics 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.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.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.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.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 Form as FormBuilderForm
from formbuilder.models import Submission from formbuilder.models import Submission
from members.models import Family, FamilyMembership, Member 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, grant_login, register_family 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 news.models import News, NewsPhoto
from shop.models import Discount, Invoice, Order, Product 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 .bulk_import import build_member_import_template, parse_member_import_rows, read_member_import_workbook
from .forms import ( from .forms import (
@@ -48,12 +53,16 @@ from .forms import (
ClubMembershipForm, ClubMembershipForm,
ClubRoleAssignForm, ClubRoleAssignForm,
EventForm, EventForm,
EventRefereeFeeForm,
EventSeriesForm, EventSeriesForm,
ExternalRefereeForm,
FamilyCreateForm, FamilyCreateForm,
GrantLoginForm, GrantLoginForm,
GroupForm,
LocationForm, LocationForm,
MemberForm, MemberForm,
MemberImportUploadForm, MemberImportUploadForm,
MemberRefereeEligibilityForm,
NewsForm, NewsForm,
NewsPhotoUploadForm, NewsPhotoUploadForm,
NewsPublishForm, NewsPublishForm,
@@ -61,13 +70,14 @@ from .forms import (
PositionForm, PositionForm,
RBIHFImportForm, RBIHFImportForm,
RecordFeePaymentForm, RecordFeePaymentForm,
RefereeLevelForm,
SponsorForm, SponsorForm,
StaffAssignmentForm, StaffAssignmentForm,
TeamForm, TeamForm,
TeamMembershipForm, TeamMembershipForm,
TeamPhotoForm, 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 from .recurrence_ui import describe_rrule
@@ -476,12 +486,31 @@ class MemberImportConfirmView(ClubAdminRequiredMixin, View):
season = current_season(request.club) season = current_season(request.club)
created = 0 created = 0
families_by_group = {}
with transaction.atomic(): with transaction.atomic():
for result in results: for result in results:
member = result["member"] member = result["member"]
if member is None: if member is None:
continue 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() 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: if season is not None:
ClubMembership.objects.create(club=request.club, member=member, season=season, signed_up_at=timezone.localdate(), **result["membership_kwargs"]) ClubMembership.objects.create(club=request.club, member=member, season=season, signed_up_at=timezone.localdate(), **result["membership_kwargs"])
created += 1 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_scoped_members = visible.filter(family_memberships__family_id__in=my_family_ids).distinct()
family_groups, _ = group_by_family(family_scoped_members) 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( return super().get_context_data(
family_groups=family_groups, family_groups=family_groups,
family_role_choices=FamilyMembership.FamilyRole.choices, 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 # signal the Personal information card uses to decide whether to show parent
# contact numbers at all. # contact numbers at all.
guardians=self.object.guardians, 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, **kwargs,
) )
@@ -623,6 +657,31 @@ class MemberAttachToFamilyView(ClubAdminRequiredMixin, RedirectOnInvalidMixin, F
return redirect("management:member_detail", pk=member.pk) 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): class MemberGrantLoginView(ClubAdminRequiredMixin, RedirectOnInvalidMixin, FormView):
"""Reachable only via the "Grant login" modal on a login-less child's row in """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) _family_members_table.html. The form itself (management.forms.GrantLoginForm)
@@ -847,6 +906,13 @@ class TeamDetailView(ClubStaffRequiredMixin, DetailView):
bottom_attenders=bottom_attenders, bottom_attenders=bottom_attenders,
missed_practices=missed_practices, missed_practices=missed_practices,
no_shows=no_shows, 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, **kwargs,
) )
@@ -1027,6 +1093,131 @@ class TeamStaffRemoveView(TeamManagerRequiredMixin, View):
return redirect(f"{reverse('management:team_detail', args=[pk])}?season={season_id}") 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): class TeamPhotoSetView(TeamManagerRequiredMixin, FormView):
"""Reachable only via the "Upload"/"Replace" modal on the team page. Binds """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 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) 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 # --- News: draft/edit is broad (any coach_manager/editor/admin), but only EDITOR/ADMIN
# may publish -- the release flow the news app exists for --------------------------- # 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. # 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] 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( return super().get_context_data(
can_manage=can_manage, can_manage=can_manage,
can_manage_referees=can_manage_referees,
referee_management_needed=referee_management_needed,
attendance_groups=attendance_groups, attendance_groups=attendance_groups,
has_attendance_rows=any(group["rows"] for group in 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, **kwargs,
) )

View File

@@ -1,7 +1,7 @@
from django.contrib import admin from django.contrib import admin
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from .models import Family, FamilyMembership, Member from .models import Family, FamilyMembership, Group, GroupMembership, Member
# Register your models here. # Register your models here.
@@ -60,3 +60,30 @@ class FamilyMembershipAdmin(admin.ModelAdmin):
list_filter = ("role",) list_filter = ("role",)
autocomplete_fields = ("family", "member") autocomplete_fields = ("family", "member")
search_fields = ("family__name", "member__first_name", "member__last_name") search_fields = ("family__name", "member__first_name", "member__last_name")
class GroupMemberInline(admin.TabularInline):
"""Members shown on the Group page."""
model = GroupMembership
extra = 1
autocomplete_fields = ("member",)
@admin.register(Group)
class GroupAdmin(admin.ModelAdmin):
list_display = ("name", "club", "member_count")
list_filter = ("club",)
search_fields = ("name",)
inlines = [GroupMemberInline]
@admin.display(description=_("members"))
def member_count(self, obj):
return obj.memberships.count()
@admin.register(GroupMembership)
class GroupMembershipAdmin(admin.ModelAdmin):
list_display = ("group", "member")
autocomplete_fields = ("group", "member")
search_fields = ("group__name", "member__first_name", "member__last_name")

View File

@@ -0,0 +1,54 @@
# Generated by Django 6.0.6 on 2026-08-08 21:53
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0020_sponsor_logo_height_sponsor_logo_width'),
('members', '0003_family_created_family_modified_member_created_and_more'),
]
operations = [
migrations.CreateModel(
name='Group',
fields=[
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=255, verbose_name='name')),
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')),
],
options={
'verbose_name': 'group',
'verbose_name_plural': 'groups',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='GroupMembership',
fields=[
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='members.group', verbose_name='group')),
('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_memberships', to='members.member', verbose_name='member')),
],
options={
'verbose_name': 'group membership',
'verbose_name_plural': 'group memberships',
'ordering': ['group', 'member__last_name', 'member__first_name'],
},
),
migrations.AddConstraint(
model_name='group',
constraint=models.UniqueConstraint(fields=('club', 'name'), name='unique_group_name_per_club'),
),
migrations.AddConstraint(
model_name='groupmembership',
constraint=models.UniqueConstraint(fields=('group', 'member'), name='unique_member_per_group'),
),
]

View File

@@ -3,7 +3,7 @@ from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from phonenumber_field.modelfields import PhoneNumberField from phonenumber_field.modelfields import PhoneNumberField
from rosterchief.base import UUIDModel from rosterchief.base import ClubScopedModel, UUIDModel
class Family(UUIDModel): class Family(UUIDModel):
@@ -99,3 +99,42 @@ class FamilyMembership(models.Model):
def __str__(self): def __str__(self):
return f"{self.family} - {self.member} ({self.get_role_display()})" return f"{self.family} - {self.member} ({self.get_role_display()})"
class Group(ClubScopedModel):
"""An arbitrary named collection of members -- deliberately generic, not
team-shaped and not aware of any specific use: "all coaches", "all team
managers", an ad-hoc committee. A Team's roster is a separate, more
specific concept (teams.TeamMembership); nothing here assumes team
semantics. Referee eligibility (who can ref which team) is a member-level
fact (teams.RefereeProfile), not a Group concern -- Group carries no
referee-specific knowledge at all."""
name = models.CharField(_("name"), max_length=255)
class Meta:
verbose_name = _("group")
verbose_name_plural = _("groups")
ordering = ["name"]
constraints = [
models.UniqueConstraint(fields=["club", "name"], name="unique_group_name_per_club"),
]
def __str__(self):
return self.name
class GroupMembership(UUIDModel):
group = models.ForeignKey(Group, on_delete=models.CASCADE, related_name="memberships", verbose_name=_("group"))
member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name="group_memberships", verbose_name=_("member"))
class Meta:
verbose_name = _("group membership")
verbose_name_plural = _("group memberships")
ordering = ["group", "member__last_name", "member__first_name"]
constraints = [
models.UniqueConstraint(fields=["group", "member"], name="unique_member_per_group"),
]
def __str__(self):
return f"{self.group} - {self.member}"

View File

@@ -18,17 +18,22 @@ def find_member_by_email(email):
return Member.objects.filter(user__email__iexact=email).first() return Member.objects.filter(user__email__iexact=email).first()
def get_or_create_login_member(email, first_name="", last_name=""): def get_or_create_login_user(email):
"""Find or create the Member behind ``email``, creating a User (no usable """Find or create the User behind ``email`` -- no usable password, they set
password -- they set one via the reset-link flow) the first time we see them. one via the reset-link flow the first time we see them. Mirrors
Mirrors controlpanel.services.admins.grant_club_admin. controlpanel.services.admins.grant_club_admin."""
"""
email = email.lower() email = email.lower()
user, user_created = User.objects.get_or_create(email=email, defaults={"is_active": True}) user, user_created = User.objects.get_or_create(email=email, defaults={"is_active": True})
if user_created: if user_created:
user.set_unusable_password() user.set_unusable_password()
user.save(update_fields=["password"]) user.save(update_fields=["password"])
return user, user_created
def get_or_create_login_member(email, first_name="", last_name=""):
"""Find or create the Member behind ``email``, creating a User the first time
we see them."""
user, _ = get_or_create_login_user(email)
member, _ = Member.objects.get_or_create(user=user, defaults={"first_name": first_name, "last_name": last_name}) member, _ = Member.objects.get_or_create(user=user, defaults={"first_name": first_name, "last_name": last_name})
return member return member

View File

@@ -15,7 +15,7 @@ from django.utils import timezone
from authentication.models import User from authentication.models import User
from club.models import Club, ClubMembership, Season from club.models import Club, ClubMembership, Season
from members.admin import FamilyAdmin from members.admin import FamilyAdmin
from members.models import Family, FamilyMembership, Member from members.models import Family, FamilyMembership, Group, GroupMembership, Member
from members.services import MemberImportResult from members.services import MemberImportResult
@@ -209,6 +209,62 @@ class FamilyMembershipModelTests(TestCase):
self.assertTrue(Member.objects.filter(pk=member.pk).exists()) self.assertTrue(Member.objects.filter(pk=member.pk).exists())
class GroupModelTests(TestCase):
def setUp(self):
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
def test_str_returns_name(self):
group = Group.objects.create(club=self.club, name="Coaches")
self.assertEqual(str(group), "Coaches")
def test_name_is_unique_per_club(self):
Group.objects.create(club=self.club, name="Coaches")
with self.assertRaises(IntegrityError):
Group.objects.create(club=self.club, name="Coaches")
def test_same_name_allowed_in_another_club(self):
other = Club.objects.create(name="Rival FC", slug="rival-fc")
Group.objects.create(club=self.club, name="Coaches")
Group.objects.create(club=other, name="Coaches")
self.assertEqual(Group.objects.filter(name="Coaches").count(), 2)
class GroupMembershipModelTests(TestCase):
def setUp(self):
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
self.group = Group.objects.create(club=self.club, name="Referees")
self.member = Member.objects.create(first_name="Ref", last_name="Eree")
def test_str(self):
membership = GroupMembership.objects.create(group=self.group, member=self.member)
self.assertEqual(str(membership), "Referees - Ref Eree")
def test_member_unique_per_group(self):
GroupMembership.objects.create(group=self.group, member=self.member)
with self.assertRaises(IntegrityError):
GroupMembership.objects.create(group=self.group, member=self.member)
def test_same_member_can_join_multiple_groups(self):
other_group = Group.objects.create(club=self.club, name="Coaches")
GroupMembership.objects.create(group=self.group, member=self.member)
GroupMembership.objects.create(group=other_group, member=self.member)
self.assertEqual(self.member.group_memberships.count(), 2)
def test_deleting_group_cascades_to_memberships(self):
GroupMembership.objects.create(group=self.group, member=self.member)
self.group.delete()
self.assertFalse(GroupMembership.objects.exists())
self.assertTrue(Member.objects.filter(pk=self.member.pk).exists())
class FamilyAdminTests(TestCase): class FamilyAdminTests(TestCase):
def test_member_count_reflects_memberships(self): def test_member_count_reflects_memberships(self):
family = Family.objects.create(name="The Smiths") family = Family.objects.create(name="The Smiths")
@@ -241,6 +297,8 @@ class AdminSmokeTests(TestCase):
("members", "member"), ("members", "member"),
("members", "family"), ("members", "family"),
("members", "familymembership"), ("members", "familymembership"),
("members", "group"),
("members", "groupmembership"),
): ):
with self.subTest(model=model): with self.subTest(model=model):
response = self.client.get(f"/admin/{app_label}/{model}/") response = self.client.get(f"/admin/{app_label}/{model}/")

View File

@@ -690,6 +690,26 @@
} }
} }
} }
.collapse-plus {
@layer daisyui.l1.l2 {
> .collapse-title:after {
position: absolute;
display: block;
height: 0.5rem;
width: 0.5rem;
@media (prefers-reduced-motion: no-preference) {
transition-property: all;
transition-duration: 300ms;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
top: 0.9rem;
inset-inline-end: 1.4rem;
--tw-content: "+";
content: var(--tw-content);
pointer-events: none;
}
}
}
.dropdown { .dropdown {
@layer daisyui.l1.l2.l3 { @layer daisyui.l1.l2.l3 {
position: relative; position: relative;
@@ -1195,6 +1215,21 @@
} }
} }
} }
.collapse-open {
@layer daisyui.l1.l2 {
grid-template-rows: max-content 1fr;
> .collapse-content {
--overflow-delay: 0.2s;
overflow: revert-layer;
content-visibility: visible;
min-height: fit-content;
padding-bottom: 1rem;
@supports not (content-visibility: visible) {
visibility: visible;
}
}
}
}
.collapse { .collapse {
visibility: collapse; visibility: collapse;
} }
@@ -1263,6 +1298,27 @@
} }
} }
} }
.toast {
@layer daisyui.l1.l2.l3 {
position: fixed;
inset-inline-start: auto;
inset-inline-end: calc(0.25rem * 4);
top: auto;
bottom: calc(0.25rem * 4);
display: flex;
flex-direction: column;
gap: calc(0.25rem * 2);
background-color: transparent;
translate: var(--toast-x, 0) var(--toast-y, 0);
width: max-content;
max-width: calc(100vw - 2rem);
& > * {
@media (prefers-reduced-motion: no-preference) {
animation: toast 0.25s ease-out;
}
}
}
}
.toggle { .toggle {
@layer daisyui.l1.l2.l3 { @layer daisyui.l1.l2.l3 {
border: var(--border) solid currentColor; border: var(--border) solid currentColor;
@@ -1530,6 +1586,105 @@
} }
} }
} }
.range {
@layer daisyui.l1.l2.l3 {
appearance: none;
webkit-appearance: none;
--range-thumb: var(--color-base-100);
--range-thumb-size: calc(var(--size-selector, 0.25rem) * 6);
--range-progress: currentColor;
--range-fill: 1;
--range-p: 0.25rem;
--range-bg: currentColor;
@supports (color: color-mix(in lab, red, red)) {
--range-bg: color-mix(in oklab, currentColor 10%, #0000);
}
--range-fill-x: calc(
(var(--range-dir, 1) * -100cqw) - (var(--range-dir, 1) * var(--range-thumb-size) / 2)
);
--range-fill-y: 0;
--range-fill-spread: calc(100cqw * var(--range-fill));
cursor: pointer;
overflow: hidden;
background-color: transparent;
vertical-align: middle;
width: clamp(3rem, 20rem, 100%);
--radius-selector-max: calc(
var(--radius-selector) + var(--radius-selector) + var(--radius-selector)
);
border-radius: calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));
border: none;
height: var(--range-thumb-size);
[dir="rtl"] & {
--range-dir: -1;
}
&:focus {
outline: none;
}
&:focus-visible {
outline: 2px solid;
outline-offset: 2px;
}
&::-webkit-slider-runnable-track {
width: 100%;
background-color: var(--range-bg);
border-radius: var(--radius-selector);
height: calc(var(--range-thumb-size) * 0.5);
}
@media (forced-colors: active) {
&::-webkit-slider-runnable-track {
border: 1px solid;
}
}
@media (forced-colors: active) {
&::-moz-range-track {
border: 1px solid;
}
}
&::-webkit-slider-thumb {
position: relative;
box-sizing: border-box;
border-radius: calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));
background-color: var(--range-thumb);
height: var(--range-thumb-size);
width: var(--range-thumb-size);
border: var(--range-p) solid;
appearance: none;
webkit-appearance: none;
inset-block-start: 50%;
color: var(--range-progress);
transform: translateY(-50%);
box-shadow: 0 -1px oklch(0% 0 0 / calc(var(--depth) * 0.1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px currentColor, 0 0 0 2rem var(--range-thumb) inset, var(--range-fill-x) var(--range-fill-y) 0 var(--range-fill-spread);
@supports (color: color-mix(in lab, red, red)) {
box-shadow: 0 -1px oklch(0% 0 0 / calc(var(--depth) * 0.1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000), 0 0 0 2rem var(--range-thumb) inset, var(--range-fill-x) var(--range-fill-y) 0 var(--range-fill-spread);
}
}
&::-moz-range-track {
width: 100%;
background-color: var(--range-bg);
border-radius: var(--radius-selector);
height: calc(var(--range-thumb-size) * 0.5);
}
&::-moz-range-thumb {
position: relative;
box-sizing: border-box;
border-radius: calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));
background-color: currentColor;
height: var(--range-thumb-size);
width: var(--range-thumb-size);
border: var(--range-p) solid;
color: var(--range-progress);
box-shadow: 0 -1px oklch(0% 0 0 / calc(var(--depth) * 0.1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px currentColor, 0 0 0 2rem var(--range-thumb) inset, var(--range-fill-x) var(--range-fill-y) 0 var(--range-fill-spread);
@supports (color: color-mix(in lab, red, red)) {
box-shadow: 0 -1px oklch(0% 0 0 / calc(var(--depth) * 0.1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000), 0 0 0 2rem var(--range-thumb) inset, var(--range-fill-x) var(--range-fill-y) 0 var(--range-fill-spread);
}
}
&:disabled {
cursor: not-allowed;
opacity: 30%;
}
}
}
.indicator { .indicator {
@layer daisyui.l1.l2.l3 { @layer daisyui.l1.l2.l3 {
position: relative; position: relative;
@@ -1616,6 +1771,51 @@
} }
} }
} }
.aura {
@layer daisyui.l1.l2.l3 {
position: relative;
display: inline-block;
--aura-padding: 0.125rem;
padding: var(--aura-padding);
border-radius: calc(var(--aura-padding) + var(--aura-radius, var(--radius-box)));
animation: aura var(--tw-duration, 6s) linear infinite;
background-image: conic-gradient(from var(--aura-angle), transparent 225deg, currentColor);
&:has( > .card, > .alert) {
--aura-radius: var(--radius-box);
}
&:has( > .btn, > .input, > .select) {
--aura-radius: var(--radius-field);
}
&:has( > .checkbox, > .toggle, > .badge) {
--aura-radius: var(--radius-selector);
}
&:before, &:after {
animation: inherit;
background-color: inherit;
background-image: inherit;
border-radius: inherit;
position: absolute;
top: calc(1 / 2 * 100%);
left: calc(1 / 2 * 100%);
z-index: 0;
display: block;
opacity: 70%;
filter: blur(0.25rem);
translate: -50% -50%;
width: 100%;
height: 100%;
content: "";
}
&:after {
opacity: 30%;
filter: blur(1rem);
}
& > * {
position: relative;
z-index: 1;
}
}
}
.steps { .steps {
@layer daisyui.l1.l2.l3 { @layer daisyui.l1.l2.l3 {
display: inline-grid; display: inline-grid;
@@ -2181,6 +2381,48 @@
} }
} }
} }
.rating {
@layer daisyui.l1.l2.l3 {
position: relative;
display: inline-flex;
vertical-align: middle;
--size: var(--size-selector, 0.25rem) * 6;
input {
cursor: pointer;
appearance: none;
}
* {
border-radius: 0;
background-color: var(--color-base-content);
opacity: 20%;
width: calc(var(--size) * 1);
height: calc(var(--size));
@media (prefers-reduced-motion: no-preference) {
animation: rating 0.25s ease-out;
}
}
.rating-hidden {
width: calc(0.25rem * 2);
background-color: transparent;
}
:checked, [aria-checked="true"], [aria-current="true"], :has( ~ :checked, ~ [aria-checked="true"], ~ [aria-current="true"]) {
opacity: 100%;
}
:focus-visible {
scale: 1.1;
@media (prefers-reduced-motion: no-preference) {
transition: scale 0.2s ease-out;
}
}
:active:focus {
animation: none;
scale: 1.1;
}
}
@layer daisyui.l1.l2 {
--size: var(--size-selector, 0.25rem) * 6;
}
}
.navbar { .navbar {
@layer daisyui.l1.l2.l3 { @layer daisyui.l1.l2.l3 {
display: flex; display: flex;
@@ -2320,6 +2562,30 @@
.sticky { .sticky {
position: sticky; position: sticky;
} }
.dropdown-right {
@layer daisyui.l1.l2 {
--anchor-h: right;
--anchor-v: span-bottom;
.dropdown-content {
inset-inline-start: 100%;
top: 0;
bottom: auto;
transform-origin: 0;
}
}
}
.dropdown-left {
@layer daisyui.l1.l2 {
--anchor-h: left;
--anchor-v: span-bottom;
.dropdown-content {
inset-inline-end: 100%;
top: 0;
bottom: auto;
transform-origin: 100%;
}
}
}
.dropdown-end { .dropdown-end {
@layer daisyui.l1.l2 { @layer daisyui.l1.l2 {
--anchor-h: span-left; --anchor-h: span-left;
@@ -2351,9 +2617,53 @@
.top-1 { .top-1 {
top: var(--spacing); top: var(--spacing);
} }
.top-2 {
top: calc(var(--spacing) * 2);
}
.right-2 {
right: calc(var(--spacing) * 2);
}
.left-1 { .left-1 {
left: var(--spacing); left: var(--spacing);
} }
.join {
display: inline-flex;
align-items: stretch;
--join-ss: 0;
--join-se: 0;
--join-es: 0;
--join-ee: 0;
--join-v: 0;
--join-h: 1;
@scope (&) {
> :where(:focus, :has(:focus)) {
z-index: 1;
}
@media (hover: hover) {
> :where(.btn:hover, :has(.btn:hover)) {
isolation: isolate;
}
}
:where(:scope > :first-child) {
--join-ss: var(--radius-field);
--join-se: calc(var(--radius-field) * var(--join-v));
--join-es: calc(var(--radius-field) * var(--join-h));
--join-ee: 0;
}
:where(:scope > :last-child) {
--join-ss: 0;
--join-se: calc(var(--radius-field) * var(--join-h));
--join-es: calc(var(--radius-field) * var(--join-v));
--join-ee: var(--radius-field);
}
:where(:scope > :only-child) {
--join-ss: var(--radius-field);
--join-se: var(--radius-field);
--join-es: var(--radius-field);
--join-ee: var(--radius-field);
}
}
}
.file-input { .file-input {
@layer daisyui.l1.l2.l3 { @layer daisyui.l1.l2.l3 {
cursor: pointer; cursor: pointer;
@@ -2545,6 +2855,25 @@
} }
} }
} }
.btn-active {
@layer daisyui.l1.l2 {
--btn-bg: var(--btn-color, var(--color-base-200));
color: var(--btn-fg, var(--color-base-content));
--btn-border: var(--btn-bg);
@supports (color: color-mix(in lab, red, red)) {
--btn-border: color-mix(in oklab, var(--btn-bg), #000 calc(var(--depth) * 5%));
}
--btn-border-style: solid;
--btn-inset: 0 0.5px 0 0.5px oklch(100% 0 0 / calc(var(--depth) * 6%));
--btn-shadow: 0 3px 2px -2px var(--btn-bg),
0 4px 3px -2px var(--btn-bg);
@supports (color: color-mix(in lab, red, red)) {
--btn-shadow: 0 3px 2px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000),
0 4px 3px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000);
}
isolation: isolate;
}
}
.stack { .stack {
@layer daisyui.l1.l2.l3 { @layer daisyui.l1.l2.l3 {
display: inline-grid; display: inline-grid;
@@ -2866,6 +3195,31 @@
} }
} }
} }
.join-item {
@layer daisyui.l1.l2.l3.l4 {
> * {
--join-ss: initial;
--join-se: initial;
--join-es: initial;
--join-ee: initial;
}
}
border-style: solid;
border-width: var(--border, 1px);
border-start-start-radius: var(--join-ss);
border-start-end-radius: var(--join-se);
border-end-start-radius: var(--join-es);
border-end-end-radius: var(--join-ee);
&:not(:first-child, :disabled, [disabled], .btn-disabled) {
margin-inline-start: calc(var(--border, 1px) * -1 * var(--join-h));
margin-block-start: calc(var(--border, 1px) * -1 * var(--join-v));
}
&:is(:disabled, [disabled], .btn-disabled) {
border-width: var(--border, 1px);
border-inline-end-width: calc(var(--border, 1px) * var(--join-v));
border-block-end-width: calc(var(--border, 1px) * var(--join-h));
}
}
.modal-action { .modal-action {
@layer daisyui.l1.l2.l3 { @layer daisyui.l1.l2.l3 {
margin-top: calc(0.25rem * 6); margin-top: calc(0.25rem * 6);
@@ -3164,12 +3518,23 @@
.inline-flex { .inline-flex {
display: inline-flex; display: inline-flex;
} }
.inline-grid {
display: inline-grid;
}
.table { .table {
display: table; display: table;
} }
.aspect-square { .aspect-square {
aspect-ratio: 1 / 1; aspect-ratio: 1 / 1;
} }
.btn-circle {
@layer daisyui.l1.l2 {
border-radius: calc(infinity * 1px);
padding-inline: 0;
width: var(--size);
height: var(--size);
}
}
.size-8 { .size-8 {
width: calc(var(--spacing) * 8); width: calc(var(--spacing) * 8);
height: calc(var(--spacing) * 8); height: calc(var(--spacing) * 8);
@@ -3186,6 +3551,9 @@
.h-72 { .h-72 {
height: calc(var(--spacing) * 72); height: calc(var(--spacing) * 72);
} }
.h-full {
height: 100%;
}
.h-screen { .h-screen {
height: 100vh; height: 100vh;
} }
@@ -3201,6 +3569,9 @@
.w-16 { .w-16 {
width: calc(var(--spacing) * 16); width: calc(var(--spacing) * 16);
} }
.w-20 {
width: calc(var(--spacing) * 20);
}
.w-60 { .w-60 {
width: calc(var(--spacing) * 60); width: calc(var(--spacing) * 60);
} }
@@ -3237,18 +3608,55 @@
.flex-1 { .flex-1 {
flex: 1; flex: 1;
} }
.flex-shrink {
flex-shrink: 1;
}
.shrink { .shrink {
flex-shrink: 1; flex-shrink: 1;
} }
.shrink-0 { .shrink-0 {
flex-shrink: 0; flex-shrink: 0;
} }
.flex-grow {
flex-grow: 1;
}
.grow { .grow {
flex-grow: 1; flex-grow: 1;
} }
.border-collapse { .border-collapse {
border-collapse: collapse; border-collapse: collapse;
} }
.transform {
transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
}
.skeleton {
@layer daisyui.l1.l2.l3 {
border-radius: var(--radius-box);
background-color: var(--color-base-300);
@media (prefers-reduced-motion: reduce) {
transition-duration: 15s;
}
will-change: background-position;
background-image: linear-gradient( 105deg, #0000 0% 40%, var(--color-base-100) 50%, #0000 60% 100% );
background-size: 200% auto;
background-position-x: -50%;
@media (prefers-reduced-motion: no-preference) {
animation: skeleton 1.8s ease-in-out infinite;
}
}
}
.aura-glow {
@layer daisyui.l1.l2 {
animation: none;
background-image: radial-gradient(closest-corner at center, currentColor 0%, transparent 90%);
&:before {
animation: aura-glow var(--tw-duration, 6s) ease-out infinite;
}
&:after {
animation: aura-glow-after var(--tw-duration, 6s) ease-out infinite;
}
}
}
.animate-pulse { .animate-pulse {
animation: var(--animate-pulse); animation: var(--animate-pulse);
} }
@@ -3324,6 +3732,9 @@
.justify-start { .justify-start {
justify-content: flex-start; justify-content: flex-start;
} }
.justify-items-start {
justify-items: start;
}
.gap-1 { .gap-1 {
gap: var(--spacing); gap: var(--spacing);
} }
@@ -3575,6 +3986,9 @@
--btn-shadow: 0 0 0 0 oklch(0% 0 0/0); --btn-shadow: 0 0 0 0 oklch(0% 0 0/0);
} }
} }
.mask-repeat {
mask-repeat: repeat;
}
.object-contain { .object-contain {
object-fit: contain; object-fit: contain;
} }
@@ -3838,6 +4252,9 @@
text-decoration-line: none; text-decoration-line: none;
} }
} }
.underline {
text-decoration-line: underline;
}
.opacity-40 { .opacity-40 {
opacity: 40%; opacity: 40%;
} }
@@ -3894,6 +4311,19 @@
.filter { .filter {
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
} }
.transition {
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events;
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
transition-duration: var(--tw-duration, var(--default-transition-duration));
}
.ease-in-out {
--tw-ease: var(--ease-in-out);
transition-timing-function: var(--ease-in-out);
}
.ease-out {
--tw-ease: var(--ease-out);
transition-timing-function: var(--ease-out);
}
.input-lg { .input-lg {
@layer daisyui.l1.l2 { @layer daisyui.l1.l2 {
--in-size-mul: 12; --in-size-mul: 12;
@@ -3905,6 +4335,17 @@
} }
} }
} }
.input-sm {
@layer daisyui.l1.l2 {
--in-size-mul: 8;
--font-size-min: 0.75rem;
--spin-my: -2;
.floating-label:has(&) {
--top-mul: 4;
--font-size: 0.75rem;
}
}
}
.select-sm { .select-sm {
@layer daisyui.l1.l2 { @layer daisyui.l1.l2 {
--sl-size-mul: 8; --sl-size-mul: 8;
@@ -4047,6 +4488,16 @@
} }
} }
} }
.hover\:badge-neutral {
&:hover {
@media (hover: hover) {
@layer daisyui.l1.l2 {
--badge-color: var(--color-neutral);
--badge-fg: var(--color-neutral-content);
}
}
}
}
.sm\:col-span-2 { .sm\:col-span-2 {
@media (width >= 40rem) { @media (width >= 40rem) {
grid-column: span 2 / span 2; grid-column: span 2 / span 2;
@@ -4122,6 +4573,11 @@
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
} }
} }
.lg\:grid-cols-4 {
@media (width >= 64rem) {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.lg\:grid-cols-6 { .lg\:grid-cols-6 {
@media (width >= 64rem) { @media (width >= 64rem) {
grid-template-columns: repeat(6, minmax(0, 1fr)); grid-template-columns: repeat(6, minmax(0, 1fr));
@@ -4552,6 +5008,26 @@
opacity: 0; opacity: 0;
} }
} }
@property --tw-rotate-x {
syntax: "*";
inherits: false;
}
@property --tw-rotate-y {
syntax: "*";
inherits: false;
}
@property --tw-rotate-z {
syntax: "*";
inherits: false;
}
@property --tw-skew-x {
syntax: "*";
inherits: false;
}
@property --tw-skew-y {
syntax: "*";
inherits: false;
}
@property --tw-space-y-reverse { @property --tw-space-y-reverse {
syntax: "*"; syntax: "*";
inherits: false; inherits: false;
@@ -4718,6 +5194,10 @@
syntax: "*"; syntax: "*";
inherits: false; inherits: false;
} }
@property --tw-ease {
syntax: "*";
inherits: false;
}
@keyframes pulse { @keyframes pulse {
50% { 50% {
opacity: 0.5; opacity: 0.5;
@@ -4726,6 +5206,11 @@
@layer properties { @layer properties {
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
*, ::before, ::after, ::backdrop { *, ::before, ::after, ::backdrop {
--tw-rotate-x: initial;
--tw-rotate-y: initial;
--tw-rotate-z: initial;
--tw-skew-x: initial;
--tw-skew-y: initial;
--tw-space-y-reverse: 0; --tw-space-y-reverse: 0;
--tw-divide-y-reverse: 0; --tw-divide-y-reverse: 0;
--tw-border-style: solid; --tw-border-style: solid;
@@ -4764,6 +5249,7 @@
--tw-drop-shadow-color: initial; --tw-drop-shadow-color: initial;
--tw-drop-shadow-alpha: 100%; --tw-drop-shadow-alpha: 100%;
--tw-drop-shadow-size: initial; --tw-drop-shadow-size: initial;
--tw-ease: initial;
} }
} }
} }

View File

@@ -1,7 +1,7 @@
from django.contrib import admin from django.contrib import admin
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from .models import Position, StaffAssignment, Team, TeamMembership, TeamPhoto from .models import Position, RefereeLevel, RefereeProfile, StaffAssignment, Team, TeamMembership, TeamPhoto
class TeamMembershipInline(admin.TabularInline): class TeamMembershipInline(admin.TabularInline):
@@ -29,8 +29,8 @@ class TeamPhotoInline(admin.TabularInline):
@admin.register(Team) @admin.register(Team)
class TeamAdmin(admin.ModelAdmin): class TeamAdmin(admin.ModelAdmin):
list_display = ["name", "short_name", "club"] list_display = ["name", "short_name", "club", "referee_management"]
list_filter = ["club"] list_filter = ["club", "referee_management"]
search_fields = ["name", "short_name"] search_fields = ["name", "short_name"]
inlines = [TeamMembershipInline, StaffAssignmentInline, TeamPhotoInline] inlines = [TeamMembershipInline, StaffAssignmentInline, TeamPhotoInline]
@@ -68,3 +68,28 @@ class StaffAssignmentAdmin(admin.ModelAdmin):
list_filter = ["team", "season", "position"] list_filter = ["team", "season", "position"]
search_fields = ["team__name", "member__first_name", "member__last_name"] search_fields = ["team__name", "member__first_name", "member__last_name"]
raw_id_fields = ["member"] raw_id_fields = ["member"]
@admin.register(RefereeLevel)
class RefereeLevelAdmin(admin.ModelAdmin):
list_display = ["name", "club", "ordering", "team_list"]
list_filter = ["club"]
search_fields = ["name"]
autocomplete_fields = ["teams"]
ordering = ["club", "ordering", "name"]
@admin.display(description=_("teams"))
def team_list(self, obj):
return ", ".join(team.name for team in obj.teams.all())
@admin.register(RefereeProfile)
class RefereeProfileAdmin(admin.ModelAdmin):
list_display = ["member", "level", "valid_until", "is_eligible"]
list_filter = ["level"]
search_fields = ["member__first_name", "member__last_name"]
autocomplete_fields = ["member"]
@admin.display(description=_("eligible"), boolean=True)
def is_eligible(self, obj):
return obj.is_eligible

View File

@@ -0,0 +1,54 @@
# Generated by Django 6.0.6 on 2026-08-09 09:25
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0020_sponsor_logo_height_sponsor_logo_width'),
('members', '0004_group_groupmembership_and_more'),
('teams', '0007_teamphoto'),
]
operations = [
migrations.CreateModel(
name='RefereeLevel',
fields=[
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=255, verbose_name='name')),
('ordering', models.PositiveSmallIntegerField(default=0, help_text='Lower numbers are listed first. Levels with the same number are ordered by name.', verbose_name='ordering')),
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')),
('teams', models.ManyToManyField(blank=True, help_text="Members holding this level can be assigned to referee these teams' home games.", related_name='referee_levels', to='teams.team', verbose_name='qualifies for')),
],
options={
'verbose_name': 'referee level',
'verbose_name_plural': 'referee levels',
'ordering': ['ordering', 'name'],
},
),
migrations.CreateModel(
name='RefereeProfile',
fields=[
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('valid_until', models.DateField(blank=True, help_text="Once this date has passed, the referee is not eligible for assignment until it's extended.", null=True, verbose_name='valid until')),
('level', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='referees', to='teams.refereelevel', verbose_name='level')),
('member', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='referee_profile', to='members.member', verbose_name='member')),
],
options={
'verbose_name': 'referee profile',
'verbose_name_plural': 'referee profiles',
'ordering': ['member__last_name', 'member__first_name'],
},
),
migrations.AddConstraint(
model_name='refereelevel',
constraint=models.UniqueConstraint(fields=('club', 'name'), name='unique_referee_level_name_per_club'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.6 on 2026-08-09 10:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('teams', '0008_refereelevel_refereeprofile_and_more'),
]
operations = [
migrations.AddField(
model_name='team',
name='referee_management',
field=models.CharField(choices=[('club', 'Club'), ('federation', 'Federation')], default='club', help_text="Who arranges referees for this team's home games. Federation-managed teams are left out of the referee tools entirely -- no eligibility, no assignment, nothing to configure.", max_length=20, verbose_name='referee management'),
),
]

View File

@@ -1,5 +1,6 @@
from django.db import models from django.db import models
from django.db.models import Q from django.db.models import Q
from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from club.models import Season from club.models import Season
@@ -8,8 +9,19 @@ from rosterchief.base import ClubScopedModel, UUIDModel, validate_club_scope
class Team(ClubScopedModel): class Team(ClubScopedModel):
class RefereeManagement(models.TextChoices):
CLUB = "club", _("Club")
FEDERATION = "federation", _("Federation")
name = models.CharField(_("name"), max_length=255) name = models.CharField(_("name"), max_length=255)
short_name = models.CharField(_("short name"), max_length=255) short_name = models.CharField(_("short name"), max_length=255)
referee_management = models.CharField(
_("referee management"),
max_length=20,
choices=RefereeManagement.choices,
default=RefereeManagement.CLUB,
help_text=_("Who arranges referees for this team's home games. Federation-managed teams are left out of the referee tools entirely -- no eligibility, no assignment, nothing to configure."),
)
class Meta: class Meta:
verbose_name = _("team") verbose_name = _("team")
@@ -96,6 +108,78 @@ class TeamMembership(UUIDModel):
validate_club_scope(self, club_id, same_club_fields=("season", "position")) validate_club_scope(self, club_id, same_club_fields=("season", "position"))
class RefereeLevel(ClubScopedModel):
"""A club-defined referee qualification tier (e.g. "Regional", "National")
-- admin-managed, like Position, so a club can name and reorder its own
levels rather than picking from a fixed list. Owns which teams it
qualifies a referee for: eligibility is a property of the *level*, not of
the individual referee -- a club typically has a handful of levels, each
unlocking a tier of teams/competitions, rather than hand-picking teams per
referee."""
name = models.CharField(_("name"), max_length=255)
ordering = models.PositiveSmallIntegerField(_("ordering"), default=0, help_text=_("Lower numbers are listed first. Levels with the same number are ordered by name."))
teams = models.ManyToManyField(Team, related_name="referee_levels", blank=True, verbose_name=_("qualifies for"), help_text=_("Members holding this level can be assigned to referee these teams' home games."))
class Meta:
verbose_name = _("referee level")
verbose_name_plural = _("referee levels")
ordering = ["ordering", "name"]
constraints = [
models.UniqueConstraint(fields=["club", "name"], name="unique_referee_level_name_per_club"),
]
def __str__(self):
return self.name
class RefereeProfile(UUIDModel):
"""Marks a member as a club referee: their level (which determines which
teams they're eligible for, via RefereeLevel.teams) and how long that
qualification is valid. A member-level fact, not a group-level one --
managed from the member's own page, unrelated to members.Group (which
stays a plain, opaque collection of people with no referee-specific
knowledge).
No level, or an expired/unset validity, both mean "not currently
eligible" -- see `is_currently_valid`/`eligible_teams` below, the single
definitions every consumer (the event assign panel, the team page, the
referees list) reads through, so "eligible" never drifts out of sync.
"""
member = models.OneToOneField(Member, on_delete=models.CASCADE, related_name="referee_profile", verbose_name=_("member"))
level = models.ForeignKey(RefereeLevel, on_delete=models.PROTECT, null=True, blank=True, related_name="referees", verbose_name=_("level"))
valid_until = models.DateField(_("valid until"), null=True, blank=True, help_text=_("Once this date has passed, the referee is not eligible for assignment until it's extended."))
class Meta:
verbose_name = _("referee profile")
verbose_name_plural = _("referee profiles")
ordering = ["member__last_name", "member__first_name"]
def __str__(self):
return f"{self.member} (referee)"
@property
def is_currently_valid(self) -> bool:
"""Whether the validity date itself hasn't passed -- independent of
whether a level is even set. Use `is_eligible` for the full gate."""
return self.valid_until is not None and self.valid_until >= timezone.localdate()
@property
def is_eligible(self) -> bool:
"""The full gate consumed everywhere eligibility actually matters: a
level is set, and its validity hasn't passed."""
return self.level_id is not None and self.is_currently_valid
@property
def eligible_teams(self):
"""Teams this profile currently qualifies for -- empty whenever it
isn't currently eligible, regardless of what level is set."""
if not self.is_eligible:
return Team.objects.none()
return self.level.teams.all()
class StaffAssignment(UUIDModel): class StaffAssignment(UUIDModel):
team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name="staff_assignments", verbose_name=_("team")) team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name="staff_assignments", verbose_name=_("team"))
member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name="staff_assignments", verbose_name=_("member")) member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name="staff_assignments", verbose_name=_("member"))

17
teams/services.py Normal file
View File

@@ -0,0 +1,17 @@
from django.utils import timezone
from club.models import ClubMembership, Season
from members.models import Member
def eligible_roster_members(club):
"""Members eligible to be added to a team's roster or staff: active (paid) for
the club this season or next, regardless of which season's roster/staff list is
currently being edited (the team page's season switcher can point at either)."""
today = timezone.localdate()
eligible_seasons = [season for season in (Season.covering(club, today), Season.next_after(club, today)) if season is not None]
return Member.objects.filter(
member_of__club=club,
member_of__season__in=eligible_seasons,
member_of__status=ClubMembership.StatusChoices.ACTIVE,
).distinct()

View File

@@ -4,11 +4,12 @@ from django.core.exceptions import ValidationError
from django.db import IntegrityError from django.db import IntegrityError
from django.db.models import ProtectedError from django.db.models import ProtectedError
from django.test import TestCase from django.test import TestCase
from django.utils import timezone
from club.models import Club, Season from club.models import Club, Season
from members.models import Member from members.models import Member
from .models import Position, StaffAssignment, Team, TeamMembership, TeamPhoto from .models import Position, RefereeLevel, RefereeProfile, StaffAssignment, Team, TeamMembership, TeamPhoto
class TeamsTestCase(TestCase): class TeamsTestCase(TestCase):
@@ -39,6 +40,9 @@ class TeamModelTests(TeamsTestCase):
self.assertEqual(Team.objects.filter(name="First Team").count(), 2) self.assertEqual(Team.objects.filter(name="First Team").count(), 2)
def test_referee_management_defaults_to_club(self):
self.assertEqual(self.team.referee_management, Team.RefereeManagement.CLUB)
class PositionModelTests(TeamsTestCase): class PositionModelTests(TeamsTestCase):
def test_str_returns_name(self): def test_str_returns_name(self):
@@ -188,3 +192,98 @@ class TeamPhotoModelTests(TeamsTestCase):
self.team.delete() self.team.delete()
self.assertEqual(TeamPhoto.objects.count(), 0) self.assertEqual(TeamPhoto.objects.count(), 0)
class RefereeLevelModelTests(TeamsTestCase):
def test_str(self):
level = RefereeLevel.objects.create(club=self.club, name="Regional")
self.assertEqual(str(level), "Regional")
def test_name_is_unique_per_club(self):
RefereeLevel.objects.create(club=self.club, name="Regional")
with self.assertRaises(IntegrityError):
RefereeLevel.objects.create(club=self.club, name="Regional")
def test_same_name_allowed_in_another_club(self):
other = Club.objects.create(name="Rival FC", slug="rival-fc")
RefereeLevel.objects.create(club=self.club, name="Regional")
RefereeLevel.objects.create(club=other, name="Regional")
self.assertEqual(RefereeLevel.objects.filter(name="Regional").count(), 2)
def test_teams_starts_empty(self):
level = RefereeLevel.objects.create(club=self.club, name="Regional")
self.assertEqual(list(level.teams.all()), [])
def test_can_qualify_for_multiple_teams(self):
other_team = Team.objects.create(club=self.club, name="Second Team", short_name="2nd")
level = RefereeLevel.objects.create(club=self.club, name="Regional")
level.teams.set([self.team, other_team])
self.assertEqual(set(level.teams.all()), {self.team, other_team})
def test_team_reverse_accessor(self):
level = RefereeLevel.objects.create(club=self.club, name="Regional")
level.teams.add(self.team)
self.assertEqual(list(self.team.referee_levels.all()), [level])
class RefereeProfileModelTests(TeamsTestCase):
def setUp(self):
super().setUp()
self.level = RefereeLevel.objects.create(club=self.club, name="Regional")
self.level.teams.add(self.team)
def test_str(self):
profile = RefereeProfile.objects.create(member=self.member)
self.assertEqual(str(profile), "Jane Doe (referee)")
def test_member_is_one_to_one(self):
RefereeProfile.objects.create(member=self.member)
with self.assertRaises(IntegrityError):
RefereeProfile.objects.create(member=self.member)
def test_no_level_is_never_eligible_even_with_a_future_validity(self):
profile = RefereeProfile.objects.create(member=self.member, valid_until=datetime.date(2099, 1, 1))
self.assertTrue(profile.is_currently_valid) # the date itself is fine...
self.assertFalse(profile.is_eligible) # ...but there's no level, so not eligible
self.assertEqual(list(profile.eligible_teams), [])
def test_no_validity_set_is_not_currently_valid_or_eligible(self):
profile = RefereeProfile.objects.create(member=self.member, level=self.level)
self.assertFalse(profile.is_currently_valid)
self.assertFalse(profile.is_eligible)
self.assertEqual(list(profile.eligible_teams), [])
def test_valid_until_today_is_currently_valid_and_eligible(self):
profile = RefereeProfile.objects.create(member=self.member, level=self.level, valid_until=timezone.localdate())
self.assertTrue(profile.is_currently_valid)
self.assertTrue(profile.is_eligible)
def test_valid_until_in_the_past_is_not_currently_valid_or_eligible(self):
profile = RefereeProfile.objects.create(member=self.member, level=self.level, valid_until=timezone.localdate() - datetime.timedelta(days=1))
self.assertFalse(profile.is_currently_valid)
self.assertFalse(profile.is_eligible)
self.assertEqual(list(profile.eligible_teams), [])
def test_eligible_teams_come_from_the_level_when_eligible(self):
profile = RefereeProfile.objects.create(member=self.member, level=self.level, valid_until=timezone.localdate() + datetime.timedelta(days=1))
self.assertEqual(list(profile.eligible_teams), [self.team])
def test_deleting_a_referenced_level_is_protected(self):
RefereeProfile.objects.create(member=self.member, level=self.level, valid_until=timezone.localdate())
with self.assertRaises(ProtectedError):
self.level.delete()
def test_deleting_the_member_deletes_the_profile(self):
profile = RefereeProfile.objects.create(member=self.member)
self.member.delete()
self.assertFalse(RefereeProfile.objects.filter(pk=profile.pk).exists())

View File

@@ -27,20 +27,22 @@
{% for authenticator in authenticators %} {% for authenticator in authenticators %}
{% element tr %} {% element tr %}
{% element td %} {% element td %}
{{ authenticator }} <div class="flex items-center gap-2">
{% if authenticator.wrap.is_passwordless is True %} {{ authenticator }}
{% element badge tags="mfa,key,primary" %} {% if authenticator.wrap.is_passwordless is True %}
{% translate "Passkey" %} {% element badge tags="mfa,key,primary" %}
{% endelement %} {% translate "Passkey" %}
{% elif authenticator.wrap.is_passwordless is False %} {% endelement %}
{% element badge tags="mfa,key,secondary" %} {% elif authenticator.wrap.is_passwordless is False %}
{% translate "Security key" %} {% element badge tags="mfa,key,secondary" %}
{% endelement %} {% translate "Security key" %}
{% else %} {% endelement %}
{% element badge title=_("This key does not indicate whether it is a passkey.") tags="mfa,key,warning" %} {% else %}
{% translate "Unspecified" %} {% element badge title=_("This key does not indicate whether it is a passkey.") tags="mfa,key,warning" %}
{% endelement %} {% translate "Unspecified" %}
{% endif %} {% endelement %}
{% endif %}
</div>
{% endelement %} {% endelement %}
{% element td %} {% element td %}
{% blocktranslate with created_at=authenticator.created_at|date:"SHORT_DATE_FORMAT" %}Added on {{ created_at }}{% endblocktranslate %}. {% blocktranslate with created_at=authenticator.created_at|date:"SHORT_DATE_FORMAT" %}Added on {{ created_at }}{% endblocktranslate %}.
@@ -51,14 +53,16 @@
{% endif %} {% endif %}
{% endelement %} {% endelement %}
{% element td align="right" %} {% element td align="right" %}
{% url 'mfa_edit_webauthn' pk=authenticator.pk as edit_url %} <div class="flex items-center justify-end gap-2">
{% element button href=edit_url icon="pencil" tags="mfa,authenticator,edit,tool" %} {% url 'mfa_edit_webauthn' pk=authenticator.pk as edit_url %}
{% translate "Edit" %} {% element button href=edit_url icon="pencil" tags="mfa,authenticator,edit,tool" %}
{% endelement %} {% translate "Edit" %}
{% url 'mfa_remove_webauthn' pk=authenticator.pk as remove_url %} {% endelement %}
{% element button href=remove_url icon="trash-2" tags="mfa,authenticator,danger,delete,tool" %} {% url 'mfa_remove_webauthn' pk=authenticator.pk as remove_url %}
{% translate "Remove" %} {% element button href=remove_url icon="trash-2" tags="mfa,authenticator,danger,delete,tool" %}
{% endelement %} {% translate "Remove" %}
{% endelement %}
</div>
{% endelement %} {% endelement %}
{% endelement %} {% endelement %}
{% endfor %} {% endfor %}