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:
@@ -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
|
||||
|
||||
15
management/templates/management/_group_bulk_add_row.html
Normal file
15
management/templates/management/_group_bulk_add_row.html
Normal 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>
|
||||
32
management/templates/management/_team_bulk_add_row.html
Normal file
32
management/templates/management/_team_bulk_add_row.html
Normal 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>
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
1057
management/tests.py
1057
management/tests.py
File diff suppressed because it is too large
Load Diff
@@ -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)s — already 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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user