News gains a PENDING_REVIEW status between draft and published. A non-editor author (can_add_news but not can_publish_news -- a coach_manager, not an ADMIN/EDITOR) gets a "Send for review" button instead of Publish; an editor/admin always sees Publish directly, no review step. Submitting notifies every ADMIN/EDITOR in-app only (see notify_members' new send_email=False) -- a review queue that emailed on every submission would get noisy fast. The notification area itself: a topbar bell (badge + dropdown, same <details>/<summary> convention as the sidebar's user-menu, generalised to a shared .dismissable-details close handler) visible on every page, plus a fuller "Notifications" card on the dashboard, both fed by a new notification_bell context processor. "Mark all read" clears the signed-in staff member's own unread notifications for this club. This is the reusable notification system's first consumer beyond news publishing itself -- validates that notify_members()/Notification generalise the way they were meant to. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
74 lines
3.5 KiB
Python
74 lines
3.5 KiB
Python
"""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, send_email: bool = True) -> list[Notification]:
|
|
"""One Notification per member. Emailed to everyone recipient_emails()
|
|
resolves for them, unless send_email is False -- e.g. a staff review
|
|
queue (see news.services.notify_editors_of_pending_review), which is
|
|
in-app only: it doesn't need every editor emailed on every submission,
|
|
just the topbar/dashboard entry. Always creates the row, even when
|
|
nobody was reachable or emailing was skipped -- that's still true
|
|
history for the 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)
|
|
if send_email:
|
|
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
|