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:
@@ -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.1–3.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
|
||||
|
||||
Reference in New Issue
Block a user