Rebuild team/group bulk add as a searchable row formset
The old page gave every eligible member a table row, which a club with a hundred-plus members can't use, and its search was a GET round-trip that discarded anything already ticked. Now you add rows: pick a person from a searchable select, pick a position, fill in the details. The position decides the role. Position.staff_position already distinguishes them, so a staff position creates a StaffAssignment and anything else a TeamMembership -- no separate "player or staff?" control that could disagree with the position picked. Someone joining as both is two rows. Jersey number and captaincy exist only on TeamMembership, so a staff row rejects them and the row script greys them out, keyed off the data-staff marker PositionSelect stamps on staff options. All-or-nothing on submit: one bad row re-renders the page with every row still filled in and the offending field flagged, rather than saving the good rows and losing the rest -- a partial save costs far more when the rows were typed by hand. Cross-row checks no single row can see (the same person twice, two rows claiming one jersey) live on the formset's clean(); per-row checks live on the row form. Eligibility is still never trusted from the POST. Captain and alternate captain on one row is refused as self-contradictory, but how many captains a team may have is deliberately left alone: neither the model nor the single-add form constrains it, and inventing the rule in one entry path only would be bypassable by adding players one at a time. A test pins that absence so it reads as a decision rather than an oversight. Two implementation notes worth keeping: rows are cloned from the template's parsed content, not its innerHTML, because assigning "<tr>...</tr>" to a detached <div> silently drops it; and these tables deliberately skip the usual overflow-x-auto wrapper, which would make a scroll container that clips the picker's dropdown for every row but the first couple. Removing a row leaves TOTAL_FORMS alone -- Django reads a form whose fields are absent from the POST as an unchanged extra and skips it, which is safe, unlike re-indexing live inputs. management/tests.py also carries the setUpTestData rationalisation from the previous commit, which couldn't be split cleanly from the new bulk-add tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -456,6 +456,32 @@ number — modeled by `TeamMembership`, exactly matching the domain note.
|
|||||||
extra tenancy field is needed on the constraint.
|
extra tenancy field is needed on the constraint.
|
||||||
- `StaffAssignment` drives the coach/manager object-scope (§3.1–3.2) — it *is* the "is a
|
- `StaffAssignment` drives the coach/manager object-scope (§3.1–3.2) — it *is* the "is a
|
||||||
coach of this team" fact; no `ClubRole` mirrors it.
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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" %}
|
{% 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>
|
</div>
|
||||||
<button class="btn btn-primary btn-sm gap-2" type="submit">{% lucide "users" size=14 %} {% trans "Add selected" %}</button>
|
{% endfor %}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="overflow-x-auto">
|
{% comment %}
|
||||||
<table class="table">
|
Deliberately not wrapped in the usual overflow-x-auto: that makes a
|
||||||
<thead>
|
scroll container, which clips the member picker's absolutely
|
||||||
<tr>
|
positioned dropdown for every row but the first couple.
|
||||||
<th></th>
|
{% endcomment %}
|
||||||
<th>{% trans "Member" %}</th>
|
<table class="table w-full">
|
||||||
</tr>
|
<thead>
|
||||||
</thead>
|
<tr>
|
||||||
<tbody>
|
<th>{% trans "Member" %}</th>
|
||||||
{% for member in members %}
|
<th></th>
|
||||||
<tr>
|
</tr>
|
||||||
<td>
|
</thead>
|
||||||
{% if member.already_in_group %}
|
<tbody id="bulk-add-rows" data-prefix="{{ formset.prefix }}">
|
||||||
<span class="badge badge-neutral badge-sm">{% trans "In group" %}</span>
|
{% for form in formset %}
|
||||||
{% else %}
|
{% include "management/_group_bulk_add_row.html" with form=form %}
|
||||||
<input type="checkbox" class="checkbox" name="member_{{ member.pk }}">
|
{% endfor %}
|
||||||
{% endif %}
|
</tbody>
|
||||||
</td>
|
</table>
|
||||||
<td>{{ member }}</td>
|
|
||||||
</tr>
|
<template id="bulk-add-row-template">
|
||||||
{% empty %}
|
{% include "management/_group_bulk_add_row.html" with form=formset.empty_form %}
|
||||||
<tr>
|
</template>
|
||||||
<td colspan="2" class="text-center opacity-60">{% trans "No members match this search." %}</td>
|
|
||||||
</tr>
|
<div class="flex flex-wrap items-center gap-2 mt-2">
|
||||||
{% endfor %}
|
<button class="btn btn-outline btn-sm gap-2" type="button" id="add-row">{% lucide "plus" size=14 %} {% trans "Add another row" %}</button>
|
||||||
</tbody>
|
<button class="btn btn-primary btn-sm gap-2" type="submit">{% lucide "users" size=14 %} {% trans "Add to group" %}</button>
|
||||||
</table>
|
|
||||||
</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 %}
|
||||||
|
|||||||
@@ -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">
|
||||||
<thead>
|
<span>{{ error }}</span>
|
||||||
<tr>
|
</div>
|
||||||
<th>{% trans "Member" %}</th>
|
{% endfor %}
|
||||||
<th>{% trans "Player" %}</th>
|
|
||||||
<th>{% trans "Position" %}</th>
|
{% comment %}
|
||||||
<th>{% trans "Jersey #" %}</th>
|
Deliberately not wrapped in the usual overflow-x-auto: that makes a
|
||||||
<th>{% trans "Staff" %}</th>
|
scroll container, which clips the member picker's absolutely
|
||||||
<th>{% trans "Position" %}</th>
|
positioned dropdown for every row but the first couple.
|
||||||
</tr>
|
{% endcomment %}
|
||||||
</thead>
|
<table class="table w-full">
|
||||||
<tbody>
|
<thead>
|
||||||
{% for member in members %}
|
<tr>
|
||||||
<tr>
|
<th>{% trans "Member" %}</th>
|
||||||
<td>{{ member }}</td>
|
<th>{% trans "Position" %}</th>
|
||||||
<td>
|
<th>{% trans "Jersey #" %}</th>
|
||||||
{% if member.already_player %}
|
<th class="text-center">{% trans "Captain" %}</th>
|
||||||
<span class="badge badge-neutral badge-sm">{% trans "On roster" %}</span>
|
<th class="text-center">{% trans "Alternate" %}</th>
|
||||||
{% else %}
|
<th></th>
|
||||||
<input type="checkbox" class="checkbox player-checkbox" name="player_{{ member.pk }}" data-member="{{ member.pk }}">
|
</tr>
|
||||||
{% endif %}
|
</thead>
|
||||||
</td>
|
<tbody id="bulk-add-rows" data-prefix="{{ formset.prefix }}">
|
||||||
<td>
|
{% for form in formset %}
|
||||||
{% if not member.already_player %}
|
{% include "management/_team_bulk_add_row.html" with form=form %}
|
||||||
<select name="player_position_{{ member.pk }}" class="select select-bordered select-sm player-position" data-member="{{ member.pk }}" disabled>
|
{% endfor %}
|
||||||
<option value="">{% trans "—" %}</option>
|
</tbody>
|
||||||
{% for position in player_positions %}
|
</table>
|
||||||
<option value="{{ position.pk }}">{{ position.name }}</option>
|
|
||||||
{% endfor %}
|
<template id="bulk-add-row-template">
|
||||||
</select>
|
{% include "management/_team_bulk_add_row.html" with form=formset.empty_form %}
|
||||||
{% endif %}
|
</template>
|
||||||
</td>
|
|
||||||
<td>
|
<div class="flex flex-wrap items-center gap-2 mt-2">
|
||||||
{% if not member.already_player %}
|
<button class="btn btn-outline btn-sm gap-2" type="button" id="add-row">{% lucide "plus" size=14 %} {% trans "Add another row" %}</button>
|
||||||
<input type="number" min="0" name="jersey_{{ member.pk }}" class="input input-bordered input-sm w-20 player-jersey" data-member="{{ member.pk }}" disabled>
|
<button class="btn btn-primary btn-sm gap-2" type="submit">{% lucide "users" size=14 %} {% trans "Add to team" %}</button>
|
||||||
{% 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>
|
|
||||||
</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 %}
|
||||||
|
|||||||
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 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 = []
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
# Recomputed server-side from eligible_roster_members, never from client
|
for row in rows:
|
||||||
# input -- a submitted member id that isn't actually eligible (lapsed
|
if row["position"].staff_position:
|
||||||
# between page load and submit, say) is silently skipped rather than
|
StaffAssignment.objects.create(team=team, season=season, member=row["member"], position=row["position"])
|
||||||
# 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:
|
|
||||||
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)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):
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
89
static/js/bulk-add-rows.js
Normal file
89
static/js/bulk-add-rows.js
Normal 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();
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -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);
|
||||||
})();
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user