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
221 lines
10 KiB
Python
221 lines
10 KiB
Python
"""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) 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.
|
|
|
|
A member provisionally rostered by management.views.SignupPlaceInTeamView
|
|
(on a team, but still PENDING -- their sign-up isn't fully processed yet) is
|
|
still subtracted back out here if an open onboarding requirement blocks this
|
|
event's kind -- see club.services.onboarding.blocked_member_ids_for_event.
|
|
Explicitly ``invited_members`` bypasses that: a named, individual invite is a
|
|
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
|
|
|
|
|
|
def resolve_season(event):
|
|
"""The season whose rosters define the event's audience."""
|
|
if event.season_id is not None:
|
|
return event.season
|
|
return Season.covering(event.club, event.start.date())
|
|
|
|
|
|
def effective_members(event):
|
|
"""Return the ``Member`` queryset invited to ``event``."""
|
|
season = resolve_season(event)
|
|
|
|
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))
|
|
|
|
invited_ids = set(event.invited_members.values_list("id", flat=True))
|
|
|
|
if season is not None:
|
|
member_ids.difference_update(blocked_member_ids_for_event(event.club, season, event.kind) - invited_ids)
|
|
|
|
member_ids.update(invited_ids)
|
|
member_ids.difference_update(event.excluded_members.values_list("id", flat=True))
|
|
|
|
return Member.objects.filter(id__in=member_ids)
|
|
|
|
|
|
def sync_event_attendances(event):
|
|
"""Reconcile ``event``'s attendance rows with its effective audience.
|
|
|
|
No-op for events that have already started. New members get a
|
|
``NO_RESPONSE`` row; members no longer invited have their row removed
|
|
outright (hard reconcile).
|
|
"""
|
|
if event.start < timezone.now():
|
|
return
|
|
|
|
desired_ids = set(effective_members(event).values_list("id", flat=True))
|
|
existing_ids = set(event.attendances.values_list("member_id", flat=True))
|
|
|
|
to_add = desired_ids - existing_ids
|
|
if to_add:
|
|
Attendance.objects.bulk_create([Attendance(event=event, member_id=member_id) for member_id in to_add])
|
|
|
|
to_remove = existing_ids - desired_ids
|
|
if to_remove:
|
|
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
|
|
through. Nothing in this codebase calls this yet; it exists so a no-show can
|
|
be recorded the moment something does."""
|
|
attendance.showed_up = showed_up
|
|
attendance.save(update_fields=["showed_up"])
|
|
|
|
|
|
def team_attendance_rate(team, season):
|
|
"""Turnout for ``team`` in ``season``: present / (present + absent) among
|
|
past events -- same definition as
|
|
controlpanel.services.statistics.attendance_rates, just scoped to one team
|
|
instead of the whole club."""
|
|
counts = Attendance.objects.filter(event__teams=team, event__season=season, event__start__lt=timezone.now()).aggregate(
|
|
present=Count("id", filter=Q(status=Attendance.AttendanceStatus.PRESENT)),
|
|
absent=Count("id", filter=Q(status=Attendance.AttendanceStatus.ABSENT)),
|
|
)
|
|
answered = counts["present"] + counts["absent"]
|
|
return round(100 * counts["present"] / answered) if answered else None
|
|
|
|
|
|
def member_attendance_sparkline(member, season, *, limit=12):
|
|
"""This member's most recent ``limit`` attendance rows in ``season``,
|
|
oldest first, each bucketed into the three states the member detail
|
|
page's sparkline draws: 'present'/'absent' for a past event (mirrors
|
|
team_attendance_rate's own present-vs-everything-else-that-isn't-present
|
|
definition -- excused/no_response/etc. all read as a miss here, same as
|
|
there) or 'upcoming' for one that hasn't happened yet."""
|
|
now = timezone.now()
|
|
rows = list(Attendance.objects.filter(member=member, event__season=season).select_related("event").order_by("-event__start")[:limit])
|
|
rows.reverse()
|
|
|
|
bars = []
|
|
for row in rows:
|
|
if row.event.start > now:
|
|
state = "upcoming"
|
|
elif row.status == Attendance.AttendanceStatus.PRESENT:
|
|
state = "present"
|
|
else:
|
|
state = "absent"
|
|
bars.append({"event": row.event, "state": state})
|
|
return bars
|
|
|
|
|
|
def member_attendance_counts(member, season):
|
|
"""Present/Absent/No-reply totals for the season, alongside the
|
|
sparkline -- counts every past attendance row, not just the (possibly
|
|
truncated) ones the sparkline itself displays."""
|
|
return Attendance.objects.filter(member=member, event__season=season, event__start__lt=timezone.now()).aggregate(
|
|
present=Count("id", filter=Q(status=Attendance.AttendanceStatus.PRESENT)),
|
|
absent=Count("id", filter=Q(status=Attendance.AttendanceStatus.ABSENT)),
|
|
no_reply=Count("id", filter=Q(status=Attendance.AttendanceStatus.NO_RESPONSE)),
|
|
)
|
|
|
|
|
|
def player_attendance_rankings(team, season, *, minimum_responses=3):
|
|
"""Each player's turnout this season, best first:
|
|
``[{"member": Member, "rate": int, "responses": int}, ...]``. Excludes
|
|
anyone with fewer than ``minimum_responses`` present/absent replies -- one
|
|
absence out of one invite would otherwise read as "0%, worst on the team."
|
|
"""
|
|
rows = (
|
|
Attendance.objects.filter(
|
|
event__teams=team,
|
|
event__season=season,
|
|
event__start__lt=timezone.now(),
|
|
status__in=[Attendance.AttendanceStatus.PRESENT, Attendance.AttendanceStatus.ABSENT],
|
|
)
|
|
.values("member")
|
|
.annotate(present=Count("id", filter=Q(status=Attendance.AttendanceStatus.PRESENT)), responses=Count("id"))
|
|
.filter(responses__gte=minimum_responses)
|
|
)
|
|
|
|
members_by_id = Member.objects.in_bulk([row["member"] for row in rows])
|
|
rankings = [{"member": members_by_id[row["member"]], "rate": round(100 * row["present"] / row["responses"]), "responses": row["responses"]} for row in rows]
|
|
rankings.sort(key=lambda entry: entry["rate"], reverse=True)
|
|
return rankings
|
|
|
|
|
|
def players_who_missed_recent_practices(team, season, *, count=2):
|
|
"""Members absent or silent (not excused) on *every one* of the team's
|
|
``count`` most recent past training-kind events this season. Empty if the
|
|
team has fewer than ``count`` past practices logged yet -- not enough
|
|
history to call anyone out."""
|
|
practices = list(Event.objects.filter(teams=team, season=season, kind=Event.EventKind.TRAINING, start__lt=timezone.now()).order_by("-start")[:count])
|
|
if len(practices) < count:
|
|
return Member.objects.none()
|
|
|
|
flagged = (Attendance.AttendanceStatus.ABSENT, Attendance.AttendanceStatus.NO_RESPONSE)
|
|
missed_by_member = None
|
|
for practice in practices:
|
|
missed_here = set(Attendance.objects.filter(event=practice, status__in=flagged).values_list("member_id", flat=True))
|
|
missed_by_member = missed_here if missed_by_member is None else missed_by_member & missed_here
|
|
|
|
return Member.objects.filter(pk__in=missed_by_member).order_by("last_name", "first_name")
|
|
|
|
|
|
def team_no_shows(team, season):
|
|
"""Attendance rows where the member RSVPed present/selected but was
|
|
checked in as showed_up=False -- most recent first. Each entry carries
|
|
both the member and the event: a no-show is about a specific missed
|
|
occasion, not a season-long rate. Never inferred from a missing check-in --
|
|
with nothing checking anyone in yet, that would flag every "present" RSVP."""
|
|
return (
|
|
Attendance.objects.filter(
|
|
event__teams=team,
|
|
event__season=season,
|
|
status__in=[Attendance.AttendanceStatus.PRESENT, Attendance.AttendanceStatus.SELECTED],
|
|
showed_up=False,
|
|
)
|
|
.select_related("member", "event")
|
|
.order_by("-event__start")
|
|
)
|