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

@@ -174,6 +174,230 @@ class StaffAssignmentForm(forms.ModelForm):
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 Meta:
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" %}
{% load i18n lucide %}
{% load i18n lucide static %}
{% block heading %}{% trans "Add multiple members" %}{% endblock heading %}
{% block subheading %}{{ group.name }}{% endblock subheading %}
{% block panel %}
<form method="get" class="mb-4">
<div class="flex flex-row items-center gap-2">
<label class="input">
<span class="opacity-50">{% lucide "search" size=16 %}</span>
<input type="search" name="q" value="{{ search }}" placeholder="{% trans 'Search members ...' %}" class="input input-bordered">
</label>
<button class="btn btn-outline gap-2" type="submit">{% lucide "filter" size=16 %} {% trans "Filter" %}</button>
</div>
</form>
<form method="post">
{% csrf_token %}
{{ formset.management_form }}
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="flex items-center justify-between mb-2">
<span class="text-sm opacity-70 font-semibold">
{% blocktrans count counter=members|length %}{{ counter }} member{% plural %}{{ counter }} members{% endblocktrans %}
</span>
<button class="btn btn-primary btn-sm gap-2" type="submit">{% lucide "users" size=14 %} {% trans "Add selected" %}</button>
</div>
{% for error in formset.non_form_errors %}
<div class="alert alert-error my-2">
<span>{{ error }}</span>
</div>
{% endfor %}
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th></th>
<th>{% trans "Member" %}</th>
</tr>
</thead>
<tbody>
{% for member in members %}
<tr>
<td>
{% if member.already_in_group %}
<span class="badge badge-neutral badge-sm">{% trans "In group" %}</span>
{% else %}
<input type="checkbox" class="checkbox" name="member_{{ member.pk }}">
{% endif %}
</td>
<td>{{ member }}</td>
</tr>
{% empty %}
<tr>
<td colspan="2" class="text-center opacity-60">{% trans "No members match this search." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% 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>
<tr>
<th>{% trans "Member" %}</th>
<th></th>
</tr>
</thead>
<tbody id="bulk-add-rows" data-prefix="{{ formset.prefix }}">
{% for form in formset %}
{% include "management/_group_bulk_add_row.html" with form=form %}
{% endfor %}
</tbody>
</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>
@@ -63,3 +52,8 @@
</div>
</form>
{% 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" %}
{% load i18n lucide %}
{% load i18n lucide static %}
{% block heading %}{% trans "Add multiple people" %}{% endblock heading %}
{% block subheading %}{{ team.name }}{% endblock subheading %}
{% block panel %}
<form method="get" class="mb-4">
<div class="flex flex-row items-center gap-2">
<label class="input">
<span class="opacity-50">{% lucide "search" size=16 %}</span>
<input type="search" name="q" value="{{ search }}" placeholder="{% trans 'Search members ...' %}" class="input input-bordered">
</label>
<button class="btn btn-outline gap-2" type="submit">{% lucide "filter" size=16 %} {% trans "Filter" %}</button>
</div>
</form>
<form method="post">
{% csrf_token %}
{{ formset.management_form }}
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="flex items-center justify-between mb-2">
<span class="text-sm opacity-70 font-semibold">
{% blocktrans count counter=members|length %}{{ counter }} eligible member{% plural %}{{ counter }} eligible members{% endblocktrans %}
</span>
<button class="btn btn-primary btn-sm gap-2" type="submit">{% lucide "users" size=14 %} {% trans "Add selected" %}</button>
</div>
<p class="text-sm opacity-70 mb-2">
{% blocktrans %}Only members who are active (paid up) for this club this season or next are eligible. Tick "Player" and/or "Staff" for anyone you want to add -- both at once works too (a playing coach, say).{% endblocktrans %}
{% 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>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>{% trans "Member" %}</th>
<th>{% trans "Player" %}</th>
<th>{% trans "Position" %}</th>
<th>{% trans "Jersey #" %}</th>
<th>{% trans "Staff" %}</th>
<th>{% trans "Position" %}</th>
</tr>
</thead>
<tbody>
{% for member in members %}
<tr>
<td>{{ member }}</td>
<td>
{% if member.already_player %}
<span class="badge badge-neutral badge-sm">{% trans "On roster" %}</span>
{% else %}
<input type="checkbox" class="checkbox player-checkbox" name="player_{{ member.pk }}" data-member="{{ member.pk }}">
{% endif %}
</td>
<td>
{% if not member.already_player %}
<select name="player_position_{{ member.pk }}" class="select select-bordered select-sm player-position" data-member="{{ member.pk }}" disabled>
<option value="">{% trans "—" %}</option>
{% for position in player_positions %}
<option value="{{ position.pk }}">{{ position.name }}</option>
{% endfor %}
</select>
{% endif %}
</td>
<td>
{% if not member.already_player %}
<input type="number" min="0" name="jersey_{{ member.pk }}" class="input input-bordered input-sm w-20 player-jersey" data-member="{{ member.pk }}" disabled>
{% endif %}
</td>
<td>
{% if member.already_staff %}
<span class="badge badge-neutral badge-sm">{% trans "On staff" %}</span>
{% else %}
<input type="checkbox" class="checkbox staff-checkbox" name="staff_{{ member.pk }}" data-member="{{ member.pk }}">
{% endif %}
</td>
<td>
{% if not member.already_staff %}
<select name="staff_position_{{ member.pk }}" class="select select-bordered select-sm staff-position" data-member="{{ member.pk }}" disabled>
<option value="">{% trans "—" %}</option>
{% for position in staff_positions %}
<option value="{{ position.pk }}">{{ position.name }}</option>
{% endfor %}
</select>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="6" class="text-center opacity-60">{% trans "No eligible members match this search." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% for error in formset.non_form_errors %}
<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>
<tr>
<th>{% trans "Member" %}</th>
<th>{% trans "Position" %}</th>
<th>{% trans "Jersey #" %}</th>
<th class="text-center">{% trans "Captain" %}</th>
<th class="text-center">{% trans "Alternate" %}</th>
<th></th>
</tr>
</thead>
<tbody id="bulk-add-rows" data-prefix="{{ formset.prefix }}">
{% for form in formset %}
{% include "management/_team_bulk_add_row.html" with form=form %}
{% endfor %}
</tbody>
</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>
@@ -104,22 +62,6 @@
{% endblock panel %}
{% block extra_body %}
<script>
(() => {
const pairs = [
{checkboxClass: "player-checkbox", fieldClasses: ["player-position", "player-jersey"]},
{checkboxClass: "staff-checkbox", fieldClasses: ["staff-position"]},
];
pairs.forEach(({checkboxClass, fieldClasses}) => {
document.querySelectorAll(`.${checkboxClass}`).forEach((checkbox) => {
const memberId = checkbox.dataset.member;
const fields = fieldClasses.flatMap((cls) => Array.from(document.querySelectorAll(`.${cls}[data-member="${memberId}"]`)));
checkbox.addEventListener("change", () => {
fields.forEach((field) => { field.disabled = !checkbox.checked; });
});
});
});
})();
</script>
<script src="{% static 'js/searchable-select.js' %}"></script>
<script src="{% static 'js/bulk-add-rows.js' %}"></script>
{% endblock extra_body %}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,6 @@
from datetime import timedelta
from decimal import Decimal
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
@@ -59,6 +58,7 @@ from .forms import (
ExternalRefereeForm,
FamilyCreateForm,
GrantLoginForm,
GroupBulkAddFormSet,
GroupForm,
LocationForm,
MemberForm,
@@ -74,9 +74,11 @@ from .forms import (
RefereeLevelForm,
SponsorForm,
StaffAssignmentForm,
TeamBulkAddFormSet,
TeamForm,
TeamMembershipForm,
TeamPhotoForm,
bulk_add_member_label,
)
from .pdf import PDFExportError, event_referee_form_pdf, membership_list_pdf, referee_form_colors
from .recurrence_ui import describe_rrule
@@ -1091,13 +1093,21 @@ class TeamStaffRemoveView(TeamManagerRequiredMixin, View):
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).
names.
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.
One row per assignment: pick a person (searchable), pick a position, and the
*position* decides what the row means -- a staff-flagged Position becomes a
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"
@@ -1108,108 +1118,75 @@ class TeamBulkAddView(TeamManagerRequiredMixin, View):
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()
def get_form_kwargs(self, team, season):
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")
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
member_choices = [("", "---------")] + [(member.pk, bulk_add_member_label(member, rostered_ids=rostered_ids, staffed_ids=staffed_ids)) for member in members]
return {
"club": club,
"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,
"member_choices": member_choices,
"position_queryset": Position.objects.filter(club=club),
"rostered_ids": rostered_ids,
"staffed_ids": staffed_ids,
}
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):
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):
team = self.get_team()
season = self.get_season()
club = self.request.club
team, season = self.get_team(), self.get_season()
formset = TeamBulkAddFormSet(request.POST, form_kwargs=self.get_form_kwargs(team, season))
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)}
if not formset.is_valid():
notify(request, f"e|{_('Could not add')}|{_('Some rows need attention -- see the errors below.')}")
return self.render_form(request, team, season, formset)
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
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:
try:
with transaction.atomic():
for row in rows:
if row["position"].staff_position:
StaffAssignment.objects.create(team=team, season=season, member=row["member"], position=row["position"])
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}
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.')}")
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}")
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):
"""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)."""
searchable-row formset, minus the position/jersey columns (group membership
carries 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))
def get_form_kwargs(self, group):
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
members = members_visible_to(self.request.user, self.request.club).order_by("last_name", "first_name")
# One member query for the whole formset -- see TeamBulkAddView.get_form_kwargs.
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):
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):
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
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 not formset.is_valid():
notify(request, f"e|{_('Could not add')}|{_('Some rows need attention -- see the errors below.')}")
return self.render_form(request, group, formset)
if added:
notify(request, f"s|{_('Group updated')}|" + _("%(count)s member(s) added to “%(group)s”.") % {"count": added, "group": group})
else:
members = [form.cleaned_data["member"] for form in formset.forms if form.cleaned_data.get("member")]
if not members:
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)