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

@@ -36,12 +36,12 @@ class EventSeriesAdmin(admin.ModelAdmin):
list_display = ["title", "kind", "rrule", "dtstart", "until", "generated_until", "club"]
list_filter = ["kind", "club"]
search_fields = ["title"]
autocomplete_fields = ["location", "opponent", "teams", "invited_members", "excluded_members"]
autocomplete_fields = ["location", "opponent", "teams", "groups", "invited_members", "excluded_members"]
fieldsets = [
[None, {"fields": ["title", "kind"]}],
[_("Recurrence"), {"fields": ["rrule", "dtstart", "until", "excluded_dates", "generated_until"]}],
[_("Timing"), {"fields": ["duration", "gathering_offset", "deadline_offset"]}],
[_("Audience"), {"fields": ["teams", "invited_members", "excluded_members"]}],
[_("Audience"), {"fields": ["teams", "groups", "club_wide", "invited_members", "excluded_members"]}],
[_("Where"), {"fields": ["location", "opponent"]}],
]
@@ -56,7 +56,7 @@ class EventAdminForm(forms.ModelForm):
class Meta:
model = Event
fields = [
"title", "kind", "season", "series", "detached", "cancelled", "teams", "invited_members", "excluded_members",
"title", "kind", "season", "series", "detached", "cancelled", "teams", "groups", "club_wide", "invited_members", "excluded_members",
"start", "end", "gathering", "deadline", "location", "opponent",
"competition", "external_game_id", "score_for", "score_against", "is_live", "max_referees",
]
@@ -82,12 +82,12 @@ class EventAdmin(admin.ModelAdmin):
list_filter = ["kind", "club", "teams", "detached", "cancelled"]
search_fields = ["title"]
date_hierarchy = "start"
autocomplete_fields = ["season", "series", "location", "opponent", "teams", "invited_members", "excluded_members"]
autocomplete_fields = ["season", "series", "location", "opponent", "teams", "groups", "invited_members", "excluded_members"]
inlines = [AttendanceInline, EventRefereeInline]
fieldsets = [
[None, {"fields": ["title", "kind", "season"]}],
[_("Series"), {"fields": ["series", "detached", "cancelled"]}],
[_("Audience"), {"fields": ["teams", "invited_members", "excluded_members"]}],
[_("Audience"), {"fields": ["teams", "groups", "club_wide", "invited_members", "excluded_members"]}],
[_("When"), {"fields": ["start", "end", "gathering", "deadline"]}],
[_("Where"), {"fields": ["location", "opponent"]}],
[_("Game"), {"fields": ["competition", "external_game_id", "score_for", "score_against", "is_live", "max_referees"]}],

View File

@@ -0,0 +1,34 @@
# Generated by Django 6.0.6 on 2026-08-11 09:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('events', '0021_eventreferee_external_name_eventreferee_fee_and_more'),
('members', '0004_group_groupmembership_and_more'),
]
operations = [
migrations.AddField(
model_name='event',
name='club_wide',
field=models.BooleanField(default=False, help_text="Invite every active club member for this event's season, instead of specific teams/groups. Can't be combined with teams or groups.", verbose_name='whole club'),
),
migrations.AddField(
model_name='event',
name='groups',
field=models.ManyToManyField(blank=True, help_text='Invite every current member of these groups.', related_name='scheduled_events', to='members.group', verbose_name='groups'),
),
migrations.AddField(
model_name='eventseries',
name='club_wide',
field=models.BooleanField(default=False, help_text="Invite every active club member for each occurrence's season, instead of specific teams/groups. Can't be combined with teams or groups.", verbose_name='whole club'),
),
migrations.AddField(
model_name='eventseries',
name='groups',
field=models.ManyToManyField(blank=True, help_text='Invite every current member of these groups.', related_name='event_series', to='members.group', verbose_name='groups'),
),
]

View File

@@ -8,7 +8,7 @@ from django.utils.translation import gettext_lazy as _
from django_countries.fields import CountryField
from club.models import Club, Season
from members.models import Member
from members.models import Group, Member
from rosterchief.base import ClubScopedModel, UUIDModel, validate_club_scope
from teams.models import Team
@@ -69,6 +69,8 @@ class Event(ClubScopedModel):
cancelled = models.BooleanField(_("cancelled"), default=False)
teams = models.ManyToManyField(Team, related_name="scheduled_events", blank=True, verbose_name=_("teams"))
groups = models.ManyToManyField(Group, related_name="scheduled_events", blank=True, verbose_name=_("groups"), help_text=_("Invite every current member of these groups."))
club_wide = models.BooleanField(_("whole club"), default=False, help_text=_("Invite every active club member for this event's season, instead of specific teams/groups. Can't be combined with teams or groups."))
invited_members = models.ManyToManyField(Member, related_name="invited_to_events", blank=True, verbose_name=_("invited members"))
excluded_members = models.ManyToManyField(Member, related_name="excluded_from_events", blank=True, verbose_name=_("excluded members"))
season = models.ForeignKey(Season, on_delete=models.SET_NULL, related_name="events", null=True, blank=True, verbose_name=_("season"), help_text=_("Season whose team rosters define the audience; derived from the start date when left blank."))
@@ -138,6 +140,8 @@ class EventSeries(ClubScopedModel):
location = models.ForeignKey(Location, on_delete=models.SET_NULL, related_name="event_series", null=True, blank=True, verbose_name=_("location"))
opponent = models.ForeignKey(Opponent, on_delete=models.SET_NULL, related_name="event_series", null=True, blank=True, verbose_name=_("opponent"))
teams = models.ManyToManyField(Team, related_name="event_series", blank=True, verbose_name=_("teams"))
groups = models.ManyToManyField(Group, related_name="event_series", blank=True, verbose_name=_("groups"), help_text=_("Invite every current member of these groups."))
club_wide = models.BooleanField(_("whole club"), default=False, help_text=_("Invite every active club member for each occurrence's season, instead of specific teams/groups. Can't be combined with teams or groups."))
invited_members = models.ManyToManyField(Member, related_name="invited_to_event_series", blank=True, verbose_name=_("invited members"))
excluded_members = models.ManyToManyField(Member, related_name="excluded_from_event_series", blank=True, verbose_name=_("excluded members"))

View File

@@ -1,15 +1,18 @@
"""Keep an event's attendance rows in sync with its effective audience.
The audience of an event is the union of the current rosters of its ``teams``
(for the event's season) plus any individually ``invited_members``, minus any
``excluded_members``. Attendance rows are reconciled against that set, but only
for events that are still in the future — history is never rewritten.
(for the event's season) and the current members of its ``groups``, plus any
individually ``invited_members``, minus any ``excluded_members`` -- or, for a
``club_wide`` event, every member with an ACTIVE ClubMembership for the
event's season instead of teams/groups (the two are mutually exclusive, see
EventForm/EventSeriesForm). Attendance rows are reconciled against that set,
but only for events that are still in the future — history is never rewritten.
"""
from django.db.models import Count, Q
from django.utils import timezone
from club.models import Season
from club.models import ClubMembership, Season
from events.models import Attendance, Event
from members.models import Member
from teams.models import TeamMembership
@@ -24,13 +27,22 @@ def resolve_season(event):
def effective_members(event):
"""Return the ``Member`` queryset invited to ``event``."""
member_ids: set = set()
season = resolve_season(event)
if season is not None:
team_ids = list(event.teams.values_list("id", flat=True))
if team_ids:
member_ids.update(TeamMembership.objects.filter(team_id__in=team_ids, season=season).values_list("member_id", flat=True))
if event.club_wide:
member_ids = set()
if season is not None:
member_ids.update(ClubMembership.objects.filter(club=event.club, season=season, status=ClubMembership.StatusChoices.ACTIVE).values_list("member_id", flat=True))
else:
member_ids = set()
if season is not None:
team_ids = list(event.teams.values_list("id", flat=True))
if team_ids:
member_ids.update(TeamMembership.objects.filter(team_id__in=team_ids, season=season).values_list("member_id", flat=True))
group_ids = list(event.groups.values_list("id", flat=True))
if group_ids:
member_ids.update(Member.objects.filter(group_memberships__group_id__in=group_ids).values_list("id", flat=True))
member_ids.update(event.invited_members.values_list("id", flat=True))
member_ids.difference_update(event.excluded_members.values_list("id", flat=True))

View File

@@ -52,12 +52,14 @@ def apply_template(series, event):
event.title = series.title
event.location = series.location
event.opponent = series.opponent
event.club_wide = series.club_wide
event.end = event.start + series.duration if series.duration is not None else None
event.gathering = event.start - series.gathering_offset if series.gathering_offset is not None else None
event.deadline = event.start - series.deadline_offset if series.deadline_offset is not None else None
event.save()
event.teams.set(series.teams.all())
event.groups.set(series.groups.all())
event.invited_members.set(series.invited_members.all())
event.excluded_members.set(series.excluded_members.all())

View File

@@ -1,11 +1,15 @@
"""Signal wiring that keeps attendance in sync with the event audience.
Registered from ``EventsConfig.ready``. Two triggers:
Registered from ``EventsConfig.ready``. Triggers:
* editing an event (its ``start``, or its ``teams`` / ``invited_members`` /
``excluded_members``) re-syncs that event;
* editing an event (its ``start``, or its ``teams`` / ``groups`` /
``invited_members`` / ``excluded_members``) re-syncs that event;
* adding or removing a member from a team roster re-syncs that team's future
events.
events;
* adding or removing a member from a group re-syncs that group's future
events;
* a club membership going active/inactive re-syncs every future club_wide
event for that club.
"""
from django.core.exceptions import ValidationError
@@ -14,8 +18,10 @@ from django.dispatch import receiver
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from club.models import ClubMembership
from events.models import Event, EventSeries
from events.services import sync_event_attendances
from members.models import Group, GroupMembership
from teams.models import Team, TeamMembership
M2M_SYNC_ACTIONS = {"post_add", "post_remove", "post_clear"}
@@ -32,12 +38,22 @@ def validate_teams_same_club(sender, instance, action, reverse, pk_set, **kwargs
raise ValidationError(_("Teams must belong to the same club as the event."))
@receiver(m2m_changed, sender=Event.groups.through)
@receiver(m2m_changed, sender=EventSeries.groups.through)
def validate_groups_same_club(sender, instance, action, reverse, pk_set, **kwargs):
if action != "pre_add" or reverse:
return
if Group.objects.filter(pk__in=pk_set).exclude(club_id=instance.club_id).exists():
raise ValidationError(_("Groups must belong to the same club as the event."))
@receiver(post_save, sender=Event)
def sync_on_event_save(sender, instance, **kwargs):
sync_event_attendances(instance)
@receiver(m2m_changed, sender=Event.teams.through)
@receiver(m2m_changed, sender=Event.groups.through)
@receiver(m2m_changed, sender=Event.invited_members.through)
@receiver(m2m_changed, sender=Event.excluded_members.through)
def sync_on_audience_change(sender, instance, action, reverse, model, pk_set, **kwargs):
@@ -58,3 +74,19 @@ def sync_on_roster_change(sender, instance, **kwargs):
now = timezone.now()
for event in Event.objects.filter(teams=instance.team_id, start__gte=now).distinct():
sync_event_attendances(event)
@receiver(post_save, sender=GroupMembership)
@receiver(post_delete, sender=GroupMembership)
def sync_on_group_membership_change(sender, instance, **kwargs):
now = timezone.now()
for event in Event.objects.filter(groups=instance.group_id, start__gte=now).distinct():
sync_event_attendances(event)
@receiver(post_save, sender=ClubMembership)
@receiver(post_delete, sender=ClubMembership)
def sync_on_club_membership_change(sender, instance, **kwargs):
now = timezone.now()
for event in Event.objects.filter(club_wide=True, club_id=instance.club_id, start__gte=now).distinct():
sync_event_attendances(event)

View File

@@ -9,8 +9,8 @@ from django.test import TestCase
from django.utils import timezone
from waffle import get_waffle_flag_model
from club.models import Club, Season
from members.models import Member
from club.models import Club, ClubMembership, Season
from members.models import Group, GroupMembership, Member
from teams.models import Position, RefereeLevel, RefereeProfile, Team, TeamMembership
from .admin import EventAdminForm
@@ -202,6 +202,55 @@ class EffectiveMembersTests(EventsTestBase):
self.assertEqual(event.attendances.count(), 0)
def test_group_members_are_included(self):
carol = Member.objects.create(first_name="Carol", last_name="Cedar")
group = Group.objects.create(club=self.club, name="Committee")
GroupMembership.objects.create(group=group, member=carol)
event = self.make_event()
event.groups.set([group])
self.assertEqual(self.attendee_ids(event), {carol.id})
def test_teams_and_groups_combine(self):
carol = Member.objects.create(first_name="Carol", last_name="Cedar")
group = Group.objects.create(club=self.club, name="Committee")
GroupMembership.objects.create(group=group, member=carol)
event = self.make_event()
event.teams.set([self.team])
event.groups.set([group])
self.assertEqual(self.attendee_ids(event), {self.alice.id, self.bob.id, carol.id})
def test_club_wide_includes_every_active_member_this_season(self):
carol = Member.objects.create(first_name="Carol", last_name="Cedar")
ClubMembership.objects.create(club=self.club, member=carol, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
lapsed = Member.objects.create(first_name="Len", last_name="Lapsed")
ClubMembership.objects.create(club=self.club, member=lapsed, season=self.season, status=ClubMembership.StatusChoices.LAPSED)
event = self.make_event(club_wide=True)
self.assertEqual(self.attendee_ids(event), {carol.id})
def test_club_wide_ignores_teams_and_groups(self):
# Teams/groups can be left populated on the row without affecting the
# audience -- validation (EventForm) is what keeps editors from doing
# this in the UI, but effective_members itself just ignores them.
carol = Member.objects.create(first_name="Carol", last_name="Cedar")
ClubMembership.objects.create(club=self.club, member=carol, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
event = self.make_event(club_wide=True)
event.teams.set([self.team])
self.assertEqual(self.attendee_ids(event), {carol.id})
def test_club_wide_still_honours_invited_and_excluded(self):
carol = Member.objects.create(first_name="Carol", last_name="Cedar")
ClubMembership.objects.create(club=self.club, member=carol, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
dave = Member.objects.create(first_name="Dave", last_name="Dogwood")
event = self.make_event(club_wide=True)
event.invited_members.set([dave])
event.excluded_members.set([carol])
self.assertEqual(self.attendee_ids(event), {dave.id})
class AttendanceSyncTests(EventsTestBase):
def test_setting_teams_creates_attendance_for_roster(self):
@@ -267,6 +316,45 @@ class RosterChangeSyncTests(EventsTestBase):
self.assertNotIn(self.alice.id, self.attendee_ids(event))
def test_adding_a_group_member_syncs_future_events(self):
group = Group.objects.create(club=self.club, name="Committee")
event = self.make_event()
event.groups.set([group])
carol = Member.objects.create(first_name="Carol", last_name="Cedar")
GroupMembership.objects.create(group=group, member=carol)
self.assertIn(carol.id, self.attendee_ids(event))
def test_removing_a_group_member_syncs_future_events(self):
group = Group.objects.create(club=self.club, name="Committee")
carol = Member.objects.create(first_name="Carol", last_name="Cedar")
membership = GroupMembership.objects.create(group=group, member=carol)
event = self.make_event()
event.groups.set([group])
membership.delete()
self.assertNotIn(carol.id, self.attendee_ids(event))
def test_a_new_active_club_membership_syncs_future_club_wide_events(self):
event = self.make_event(club_wide=True)
carol = Member.objects.create(first_name="Carol", last_name="Cedar")
ClubMembership.objects.create(club=self.club, member=carol, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
self.assertIn(carol.id, self.attendee_ids(event))
def test_a_club_membership_lapsing_syncs_future_club_wide_events(self):
carol = Member.objects.create(first_name="Carol", last_name="Cedar")
membership = ClubMembership.objects.create(club=self.club, member=carol, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
event = self.make_event(club_wide=True)
membership.status = ClubMembership.StatusChoices.LAPSED
membership.save()
self.assertNotIn(carol.id, self.attendee_ids(event))
def test_roster_change_leaves_past_events_untouched(self):
past = self.make_event(start=timezone.now() - timedelta(days=1))
past.teams.add(self.team)