From 618d15a3e067667e0c9c9d6139b9521281231771 Mon Sep 17 00:00:00 2001 From: Bernard Siebens Date: Sat, 22 Aug 2026 18:44:45 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9 --- events/services/__init__.py | 2 ++ events/services/attendance.py | 19 +++++++++++ events/signals.py | 38 +++++++++++++++++++--- events/tests.py | 60 +++++++++++++++++++++++++++++++++++ 4 files changed, 115 insertions(+), 4 deletions(-) diff --git a/events/services/__init__.py b/events/services/__init__.py index 38c1135..5222ddb 100644 --- a/events/services/__init__.py +++ b/events/services/__init__.py @@ -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", diff --git a/events/services/attendance.py b/events/services/attendance.py index 5197656..eac4900 100644 --- a/events/services/attendance.py +++ b/events/services/attendance.py @@ -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 diff --git a/events/signals.py b/events/signals.py index b8ef3d7..0721a12 100644 --- a/events/signals.py +++ b/events/signals.py @@ -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) diff --git a/events/tests.py b/events/tests.py index ffde616..f052d3a 100644 --- a/events/tests.py +++ b/events/tests.py @@ -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