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:
153
ARCHITECTURE.md
153
ARCHITECTURE.md
@@ -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.1–3.2) — it *is* the "is a
|
- `StaffAssignment` drives the coach/manager object-scope (§3.1–3.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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
BIN
Leeg Document RefereesCompetitie25-26.pdf
Normal file
BIN
Leeg Document RefereesCompetitie25-26.pdf
Normal file
Binary file not shown.
@@ -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">
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
18
club/migrations/0021_club_legal_name.py
Normal file
18
club/migrations/0021_club_legal_name.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
{% block heading %}{{ club.name }}{% endblock heading %}
|
{% block heading %}{{ club.name }}{% endblock heading %}
|
||||||
|
|
||||||
{% block subheading %}
|
{% block subheading %}
|
||||||
{{ club.slug }}.rosterchief.app · {{ club.get_sport_type_display }}
|
{{ club.slug }}.rosterchief.app · {{ club.get_sport_type_display }}{% if club.legal_name %} · {{ 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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
38
events/migrations/0020_event_max_referees_eventreferee.py
Normal file
38
events/migrations/0020_event_max_referees_eventreferee.py
Normal 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')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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
147
events/services/referees.py
Normal 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
|
||||||
276
events/tests.py
276
events/tests.py
@@ -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"))
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
{% load i18n lucide ui %}
|
||||||
|
{% comment %}
|
||||||
|
Shared by the event detail page and the referee management dashboard --
|
||||||
|
context: event, referees (EventReferee rows, each with a .fee_form when
|
||||||
|
can_manage_referees), referee_candidates, referees_full,
|
||||||
|
can_manage_referees, and an optional next_url to return to after a POST
|
||||||
|
(defaults to the event detail page when blank).
|
||||||
|
{% endcomment %}
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="card-title text-base">
|
||||||
|
{% lucide "flag" size=18 %} {% trans "Referees" %}
|
||||||
|
<span class="badge badge-sm {% if referees_full %}badge-neutral{% else %}badge-outline{% endif %}">
|
||||||
|
{{ referees|length }} / {{ event.max_referees }}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
{% if can_manage_referees %}
|
||||||
|
<a class="btn btn-outline btn-sm gap-1" href="{% url 'management:event_referee_form_pdf' event.pk %}">{% lucide "file-down" size=12 %} {% trans "Referee form (PDF)" %}</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<ul class="divide-y divide-base-200">
|
||||||
|
{% for referee in referees %}
|
||||||
|
<li class="flex items-center justify-between py-2 gap-2">
|
||||||
|
<span>
|
||||||
|
{{ referee.display_name }}
|
||||||
|
{% if referee.is_external %}<span class="badge badge-neutral badge-xs">{% trans "External" %}</span>{% endif %}
|
||||||
|
{% if referee.assigned_by %}<span class="text-xs opacity-60">— {% blocktrans with name=referee.assigned_by %}assigned by {{ name }}{% endblocktrans %}</span>{% endif %}
|
||||||
|
{% if referee.total_payable %}<span class="text-xs opacity-60">— {% blocktrans with total=referee.total_payable %}€{{ total }} due{% endblocktrans %}</span>{% endif %}
|
||||||
|
</span>
|
||||||
|
{% if can_manage_referees %}
|
||||||
|
<div class="flex gap-1 shrink-0">
|
||||||
|
<button class="btn btn-xs btn-outline" type="button" onclick="document.getElementById('{{ referee.pk|dom_id:"referee_fee_modal" }}').showModal()">{% lucide "euro" size=12 %} {% trans "Fee" %}</button>
|
||||||
|
<form method="post" action="{% url 'management:event_referee_remove' event.pk referee.pk %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% if next_url %}<input type="hidden" name="next" value="{{ next_url }}">{% endif %}
|
||||||
|
<button class="btn btn-xs btn-outline btn-error gap-1" type="submit">{% lucide "x" size=12 %} {% trans "Remove" %}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% empty %}
|
||||||
|
<li class="py-2 text-sm opacity-60">{% trans "No referees assigned yet." %}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{% if can_manage_referees %}
|
||||||
|
{% if referees_full %}
|
||||||
|
<p class="text-sm opacity-60 mt-2">{% trans "This game already has its maximum number of referees." %}</p>
|
||||||
|
{% else %}
|
||||||
|
{% if referee_candidates %}
|
||||||
|
<form method="post" action="{% url 'management:event_referee_assign' event.pk %}" class="mt-2 flex flex-wrap items-center gap-2">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% if next_url %}<input type="hidden" name="next" value="{{ next_url }}">{% endif %}
|
||||||
|
<select name="member" class="select select-bordered select-sm">
|
||||||
|
{% for candidate in referee_candidates %}
|
||||||
|
<option value="{{ candidate.pk }}" {% if candidate.has_conflict %}title="{% blocktrans with events=candidate.conflict_titles %}Also expected at: {{ events }}{% endblocktrans %}"{% endif %}>
|
||||||
|
{{ candidate }}{% if candidate.has_conflict %} ⚠{% endif %}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-outline btn-sm gap-2" type="submit">{% lucide "user-plus" size=14 %} {% trans "Assign" %}</button>
|
||||||
|
</form>
|
||||||
|
<p class="text-xs opacity-60 mt-1">{% trans "⚠ = also expected at another event around this time -- shown as a warning, not blocked." %}</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-sm opacity-60 mt-2">
|
||||||
|
{% trans "No eligible referees for this team yet." %}
|
||||||
|
<a class="link" href="{% url 'management:referee_level_list' %}">{% trans "Link a referee level to this team." %}</a>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="{% url 'management:event_referee_add_external' event.pk %}" class="mt-2 flex flex-wrap items-center gap-2">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% if next_url %}<input type="hidden" name="next" value="{{ next_url }}">{% endif %}
|
||||||
|
<input type="text" name="name" class="input input-bordered input-sm" placeholder="{% trans 'External referee name' %}">
|
||||||
|
<button class="btn btn-outline btn-sm gap-2" type="submit">{% lucide "user-plus" size=14 %} {% trans "Add external" %}</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if can_manage_referees %}
|
||||||
|
{% trans "Referee fee" as fee_title %}
|
||||||
|
{% trans "Save" as save_label %}
|
||||||
|
{% with encoded_next=next_url|urlencode %}
|
||||||
|
{% for referee in referees %}
|
||||||
|
{% url 'management:event_referee_fee_update' event.pk referee.pk as fee_action_url %}
|
||||||
|
{% with fee_action_url=fee_action_url|add:"?next="|add:encoded_next %}
|
||||||
|
{% include "controlpanel/_modal_form.html" with modal_id=referee.pk|dom_id:"referee_fee_modal" title=fee_title form=referee.fee_form action_url=fee_action_url submit_label=save_label submit_icon="save" %}
|
||||||
|
{% endwith %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endwith %}
|
||||||
|
{% endif %}
|
||||||
@@ -128,6 +128,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 %}
|
||||||
|
|||||||
@@ -49,23 +49,23 @@
|
|||||||
<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">
|
||||||
<a class="btn btn-outline gap-2" href="{% if update_view %}{% url "management:event_detail" object.pk %}{% else %}{% url "management:event_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
|
<a class="btn btn-outline gap-2" href="{% if update_view %}{% url "management:event_detail" object.pk %}{% else %}{% url "management:event_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
|
||||||
|
|||||||
90
management/templates/management/event_referee_form_pdf.html
Normal file
90
management/templates/management/event_referee_form_pdf.html
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
Rendered by WeasyPrint -- modeled directly on the club's existing paper
|
||||||
|
referee payment form ("Leeg Document RefereesCompetitie25-26.pdf"): a
|
||||||
|
3-line header (club name/address), match details, referee names, a
|
||||||
|
payment sentence, a fee+km breakdown per referee, and blank signature
|
||||||
|
lines. Header uses club.official_name (legal_name if set, else name);
|
||||||
|
the payment sentence uses the plain club name -- the original document
|
||||||
|
does the same, using a longer legal form up top and a shorter one in the
|
||||||
|
body text.
|
||||||
|
{% endcomment %}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>{% trans "Referee form" %}</title>
|
||||||
|
<style>
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 25mm 20mm;
|
||||||
|
@bottom-center {
|
||||||
|
content: "{{ club.name }} — {% trans "referee form" %} — " counter(page) " / " counter(pages);
|
||||||
|
font-size: 8pt;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body { font-family: sans-serif; font-size: 10.5pt; color: #111; line-height: 1.5; }
|
||||||
|
.header { text-align: center; margin-bottom: 14mm; }
|
||||||
|
.header div { margin: 0; }
|
||||||
|
.field { margin-bottom: 4mm; }
|
||||||
|
.field .label { display: inline-block; min-width: 32mm; }
|
||||||
|
.fill-line { display: block; border-bottom: 1px solid #333; min-height: 5mm; margin: 2mm 0 4mm; }
|
||||||
|
.section-title { margin: 8mm 0 2mm; }
|
||||||
|
.fee-row { border-bottom: 1px solid #333; padding-bottom: 1mm; margin-bottom: 4mm; min-height: 5mm; }
|
||||||
|
.signature-block { margin-top: 12mm; }
|
||||||
|
.signature-line { border-bottom: 1px solid #333; min-height: 16mm; margin-top: 2mm; }
|
||||||
|
.collected-line { margin-top: 12mm; }
|
||||||
|
.collected-line .fill { display: inline-block; border-bottom: 1px solid #333; min-width: 90mm; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<div>{{ club.official_name }}</div>
|
||||||
|
{% if home_location %}
|
||||||
|
<div>{{ home_location.address }}</div>
|
||||||
|
<div>{{ home_location.zip_code }} {{ home_location.city }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<span class="label">{% trans "Regarding: Game" %}</span>
|
||||||
|
{% for team in event.teams.all %}{{ team.short_name }}{% if not forloop.last %}, {% endif %}{% endfor %}
|
||||||
|
{% if event.opponent %}{% trans "vs" %} {{ event.opponent }}{% endif %}
|
||||||
|
{% if event.external_game_id %}({% trans "ID" %}: {{ event.external_game_id }}){% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="field"><span class="label">{% trans "Date" %}</span> {{ event.start|date:"j F Y" }}</div>
|
||||||
|
<div class="field"><span class="label">{% trans "Time" %}</span> {{ event.start|date:"H:i" }}</div>
|
||||||
|
<div class="field">
|
||||||
|
<span class="label">{% trans "Referee names" %}</span>
|
||||||
|
{% for referee in referees %}{{ referee.display_name }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="fill-line"></div>
|
||||||
|
|
||||||
|
<p>{% blocktrans with name=club.name %}{{ name }} pays the fee to the referee(s) for officiating the game mentioned above.{% endblocktrans %}</p>
|
||||||
|
|
||||||
|
<div class="section-title">{% trans "Referees and fee, plus any kilometers" %}</div>
|
||||||
|
{% for referee in referees %}
|
||||||
|
<div class="fee-row">
|
||||||
|
{{ referee.display_name }} —
|
||||||
|
{% blocktrans with fee=referee.fee %}fee: €{{ fee }}{% endblocktrans %}
|
||||||
|
{% if referee.km %}
|
||||||
|
{% blocktrans with km=referee.km rate=referee.km_rate total=referee.km_total %}+ {{ km }} km × €{{ rate }}/km = €{{ total }}{% endblocktrans %}
|
||||||
|
{% endif %}
|
||||||
|
— {% blocktrans with total=referee.total_payable %}total: €{{ total }}{% endblocktrans %}
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="fee-row"></div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="signature-block">
|
||||||
|
<div>{% trans "Referee signature" %}</div>
|
||||||
|
<div class="signature-line"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="collected-line">
|
||||||
|
{% trans "Collected by team manager" %}: <span class="fill"> </span>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
65
management/templates/management/group_bulk_add.html
Normal file
65
management/templates/management/group_bulk_add.html
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
{% extends "management/base.html" %}
|
||||||
|
{% load i18n lucide %}
|
||||||
|
|
||||||
|
{% block heading %}{% trans "Add multiple members" %}{% endblock heading %}
|
||||||
|
{% block subheading %}{{ group.name }}{% endblock subheading %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
|
<form method="get" class="mb-4">
|
||||||
|
<div class="flex flex-row items-center gap-2">
|
||||||
|
<label class="input">
|
||||||
|
<span class="opacity-50">{% lucide "search" size=16 %}</span>
|
||||||
|
<input type="search" name="q" value="{{ search }}" placeholder="{% trans 'Search members ...' %}" class="input input-bordered">
|
||||||
|
</label>
|
||||||
|
<button class="btn btn-outline gap-2" type="submit">{% lucide "filter" size=16 %} {% trans "Filter" %}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="text-sm opacity-70 font-semibold">
|
||||||
|
{% blocktrans count counter=members|length %}{{ counter }} member{% plural %}{{ counter }} members{% endblocktrans %}
|
||||||
|
</span>
|
||||||
|
<button class="btn btn-primary btn-sm gap-2" type="submit">{% lucide "users" size=14 %} {% trans "Add selected" %}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th></th>
|
||||||
|
<th>{% trans "Member" %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for member in members %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{% if member.already_in_group %}
|
||||||
|
<span class="badge badge-neutral badge-sm">{% trans "In group" %}</span>
|
||||||
|
{% else %}
|
||||||
|
<input type="checkbox" class="checkbox" name="member_{{ member.pk }}">
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ member }}</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="2" class="text-center opacity-60">{% trans "No members match this search." %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<a class="btn btn-outline gap-2" href="{% url 'management:group_detail' group.pk %}">{% lucide "arrow-left" size=16 %} {% trans "Back" %}</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock panel %}
|
||||||
51
management/templates/management/group_detail.html
Normal file
51
management/templates/management/group_detail.html
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{% extends "management/base.html" %}
|
||||||
|
{% load i18n lucide ui %}
|
||||||
|
|
||||||
|
{% block heading %}{{ group.name }}{% endblock heading %}
|
||||||
|
|
||||||
|
{% block actions %}
|
||||||
|
<a class="btn btn-outline gap-2" href="{% url 'management:group_update' group.pk %}">{% lucide "pencil" size=16 %} {% trans "Edit" %}</a>
|
||||||
|
{% endblock actions %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="card-title text-base">{% trans "Members" %}</h2>
|
||||||
|
<a class="btn btn-outline btn-sm gap-2" href="{% url 'management:group_bulk_add' group.pk %}">{% lucide "users" size=14 %} {% trans "Add multiple" %}</a>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Member" %}</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for membership in memberships %}
|
||||||
|
<tr>
|
||||||
|
<td><a class="link link-hover" href="{% url 'management:member_detail' membership.member.pk %}">{{ membership.member }}</a></td>
|
||||||
|
<td class="text-right">
|
||||||
|
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ membership.pk|dom_id:"remove_member_modal" }}').showModal()">{% lucide "user-minus" size=14 %} {% trans "Remove" %}</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="2" class="text-center opacity-60">{% trans "No members yet." %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% trans "Remove from group" as remove_member_title %}
|
||||||
|
{% trans "Remove" as remove_label %}
|
||||||
|
{% for membership in memberships %}
|
||||||
|
{% blocktrans with member=membership.member group_name=group.name asvar remove_member_body %}Remove {{ member }} from “{{ group_name }}”?{% endblocktrans %}
|
||||||
|
{% url 'management:group_member_remove' group.pk membership.pk as remove_member_url %}
|
||||||
|
{% include "controlpanel/_confirm_modal.html" with modal_id=membership.pk|dom_id:"remove_member_modal" title=remove_member_title body=remove_member_body action_url=remove_member_url submit_label=remove_label submit_icon="user-minus" %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock panel %}
|
||||||
35
management/templates/management/group_form.html
Normal file
35
management/templates/management/group_form.html
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{% extends "management/base.html" %}
|
||||||
|
{% load i18n lucide static ui %}
|
||||||
|
|
||||||
|
{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New group" %}{% endif %}{% endblock heading %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
|
<div class="card w-full bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
{% for error in form.non_field_errors %}
|
||||||
|
<div class="alert alert-error my-2">
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{% for field in form %}
|
||||||
|
{% form_field field %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-actions justify-start pt-2 mt-2">
|
||||||
|
<a class="btn btn-outline gap-2" href="{% if update_view %}{% url "management:group_detail" object.pk %}{% else %}{% url "management:group_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
|
||||||
|
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock panel %}
|
||||||
|
|
||||||
|
{% block extra_body %}
|
||||||
|
<script src="{% static 'js/searchable-select.js' %}"></script>
|
||||||
|
{% endblock extra_body %}
|
||||||
53
management/templates/management/group_list.html
Normal file
53
management/templates/management/group_list.html
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
{% extends "management/base.html" %}
|
||||||
|
{% load i18n lucide ui %}
|
||||||
|
|
||||||
|
{% block heading %}{% trans "Groups" %}{% endblock heading %}
|
||||||
|
{% block subheading %}{% trans "Named collections of members -- coaches, team managers, referee pools, committees..." %}{% endblock subheading %}
|
||||||
|
|
||||||
|
{% block actions %}
|
||||||
|
<a class="btn btn-outline gap-2" href="{% url 'management:group_create' %}">{% lucide "plus" size=16 %} {% trans "New group" %}</a>
|
||||||
|
{% endblock actions %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Name" %}</th>
|
||||||
|
<th>{% trans "Members" %}</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for group in groups %}
|
||||||
|
<tr>
|
||||||
|
<td><a class="link link-hover" href="{% url 'management:group_detail' group.pk %}">{{ group.name }}</a></td>
|
||||||
|
<td>{{ group.member_count }}</td>
|
||||||
|
<td class="text-right">
|
||||||
|
<div class="flex justify-end gap-1">
|
||||||
|
<a class="btn btn-outline btn-sm" href="{% url 'management:group_update' group.pk %}" aria-label="{% trans 'Edit' %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
|
||||||
|
<button class="btn btn-sm btn-outline btn-error" type="button" onclick="document.getElementById('{{ group.pk|dom_id:"group_delete_modal" }}').showModal()" aria-label="{% trans 'Delete' %}">{% lucide "trash-2" size=14 %} {% trans "Delete" %}</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="text-center opacity-60">{% trans "No groups yet." %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% trans "Delete group" as delete_group_title %}
|
||||||
|
{% trans "Delete" as delete_label %}
|
||||||
|
{% for group in groups %}
|
||||||
|
{% url 'management:group_delete' group.pk as group_delete_url %}
|
||||||
|
{% blocktrans with name=group.name asvar delete_group_body %}Delete “{{ name }}”? Its members are removed from it, and any teams it's linked to as a referee source lose that link. This cannot be undone.{% endblocktrans %}
|
||||||
|
{% include "controlpanel/_confirm_modal.html" with modal_id=group.pk|dom_id:"group_delete_modal" title=delete_group_title body=delete_group_body action_url=group_delete_url submit_label=delete_label submit_icon="trash-2" %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock panel %}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "management/base.html" %}
|
{% 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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
35
management/templates/management/referee_level_form.html
Normal file
35
management/templates/management/referee_level_form.html
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{% extends "management/base.html" %}
|
||||||
|
{% load i18n lucide static ui %}
|
||||||
|
|
||||||
|
{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New referee level" %}{% endif %}{% endblock heading %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
|
<div class="card w-full bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
{% for error in form.non_field_errors %}
|
||||||
|
<div class="alert alert-error my-2">
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 items-start">
|
||||||
|
{% for field in form %}
|
||||||
|
{% form_field field %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-actions justify-start pt-2 mt-2">
|
||||||
|
<a class="btn btn-outline gap-2" href="{% url "management:referee_level_list" %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
|
||||||
|
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock panel %}
|
||||||
|
|
||||||
|
{% block extra_body %}
|
||||||
|
<script src="{% static 'js/searchable-select.js' %}"></script>
|
||||||
|
{% endblock extra_body %}
|
||||||
54
management/templates/management/referee_level_list.html
Normal file
54
management/templates/management/referee_level_list.html
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
{% extends "management/base.html" %}
|
||||||
|
{% load i18n lucide %}
|
||||||
|
|
||||||
|
{% block heading %}{% trans "Referee levels" %}{% endblock heading %}
|
||||||
|
{% block subheading %}{% trans "Which teams' home games each level qualifies a referee for." %}{% endblock subheading %}
|
||||||
|
|
||||||
|
{% block actions %}
|
||||||
|
{% if is_club_admin %}
|
||||||
|
<a class="btn btn-outline gap-2" href="{% url 'management:referee_level_create' %}">{% lucide "plus" size=16 %} {% trans "New level" %}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock actions %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Ordering" %}</th>
|
||||||
|
<th>{% trans "Name" %}</th>
|
||||||
|
<th>{% trans "Qualifies for" %}</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for level in levels %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ level.ordering }}</td>
|
||||||
|
<td>{{ level.name }}</td>
|
||||||
|
<td>
|
||||||
|
{% for team in level.teams.all %}
|
||||||
|
<span class="badge badge-outline badge-sm">{{ team.name }}</span>
|
||||||
|
{% empty %}
|
||||||
|
<span class="opacity-40">—</span>
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
<td class="text-right">
|
||||||
|
{% if is_club_admin %}
|
||||||
|
<a class="btn btn-outline btn-sm gap-2" href="{% url 'management:referee_level_update' level.pk %}">{% lucide "pencil" size=14 %} {% trans "Edit" %}</a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="text-center opacity-60">{% trans "No referee levels yet." %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock panel %}
|
||||||
60
management/templates/management/referee_list.html
Normal file
60
management/templates/management/referee_list.html
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
{% extends "management/base.html" %}
|
||||||
|
{% load i18n lucide %}
|
||||||
|
|
||||||
|
{% block heading %}{% trans "Referees" %}{% endblock heading %}
|
||||||
|
{% block subheading %}{% trans "Every club referee, their level, and whether their qualification is currently valid." %}{% endblock subheading %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Referee" %}</th>
|
||||||
|
<th>{% trans "Level" %}</th>
|
||||||
|
<th>{% trans "Can referee for" %}</th>
|
||||||
|
<th>{% trans "Valid until" %}</th>
|
||||||
|
<th>{% trans "Status" %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for referee in referees %}
|
||||||
|
<tr>
|
||||||
|
<td><a class="link link-hover" href="{% url 'management:member_detail' referee.pk %}">{{ referee }}</a></td>
|
||||||
|
<td>{{ referee.referee_profile.level|default:"—" }}</td>
|
||||||
|
<td>
|
||||||
|
{% if referee.referee_profile.is_eligible %}
|
||||||
|
{% for team in referee.referee_profile.eligible_teams %}
|
||||||
|
<span class="badge badge-outline badge-sm">{{ team.name }}</span>
|
||||||
|
{% empty %}
|
||||||
|
<span class="opacity-40">—</span>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<span class="opacity-40">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ referee.referee_profile.valid_until|default:"—" }}</td>
|
||||||
|
<td>
|
||||||
|
{% if referee.referee_profile.is_eligible %}
|
||||||
|
<span class="badge badge-success badge-sm">{% trans "Valid" %}</span>
|
||||||
|
{% elif not referee.referee_profile.level %}
|
||||||
|
<span class="badge badge-warning badge-sm">{% trans "No level" %}</span>
|
||||||
|
{% elif not referee.referee_profile.valid_until %}
|
||||||
|
<span class="badge badge-warning badge-sm">{% trans "No validity set" %}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-error badge-sm">{% trans "Expired" %}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="text-center opacity-60">{% trans "No referees yet -- set a level and validity from a member's own page." %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock panel %}
|
||||||
99
management/templates/management/referee_management.html
Normal file
99
management/templates/management/referee_management.html
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
{% extends "management/base.html" %}
|
||||||
|
{% load i18n lucide ui %}
|
||||||
|
|
||||||
|
{% block heading %}{% trans "Referee management" %}{% endblock heading %}
|
||||||
|
{% block subheading %}{% trans "Every upcoming home game that needs a club-arranged referee." %}{% endblock subheading %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
|
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<div class="card bg-base-100 shadow border-l-4 border-info">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "calendar" size=16 %} {% trans "Games in view" %}</div>
|
||||||
|
<div class="text-4xl font-bold tabular-nums font-mono">{{ kpi_total }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card bg-base-100 shadow border-l-4 {% if kpi_no_referee %}border-error{% else %}border-success{% endif %}">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "circle-alert" size=16 %} {% trans "Without a referee" %}</div>
|
||||||
|
<div class="text-4xl font-bold tabular-nums font-mono">{{ kpi_no_referee }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card bg-base-100 shadow border-l-4 {% if kpi_understaffed %}border-warning{% else %}border-success{% endif %}">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "circle-dashed" size=16 %} {% trans "Partially staffed" %}</div>
|
||||||
|
<div class="text-4xl font-bold tabular-nums font-mono">{{ kpi_understaffed }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card bg-base-100 shadow border-l-4 border-success">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="flex items-center gap-2 text-sm opacity-70 mb-3">{% lucide "circle-check" size=16 %} {% trans "Fully staffed" %}</div>
|
||||||
|
<div class="text-4xl font-bold tabular-nums font-mono">{{ kpi_fully_staffed }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="join mb-4 w-full flex-wrap">
|
||||||
|
<a href="?range=week" class="btn join-item flex-1 {% if range_choice == "week" %}btn-primary{% endif %}">{% trans "This week" %}</a>
|
||||||
|
<a href="?range=two_weeks" class="btn join-item flex-1 {% if range_choice == "two_weeks" %}btn-primary{% endif %}">{% trans "This + next week" %}</a>
|
||||||
|
<a href="?range=10" class="btn join-item flex-1 {% if range_choice == "10" %}btn-primary{% endif %}">{% trans "Next 10" %}</a>
|
||||||
|
<a href="?range=25" class="btn join-item flex-1 {% if range_choice == "25" %}btn-primary{% endif %}">{% trans "Next 25" %}</a>
|
||||||
|
<a href="?range=50" class="btn join-item flex-1 {% if range_choice == "50" %}btn-primary{% endif %}">{% trans "Next 50" %}</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% regroup games by start.date as day_groups %}
|
||||||
|
{% for day in day_groups %}
|
||||||
|
<h3 class="font-semibold text-sm opacity-70 mt-4 mb-2">{{ day.grouper|date:"l j F" }}</h3>
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 mb-2">
|
||||||
|
{% for game in day.list %}
|
||||||
|
<div class="card bg-base-100 shadow-sm border {% if not game.referee_rows %}border-error{% elif not game.referees_full %}border-warning{% else %}border-base-300{% endif %}">
|
||||||
|
<div class="card-body p-4 gap-2">
|
||||||
|
<a class="link link-hover font-semibold" href="{% url 'management:event_detail' game.pk %}">
|
||||||
|
{% for team in game.teams.all %}{{ team.short_name }}{% if not forloop.last %}, {% endif %}{% endfor %}
|
||||||
|
{% if game.opponent %}{% trans "vs" %} {{ game.opponent }}{% endif %}
|
||||||
|
</a>
|
||||||
|
<div class="text-xs opacity-70">{{ game.start|date:"H:i" }}{% if game.location %} — {{ game.location }}{% endif %}</div>
|
||||||
|
<div class="text-xs">
|
||||||
|
{% for referee in game.referee_rows %}
|
||||||
|
<span class="badge badge-ghost badge-sm">{{ referee.display_name }}{% if referee.is_external %} ({% trans "ext." %}){% endif %}</span>
|
||||||
|
{% empty %}
|
||||||
|
<span class="opacity-60">{% trans "No referees assigned yet." %}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="badge badge-sm {% if not game.referee_rows %}badge-error{% elif not game.referees_full %}badge-warning{% else %}badge-success{% endif %}">
|
||||||
|
{{ game.referee_rows|length }} / {{ game.max_referees }}
|
||||||
|
</span>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<a class="btn btn-xs btn-outline" href="{% url 'management:event_referee_form_pdf' game.pk %}">
|
||||||
|
{% lucide "file-down" size=12 %} {% trans "Referee form" %}
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-xs btn-outline" type="button" onclick="document.getElementById('{{ game.pk|dom_id:"referee_game_modal" }}').showModal()">
|
||||||
|
{% lucide "flag" size=12 %} {% trans "Manage" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dialog id="{{ game.pk|dom_id:"referee_game_modal" }}" class="modal">
|
||||||
|
<div class="modal-box max-w-2xl">
|
||||||
|
<form method="dialog"><button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button></form>
|
||||||
|
<h3 class="font-bold text-lg mb-1">
|
||||||
|
{% for team in game.teams.all %}{{ team.short_name }}{% if not forloop.last %}, {% endif %}{% endfor %}
|
||||||
|
{% if game.opponent %}{% trans "vs" %} {{ game.opponent }}{% endif %}
|
||||||
|
</h3>
|
||||||
|
<div class="text-sm opacity-70 mb-4">{{ game.start|date:"j M Y H:i" }}</div>
|
||||||
|
{% include "management/_referee_assignment_panel.html" with event=game referees=game.referee_rows referee_candidates=game.referee_candidates referees_full=game.referees_full can_manage_referees=True next_url=request.get_full_path %}
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
||||||
|
</dialog>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-center opacity-60">{% trans "No upcoming home games need a club-arranged referee." %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock panel %}
|
||||||
125
management/templates/management/team_bulk_add.html
Normal file
125
management/templates/management/team_bulk_add.html
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
{% extends "management/base.html" %}
|
||||||
|
{% load i18n lucide %}
|
||||||
|
|
||||||
|
{% block heading %}{% trans "Add multiple people" %}{% endblock heading %}
|
||||||
|
{% block subheading %}{{ team.name }}{% endblock subheading %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
|
<form method="get" class="mb-4">
|
||||||
|
<div class="flex flex-row items-center gap-2">
|
||||||
|
<label class="input">
|
||||||
|
<span class="opacity-50">{% lucide "search" size=16 %}</span>
|
||||||
|
<input type="search" name="q" value="{{ search }}" placeholder="{% trans 'Search members ...' %}" class="input input-bordered">
|
||||||
|
</label>
|
||||||
|
<button class="btn btn-outline gap-2" type="submit">{% lucide "filter" size=16 %} {% trans "Filter" %}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="text-sm opacity-70 font-semibold">
|
||||||
|
{% blocktrans count counter=members|length %}{{ counter }} eligible member{% plural %}{{ counter }} eligible members{% endblocktrans %}
|
||||||
|
</span>
|
||||||
|
<button class="btn btn-primary btn-sm gap-2" type="submit">{% lucide "users" size=14 %} {% trans "Add selected" %}</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm opacity-70 mb-2">
|
||||||
|
{% blocktrans %}Only members who are active (paid up) for this club this season or next are eligible. Tick "Player" and/or "Staff" for anyone you want to add -- both at once works too (a playing coach, say).{% endblocktrans %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Member" %}</th>
|
||||||
|
<th>{% trans "Player" %}</th>
|
||||||
|
<th>{% trans "Position" %}</th>
|
||||||
|
<th>{% trans "Jersey #" %}</th>
|
||||||
|
<th>{% trans "Staff" %}</th>
|
||||||
|
<th>{% trans "Position" %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for member in members %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ member }}</td>
|
||||||
|
<td>
|
||||||
|
{% if member.already_player %}
|
||||||
|
<span class="badge badge-neutral badge-sm">{% trans "On roster" %}</span>
|
||||||
|
{% else %}
|
||||||
|
<input type="checkbox" class="checkbox player-checkbox" name="player_{{ member.pk }}" data-member="{{ member.pk }}">
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if not member.already_player %}
|
||||||
|
<select name="player_position_{{ member.pk }}" class="select select-bordered select-sm player-position" data-member="{{ member.pk }}" disabled>
|
||||||
|
<option value="">{% trans "—" %}</option>
|
||||||
|
{% for position in player_positions %}
|
||||||
|
<option value="{{ position.pk }}">{{ position.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if not member.already_player %}
|
||||||
|
<input type="number" min="0" name="jersey_{{ member.pk }}" class="input input-bordered input-sm w-20 player-jersey" data-member="{{ member.pk }}" disabled>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if member.already_staff %}
|
||||||
|
<span class="badge badge-neutral badge-sm">{% trans "On staff" %}</span>
|
||||||
|
{% else %}
|
||||||
|
<input type="checkbox" class="checkbox staff-checkbox" name="staff_{{ member.pk }}" data-member="{{ member.pk }}">
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if not member.already_staff %}
|
||||||
|
<select name="staff_position_{{ member.pk }}" class="select select-bordered select-sm staff-position" data-member="{{ member.pk }}" disabled>
|
||||||
|
<option value="">{% trans "—" %}</option>
|
||||||
|
{% for position in staff_positions %}
|
||||||
|
<option value="{{ position.pk }}">{{ position.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="text-center opacity-60">{% trans "No eligible members match this search." %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<a class="btn btn-outline gap-2" href="{% url 'management:team_detail' team.pk %}?season={{ season.pk }}">{% lucide "arrow-left" size=16 %} {% trans "Back" %}</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock panel %}
|
||||||
|
|
||||||
|
{% block extra_body %}
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const pairs = [
|
||||||
|
{checkboxClass: "player-checkbox", fieldClasses: ["player-position", "player-jersey"]},
|
||||||
|
{checkboxClass: "staff-checkbox", fieldClasses: ["staff-position"]},
|
||||||
|
];
|
||||||
|
|
||||||
|
pairs.forEach(({checkboxClass, fieldClasses}) => {
|
||||||
|
document.querySelectorAll(`.${checkboxClass}`).forEach((checkbox) => {
|
||||||
|
const memberId = checkbox.dataset.member;
|
||||||
|
const fields = fieldClasses.flatMap((cls) => Array.from(document.querySelectorAll(`.${cls}[data-member="${memberId}"]`)));
|
||||||
|
checkbox.addEventListener("change", () => {
|
||||||
|
fields.forEach((field) => { field.disabled = !checkbox.checked; });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock extra_body %}
|
||||||
@@ -149,7 +149,10 @@
|
|||||||
<div class="flex items-center justify-between">
|
<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 %}
|
||||||
|
<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>
|
<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 %}
|
||||||
|
<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>
|
<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">
|
||||||
|
|||||||
1085
management/tests.py
1085
management/tests.py
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@ urlpatterns = [
|
|||||||
path("members/<uuid:pk>/delete/", views.MemberDeleteView.as_view(), name="member_delete"),
|
path("members/<uuid:pk>/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"),
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
54
members/migrations/0004_group_groupmembership_and_more.py
Normal file
54
members/migrations/0004_group_groupmembership_and_more.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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}/")
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
teams/migrations/0009_team_referee_management.py
Normal file
18
teams/migrations/0009_team_referee_management.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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
17
teams/services.py
Normal 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()
|
||||||
101
teams/tests.py
101
teams/tests.py
@@ -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())
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
{% for authenticator in authenticators %}
|
{% for authenticator in authenticators %}
|
||||||
{% element tr %}
|
{% element tr %}
|
||||||
{% element td %}
|
{% element td %}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
{{ authenticator }}
|
{{ authenticator }}
|
||||||
{% if authenticator.wrap.is_passwordless is True %}
|
{% if authenticator.wrap.is_passwordless is True %}
|
||||||
{% element badge tags="mfa,key,primary" %}
|
{% element badge tags="mfa,key,primary" %}
|
||||||
@@ -41,6 +42,7 @@
|
|||||||
{% translate "Unspecified" %}
|
{% translate "Unspecified" %}
|
||||||
{% endelement %}
|
{% endelement %}
|
||||||
{% endif %}
|
{% 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,6 +53,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endelement %}
|
{% endelement %}
|
||||||
{% element td align="right" %}
|
{% element td align="right" %}
|
||||||
|
<div class="flex items-center justify-end gap-2">
|
||||||
{% url 'mfa_edit_webauthn' pk=authenticator.pk as edit_url %}
|
{% url 'mfa_edit_webauthn' pk=authenticator.pk as edit_url %}
|
||||||
{% element button href=edit_url icon="pencil" tags="mfa,authenticator,edit,tool" %}
|
{% element button href=edit_url icon="pencil" tags="mfa,authenticator,edit,tool" %}
|
||||||
{% translate "Edit" %}
|
{% translate "Edit" %}
|
||||||
@@ -59,6 +62,7 @@
|
|||||||
{% element button href=remove_url icon="trash-2" tags="mfa,authenticator,danger,delete,tool" %}
|
{% element button href=remove_url icon="trash-2" tags="mfa,authenticator,danger,delete,tool" %}
|
||||||
{% translate "Remove" %}
|
{% translate "Remove" %}
|
||||||
{% endelement %}
|
{% endelement %}
|
||||||
|
</div>
|
||||||
{% endelement %}
|
{% endelement %}
|
||||||
{% endelement %}
|
{% endelement %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
Reference in New Issue
Block a user