Add a reusable notification system; wire it into news publishing
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
This commit is contained in:
69
notifications/services.py
Normal file
69
notifications/services.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Fan a notification out to members -- and, always, their parents/guardians.
|
||||
|
||||
Built for news publishing first (see news.tasks.notify_news_published), but
|
||||
recipient resolution and delivery live here, generically, so any future
|
||||
"notify these members about X" need reuses this instead of writing its own
|
||||
version of the same guardian-fallback logic club.services.invoicing.recipient_for
|
||||
already established for dues invoices.
|
||||
"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import Notification
|
||||
|
||||
|
||||
def recipient_emails(member) -> list[str]:
|
||||
"""The member's own email if they hold a login, plus every parent/guardian's,
|
||||
always -- a child with their own account doesn't opt their parents out of
|
||||
also being told. De-duplicated, order-stable. Empty means nobody reachable
|
||||
at all."""
|
||||
seen, emails = set(), []
|
||||
if member.user_id and member.contact_email:
|
||||
seen.add(member.contact_email)
|
||||
emails.append(member.contact_email)
|
||||
for guardian in member.guardians.order_by("last_name", "first_name"):
|
||||
email = guardian.contact_email
|
||||
if email and email not in seen:
|
||||
seen.add(email)
|
||||
emails.append(email)
|
||||
return emails
|
||||
|
||||
|
||||
def _send_email(notification: Notification, emails: list[str]) -> None:
|
||||
context = {"club": notification.club, "notification": notification}
|
||||
subject = " ".join(render_to_string("notifications/email/notification_subject.txt", context).split())
|
||||
text_body = render_to_string("notifications/email/notification.txt", context).strip() + "\n"
|
||||
html_body = render_to_string("notifications/email/notification.html", context)
|
||||
|
||||
for email in emails:
|
||||
message = EmailMultiAlternatives(subject, text_body, settings.DEFAULT_FROM_EMAIL, [email])
|
||||
message.attach_alternative(html_body, "text/html")
|
||||
try:
|
||||
message.send(fail_silently=False)
|
||||
except OSError:
|
||||
# Never fatal -- the Notification row (and whichever other addresses
|
||||
# in this same batch do go out) stands either way, same reasoning as
|
||||
# every other branded send in this app (see e.g.
|
||||
# members.services.claims.send_claim_approved_email).
|
||||
continue
|
||||
|
||||
|
||||
def notify_members(members, *, club, title: str, body: str, source=None) -> list[Notification]:
|
||||
"""One Notification per member, emailed to everyone recipient_emails()
|
||||
resolves for them. Always creates the row, even when nobody was reachable
|
||||
-- that's still true history for a future in-app feed, not a failure to
|
||||
silently drop."""
|
||||
notifications = []
|
||||
for member in members:
|
||||
notification = Notification.objects.create(club=club, member=member, title=title, body=body, source=source)
|
||||
emails = recipient_emails(member)
|
||||
if emails:
|
||||
_send_email(notification, emails)
|
||||
notification.sent_at = timezone.now()
|
||||
notification.sent_to_emails = emails
|
||||
notification.save(update_fields=["sent_at", "sent_to_emails", "modified"])
|
||||
notifications.append(notification)
|
||||
return notifications
|
||||
Reference in New Issue
Block a user