diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index fd19abb..72ddccf 100644 --- a/ARCHITECTURE.md +++ b/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 "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`** — **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 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` ``` @@ -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 `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 `` 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) **`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 ├───< 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 │ └───> Team (nullable) + ├───< EventReferee >─── Event (assigned_by another Member; home games only) │ ├───< 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 anchor, mutable total)? Doc implements checkout-date; confirm no club needs the literal "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. --- diff --git a/Leeg Document RefereesCompetitie25-26.pdf b/Leeg Document RefereesCompetitie25-26.pdf new file mode 100644 index 0000000..aecb772 Binary files /dev/null and b/Leeg Document RefereesCompetitie25-26.pdf differ diff --git a/billing/templates/billing/invoice.html b/billing/templates/billing/invoice.html index fa10404..8ecc632 100644 --- a/billing/templates/billing/invoice.html +++ b/billing/templates/billing/invoice.html @@ -52,7 +52,7 @@

Billed to

-
{{ club.name }}
+
{{ club.official_name }}
{{ club.slug }}.rosterchief.app
diff --git a/billing/tests.py b/billing/tests.py index 0de882f..7d6ac0a 100644 --- a/billing/tests.py +++ b/billing/tests.py @@ -334,6 +334,17 @@ class InvoiceTests(BillingTestBase): self.assertIn("200.00", html) # paid 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): # WeasyPrint binds to native pango/cairo. The app, the tests and every other page must # run without them; only this call may fail. diff --git a/club/migrations/0021_club_legal_name.py b/club/migrations/0021_club_legal_name.py new file mode 100644 index 0000000..2c08364 --- /dev/null +++ b/club/migrations/0021_club_legal_name.py @@ -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'), + ), + ] diff --git a/club/models.py b/club/models.py index 982ba7b..8002828 100644 --- a/club/models.py +++ b/club/models.py @@ -39,6 +39,7 @@ class Club(UUIDModel): OTHER = "other", _("Other") 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).")) logo = models.FileField( @@ -107,6 +108,12 @@ class Club(UUIDModel): def is_archived(self) -> bool: 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 def initials(self) -> str: """Stand-in for a missing logo. Never the RosterChief mark — that would diff --git a/controlpanel/forms.py b/controlpanel/forms.py index 9497366..3ba8a81 100644 --- a/controlpanel/forms.py +++ b/controlpanel/forms.py @@ -14,7 +14,7 @@ from .services.admins import find_member_by_email class ClubForm(forms.ModelForm): class Meta: 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.")} # Deliberately a text input, not : a colour picker cannot # express "no colour" -- it would submit #000000 for every club that never diff --git a/controlpanel/templates/controlpanel/club_detail.html b/controlpanel/templates/controlpanel/club_detail.html index 384699d..bfc84cd 100644 --- a/controlpanel/templates/controlpanel/club_detail.html +++ b/controlpanel/templates/controlpanel/club_detail.html @@ -27,7 +27,7 @@ {% block heading %}{{ club.name }}{% endblock heading %} {% 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 %} Archived {% endif %} diff --git a/controlpanel/templates/controlpanel/club_form.html b/controlpanel/templates/controlpanel/club_form.html index 6aa9878..ada8eb5 100644 --- a/controlpanel/templates/controlpanel/club_form.html +++ b/controlpanel/templates/controlpanel/club_form.html @@ -15,8 +15,9 @@
{% endfor %} -
+
{% form_field form.name %} + {% form_field form.legal_name %} {% form_field form.slug %} {% form_field form.sport_type %}
diff --git a/controlpanel/templates/templatetags/field.html b/controlpanel/templates/templatetags/field.html index db9360b..67dc2a3 100644 --- a/controlpanel/templates/templatetags/field.html +++ b/controlpanel/templates/templatetags/field.html @@ -1,6 +1,6 @@ {% load i18n %} -
+
{% if show_label %}
diff --git a/events/admin.py b/events/admin.py index 048db51..855ab21 100644 --- a/events/admin.py +++ b/events/admin.py @@ -2,7 +2,7 @@ from django import forms from django.contrib import admin 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) @@ -25,6 +25,12 @@ class AttendanceInline(admin.TabularInline): raw_id_fields = ["member"] +class EventRefereeInline(admin.TabularInline): + model = EventReferee + extra = 0 + raw_id_fields = ["member", "assigned_by"] + + @admin.register(EventSeries) class EventSeriesAdmin(admin.ModelAdmin): list_display = ["title", "kind", "rrule", "dtstart", "until", "generated_until", "club"] @@ -52,7 +58,7 @@ class EventAdminForm(forms.ModelForm): fields = [ "title", "kind", "season", "series", "detached", "cancelled", "teams", "invited_members", "excluded_members", "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): @@ -77,14 +83,14 @@ class EventAdmin(admin.ModelAdmin): search_fields = ["title"] date_hierarchy = "start" autocomplete_fields = ["season", "series", "location", "opponent", "teams", "invited_members", "excluded_members"] - inlines = [AttendanceInline] + inlines = [AttendanceInline, EventRefereeInline] fieldsets = [ [None, {"fields": ["title", "kind", "season"]}], [_("Series"), {"fields": ["series", "detached", "cancelled"]}], [_("Audience"), {"fields": ["teams", "invited_members", "excluded_members"]}], [_("When"), {"fields": ["start", "end", "gathering", "deadline"]}], [_("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"] search_fields = ["event__title", "member__first_name", "member__last_name"] 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 diff --git a/events/migrations/0020_event_max_referees_eventreferee.py b/events/migrations/0020_event_max_referees_eventreferee.py new file mode 100644 index 0000000..bcb3704 --- /dev/null +++ b/events/migrations/0020_event_max_referees_eventreferee.py @@ -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')], + }, + ), + ] diff --git a/events/migrations/0021_eventreferee_external_name_eventreferee_fee_and_more.py b/events/migrations/0021_eventreferee_external_name_eventreferee_fee_and_more.py new file mode 100644 index 0000000..0875afc --- /dev/null +++ b/events/migrations/0021_eventreferee_external_name_eventreferee_fee_and_more.py @@ -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'), + ), + ] diff --git a/events/models.py b/events/models.py index a8f0204..bdc8900 100644 --- a/events/models.py +++ b/events/models.py @@ -1,3 +1,5 @@ +from decimal import Decimal + from django.conf import settings from django.db import models from django.db.models import Q @@ -86,6 +88,8 @@ class Event(ClubScopedModel): 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.")) + 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: verbose_name = _("event") verbose_name_plural = _("events") @@ -172,6 +176,65 @@ class Attendance(UUIDModel): 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): """A competition has a name with a specific URL to fetch data from. These are managed centrally.""" diff --git a/events/services/referees.py b/events/services/referees.py new file mode 100644 index 0000000..cccb922 --- /dev/null +++ b/events/services/referees.py @@ -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 diff --git a/events/tests.py b/events/tests.py index 420c088..637b942 100644 --- a/events/tests.py +++ b/events/tests.py @@ -1,4 +1,5 @@ from datetime import timedelta +from decimal import Decimal from io import StringIO from django.core.exceptions import ValidationError @@ -10,10 +11,10 @@ from waffle import get_waffle_flag_model from club.models import Club, Season 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 .models import Attendance, Competition, Event, EventSeries, Location, Opponent +from .models import Attendance, Competition, Event, EventReferee, EventSeries, Location, Opponent from .services import ( cancel_occurrence, detach_occurrence, @@ -28,6 +29,7 @@ from .services import ( team_no_shows, ) 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): @@ -893,3 +895,273 @@ class RBIHFImportPlanTests(EventsTestBase): self.assertEqual(result, {"created": 0, "updated": 0, "deleted": 1}) 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")) diff --git a/management/bulk_import.py b/management/bulk_import.py index 4715219..d0efc85 100644 --- a/management/bulk_import.py +++ b/management/bulk_import.py @@ -15,14 +15,22 @@ from django.utils.translation import gettext_lazy as _ from openpyxl.worksheet.datavalidation import DataValidation from club.models import ClubMembership -from members.models import Member +from members.models import FamilyMembership, Member from .forms import MemberForm -TEMPLATE_COLUMNS = ["first_name", "last_name", "date_of_birth", "email", "phone", "emergency_phone", "license", "status", "fee_status"] +TEMPLATE_COLUMNS = ["first_name", "last_name", "date_of_birth", "email", "phone", "emergency_phone", "license", "status", "fee_status", "family_group", "family_role"] REQUIRED_HEADER_COLUMNS = {"first_name", "last_name"} -TEMPLATE_EXAMPLE_ROW = ["Alex", "Morgan", date(2012, 5, 14), "alex.morgan@example.com", "+32470123456", "+32470654321", "", ClubMembership.StatusChoices.ACTIVE, ClubMembership.FeeStatus.UNPAID] +TEMPLATE_EXAMPLE_ROWS = [ + # A standalone member -- no family link. + ["Alex", "Morgan", date(2012, 5, 14), "alex.morgan@example.com", "+32470123456", "+32470654321", "", ClubMembership.StatusChoices.ACTIVE, ClubMembership.FeeStatus.UNPAID, "", ""], + # A parent and child linked together: same family_group value, one row each. + # The child has no email of its own -- it gets a login only if given one via + # the "Grant login" action later, same as adding a family by hand. + ["Taylor", "Doe", "", "taylor.doe@example.com", "+32470654322", "", "", ClubMembership.StatusChoices.ACTIVE, ClubMembership.FeeStatus.UNPAID, "Doe family", FamilyMembership.FamilyRole.PARENT], + ["Jamie", "Doe", date(2014, 3, 2), "", "", "+32470654322", "", ClubMembership.StatusChoices.ACTIVE, ClubMembership.FeeStatus.UNPAID, "Doe family", FamilyMembership.FamilyRole.CHILD], +] def build_member_import_template(): @@ -37,9 +45,10 @@ def build_member_import_template(): cell.font = openpyxl.styles.Font(bold=True) sheet.freeze_panes = "A2" - sheet.append(TEMPLATE_EXAMPLE_ROW) + for example_row in TEMPLATE_EXAMPLE_ROWS: + sheet.append(example_row) - for column_name, choices in (("status", ClubMembership.StatusChoices), ("fee_status", ClubMembership.FeeStatus)): + for column_name, choices in (("status", ClubMembership.StatusChoices), ("fee_status", ClubMembership.FeeStatus), ("family_role", FamilyMembership.FamilyRole)): column_index = TEMPLATE_COLUMNS.index(column_name) + 1 column_letter = sheet.cell(row=1, column=column_index).column_letter options = ",".join(choices.values) @@ -128,7 +137,20 @@ def parse_member_import_rows(rows, club): membership_kwargs, status_fee_errors = _parse_membership_fields(raw) errors.extend(status_fee_errors) - results.append({"line_number": line_number, "raw": raw, "member": member if not errors else None, "membership_kwargs": membership_kwargs, "errors": errors}) + family_group, family_role, family_errors = _parse_family_fields(raw) + errors.extend(family_errors) + + results.append( + { + "line_number": line_number, + "raw": raw, + "member": member if not errors else None, + "membership_kwargs": membership_kwargs, + "family_group": family_group, + "family_role": family_role, + "errors": errors, + } + ) return results @@ -156,6 +178,32 @@ def _parse_membership_fields(raw): return {"license": license_number, "status": status_value, "fee_status": fee_status_value}, errors +def _parse_family_fields(raw): + """family_group is a freeform label -- rows sharing the same non-blank value + (within this file only; it isn't matched against families already in the + club) are linked into one family. family_role decides whether the row gets a + login: PARENT/GUARDIAN do (via their email), CHILD/OTHER don't -- same as + registering a family by hand (see members.services.family).""" + errors = [] + family_group = raw.get("family_group", "").strip() + family_role_raw = raw.get("family_role", "").strip() + + if not family_group: + if family_role_raw: + errors.append(_("family_role given without a family_group.")) + return "", None, errors + + if not family_role_raw: + errors.append(_("family_role is required when family_group is set.")) + return family_group, None, errors + + family_role = _match_choice(family_role_raw, FamilyMembership.FamilyRole) + if family_role is None: + errors.append(_("Invalid family_role '%(value)s'.") % {"value": family_role_raw}) + + return family_group, family_role, errors + + def _match_choice(value, choices): for choice_value in choices.values: if choice_value.lower() == value.lower(): diff --git a/management/context_processors.py b/management/context_processors.py index 7201b70..63905fa 100644 --- a/management/context_processors.py +++ b/management/context_processors.py @@ -29,6 +29,7 @@ _NAV_SECTIONS = { "member_delete": "member_list", "member_attach_family": "member_list", "member_grant_login": "member_list", + "member_referee_eligibility_update": "member_list", "member_detach_family": "member_list", "family_create": "member_list", "family_detail": "member_list", @@ -46,12 +47,20 @@ _NAV_SECTIONS = { "role_list": "role_list", "role_create": "role_list", "role_revoke": "role_list", + "group_list": "group_list", + "group_create": "group_list", + "group_detail": "group_list", + "group_update": "group_list", + "group_delete": "group_list", + "group_bulk_add": "group_list", + "group_member_remove": "group_list", "team_list": "team_list", "team_create": "team_list", "team_update": "team_list", "team_delete": "team_list", "team_detail": "team_list", "team_roster_add": "team_list", + "team_bulk_add": "team_list", "team_roster_update": "team_list", "team_roster_remove": "team_list", "team_staff_add": "team_list", @@ -59,6 +68,11 @@ _NAV_SECTIONS = { "team_staff_remove": "team_list", "team_photo_set": "team_list", "team_photo_delete": "team_list", + "referee_list": "referee_list", + "referee_management": "referee_management", + "referee_level_list": "referee_level_list", + "referee_level_create": "referee_level_list", + "referee_level_update": "referee_level_list", "news_list": "news_list", "news_create": "news_list", "news_detail": "news_list", @@ -76,6 +90,11 @@ _NAV_SECTIONS = { "event_delete": "event_list", "event_detach": "event_list", "event_fetch_game_info": "event_list", + "event_referee_assign": "event_list", + "event_referee_add_external": "event_list", + "event_referee_remove": "event_list", + "event_referee_fee_update": "event_list", + "event_referee_form_pdf": "event_list", "rbihf_import": "event_list", "rbihf_import_confirm": "event_list", "event_series_create": "event_list", diff --git a/management/forms.py b/management/forms.py index cebe039..ee6d883 100644 --- a/management/forms.py +++ b/management/forms.py @@ -6,14 +6,15 @@ from django.contrib.auth import get_user_model from django.utils import timezone from django.utils.translation import gettext_lazy as _ -from club.models import ClubMembership, ClubRole, FeePayment, Season, Sponsor +from club.models import ClubMembership, ClubRole, FeePayment, Sponsor from club.services.access import is_club_admin, teams_managed_by -from events.models import Competition, Event, EventSeries, Location, Opponent +from events.models import Competition, Event, EventReferee, EventSeries, Location, Opponent from events.services.rbihf_import import RBIHFImportError, extract_team_id -from members.models import Family, FamilyMembership, Member +from members.models import Family, FamilyMembership, Group, Member from members.services.family import find_member_by_email from news.models import News -from teams.models import Position, StaffAssignment, Team, TeamMembership, TeamPhoto +from teams.models import Position, RefereeLevel, RefereeProfile, StaffAssignment, Team, TeamMembership, TeamPhoto +from teams.services import eligible_roster_members from .recurrence_ui import FREQUENCY_CHOICES, WEEKDAY_CHOICES, build_rrule, parse_rrule @@ -30,7 +31,7 @@ class MemberForm(forms.ModelForm): class TeamForm(forms.ModelForm): class Meta: model = Team - fields = ["name", "short_name"] + fields = ["name", "short_name", "referee_management"] class TeamPhotoForm(forms.ModelForm): @@ -43,6 +44,74 @@ class TeamPhotoForm(forms.ModelForm): fields = ["image"] +class GroupForm(forms.ModelForm): + """A generic named collection of members -- nothing team- or + referee-specific here; see MemberRefereeEligibilityForm for that.""" + + class Meta: + model = Group + fields = ["name"] + + +class RefereeLevelForm(forms.ModelForm): + class Meta: + model = RefereeLevel + fields = ["name", "ordering", "teams"] + widgets = {"teams": forms.SelectMultiple(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a team to search...")})} + + def __init__(self, *args, club=None, **kwargs): + super().__init__(*args, **kwargs) + self.fields["teams"].queryset = Team.objects.filter(club=club) + + +class MemberRefereeEligibilityForm(forms.ModelForm): + """This member's referee level and how long it's valid for -- edited from + the member's own page (teams.RefereeProfile), get-or-created on save. + Which teams that translates to is derived entirely from the level + (RefereeLevel.teams), not picked here -- see RefereeProfile.eligible_teams.""" + + class Meta: + model = RefereeProfile + fields = ["level", "valid_until"] + widgets = {"valid_until": forms.DateInput(attrs={"type": "date"})} + + def __init__(self, *args, club=None, member=None, **kwargs): + instance = RefereeProfile.objects.filter(member=member).first() if member is not None else None + super().__init__(*args, instance=instance, **kwargs) + # UUIDModel PKs get their default at construction time (not at save()), + # so a brand-new instance already has a non-None pk -- "is this new" + # can't be read off self.instance.pk. Setting member here (rather than + # in save()) means it's already correct before validation runs too. + if instance is None: + self.instance.member = member + self.fields["level"].queryset = RefereeLevel.objects.filter(club=club) + self.fields["level"].required = False + self.fields["level"].empty_label = _("— no level (not eligible) —") + + +class ExternalRefereeForm(forms.Form): + """Log a non-member referee (federation-appointed, most often) by name + only -- see events.services.referees.add_external_referee.""" + + name = forms.CharField(label=_("Referee name")) + + +class EventRefereeFeeForm(forms.ModelForm): + """One referee assignment's payment details -- see + events.services.referees.set_referee_fee. `km`/`km_rate` are left blank + (not defaulted to 0) whenever no travel was logged, so the PDF export can + tell "no kilometers" from "zero kilometers claimed".""" + + class Meta: + model = EventReferee + fields = ["fee", "km", "km_rate"] + widgets = { + "fee": forms.NumberInput(attrs={"step": "0.01", "min": "0"}), + "km": forms.NumberInput(attrs={"step": "0.1", "min": "0"}), + "km_rate": forms.NumberInput(attrs={"step": "any", "min": "0"}), + } + + class TeamMembershipForm(forms.ModelForm): """Add/edit one roster entry -- team and season come from the view (the URL already identifies both), never from the form itself.""" @@ -56,13 +125,11 @@ class TeamMembershipForm(forms.ModelForm): super().__init__(*args, **kwargs) self.team = team self.season = season - # Eligible to be added regardless of which season's roster is being edited - # (the team detail page's season switcher can be pointed at an older - # season): active this season or the next one, not lapsed/pending/cancelled - # or active only in some other season. - today = timezone.localdate() - eligible_seasons = [s for s in (Season.covering(club, today), Season.next_after(club, today)) if s is not None] - members = Member.objects.filter(member_of__club=club, member_of__season__in=eligible_seasons, member_of__status=ClubMembership.StatusChoices.ACTIVE).distinct() + # eligible_roster_members already covers "active this season or the next + # one, not lapsed/pending/cancelled or active only in some other season" -- + # eligible regardless of which season's roster is being edited (the team + # detail page's season switcher can be pointed at an older season). + members = eligible_roster_members(club) if team is not None and season is not None: # Already on this team's roster this season -- offering them again # would just fail the unique_member_per_team_per_season constraint. @@ -99,9 +166,7 @@ class StaffAssignmentForm(forms.ModelForm): super().__init__(*args, **kwargs) # See TeamMembershipForm for why current-or-next-season: eligible to be # assigned regardless of which season's staff list is being edited. - today = timezone.localdate() - eligible_seasons = [s for s in (Season.covering(club, today), Season.next_after(club, today)) if s is not None] - members = Member.objects.filter(member_of__club=club, member_of__season__in=eligible_seasons, member_of__status=ClubMembership.StatusChoices.ACTIVE).distinct() + members = eligible_roster_members(club) if team is not None and season is not None: taken = StaffAssignment.objects.filter(team=team, season=season).exclude(pk=self.instance.pk).values_list("member_id", flat=True) members = members.exclude(pk__in=taken) @@ -194,7 +259,7 @@ _AUDIENCE_WIDGETS = { class EventForm(EventAudienceFormMixin, forms.ModelForm): class Meta: model = Event - fields = ["title", "kind", "teams", "invited_members", "excluded_members", "location", "opponent", "start", "end", "gathering", "deadline", "competition", "external_game_id", "score_for", "score_against", "is_live"] + fields = ["title", "kind", "teams", "invited_members", "excluded_members", "location", "opponent", "start", "end", "gathering", "deadline", "competition", "external_game_id", "score_for", "score_against", "is_live", "max_referees"] widgets = { "start": forms.DateTimeInput(attrs={"type": "datetime-local"}), "end": forms.DateTimeInput(attrs={"type": "datetime-local"}), @@ -202,6 +267,7 @@ class EventForm(EventAudienceFormMixin, forms.ModelForm): "deadline": forms.DateTimeInput(attrs={"type": "datetime-local"}), "score_for": forms.NumberInput(attrs={"min": 0}), "score_against": forms.NumberInput(attrs={"min": 0}), + "max_referees": forms.NumberInput(attrs={"min": 1}), **_AUDIENCE_WIDGETS, } diff --git a/management/pdf.py b/management/pdf.py index 32624c4..fe6ac1e 100644 --- a/management/pdf.py +++ b/management/pdf.py @@ -28,3 +28,8 @@ def render_pdf(html: str) -> bytes: def membership_list_pdf(context: dict) -> bytes: html = render_to_string("management/membership_list_pdf.html", context) return render_pdf(html) + + +def event_referee_form_pdf(context: dict) -> bytes: + html = render_to_string("management/event_referee_form_pdf.html", context) + return render_pdf(html) diff --git a/management/templates/management/_nav_items.html b/management/templates/management/_nav_items.html index c720998..83f90aa 100644 --- a/management/templates/management/_nav_items.html +++ b/management/templates/management/_nav_items.html @@ -19,11 +19,17 @@ {% if is_club_admin %}
  • {% lucide "wallet" size=16 %} {% trans "Memberships" %}
  • {% lucide "shield-check" size=16 %} {% trans "Roles" %}
  • +
  • {% lucide "users-round" size=16 %} {% trans "Groups" %}
  • {% endif %}
  • {% lucide "shirt" size=16 %} {% trans "Teams" %}
  • {% lucide "tags" size=16 %} {% trans "Positions" %}
  • +
  • {% lucide "flag" size=16 %} {% trans "Referees" %}
  • +{% if is_club_admin %} +
  • {% lucide "calendar-check" size=16 %} {% trans "Referee management" %}
  • +
  • {% lucide "badge-check" size=16 %} {% trans "Referee levels" %}
  • +{% endif %}
  • {% lucide "newspaper" size=16 %} {% trans "News" %}
  • diff --git a/management/templates/management/_referee_assignment_panel.html b/management/templates/management/_referee_assignment_panel.html new file mode 100644 index 0000000..5e15365 --- /dev/null +++ b/management/templates/management/_referee_assignment_panel.html @@ -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 %} +
    +

    + {% lucide "flag" size=18 %} {% trans "Referees" %} + + {{ referees|length }} / {{ event.max_referees }} + +

    + {% if can_manage_referees %} + {% lucide "file-down" size=12 %} {% trans "Referee form (PDF)" %} + {% endif %} +
    +
      + {% for referee in referees %} +
    • + + {{ referee.display_name }} + {% if referee.is_external %}{% trans "External" %}{% endif %} + {% if referee.assigned_by %}— {% blocktrans with name=referee.assigned_by %}assigned by {{ name }}{% endblocktrans %}{% endif %} + {% if referee.total_payable %}— {% blocktrans with total=referee.total_payable %}€{{ total }} due{% endblocktrans %}{% endif %} + + {% if can_manage_referees %} +
      + +
      + {% csrf_token %} + {% if next_url %}{% endif %} + +
      +
      + {% endif %} +
    • + {% empty %} +
    • {% trans "No referees assigned yet." %}
    • + {% endfor %} +
    + +{% if can_manage_referees %} + {% if referees_full %} +

    {% trans "This game already has its maximum number of referees." %}

    + {% else %} + {% if referee_candidates %} +
    + {% csrf_token %} + {% if next_url %}{% endif %} + + +
    +

    {% trans "⚠ = also expected at another event around this time -- shown as a warning, not blocked." %}

    + {% else %} +

    + {% trans "No eligible referees for this team yet." %} + {% trans "Link a referee level to this team." %} +

    + {% endif %} + +
    + {% csrf_token %} + {% if next_url %}{% endif %} + + +
    + {% 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 %} diff --git a/management/templates/management/event_detail.html b/management/templates/management/event_detail.html index d1fa466..41d4457 100644 --- a/management/templates/management/event_detail.html +++ b/management/templates/management/event_detail.html @@ -128,6 +128,21 @@
    {% endif %} + + {% if event.is_home_game and not referee_management_needed %} +
    + {% lucide "info" size=20 %} + {% trans "Referees for this game are managed by the federation, not the club." %} +
    + {% endif %} + + {% if referee_management_needed %} +
    +
    + {% include "management/_referee_assignment_panel.html" %} +
    +
    + {% endif %} {% endblock panel %} {% block extra_body %} diff --git a/management/templates/management/event_form.html b/management/templates/management/event_form.html index 063303a..79ce00a 100644 --- a/management/templates/management/event_form.html +++ b/management/templates/management/event_form.html @@ -49,22 +49,22 @@

    {% trans "Game" %}

    -
    +
    {% form_field form.competition %} {% form_field form.external_game_id %} -
    - {% if update_view %} - {% comment %} + {% form_field form.max_referees %} + {% if update_view %} + {% comment %} Score/live status only exist to record once a game has actually been created -- the add form leaves them out entirely (see EventForm's editing=False path). - {% endcomment %} -
    + {% endcomment %} + {% form_field form.score_for %} {% form_field form.score_against %} {% form_field form.is_live %} -
    - {% endif %} + {% endif %} +
    diff --git a/management/templates/management/event_referee_form_pdf.html b/management/templates/management/event_referee_form_pdf.html new file mode 100644 index 0000000..26d98fb --- /dev/null +++ b/management/templates/management/event_referee_form_pdf.html @@ -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 %} + + + + + {% trans "Referee form" %} + + + +
    +
    {{ club.official_name }}
    + {% if home_location %} +
    {{ home_location.address }}
    +
    {{ home_location.zip_code }} {{ home_location.city }}
    + {% endif %} +
    + +
    + {% trans "Regarding: Game" %} + {% 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 %} +
    +
    {% trans "Date" %} {{ event.start|date:"j F Y" }}
    +
    {% trans "Time" %} {{ event.start|date:"H:i" }}
    +
    + {% trans "Referee names" %} + {% for referee in referees %}{{ referee.display_name }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %} +
    +
    + +

    {% blocktrans with name=club.name %}{{ name }} pays the fee to the referee(s) for officiating the game mentioned above.{% endblocktrans %}

    + +
    {% trans "Referees and fee, plus any kilometers" %}
    + {% for referee in referees %} +
    + {{ 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 %} +
    + {% empty %} +
    + {% endfor %} + +
    +
    {% trans "Referee signature" %}
    +
    +
    + +
    + {% trans "Collected by team manager" %}:   +
    + + diff --git a/management/templates/management/group_bulk_add.html b/management/templates/management/group_bulk_add.html new file mode 100644 index 0000000..9545111 --- /dev/null +++ b/management/templates/management/group_bulk_add.html @@ -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 %} +
    +
    + + +
    +
    + +
    + {% csrf_token %} + +
    +
    +
    + + {% blocktrans count counter=members|length %}{{ counter }} member{% plural %}{{ counter }} members{% endblocktrans %} + + +
    + +
    + + + + + + + + + {% for member in members %} + + + + + {% empty %} + + + + {% endfor %} + +
    {% trans "Member" %}
    + {% if member.already_in_group %} + {% trans "In group" %} + {% else %} + + {% endif %} + {{ member }}
    {% trans "No members match this search." %}
    +
    +
    +
    + + +
    +{% endblock panel %} diff --git a/management/templates/management/group_detail.html b/management/templates/management/group_detail.html new file mode 100644 index 0000000..d575910 --- /dev/null +++ b/management/templates/management/group_detail.html @@ -0,0 +1,51 @@ +{% extends "management/base.html" %} +{% load i18n lucide ui %} + +{% block heading %}{{ group.name }}{% endblock heading %} + +{% block actions %} + {% lucide "pencil" size=16 %} {% trans "Edit" %} +{% endblock actions %} + +{% block panel %} +
    +
    + +
    + + + + + + + + + {% for membership in memberships %} + + + + + {% empty %} + + + + {% endfor %} + +
    {% trans "Member" %}
    {{ membership.member }} + +
    {% trans "No members yet." %}
    +
    +
    +
    + + {% 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 %} diff --git a/management/templates/management/group_form.html b/management/templates/management/group_form.html new file mode 100644 index 0000000..ac546d9 --- /dev/null +++ b/management/templates/management/group_form.html @@ -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 %} +
    +
    +
    + {% csrf_token %} + + {% for error in form.non_field_errors %} +
    + {{ error }} +
    + {% endfor %} + +
    + {% for field in form %} + {% form_field field %} + {% endfor %} +
    + +
    + {% lucide "arrow-left" size=16 %} {% trans "Cancel" %} + +
    +
    +
    +
    +{% endblock panel %} + +{% block extra_body %} + +{% endblock extra_body %} diff --git a/management/templates/management/group_list.html b/management/templates/management/group_list.html new file mode 100644 index 0000000..8e868de --- /dev/null +++ b/management/templates/management/group_list.html @@ -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 %} + {% lucide "plus" size=16 %} {% trans "New group" %} +{% endblock actions %} + +{% block panel %} +
    +
    +
    + + + + + + + + + + {% for group in groups %} + + + + + + {% empty %} + + + + {% endfor %} + +
    {% trans "Name" %}{% trans "Members" %}
    {{ group.name }}{{ group.member_count }} +
    + {% lucide "pencil" size=14 %} {% trans "Edit" %} + +
    +
    {% trans "No groups yet." %}
    +
    +
    +
    + + {% 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 %} diff --git a/management/templates/management/member_detail.html b/management/templates/management/member_detail.html index 436599e..5ce7315 100644 --- a/management/templates/management/member_detail.html +++ b/management/templates/management/member_detail.html @@ -1,5 +1,5 @@ {% extends "management/base.html" %} -{% load lucide ui i18n %} +{% load lucide ui i18n static %} {% block heading %}{{ member.get_full_name }}{% endblock heading %} {% block subheading %}{{ member.contact_email }}{% endblock subheading %} @@ -127,6 +127,66 @@
    + {% if referee_profile or is_club_admin %} +
    +
    +
    +

    {% lucide "flag" size=18 %} {% trans "Referee eligibility" %}

    + {% if is_club_admin %} + + {% endif %} +
    + + {% if referee_profile and referee_profile.level %} +
    +
    +
    {% trans "Level" %}
    +
    {{ referee_profile.level }}
    +
    +
    +
    {% trans "Valid until" %}
    +
    + {{ referee_profile.valid_until|default:"—" }} + {% if referee_profile.is_currently_valid %} + {% trans "Valid" %} + {% elif referee_profile.valid_until %} + {% trans "Expired" %} + {% else %} + {% trans "No validity set" %} + {% endif %} +
    +
    +
    + + {% if referee_profile.is_eligible %} +
    {% trans "Can referee for" %}
    +
    + {% for team in referee_profile.eligible_teams %} + {{ team.name }} + {% empty %} + {% trans "This level has no teams linked yet." %} + {% endfor %} +
    + {% else %} +
    + {% lucide "triangle-alert" size=16 %} + {% trans "Not currently eligible to referee -- set or extend the validity date above to reinstate them." %} +
    + {% endif %} + {% else %} +

    {% trans "Not eligible to referee for any team." %}

    + {% endif %} +
    +
    + + {% if is_club_admin %} + {% url 'management:member_referee_eligibility_update' member.pk as referee_eligibility_url %} + {% trans "Referee eligibility" as referee_eligibility_title %} + {% trans "Save" as save_label %} + {% include "controlpanel/_modal_form.html" with modal_id="referee_eligibility_modal" title=referee_eligibility_title form=referee_eligibility_form action_url=referee_eligibility_url submit_label=save_label submit_icon="save" %} + {% endif %} + {% endif %} + {% trans "Add parent" as add_parent_label %} {% trans "Add child" as add_child_label %} {% trans "If this email has no account yet, one is created and they set a password via the reset link." as add_parent_blurb %} @@ -184,3 +244,7 @@ {% include "controlpanel/_modal_form.html" with modal_id="attach_family_modal" title=add_to_family_title form=attach_to_family_form action_url=attach_family_url submit_label=add_label submit_icon="user-plus" blurb=attach_family_blurb %} {% endif %} {% endblock panel %} + +{% block extra_body %} + +{% endblock extra_body %} diff --git a/management/templates/management/member_import.html b/management/templates/management/member_import.html index ab92361..7717f07 100644 --- a/management/templates/management/member_import.html +++ b/management/templates/management/member_import.html @@ -15,6 +15,9 @@ here. Nothing is created yet: you'll see exactly what will be added before anything is saved.{% endblocktrans %}

    +

    + {% blocktrans %}To register a family, give matching rows the same family_group value (any label works, e.g. a surname) and set each row's family_role. 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 %} +

    {% csrf_token %} diff --git a/management/templates/management/member_import_preview.html b/management/templates/management/member_import_preview.html index 5875072..1a6f4e1 100644 --- a/management/templates/management/member_import_preview.html +++ b/management/templates/management/member_import_preview.html @@ -26,6 +26,7 @@ {% trans "Last name" %} {% trans "First name" %} {% trans "Email" %} + {% trans "Family" %} {% trans "Outcome" %} @@ -36,6 +37,14 @@ {{ result.raw.last_name }} {{ result.raw.first_name }} {{ result.raw.email }} + + {% if result.family_group %} + {{ result.family_group }} + {% if result.family_role %}{{ result.family_role|capfirst }}{% endif %} + {% else %} + - + {% endif %} + {% if result.member %} {% trans "Will create" %} @@ -47,7 +56,7 @@ {% empty %} - {% trans "No rows found in the uploaded file." %} + {% trans "No rows found in the uploaded file." %} {% endfor %} diff --git a/management/templates/management/referee_level_form.html b/management/templates/management/referee_level_form.html new file mode 100644 index 0000000..ab82691 --- /dev/null +++ b/management/templates/management/referee_level_form.html @@ -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 %} +
    +
    + + {% csrf_token %} + + {% for error in form.non_field_errors %} +
    + {{ error }} +
    + {% endfor %} + +
    + {% for field in form %} + {% form_field field %} + {% endfor %} +
    + +
    + {% lucide "arrow-left" size=16 %} {% trans "Cancel" %} + +
    + +
    +
    +{% endblock panel %} + +{% block extra_body %} + +{% endblock extra_body %} diff --git a/management/templates/management/referee_level_list.html b/management/templates/management/referee_level_list.html new file mode 100644 index 0000000..ee60dac --- /dev/null +++ b/management/templates/management/referee_level_list.html @@ -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 %} + {% lucide "plus" size=16 %} {% trans "New level" %} + {% endif %} +{% endblock actions %} + +{% block panel %} +
    +
    +
    + + + + + + + + + + + {% for level in levels %} + + + + + + + {% empty %} + + + + {% endfor %} + +
    {% trans "Ordering" %}{% trans "Name" %}{% trans "Qualifies for" %}
    {{ level.ordering }}{{ level.name }} + {% for team in level.teams.all %} + {{ team.name }} + {% empty %} + + {% endfor %} + + {% if is_club_admin %} + {% lucide "pencil" size=14 %} {% trans "Edit" %} + {% endif %} +
    {% trans "No referee levels yet." %}
    +
    +
    +
    +{% endblock panel %} diff --git a/management/templates/management/referee_list.html b/management/templates/management/referee_list.html new file mode 100644 index 0000000..a8446d7 --- /dev/null +++ b/management/templates/management/referee_list.html @@ -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 %} +
    +
    +
    + + + + + + + + + + + + {% for referee in referees %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
    {% trans "Referee" %}{% trans "Level" %}{% trans "Can referee for" %}{% trans "Valid until" %}{% trans "Status" %}
    {{ referee }}{{ referee.referee_profile.level|default:"—" }} + {% if referee.referee_profile.is_eligible %} + {% for team in referee.referee_profile.eligible_teams %} + {{ team.name }} + {% empty %} + + {% endfor %} + {% else %} + + {% endif %} + {{ referee.referee_profile.valid_until|default:"—" }} + {% if referee.referee_profile.is_eligible %} + {% trans "Valid" %} + {% elif not referee.referee_profile.level %} + {% trans "No level" %} + {% elif not referee.referee_profile.valid_until %} + {% trans "No validity set" %} + {% else %} + {% trans "Expired" %} + {% endif %} +
    {% trans "No referees yet -- set a level and validity from a member's own page." %}
    +
    +
    +
    +{% endblock panel %} diff --git a/management/templates/management/referee_management.html b/management/templates/management/referee_management.html new file mode 100644 index 0000000..e40fe55 --- /dev/null +++ b/management/templates/management/referee_management.html @@ -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 %} +
    +
    +
    +
    {% lucide "calendar" size=16 %} {% trans "Games in view" %}
    +
    {{ kpi_total }}
    +
    +
    +
    +
    +
    {% lucide "circle-alert" size=16 %} {% trans "Without a referee" %}
    +
    {{ kpi_no_referee }}
    +
    +
    +
    +
    +
    {% lucide "circle-dashed" size=16 %} {% trans "Partially staffed" %}
    +
    {{ kpi_understaffed }}
    +
    +
    +
    +
    +
    {% lucide "circle-check" size=16 %} {% trans "Fully staffed" %}
    +
    {{ kpi_fully_staffed }}
    +
    +
    +
    + + + + {% regroup games by start.date as day_groups %} + {% for day in day_groups %} +

    {{ day.grouper|date:"l j F" }}

    +
    + {% for game in day.list %} +
    +
    + + {% for team in game.teams.all %}{{ team.short_name }}{% if not forloop.last %}, {% endif %}{% endfor %} + {% if game.opponent %}{% trans "vs" %} {{ game.opponent }}{% endif %} + +
    {{ game.start|date:"H:i" }}{% if game.location %} — {{ game.location }}{% endif %}
    +
    + {% for referee in game.referee_rows %} + {{ referee.display_name }}{% if referee.is_external %} ({% trans "ext." %}){% endif %} + {% empty %} + {% trans "No referees assigned yet." %} + {% endfor %} +
    +
    + + {{ game.referee_rows|length }} / {{ game.max_referees }} + +
    + + {% lucide "file-down" size=12 %} {% trans "Referee form" %} + + +
    +
    +
    +
    + + + + + + {% endfor %} +
    + {% empty %} +
    +
    +

    {% trans "No upcoming home games need a club-arranged referee." %}

    +
    +
    + {% endfor %} +{% endblock panel %} diff --git a/management/templates/management/team_bulk_add.html b/management/templates/management/team_bulk_add.html new file mode 100644 index 0000000..6c116c1 --- /dev/null +++ b/management/templates/management/team_bulk_add.html @@ -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 %} +
    +
    + + +
    +
    + +
    + {% csrf_token %} + +
    +
    +
    + + {% blocktrans count counter=members|length %}{{ counter }} eligible member{% plural %}{{ counter }} eligible members{% endblocktrans %} + + +
    +

    + {% 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 %} +

    + +
    + + + + + + + + + + + + + {% for member in members %} + + + + + + + + + {% empty %} + + + + {% endfor %} + +
    {% trans "Member" %}{% trans "Player" %}{% trans "Position" %}{% trans "Jersey #" %}{% trans "Staff" %}{% trans "Position" %}
    {{ member }} + {% if member.already_player %} + {% trans "On roster" %} + {% else %} + + {% endif %} + + {% if not member.already_player %} + + {% endif %} + + {% if not member.already_player %} + + {% endif %} + + {% if member.already_staff %} + {% trans "On staff" %} + {% else %} + + {% endif %} + + {% if not member.already_staff %} + + {% endif %} +
    {% trans "No eligible members match this search." %}
    +
    +
    +
    + + +
    +{% endblock panel %} + +{% block extra_body %} + +{% endblock extra_body %} diff --git a/management/templates/management/team_detail.html b/management/templates/management/team_detail.html index 496dd6e..08aea43 100644 --- a/management/templates/management/team_detail.html +++ b/management/templates/management/team_detail.html @@ -149,7 +149,10 @@

    {% trans "Roster" %}

    {% if can_manage %} - +
    + {% lucide "users" size=14 %} {% trans "Add multiple" %} + +
    {% endif %}
    @@ -198,7 +201,10 @@

    {% trans "Staff" %}

    {% if can_manage %} - +
    + {% lucide "users" size=14 %} {% trans "Add multiple" %} + +
    {% endif %}
    @@ -235,6 +241,26 @@
    +
    +
    +

    {% lucide "flag" size=18 %} {% trans "Eligible referees" %}

    + {% if eligible_referees is None %} +

    {% trans "Referees for this team's home games are managed by the federation, not the club -- nothing to configure here." %}

    + {% else %} +

    {% trans "Members who can be assigned to referee this team's home games. Set from each member's own page." %}

    + {% if eligible_referees %} +
    + {% for referee in eligible_referees %} + {{ referee }} + {% endfor %} +
    + {% else %} +

    {% trans "No one yet." %}

    + {% endif %} + {% endif %} +
    +
    +
    diff --git a/management/tests.py b/management/tests.py index d5dc34b..4f580f7 100644 --- a/management/tests.py +++ b/management/tests.py @@ -18,16 +18,16 @@ from waffle import get_waffle_flag_model from billing.models import Plan, PlanPrice from billing.services.dues import record_payment, subscribe from club.models import Club, ClubMembership, ClubRole, FeePayment, Season, Sponsor -from events.models import Attendance, Competition, Event, EventSeries, Location, Opponent +from events.models import Attendance, Competition, Event, EventReferee, EventSeries, Location, Opponent from events.services.rbihf_import import RBIHFImportError from events.services.recurrence import detach_occurrence, generate_occurrences from management.bulk_import import TEMPLATE_COLUMNS from management.pdf import PDFExportError, render_pdf from management.recurrence_ui import build_rrule, describe_rrule, parse_rrule -from members.models import Family, FamilyMembership, Member +from members.models import Family, FamilyMembership, Group, GroupMembership, Member from news.models import News, NewsPhoto from shop.models import Order -from teams.models import Position, StaffAssignment, Team, TeamMembership, TeamPhoto +from teams.models import Position, RefereeLevel, RefereeProfile, StaffAssignment, Team, TeamMembership, TeamPhoto User = get_user_model() @@ -261,11 +261,17 @@ class TeamManagementTests(ManagementTestBase): self.assertNotContains(response, "Rival Team") def test_creating_a_team(self): - response = self.club_post("team_create", {"name": "U15", "short_name": "U15"}) + response = self.club_post("team_create", {"name": "U15", "short_name": "U15", "referee_management": "club"}) team = Team.objects.get(club=self.club, name="U15") self.assertRedirects(response, reverse("management:team_detail", args=[team.pk])) + def test_creating_a_federation_managed_team(self): + self.club_post("team_create", {"name": "U15", "short_name": "U15", "referee_management": "federation"}) + + team = Team.objects.get(club=self.club, name="U15") + self.assertEqual(team.referee_management, Team.RefereeManagement.FEDERATION) + def test_deleting_a_team(self): team = Team.objects.create(club=self.club, name="U16", short_name="U16") @@ -520,6 +526,243 @@ class TeamRosterStaffTests(ManagementTestBase): self.assertFalse(StaffAssignment.objects.filter(pk=assignment.pk).exists()) +class TeamBulkAddTests(ManagementTestBase): + """Adding many people to a team's roster/staff in one submit -- see + management.views.TeamBulkAddView.""" + + def setUp(self): + super().setUp() + self.team = Team.objects.create(club=self.club, name="First Team", short_name="1st") + self.other_team = Team.objects.create(club=self.club, name="Second Team", short_name="2nd") + self.player_position = Position.objects.create(club=self.club, name="Forward", short_name="FW", staff_position=False) + self.coach_position = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True) + + self.player = Member.objects.create(first_name="Peter", last_name="Player") + ClubMembership.objects.create(club=self.club, member=self.player, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + self.other_player = Member.objects.create(first_name="Olly", last_name="Other") + ClubMembership.objects.create(club=self.club, member=self.other_player, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + + def make_team_coach(self, team, email="coach-bulk@example.com"): + coach_user = User.objects.create_user(email=email, password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=self.coach_position) + return coach_user + + def test_the_page_lists_eligible_members_and_flags_who_is_already_on(self): + TeamMembership.objects.create(team=self.team, season=self.season, member=self.player, position=self.player_position) + self.client.force_login(self.admin_user) + + response = self.club_get("team_bulk_add", self.team.pk, self.season.pk) + + self.assertContains(response, "Peter Player") + self.assertContains(response, "Olly Other") + self.assertContains(response, "On roster") + + def test_a_lapsed_member_is_not_listed(self): + lapsed = Member.objects.create(first_name="Lex", last_name="Lapsed") + ClubMembership.objects.create(club=self.club, member=lapsed, season=self.season, status=ClubMembership.StatusChoices.LAPSED) + self.client.force_login(self.admin_user) + + response = self.club_get("team_bulk_add", self.team.pk, self.season.pk) + + self.assertNotContains(response, "Lex Lapsed") + + def test_admin_can_add_two_players_in_one_submit(self): + self.client.force_login(self.admin_user) + + response = self.club_post( + "team_bulk_add", + { + f"player_{self.player.pk}": "on", + f"player_position_{self.player.pk}": str(self.player_position.pk), + f"jersey_{self.player.pk}": "9", + f"player_{self.other_player.pk}": "on", + f"player_position_{self.other_player.pk}": str(self.player_position.pk), + }, + self.team.pk, + self.season.pk, + ) + + self.assertRedirects(response, f"{reverse('management:team_detail', args=[self.team.pk])}?season={self.season.pk}") + self.assertEqual(TeamMembership.objects.filter(team=self.team, season=self.season).count(), 2) + self.assertEqual(TeamMembership.objects.get(team=self.team, member=self.player).jersey_number, 9) + + def test_a_member_can_be_added_as_both_player_and_staff_at_once(self): + self.client.force_login(self.admin_user) + + self.club_post( + "team_bulk_add", + { + f"player_{self.player.pk}": "on", + f"player_position_{self.player.pk}": str(self.player_position.pk), + f"staff_{self.player.pk}": "on", + f"staff_position_{self.player.pk}": str(self.coach_position.pk), + }, + self.team.pk, + self.season.pk, + ) + + self.assertTrue(TeamMembership.objects.filter(team=self.team, season=self.season, member=self.player).exists()) + self.assertTrue(StaffAssignment.objects.filter(team=self.team, season=self.season, member=self.player).exists()) + + def test_a_clashing_jersey_number_is_skipped_without_a_500(self): + TeamMembership.objects.create(team=self.team, season=self.season, member=self.player, position=self.player_position, jersey_number=7) + self.client.force_login(self.admin_user) + + response = self.club_post( + "team_bulk_add", + { + f"player_{self.other_player.pk}": "on", + f"player_position_{self.other_player.pk}": str(self.player_position.pk), + f"jersey_{self.other_player.pk}": "7", + }, + self.team.pk, + self.season.pk, + ) + + self.assertEqual(response.status_code, 302) + self.assertFalse(TeamMembership.objects.filter(team=self.team, season=self.season, member=self.other_player).exists()) + + def test_a_member_already_on_the_roster_cannot_be_re_added_via_a_crafted_post(self): + TeamMembership.objects.create(team=self.team, season=self.season, member=self.player, position=self.player_position) + self.client.force_login(self.admin_user) + + self.club_post( + "team_bulk_add", + {f"player_{self.player.pk}": "on", f"player_position_{self.player.pk}": str(self.player_position.pk)}, + self.team.pk, + self.season.pk, + ) + + self.assertEqual(TeamMembership.objects.filter(team=self.team, season=self.season, member=self.player).count(), 1) + + def test_a_lapsed_member_cannot_be_added_via_a_crafted_post(self): + lapsed = Member.objects.create(first_name="Lex", last_name="Lapsed") + ClubMembership.objects.create(club=self.club, member=lapsed, season=self.season, status=ClubMembership.StatusChoices.LAPSED) + self.client.force_login(self.admin_user) + + self.club_post( + "team_bulk_add", + {f"player_{lapsed.pk}": "on", f"player_position_{lapsed.pk}": str(self.player_position.pk)}, + self.team.pk, + self.season.pk, + ) + + self.assertFalse(TeamMembership.objects.filter(team=self.team, season=self.season, member=lapsed).exists()) + + def test_a_different_teams_coach_cannot_bulk_add(self): + self.client.force_login(self.make_team_coach(self.other_team)) + + response = self.club_post( + "team_bulk_add", + {f"player_{self.player.pk}": "on", f"player_position_{self.player.pk}": str(self.player_position.pk)}, + self.team.pk, + self.season.pk, + ) + + self.assertEqual(response.status_code, 403) + + +class GroupManagementTests(ManagementTestBase): + """Generic named collections of members -- see management.views.Group* and + management.forms.GroupForm. Deliberately has no team/referee knowledge at + all; see MemberRefereeEligibilityTests for that (teams.RefereeProfile).""" + + def setUp(self): + super().setUp() + self.team = Team.objects.create(club=self.club, name="First Team", short_name="1st") + self.other_team = Team.objects.create(club=self.club, name="Second Team", short_name="2nd") + self.member = Member.objects.create(first_name="Peter", last_name="Player") + ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + + def make_non_admin_coach(self, email="coach-groups@example.com"): + coach_user = User.objects.create_user(email=email, password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + position = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True) + StaffAssignment.objects.create(team=self.team, member=coach_member, season=self.season, position=position) + return coach_user + + def test_list_is_admin_only(self): + self.client.force_login(self.make_non_admin_coach()) + self.assertEqual(self.club_get("group_list").status_code, 403) + + def test_admin_can_create_a_group(self): + self.client.force_login(self.admin_user) + + response = self.club_post("group_create", {"name": "Referees"}) + + group = Group.objects.get(club=self.club, name="Referees") + self.assertRedirects(response, reverse("management:group_detail", args=[group.pk])) + + def test_editing_a_group_renames_it(self): + self.client.force_login(self.admin_user) + group = Group.objects.create(club=self.club, name="Old name") + + self.club_post("group_update", {"name": "New name"}, group.pk) + + group.refresh_from_db() + self.assertEqual(group.name, "New name") + + def test_deleting_a_group_removes_it(self): + self.client.force_login(self.admin_user) + group = Group.objects.create(club=self.club, name="Doomed") + + self.club_post("group_delete", {}, group.pk) + + self.assertFalse(Group.objects.filter(pk=group.pk).exists()) + + def test_bulk_add_lists_eligible_members_and_flags_existing_ones(self): + group = Group.objects.create(club=self.club, name="Referees") + GroupMembership.objects.create(group=group, member=self.member) + other_member = Member.objects.create(first_name="Olly", last_name="Other") + ClubMembership.objects.create(club=self.club, member=other_member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + self.client.force_login(self.admin_user) + + response = self.club_get("group_bulk_add", group.pk) + + self.assertContains(response, "In group") + self.assertContains(response, "Olly Other") + + def test_bulk_add_adds_selected_members(self): + group = Group.objects.create(club=self.club, name="Referees") + other_member = Member.objects.create(first_name="Olly", last_name="Other") + ClubMembership.objects.create(club=self.club, member=other_member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + self.client.force_login(self.admin_user) + + response = self.club_post("group_bulk_add", {f"member_{self.member.pk}": "on", f"member_{other_member.pk}": "on"}, group.pk) + + self.assertRedirects(response, reverse("management:group_detail", args=[group.pk])) + self.assertEqual(GroupMembership.objects.filter(group=group).count(), 2) + + def test_bulk_add_cannot_re_add_an_existing_member_via_a_crafted_post(self): + group = Group.objects.create(club=self.club, name="Referees") + GroupMembership.objects.create(group=group, member=self.member) + self.client.force_login(self.admin_user) + + self.club_post("group_bulk_add", {f"member_{self.member.pk}": "on"}, group.pk) + + self.assertEqual(GroupMembership.objects.filter(group=group, member=self.member).count(), 1) + + def test_removing_a_member_deletes_the_membership(self): + group = Group.objects.create(club=self.club, name="Referees") + membership = GroupMembership.objects.create(group=group, member=self.member) + self.client.force_login(self.admin_user) + + self.club_post("group_member_remove", {}, group.pk, membership.pk) + + self.assertFalse(GroupMembership.objects.filter(pk=membership.pk).exists()) + + def test_groups_are_scoped_to_the_club(self): + other_club = Club.objects.create(name="Rival FC", slug="rival-fc") + other_group = Group.objects.create(club=other_club, name="Rival Referees") + self.client.force_login(self.admin_user) + + response = self.club_get("group_list") + + self.assertNotContains(response, "Rival Referees") + self.assertEqual(self.club_get("group_detail", other_group.pk).status_code, 404) + + ONE_PIXEL_PNG = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82" @@ -659,6 +902,135 @@ class PositionManagementTests(ManagementTestBase): self.assertFormError(response.context["form"], "management_position", "A management position must also be a staff position.") +class RefereeLevelManagementTests(ManagementTestBase): + """Admin-managed referee qualification tiers -- see + management.views.RefereeLevel* and teams.RefereeLevel.""" + + def setUp(self): + super().setUp() + self.team = Team.objects.create(club=self.club, name="First Team", short_name="1st") + self.other_team = Team.objects.create(club=self.club, name="Second Team", short_name="2nd") + + def make_non_admin_coach(self, email="coach-levels@example.com"): + coach_user = User.objects.create_user(email=email, password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + position = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True) + StaffAssignment.objects.create(team=self.team, member=coach_member, season=self.season, position=position) + return coach_user + + def test_list_is_visible_to_any_staff(self): + RefereeLevel.objects.create(club=self.club, name="Regional") + self.client.force_login(self.make_non_admin_coach()) + + response = self.club_get("referee_level_list") + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Regional") + + def test_create_is_admin_only(self): + self.client.force_login(self.make_non_admin_coach()) + + response = self.club_post("referee_level_create", {"name": "Regional", "ordering": 0, "teams": []}) + + self.assertEqual(response.status_code, 403) + + def test_admin_can_create_a_level_with_teams(self): + self.client.force_login(self.admin_user) + + response = self.club_post("referee_level_create", {"name": "Regional", "ordering": 0, "teams": [str(self.team.pk), str(self.other_team.pk)]}) + + level = RefereeLevel.objects.get(club=self.club, name="Regional") + self.assertRedirects(response, reverse("management:referee_level_list")) + self.assertEqual(set(level.teams.all()), {self.team, self.other_team}) + + def test_admin_can_update_a_levels_teams(self): + level = RefereeLevel.objects.create(club=self.club, name="Regional") + level.teams.add(self.team) + self.client.force_login(self.admin_user) + + self.club_post("referee_level_update", {"name": "Regional", "ordering": 0, "teams": [str(self.other_team.pk)]}, level.pk) + + self.assertEqual(set(level.teams.all()), {self.other_team}) + + def test_list_scoped_to_the_club(self): + other_club = Club.objects.create(name="Rival FC", slug="rival-fc") + RefereeLevel.objects.create(club=other_club, name="Rival Level") + self.client.force_login(self.admin_user) + + response = self.club_get("referee_level_list") + + self.assertNotContains(response, "Rival Level") + + +class RefereeListViewTests(ManagementTestBase): + """The club-wide referee overview -- see management.views.RefereeListView.""" + + def setUp(self): + super().setUp() + self.team = Team.objects.create(club=self.club, name="First Team", short_name="1st") + self.level = RefereeLevel.objects.create(club=self.club, name="Regional") + self.level.teams.add(self.team) + self.member = Member.objects.create(first_name="Ref", last_name="Eree") + ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + self.client.force_login(self.admin_user) + + def test_lists_a_valid_referee_with_level_and_teams(self): + RefereeProfile.objects.create(member=self.member, level=self.level, valid_until=timezone.localdate() + datetime.timedelta(days=30)) + + response = self.club_get("referee_list") + + self.assertContains(response, "Ref Eree") + self.assertContains(response, "Regional") + self.assertContains(response, "First Team") + self.assertContains(response, "Valid") + + def test_shows_expired_status(self): + RefereeProfile.objects.create(member=self.member, level=self.level, valid_until=timezone.localdate() - datetime.timedelta(days=1)) + + response = self.club_get("referee_list") + + self.assertContains(response, "Expired") + + def test_shows_no_level_status(self): + RefereeProfile.objects.create(member=self.member, valid_until=timezone.localdate() + datetime.timedelta(days=30)) + + response = self.club_get("referee_list") + + self.assertContains(response, "No level") + + def test_shows_no_validity_set_status(self): + RefereeProfile.objects.create(member=self.member, level=self.level) + + response = self.club_get("referee_list") + + self.assertContains(response, "No validity set") + + def test_a_member_with_no_referee_profile_is_not_listed(self): + other_member = Member.objects.create(first_name="Not", last_name="Referee") + ClubMembership.objects.create(club=self.club, member=other_member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + + response = self.club_get("referee_list") + + self.assertNotContains(response, "Not Referee") + + def test_visible_to_any_staff(self): + coach_user = User.objects.create_user(email="coach-reflist@example.com", password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + coach_position = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True) + StaffAssignment.objects.create(team=self.team, member=coach_member, season=self.season, position=coach_position) + RefereeProfile.objects.create(member=self.member, level=self.level, valid_until=timezone.localdate() + datetime.timedelta(days=30)) + # Give the coach visibility into self.member too (members_visible_to + # scopes a non-admin to teams they're staffed on). + player_position = Position.objects.create(club=self.club, name="Forward", short_name="FW") + TeamMembership.objects.create(team=self.team, member=self.member, season=self.season, position=player_position) + self.client.force_login(coach_user) + + response = self.club_get("referee_list") + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Ref Eree") + + class ClubRoleManagementTests(ManagementTestBase): def setUp(self): super().setUp() @@ -997,6 +1369,138 @@ class MemberGrantLoginTests(ManagementTestBase): self.assertIsNone(self.child.user) +class MemberRefereeEligibilityTests(ManagementTestBase): + """A member's referee level and validity, set from their own page -- see + management.views.MemberRefereeEligibilityUpdateView and + teams.RefereeProfile. Eligible teams are derived from the level + (teams.RefereeLevel), not picked here. Deliberately unrelated to + members.Group.""" + + def setUp(self): + super().setUp() + self.team = Team.objects.create(club=self.club, name="First Team", short_name="1st") + self.other_team = Team.objects.create(club=self.club, name="Second Team", short_name="2nd") + self.level = RefereeLevel.objects.create(club=self.club, name="Regional") + self.level.teams.add(self.team, self.other_team) + self.member = Member.objects.create(first_name="Ref", last_name="Eree") + ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + self.future_date = timezone.localdate() + datetime.timedelta(days=30) + + def test_member_page_shows_not_eligible_for_any_team_by_default(self): + self.client.force_login(self.admin_user) + + response = self.club_get("member_detail", self.member.pk) + + self.assertContains(response, "Not eligible to referee for any team.") + + def test_admin_can_set_level_and_validity_for_a_member_with_no_profile_yet(self): + self.client.force_login(self.admin_user) + + response = self.club_post("member_referee_eligibility_update", {"level": str(self.level.pk), "valid_until": self.future_date.isoformat()}, self.member.pk) + + self.assertRedirects(response, reverse("management:member_detail", args=[self.member.pk])) + profile = RefereeProfile.objects.get(member=self.member) + self.assertEqual(profile.level, self.level) + self.assertEqual(profile.valid_until, self.future_date) + self.assertEqual(set(profile.eligible_teams), {self.team, self.other_team}) + + def test_admin_can_update_an_existing_profile(self): + other_level = RefereeLevel.objects.create(club=self.club, name="National") + profile = RefereeProfile.objects.create(member=self.member, level=self.level, valid_until=self.future_date) + self.client.force_login(self.admin_user) + + self.club_post("member_referee_eligibility_update", {"level": str(other_level.pk), "valid_until": self.future_date.isoformat()}, self.member.pk) + + profile.refresh_from_db() + self.assertEqual(profile.level, other_level) + + def test_admin_can_clear_the_level_to_make_someone_ineligible(self): + profile = RefereeProfile.objects.create(member=self.member, level=self.level, valid_until=self.future_date) + self.client.force_login(self.admin_user) + + self.club_post("member_referee_eligibility_update", {"level": "", "valid_until": self.future_date.isoformat()}, self.member.pk) + + profile.refresh_from_db() + self.assertIsNone(profile.level) + self.assertFalse(profile.is_eligible) + + def test_member_page_shows_eligible_teams_when_valid(self): + RefereeProfile.objects.create(member=self.member, level=self.level, valid_until=self.future_date) + self.client.force_login(self.admin_user) + + response = self.club_get("member_detail", self.member.pk) + + self.assertContains(response, "First Team") + + def test_member_page_shows_a_warning_once_expired(self): + RefereeProfile.objects.create(member=self.member, level=self.level, valid_until=timezone.localdate() - datetime.timedelta(days=1)) + self.client.force_login(self.admin_user) + + response = self.club_get("member_detail", self.member.pk) + + self.assertContains(response, "Not currently eligible to referee") + self.assertNotContains(response, "First Team") + + def test_team_page_lists_eligible_referees(self): + RefereeProfile.objects.create(member=self.member, level=self.level, valid_until=self.future_date) + self.client.force_login(self.admin_user) + + response = self.club_get("team_detail", self.team.pk) + + self.assertContains(response, "Ref Eree") + + def test_team_page_excludes_an_expired_referee(self): + # "Ref Eree" alone also matches the (unrelated) add-player/assign-staff + # dropdowns, which list every active club member regardless of referee + # status -- assert on the eligible-referees panel's own empty state. + RefereeProfile.objects.create(member=self.member, level=self.level, valid_until=timezone.localdate() - datetime.timedelta(days=1)) + self.client.force_login(self.admin_user) + + response = self.club_get("team_detail", self.team.pk) + + self.assertContains(response, "No one yet.") + + def test_team_page_shows_a_federation_note_instead_of_eligible_referees(self): + RefereeProfile.objects.create(member=self.member, level=self.level, valid_until=self.future_date) + self.team.referee_management = Team.RefereeManagement.FEDERATION + self.team.save(update_fields=["referee_management"]) + self.client.force_login(self.admin_user) + + response = self.club_get("team_detail", self.team.pk) + + self.assertContains(response, "managed by the federation") + self.assertNotContains(response, "No one yet.") + + def test_non_admin_gets_403(self): + coach_user = User.objects.create_user(email="coach-referee-elig@example.com", password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + position = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True) + StaffAssignment.objects.create(team=self.team, member=coach_member, season=self.season, position=position) + self.client.force_login(coach_user) + + response = self.club_post("member_referee_eligibility_update", {"level": str(self.level.pk), "valid_until": self.future_date.isoformat()}, self.member.pk) + + self.assertEqual(response.status_code, 403) + self.assertFalse(RefereeProfile.objects.filter(member=self.member).exists()) + + def test_non_admin_does_not_see_the_edit_button(self): + coach_user = User.objects.create_user(email="coach-referee-elig2@example.com", password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + coach_position = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True) + StaffAssignment.objects.create(team=self.team, member=coach_member, season=self.season, position=coach_position) + # Puts self.member within the coach's visibility (members_visible_to) so + # the response is a real 200 -- otherwise "not contains" would trivially + # pass on a 404 for the wrong reason. + player_position = Position.objects.create(club=self.club, name="Forward", short_name="FW") + TeamMembership.objects.create(team=self.team, member=self.member, season=self.season, position=player_position) + self.client.force_login(coach_user) + + response = self.club_get("member_detail", self.member.pk) + + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, reverse("management:member_referee_eligibility_update", args=[self.member.pk])) + + class MemberFamilyAttachDetachTests(ManagementTestBase): def setUp(self): super().setUp() @@ -2128,6 +2632,54 @@ class MemberBulkImportTests(ManagementTestBase): self.assertEqual(response.status_code, 403) + def test_a_family_group_links_a_parent_and_child_and_grants_the_parent_a_login(self): + upload = make_import_workbook( + [ + ["Taylor", "Doe", "", "taylor.doe@example.com", "", "", "", "", "", "Doe family", "parent"], + ["Jamie", "Doe", "2014-03-02", "", "", "", "", "", "", "Doe family", "child"], + ] + ) + self.club_post("member_import", {"file": upload}) + + self.club_post("member_import_confirm", {}) + + parent = Member.objects.get(email="taylor.doe@example.com") + child = Member.objects.get(first_name="Jamie", last_name="Doe") + self.assertIsNotNone(parent.user_id) + self.assertTrue(User.objects.filter(email="taylor.doe@example.com").exists()) + self.assertIsNone(child.user_id) + family = Family.objects.get(memberships__member=parent) + self.assertEqual(family, Family.objects.get(memberships__member=child)) + self.assertEqual(FamilyMembership.objects.get(family=family, member=parent).role, FamilyMembership.FamilyRole.PARENT) + self.assertEqual(FamilyMembership.objects.get(family=family, member=child).role, FamilyMembership.FamilyRole.CHILD) + + def test_family_role_without_a_group_is_an_error(self): + upload = make_import_workbook([["Odd", "Row", "", "odd.row@example.com", "", "", "", "", "", "", "parent"]]) + + response = self.club_post("member_import", {"file": upload}) + + result = response.context["results"][0] + self.assertIsNone(result["member"]) + self.assertTrue(any("family_group" in error.lower() for error in result["errors"])) + + def test_family_group_without_a_role_is_an_error(self): + upload = make_import_workbook([["Odd", "Row", "", "odd.row2@example.com", "", "", "", "", "", "Odd family", ""]]) + + response = self.club_post("member_import", {"file": upload}) + + result = response.context["results"][0] + self.assertIsNone(result["member"]) + self.assertTrue(any("family_role" in error.lower() for error in result["errors"])) + + def test_a_standalone_row_is_not_linked_to_any_family(self): + upload = make_import_workbook([["Solo", "Standalone", "", "solo@example.com", "", "", "", "", "", "", ""]]) + self.club_post("member_import", {"file": upload}) + + self.club_post("member_import_confirm", {}) + + member = Member.objects.get(email="solo@example.com") + self.assertFalse(FamilyMembership.objects.filter(member=member).exists()) + class NewsManagementTests(ManagementTestBase): def setUp(self): @@ -2906,6 +3458,7 @@ class EventManagementTests(ManagementTestBase): "end": "", "gathering": "", "deadline": "", + "max_referees": "2", } data.update(overrides) return data @@ -3360,6 +3913,530 @@ class EventDetailDisplayTests(ManagementTestBase): self.assertContains(response, "is not enabled for this club") +class EventRefereeManagementTests(ManagementTestBase): + """Assigning/removing referees from the event detail page's Referees + panel -- home games only, see management.views.EventRefereeAssignView/ + EventRefereeRemoveView and events.services.referees.""" + + def setUp(self): + super().setUp() + self.team = Team.objects.create(club=self.club, name="First Team", short_name="1st") + 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") + ClubMembership.objects.create(club=self.club, member=self.referee, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + self.referee_profile = RefereeProfile.objects.create(member=self.referee, level=self.level, valid_until=timezone.localdate() + datetime.timedelta(days=30)) + + def make_coach(self, team, email="coach-referees@example.com"): + coach_user = User.objects.create_user(email=email, password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + position = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True) + StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position) + return coach_user + + def make_game(self, **kwargs): + kwargs.setdefault("title", "Cup game") + kwargs.setdefault("kind", Event.EventKind.GAME) + kwargs.setdefault("location", self.home_ground) + kwargs.setdefault("start", timezone.now() + datetime.timedelta(days=1)) + event = Event.objects.create(club=self.club, **kwargs) + event.teams.add(self.team) + return event + + def test_the_referees_panel_only_shows_for_a_home_game(self): + # "Referees" alone also matches the nav link on every page -- assert on + # text unique to the panel itself. + home_game = self.make_game() + away_game = self.make_game(title="Away game", location=self.away_ground) + self.client.force_login(self.admin_user) + + self.assertContains(self.club_get("event_detail", home_game.pk), "No referees assigned yet.") + self.assertNotContains(self.club_get("event_detail", away_game.pk), "No referees assigned yet.") + + def test_the_referees_panel_is_replaced_by_a_note_for_a_federation_managed_team(self): + self.team.referee_management = Team.RefereeManagement.FEDERATION + self.team.save(update_fields=["referee_management"]) + home_game = self.make_game() + self.client.force_login(self.admin_user) + + response = self.club_get("event_detail", home_game.pk) + + self.assertNotContains(response, "No referees assigned yet.") + self.assertContains(response, "managed by the federation") + + def test_cannot_assign_a_referee_to_a_federation_managed_teams_game(self): + self.team.referee_management = Team.RefereeManagement.FEDERATION + self.team.save(update_fields=["referee_management"]) + game = self.make_game() + self.client.force_login(self.admin_user) + + response = self.club_post("event_referee_assign", {"member": str(self.referee.pk)}, game.pk) + + self.assertEqual(response.status_code, 404) + self.assertFalse(EventReferee.objects.filter(event=game).exists()) + + def test_a_teams_own_coach_gets_403_assigning_a_referee(self): + # Admin-only for now, even for the coach who manages this team -- + # see management.views.EventRefereeAssignView. + game = self.make_game() + self.client.force_login(self.make_coach(self.team)) + + response = self.club_post("event_referee_assign", {"member": str(self.referee.pk)}, game.pk) + + self.assertEqual(response.status_code, 403) + self.assertFalse(EventReferee.objects.filter(event=game, member=self.referee).exists()) + + def test_a_teams_own_coach_gets_403_removing_a_referee(self): + game = self.make_game() + assignment = EventReferee.objects.create(event=game, member=self.referee, assigned_by=self.admin_member) + self.client.force_login(self.make_coach(self.team)) + + response = self.club_post("event_referee_remove", {}, game.pk, assignment.pk) + + self.assertEqual(response.status_code, 403) + self.assertTrue(EventReferee.objects.filter(event=game, member=self.referee).exists()) + + def test_a_teams_coach_sees_the_referees_panel_but_not_the_assign_or_remove_controls(self): + game = self.make_game() + assignment = EventReferee.objects.create(event=game, member=self.referee, assigned_by=self.admin_member) + self.client.force_login(self.make_coach(self.team)) + + response = self.club_get("event_detail", game.pk) + + # The panel itself, and who's assigned, are still visible... + self.assertContains(response, "Ref Eree") + self.assertContains(response, "1 / 2") + # ...but not the controls to change it. + self.assertNotContains(response, reverse("management:event_referee_assign", args=[game.pk])) + self.assertNotContains(response, reverse("management:event_referee_remove", args=[game.pk, assignment.pk])) + + def test_admin_can_assign_an_eligible_referee(self): + game = self.make_game() + self.client.force_login(self.admin_user) + + response = self.club_post("event_referee_assign", {"member": str(self.referee.pk)}, game.pk) + + self.assertRedirects(response, reverse("management:event_detail", args=[game.pk])) + self.assertTrue(EventReferee.objects.filter(event=game, member=self.referee).exists()) + + def test_assigning_records_which_admin_assigned_them(self): + game = self.make_game() + self.client.force_login(self.admin_user) + + self.club_post("event_referee_assign", {"member": str(self.referee.pk)}, game.pk) + + assignment = EventReferee.objects.get(event=game, member=self.referee) + self.assertEqual(assignment.assigned_by, self.admin_member) + + def test_cannot_assign_beyond_max_referees(self): + game = self.make_game(max_referees=1) + second_referee = Member.objects.create(first_name="Second", last_name="Ref") + RefereeProfile.objects.create(member=second_referee, level=self.level, valid_until=timezone.localdate() + datetime.timedelta(days=30)) + self.client.force_login(self.admin_user) + self.club_post("event_referee_assign", {"member": str(self.referee.pk)}, game.pk) + + response = self.club_post("event_referee_assign", {"member": str(second_referee.pk)}, game.pk) + + self.assertRedirects(response, reverse("management:event_detail", args=[game.pk])) + self.assertEqual(EventReferee.objects.filter(event=game).count(), 1) + + def test_cannot_assign_a_referee_to_an_away_game(self): + # eligible_referees() is already empty for a non-home game, so the + # attempted member isn't found at all -- same 404 as any other + # crafted POST naming someone who isn't a legitimate candidate. + game = self.make_game(location=self.away_ground) + self.client.force_login(self.admin_user) + + response = self.club_post("event_referee_assign", {"member": str(self.referee.pk)}, game.pk) + + self.assertEqual(response.status_code, 404) + self.assertFalse(EventReferee.objects.filter(event=game).exists()) + + def test_cannot_assign_someone_not_eligible_via_a_crafted_post(self): + game = self.make_game() + ineligible = Member.objects.create(first_name="Not", last_name="Eligible") + self.client.force_login(self.admin_user) + + response = self.club_post("event_referee_assign", {"member": str(ineligible.pk)}, game.pk) + + self.assertEqual(response.status_code, 404) + self.assertFalse(EventReferee.objects.filter(event=game).exists()) + + def test_a_different_teams_coach_cannot_assign_a_referee(self): + other_team = Team.objects.create(club=self.club, name="Second Team", short_name="2nd") + game = self.make_game() + self.client.force_login(self.make_coach(other_team)) + + response = self.club_post("event_referee_assign", {"member": str(self.referee.pk)}, game.pk) + + self.assertEqual(response.status_code, 403) + + def test_can_remove_an_assigned_referee(self): + game = self.make_game() + assignment = EventReferee.objects.create(event=game, member=self.referee, assigned_by=self.admin_member) + self.client.force_login(self.admin_user) + + response = self.club_post("event_referee_remove", {}, game.pk, assignment.pk) + + self.assertRedirects(response, reverse("management:event_detail", args=[game.pk])) + self.assertFalse(EventReferee.objects.filter(event=game, member=self.referee).exists()) + + def test_conflict_warning_shown_but_does_not_block_the_assign_control(self): + # The referee is also on this team's roster and expected at an + # overlapping training -- shown as a warning, still selectable. + position = Position.objects.create(club=self.club, name="Forward", short_name="FW") + TeamMembership.objects.create(team=self.team, member=self.referee, season=self.season, position=position) + game = self.make_game(start=timezone.now() + datetime.timedelta(days=1)) + clashing_training = Event.objects.create(club=self.club, title="Clashing training", kind=Event.EventKind.TRAINING, start=game.start, end=game.start + datetime.timedelta(hours=1)) + clashing_training.teams.add(self.team) + self.client.force_login(self.admin_user) + + response = self.club_get("event_detail", game.pk) + + self.assertContains(response, "⚠") + self.assertContains(response, f'value="{self.referee.pk}"') + + def test_admin_can_add_an_external_referee(self): + game = self.make_game() + self.client.force_login(self.admin_user) + + response = self.club_post("event_referee_add_external", {"name": "Guest Referee"}, game.pk) + + self.assertRedirects(response, reverse("management:event_detail", args=[game.pk])) + assignment = EventReferee.objects.get(event=game, external_name="Guest Referee") + self.assertIsNone(assignment.member) + + def test_adding_an_external_referee_with_a_blank_name_is_rejected(self): + game = self.make_game() + self.client.force_login(self.admin_user) + + self.club_post("event_referee_add_external", {"name": " "}, game.pk) + + self.assertFalse(EventReferee.objects.filter(event=game).exists()) + + def test_a_coach_gets_403_adding_an_external_referee(self): + game = self.make_game() + self.client.force_login(self.make_coach(self.team)) + + response = self.club_post("event_referee_add_external", {"name": "Guest Referee"}, game.pk) + + self.assertEqual(response.status_code, 403) + + def test_event_detail_page_shows_an_external_referee(self): + game = self.make_game() + EventReferee.objects.create(event=game, external_name="Guest Referee", assigned_by=self.admin_member) + self.client.force_login(self.admin_user) + + response = self.club_get("event_detail", game.pk) + + self.assertContains(response, "Guest Referee") + self.assertContains(response, "External") + + def test_admin_can_set_a_referees_fee(self): + game = self.make_game() + assignment = EventReferee.objects.create(event=game, member=self.referee, assigned_by=self.admin_member) + self.client.force_login(self.admin_user) + + response = self.club_post("event_referee_fee_update", {"fee": "25.00", "km": "40", "km_rate": "0.35"}, game.pk, assignment.pk) + + self.assertRedirects(response, reverse("management:event_detail", args=[game.pk])) + assignment.refresh_from_db() + self.assertEqual(assignment.fee, Decimal("25.00")) + self.assertEqual(assignment.total_payable, Decimal("39.00")) + + def test_a_km_rate_with_more_than_two_decimals_is_accepted(self): + # e.g. a per-km rate of €0.083 -- the km_rate input must not be pinned + # to money-style step="0.01" the way the fee field is. + game = self.make_game() + assignment = EventReferee.objects.create(event=game, member=self.referee, assigned_by=self.admin_member) + self.client.force_login(self.admin_user) + + response = self.club_post("event_referee_fee_update", {"fee": "0", "km": "40", "km_rate": "0.083"}, game.pk, assignment.pk) + + self.assertRedirects(response, reverse("management:event_detail", args=[game.pk])) + assignment.refresh_from_db() + self.assertEqual(assignment.km_rate, Decimal("0.083")) + + def test_event_detail_page_shows_the_total_due_once_a_fee_is_set(self): + game = self.make_game() + EventReferee.objects.create(event=game, member=self.referee, assigned_by=self.admin_member, fee=Decimal("25.00")) + self.client.force_login(self.admin_user) + + response = self.club_get("event_detail", game.pk) + + self.assertContains(response, "25.00") + + def test_a_coach_gets_403_setting_a_fee(self): + game = self.make_game() + assignment = EventReferee.objects.create(event=game, member=self.referee, assigned_by=self.admin_member) + self.client.force_login(self.make_coach(self.team)) + + response = self.club_post("event_referee_fee_update", {"fee": "25.00"}, game.pk, assignment.pk) + + self.assertEqual(response.status_code, 403) + + +class EventRefereeFormPdfTests(ManagementTestBase): + """Downloadable referee payment form -- see management.views.EventRefereeFormPdfView, + modeled on the club's existing paper form.""" + + def setUp(self): + super().setUp() + self.team = Team.objects.create(club=self.club, name="First Team", short_name="1st") + 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.referee = Member.objects.create(first_name="Ref", last_name="Eree") + ClubMembership.objects.create(club=self.club, member=self.referee, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + + def make_coach(self, team, email="coach-refpdf@example.com"): + coach_user = User.objects.create_user(email=email, password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + position = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True) + StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position) + return coach_user + + def make_game(self, **kwargs): + kwargs.setdefault("title", "Cup game") + kwargs.setdefault("kind", Event.EventKind.GAME) + kwargs.setdefault("location", self.home_ground) + kwargs.setdefault("start", timezone.now() + datetime.timedelta(days=1)) + event = Event.objects.create(club=self.club, **kwargs) + event.teams.add(self.team) + return event + + def test_downloads_as_a_pdf(self): + game = self.make_game() + self.client.force_login(self.admin_user) + + with mock.patch("management.views.event_referee_form_pdf", return_value=b"%PDF-fake") as renderer: + response = self.club_get("event_referee_form_pdf", game.pk) + + self.assertEqual(response["Content-Type"], "application/pdf") + self.assertIn(".pdf", response["Content-Disposition"]) + self.assertEqual(response.content, b"%PDF-fake") + renderer.assert_called_once() + + def test_uses_the_clubs_legal_name_and_home_location_when_set(self): + self.club.legal_name = "Ajax United VZW" + self.club.save(update_fields=["legal_name"]) + game = self.make_game() + EventReferee.objects.create(event=game, member=self.referee, assigned_by=self.admin_member, fee=Decimal("25.00"), km=Decimal("40"), km_rate=Decimal("0.35")) + self.client.force_login(self.admin_user) + + with mock.patch("management.views.event_referee_form_pdf", return_value=b"%PDF-fake") as renderer: + self.club_get("event_referee_form_pdf", game.pk) + + context = renderer.call_args[0][0] + self.assertEqual(context["club"].official_name, "Ajax United VZW") + self.assertEqual(context["home_location"], self.home_ground) + self.assertEqual(list(context["referees"]), [EventReferee.objects.get(event=game)]) + + def test_a_missing_pdf_library_is_reported_rather_than_a_500(self): + game = self.make_game() + self.client.force_login(self.admin_user) + + with mock.patch("management.views.event_referee_form_pdf", side_effect=PDFExportError("PDF rendering needs the native pango/cairo libraries.")): + response = self.club_get("event_referee_form_pdf", game.pk) + response = self.client.get(response.url, HTTP_HOST="ajax-united.rosterchief.app") + + self.assertContains(response, "pango") + + def test_a_coach_gets_403(self): + game = self.make_game() + self.client.force_login(self.make_coach(self.team)) + + response = self.club_get("event_referee_form_pdf", game.pk) + + self.assertEqual(response.status_code, 403) + + +class RefereeManagementDashboardTests(ManagementTestBase): + """The admin-only one-stop view of upcoming home games needing a + club-arranged referee -- see management.views.RefereeManagementDashboardView.""" + + def setUp(self): + super().setUp() + self.team = Team.objects.create(club=self.club, name="First Team", short_name="1st") + self.federation_team = Team.objects.create(club=self.club, name="Federation Team", short_name="Fed", referee_management=Team.RefereeManagement.FEDERATION) + 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") + ClubMembership.objects.create(club=self.club, member=self.referee, season=self.season, status=ClubMembership.StatusChoices.ACTIVE) + RefereeProfile.objects.create(member=self.referee, level=self.level, valid_until=timezone.localdate() + datetime.timedelta(days=30)) + + def make_coach(self, team, email="coach-refdash@example.com"): + coach_user = User.objects.create_user(email=email, password="pw-secret-123") + coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach") + position = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True) + StaffAssignment.objects.create(team=team, member=coach_member, season=self.season, position=position) + return coach_user + + def make_game(self, team=None, **kwargs): + kwargs.setdefault("title", "Cup game") + kwargs.setdefault("kind", Event.EventKind.GAME) + kwargs.setdefault("location", self.home_ground) + kwargs.setdefault("start", timezone.now() + datetime.timedelta(days=1)) + event = Event.objects.create(club=self.club, **kwargs) + event.teams.add(team or self.team) + return event + + def test_is_admin_only(self): + self.client.force_login(self.make_coach(self.team)) + self.assertEqual(self.club_get("referee_management").status_code, 403) + + def test_lists_an_upcoming_club_managed_home_game(self): + game = self.make_game() + self.client.force_login(self.admin_user) + + response = self.club_get("referee_management") + + self.assertContains(response, reverse("management:event_detail", args=[game.pk])) + + def test_each_game_tile_links_straight_to_the_referee_form_pdf(self): + game = self.make_game() + self.client.force_login(self.admin_user) + + response = self.club_get("referee_management") + + self.assertContains(response, reverse("management:event_referee_form_pdf", args=[game.pk])) + + def test_the_game_tile_shows_assigned_referee_names(self): + game = self.make_game() + EventReferee.objects.create(event=game, member=self.referee, assigned_by=self.admin_member) + self.client.force_login(self.admin_user) + + response = self.club_get("referee_management") + + self.assertContains(response, str(self.referee)) + + def test_excludes_a_federation_managed_teams_game(self): + game = self.make_game(team=self.federation_team) + self.client.force_login(self.admin_user) + + response = self.club_get("referee_management") + + self.assertNotContains(response, reverse("management:event_detail", args=[game.pk])) + + def test_excludes_an_away_game(self): + game = self.make_game(location=self.away_ground) + self.client.force_login(self.admin_user) + + response = self.club_get("referee_management") + + self.assertNotContains(response, reverse("management:event_detail", args=[game.pk])) + + def test_excludes_a_past_game(self): + game = self.make_game(start=timezone.now() - datetime.timedelta(days=1)) + self.client.force_login(self.admin_user) + + response = self.club_get("referee_management") + + self.assertNotContains(response, reverse("management:event_detail", args=[game.pk])) + + def test_excludes_a_cancelled_game(self): + game = self.make_game(cancelled=True) + self.client.force_login(self.admin_user) + + response = self.club_get("referee_management") + + self.assertNotContains(response, reverse("management:event_detail", args=[game.pk])) + + def test_an_out_of_range_value_falls_back_to_the_default(self): + self.client.force_login(self.admin_user) + + response = self.club_get("referee_management") + response_with_bad_range = self.client.get(f"{reverse('management:referee_management')}?range=bogus", HTTP_HOST="ajax-united.rosterchief.app") + + self.assertEqual(response.context["range_choice"], "10") + self.assertEqual(response_with_bad_range.context["range_choice"], "10") + + def test_a_valid_range_is_honoured(self): + self.client.force_login(self.admin_user) + + response = self.client.get(f"{reverse('management:referee_management')}?range=25", HTTP_HOST="ajax-united.rosterchief.app") + + self.assertEqual(response.context["range_choice"], "25") + + def test_the_week_range_excludes_a_game_beyond_this_week(self): + today = timezone.localdate() + end_of_this_week = today + datetime.timedelta(days=6 - today.weekday()) + game_this_week = self.make_game(start=timezone.now() + datetime.timedelta(minutes=5)) + game_next_week = self.make_game(start=timezone.make_aware(datetime.datetime.combine(end_of_this_week + datetime.timedelta(days=1), datetime.time(10, 0)))) + self.client.force_login(self.admin_user) + + response = self.client.get(f"{reverse('management:referee_management')}?range=week", HTTP_HOST="ajax-united.rosterchief.app") + + self.assertContains(response, reverse("management:event_detail", args=[game_this_week.pk])) + self.assertNotContains(response, reverse("management:event_detail", args=[game_next_week.pk])) + + def test_kpis_count_games_by_referee_staffing(self): + self.make_game() + partially_staffed = self.make_game() + EventReferee.objects.create(event=partially_staffed, member=self.referee, assigned_by=self.admin_member) + self.client.force_login(self.admin_user) + + response = self.club_get("referee_management") + + self.assertEqual(response.context["kpi_total"], 2) + self.assertEqual(response.context["kpi_no_referee"], 1) + self.assertEqual(response.context["kpi_understaffed"], 1) + self.assertEqual(response.context["kpi_fully_staffed"], 0) + + def test_an_assigned_referee_gets_a_fee_form_for_the_dashboard_modal(self): + game = self.make_game() + EventReferee.objects.create(event=game, member=self.referee, assigned_by=self.admin_member) + self.client.force_login(self.admin_user) + + response = self.club_get("referee_management") + + self.assertContains(response, 'name="fee"') + + def test_assigning_from_the_dashboard_redirects_back_to_the_dashboard(self): + game = self.make_game() + self.client.force_login(self.admin_user) + + response = self.client.post( + reverse("management:event_referee_assign", args=[game.pk]), + {"member": str(self.referee.pk), "next": reverse("management:referee_management")}, + HTTP_HOST="ajax-united.rosterchief.app", + ) + + self.assertRedirects(response, reverse("management:referee_management")) + self.assertTrue(EventReferee.objects.filter(event=game, member=self.referee).exists()) + + def test_removing_from_the_dashboard_redirects_back_to_the_dashboard(self): + game = self.make_game() + assignment = EventReferee.objects.create(event=game, member=self.referee, assigned_by=self.admin_member) + self.client.force_login(self.admin_user) + + response = self.client.post( + reverse("management:event_referee_remove", args=[game.pk, assignment.pk]), + {"next": reverse("management:referee_management")}, + HTTP_HOST="ajax-united.rosterchief.app", + ) + + self.assertRedirects(response, reverse("management:referee_management")) + self.assertFalse(EventReferee.objects.filter(event=game, member=self.referee).exists()) + + def test_an_unsafe_next_is_ignored(self): + game = self.make_game() + self.client.force_login(self.admin_user) + + response = self.client.post( + reverse("management:event_referee_assign", args=[game.pk]), + {"member": str(self.referee.pk), "next": "https://evil.example.com/"}, + HTTP_HOST="ajax-united.rosterchief.app", + ) + + self.assertRedirects(response, reverse("management:event_detail", args=[game.pk])) + + class FeatureGatedSectionsTests(ManagementTestBase): """The Shop and Forms sections are still stubs (StubListMixin) and, on top of being admin-only, only exist for a club at all once their own waffle diff --git a/management/urls.py b/management/urls.py index 208c95f..4bc3441 100644 --- a/management/urls.py +++ b/management/urls.py @@ -22,6 +22,7 @@ urlpatterns = [ path("members//delete/", views.MemberDeleteView.as_view(), name="member_delete"), path("members//attach-family/", views.MemberAttachToFamilyView.as_view(), name="member_attach_family"), path("members//grant-login/", views.MemberGrantLoginView.as_view(), name="member_grant_login"), + path("members//referee-eligibility/", views.MemberRefereeEligibilityUpdateView.as_view(), name="member_referee_eligibility_update"), path("members//detach-family//", views.MemberDetachFromFamilyView.as_view(), name="member_detach_family"), path("families/new/", views.FamilyCreateView.as_view(), name="family_create"), path("families//", views.FamilyDetailView.as_view(), name="family_detail"), @@ -35,6 +36,13 @@ urlpatterns = [ path("roles/", views.ClubRoleListView.as_view(), name="role_list"), path("roles/new/", views.ClubRoleCreateView.as_view(), name="role_create"), path("roles//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//", views.GroupDetailView.as_view(), name="group_detail"), + path("groups//edit/", views.GroupUpdateView.as_view(), name="group_update"), + path("groups//delete/", views.GroupDeleteView.as_view(), name="group_delete"), + path("groups//bulk-add/", views.GroupBulkAddView.as_view(), name="group_bulk_add"), + path("groups//members//remove/", views.GroupMemberRemoveView.as_view(), name="group_member_remove"), # Teams path("teams/", views.TeamListView.as_view(), name="team_list"), path("teams/new/", views.TeamCreateView.as_view(), name="team_create"), @@ -42,6 +50,7 @@ urlpatterns = [ path("teams//edit/", views.TeamUpdateView.as_view(), name="team_update"), path("teams//delete/", views.TeamDeleteView.as_view(), name="team_delete"), path("teams//roster//add/", views.TeamRosterAddView.as_view(), name="team_roster_add"), + path("teams//roster//bulk-add/", views.TeamBulkAddView.as_view(), name="team_bulk_add"), path("teams//roster//edit/", views.TeamRosterUpdateView.as_view(), name="team_roster_update"), path("teams//roster//remove/", views.TeamRosterRemoveView.as_view(), name="team_roster_remove"), path("teams//staff//add/", views.TeamStaffAddView.as_view(), name="team_staff_add"), @@ -49,6 +58,11 @@ urlpatterns = [ path("teams//staff//remove/", views.TeamStaffRemoveView.as_view(), name="team_staff_remove"), path("teams//photo//set/", views.TeamPhotoSetView.as_view(), name="team_photo_set"), path("teams//photo//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//edit/", views.RefereeLevelUpdateView.as_view(), name="referee_level_update"), # News path("news/", views.NewsListView.as_view(), name="news_list"), path("news/new/", views.NewsCreateView.as_view(), name="news_create"), @@ -68,6 +82,11 @@ urlpatterns = [ path("events//delete/", views.EventDeleteView.as_view(), name="event_delete"), path("events//detach/", views.EventDetachView.as_view(), name="event_detach"), path("events//fetch-game-info/", views.EventFetchGameInfoView.as_view(), name="event_fetch_game_info"), + path("events//referees/assign/", views.EventRefereeAssignView.as_view(), name="event_referee_assign"), + path("events//referees/add-external/", views.EventRefereeAddExternalView.as_view(), name="event_referee_add_external"), + path("events//referees//remove/", views.EventRefereeRemoveView.as_view(), name="event_referee_remove"), + path("events//referees//fee/", views.EventRefereeFeeUpdateView.as_view(), name="event_referee_fee_update"), + path("events//referees/form-pdf/", views.EventRefereeFormPdfView.as_view(), name="event_referee_form_pdf"), path("events/rbihf-import/", views.RBIHFImportView.as_view(), name="rbihf_import"), path("events/rbihf-import/confirm/", views.RBIHFImportConfirmView.as_view(), name="rbihf_import_confirm"), path("event-series/new/", views.EventSeriesCreateView.as_view(), name="event_series_create"), diff --git a/management/views.py b/management/views.py index 6632c30..6c621a9 100644 --- a/management/views.py +++ b/management/views.py @@ -1,3 +1,6 @@ +from datetime import timedelta + +from django.core.exceptions import ValidationError from django.db import IntegrityError, transaction from django.db.models import Count, ProtectedError, Q from django.http import HttpResponse @@ -27,18 +30,20 @@ from club.services.fees import mark_as_paid, record_payment, remaining_balance from controlpanel.messages import notify from controlpanel.mixins import RedirectOnInvalidMixin from controlpanel.services.statistics import club_attention, club_charts, club_statistics -from events.models import Attendance, Event, EventSeries, Location, Opponent +from events.models import Attendance, Event, EventReferee, EventSeries, Location, Opponent from events.services.attendance import player_attendance_rankings, players_who_missed_recent_practices, team_attendance_rate, team_no_shows from events.services.competitions import CompetitionFetchError, fetch_game_info from events.services.rbihf_import import RBIHFImportError, apply_plan, build_plan, extract_team_id, fetch_html from events.services.recurrence import cancel_occurrence, detach_occurrence, generate_occurrences, propagate_series +from events.services.referees import RefereeAssignmentError, add_external_referee, assign_referee, conflicting_events, eligible_referees, needs_referee_management, remove_referee, set_referee_fee from formbuilder.models import Form as FormBuilderForm from formbuilder.models import Submission -from members.models import Family, FamilyMembership, Member -from members.services.family import add_child_to_family, add_parent_to_family, attach_to_family, detach_from_family, grant_login, register_family +from members.models import Family, FamilyMembership, Group, GroupMembership, Member +from members.services.family import add_child_to_family, add_parent_to_family, attach_to_family, detach_from_family, get_or_create_login_user, grant_login, register_family from news.models import News, NewsPhoto from shop.models import Discount, Invoice, Order, Product -from teams.models import Position, StaffAssignment, Team, TeamMembership, TeamPhoto +from teams.models import Position, RefereeLevel, RefereeProfile, StaffAssignment, Team, TeamMembership, TeamPhoto +from teams.services import eligible_roster_members from .bulk_import import build_member_import_template, parse_member_import_rows, read_member_import_workbook from .forms import ( @@ -48,12 +53,16 @@ from .forms import ( ClubMembershipForm, ClubRoleAssignForm, EventForm, + EventRefereeFeeForm, EventSeriesForm, + ExternalRefereeForm, FamilyCreateForm, GrantLoginForm, + GroupForm, LocationForm, MemberForm, MemberImportUploadForm, + MemberRefereeEligibilityForm, NewsForm, NewsPhotoUploadForm, NewsPublishForm, @@ -61,13 +70,14 @@ from .forms import ( PositionForm, RBIHFImportForm, RecordFeePaymentForm, + RefereeLevelForm, SponsorForm, StaffAssignmentForm, TeamForm, TeamMembershipForm, TeamPhotoForm, ) -from .pdf import PDFExportError, membership_list_pdf +from .pdf import PDFExportError, event_referee_form_pdf, membership_list_pdf from .recurrence_ui import describe_rrule @@ -476,12 +486,31 @@ class MemberImportConfirmView(ClubAdminRequiredMixin, View): season = current_season(request.club) created = 0 + families_by_group = {} with transaction.atomic(): for result in results: member = result["member"] if member is None: continue + + family_role = result["family_role"] + if family_role in (FamilyMembership.FamilyRole.PARENT, FamilyMembership.FamilyRole.GUARDIAN) and member.email: + # Give the parent/guardian a login before saving, same as + # registering a family by hand -- get_or_create_login_user + # reuses an existing account for that email rather than + # risking a duplicate. + member.user, _unused = get_or_create_login_user(member.email) + member.save() + + family_group = result["family_group"] + if family_group: + family = families_by_group.get(family_group) + if family is None: + family = Family.objects.create() + families_by_group[family_group] = family + FamilyMembership.objects.create(family=family, member=member, role=family_role) + if season is not None: ClubMembership.objects.create(club=request.club, member=member, season=season, signed_up_at=timezone.localdate(), **result["membership_kwargs"]) created += 1 @@ -585,6 +614,9 @@ class MemberDetailView(ClubStaffRequiredMixin, DetailView): family_scoped_members = visible.filter(family_memberships__family_id__in=my_family_ids).distinct() family_groups, _ = group_by_family(family_scoped_members) + is_admin = is_club_admin(self.request.user, self.request.club) + referee_profile = RefereeProfile.objects.filter(member=self.object).select_related("level").first() + return super().get_context_data( family_groups=family_groups, family_role_choices=FamilyMembership.FamilyRole.choices, @@ -597,6 +629,8 @@ class MemberDetailView(ClubStaffRequiredMixin, DetailView): # signal the Personal information card uses to decide whether to show parent # contact numbers at all. guardians=self.object.guardians, + referee_profile=referee_profile, + referee_eligibility_form=MemberRefereeEligibilityForm(club=self.request.club, member=self.object) if is_admin else None, **kwargs, ) @@ -623,6 +657,31 @@ class MemberAttachToFamilyView(ClubAdminRequiredMixin, RedirectOnInvalidMixin, F return redirect("management:member_detail", pk=member.pk) +class MemberRefereeEligibilityUpdateView(ClubAdminRequiredMixin, RedirectOnInvalidMixin, FormView): + """Reachable only via the "Referee eligibility" modal on a member's page -- + which teams' home games this member can be assigned to referee + (teams.RefereeProfile). Admin-only, same as Roles/Positions/Groups.""" + + form_class = MemberRefereeEligibilityForm + http_method_names = ["post"] + invalid_redirect_url_name = "management:member_detail" + + def get_invalid_redirect_kwargs(self): + return {"pk": self.kwargs["pk"]} + + def get_member(self): + return get_object_or_404(members_visible_to(self.request.user, self.request.club), pk=self.kwargs["pk"]) + + def get_form_kwargs(self): + return super().get_form_kwargs() | {"club": self.request.club, "member": self.get_member()} + + def form_valid(self, form): + member = self.get_member() + form.save() + notify(self.request, f"s|{_('Referee eligibility updated')}|" + _("Updated which teams “%(member)s” can referee for.") % {"member": member}) + return redirect("management:member_detail", pk=member.pk) + + class MemberGrantLoginView(ClubAdminRequiredMixin, RedirectOnInvalidMixin, FormView): """Reachable only via the "Grant login" modal on a login-less child's row in _family_members_table.html. The form itself (management.forms.GrantLoginForm) @@ -847,6 +906,13 @@ class TeamDetailView(ClubStaffRequiredMixin, DetailView): bottom_attenders=bottom_attenders, missed_practices=missed_practices, no_shows=no_shows, + # None (not an empty queryset) signals "federation-managed" to the + # template, distinct from "club-managed, nobody eligible yet". + eligible_referees=( + Member.objects.filter(referee_profile__level__teams=team, referee_profile__valid_until__gte=timezone.localdate()).order_by("last_name", "first_name") + if team.referee_management == Team.RefereeManagement.CLUB + else None + ), **kwargs, ) @@ -1027,6 +1093,131 @@ class TeamStaffRemoveView(TeamManagerRequiredMixin, View): return redirect(f"{reverse('management:team_detail', args=[pk])}?season={season_id}") +class TeamBulkAddView(TeamManagerRequiredMixin, View): + """Add many people to a team's roster and/or staff in one go -- the one-by-one + modals (TeamRosterAddView / TeamStaffAddView) don't scale past a handful of + names. Every eligible member gets an independent "add as player" and "add as + staff" pair of controls, so one person can be added as both in a single submit + (a playing coach, most often). + + Same eligibility rule as the single-add forms (eligible_roster_members: active + -- i.e. paid -- for this club this season or next), enforced server-side + regardless of what the client submits. + """ + + template_name = "management/team_bulk_add.html" + + def get_team(self): + return get_object_or_404(Team.objects.filter(club=self.request.club), pk=self.kwargs["pk"]) + + def get_season(self): + return get_object_or_404(Season.objects.filter(club=self.request.club), pk=self.kwargs["season_pk"]) + + def get_context_data(self, **kwargs): + team = self.get_team() + season = self.get_season() + club = self.request.club + + members = eligible_roster_members(club).order_by("last_name", "first_name") + search = self.request.GET.get("q", "").strip() + if search: + members = members.filter(Q(first_name__icontains=search) | Q(last_name__icontains=search)) + + rostered = set(TeamMembership.objects.filter(team=team, season=season).values_list("member_id", flat=True)) + staffed = set(StaffAssignment.objects.filter(team=team, season=season).values_list("member_id", flat=True)) + members = list(members) + for member in members: + member.already_player = member.pk in rostered + member.already_staff = member.pk in staffed + + return { + "team": team, + "season": season, + "members": members, + "search": search, + "player_positions": Position.objects.filter(club=club, staff_position=False), + "staff_positions": Position.objects.filter(club=club, staff_position=True), + **kwargs, + } + + def get(self, request, *args, **kwargs): + return render(request, self.template_name, self.get_context_data()) + + def post(self, request, *args, **kwargs): + team = self.get_team() + season = self.get_season() + club = self.request.club + + rostered = set(TeamMembership.objects.filter(team=team, season=season).values_list("member_id", flat=True)) + staffed = set(StaffAssignment.objects.filter(team=team, season=season).values_list("member_id", flat=True)) + used_jerseys = set(TeamMembership.objects.filter(team=team, season=season, jersey_number__isnull=False).values_list("jersey_number", flat=True)) + player_positions = {str(position.pk): position for position in Position.objects.filter(club=club, staff_position=False)} + staff_positions = {str(position.pk): position for position in Position.objects.filter(club=club, staff_position=True)} + + players_added = staff_added = 0 + errors = [] + + # Recomputed server-side from eligible_roster_members, never from client + # input -- a submitted member id that isn't actually eligible (lapsed + # between page load and submit, say) is silently skipped rather than + # trusted. + for member in eligible_roster_members(club): + key = str(member.pk) + + if member.pk not in rostered and request.POST.get(f"player_{key}"): + position = player_positions.get(request.POST.get(f"player_position_{key}", "")) + if position is None: + errors.append(_("%(member)s: choose a position to add them as a player.") % {"member": member}) + else: + jersey_raw = request.POST.get(f"jersey_{key}", "").strip() + jersey_number, jersey_error = None, False + if jersey_raw: + try: + jersey_number = int(jersey_raw) + except ValueError: + jersey_error = True + errors.append(_("%(member)s: jersey number must be a whole number.") % {"member": member}) + + if not jersey_error: + if jersey_number is not None and jersey_number in used_jerseys: + errors.append(_("%(member)s: jersey #%(number)s is already taken this season.") % {"member": member, "number": jersey_number}) + else: + membership = TeamMembership(team=team, season=season, member=member, position=position, jersey_number=jersey_number) + try: + membership.full_clean() + membership.save() + except (ValidationError, IntegrityError): + errors.append(_("%(member)s: could not be added as a player -- please check the details and try again.") % {"member": member}) + else: + players_added += 1 + if jersey_number is not None: + used_jerseys.add(jersey_number) + + if member.pk not in staffed and request.POST.get(f"staff_{key}"): + position = staff_positions.get(request.POST.get(f"staff_position_{key}", "")) + if position is None: + errors.append(_("%(member)s: choose a position to add them as staff.") % {"member": member}) + else: + assignment = StaffAssignment(team=team, season=season, member=member, position=position) + try: + assignment.full_clean() + assignment.save() + except (ValidationError, IntegrityError): + errors.append(_("%(member)s: could not be assigned as staff -- please check the details and try again.") % {"member": member}) + else: + staff_added += 1 + + if players_added or staff_added: + body = _("%(players)s added as player(s), %(staff)s added as staff.") % {"players": players_added, "staff": staff_added} + notify(request, f"s|{_('Team updated')}|{body}") + for error in errors: + notify(request, f"e|{_('Could not add')}|{error}") + if not players_added and not staff_added and not errors: + notify(request, f"i|{_('Nothing to add')}|{_('No one was selected.')}") + + return redirect(f"{reverse('management:team_detail', args=[team.pk])}?season={season.pk}") + + class TeamPhotoSetView(TeamManagerRequiredMixin, FormView): """Reachable only via the "Upload"/"Replace" modal on the team page. Binds to the existing TeamPhoto for this team+season (if any) so re-uploading @@ -1315,6 +1506,200 @@ class PositionUpdateView(ClubAdminRequiredMixin, UpdateView): return super().get_context_data(update_view=True, **kwargs) +class RefereeLevelListView(ClubStaffRequiredMixin, ListView): + """Visible to any staff, same reasoning as PositionListView; creating/ + editing a level is admin-only.""" + + template_name = "management/referee_level_list.html" + context_object_name = "levels" + + def get_queryset(self): + return RefereeLevel.objects.filter(club=self.request.club).prefetch_related("teams") + + +class RefereeLevelCreateView(ClubAdminRequiredMixin, CreateView): + model = RefereeLevel + form_class = RefereeLevelForm + template_name = "management/referee_level_form.html" + + def get_form_kwargs(self): + return super().get_form_kwargs() | {"club": self.request.club} + + def form_valid(self, form): + form.instance.club = self.request.club + response = super().form_valid(form) + body = _("“%(level)s” created.") % {"level": self.object} + notify(self.request, f"s|{_('Referee level created')}|{body}") + return response + + def get_success_url(self): + return reverse("management:referee_level_list") + + +class RefereeLevelUpdateView(ClubAdminRequiredMixin, UpdateView): + model = RefereeLevel + form_class = RefereeLevelForm + template_name = "management/referee_level_form.html" + + def get_queryset(self): + return RefereeLevel.objects.filter(club=self.request.club) + + def get_form_kwargs(self): + return super().get_form_kwargs() | {"club": self.request.club} + + def form_valid(self, form): + response = super().form_valid(form) + body = _("“%(level)s” updated.") % {"level": self.object} + notify(self.request, f"s|{_('Referee level updated')}|{body}") + return response + + def get_success_url(self): + return reverse("management:referee_level_list") + + def get_context_data(self, **kwargs): + return super().get_context_data(update_view=True, **kwargs) + + +class RefereeListView(ClubStaffRequiredMixin, ListView): + """Every referee in the club, at a glance: level, eligible teams, validity + -- see teams.RefereeProfile. Read-only; editing happens on the member's + own page (MemberRefereeEligibilityUpdateView).""" + + template_name = "management/referee_list.html" + context_object_name = "referees" + + def get_queryset(self): + members = members_visible_to(self.request.user, self.request.club).filter(referee_profile__isnull=False) + return members.select_related("referee_profile", "referee_profile__level").prefetch_related("referee_profile__level__teams").order_by("last_name", "first_name") + + +# --- Groups: a generic named collection of members (all coaches, all team managers, +# a referee pool, ...) -- admin-only, like Positions/Roles above ------------------- + + +class GroupListView(ClubAdminRequiredMixin, ListView): + template_name = "management/group_list.html" + context_object_name = "groups" + + def get_queryset(self): + return Group.objects.filter(club=self.request.club).annotate(member_count=Count("memberships", distinct=True)) + + +class GroupCreateView(ClubAdminRequiredMixin, CreateView): + model = Group + form_class = GroupForm + template_name = "management/group_form.html" + + def form_valid(self, form): + form.instance.club = self.request.club + response = super().form_valid(form) + body = _("“%(group)s” created.") % {"group": self.object} + notify(self.request, f"s|{_('Group created')}|{body}") + return response + + def get_success_url(self): + return reverse("management:group_detail", args=[self.object.pk]) + + +class GroupUpdateView(ClubAdminRequiredMixin, UpdateView): + model = Group + form_class = GroupForm + template_name = "management/group_form.html" + + def get_queryset(self): + return Group.objects.filter(club=self.request.club) + + def form_valid(self, form): + response = super().form_valid(form) + body = _("“%(group)s” updated.") % {"group": self.object} + notify(self.request, f"s|{_('Group updated')}|{body}") + return response + + def get_success_url(self): + return reverse("management:group_detail", args=[self.object.pk]) + + def get_context_data(self, **kwargs): + return super().get_context_data(update_view=True, **kwargs) + + +class GroupDeleteView(ClubAdminRequiredMixin, View): + def post(self, request, pk): + group = get_object_or_404(Group.objects.filter(club=request.club), pk=pk) + name = str(group) + group.delete() + notify(request, f"w|{_('Group deleted')}|" + _("“%(group)s” deleted.") % {"group": name}) + return redirect("management:group_list") + + +class GroupDetailView(ClubAdminRequiredMixin, DetailView): + template_name = "management/group_detail.html" + context_object_name = "group" + + def get_queryset(self): + return Group.objects.filter(club=self.request.club) + + def get_context_data(self, **kwargs): + return super().get_context_data( + memberships=GroupMembership.objects.filter(group=self.object).select_related("member"), + **kwargs, + ) + + +class GroupBulkAddView(ClubAdminRequiredMixin, View): + """Add many members to a group in one go -- mirrors TeamBulkAddView's + checkbox-table pattern, minus the player/staff split (group membership has + no per-member attributes).""" + + template_name = "management/group_bulk_add.html" + + def get_group(self): + return get_object_or_404(Group.objects.filter(club=self.request.club), pk=self.kwargs["pk"]) + + def get_context_data(self, **kwargs): + group = self.get_group() + members = members_visible_to(self.request.user, self.request.club).order_by("last_name", "first_name") + search = self.request.GET.get("q", "").strip() + if search: + members = members.filter(Q(first_name__icontains=search) | Q(last_name__icontains=search)) + + existing_ids = set(GroupMembership.objects.filter(group=group).values_list("member_id", flat=True)) + members = list(members) + for member in members: + member.already_in_group = member.pk in existing_ids + + return {"group": group, "members": members, "search": search, **kwargs} + + def get(self, request, *args, **kwargs): + return render(request, self.template_name, self.get_context_data()) + + def post(self, request, *args, **kwargs): + group = self.get_group() + existing_ids = set(GroupMembership.objects.filter(group=group).values_list("member_id", flat=True)) + + added = 0 + for member in members_visible_to(request.user, request.club): + if member.pk not in existing_ids and request.POST.get(f"member_{member.pk}"): + GroupMembership.objects.create(group=group, member=member) + added += 1 + + if added: + notify(request, f"s|{_('Group updated')}|" + _("%(count)s member(s) added to “%(group)s”.") % {"count": added, "group": group}) + else: + notify(request, f"i|{_('Nothing to add')}|{_('No one was selected.')}") + + return redirect("management:group_detail", pk=group.pk) + + +class GroupMemberRemoveView(ClubAdminRequiredMixin, View): + def post(self, request, pk, membership_pk): + group = get_object_or_404(Group.objects.filter(club=request.club), pk=pk) + membership = get_object_or_404(GroupMembership.objects.filter(group=group), pk=membership_pk) + member = membership.member + membership.delete() + notify(request, f"w|{_('Removed from group')}|" + _("“%(member)s” removed from “%(group)s”.") % {"member": member, "group": group}) + return redirect("management:group_detail", pk=group.pk) + + # --- News: draft/edit is broad (any coach_manager/editor/admin), but only EDITOR/ADMIN # may publish -- the release flow the news app exists for --------------------------- @@ -1596,10 +1981,251 @@ class EventDetailView(ClubStaffRequiredMixin, DetailView): # per-status sections, so there's exactly one query, not two. attendance_groups = [{"value": value, "label": label, "rows": rows_by_status.get(value, [])} for value, label in Attendance.AttendanceStatus.choices] + # Admin-only for now, unlike can_manage's other actions (edit, fetch info, ...) -- + # a team manager/coach still sees the Referees panel (who's assigned, capacity), + # just not the assign/remove controls. See EventRefereeAssignView/RemoveView. + can_manage_referees = is_club_admin(user, club) + + referee_management_needed = needs_referee_management(event) + referees = [] + referee_candidates = [] + referees_full = False + if referee_management_needed: + referees = list(event.referees.select_related("member", "assigned_by").order_by("member__last_name", "member__first_name")) + referees_full = len(referees) >= event.max_referees + if can_manage_referees: + for referee in referees: + referee.fee_form = EventRefereeFeeForm(instance=referee) + if can_manage_referees and not referees_full: + for candidate in eligible_referees(event): + conflicts = conflicting_events(candidate, event) + candidate.has_conflict = bool(conflicts) + candidate.conflict_titles = ", ".join(conflict.title for conflict in conflicts) + referee_candidates.append(candidate) + return super().get_context_data( can_manage=can_manage, + can_manage_referees=can_manage_referees, + referee_management_needed=referee_management_needed, attendance_groups=attendance_groups, has_attendance_rows=any(group["rows"] for group in attendance_groups), + referees=referees, + referee_candidates=referee_candidates, + referees_full=referees_full, + **kwargs, + ) + + +def _redirect_next_or(request, fallback_url): + """`next` (POST body, or the query string -- the fee-edit modal posts to a + plain action_url with no room to inject a hidden field, so it carries + `next` there instead) if it's safe to redirect to, else `fallback_url`. + Lets the same assign/remove/fee endpoints be posted to from more than one + page (the event detail page, and the referee management dashboard) and + return the visitor to wherever they actually came from.""" + next_url = request.POST.get("next") or request.GET.get("next") + if next_url and url_has_allowed_host_and_scheme(next_url, allowed_hosts={request.get_host()}, require_https=request.is_secure()): + return redirect(next_url) + return redirect(fallback_url) + + +class EventRefereeAssignView(ClubAdminRequiredMixin, View): + """Reachable via the assign control on the event detail page's Referees + panel, or the referee management dashboard -- POST-only, no standalone + template. Admin-only for now (unlike most event actions, which a team + manager can also do) -- see EventDetailView's can_manage_referees; team + managers/coaches still see the panel, just not the assign/remove + controls.""" + + def get_event(self): + return get_object_or_404(Event.objects.filter(club=self.request.club), pk=self.kwargs["pk"]) + + def post(self, request, pk): + event = self.get_event() + member = get_object_or_404(eligible_referees(event), pk=request.POST.get("member")) + assigned_by = Member.objects.filter(user=request.user).first() + + try: + assign_referee(event, member, assigned_by=assigned_by) + except RefereeAssignmentError as error: + notify(request, f"e|{_('Could not assign referee')}|{error}") + else: + notify(request, f"s|{_('Referee assigned')}|" + _("“%(member)s” will referee this game.") % {"member": member}) + + return _redirect_next_or(request, reverse("management:event_detail", args=[event.pk])) + + +class EventRefereeRemoveView(ClubAdminRequiredMixin, View): + def get_event(self): + return get_object_or_404(Event.objects.filter(club=self.request.club), pk=self.kwargs["pk"]) + + def post(self, request, pk, referee_pk): + event = self.get_event() + referee = get_object_or_404(EventReferee.objects.filter(event=event), pk=referee_pk) + name = referee.display_name + remove_referee(referee) + notify(request, f"w|{_('Referee removed')}|" + _("“%(name)s” is no longer refereeing this game.") % {"name": name}) + return _redirect_next_or(request, reverse("management:event_detail", args=[event.pk])) + + +class EventRefereeAddExternalView(ClubAdminRequiredMixin, View): + """Log a non-member referee (federation-appointed, most often) against a + game -- reachable from the same Referees panel as EventRefereeAssignView, + on both the event detail page and the referee management dashboard.""" + + def get_event(self): + return get_object_or_404(Event.objects.filter(club=self.request.club), pk=self.kwargs["pk"]) + + def post(self, request, pk): + event = self.get_event() + form = ExternalRefereeForm(request.POST) + fallback = _redirect_next_or(request, reverse("management:event_detail", args=[event.pk])) + + if not form.is_valid(): + notify(request, f"e|{_('Could not add referee')}|{_('A name is required.')}") + return fallback + + assigned_by = Member.objects.filter(user=request.user).first() + try: + add_external_referee(event, form.cleaned_data["name"], assigned_by=assigned_by) + except RefereeAssignmentError as error: + notify(request, f"e|{_('Could not add referee')}|{error}") + else: + notify(request, f"s|{_('Referee added')}|" + _("“%(name)s” will referee this game.") % {"name": form.cleaned_data["name"]}) + + return fallback + + +class EventRefereeFeeUpdateView(ClubAdminRequiredMixin, FormView): + """Set one referee assignment's fee/km/rate -- reachable via the "Fee" + modal on the Referees panel. POST-only, no standalone template. Not + RedirectOnInvalidMixin: that redirects to a fixed url name, but this view + (like the assign/remove ones) needs to honour the next-aware fallback so + it works from both the event detail page and the dashboard.""" + + form_class = EventRefereeFeeForm + http_method_names = ["post"] + + def get_event(self): + return get_object_or_404(Event.objects.filter(club=self.request.club), pk=self.kwargs["pk"]) + + def get_referee(self): + return get_object_or_404(EventReferee.objects.filter(event=self.get_event()), pk=self.kwargs["referee_pk"]) + + def get_fallback(self): + return _redirect_next_or(self.request, reverse("management:event_detail", args=[self.kwargs["pk"]])) + + def get_form_kwargs(self): + return super().get_form_kwargs() | {"instance": self.get_referee()} + + def form_invalid(self, form): + for error in form.errors.values(): + notify(self.request, f"e|{_('Could not update fee')}|{' '.join(error)}") + return self.get_fallback() + + def form_valid(self, form): + referee = self.get_referee() + set_referee_fee(referee, fee=form.cleaned_data["fee"], km=form.cleaned_data["km"], km_rate=form.cleaned_data["km_rate"]) + notify(self.request, f"s|{_('Fee updated')}|" + _("Updated the fee for “%(name)s”.") % {"name": referee.display_name}) + return self.get_fallback() + + +class EventRefereeFormPdfView(ClubAdminRequiredMixin, View): + """Downloadable PDF of the referee payment form for one game, modeled on + the club's existing paper form -- club header (legal name if set, else + plain name; address from the club's home Location, not this specific + event's, so the form still reads right even if called from a page where + the event's own location happens to be blank) plus this game's details, + referees and their fee/km breakdown, and blank signature lines.""" + + def get(self, request, pk): + event = get_object_or_404(Event.objects.filter(club=request.club).prefetch_related("teams", "referees__member"), pk=pk) + home_location = Location.objects.filter(club=request.club, is_home=True).first() + context = {"club": request.club, "event": event, "referees": list(event.referees.all()), "home_location": home_location} + + try: + pdf = event_referee_form_pdf(context) + except PDFExportError as error: + notify(request, f"e|{_('PDF unavailable')}|{error}") + return redirect(reverse("management:event_detail", args=[event.pk])) + + response = HttpResponse(pdf, content_type="application/pdf") + response["Content-Disposition"] = f'attachment; filename="referee-form-{event.pk}.pdf"' + return response + + +class RefereeManagementDashboardView(ClubAdminRequiredMixin, TemplateView): + """One-stop admin view of every upcoming home game that needs a + club-arranged referee (federation-managed teams never appear here, see + events.services.referees.needs_referee_management), with inline + assign/remove -- posts to the same EventRefereeAssignView/RemoveView the + event detail page uses, via the shared _referee_assignment_panel include, + and returns here afterwards rather than to the event detail page. + + The `range` GET param picks either a calendar window ("week"/"two_weeks", + both anchored on the ISO week so "this week" always means Mon-Sun of the + current week regardless of what weekday it is today) or a flat count of + upcoming games -- buttons in the template, not a dropdown, since there + are only a handful of sensible choices.""" + + template_name = "management/referee_management.html" + RANGE_CHOICES = ["week", "two_weeks", "10", "25", "50"] + DEFAULT_RANGE = "10" + + def get_range(self): + value = self.request.GET.get("range", self.DEFAULT_RANGE) + return value if value in self.RANGE_CHOICES else self.DEFAULT_RANGE + + def get_context_data(self, **kwargs): + club = self.request.club + range_choice = self.get_range() + + queryset = ( + Event.objects.filter(club=club, kind=Event.EventKind.GAME, cancelled=False, location__is_home=True, start__gte=timezone.now(), teams__referee_management=Team.RefereeManagement.CLUB) + .distinct() + .select_related("location", "opponent") + .prefetch_related("teams", "referees__member", "referees__assigned_by") + .order_by("start") + ) + + if range_choice in ("week", "two_weeks"): + today = timezone.localdate() + end_of_this_week = today + timedelta(days=6 - today.weekday()) + end_date = end_of_this_week + timedelta(days=7) if range_choice == "two_weeks" else end_of_this_week + games = list(queryset.filter(start__date__lte=end_date)) + else: + games = list(queryset[: int(range_choice)]) + + kpi_no_referee = 0 + kpi_understaffed = 0 + kpi_fully_staffed = 0 + + for game in games: + game.referee_rows = list(game.referees.all()) + for referee in game.referee_rows: + referee.fee_form = EventRefereeFeeForm(instance=referee) + game.referees_full = len(game.referee_rows) >= game.max_referees + game.referee_candidates = [] + if not game.referee_rows: + kpi_no_referee += 1 + elif not game.referees_full: + kpi_understaffed += 1 + else: + kpi_fully_staffed += 1 + if not game.referees_full: + for candidate in eligible_referees(game): + conflicts = conflicting_events(candidate, game) + candidate.has_conflict = bool(conflicts) + candidate.conflict_titles = ", ".join(conflict.title for conflict in conflicts) + game.referee_candidates.append(candidate) + + return super().get_context_data( + games=games, + range_choice=range_choice, + kpi_total=len(games), + kpi_no_referee=kpi_no_referee, + kpi_understaffed=kpi_understaffed, + kpi_fully_staffed=kpi_fully_staffed, **kwargs, ) diff --git a/members/admin.py b/members/admin.py index f76e698..5839024 100644 --- a/members/admin.py +++ b/members/admin.py @@ -1,7 +1,7 @@ from django.contrib import admin 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. @@ -60,3 +60,30 @@ class FamilyMembershipAdmin(admin.ModelAdmin): list_filter = ("role",) autocomplete_fields = ("family", "member") 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") diff --git a/members/migrations/0004_group_groupmembership_and_more.py b/members/migrations/0004_group_groupmembership_and_more.py new file mode 100644 index 0000000..c43deff --- /dev/null +++ b/members/migrations/0004_group_groupmembership_and_more.py @@ -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'), + ), + ] diff --git a/members/models.py b/members/models.py index 27bcd37..ddd4ce2 100644 --- a/members/models.py +++ b/members/models.py @@ -3,7 +3,7 @@ from django.db import models from django.utils.translation import gettext_lazy as _ from phonenumber_field.modelfields import PhoneNumberField -from rosterchief.base import UUIDModel +from rosterchief.base import ClubScopedModel, UUIDModel class Family(UUIDModel): @@ -99,3 +99,42 @@ class FamilyMembership(models.Model): def __str__(self): 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}" diff --git a/members/services/family.py b/members/services/family.py index 8dc4916..416e367 100644 --- a/members/services/family.py +++ b/members/services/family.py @@ -18,17 +18,22 @@ def find_member_by_email(email): return Member.objects.filter(user__email__iexact=email).first() -def get_or_create_login_member(email, first_name="", last_name=""): - """Find or create the Member behind ``email``, creating a User (no usable - password -- they set one via the reset-link flow) the first time we see them. - Mirrors controlpanel.services.admins.grant_club_admin. - """ +def get_or_create_login_user(email): + """Find or create the User behind ``email`` -- no usable password, they set + one via the reset-link flow the first time we see them. Mirrors + controlpanel.services.admins.grant_club_admin.""" email = email.lower() user, user_created = User.objects.get_or_create(email=email, defaults={"is_active": True}) if user_created: user.set_unusable_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}) return member diff --git a/members/tests.py b/members/tests.py index aaa77fd..c029b1b 100644 --- a/members/tests.py +++ b/members/tests.py @@ -15,7 +15,7 @@ from django.utils import timezone from authentication.models import User from club.models import Club, ClubMembership, Season 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 @@ -209,6 +209,62 @@ class FamilyMembershipModelTests(TestCase): 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): def test_member_count_reflects_memberships(self): family = Family.objects.create(name="The Smiths") @@ -241,6 +297,8 @@ class AdminSmokeTests(TestCase): ("members", "member"), ("members", "family"), ("members", "familymembership"), + ("members", "group"), + ("members", "groupmembership"), ): with self.subTest(model=model): response = self.client.get(f"/admin/{app_label}/{model}/") diff --git a/static/css/app.css b/static/css/app.css index 8a11fc5..f220db8 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -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 { @layer daisyui.l1.l2.l3 { 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 { 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 { @layer daisyui.l1.l2.l3 { 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 { @layer daisyui.l1.l2.l3 { 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 { @layer daisyui.l1.l2.l3 { 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 { @layer daisyui.l1.l2.l3 { display: flex; @@ -2320,6 +2562,30 @@ .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 { @layer daisyui.l1.l2 { --anchor-h: span-left; @@ -2351,9 +2617,53 @@ .top-1 { top: var(--spacing); } + .top-2 { + top: calc(var(--spacing) * 2); + } + .right-2 { + right: calc(var(--spacing) * 2); + } .left-1 { 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 { @layer daisyui.l1.l2.l3 { 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 { @layer daisyui.l1.l2.l3 { 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 { @layer daisyui.l1.l2.l3 { margin-top: calc(0.25rem * 6); @@ -3164,12 +3518,23 @@ .inline-flex { display: inline-flex; } + .inline-grid { + display: inline-grid; + } .table { display: table; } .aspect-square { 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 { width: calc(var(--spacing) * 8); height: calc(var(--spacing) * 8); @@ -3186,6 +3551,9 @@ .h-72 { height: calc(var(--spacing) * 72); } + .h-full { + height: 100%; + } .h-screen { height: 100vh; } @@ -3201,6 +3569,9 @@ .w-16 { width: calc(var(--spacing) * 16); } + .w-20 { + width: calc(var(--spacing) * 20); + } .w-60 { width: calc(var(--spacing) * 60); } @@ -3237,18 +3608,55 @@ .flex-1 { flex: 1; } + .flex-shrink { + flex-shrink: 1; + } .shrink { flex-shrink: 1; } .shrink-0 { flex-shrink: 0; } + .flex-grow { + flex-grow: 1; + } .grow { flex-grow: 1; } .border-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 { animation: var(--animate-pulse); } @@ -3324,6 +3732,9 @@ .justify-start { justify-content: flex-start; } + .justify-items-start { + justify-items: start; + } .gap-1 { gap: var(--spacing); } @@ -3575,6 +3986,9 @@ --btn-shadow: 0 0 0 0 oklch(0% 0 0/0); } } + .mask-repeat { + mask-repeat: repeat; + } .object-contain { object-fit: contain; } @@ -3838,6 +4252,9 @@ text-decoration-line: none; } } + .underline { + text-decoration-line: underline; + } .opacity-40 { opacity: 40%; } @@ -3894,6 +4311,19 @@ .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,); } + .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 { @layer daisyui.l1.l2 { --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 { @layer daisyui.l1.l2 { --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 { @media (width >= 40rem) { grid-column: span 2 / span 2; @@ -4122,6 +4573,11 @@ 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 { @media (width >= 64rem) { grid-template-columns: repeat(6, minmax(0, 1fr)); @@ -4552,6 +5008,26 @@ 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 { syntax: "*"; inherits: false; @@ -4718,6 +5194,10 @@ syntax: "*"; inherits: false; } +@property --tw-ease { + syntax: "*"; + inherits: false; +} @keyframes pulse { 50% { opacity: 0.5; @@ -4726,6 +5206,11 @@ @layer properties { @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 { + --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-divide-y-reverse: 0; --tw-border-style: solid; @@ -4764,6 +5249,7 @@ --tw-drop-shadow-color: initial; --tw-drop-shadow-alpha: 100%; --tw-drop-shadow-size: initial; + --tw-ease: initial; } } } diff --git a/teams/admin.py b/teams/admin.py index d570912..aeb86a3 100644 --- a/teams/admin.py +++ b/teams/admin.py @@ -1,7 +1,7 @@ from django.contrib import admin 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): @@ -29,8 +29,8 @@ class TeamPhotoInline(admin.TabularInline): @admin.register(Team) class TeamAdmin(admin.ModelAdmin): - list_display = ["name", "short_name", "club"] - list_filter = ["club"] + list_display = ["name", "short_name", "club", "referee_management"] + list_filter = ["club", "referee_management"] search_fields = ["name", "short_name"] inlines = [TeamMembershipInline, StaffAssignmentInline, TeamPhotoInline] @@ -68,3 +68,28 @@ class StaffAssignmentAdmin(admin.ModelAdmin): list_filter = ["team", "season", "position"] search_fields = ["team__name", "member__first_name", "member__last_name"] 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 diff --git a/teams/migrations/0008_refereelevel_refereeprofile_and_more.py b/teams/migrations/0008_refereelevel_refereeprofile_and_more.py new file mode 100644 index 0000000..3bf32d4 --- /dev/null +++ b/teams/migrations/0008_refereelevel_refereeprofile_and_more.py @@ -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'), + ), + ] diff --git a/teams/migrations/0009_team_referee_management.py b/teams/migrations/0009_team_referee_management.py new file mode 100644 index 0000000..224af9a --- /dev/null +++ b/teams/migrations/0009_team_referee_management.py @@ -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'), + ), + ] diff --git a/teams/models.py b/teams/models.py index 8a2e5cc..ebc2eb1 100644 --- a/teams/models.py +++ b/teams/models.py @@ -1,5 +1,6 @@ from django.db import models from django.db.models import Q +from django.utils import timezone from django.utils.translation import gettext_lazy as _ from club.models import Season @@ -8,8 +9,19 @@ from rosterchief.base import ClubScopedModel, UUIDModel, validate_club_scope class Team(ClubScopedModel): + class RefereeManagement(models.TextChoices): + CLUB = "club", _("Club") + FEDERATION = "federation", _("Federation") + name = models.CharField(_("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: verbose_name = _("team") @@ -96,6 +108,78 @@ class TeamMembership(UUIDModel): 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): 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")) diff --git a/teams/services.py b/teams/services.py new file mode 100644 index 0000000..0bb4d54 --- /dev/null +++ b/teams/services.py @@ -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() diff --git a/teams/tests.py b/teams/tests.py index 08f4bdb..1d433fd 100644 --- a/teams/tests.py +++ b/teams/tests.py @@ -4,11 +4,12 @@ from django.core.exceptions import ValidationError from django.db import IntegrityError from django.db.models import ProtectedError from django.test import TestCase +from django.utils import timezone from club.models import Club, Season 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): @@ -39,6 +40,9 @@ class TeamModelTests(TeamsTestCase): 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): def test_str_returns_name(self): @@ -188,3 +192,98 @@ class TeamPhotoModelTests(TeamsTestCase): self.team.delete() 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()) diff --git a/templates/mfa/webauthn/authenticator_list.html b/templates/mfa/webauthn/authenticator_list.html index 2ed3050..23290d1 100644 --- a/templates/mfa/webauthn/authenticator_list.html +++ b/templates/mfa/webauthn/authenticator_list.html @@ -27,20 +27,22 @@ {% for authenticator in authenticators %} {% element tr %} {% element td %} - {{ authenticator }} - {% if authenticator.wrap.is_passwordless is True %} - {% element badge tags="mfa,key,primary" %} - {% translate "Passkey" %} - {% endelement %} - {% elif authenticator.wrap.is_passwordless is False %} - {% element badge tags="mfa,key,secondary" %} - {% translate "Security key" %} - {% endelement %} - {% else %} - {% element badge title=_("This key does not indicate whether it is a passkey.") tags="mfa,key,warning" %} - {% translate "Unspecified" %} - {% endelement %} - {% endif %} +
    + {{ authenticator }} + {% if authenticator.wrap.is_passwordless is True %} + {% element badge tags="mfa,key,primary" %} + {% translate "Passkey" %} + {% endelement %} + {% elif authenticator.wrap.is_passwordless is False %} + {% element badge tags="mfa,key,secondary" %} + {% translate "Security key" %} + {% endelement %} + {% else %} + {% element badge title=_("This key does not indicate whether it is a passkey.") tags="mfa,key,warning" %} + {% translate "Unspecified" %} + {% endelement %} + {% endif %} +
    {% endelement %} {% element td %} {% blocktranslate with created_at=authenticator.created_at|date:"SHORT_DATE_FORMAT" %}Added on {{ created_at }}{% endblocktranslate %}. @@ -51,14 +53,16 @@ {% endif %} {% endelement %} {% element td align="right" %} - {% url 'mfa_edit_webauthn' pk=authenticator.pk as edit_url %} - {% element button href=edit_url icon="pencil" tags="mfa,authenticator,edit,tool" %} - {% translate "Edit" %} - {% endelement %} - {% url 'mfa_remove_webauthn' pk=authenticator.pk as remove_url %} - {% element button href=remove_url icon="trash-2" tags="mfa,authenticator,danger,delete,tool" %} - {% translate "Remove" %} - {% endelement %} +
    + {% url 'mfa_edit_webauthn' pk=authenticator.pk as edit_url %} + {% element button href=edit_url icon="pencil" tags="mfa,authenticator,edit,tool" %} + {% translate "Edit" %} + {% endelement %} + {% url 'mfa_remove_webauthn' pk=authenticator.pk as remove_url %} + {% element button href=remove_url icon="trash-2" tags="mfa,authenticator,danger,delete,tool" %} + {% translate "Remove" %} + {% endelement %} +
    {% endelement %} {% endelement %} {% endfor %}