Add group/club-wide event audiences and a Resend email backend

Events can now target members.Group audiences alongside teams, or go
club_wide (every ACTIVE ClubMembership member for the event's season)
instead of specific teams/groups -- the two are mutually exclusive,
enforced in EventForm/EventSeriesForm.clean() since an M2M can't be
validated via a DB CheckConstraint or Event.clean() (no PK yet). Attendance
sync (events/signals.py) now reacts to GroupMembership and ClubMembership
changes the same way it already did for TeamMembership. Authorization:
club.services.access.groups_manageable_by mirrors teams_managed_by (all
groups for an ADMIN, else only the ones the user belongs to -- Group has no
manager/owner concept); a non-admin needs at least one managed team or
belonged-to group to create/edit an event, club_wide stays admin-only, and
EventManagerRequiredMixin gained a get_groups() hook so a non-admin who
creates a group-only event isn't immediately locked out of managing it.

Also adds rosterchief.mail.ResendEmailBackend, an HTTP-API-based Django
email backend for Resend (resend.com) using the existing `requests`
dependency -- no new SDK. Opt in via DJANGO_EMAIL_BACKEND and RESEND_API_KEY;
every Django-sent email (allauth's password reset included) follows
whichever EMAIL_BACKEND is configured, so this covers all of them for free.
Resend's own SMTP relay remains a valid code-free alternative, documented
alongside it in .env.production.example.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 12:02:26 +02:00
parent e737de9140
commit 581cc81ba7
22 changed files with 720 additions and 70 deletions

View File

@@ -7,7 +7,7 @@ from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from club.models import ClubMembership, ClubRole, FeePayment, Sponsor
from club.services.access import is_club_admin, teams_managed_by
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
@@ -240,29 +240,47 @@ def _location_label(location) -> str:
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), and a non-admin must pick at least one -- a team-less/club-wide
event (e.g. an AGM) has no team-manager claim to anchor it to, so that's
admin-only."""
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 = Team.objects.filter(club=club) if is_club_admin(user, club) else teams_managed_by(user, club)
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_teams_requires_one_for_non_admins(self, cleaned):
def clean_audience_requires_a_claim_for_non_admins(self, cleaned):
if is_club_admin(self.user, self.club):
return
teams = cleaned.get("teams")
if teams is not None and not teams.exists() and not is_club_admin(self.user, self.club):
self.add_error("teams", _("Select at least one of your teams, or ask an admin to create a club-wide event."))
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...")}),
@@ -272,7 +290,27 @@ _AUDIENCE_WIDGETS = {
class EventForm(EventAudienceFormMixin, forms.ModelForm):
class Meta:
model = Event
fields = ["title", "kind", "teams", "invited_members", "excluded_members", "location", "opponent", "start", "end", "gathering", "deadline", "competition", "external_game_id", "score_for", "score_against", "is_live", "max_referees"]
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"}),
@@ -308,7 +346,8 @@ class EventForm(EventAudienceFormMixin, forms.ModelForm):
def clean(self):
cleaned = super().clean()
self.clean_teams_requires_one_for_non_admins(cleaned)
self.clean_audience_requires_a_claim_for_non_admins(cleaned)
self.clean_club_wide_excludes_teams_and_groups(cleaned)
return cleaned
@@ -357,7 +396,7 @@ class EventSeriesForm(EventAudienceFormMixin, forms.ModelForm):
class Meta:
model = EventSeries
fields = ["title", "kind", "dtstart", "until", "teams", "invited_members", "excluded_members", "location", "opponent"]
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"}),
@@ -390,7 +429,8 @@ class EventSeriesForm(EventAudienceFormMixin, forms.ModelForm):
def clean(self):
cleaned = super().clean()
self.clean_teams_requires_one_for_non_admins(cleaned)
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

View File

@@ -25,11 +25,11 @@
<li class="menu-title">{% trans "Teams" %}</li>
<li><a class="{% if nav == 'team_list' %}menu-active{% endif %}" href="{% url 'management:team_list' %}">{% lucide "shirt" size=16 %} {% trans "Teams" %}</a></li>
<li><a class="{% if nav == 'position_list' %}menu-active{% endif %}" href="{% url 'management:position_list' %}">{% lucide "tags" size=16 %} {% trans "Positions" %}</a></li>
<li><a class="{% if nav == 'referee_list' %}menu-active{% endif %}" href="{% url 'management:referee_list' %}">{% lucide "flag" size=16 %} {% trans "Referees" %}</a></li>
{% if is_club_admin %}
<li><a class="{% if nav == 'referee_management' %}menu-active{% endif %}" href="{% url 'management:referee_management' %}">{% lucide "calendar-check" size=16 %} {% trans "Referee management" %}</a></li>
<li><a class="{% if nav == 'referee_level_list' %}menu-active{% endif %}" href="{% url 'management:referee_level_list' %}">{% lucide "badge-check" size=16 %} {% trans "Referee levels" %}</a></li>
<li><a class="{% if nav == 'referee_management' %}menu-active{% endif %}" href="{% url 'management:referee_management' %}">{% lucide "calendar-check" size=16 %} {% trans "Referee management" %}</a></li>
{% endif %}
<li><a class="{% if nav == 'referee_list' %}menu-active{% endif %}" href="{% url 'management:referee_list' %}">{% lucide "flag" size=16 %} {% trans "Referees" %}</a></li>
<li class="menu-title">{% trans "News" %}</li>
<li><a class="{% if nav == 'news_list' %}menu-active{% endif %}" href="{% url 'management:news_list' %}">{% lucide "newspaper" size=16 %} {% trans "News" %}</a></li>

View File

@@ -65,6 +65,16 @@
<dt class="text-sm opacity-70">{% trans "Teams" %}</dt>
<dd>{% for team in event.teams.all %}{{ team.name }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}</dd>
</div>
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "Groups" %}</dt>
<dd>{% for group in event.groups.all %}{{ group.name }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}</dd>
</div>
{% if event.club_wide %}
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{% trans "Audience" %}</dt>
<dd><span class="badge badge-primary">{% trans "Whole club" %}</span></dd>
</div>
{% endif %}
</dl>
</div>
</div>

View File

@@ -24,6 +24,12 @@
<h3 class="text-lg font-semibold mb-3">{% trans "Audience" %}</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
{% form_field form.teams %}
{% form_field form.groups %}
{% if "club_wide" in form.fields %}
{% form_field form.club_wide %}
{% endif %}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
{% form_field form.invited_members %}
{% form_field form.excluded_members %}
</div>

View File

@@ -41,6 +41,12 @@
<h3 class="text-lg font-semibold mb-3">{% trans "Audience" %}</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
{% form_field form.teams %}
{% form_field form.groups %}
{% if "club_wide" in form.fields %}
{% form_field form.club_wide %}
{% endif %}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
{% form_field form.invited_members %}
{% form_field form.excluded_members %}
</div>

View File

@@ -3484,6 +3484,19 @@ class EventManagementTests(ManagementTestBase):
StaffAssignment.objects.create(team=self.own_team, member=staff_member, season=self.season, position=position)
return staff_user
def make_group_member(self, group, email="committee-events@example.com"):
# Staff (so they can reach the management app at all -- ClubStaffRequiredMixin
# excludes a plain MEMBER-only club member entirely), but NOT a team manager of
# anything: a non-management StaffAssignment on an unrelated team, so
# teams_managed_by(this user) is empty and the group claim is what's really
# being tested, isolated from any team-manager claim.
member_user = User.objects.create_user(email=email, password="pw-secret-123")
member = Member.objects.create(user=member_user, first_name="Gale", last_name="Group")
physio_position = Position.objects.create(club=self.club, name="Committee Physio", short_name="CP", staff_position=True, management_position=False)
StaffAssignment.objects.create(team=self.other_team, member=member, season=self.season, position=physio_position)
GroupMembership.objects.create(group=group, member=member)
return member_user
def event_data(self, **overrides):
data = {
"title": "Training",
@@ -3550,6 +3563,80 @@ class EventManagementTests(ManagementTestBase):
self.assertContains(response, 'name="location"')
self.assertContains(response, "data-searchable")
def test_a_group_member_can_create_an_event_for_their_own_group(self):
group = Group.objects.create(club=self.club, name="Committee")
self.client.force_login(self.make_group_member(group))
response = self.club_post("event_create", self.event_data(teams=[], groups=[str(group.pk)]))
event = Event.objects.get(title="Training")
self.assertRedirects(response, reverse("management:event_detail", args=[event.pk]))
self.assertIn(group, event.groups.all())
def test_a_group_member_cannot_pick_a_group_they_do_not_belong_to(self):
own_group = Group.objects.create(club=self.club, name="Committee")
other_group = Group.objects.create(club=self.club, name="Other Committee")
self.client.force_login(self.make_group_member(own_group))
response = self.club_post("event_create", self.event_data(teams=[], groups=[str(other_group.pk)]))
self.assertEqual(response.status_code, 200)
self.assertFalse(Event.objects.filter(title="Training").exists())
def test_a_non_admin_with_neither_team_nor_group_is_rejected(self):
self.client.force_login(self.make_group_member(Group.objects.create(club=self.club, name="Committee")))
response = self.club_post("event_create", self.event_data(teams=[], groups=[]))
self.assertEqual(response.status_code, 200)
self.assertFalse(Event.objects.filter(title="Training").exists())
def test_club_wide_is_not_offered_to_a_non_admin(self):
self.client.force_login(self.make_coach(self.own_team))
response = self.club_get("event_create")
self.assertNotContains(response, 'name="club_wide"')
def test_a_non_admin_cannot_force_a_club_wide_event(self):
# club_wide isn't in the form for a non-admin, so even a forged POST
# value must not slip through as a create-time claim.
self.client.force_login(self.make_coach(self.own_team))
response = self.club_post("event_create", self.event_data(teams=[], club_wide="on"))
self.assertEqual(response.status_code, 200)
event = Event.objects.filter(title="Training").first()
self.assertIsNone(event)
def test_an_admin_can_schedule_a_club_wide_event(self):
self.client.force_login(self.admin_user)
response = self.club_post("event_create", self.event_data(teams=[], club_wide="on"))
event = Event.objects.get(title="Training")
self.assertRedirects(response, reverse("management:event_detail", args=[event.pk]))
self.assertTrue(event.club_wide)
def test_club_wide_cannot_be_combined_with_teams(self):
self.client.force_login(self.admin_user)
response = self.club_post("event_create", self.event_data(club_wide="on"))
self.assertEqual(response.status_code, 200)
self.assertFalse(Event.objects.filter(title="Training").exists())
def test_a_group_member_who_is_not_a_team_manager_can_edit_their_group_event(self):
group = Group.objects.create(club=self.club, name="Committee")
member_user = self.make_group_member(group)
self.client.force_login(member_user)
self.club_post("event_create", self.event_data(teams=[], groups=[str(group.pk)]))
event = Event.objects.get(title="Training")
response = self.club_get("event_update", event.pk)
self.assertEqual(response.status_code, 200)
def test_the_new_event_forms_competition_dropdown_shows_every_competition_regardless_of_flag(self):
# Unlike the Django-admin form, this dropdown isn't filtered by whether the
# competition's flag is active for the club -- see management.forms.EventForm
@@ -3806,6 +3893,30 @@ class EventSeriesManagementTests(ManagementTestBase):
self.assertTrue(series.occurrences.exists())
def test_a_group_member_can_create_a_series_for_their_own_group(self):
group = Group.objects.create(club=self.club, name="Committee")
member_user = User.objects.create_user(email="committee-series@example.com", password="pw-secret-123")
member = Member.objects.create(user=member_user, first_name="Gale", last_name="Group")
physio_position = Position.objects.create(club=self.club, name="Series Physio", short_name="SP", staff_position=True, management_position=False)
StaffAssignment.objects.create(team=self.other_team, member=member, season=self.season, position=physio_position)
GroupMembership.objects.create(group=group, member=member)
self.client.force_login(member_user)
response = self.club_post("event_series_create", self.series_data(teams=[], groups=[str(group.pk)]))
series = EventSeries.objects.get(title="Weekly training")
self.assertRedirects(response, reverse("management:event_series_detail", args=[series.pk]))
self.assertIn(group, series.groups.all())
def test_generated_occurrences_copy_groups_and_club_wide(self):
self.client.force_login(self.admin_user)
series = EventSeries.objects.create(club=self.club, title="AGM", kind=Event.EventKind.MEETING, dtstart=timezone.now() + datetime.timedelta(days=1), rrule="FREQ=WEEKLY;COUNT=1", club_wide=True)
generate_occurrences(series)
occurrence = series.occurrences.get()
self.assertTrue(occurrence.club_wide)
def test_editing_a_series_propagates_to_future_occurrences_but_not_a_detached_one(self):
self.client.force_login(self.make_coach(self.team))
series = self.create_series()

View File

@@ -26,7 +26,7 @@ from club.mixins import (
TeamManagerRequiredMixin,
)
from club.models import ClubMembership, ClubRole, Season, Sponsor
from club.services.access import can_edit_news, can_publish_news, current_season, is_club_admin, members_visible_to, teams_managed_by, teams_staffed_by
from club.services.access import can_edit_news, can_publish_news, current_season, groups_manageable_by, is_club_admin, members_visible_to, teams_managed_by, teams_staffed_by
from club.services.fees import mark_as_paid, record_payment, remaining_balance
from controlpanel.messages import notify
from controlpanel.mixins import RedirectOnInvalidMixin
@@ -261,12 +261,12 @@ class MembershipListView(ClubAdminRequiredMixin, ListView):
# family that has an explicit name of "Smith" or that includes anyone
# surnamed Smith, not just a member literally named Smith themself.
memberships = (
memberships.filter(member__first_name__icontains=search)
| memberships.filter(member__last_name__icontains=search)
| memberships.filter(member__email__icontains=search)
| memberships.filter(member__user__email__icontains=search)
| memberships.filter(member__family_memberships__family__name__icontains=search)
| memberships.filter(member__family_memberships__family__memberships__member__last_name__icontains=search)
memberships.filter(member__first_name__icontains=search)
| memberships.filter(member__last_name__icontains=search)
| memberships.filter(member__email__icontains=search)
| memberships.filter(member__user__email__icontains=search)
| memberships.filter(member__family_memberships__family__name__icontains=search)
| memberships.filter(member__family_memberships__family__memberships__member__last_name__icontains=search)
)
return memberships.distinct()
@@ -348,7 +348,7 @@ class MembershipMarkPaidView(ClubAdminRequiredMixin, View):
mark_as_paid(membership, recorded_by=request.user)
count += 1
notify(request, f"s|{_('Marked as paid')}|{_('%(count)d membership(s) updated.') % {'count': count} }")
notify(request, f"s|{_('Marked as paid')}|{_('%(count)d membership(s) updated.') % {'count': count}}")
next_url = request.POST.get("next")
if next_url and url_has_allowed_host_and_scheme(next_url, allowed_hosts={request.get_host()}, require_https=request.is_secure()):
@@ -573,9 +573,7 @@ class MemberUpdateView(ClubAdminRequiredMixin, View):
season = current_season(self.request.club)
if season is None:
return None
return ClubMembership.objects.filter(club=self.request.club, member=member, season=season).first() or ClubMembership(
club=self.request.club, member=member, season=season, signed_up_at=timezone.localdate()
)
return ClubMembership.objects.filter(club=self.request.club, member=member, season=season).first() or ClubMembership(club=self.request.club, member=member, season=season, signed_up_at=timezone.localdate())
def render_form(self, member, form, membership_form):
return render(self.request, self.template_name, {"object": member, "update_view": True, "form": form, "membership_form": membership_form})
@@ -596,7 +594,7 @@ class MemberUpdateView(ClubAdminRequiredMixin, View):
form.save()
if membership_form is not None:
membership_form.save()
notify(request, f"s|{_('Member updated')}|{_('%(member)s” updated.') % {'member': member} }")
notify(request, f"s|{_('Member updated')}|{_('%(member)s” updated.') % {'member': member}}")
return redirect("management:member_detail", pk=member.pk)
return self.render_form(member, form, membership_form)
@@ -701,10 +699,10 @@ class MemberGrantLoginView(ClubAdminRequiredMixin, RedirectOnInvalidMixin, FormV
if member.user_id is not None:
# Already has one -- the row's button shouldn't have been there at all;
# a direct POST replay (e.g. a resubmitted form) is the only way here.
notify(self.request, f"w|{_('Already has a login')}|{_('%(member)s” can already sign in.') % {'member': member} }")
notify(self.request, f"w|{_('Already has a login')}|{_('%(member)s” can already sign in.') % {'member': member}}")
else:
grant_login(member, form.cleaned_data["email"])
notify(self.request, f"s|{_('Login granted')}|{_('%(member)s” can now sign in.') % {'member': member} }")
notify(self.request, f"s|{_('Login granted')}|{_('%(member)s” can now sign in.') % {'member': member}}")
return redirect("management:member_detail", pk=member.pk)
@@ -909,11 +907,7 @@ class TeamDetailView(ClubStaffRequiredMixin, DetailView):
no_shows=no_shows,
# None (not an empty queryset) signals "federation-managed" to the
# template, distinct from "club-managed, nobody eligible yet".
eligible_referees=(
Member.objects.filter(referee_profile__level__teams=team, referee_profile__valid_until__gte=timezone.localdate()).order_by("last_name", "first_name")
if team.referee_management == Team.RefereeManagement.CLUB
else None
),
eligible_referees=(Member.objects.filter(referee_profile__level__teams=team, referee_profile__valid_until__gte=timezone.localdate()).order_by("last_name", "first_name") if team.referee_management == Team.RefereeManagement.CLUB else None),
**kwargs,
)
@@ -1187,7 +1181,7 @@ class TeamBulkAddView(TeamManagerRequiredMixin, View):
try:
membership.full_clean()
membership.save()
except (ValidationError, IntegrityError):
except ValidationError, IntegrityError:
errors.append(_("%(member)s: could not be added as a player -- please check the details and try again.") % {"member": member})
else:
players_added += 1
@@ -1203,7 +1197,7 @@ class TeamBulkAddView(TeamManagerRequiredMixin, View):
try:
assignment.full_clean()
assignment.save()
except (ValidationError, IntegrityError):
except ValidationError, IntegrityError:
errors.append(_("%(member)s: could not be assigned as staff -- please check the details and try again.") % {"member": member})
else:
staff_added += 1
@@ -2234,9 +2228,10 @@ class RefereeManagementDashboardView(ClubAdminRequiredMixin, TemplateView):
class EventCreateView(ClubStaffRequiredMixin, CreateView):
"""Broader than EventManagerRequiredMixin's own gate (no object yet to check
teams against): anyone managing at least one team, or an admin. EventForm
itself then restricts *which* teams a non-admin can pick and requires at
least one, so a team-less/club-wide event stays admin-only."""
teams/groups against): anyone managing at least one team, belonging to at
least one group, or an admin. EventForm itself then restricts *which*
teams/groups a non-admin can pick and requires at least one, so a
club-wide event stays admin-only."""
model = Event
form_class = EventForm
@@ -2244,7 +2239,7 @@ class EventCreateView(ClubStaffRequiredMixin, CreateView):
def test_func(self):
user, club = self.request.user, self.request.club
return is_club_admin(user, club) or teams_managed_by(user, club).exists()
return is_club_admin(user, club) or teams_managed_by(user, club).exists() or groups_manageable_by(user, club).exists()
def get_form_kwargs(self):
# Event.clean() rejects a location/opponent from another club by comparing
@@ -2274,6 +2269,9 @@ class EventUpdateView(EventManagerRequiredMixin, UpdateView):
def get_teams(self):
return get_object_or_404(Event.objects.filter(club=self.request.club), pk=self.kwargs["pk"]).teams.all()
def get_groups(self):
return get_object_or_404(Event.objects.filter(club=self.request.club), pk=self.kwargs["pk"]).groups.all()
def get_form_kwargs(self):
return super().get_form_kwargs() | {"club": self.request.club, "user": self.request.user, "editing": True}
@@ -2301,6 +2299,9 @@ class EventDeleteView(EventManagerRequiredMixin, View):
def get_teams(self):
return self.get_event().teams.all()
def get_groups(self):
return self.get_event().groups.all()
def post(self, request, pk):
event = self.get_event()
title = str(event)
@@ -2324,6 +2325,9 @@ class EventDetachView(EventManagerRequiredMixin, View):
def get_teams(self):
return self.get_event().teams.all()
def get_groups(self):
return self.get_event().groups.all()
def post(self, request, pk):
event = self.get_event()
detach_occurrence(event)
@@ -2345,6 +2349,9 @@ class EventFetchGameInfoView(EventManagerRequiredMixin, View):
def get_teams(self):
return self.get_event().teams.all()
def get_groups(self):
return self.get_event().groups.all()
def post(self, request, pk):
event = self.get_event()
try:
@@ -2460,7 +2467,7 @@ class EventSeriesCreateView(ClubStaffRequiredMixin, CreateView):
def test_func(self):
user, club = self.request.user, self.request.club
return is_club_admin(user, club) or teams_managed_by(user, club).exists()
return is_club_admin(user, club) or teams_managed_by(user, club).exists() or groups_manageable_by(user, club).exists()
def get_form_kwargs(self):
# Same reasoning as EventCreateView: EventSeries.clean() needs a real
@@ -2492,6 +2499,9 @@ class EventSeriesUpdateView(EventManagerRequiredMixin, UpdateView):
def get_teams(self):
return get_object_or_404(EventSeries.objects.filter(club=self.request.club), pk=self.kwargs["pk"]).teams.all()
def get_groups(self):
return get_object_or_404(EventSeries.objects.filter(club=self.request.club), pk=self.kwargs["pk"]).groups.all()
def get_form_kwargs(self):
return super().get_form_kwargs() | {"club": self.request.club, "user": self.request.user}
@@ -2526,6 +2536,9 @@ class EventSeriesDeleteView(EventManagerRequiredMixin, View):
def get_teams(self):
return self.get_series().teams.all()
def get_groups(self):
return self.get_series().groups.all()
def post(self, request, pk):
series = self.get_series()
title = str(series)
@@ -2545,6 +2558,9 @@ class EventSeriesStopView(EventManagerRequiredMixin, View):
def get_teams(self):
return self.get_series().teams.all()
def get_groups(self):
return self.get_series().groups.all()
def post(self, request, pk):
series = self.get_series()
series.until = timezone.now()
@@ -2560,6 +2576,9 @@ class EventSeriesGenerateView(EventManagerRequiredMixin, View):
def get_teams(self):
return self.get_series().teams.all()
def get_groups(self):
return self.get_series().groups.all()
def post(self, request, pk):
series = self.get_series()
created = generate_occurrences(series)