Files
RosterChief/management/forms.py
Bernard Siebens 9e581370e4 Add a club event-background photo, shown in black & white on event heroes
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
2026-08-21 18:11:02 +02:00

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}))