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