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. extra tenancy field is needed on the constraint.
- `StaffAssignment` drives the coach/manager object-scope (§3.13.2) — it *is* the "is a - `StaffAssignment` drives the coach/manager object-scope (§3.13.2) — it *is* the "is a
coach of this team" fact; no `ClubRole` mirrors it. coach of this team" fact; no `ClubRole` mirrors it.
- **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`, **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 default `club`) — whether the *club* arranges referees for this team's home games, or the

View File

@@ -174,6 +174,230 @@ class StaffAssignmentForm(forms.ModelForm):
self.fields["position"].queryset = Position.objects.filter(club=club, staff_position=True) self.fields["position"].queryset = Position.objects.filter(club=club, staff_position=True)
class PositionSelect(forms.Select):
"""Marks staff positions in the DOM (``data-staff``) so a bulk-add row's
jersey-number input can disable itself for them -- a jersey number is
meaningless on a StaffAssignment, which has no such field."""
def create_option(self, name, value, *args, **kwargs):
option = super().create_option(name, value, *args, **kwargs)
position = getattr(value, "instance", None)
if position is not None and position.staff_position:
option["attrs"]["data-staff"] = "1"
return option
class GroupedPositionChoiceField(forms.ModelChoiceField):
"""Positions split into "Player positions" / "Staff positions" optgroups.
Which group a position sits in is exactly what tells the bulk-add view
whether a row means a ``TeamMembership`` or a ``StaffAssignment``
(``Position.staff_position``) -- so the row needs no separate "player or
staff?" control that could drift out of step with the position picked.
"""
widget = PositionSelect
def _get_choices(self):
if hasattr(self, "_choices"):
return self._choices
iterator = self.iterator(self)
player, staff = [], []
for value, label in iterator:
if value == "":
continue # the empty label is yielded separately below
target = staff if getattr(value, "instance", None) is not None and value.instance.staff_position else player
target.append((value, label))
grouped = []
if self.empty_label is not None:
grouped.append(("", self.empty_label))
if player:
grouped.append((_("Player positions"), player))
if staff:
grouped.append((_("Staff positions"), staff))
return grouped
choices = property(_get_choices, forms.ChoiceField.choices.fset)
def bulk_add_member_label(member, *, rostered_ids, staffed_ids):
""""Peter Player — on roster" for the bulk-add member picker.
Already-assigned members stay selectable rather than being filtered out:
someone already on the roster as a player can still legitimately be added
as staff (a playing coach), so what's "taken" depends on the position the
row ends up picking. The label is the hint; the row's own clean() is what
actually refuses a true duplicate.
"""
marks = []
if member.pk in rostered_ids:
marks.append(_("on roster"))
if member.pk in staffed_ids:
marks.append(_("on staff"))
if not marks:
return str(member)
# Not itself a translatable message -- just a separator between the member's
# name and the already-translated markers.
return f"{member}{', '.join(str(mark) for mark in marks)}"
class TeamBulkAddRowForm(forms.Form):
"""One row of the team bulk-add page: who, in what position, and (players
only) their jersey number and captaincy.
Rows replace the old "every eligible member gets a table row" layout, which
doesn't survive a club with a hundred-plus members. `member` is a searchable
select (static ``choices``, not the field's own lazy queryset iterator, so a
twenty-row formset doesn't re-query the member list twenty times over).
"""
member = forms.ModelChoiceField(
queryset=Member.objects.none(),
label=_("Member"),
widget=forms.Select(attrs={"class": "select select-bordered w-full", "data-searchable": "true", "data-search-placeholder": _("Type a name to search...")}),
)
position = GroupedPositionChoiceField(
queryset=Position.objects.none(),
label=_("Position"),
widget=PositionSelect(attrs={"class": "select select-bordered w-full position-select"}),
)
jersey_number = forms.IntegerField(
required=False,
min_value=0,
label=_("Jersey #"),
widget=forms.NumberInput(attrs={"class": "input input-bordered w-full player-only", "min": "0"}),
)
# Player-only, same as jersey_number -- "player-only" marks them for the
# row script, which greys them out when a staff position is picked.
is_captain = forms.BooleanField(required=False, label=_("Captain"), widget=forms.CheckboxInput(attrs={"class": "checkbox player-only"}))
is_alternate_captain = forms.BooleanField(required=False, label=_("Alternate"), widget=forms.CheckboxInput(attrs={"class": "checkbox player-only"}))
def __init__(self, *args, club=None, team=None, season=None, member_choices=None, position_queryset=None, rostered_ids=frozenset(), staffed_ids=frozenset(), **kwargs):
super().__init__(*args, **kwargs)
self.team = team
self.season = season
self.rostered_ids = rostered_ids
self.staffed_ids = staffed_ids
self.fields["member"].queryset = eligible_roster_members(club)
if member_choices is not None:
self.fields["member"].choices = member_choices
self.fields["position"].queryset = position_queryset if position_queryset is not None else Position.objects.filter(club=club)
def clean(self):
cleaned = super().clean()
member, position = cleaned.get("member"), cleaned.get("position")
if member is None or position is None:
return cleaned
if position.staff_position:
# A staff row becomes a StaffAssignment, which has no jersey number
# and no captaincy -- both belong to TeamMembership only.
if member.pk in self.staffed_ids:
self.add_error("member", _("%(member)s is already on this team's staff for this season.") % {"member": member})
if cleaned.get("jersey_number") is not None:
self.add_error("jersey_number", _("A jersey number doesn't apply to a staff position."))
if cleaned.get("is_captain"):
self.add_error("is_captain", _("Captaincy doesn't apply to a staff position."))
if cleaned.get("is_alternate_captain"):
self.add_error("is_alternate_captain", _("Captaincy doesn't apply to a staff position."))
return cleaned
if member.pk in self.rostered_ids:
self.add_error("member", _("%(member)s is already on this team's roster for this season.") % {"member": member})
# Contradictory rather than a club policy: an alternate captain is by
# definition not the captain. How *many* captains a team may have isn't
# constrained anywhere (not by the model, not by the single-add form),
# so this row deliberately doesn't invent that rule either.
if cleaned.get("is_captain") and cleaned.get("is_alternate_captain"):
self.add_error("is_alternate_captain", _("Someone can be captain or alternate captain, not both."))
jersey_number = cleaned.get("jersey_number")
if jersey_number is not None and self.team is not None and self.season is not None:
if TeamMembership.objects.filter(team=self.team, season=self.season, jersey_number=jersey_number).exists():
self.add_error("jersey_number", _("Jersey #%(number)s is already taken on this team this season.") % {"number": jersey_number})
return cleaned
class BaseTeamBulkAddFormSet(forms.BaseFormSet):
"""Cross-row checks the individual rows can't see: the same person twice,
or two rows claiming one jersey number. Per-row checks (already assigned,
jersey already taken by an *existing* roster entry) live on the row form."""
def clean(self):
super().clean()
if any(self.errors):
return
seen_assignments, seen_jerseys = set(), set()
for form in self.forms:
member, position = form.cleaned_data.get("member"), form.cleaned_data.get("position")
if member is None or position is None:
continue
assignment = (member.pk, bool(position.staff_position))
if assignment in seen_assignments:
form.add_error("member", _("%(member)s is listed twice for the same kind of position.") % {"member": member})
seen_assignments.add(assignment)
jersey_number = form.cleaned_data.get("jersey_number")
if jersey_number is None or position.staff_position:
continue
if jersey_number in seen_jerseys:
form.add_error("jersey_number", _("Jersey #%(number)s is claimed by more than one row.") % {"number": jersey_number})
seen_jerseys.add(jersey_number)
TeamBulkAddFormSet = forms.formset_factory(TeamBulkAddRowForm, formset=BaseTeamBulkAddFormSet, extra=3)
class GroupBulkAddRowForm(forms.Form):
"""One row of the group bulk-add page -- just who. Group membership carries
no per-member attributes, so there's nothing else to fill in."""
member = forms.ModelChoiceField(
queryset=Member.objects.none(),
label=_("Member"),
widget=forms.Select(attrs={"class": "select select-bordered w-full", "data-searchable": "true", "data-search-placeholder": _("Type a name to search...")}),
)
def __init__(self, *args, member_queryset=None, member_choices=None, existing_ids=frozenset(), **kwargs):
super().__init__(*args, **kwargs)
self.existing_ids = existing_ids
self.fields["member"].queryset = member_queryset if member_queryset is not None else Member.objects.none()
if member_choices is not None:
self.fields["member"].choices = member_choices
def clean_member(self):
member = self.cleaned_data["member"]
if member.pk in self.existing_ids:
raise forms.ValidationError(_("%(member)s is already in this group.") % {"member": member})
return member
class BaseGroupBulkAddFormSet(forms.BaseFormSet):
def clean(self):
super().clean()
if any(self.errors):
return
seen = set()
for form in self.forms:
member = form.cleaned_data.get("member")
if member is None:
continue
if member.pk in seen:
form.add_error("member", _("%(member)s is listed twice.") % {"member": member})
seen.add(member.pk)
GroupBulkAddFormSet = forms.formset_factory(GroupBulkAddRowForm, formset=BaseGroupBulkAddFormSet, extra=3)
class PositionForm(forms.ModelForm): class PositionForm(forms.ModelForm):
class Meta: class Meta:
model = Position model = Position

View File

@@ -0,0 +1,15 @@
{% load i18n lucide %}
{% comment %}
One row of the group bulk-add formset -- see _team_bulk_add_row.html for why
this is a partial (rendered both for real rows and into the clone template).
{% endcomment %}
<tr class="bulk-add-row">
<td>
{{ form.member }}
{% for error in form.member.errors %}<p class="text-xs text-error mt-1">{{ error }}</p>{% endfor %}
{% for error in form.non_field_errors %}<p class="text-xs text-error mt-1">{{ error }}</p>{% endfor %}
</td>
<td class="w-12">
<button class="btn btn-ghost btn-sm remove-row" type="button" aria-label="{% trans 'Remove this row' %}" title="{% trans 'Remove this row' %}">{% lucide "x" size=16 %}</button>
</td>
</tr>

View File

@@ -0,0 +1,32 @@
{% load i18n lucide %}
{% comment %}
One row of the team bulk-add formset. Rendered both for the rows already in
the formset and (with __prefix__ placeholders) into the <template> the "Add
row" button clones from -- so the two can never drift apart.
{% endcomment %}
<tr class="bulk-add-row">
<td>
{{ form.member }}
{% for error in form.member.errors %}<p class="text-xs text-error mt-1">{{ error }}</p>{% endfor %}
{% for error in form.non_field_errors %}<p class="text-xs text-error mt-1">{{ error }}</p>{% endfor %}
</td>
<td>
{{ form.position }}
{% for error in form.position.errors %}<p class="text-xs text-error mt-1">{{ error }}</p>{% endfor %}
</td>
<td class="w-24">
{{ form.jersey_number }}
{% for error in form.jersey_number.errors %}<p class="text-xs text-error mt-1">{{ error }}</p>{% endfor %}
</td>
<td class="w-16 text-center">
{{ form.is_captain }}
{% for error in form.is_captain.errors %}<p class="text-xs text-error mt-1">{{ error }}</p>{% endfor %}
</td>
<td class="w-16 text-center">
{{ form.is_alternate_captain }}
{% for error in form.is_alternate_captain.errors %}<p class="text-xs text-error mt-1">{{ error }}</p>{% endfor %}
</td>
<td class="w-12">
<button class="btn btn-ghost btn-sm remove-row" type="button" aria-label="{% trans 'Remove this row' %}" title="{% trans 'Remove this row' %}">{% lucide "x" size=16 %}</button>
</td>
</tr>

View File

@@ -1,59 +1,48 @@
{% extends "management/base.html" %} {% extends "management/base.html" %}
{% load i18n lucide %} {% load i18n lucide static %}
{% block heading %}{% trans "Add multiple members" %}{% endblock heading %} {% block heading %}{% trans "Add multiple members" %}{% endblock heading %}
{% block subheading %}{{ group.name }}{% endblock subheading %} {% block subheading %}{{ group.name }}{% endblock subheading %}
{% block panel %} {% block panel %}
<form method="get" class="mb-4">
<div class="flex flex-row items-center gap-2">
<label class="input">
<span class="opacity-50">{% lucide "search" size=16 %}</span>
<input type="search" name="q" value="{{ search }}" placeholder="{% trans 'Search members ...' %}" class="input input-bordered">
</label>
<button class="btn btn-outline gap-2" type="submit">{% lucide "filter" size=16 %} {% trans "Filter" %}</button>
</div>
</form>
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{{ formset.management_form }}
<div class="card bg-base-100 shadow"> <div class="card bg-base-100 shadow">
<div class="card-body"> <div class="card-body">
<div class="flex items-center justify-between mb-2"> {% for error in formset.non_form_errors %}
<span class="text-sm opacity-70 font-semibold"> <div class="alert alert-error my-2">
{% blocktrans count counter=members|length %}{{ counter }} member{% plural %}{{ counter }} members{% endblocktrans %} <span>{{ error }}</span>
</span>
<button class="btn btn-primary btn-sm gap-2" type="submit">{% lucide "users" size=14 %} {% trans "Add selected" %}</button>
</div> </div>
{% endfor %}
<div class="overflow-x-auto"> {% comment %}
<table class="table"> Deliberately not wrapped in the usual overflow-x-auto: that makes a
scroll container, which clips the member picker's absolutely
positioned dropdown for every row but the first couple.
{% endcomment %}
<table class="table w-full">
<thead> <thead>
<tr> <tr>
<th></th>
<th>{% trans "Member" %}</th> <th>{% trans "Member" %}</th>
<th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody id="bulk-add-rows" data-prefix="{{ formset.prefix }}">
{% for member in members %} {% for form in formset %}
<tr> {% include "management/_group_bulk_add_row.html" with form=form %}
<td>
{% if member.already_in_group %}
<span class="badge badge-neutral badge-sm">{% trans "In group" %}</span>
{% else %}
<input type="checkbox" class="checkbox" name="member_{{ member.pk }}">
{% endif %}
</td>
<td>{{ member }}</td>
</tr>
{% empty %}
<tr>
<td colspan="2" class="text-center opacity-60">{% trans "No members match this search." %}</td>
</tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
<template id="bulk-add-row-template">
{% include "management/_group_bulk_add_row.html" with form=formset.empty_form %}
</template>
<div class="flex flex-wrap items-center gap-2 mt-2">
<button class="btn btn-outline btn-sm gap-2" type="button" id="add-row">{% lucide "plus" size=14 %} {% trans "Add another row" %}</button>
<button class="btn btn-primary btn-sm gap-2" type="submit">{% lucide "users" size=14 %} {% trans "Add to group" %}</button>
</div> </div>
</div> </div>
</div> </div>
@@ -63,3 +52,8 @@
</div> </div>
</form> </form>
{% endblock panel %} {% endblock panel %}
{% block extra_body %}
<script src="{% static 'js/searchable-select.js' %}"></script>
<script src="{% static 'js/bulk-add-rows.js' %}"></script>
{% endblock extra_body %}

View File

@@ -1,98 +1,56 @@
{% extends "management/base.html" %} {% extends "management/base.html" %}
{% load i18n lucide %} {% load i18n lucide static %}
{% block heading %}{% trans "Add multiple people" %}{% endblock heading %} {% block heading %}{% trans "Add multiple people" %}{% endblock heading %}
{% block subheading %}{{ team.name }}{% endblock subheading %} {% block subheading %}{{ team.name }}{% endblock subheading %}
{% block panel %} {% block panel %}
<form method="get" class="mb-4">
<div class="flex flex-row items-center gap-2">
<label class="input">
<span class="opacity-50">{% lucide "search" size=16 %}</span>
<input type="search" name="q" value="{{ search }}" placeholder="{% trans 'Search members ...' %}" class="input input-bordered">
</label>
<button class="btn btn-outline gap-2" type="submit">{% lucide "filter" size=16 %} {% trans "Filter" %}</button>
</div>
</form>
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{{ formset.management_form }}
<div class="card bg-base-100 shadow"> <div class="card bg-base-100 shadow">
<div class="card-body"> <div class="card-body">
<div class="flex items-center justify-between mb-2">
<span class="text-sm opacity-70 font-semibold">
{% blocktrans count counter=members|length %}{{ counter }} eligible member{% plural %}{{ counter }} eligible members{% endblocktrans %}
</span>
<button class="btn btn-primary btn-sm gap-2" type="submit">{% lucide "users" size=14 %} {% trans "Add selected" %}</button>
</div>
<p class="text-sm opacity-70 mb-2"> <p class="text-sm opacity-70 mb-2">
{% blocktrans %}Only members who are active (paid up) for this club this season or next are eligible. Tick "Player" and/or "Staff" for anyone you want to add -- both at once works too (a playing coach, say).{% endblocktrans %} {% blocktrans %}Only members who are active (paid up) for this club this season or next can be added. The position you pick decides the role: a staff position adds them to the staff, any other position adds them to the roster. To add someone as both a player and staff, give them two rows.{% endblocktrans %}
</p> </p>
<div class="overflow-x-auto"> {% for error in formset.non_form_errors %}
<table class="table"> <div class="alert alert-error my-2">
<span>{{ error }}</span>
</div>
{% endfor %}
{% comment %}
Deliberately not wrapped in the usual overflow-x-auto: that makes a
scroll container, which clips the member picker's absolutely
positioned dropdown for every row but the first couple.
{% endcomment %}
<table class="table w-full">
<thead> <thead>
<tr> <tr>
<th>{% trans "Member" %}</th> <th>{% trans "Member" %}</th>
<th>{% trans "Player" %}</th>
<th>{% trans "Position" %}</th> <th>{% trans "Position" %}</th>
<th>{% trans "Jersey #" %}</th> <th>{% trans "Jersey #" %}</th>
<th>{% trans "Staff" %}</th> <th class="text-center">{% trans "Captain" %}</th>
<th>{% trans "Position" %}</th> <th class="text-center">{% trans "Alternate" %}</th>
<th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody id="bulk-add-rows" data-prefix="{{ formset.prefix }}">
{% for member in members %} {% for form in formset %}
<tr> {% include "management/_team_bulk_add_row.html" with form=form %}
<td>{{ member }}</td>
<td>
{% if member.already_player %}
<span class="badge badge-neutral badge-sm">{% trans "On roster" %}</span>
{% else %}
<input type="checkbox" class="checkbox player-checkbox" name="player_{{ member.pk }}" data-member="{{ member.pk }}">
{% endif %}
</td>
<td>
{% if not member.already_player %}
<select name="player_position_{{ member.pk }}" class="select select-bordered select-sm player-position" data-member="{{ member.pk }}" disabled>
<option value="">{% trans "—" %}</option>
{% for position in player_positions %}
<option value="{{ position.pk }}">{{ position.name }}</option>
{% endfor %}
</select>
{% endif %}
</td>
<td>
{% if not member.already_player %}
<input type="number" min="0" name="jersey_{{ member.pk }}" class="input input-bordered input-sm w-20 player-jersey" data-member="{{ member.pk }}" disabled>
{% endif %}
</td>
<td>
{% if member.already_staff %}
<span class="badge badge-neutral badge-sm">{% trans "On staff" %}</span>
{% else %}
<input type="checkbox" class="checkbox staff-checkbox" name="staff_{{ member.pk }}" data-member="{{ member.pk }}">
{% endif %}
</td>
<td>
{% if not member.already_staff %}
<select name="staff_position_{{ member.pk }}" class="select select-bordered select-sm staff-position" data-member="{{ member.pk }}" disabled>
<option value="">{% trans "—" %}</option>
{% for position in staff_positions %}
<option value="{{ position.pk }}">{{ position.name }}</option>
{% endfor %}
</select>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="6" class="text-center opacity-60">{% trans "No eligible members match this search." %}</td>
</tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
<template id="bulk-add-row-template">
{% include "management/_team_bulk_add_row.html" with form=formset.empty_form %}
</template>
<div class="flex flex-wrap items-center gap-2 mt-2">
<button class="btn btn-outline btn-sm gap-2" type="button" id="add-row">{% lucide "plus" size=14 %} {% trans "Add another row" %}</button>
<button class="btn btn-primary btn-sm gap-2" type="submit">{% lucide "users" size=14 %} {% trans "Add to team" %}</button>
</div> </div>
</div> </div>
</div> </div>
@@ -104,22 +62,6 @@
{% endblock panel %} {% endblock panel %}
{% block extra_body %} {% block extra_body %}
<script> <script src="{% static 'js/searchable-select.js' %}"></script>
(() => { <script src="{% static 'js/bulk-add-rows.js' %}"></script>
const pairs = [
{checkboxClass: "player-checkbox", fieldClasses: ["player-position", "player-jersey"]},
{checkboxClass: "staff-checkbox", fieldClasses: ["staff-position"]},
];
pairs.forEach(({checkboxClass, fieldClasses}) => {
document.querySelectorAll(`.${checkboxClass}`).forEach((checkbox) => {
const memberId = checkbox.dataset.member;
const fields = fieldClasses.flatMap((cls) => Array.from(document.querySelectorAll(`.${cls}[data-member="${memberId}"]`)));
checkbox.addEventListener("change", () => {
fields.forEach((field) => { field.disabled = !checkbox.checked; });
});
});
});
})();
</script>
{% endblock extra_body %} {% endblock extra_body %}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,6 @@
from datetime import timedelta from datetime import timedelta
from decimal import Decimal from decimal import Decimal
from django.core.exceptions import ValidationError
from django.db import IntegrityError, transaction from django.db import IntegrityError, transaction
from django.db.models import Count, ProtectedError, Q from django.db.models import Count, ProtectedError, Q
from django.http import HttpResponse from django.http import HttpResponse
@@ -59,6 +58,7 @@ from .forms import (
ExternalRefereeForm, ExternalRefereeForm,
FamilyCreateForm, FamilyCreateForm,
GrantLoginForm, GrantLoginForm,
GroupBulkAddFormSet,
GroupForm, GroupForm,
LocationForm, LocationForm,
MemberForm, MemberForm,
@@ -74,9 +74,11 @@ from .forms import (
RefereeLevelForm, RefereeLevelForm,
SponsorForm, SponsorForm,
StaffAssignmentForm, StaffAssignmentForm,
TeamBulkAddFormSet,
TeamForm, TeamForm,
TeamMembershipForm, TeamMembershipForm,
TeamPhotoForm, TeamPhotoForm,
bulk_add_member_label,
) )
from .pdf import PDFExportError, event_referee_form_pdf, membership_list_pdf, referee_form_colors from .pdf import PDFExportError, event_referee_form_pdf, membership_list_pdf, referee_form_colors
from .recurrence_ui import describe_rrule from .recurrence_ui import describe_rrule
@@ -1091,13 +1093,21 @@ class TeamStaffRemoveView(TeamManagerRequiredMixin, View):
class TeamBulkAddView(TeamManagerRequiredMixin, View): class TeamBulkAddView(TeamManagerRequiredMixin, View):
"""Add many people to a team's roster and/or staff in one go -- the one-by-one """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 modals (TeamRosterAddView / TeamStaffAddView) don't scale past a handful of
names. Every eligible member gets an independent "add as player" and "add as names.
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 One row per assignment: pick a person (searchable), pick a position, and the
-- i.e. paid -- for this club this season or next), enforced server-side *position* decides what the row means -- a staff-flagged Position becomes a
regardless of what the client submits. StaffAssignment, anything else a TeamMembership with an optional jersey
number. Someone joining as both a player and staff (a playing coach) is two
rows. Rows are added client-side from the formset's ``empty_form``; the
earlier layout gave *every* eligible member a table row, which a club with a
hundred-plus members can't realistically use.
All-or-nothing: one bad row re-renders the page with every row the user typed
still filled in and the offending field flagged, rather than saving the good
rows and silently dropping the rest. Eligibility is recomputed server-side
from eligible_roster_members -- a submitted member id that isn't actually
eligible fails the field's own queryset lookup, never reaching the database.
""" """
template_name = "management/team_bulk_add.html" template_name = "management/team_bulk_add.html"
@@ -1108,108 +1118,75 @@ class TeamBulkAddView(TeamManagerRequiredMixin, View):
def get_season(self): def get_season(self):
return get_object_or_404(Season.objects.filter(club=self.request.club), pk=self.kwargs["season_pk"]) return get_object_or_404(Season.objects.filter(club=self.request.club), pk=self.kwargs["season_pk"])
def get_context_data(self, **kwargs): def get_form_kwargs(self, team, season):
team = self.get_team()
season = self.get_season()
club = self.request.club club = self.request.club
rostered_ids = set(TeamMembership.objects.filter(team=team, season=season).values_list("member_id", flat=True))
staffed_ids = set(StaffAssignment.objects.filter(team=team, season=season).values_list("member_id", flat=True))
# Built once here rather than per row: a ModelChoiceField re-runs its own
# queryset for every form in the formset, so a twenty-row submit would
# otherwise mean twenty full member queries.
members = eligible_roster_members(club).order_by("last_name", "first_name") members = eligible_roster_members(club).order_by("last_name", "first_name")
search = self.request.GET.get("q", "").strip() member_choices = [("", "---------")] + [(member.pk, bulk_add_member_label(member, rostered_ids=rostered_ids, staffed_ids=staffed_ids)) for member in members]
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 { return {
"club": club,
"team": team, "team": team,
"season": season, "season": season,
"members": members, "member_choices": member_choices,
"search": search, "position_queryset": Position.objects.filter(club=club),
"player_positions": Position.objects.filter(club=club, staff_position=False), "rostered_ids": rostered_ids,
"staff_positions": Position.objects.filter(club=club, staff_position=True), "staffed_ids": staffed_ids,
**kwargs,
} }
def render_form(self, request, team, season, formset):
return render(request, self.template_name, {"team": team, "season": season, "formset": formset})
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
return render(request, self.template_name, self.get_context_data()) team, season = self.get_team(), self.get_season()
formset = TeamBulkAddFormSet(form_kwargs=self.get_form_kwargs(team, season))
return self.render_form(request, team, season, formset)
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
team = self.get_team() team, season = self.get_team(), self.get_season()
season = self.get_season() formset = TeamBulkAddFormSet(request.POST, form_kwargs=self.get_form_kwargs(team, season))
club = self.request.club
rostered = set(TeamMembership.objects.filter(team=team, season=season).values_list("member_id", flat=True)) if not formset.is_valid():
staffed = set(StaffAssignment.objects.filter(team=team, season=season).values_list("member_id", flat=True)) notify(request, f"e|{_('Could not add')}|{_('Some rows need attention -- see the errors below.')}")
used_jerseys = set(TeamMembership.objects.filter(team=team, season=season, jersey_number__isnull=False).values_list("jersey_number", flat=True)) return self.render_form(request, team, season, formset)
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)} rows = [form.cleaned_data for form in formset.forms if form.cleaned_data.get("member") and form.cleaned_data.get("position")]
if not rows:
notify(request, f"i|{_('Nothing to add')}|{_('No one was selected.')}")
return redirect(f"{reverse('management:team_detail', args=[team.pk])}?season={season.pk}")
players_added = staff_added = 0 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: try:
jersey_number = int(jersey_raw) with transaction.atomic():
except ValueError: for row in rows:
jersey_error = True if row["position"].staff_position:
errors.append(_("%(member)s: jersey number must be a whole number.") % {"member": member}) StaffAssignment.objects.create(team=team, season=season, member=row["member"], position=row["position"])
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 staff_added += 1
else:
TeamMembership.objects.create(
team=team,
season=season,
member=row["member"],
position=row["position"],
jersey_number=row.get("jersey_number"),
is_captain=row.get("is_captain", False),
is_alternate_captain=row.get("is_alternate_captain", False),
)
players_added += 1
except IntegrityError:
# Someone else claimed a jersey number, or added the same person, between
# validation and the write. The atomic block means nothing was saved, so
# hand the whole form back rather than reporting a half-finished add.
notify(request, f"e|{_('Could not add')}|{_('Someone changed this team while you were filling in the form. Nothing was saved -- please check the rows and try again.')}")
return self.render_form(request, team, season, formset)
if players_added or staff_added:
body = _("%(players)s added as player(s), %(staff)s added as staff.") % {"players": players_added, "staff": 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}") 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}") return redirect(f"{reverse('management:team_detail', args=[team.pk])}?season={season.pk}")
@@ -1642,46 +1619,47 @@ class GroupDetailView(ClubAdminRequiredMixin, DetailView):
class GroupBulkAddView(ClubAdminRequiredMixin, View): class GroupBulkAddView(ClubAdminRequiredMixin, View):
"""Add many members to a group in one go -- mirrors TeamBulkAddView's """Add many members to a group in one go -- mirrors TeamBulkAddView's
checkbox-table pattern, minus the player/staff split (group membership has searchable-row formset, minus the position/jersey columns (group membership
no per-member attributes).""" carries no per-member attributes)."""
template_name = "management/group_bulk_add.html" template_name = "management/group_bulk_add.html"
def get_group(self): def get_group(self):
return get_object_or_404(Group.objects.filter(club=self.request.club), pk=self.kwargs["pk"]) return get_object_or_404(Group.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
def get_context_data(self, **kwargs): def get_form_kwargs(self, group):
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)) existing_ids = set(GroupMembership.objects.filter(group=group).values_list("member_id", flat=True))
members = list(members) members = members_visible_to(self.request.user, self.request.club).order_by("last_name", "first_name")
for member in members: # One member query for the whole formset -- see TeamBulkAddView.get_form_kwargs.
member.already_in_group = member.pk in existing_ids member_choices = [("", "---------")] + [(member.pk, _("%(member)salready in this group") % {"member": member} if member.pk in existing_ids else str(member)) for member in members]
return {"group": group, "members": members, "search": search, **kwargs} return {"member_queryset": members, "member_choices": member_choices, "existing_ids": existing_ids}
def render_form(self, request, group, formset):
return render(request, self.template_name, {"group": group, "formset": formset})
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
return render(request, self.template_name, self.get_context_data()) group = self.get_group()
formset = GroupBulkAddFormSet(form_kwargs=self.get_form_kwargs(group))
return self.render_form(request, group, formset)
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
group = self.get_group() group = self.get_group()
existing_ids = set(GroupMembership.objects.filter(group=group).values_list("member_id", flat=True)) formset = GroupBulkAddFormSet(request.POST, form_kwargs=self.get_form_kwargs(group))
added = 0 if not formset.is_valid():
for member in members_visible_to(request.user, request.club): notify(request, f"e|{_('Could not add')}|{_('Some rows need attention -- see the errors below.')}")
if member.pk not in existing_ids and request.POST.get(f"member_{member.pk}"): return self.render_form(request, group, formset)
GroupMembership.objects.create(group=group, member=member)
added += 1
if added: members = [form.cleaned_data["member"] for form in formset.forms if form.cleaned_data.get("member")]
notify(request, f"s|{_('Group updated')}|" + _("%(count)s member(s) added to “%(group)s”.") % {"count": added, "group": group}) if not members:
else:
notify(request, f"i|{_('Nothing to add')}|{_('No one was selected.')}") notify(request, f"i|{_('Nothing to add')}|{_('No one was selected.')}")
return redirect("management:group_detail", pk=group.pk)
with transaction.atomic():
GroupMembership.objects.bulk_create([GroupMembership(group=group, member=member) for member in members])
notify(request, f"s|{_('Group updated')}|" + _("%(count)s member(s) added to “%(group)s”.") % {"count": len(members), "group": group})
return redirect("management:group_detail", pk=group.pk) return redirect("management:group_detail", pk=group.pk)

View File

@@ -3572,6 +3572,12 @@
.w-20 { .w-20 {
width: calc(var(--spacing) * 20); width: calc(var(--spacing) * 20);
} }
.w-24 {
width: calc(var(--spacing) * 24);
}
.w-28 {
width: calc(var(--spacing) * 28);
}
.w-60 { .w-60 {
width: calc(var(--spacing) * 60); width: calc(var(--spacing) * 60);
} }
@@ -4528,6 +4534,31 @@
padding-inline: calc(var(--spacing) * 6); padding-inline: calc(var(--spacing) * 6);
} }
} }
.md\:col-span-2 {
@media (width >= 48rem) {
grid-column: span 2 / span 2;
}
}
.md\:col-span-4 {
@media (width >= 48rem) {
grid-column: span 4 / span 4;
}
}
.md\:mb-2 {
@media (width >= 48rem) {
margin-bottom: calc(var(--spacing) * 2);
}
}
.md\:grid {
@media (width >= 48rem) {
display: grid;
}
}
.md\:hidden {
@media (width >= 48rem) {
display: none;
}
}
.md\:grid-cols-2 { .md\:grid-cols-2 {
@media (width >= 48rem) { @media (width >= 48rem) {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -4548,6 +4579,26 @@
grid-template-columns: repeat(5, minmax(0, 1fr)); grid-template-columns: repeat(5, minmax(0, 1fr));
} }
} }
.md\:grid-cols-\[minmax\(0\,1fr\)_2\.5rem\] {
@media (width >= 48rem) {
grid-template-columns: minmax(0,1fr) 2.5rem;
}
}
.md\:grid-cols-\[minmax\(0\,2fr\)_minmax\(0\,2fr\)_7rem_2\.5rem\] {
@media (width >= 48rem) {
grid-template-columns: minmax(0,2fr) minmax(0,2fr) 7rem 2.5rem;
}
}
.md\:items-start {
@media (width >= 48rem) {
align-items: flex-start;
}
}
.md\:justify-center {
@media (width >= 48rem) {
justify-content: center;
}
}
.lg\:block { .lg\:block {
@media (width >= 64rem) { @media (width >= 64rem) {
display: block; display: block;

View File

@@ -0,0 +1,89 @@
/*
* Dynamic rows for the bulk-add formsets (team roster/staff, group members).
*
* Progressive enhancement: the formset's own `extra` rows are already in the
* page and post normally with no JS at all -- this only adds the ability to
* clone more rows from the hidden <template> holding the formset's empty_form,
* and to drop a row you've changed your mind about.
*
* Removing a row deletes it outright and deliberately leaves TOTAL_FORMS alone
* rather than re-indexing everything after it: Django reads a form whose fields
* are simply absent from the POST as an unchanged extra form and skips it, so a
* gap in the indices is harmless, while re-indexing live inputs is not.
*/
(() => {
const rows = document.getElementById("bulk-add-rows");
const template = document.getElementById("bulk-add-row-template");
const addButton = document.getElementById("add-row");
if (!rows || !template || !addButton) return;
const totalForms = document.getElementById(`id_${rows.dataset.prefix}-TOTAL_FORMS`);
if (!totalForms) return;
// Jersey number and captaincy belong to a TeamMembership; a staff position
// becomes a StaffAssignment, which has neither. PositionSelect
// (management/forms.py) marks the staff options so those inputs can grey
// themselves out to match -- and a disabled input isn't submitted, so the
// server sees nothing set either way. The row form rejects them regardless;
// this only saves the user from filling in something that can't apply.
const syncPlayerOnlyFields = (row) => {
const position = row.querySelector(".position-select");
if (!position) return;
const chosen = position.options[position.selectedIndex];
const isStaff = Boolean(chosen && chosen.dataset.staff === "1");
row.querySelectorAll(".player-only").forEach((field) => {
field.disabled = isStaff;
if (!isStaff) return;
if (field.type === "checkbox") {
field.checked = false;
} else {
field.value = "";
}
});
};
const wireRow = (row) => {
row.querySelectorAll("select[data-searchable]").forEach((select) => {
if (window.enhanceSearchableSelect) window.enhanceSearchableSelect(select);
});
const position = row.querySelector(".position-select");
if (position) position.addEventListener("change", () => syncPlayerOnlyFields(row));
syncPlayerOnlyFields(row);
const remove = row.querySelector(".remove-row");
if (remove) remove.addEventListener("click", () => row.remove());
};
rows.querySelectorAll(".bulk-add-row").forEach(wireRow);
// Cloned from the template's parsed content rather than re-parsed from its
// innerHTML: the rows are <tr>s, and assigning "<tr>...</tr>" to some stray
// <div>'s innerHTML drops them on the floor -- a <tr> is only valid inside a
// table, which is exactly the context <template> already parsed it in.
const buildRow = (index) => {
const row = template.content.firstElementChild.cloneNode(true);
for (const element of [row, ...row.querySelectorAll("*")]) {
for (const attribute of Array.from(element.attributes)) {
if (attribute.value.includes("__prefix__")) {
attribute.value = attribute.value.replace(/__prefix__/g, index);
}
}
}
return row;
};
addButton.addEventListener("click", () => {
const index = Number(totalForms.value);
const row = buildRow(index);
rows.appendChild(row);
totalForms.value = index + 1;
wireRow(row);
const firstInput = row.querySelector("input, select");
if (firstInput) firstInput.focus();
});
})();

View File

@@ -11,6 +11,12 @@
*/ */
(() => { (() => {
function enhance(select) { function enhance(select) {
// Rows added after page load (see team_bulk_add.html) are enhanced on
// demand via window.enhanceSearchableSelect, so a select can be handed
// here twice -- without this it would grow a second search input.
if (select.dataset.searchableReady === "1") return;
select.dataset.searchableReady = "1";
const isMultiple = select.multiple; const isMultiple = select.multiple;
const options = Array.from(select.options).filter((option) => option.value !== ""); const options = Array.from(select.options).filter((option) => option.value !== "");
@@ -153,5 +159,10 @@
} }
} }
// Exposed for forms that clone new rows in after this initial sweep has run
// (the bulk-add pages) -- the sweep below only ever sees what's already in
// the DOM, and a <template>'s inert content is deliberately not matched.
window.enhanceSearchableSelect = enhance;
document.querySelectorAll("select[data-searchable]").forEach(enhance); document.querySelectorAll("select[data-searchable]").forEach(enhance);
})(); })();