New `notifications` app: Notification (club-scoped, keyed to the member it's about, generic `source` via a ContentType/object_id pair so future activities can reuse this without a new model each time) plus notify_members(), which resolves each member's own email (if they hold a login) and every parent/guardian's, always -- a child with their own account doesn't opt their parents out -- and emails the club-branded template to whichever addresses that resolves to. Delivery is email-only for now (no in-app feed exists yet); the row is created either way, ready for one later. news.tasks.notify_news_published resolves the audience (a team-scoped item's current rosters, or every active member if it's club-wide) and calls notify_members with the item's title/plain-text body. NewsPublishForm gained a "Notify linked members" checkbox (opt-in, default off); when checked, NewsPublishView schedules the task with Celery's `eta` set to the item's own published_at -- a scheduled item's notification arrives when it actually goes live, and an immediate publish (eta in the past) just runs right away, no separate branch needed. Registered the new notification email on the Club identity page's Email tab alongside the others. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
70 lines
3.0 KiB
Python
70 lines
3.0 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
|
|
|
|
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 _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 = list(_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)."
|