Files
RosterChief/news/tasks.py
Bernard Siebens 2b6a4d21bb Rationalize news notifications and the Home news teaser for multi-child families
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
2026-08-22 16:09:36 +02:00

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)."