New Club.event_background, uploaded from the identity page. Individual events have no photo of their own (an established, deliberate scope decision for this build), so this is the one club-wide stand-in for the photo the design canvas's own hero mockups call for -- shown under the same dark gradient the hero already used, filtered to grayscale so it never fights the club's own brand colours. Falls back to the existing plain dark background when nothing's uploaded. Applied to both event hero treatments: M2's own event-detail screen (the "big black thing" this was reported against) and Home's "next up" card. The image and gradient are separate absolutely-positioned layers behind a z-10 content wrapper, not a filter on the card itself, so the grayscale treatment never touches the text/buttons drawn on top of it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
986 lines
47 KiB
Python
986 lines
47 KiB
Python
import datetime
|
|
from decimal import Decimal
|
|
|
|
from django import forms
|
|
from django.contrib.auth import get_user_model
|
|
from django.utils import timezone
|
|
from django.utils.translation import gettext_lazy as _
|
|
|
|
from club.models import Club, ClubMembership, ClubRole, FeePayment, OnboardingRequirement, Sponsor
|
|
from club.services.access import groups_manageable_by, is_club_admin, teams_managed_by
|
|
from events.models import Competition, Event, EventReferee, EventSeries, Location, Opponent
|
|
from events.services.rbihf_import import RBIHFImportError, extract_team_id
|
|
from members.models import Family, FamilyMembership, Group, Member
|
|
from members.services.family import find_member_by_email
|
|
from news.models import News
|
|
from teams.models import Position, RefereeLevel, RefereeProfile, StaffAssignment, Team, TeamMembership, TeamPhoto
|
|
from teams.services import eligible_roster_members
|
|
|
|
from .recurrence_ui import FREQUENCY_CHOICES, WEEKDAY_CHOICES, build_rrule, parse_rrule
|
|
|
|
User = get_user_model()
|
|
|
|
|
|
class MemberForm(forms.ModelForm):
|
|
class Meta:
|
|
model = Member
|
|
fields = ["first_name", "last_name", "date_of_birth", "email", "phone", "emergency_phone"]
|
|
widgets = {"date_of_birth": forms.DateInput(attrs={"type": "date"})}
|
|
|
|
|
|
class TeamForm(forms.ModelForm):
|
|
class Meta:
|
|
model = Team
|
|
fields = ["name", "short_name", "referee_management"]
|
|
|
|
|
|
class TeamPhotoForm(forms.ModelForm):
|
|
"""One photo per (team, season) -- bound to the existing TeamPhoto (if
|
|
any) by the view's get_form_kwargs, same "create or update the one row
|
|
for this scope" pattern as controlpanel.forms.HomeLocationForm."""
|
|
|
|
class Meta:
|
|
model = TeamPhoto
|
|
fields = ["image"]
|
|
|
|
|
|
class GroupForm(forms.ModelForm):
|
|
"""A generic named collection of members -- nothing team- or
|
|
referee-specific here; see MemberRefereeEligibilityForm for that."""
|
|
|
|
class Meta:
|
|
model = Group
|
|
fields = ["name"]
|
|
|
|
|
|
class RefereeLevelForm(forms.ModelForm):
|
|
class Meta:
|
|
model = RefereeLevel
|
|
fields = ["name", "teams", "inherits_from"]
|
|
widgets = {"teams": forms.SelectMultiple(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a team to search...")})}
|
|
|
|
def __init__(self, *args, club=None, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self.fields["teams"].queryset = Team.objects.filter(club=club)
|
|
# Excludes self from the dropdown -- self.instance always has a pk (UUIDModel
|
|
# gets its default at construction, not at save()), so this also works for a
|
|
# brand-new, unsaved level. clean() still catches an indirect loop (A -> B -> A).
|
|
self.fields["inherits_from"].queryset = RefereeLevel.objects.filter(club=club).exclude(pk=self.instance.pk)
|
|
self.fields["inherits_from"].empty_label = _("— none —")
|
|
|
|
|
|
class MemberRefereeEligibilityForm(forms.ModelForm):
|
|
"""This member's referee level and how long it's valid for -- edited from
|
|
the member's own page (teams.RefereeProfile), get-or-created on save.
|
|
Which teams that translates to is derived entirely from the level
|
|
(RefereeLevel.teams), not picked here -- see RefereeProfile.eligible_teams."""
|
|
|
|
class Meta:
|
|
model = RefereeProfile
|
|
fields = ["level", "valid_until"]
|
|
widgets = {"valid_until": forms.DateInput(attrs={"type": "date"})}
|
|
|
|
def __init__(self, *args, club=None, member=None, **kwargs):
|
|
instance = RefereeProfile.objects.filter(member=member).first() if member is not None else None
|
|
super().__init__(*args, instance=instance, **kwargs)
|
|
# UUIDModel PKs get their default at construction time (not at save()),
|
|
# so a brand-new instance already has a non-None pk -- "is this new"
|
|
# can't be read off self.instance.pk. Setting member here (rather than
|
|
# in save()) means it's already correct before validation runs too.
|
|
if instance is None:
|
|
self.instance.member = member
|
|
self.fields["level"].queryset = RefereeLevel.objects.filter(club=club)
|
|
self.fields["level"].required = False
|
|
self.fields["level"].empty_label = _("— no level (not eligible) —")
|
|
|
|
|
|
class ExternalRefereeForm(forms.Form):
|
|
"""Log a non-member referee (federation-appointed, most often) by name
|
|
only -- see events.services.referees.add_external_referee."""
|
|
|
|
name = forms.CharField(label=_("Referee name"))
|
|
|
|
|
|
class EventRefereeFeeForm(forms.ModelForm):
|
|
"""One referee assignment's payment details -- see
|
|
events.services.referees.set_referee_fee. `km`/`km_rate` are left blank
|
|
(not defaulted to 0) whenever no travel was logged, so the PDF export can
|
|
tell "no kilometers" from "zero kilometers claimed"."""
|
|
|
|
class Meta:
|
|
model = EventReferee
|
|
fields = ["fee", "km", "km_rate"]
|
|
widgets = {
|
|
"fee": forms.NumberInput(attrs={"step": "0.01", "min": "0"}),
|
|
"km": forms.NumberInput(attrs={"step": "0.1", "min": "0"}),
|
|
"km_rate": forms.NumberInput(attrs={"step": "any", "min": "0"}),
|
|
}
|
|
|
|
|
|
class TeamMembershipForm(forms.ModelForm):
|
|
"""Add/edit one roster entry -- team and season come from the view (the URL
|
|
already identifies both), never from the form itself."""
|
|
|
|
class Meta:
|
|
model = TeamMembership
|
|
fields = ["member", "position", "jersey_number", "is_captain", "is_alternate_captain"]
|
|
widgets = {"member": forms.Select(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a name to search...")})}
|
|
|
|
def __init__(self, *args, club=None, team=None, season=None, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self.team = team
|
|
self.season = season
|
|
# eligible_roster_members already covers "active this season or the next
|
|
# one, not lapsed/pending/cancelled or active only in some other season" --
|
|
# eligible regardless of which season's roster is being edited (the team
|
|
# detail page's season switcher can be pointed at an older season).
|
|
members = eligible_roster_members(club)
|
|
if team is not None and season is not None:
|
|
# Already on this team's roster this season -- offering them again
|
|
# would just fail the unique_member_per_team_per_season constraint.
|
|
taken = TeamMembership.objects.filter(team=team, season=season).exclude(pk=self.instance.pk).values_list("member_id", flat=True)
|
|
members = members.exclude(pk__in=taken)
|
|
self.fields["member"].queryset = members
|
|
self.fields["position"].queryset = Position.objects.filter(club=club, staff_position=False)
|
|
# The model field itself is optional (SignupTeamPlacementForm leaves it
|
|
# blank on purpose -- see that form's own docstring), but adding/editing a
|
|
# roster spot here is still expected to pick one; only the Sign-up page's
|
|
# own placement skips it.
|
|
self.fields["position"].required = True
|
|
|
|
def clean(self):
|
|
cleaned = super().clean()
|
|
# team/season aren't form fields (the view sets them from the URL, not user
|
|
# input), so Django's automatic validate_unique() excludes both of them --
|
|
# and with them, the whole unique_jersey_number_per_team_per_season check.
|
|
# Without this, a clashing jersey number reaches the database unrejected
|
|
# and surfaces as a raw IntegrityError instead of a form error.
|
|
jersey_number = cleaned.get("jersey_number")
|
|
if jersey_number is not None and self.team is not None and self.season is not None:
|
|
clash = TeamMembership.objects.filter(team=self.team, season=self.season, jersey_number=jersey_number).exclude(pk=self.instance.pk).exists()
|
|
if clash:
|
|
self.add_error("jersey_number", _("Another player on this team already has this jersey number this season."))
|
|
return cleaned
|
|
|
|
|
|
class StaffAssignmentForm(forms.ModelForm):
|
|
"""Assign/edit one staff assignment -- team and season come from the view,
|
|
same reasoning as TeamMembershipForm."""
|
|
|
|
class Meta:
|
|
model = StaffAssignment
|
|
fields = ["member", "position"]
|
|
widgets = {"member": forms.Select(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a name to search...")})}
|
|
|
|
def __init__(self, *args, club=None, team=None, season=None, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
# See TeamMembershipForm for why current-or-next-season: eligible to be
|
|
# assigned regardless of which season's staff list is being edited.
|
|
members = eligible_roster_members(club)
|
|
if team is not None and season is not None:
|
|
taken = StaffAssignment.objects.filter(team=team, season=season).exclude(pk=self.instance.pk).values_list("member_id", flat=True)
|
|
members = members.exclude(pk__in=taken)
|
|
self.fields["member"].queryset = members
|
|
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
|
|
fields = ["name", "short_name", "ordering", "staff_position", "management_position"]
|
|
|
|
def clean(self):
|
|
cleaned = super().clean()
|
|
# Mirrors Position's management_position_implies_staff_position check
|
|
# constraint -- caught here so it reads as a form error, not a 500.
|
|
if cleaned.get("management_position") and not cleaned.get("staff_position"):
|
|
self.add_error("management_position", _("A management position must also be a staff position."))
|
|
return cleaned
|
|
|
|
|
|
class LocationForm(forms.ModelForm):
|
|
class Meta:
|
|
model = Location
|
|
fields = ["name", "address", "city", "zip_code", "country"]
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
# CountryField's own widget (a lazily-translated Select) must stay the widget
|
|
# class -- only the searchable-select JS hooks are added on top of it, the
|
|
# same progressive enhancement TeamMembershipForm uses for its member field.
|
|
self.fields["country"].widget.attrs.update({"data-searchable": "true", "data-search-placeholder": _("Type a country to search...")})
|
|
|
|
|
|
class OpponentForm(forms.ModelForm):
|
|
class Meta:
|
|
model = Opponent
|
|
fields = ["name", "logo"]
|
|
|
|
|
|
class SponsorForm(forms.ModelForm):
|
|
class Meta:
|
|
model = Sponsor
|
|
fields = ["name", "logo", "url", "start_date", "end_date"]
|
|
widgets = {
|
|
"start_date": forms.DateInput(attrs={"type": "date"}),
|
|
"end_date": forms.DateInput(attrs={"type": "date"}),
|
|
}
|
|
|
|
def clean(self):
|
|
cleaned = super().clean()
|
|
# Mirrors Sponsor.clean() -- caught here too so it reads as a form
|
|
# error tied to the end_date field, not a raw ValidationError.
|
|
start_date, end_date = cleaned.get("start_date"), cleaned.get("end_date")
|
|
if start_date and end_date and end_date < start_date:
|
|
self.add_error("end_date", _("End date can't be before the start date."))
|
|
return cleaned
|
|
|
|
|
|
def _location_label(location) -> str:
|
|
""""Name — City" for the location picker, plus the country when it isn't
|
|
Belgium (the club's home country and, in practice, nearly every location
|
|
a club will ever add) -- lets an admin tell two same-named venues apart,
|
|
or spot an away trip abroad, straight from the dropdown."""
|
|
label = f"{location.name} — {location.city}"
|
|
if location.country.code != "BE":
|
|
label += f", {location.country.name}"
|
|
return label
|
|
|
|
|
|
class EventAudienceFormMixin:
|
|
"""Shared club/user-scoped audience fields for EventForm and EventSeriesForm:
|
|
teams restricted to the ones the requester manages (all of them for an
|
|
admin), groups restricted to the ones the requester belongs to (all of them
|
|
for an admin -- Group has no manager/owner concept the way Team does, so
|
|
membership is the only claim there is), and a non-admin must pick at least
|
|
one team or group -- a club-wide event (e.g. an AGM) has no such claim to
|
|
anchor it to, so `club_wide` stays admin-only (the field doesn't even exist
|
|
on the form otherwise)."""
|
|
|
|
def scope_audience_fields(self, club, user):
|
|
self.club = club
|
|
self.user = user
|
|
self.fields["teams"].queryset = teams_managed_by(user, club)
|
|
self.fields["groups"].queryset = groups_manageable_by(user, club)
|
|
self.fields["location"].queryset = Location.objects.filter(club=club)
|
|
self.fields["location"].label_from_instance = _location_label
|
|
self.fields["opponent"].queryset = Opponent.objects.filter(club=club)
|
|
members = Member.objects.filter(member_of__club=club).distinct()
|
|
self.fields["invited_members"].queryset = members
|
|
self.fields["excluded_members"].queryset = members
|
|
if not is_club_admin(user, club):
|
|
del self.fields["club_wide"]
|
|
|
|
def clean_audience_requires_a_claim_for_non_admins(self, cleaned):
|
|
if is_club_admin(self.user, self.club):
|
|
return
|
|
teams = cleaned.get("teams")
|
|
groups = cleaned.get("groups")
|
|
if not (teams is not None and teams.exists()) and not (groups is not None and groups.exists()):
|
|
self.add_error(None, _("Select at least one of your teams or groups, or ask an admin to create a club-wide event."))
|
|
|
|
def clean_club_wide_excludes_teams_and_groups(self, cleaned):
|
|
if not cleaned.get("club_wide"):
|
|
return
|
|
teams = cleaned.get("teams")
|
|
groups = cleaned.get("groups")
|
|
if (teams is not None and teams.exists()) or (groups is not None and groups.exists()):
|
|
self.add_error("club_wide", _("A whole-club event can't also list specific teams or groups -- clear them, or turn this off."))
|
|
|
|
|
|
_AUDIENCE_WIDGETS = {
|
|
"teams": forms.SelectMultiple(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a team to search...")}),
|
|
"groups": forms.SelectMultiple(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a group to search...")}),
|
|
"invited_members": forms.SelectMultiple(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a name to search...")}),
|
|
"excluded_members": forms.SelectMultiple(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a name to search...")}),
|
|
"location": forms.Select(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a name or city to search...")}),
|
|
}
|
|
|
|
|
|
class EventForm(EventAudienceFormMixin, forms.ModelForm):
|
|
class Meta:
|
|
model = Event
|
|
fields = [
|
|
"title",
|
|
"kind",
|
|
"teams",
|
|
"groups",
|
|
"club_wide",
|
|
"invited_members",
|
|
"excluded_members",
|
|
"location",
|
|
"opponent",
|
|
"start",
|
|
"end",
|
|
"gathering",
|
|
"deadline",
|
|
"competition",
|
|
"external_game_id",
|
|
"score_for",
|
|
"score_against",
|
|
"is_live",
|
|
"max_referees",
|
|
]
|
|
widgets = {
|
|
"start": forms.DateTimeInput(attrs={"type": "datetime-local"}),
|
|
"end": forms.DateTimeInput(attrs={"type": "datetime-local"}),
|
|
"gathering": forms.DateTimeInput(attrs={"type": "datetime-local"}),
|
|
"deadline": forms.DateTimeInput(attrs={"type": "datetime-local"}),
|
|
"score_for": forms.NumberInput(attrs={"min": 0}),
|
|
"score_against": forms.NumberInput(attrs={"min": 0}),
|
|
"max_referees": forms.NumberInput(attrs={"min": 1}),
|
|
**_AUDIENCE_WIDGETS,
|
|
}
|
|
|
|
def __init__(self, *args, club=None, user=None, editing=False, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self.scope_audience_fields(club, user)
|
|
# Unlike the Django-admin form (events.admin.EventAdminForm), this dropdown
|
|
# isn't filtered by which competitions have their feature flag on for the
|
|
# club -- a manager should be able to pick any competition when scheduling a
|
|
# game; it's fetch_game_info (events.services.competitions) that gates the
|
|
# actual per-club fetch once a competition is set.
|
|
self.fields["competition"] = forms.ChoiceField(
|
|
choices=[("", "---------"), *[(competition.name, competition.name) for competition in Competition.objects.all()]],
|
|
required=False,
|
|
label=self.fields["competition"].label,
|
|
help_text=self.fields["competition"].help_text,
|
|
)
|
|
if not editing:
|
|
# Score/live status don't exist yet for a game that's only just being
|
|
# scheduled -- offering them on the add form is just noise. Editing an
|
|
# existing game is the only time there's anything to record here.
|
|
del self.fields["score_for"]
|
|
del self.fields["score_against"]
|
|
del self.fields["is_live"]
|
|
|
|
def clean(self):
|
|
cleaned = super().clean()
|
|
self.clean_audience_requires_a_claim_for_non_admins(cleaned)
|
|
self.clean_club_wide_excludes_teams_and_groups(cleaned)
|
|
return cleaned
|
|
|
|
|
|
class RBIHFImportForm(forms.Form):
|
|
"""Step 1 of importing a team's fixtures from RBIHF's own website -- see
|
|
events.services.rbihf_import. Only the URL and which of the club's own
|
|
teams it applies to; everything else is scraped."""
|
|
|
|
url = forms.CharField(
|
|
label=_("RBIHF team page URL"),
|
|
help_text=_("E.g. https://www.rbihf.be/league/team/4460"),
|
|
widget=forms.URLInput(attrs={"placeholder": "https://www.rbihf.be/league/team/4460"}),
|
|
)
|
|
team = forms.ModelChoiceField(queryset=Team.objects.none(), label=_("Team"), help_text=_("Which of your teams this fixture list is for."))
|
|
|
|
def __init__(self, *args, club=None, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self.fields["team"].queryset = Team.objects.filter(club=club)
|
|
|
|
def clean_url(self):
|
|
url = self.cleaned_data["url"].strip()
|
|
try:
|
|
extract_team_id(url)
|
|
except RBIHFImportError as error:
|
|
raise forms.ValidationError(str(error)) from error
|
|
return url
|
|
|
|
|
|
class EventSeriesForm(EventAudienceFormMixin, forms.ModelForm):
|
|
"""A friendly weekly/monthly recurrence picker on top of EventSeries' raw
|
|
RRULE (see management.recurrence_ui) -- covers the common case (every N
|
|
weeks/months) with an advanced raw-RRULE field as an escape hatch for
|
|
anything else."""
|
|
|
|
frequency = forms.ChoiceField(choices=FREQUENCY_CHOICES, initial="weekly", label=_("Repeats"))
|
|
interval = forms.IntegerField(min_value=1, initial=1, label=_("Every"), help_text=_("E.g. 2 for every other week/month."))
|
|
# A plain multi-select, not CheckboxSelectMultiple: its widget_type ("checkboxselectmultiple")
|
|
# isn't one form_field's ui.py recognises, which would silently render a broken
|
|
# plain text input instead -- see the "lazyselect" fix for the same class of bug.
|
|
weekdays = forms.MultipleChoiceField(choices=WEEKDAY_CHOICES, required=False, label=_("On"), widget=forms.SelectMultiple(attrs={"data-searchable": "true"}))
|
|
duration_hours = forms.IntegerField(min_value=0, required=False, label=_("Duration (hours)"))
|
|
duration_minutes = forms.IntegerField(min_value=0, max_value=59, required=False, label=_("Duration (minutes)"))
|
|
gathering_minutes_before = forms.IntegerField(min_value=0, required=False, label=_("Gather (minutes before)"))
|
|
deadline_minutes_before = forms.IntegerField(min_value=0, required=False, label=_("Registration deadline (minutes before)"))
|
|
advanced_rrule = forms.CharField(required=False, label=_("Advanced: raw recurrence rule"), help_text=_("Overrides the fields above if filled in. RFC 5545 RRULE, e.g. FREQ=WEEKLY;BYDAY=MO,WE."))
|
|
|
|
class Meta:
|
|
model = EventSeries
|
|
fields = ["title", "kind", "dtstart", "until", "teams", "groups", "club_wide", "invited_members", "excluded_members", "location", "opponent"]
|
|
widgets = {
|
|
"dtstart": forms.DateTimeInput(attrs={"type": "datetime-local"}),
|
|
"until": forms.DateTimeInput(attrs={"type": "datetime-local"}),
|
|
**_AUDIENCE_WIDGETS,
|
|
}
|
|
|
|
def __init__(self, *args, club=None, user=None, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self.scope_audience_fields(club, user)
|
|
|
|
if self.instance.pk:
|
|
parsed = parse_rrule(self.instance.rrule)
|
|
if parsed is not None:
|
|
self.fields["frequency"].initial = parsed["frequency"]
|
|
self.fields["interval"].initial = parsed["interval"]
|
|
self.fields["weekdays"].initial = parsed["weekdays"]
|
|
else:
|
|
# Not one of our two shapes (hand-written/imported) -- show it
|
|
# verbatim in the advanced field rather than guessing at it.
|
|
self.fields["advanced_rrule"].initial = self.instance.rrule
|
|
|
|
if self.instance.duration is not None:
|
|
total_minutes = int(self.instance.duration.total_seconds() // 60)
|
|
self.fields["duration_hours"].initial = total_minutes // 60
|
|
self.fields["duration_minutes"].initial = total_minutes % 60
|
|
if self.instance.gathering_offset is not None:
|
|
self.fields["gathering_minutes_before"].initial = int(self.instance.gathering_offset.total_seconds() // 60)
|
|
if self.instance.deadline_offset is not None:
|
|
self.fields["deadline_minutes_before"].initial = int(self.instance.deadline_offset.total_seconds() // 60)
|
|
|
|
def clean(self):
|
|
cleaned = super().clean()
|
|
self.clean_audience_requires_a_claim_for_non_admins(cleaned)
|
|
self.clean_club_wide_excludes_teams_and_groups(cleaned)
|
|
|
|
if cleaned.get("advanced_rrule"):
|
|
return cleaned # the advanced field wins outright; nothing else to check
|
|
|
|
if cleaned.get("frequency") == "weekly" and not cleaned.get("weekdays"):
|
|
self.add_error("weekdays", _("Pick at least one day of the week."))
|
|
return cleaned
|
|
|
|
def save(self, commit=True):
|
|
instance = super().save(commit=False)
|
|
cleaned = self.cleaned_data
|
|
|
|
advanced = (cleaned.get("advanced_rrule") or "").strip()
|
|
instance.rrule = advanced or build_rrule(cleaned["frequency"], cleaned["interval"], cleaned.get("weekdays"))
|
|
|
|
hours, minutes = cleaned.get("duration_hours") or 0, cleaned.get("duration_minutes") or 0
|
|
instance.duration = datetime.timedelta(hours=hours, minutes=minutes) if (hours or minutes) else None
|
|
|
|
gathering_minutes = cleaned.get("gathering_minutes_before")
|
|
instance.gathering_offset = datetime.timedelta(minutes=gathering_minutes) if gathering_minutes else None
|
|
|
|
deadline_minutes = cleaned.get("deadline_minutes_before")
|
|
instance.deadline_offset = datetime.timedelta(minutes=deadline_minutes) if deadline_minutes else None
|
|
|
|
if commit:
|
|
instance.save()
|
|
self.save_m2m()
|
|
return instance
|
|
|
|
|
|
class ClubRoleAssignForm(forms.ModelForm):
|
|
"""Grant a club-wide role to a member already affiliated with this club."""
|
|
|
|
class Meta:
|
|
model = ClubRole
|
|
fields = ["member", "role"]
|
|
widgets = {"member": forms.Select(attrs={"data-searchable": "true", "data-search-placeholder": _("Type a name to search...")})}
|
|
|
|
def __init__(self, *args, club=None, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
# Never list members of other clubs -- this isn't a platform-wide picker.
|
|
self.fields["member"].queryset = Member.objects.filter(member_of__club=club).distinct()
|
|
|
|
|
|
class FamilyCreateForm(forms.Form):
|
|
"""One new family in one go: a parent (who gets a login) and a child (who
|
|
doesn't). See members.services.family.register_family."""
|
|
|
|
parent_first_name = forms.CharField(label=_("Parent first name"))
|
|
parent_last_name = forms.CharField(label=_("Parent last name"))
|
|
parent_email = forms.EmailField(label=_("Parent email"), help_text=_("If this email has no account yet, one is created and they set a password via the reset link."))
|
|
|
|
parent_is_member = forms.BooleanField(
|
|
label=_("Parent is also a member"),
|
|
required=False,
|
|
help_text=_("Tick only if this parent belongs to the club in their own right (they play, or hold a membership). Left unticked they are a guardian: they hold the login and can be contacted, but owe no fee and are not counted as a member."),
|
|
)
|
|
|
|
child_first_name = forms.CharField(label=_("Child first name"))
|
|
child_last_name = forms.CharField(label=_("Child last name"))
|
|
child_date_of_birth = forms.DateField(label=_("Child date of birth"), required=False, widget=forms.DateInput(attrs={"type": "date"}))
|
|
|
|
|
|
class AddChildForm(forms.Form):
|
|
"""A family that needs one more child registered -- see
|
|
members.services.family.add_child_to_family."""
|
|
|
|
first_name = forms.CharField(label=_("First name"))
|
|
last_name = forms.CharField(label=_("Last name"))
|
|
date_of_birth = forms.DateField(label=_("Date of birth"), required=False, widget=forms.DateInput(attrs={"type": "date"}))
|
|
|
|
|
|
class AddParentForm(forms.Form):
|
|
"""A family that needs one more parent/guardian registered -- see
|
|
members.services.family.add_parent_to_family."""
|
|
|
|
email = forms.EmailField(label=_("Email address"), help_text=_("If this email has no account yet, one is created and they set a password via the reset link."))
|
|
first_name = forms.CharField(label=_("First name"), required=False)
|
|
last_name = forms.CharField(label=_("Last name"), required=False)
|
|
parent_is_member = forms.BooleanField(
|
|
label=_("Also a member"),
|
|
required=False,
|
|
help_text=_("Tick only if this parent belongs to the club in their own right (they play, or hold a membership). Left unticked they are a guardian: they hold the login and can be contacted, but owe no fee and are not counted as a member."),
|
|
)
|
|
|
|
def clean(self):
|
|
cleaned = super().clean()
|
|
email = cleaned.get("email")
|
|
|
|
# Only a brand-new person needs a name; an existing member already has one.
|
|
if email and find_member_by_email(email) is None:
|
|
for field in ("first_name", "last_name"):
|
|
if not cleaned.get(field):
|
|
self.add_error(field, _("Required: this email has no account yet."))
|
|
|
|
return cleaned
|
|
|
|
|
|
class AttachToFamilyForm(forms.Form):
|
|
"""Link a standalone member into a family -- a new one, or an existing one they
|
|
turn out to belong to. See members.services.family.attach_to_family."""
|
|
|
|
role = forms.ChoiceField(label=_("Role"), choices=FamilyMembership.FamilyRole.choices)
|
|
family = forms.ModelChoiceField(label=_("Family"), queryset=Family.objects.none(), required=False, empty_label=_("— start a new family —"))
|
|
|
|
def __init__(self, *args, club=None, member=None, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
# Same scoping query as management.views.families_of_club -- inlined rather
|
|
# than imported, since that function lives in views.py, which imports this
|
|
# module (a module-level import back here would be circular).
|
|
queryset = Family.objects.filter(memberships__member__member_of__club=club).distinct()
|
|
if member is not None:
|
|
# Already a member of it -- offering it again would be a no-op re-add.
|
|
queryset = queryset.exclude(memberships__member=member)
|
|
self.fields["family"].queryset = queryset
|
|
|
|
|
|
class MemberImportUploadForm(forms.Form):
|
|
"""The mass-upload entry point -- one .xlsx file, built from the downloadable
|
|
template. See management.bulk_import.read_member_import_workbook."""
|
|
|
|
file = forms.FileField(label=_("Excel file"), help_text=_("Use the downloaded template — one row per member."))
|
|
|
|
|
|
class GrantLoginForm(forms.Form):
|
|
"""A login-less family member (a child, typically) getting their own account --
|
|
see members.services.family.grant_login. Pre-filled from the member's contact
|
|
email where one is already on file; still editable, and required either way."""
|
|
|
|
email = forms.EmailField(label=_("Email"), help_text=_("They'll set a password via the reset link the first time they sign in."))
|
|
|
|
def clean_email(self):
|
|
email = self.cleaned_data["email"]
|
|
if User.objects.filter(email__iexact=email).exists():
|
|
raise forms.ValidationError(_("This email is already in use."))
|
|
return email
|
|
|
|
|
|
class ClubMembershipForm(forms.ModelForm):
|
|
"""This season's standing -- shown and edited right on the member's own page,
|
|
since a Member has no club of its own without one. fee_amount is the only
|
|
money field here -- amount_paid is exclusively written by club.services.fees,
|
|
never hand-edited."""
|
|
|
|
class Meta:
|
|
model = ClubMembership
|
|
fields = ["kind", "license", "status", "fee_status", "fee_amount"]
|
|
|
|
|
|
class NewsForm(forms.ModelForm):
|
|
"""Title/teams/visibility/body only -- status and published_at are never
|
|
directly editable, only through the publish/unpublish actions."""
|
|
|
|
class Meta:
|
|
model = News
|
|
fields = ["title", "title_en", "teams", "visibility", "body", "body_en"]
|
|
widgets = {
|
|
"teams": forms.SelectMultiple(attrs={"data-searchable": "true", "data-search-placeholder": _("Type to filter teams...")}),
|
|
"body": forms.Textarea(attrs={"rows": 8}),
|
|
"body_en": forms.Textarea(attrs={"rows": 8}),
|
|
}
|
|
|
|
def __init__(self, *args, club=None, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self.fields["teams"].queryset = Team.objects.filter(club=club)
|
|
|
|
|
|
class MultipleFileInput(forms.ClearableFileInput):
|
|
allow_multiple_selected = True
|
|
|
|
|
|
class MultipleFileField(forms.FileField):
|
|
"""Django's own documented recipe for a multi-file upload field: the plain
|
|
FileField only ever picks up one of several selected files, so clean() has
|
|
to iterate the list itself instead."""
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
kwargs.setdefault("widget", MultipleFileInput(attrs={"multiple": True}))
|
|
super().__init__(*args, **kwargs)
|
|
|
|
def clean(self, data, initial=None):
|
|
single_file_clean = super().clean
|
|
if isinstance(data, (list, tuple)):
|
|
return [single_file_clean(item, initial) for item in data]
|
|
return [single_file_clean(data, initial)] if data else []
|
|
|
|
|
|
class NewsPhotoUploadForm(forms.Form):
|
|
images = MultipleFileField(label=_("Photos"))
|
|
|
|
|
|
class NewsPublishForm(forms.Form):
|
|
published_at = forms.DateTimeField(
|
|
label=_("Publish date"),
|
|
initial=timezone.now,
|
|
widget=forms.DateTimeInput(attrs={"type": "datetime-local"}),
|
|
help_text=_("Leave as now to publish immediately, or pick a future date/time to schedule it."),
|
|
)
|
|
notify_members = forms.BooleanField(
|
|
label=_("Notify linked members"),
|
|
required=False,
|
|
initial=False,
|
|
help_text=_("Emails everyone this item reaches (its teams' current rosters, or every active member if it's club-wide) -- and, always, their parent/guardian. Sent when this actually goes live, not necessarily right now."),
|
|
)
|
|
|
|
|
|
class RecordFeePaymentForm(forms.Form):
|
|
"""Money received against one membership's fee -- see club.services.fees.record_payment.
|
|
Reusable for any amount, partial or the exact remaining balance; "Mark fully
|
|
paid" (management.views.MembershipMarkFullyPaidView) skips this form entirely
|
|
and settles the balance directly in one click."""
|
|
|
|
amount = forms.DecimalField(label=_("Amount"), max_digits=10, decimal_places=2, min_value=Decimal("0.01"))
|
|
method = forms.ChoiceField(label=_("Method"), choices=FeePayment.Method.choices)
|
|
reference = forms.CharField(label=_("Reference"), required=False, help_text=_("Bank reference, transaction id — whatever lets you find this again."))
|
|
note = forms.CharField(label=_("Note"), required=False, widget=forms.Textarea(attrs={"rows": 2}))
|
|
|
|
|
|
class SendDuesInvoicesForm(forms.Form):
|
|
"""The one shared setting for a batch of invoices sent from the Dues & billing
|
|
page -- see club.services.invoicing.create_or_resend_invoice. Applies to every
|
|
membership selected in the same submit, not chosen per row.
|
|
|
|
The widget's own `form` attr (rendered as-is by templatetags/field.html's attrs
|
|
passthrough) is what lets this field live inside the confirm dialog while still
|
|
posting through the row-checkboxes' #membership-form -- see membership_list.html's
|
|
send_invoices_modal."""
|
|
|
|
due_in_days = forms.IntegerField(label=_("Due in"), min_value=1, max_value=365, initial=14, help_text=_("Days from today."), widget=forms.NumberInput(attrs={"form": "membership-form"}))
|
|
|
|
|
|
class ClubSettingsForm(forms.ModelForm):
|
|
"""A club's own self-service identity/branding editor (management:club_settings) --
|
|
the club-facing equivalent of controlpanel's ClubForm, minus everything only
|
|
platform staff should touch: `slug` (the club's subdomain -- changing it breaks
|
|
every link the club has ever shared) and the season fields (governed by
|
|
club.services.seasons, not something to edit casually from a settings form)."""
|
|
|
|
class Meta:
|
|
model = Club
|
|
fields = ["name", "legal_name", "legal_address", "legal_zip_code", "legal_city", "contact_email", "website", "logo", "event_background", "primary_color", "secondary_color"]
|
|
widgets = {
|
|
"primary_color": forms.TextInput(attrs={"placeholder": "#1e40af"}),
|
|
"secondary_color": forms.TextInput(attrs={"placeholder": "#be185d"}),
|
|
"logo": forms.ClearableFileInput(attrs={"accept": "image/png,image/jpeg,image/gif,image/webp,image/svg+xml"}),
|
|
"event_background": forms.ClearableFileInput(attrs={"accept": "image/png,image/jpeg,image/gif,image/webp"}),
|
|
}
|
|
|
|
|
|
class OnboardingRequirementForm(forms.ModelForm):
|
|
#: Not a model field lookup -- blocked_event_kinds is a plain JSONField (see
|
|
#: OnboardingRequirement's own docstring for why: importing Event.EventKind at
|
|
#: the model layer would create a club<->events import cycle). This is the one
|
|
#: place that actually validates against real event kinds.
|
|
blocked_event_kinds = forms.MultipleChoiceField(
|
|
label=_("Blocks selection for"),
|
|
choices=Event.EventKind.choices,
|
|
required=False,
|
|
widget=forms.CheckboxSelectMultiple,
|
|
help_text=_("Event kinds a member can't be invited to or selected for while this is open. Leave unchecked for a purely informational requirement."),
|
|
)
|
|
|
|
class Meta:
|
|
model = OnboardingRequirement
|
|
fields = ["name", "description", "requires_document", "blocked_event_kinds", "is_active"]
|
|
|
|
|
|
class RequirementCompletionForm(forms.Form):
|
|
"""Marks one OnboardingRequirement complete for one membership -- see
|
|
club.services.onboarding.mark_complete. `document` is only meaningful when the
|
|
requirement itself has `requires_document=True`; the view doesn't reject an
|
|
upload against a requirement that doesn't ask for one, it's just unused."""
|
|
|
|
document = forms.FileField(label=_("Document"), required=False)
|
|
note = forms.CharField(label=_("Note"), required=False, widget=forms.Textarea(attrs={"rows": 2}))
|
|
|
|
|
|
class SignupTeamPlacementForm(forms.ModelForm):
|
|
"""Places one member (fixed by the view, not a form field) onto a team from
|
|
the Sign-up page -- team only, picked from big buttons rather than a form.
|
|
Position is left blank entirely (TeamMembership.position is nullable) rather
|
|
than guessed at: which position/number they end up with is the team
|
|
manager's own call to make correctly later (team_roster_update), and a
|
|
blank position is also what groups every just-placed, not-yet-sorted member
|
|
together (e.g. via the API) until then.
|
|
|
|
Deliberately does NOT filter through eligible_roster_members like
|
|
TeamMembershipForm's own member queryset does -- the whole point here is
|
|
placing a still-PENDING member so a team's coach can already see them and the
|
|
roster count is accurate, before their status/documents are actually clean.
|
|
See club.services.onboarding.blocked_member_ids_for_event for what still
|
|
gates their event invitations in the meantime."""
|
|
|
|
class Meta:
|
|
model = TeamMembership
|
|
fields = ["team"]
|
|
|
|
def __init__(self, *args, club=None, season=None, member=None, **kwargs):
|
|
# Pre-seed the instance (not just self.season/self.member) -- same reasoning
|
|
# as TeamRosterAddView's own form_kwargs comment: TeamMembership.clean()'s
|
|
# validate_club_scope needs season/member set on the instance *before*
|
|
# is_valid() runs, since ModelForm._post_clean() calls instance.clean()
|
|
# straight after copying the form's own fields (team, the only real one
|
|
# here) onto it -- season/member never being form fields, they'd
|
|
# otherwise still be unset at that point. position is left at its default
|
|
# (None) -- see the class docstring.
|
|
kwargs.setdefault("instance", TeamMembership(season=season, member=member))
|
|
super().__init__(*args, **kwargs)
|
|
self.fields["team"].queryset = Team.objects.filter(club=club)
|
|
|
|
|
|
class RequirementBypassForm(forms.Form):
|
|
"""Marks one OnboardingRequirement as not needed for one member -- see
|
|
club.services.onboarding.mark_bypassed. A note is required here (unlike
|
|
RequirementCompletionForm's optional one): "why" is the whole point of a
|
|
bypass in a way it isn't for an ordinary completion."""
|
|
|
|
note = forms.CharField(label=_("Why isn't this needed?"), widget=forms.Textarea(attrs={"rows": 2}))
|