A family with several kids on a news item's audience now gets one email/one Notification, not one per child -- deduped on resolved recipient emails, so overlapping (not just identical) guardian sets still collapse correctly. Event notifications are untouched: each child still needs their own reply. Home's "Club news" teaser is also decoupled from the person-scope switcher -- a parent with no team of their own still sees their kids' team news when they've picked their own "Me" chip, not just when "All" is selected. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
92 lines
4.1 KiB
Python
92 lines
4.1 KiB
Python
"""Notifying members when a news item goes live.
|
|
|
|
Scheduled from management.views.NewsPublishView with an ETA matching the
|
|
item's own published_at (immediate publishes just get an ETA in the past,
|
|
which Celery runs right away -- no separate immediate/scheduled branch
|
|
needed). Not on CELERY_BEAT_SCHEDULE -- this fires once, on demand, per
|
|
publish, not on a recurring schedule, so it's not in features.jobs.JOB_REGISTRY
|
|
either (see features/signals.py: only registered jobs show up on the control
|
|
panel's Jobs tab).
|
|
"""
|
|
|
|
from celery import shared_task
|
|
from django.utils.html import strip_tags
|
|
|
|
from club.models import ClubMembership
|
|
from club.services.access import current_season
|
|
from members.models import Member
|
|
from notifications.services import notify_members, recipient_emails
|
|
|
|
from .models import News
|
|
from .services import render_body_html
|
|
|
|
|
|
def _notify_audience(news_item):
|
|
"""Every current-season, active member linked to this news item -- via its
|
|
teams if it has any, or every active member if it's club-wide. Guardians
|
|
are never part of this set themselves (notifications.services.recipient_emails
|
|
resolves them per member); a guardian ClubMembership has kind=GUARDIAN and
|
|
is excluded the same way teams.services.eligible_roster_members excludes
|
|
it from a roster."""
|
|
season = current_season(news_item.club)
|
|
if season is None:
|
|
return Member.objects.none()
|
|
|
|
members = Member.objects.filter(
|
|
member_of__club=news_item.club,
|
|
member_of__season=season,
|
|
member_of__status=ClubMembership.StatusChoices.ACTIVE,
|
|
member_of__kind=ClubMembership.Kind.MEMBER,
|
|
)
|
|
if news_item.teams.exists():
|
|
members = members.filter(team_memberships__team__in=news_item.teams.all(), team_memberships__season=season)
|
|
return members.distinct()
|
|
|
|
|
|
def _dedupe_by_recipients(members):
|
|
"""Collapses siblings (or anyone else sharing a guardian) down to one
|
|
notification each, keyed on where the email would actually land -- not
|
|
family membership itself, since a blended family's kids don't
|
|
necessarily share the exact same guardian set, only some overlap. A
|
|
parent of three kids all on the news audience gets one email, not three;
|
|
each kid still gets their own row (and read state) via events.tasks.
|
|
notify_new_event, which is deliberately untouched by this -- an event
|
|
needs a reply per child, a news post doesn't. Members nobody's reachable
|
|
for (no login, no guardian) are never deduped against anything -- there's
|
|
no shared inbox to spare."""
|
|
seen_emails = set()
|
|
representatives = []
|
|
for member in members:
|
|
emails = recipient_emails(member)
|
|
if emails and any(email in seen_emails for email in emails):
|
|
continue
|
|
representatives.append(member)
|
|
seen_emails.update(emails)
|
|
return representatives
|
|
|
|
|
|
def _plain_text_body(news_item) -> str:
|
|
"""The notification's own plain-text body: rendered from the same
|
|
Markdown source the public site would use, then stripped back to plain
|
|
text. Notification.body is reused by every future notification source
|
|
(see that model's own docstring), so it stores plain text, not this one
|
|
source's particular markup."""
|
|
return strip_tags(render_body_html(news_item.body))
|
|
|
|
|
|
@shared_task(name="news.tasks.notify_news_published")
|
|
def notify_news_published(news_id):
|
|
news_item = News.objects.filter(pk=news_id, status=News.Status.PUBLISHED).select_related("club").prefetch_related("teams").first()
|
|
if news_item is None:
|
|
# Unpublished or deleted between scheduling this and the ETA firing --
|
|
# nothing to notify about, and not an error worth a JobRun-style alert
|
|
# (this task isn't registered for that anyway -- see the module docstring).
|
|
return "Skipped: not published."
|
|
|
|
members = _dedupe_by_recipients(_notify_audience(news_item))
|
|
if not members:
|
|
return "Skipped: no current-season active members to notify."
|
|
|
|
notifications = notify_members(members, club=news_item.club, title=news_item.title, body=_plain_text_body(news_item), source=news_item)
|
|
return f"Notified {len(notifications)} member(s)."
|