Rebuild team/group bulk add as a searchable row formset

The old page gave every eligible member a table row, which a club with a
hundred-plus members can't use, and its search was a GET round-trip that
discarded anything already ticked. Now you add rows: pick a person from a
searchable select, pick a position, fill in the details.

The position decides the role. Position.staff_position already distinguishes
them, so a staff position creates a StaffAssignment and anything else a
TeamMembership -- no separate "player or staff?" control that could disagree
with the position picked. Someone joining as both is two rows. Jersey number
and captaincy exist only on TeamMembership, so a staff row rejects them and the
row script greys them out, keyed off the data-staff marker PositionSelect
stamps on staff options.

All-or-nothing on submit: one bad row re-renders the page with every row still
filled in and the offending field flagged, rather than saving the good rows and
losing the rest -- a partial save costs far more when the rows were typed by
hand. Cross-row checks no single row can see (the same person twice, two rows
claiming one jersey) live on the formset's clean(); per-row checks live on the
row form. Eligibility is still never trusted from the POST.

Captain and alternate captain on one row is refused as self-contradictory, but
how many captains a team may have is deliberately left alone: neither the model
nor the single-add form constrains it, and inventing the rule in one entry path
only would be bypassable by adding players one at a time. A test pins that
absence so it reads as a decision rather than an oversight.

Two implementation notes worth keeping: rows are cloned from the template's
parsed content, not its innerHTML, because assigning "<tr>...</tr>" to a
detached <div> silently drops it; and these tables deliberately skip the usual
overflow-x-auto wrapper, which would make a scroll container that clips the
picker's dropdown for every row but the first couple. Removing a row leaves
TOTAL_FORMS alone -- Django reads a form whose fields are absent from the POST
as an unchanged extra and skips it, which is safe, unlike re-indexing live
inputs.

management/tests.py also carries the setUpTestData rationalisation from the
previous commit, which couldn't be split cleanly from the new bulk-add tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 13:48:19 +02:00
parent ffe8a3d301
commit 1d5286fbbf
11 changed files with 1268 additions and 667 deletions

View File

@@ -456,6 +456,32 @@ number — modeled by `TeamMembership`, exactly matching the domain note.
extra tenancy field is needed on the constraint.
- `StaffAssignment` drives the coach/manager object-scope (§3.13.2) — it *is* the "is a
coach of this team" fact; no `ClubRole` mirrors it.
- **Bulk add is a row formset, not a table of every member** (`TeamBulkAddView`, and the same
shape for groups in `GroupBulkAddView`). Each row picks one person from a searchable select
and one `Position`; the *position* decides what the row means — `Position.staff_position`
true ⇒ a `StaffAssignment`, otherwise a `TeamMembership` with an optional jersey number and
captain/alternate-captain flags — so there's no separate "player or staff?" control that
could disagree with the position picked. Jersey number and captaincy exist only on
`TeamMembership`, so a staff row rejects them (and the row script greys them out, keyed off
the `data-staff` marker `PositionSelect` stamps on staff options). Captain *and* alternate
captain on one row is refused as self-contradictory, but how many captains a team may have
is left alone: neither the model nor the single-add form constrains it, and inventing the
rule in one entry path only would be worse than not having it.
A playing coach is simply two rows. Rows are cloned client-side from the formset's
`empty_form` (`static/js/bulk-add-rows.js`); removing one deletes the node and deliberately
leaves `TOTAL_FORMS` alone, since Django reads a form whose fields are absent from the POST
as an unchanged extra and skips it — safe, unlike re-indexing live inputs. The earlier
design rendered *every* eligible member as a table row, which a club with a hundred-plus
members can't use, and its search was a GET round-trip that discarded anything already
ticked. **All-or-nothing on submit**: one bad row re-renders the page with every row still
filled in and the offending field flagged, rather than saving the good rows and losing the
rest (a partial save is far more costly when the rows were typed by hand). Cross-row checks
no single row can see — the same person twice, two rows claiming one jersey — live on the
formset's `clean()`; per-row checks (already assigned, jersey already taken by an existing
entry) live on the row form. Eligibility is never trusted from the POST: the member field's
queryset is `eligible_roster_members`, so an id that was never offered fails its own lookup.
The member `choices` are built once in the view and assigned onto each row's field —
a `ModelChoiceField` otherwise re-runs its queryset per form, i.e. once per row.
**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