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