Notify a member once when a roster/group change adds them to new events

Joining a team or group already re-synced Attendance rows for its upcoming
events (events/signals.py), but did so silently. A fresh membership (not a
field edit, not a removal) now sends one summary notification -- "N new
events on your calendar" -- rather than one per event, so joining mid-season
with a whole recurring series already scheduled doesn't flood the member
with pushes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
2026-08-22 18:44:45 +02:00
parent 5db885c809
commit 618d15a3e0
4 changed files with 115 additions and 4 deletions

View File

@@ -1,5 +1,6 @@
from .attendance import (
effective_members,
notify_newly_invited,
player_attendance_rankings,
players_who_missed_recent_practices,
record_check_in,
@@ -24,6 +25,7 @@ __all__ = [
"effective_members",
"generate_occurrences",
"horizon",
"notify_newly_invited",
"occurrence_datetimes",
"player_attendance_rankings",
"players_who_missed_recent_practices",

View File

@@ -18,11 +18,14 @@ deliberate staff decision that should win regardless.
from django.db.models import Count, Q
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.utils.translation import ngettext
from club.models import ClubMembership, Season
from club.services.onboarding import blocked_member_ids_for_event
from events.models import Attendance, Event
from members.models import Member
from notifications.services import notify_members
from teams.models import TeamMembership
@@ -85,6 +88,22 @@ def sync_event_attendances(event):
event.attendances.filter(member_id__in=to_remove).delete()
def notify_newly_invited(member, *, club, events):
"""One notification (push+email), not one per event -- used when a
roster/group change (events/signals.py's sync_on_roster_change/
sync_on_group_membership_change) adds ``member`` to a team/group that
already has upcoming events on the calendar. A flood of one-per-event
pushes for, say, joining a team mid-season with a whole recurring
practice series already scheduled would be worse than useless -- same
reasoning as news.tasks._dedupe_by_recipients's single-summary
preference over a flood, just per-recipient instead of cross-family."""
if not events:
return
title = _("New events on your calendar")
body = ngettext("%(count)d new event has been added to your calendar.", "%(count)d new events have been added to your calendar.", len(events)) % {"count": len(events)}
notify_members([member], club=club, title=title, body=body)
def record_check_in(attendance, *, showed_up):
"""Record whether ``attendance``'s member actually showed up, separate from
their RSVP status -- the hook a future check-in UI (the coaches app) writes

View File

@@ -10,6 +10,12 @@ Registered from ``EventsConfig.ready``. Triggers:
events;
* a club membership going active/inactive re-syncs every future club_wide
event for that club.
Joining a team/group specifically (not a plain field edit, not a removal)
also sends one summary notification (events.services.attendance.
notify_newly_invited) for however many upcoming events that just put on the
member's calendar -- one notification, not one per event, even if a whole
recurring series is already scheduled.
"""
from django.core.exceptions import ValidationError
@@ -19,8 +25,8 @@ 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 events.models import Attendance, Event, EventSeries
from events.services import notify_newly_invited, sync_event_attendances
from events.services.referees import sync_referee_invites
from members.models import Group, GroupMembership
from teams.models import Team, TeamMembership
@@ -86,17 +92,41 @@ def sync_referee_invites_on_teams_change(sender, instance, action, reverse, **kw
@receiver(post_delete, sender=TeamMembership)
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():
events = list(Event.objects.filter(teams=instance.team_id, start__gte=now).distinct())
# Only a fresh addition to the roster has anything new to tell the member
# about -- a plain field edit (jersey number, position) re-saves the same
# row (kwargs["created"] is False), and a removal has nothing new to add.
# post_delete carries no "created" key at all, hence the default.
newly_invited = []
if kwargs.get("created", False):
already_invited_ids = set(Attendance.objects.filter(member=instance.member, event__in=events).values_list("event_id", flat=True))
newly_invited = [event for event in events if event.pk not in already_invited_ids]
for event in events:
sync_event_attendances(event)
if newly_invited:
notify_newly_invited(instance.member, club=instance.team.club, events=newly_invited)
@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():
events = list(Event.objects.filter(groups=instance.group_id, start__gte=now).distinct())
newly_invited = []
if kwargs.get("created", False):
already_invited_ids = set(Attendance.objects.filter(member=instance.member, event__in=events).values_list("event_id", flat=True))
newly_invited = [event for event in events if event.pk not in already_invited_ids]
for event in events:
sync_event_attendances(event)
if newly_invited:
notify_newly_invited(instance.member, club=instance.group.club, events=newly_invited)
@receiver(post_save, sender=ClubMembership)
@receiver(post_delete, sender=ClubMembership)

View File

@@ -567,6 +567,66 @@ class RosterChangeSyncTests(EventsTestBase):
self.assertTrue(past.attendances.filter(member=self.alice).exists())
def test_joining_a_team_notifies_the_member_of_new_events(self):
event = self.make_event()
event.teams.set([self.team])
dave = Member.objects.create(first_name="Dave", last_name="Dogwood")
TeamMembership.objects.create(team=self.team, member=dave, season=self.season, position=self.position)
notification = Notification.objects.get(member=dave)
self.assertEqual(notification.title, "New events on your calendar")
self.assertIn("1 new event", notification.body)
def test_joining_a_team_with_several_upcoming_events_sends_one_summary_notification(self):
first = self.make_event(title="First")
first.teams.set([self.team])
second = self.make_event(title="Second", start=self.future + timedelta(days=1))
second.teams.set([self.team])
dave = Member.objects.create(first_name="Dave", last_name="Dogwood")
TeamMembership.objects.create(team=self.team, member=dave, season=self.season, position=self.position)
self.assertEqual(Notification.objects.filter(member=dave).count(), 1)
notification = Notification.objects.get(member=dave)
self.assertIn("2 new events", notification.body)
def test_joining_a_team_with_no_upcoming_events_does_not_notify(self):
dave = Member.objects.create(first_name="Dave", last_name="Dogwood")
TeamMembership.objects.create(team=self.team, member=dave, season=self.season, position=self.position)
self.assertFalse(Notification.objects.filter(member=dave).exists())
def test_leaving_a_team_does_not_notify(self):
event = self.make_event()
event.teams.set([self.team])
TeamMembership.objects.get(team=self.team, member=self.alice).delete()
self.assertFalse(Notification.objects.filter(member=self.alice).exists())
def test_editing_a_membership_field_does_not_notify(self):
event = self.make_event()
event.teams.set([self.team])
membership = TeamMembership.objects.get(team=self.team, member=self.alice)
membership.jersey_number = 42
membership.save()
self.assertFalse(Notification.objects.filter(member=self.alice).exists())
def test_joining_a_group_notifies_the_member_of_new_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)
notification = Notification.objects.get(member=carol)
self.assertIn("1 new event", notification.body)
class RecurrenceTestBase(EventsTestBase):
@classmethod