New event: add Competition ID (games) and a Gathering time, both optional

- Competition ID (Event.external_game_id) sits under Competition, game-only,
  one-off only (no EventSeries equivalent) -- was already part of EventForm,
  just never rendered here.
- Gathering time (Event.gathering / EventSeries.gathering_offset) is
  available for every kind, in both the one-off and recurring paths -- the
  recurring form had this explicitly cut earlier; un-cut now that it's
  wanted, mirroring the existing Answers-close/deadline_minutes_before
  pattern right next to it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 17:35:59 +02:00
parent 1eaf92c977
commit b9900510b1
3 changed files with 86 additions and 12 deletions

View File

@@ -440,18 +440,17 @@ class CoachCreateEventView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
# one of the four tiles COACH_EVENT_KINDS offers below.
instance = Event(club=self.request.club, created_by=self.me, kind=Event.EventKind.TRAINING)
form = EventForm(data, club=self.request.club, user=self.request.user, editing=False, instance=instance)
# max_referees/external_game_id have no use on a coach-created event --
# max_referees has a model default (2) but no blank=True, so the field
# is required despite it; construct_instance skips a deleted field
# entirely, leaving the instance's own default/blank.
# is required despite it -- delete it rather than render a referee-
# count control this screen has no use for; construct_instance skips
# a deleted field entirely, leaving the instance's own default.
del form.fields["max_referees"]
del form.fields["external_game_id"]
# Narrowed to the four kinds the tile picker actually offers -- social/
# other don't get their own tile, and this keeps a tampered request from
# setting one anyway (the desktop form still offers the full list).
form.fields["kind"].choices = [choice for choice in form.fields["kind"].choices if choice[0] in COACH_EVENT_KINDS]
self._scope_shared_fields(form)
for field_name in ("start", "deadline", "competition"):
for field_name in ("start", "gathering", "deadline", "competition", "external_game_id"):
form.fields[field_name].widget.attrs["class"] = _INPUT_CLASSES
return form
@@ -462,9 +461,6 @@ class CoachCreateEventView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
# friendly frequency/interval/weekdays fields below cover the common
# weekly/monthly cases this screen is for.
del form.fields["advanced_rrule"]
# Not offered here: neither maps to a "how long since kickoff" a coach
# thinks in the way duration_hours/minutes below does.
del form.fields["gathering_minutes_before"]
form.fields["kind"].choices = [choice for choice in form.fields["kind"].choices if choice[0] in COACH_EVENT_KINDS]
self._scope_shared_fields(form)
# SelectMultiple relies on the desktop's searchable-select JS (not loaded
@@ -478,7 +474,7 @@ class CoachCreateEventView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
# that (same trap _scope_shared_fields' own comment covers, just the
# plain-ChoiceField shape of it).
form.fields["weekdays"].widget = forms.CheckboxSelectMultiple(attrs={"class": "sr-only"}, choices=form.fields["weekdays"].choices)
for field_name in ("dtstart", "until", "frequency", "interval", "duration_hours", "duration_minutes", "deadline_minutes_before"):
for field_name in ("dtstart", "until", "frequency", "interval", "duration_hours", "duration_minutes", "gathering_minutes_before", "deadline_minutes_before"):
form.fields[field_name].widget.attrs["class"] = _INPUT_CLASSES
return form

View File

@@ -70,6 +70,11 @@
<label class="mb-1 block text-xs font-semibold text-muted" for="{{ form.start.id_for_label }}">{% trans "Date &amp; time" %}</label>
{{ form.start }}
{% for error in form.start.errors %}<p class="mt-1 text-xs text-club-dark">{{ error }}</p>{% endfor %}
<div class="my-3 h-px bg-rule"></div>
<label class="mb-1 block text-xs font-semibold text-muted" for="{{ form.gathering.id_for_label }}">{% trans "Gathering time" %}</label>
{{ form.gathering }}
<p class="mt-1 text-xs text-dim">{% trans "Leave blank if there's no separate meet-up time." %}</p>
{% for error in form.gathering.errors %}<p class="mt-1 text-xs text-club-dark">{{ error }}</p>{% endfor %}
</div>
<div x-show="isRecurring" x-cloak>
@@ -77,6 +82,11 @@
<label class="mb-1 block text-xs font-semibold text-muted" for="{{ series_form.dtstart.id_for_label }}">{% trans "First occurrence" %}</label>
{{ series_form.dtstart }}
{% for error in series_form.dtstart.errors %}<p class="mt-1 text-xs text-club-dark">{{ error }}</p>{% endfor %}
<div class="my-3 h-px bg-rule"></div>
<label class="mb-1 block text-xs font-semibold text-muted" for="{{ series_form.gathering_minutes_before.id_for_label }}">{% trans "Gathering (minutes before each occurrence)" %}</label>
{{ series_form.gathering_minutes_before }}
<p class="mt-1 text-xs text-dim">{% trans "Leave blank if there's no separate meet-up time." %}</p>
{% for error in series_form.gathering_minutes_before.errors %}<p class="mt-1 text-xs text-club-dark">{{ error }}</p>{% endfor %}
</div>
<div class="my-3 h-px bg-rule"></div>
@@ -89,9 +99,18 @@
<div class="m-card p-4">
{% include "mobile/coach/_opponent_picker.html" %}
</div>
<div x-show="!isRecurring" class="m-card p-4">
<label class="mb-1 block text-xs font-semibold text-muted" for="{{ form.competition.id_for_label }}">{% trans "Competition" %}</label>
{{ form.competition }}
<div x-show="!isRecurring" class="m-card flex flex-col p-4">
<div>
<label class="mb-1 block text-xs font-semibold text-muted" for="{{ form.competition.id_for_label }}">{% trans "Competition" %}</label>
{{ form.competition }}
</div>
<div class="my-3 h-px bg-rule"></div>
<div>
<label class="mb-1 block text-xs font-semibold text-muted" for="{{ form.external_game_id.id_for_label }}">{% trans "Competition ID" %}</label>
{{ form.external_game_id }}
<p class="mt-1 text-xs text-dim">{% trans "This game's id in the competition's own data source, if it has one." %}</p>
{% for error in form.external_game_id.errors %}<p class="mt-1 text-xs text-club-dark">{{ error }}</p>{% endfor %}
</div>
</div>
</div>

View File

@@ -3700,6 +3700,65 @@ class CoachCreateEventViewTests(TestCase):
self.assertEqual(event.opponent, opponent)
self.assertEqual(event.competition, "Regional League")
def test_can_set_a_competition_id_for_a_game(self):
self.client.force_login(self.user)
self._post(kind="game", title="Away game", external_game_id="4460")
event = Event.objects.get(title="Away game")
self.assertEqual(event.external_game_id, "4460")
def test_competition_id_is_optional(self):
self.client.force_login(self.user)
self._post(title="Plain practice")
event = Event.objects.get(title="Plain practice")
self.assertEqual(event.external_game_id, "")
def test_can_set_a_gathering_time(self):
self.client.force_login(self.user)
gathering = timezone.localtime(timezone.now() + datetime.timedelta(days=5, hours=-1)).strftime("%Y-%m-%dT%H:%M")
self._post(title="Early gather practice", gathering=gathering)
event = Event.objects.get(title="Early gather practice")
self.assertIsNotNone(event.gathering)
def test_gathering_time_is_optional(self):
self.client.force_login(self.user)
self._post(title="No gather practice")
event = Event.objects.get(title="No gather practice")
self.assertIsNone(event.gathering)
def test_recurring_can_set_a_gathering_offset(self):
self.client.force_login(self.user)
dtstart = timezone.localtime(timezone.now() + datetime.timedelta(days=1)).strftime("%Y-%m-%dT%H:%M")
self.client.post(
reverse("mobile:coach_create_event"),
{
"is_recurring": "on",
"kind": "training",
"title": "Gathering series",
"teams": [str(self.team.pk)],
"dtstart": dtstart,
"frequency": "weekly",
"interval": "1",
"weekdays": ["MO"],
"gathering_minutes_before": "30",
},
HTTP_HOST="ajax-united.rosterchief.app",
)
series = EventSeries.objects.get(title="Gathering series")
self.assertEqual(series.gathering_offset, datetime.timedelta(minutes=30))
occurrence = series.occurrences.first()
self.assertIsNotNone(occurrence)
self.assertEqual(occurrence.gathering, occurrence.start - datetime.timedelta(minutes=30))
def test_invited_members_pool_excludes_the_current_roster(self):
on_roster = Member.objects.create(first_name="On", last_name="Roster")
TeamMembership.objects.create(team=self.team, member=on_roster, season=self.season)