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:
@@ -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"]}],
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user