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:
@@ -4,10 +4,11 @@ so a club can see exactly what a member/parent receives without anything
|
||||
actually being sent.
|
||||
|
||||
Each entry renders the *real* templates the real send functions use (see
|
||||
club.services.invoicing.send_invoice_email/send_reminder_email, and allauth's
|
||||
own password-reset flow via templates/account/email/password_reset_key_message.html)
|
||||
against a hand-built sample context -- never a real Member/ClubMembership/
|
||||
DuesInvoice row, so this needs nothing from the database beyond the current
|
||||
club.services.invoicing.send_invoice_email/send_reminder_email,
|
||||
notifications.services.notify_members, and allauth's own password-reset flow
|
||||
via templates/account/email/password_reset_key_message.html) against a
|
||||
hand-built sample context -- never a real Member/ClubMembership/DuesInvoice/
|
||||
Notification row, so this needs nothing from the database beyond the current
|
||||
club itself, and can't leak anything real. Adding a new branded email later
|
||||
means adding one entry here, not touching the view or template.
|
||||
"""
|
||||
@@ -43,6 +44,11 @@ def _dues_invoice_context(club, request, *, overdue):
|
||||
return {"club": club, "invoice": invoice, "membership": membership, "member": member, "request": request}
|
||||
|
||||
|
||||
def _notification_context(club, request):
|
||||
notification = SimpleNamespace(title="Training moved to Tuesday", body="This week's U16 training moves from Thursday 19:00 to Tuesday 19:00, same location. See you there!")
|
||||
return {"club": club, "notification": notification, "request": request}
|
||||
|
||||
|
||||
def _password_reset_context(club, request):
|
||||
# Mirrors allauth.account.internal.flows.password_reset.request_password_reset's
|
||||
# own context -- current_site is only used by the *stock* .txt template's
|
||||
@@ -77,6 +83,15 @@ EMAIL_PREVIEWS = [
|
||||
html_template="club/email/dues_invoice_reminder.html",
|
||||
build_context=lambda club, request: _dues_invoice_context(club, request, overdue=True),
|
||||
),
|
||||
EmailPreview(
|
||||
key="notification",
|
||||
label=_("Member notification"),
|
||||
description=_("Sent to a member (and always their parent/guardian too) when staff notify them about something -- e.g. the “Notify linked members” option when publishing news."),
|
||||
subject_template="notifications/email/notification_subject.txt",
|
||||
text_template="notifications/email/notification.txt",
|
||||
html_template="notifications/email/notification.html",
|
||||
build_context=_notification_context,
|
||||
),
|
||||
EmailPreview(
|
||||
key="password_reset",
|
||||
label=_("Password reset"),
|
||||
|
||||
@@ -863,6 +863,12 @@ class NewsPublishForm(forms.Form):
|
||||
widget=forms.DateTimeInput(attrs={"type": "datetime-local"}),
|
||||
help_text=_("Leave as now to publish immediately, or pick a future date/time to schedule it."),
|
||||
)
|
||||
notify_members = forms.BooleanField(
|
||||
label=_("Notify linked members"),
|
||||
required=False,
|
||||
initial=False,
|
||||
help_text=_("Emails everyone this item reaches (its teams' current rosters, or every active member if it's club-wide) -- and, always, their parent/guardian. Sent when this actually goes live, not necessarily right now."),
|
||||
)
|
||||
|
||||
|
||||
class RecordFeePaymentForm(forms.Form):
|
||||
|
||||
@@ -4145,6 +4145,33 @@ class NewsManagementTests(ManagementTestBase):
|
||||
item.refresh_from_db()
|
||||
self.assertFalse(item.is_scheduled)
|
||||
|
||||
def test_notify_members_checkbox_emails_the_audience(self):
|
||||
member = Member.objects.create(first_name="Jamie", last_name="Doe", email="jamie@example.com")
|
||||
ClubMembership.objects.create(club=self.club, member=member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
|
||||
User.objects.create_user(email="jamie@example.com", password="pw-secret-123")
|
||||
member.user = User.objects.get(email="jamie@example.com")
|
||||
member.save(update_fields=["user"])
|
||||
item = News.objects.create(club=self.club, title="Draft item", body="Body.")
|
||||
self.client.force_login(self.editor)
|
||||
|
||||
self.club_post("news_publish", {"published_at": timezone.now().strftime("%Y-%m-%dT%H:%M"), "notify_members": "on"}, item.pk)
|
||||
|
||||
# Not asserting an exact outbox size: the base fixture's own staff
|
||||
# members (self.editor etc.) may also be active MEMBER-kind club
|
||||
# members in this same season, so they legitimately get one too.
|
||||
sent_to = [address for message in mail.outbox for address in message.to]
|
||||
self.assertIn("jamie@example.com", sent_to)
|
||||
|
||||
def test_leaving_notify_members_unchecked_sends_nothing(self):
|
||||
member = Member.objects.create(first_name="Jamie", last_name="Doe", email="jamie@example.com")
|
||||
ClubMembership.objects.create(club=self.club, member=member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
|
||||
item = News.objects.create(club=self.club, title="Draft item", body="Body.")
|
||||
self.client.force_login(self.editor)
|
||||
|
||||
self.club_post("news_publish", {"published_at": timezone.now().strftime("%Y-%m-%dT%H:%M")}, item.pk)
|
||||
|
||||
self.assertEqual(len(mail.outbox), 0)
|
||||
|
||||
def test_unpublishing_reverts_to_draft(self):
|
||||
item = News.objects.create(club=self.club, title="Live item", body="Body.")
|
||||
item.publish()
|
||||
|
||||
@@ -49,6 +49,7 @@ from members.models import Family, FamilyMembership, Group, GroupMembership, Mem
|
||||
from members.services.claims import ClaimError, approve_claim, children_awaiting_a_parent, reject_claim, send_claim_approved_email, suggested_children
|
||||
from members.services.family import add_child_to_family, add_parent_to_family, attach_to_family, detach_from_family, get_or_create_login_user, grant_login, register_family
|
||||
from news.models import News, NewsPhoto
|
||||
from news.tasks import notify_news_published
|
||||
from shop.models import Discount, Invoice, Order, Product
|
||||
from teams.models import Position, RefereeLevel, RefereeProfile, StaffAssignment, Team, TeamMembership, TeamPhoto
|
||||
from teams.services import eligible_roster_members
|
||||
@@ -2195,6 +2196,12 @@ class NewsPublishView(NewsPublisherRequiredMixin, RedirectOnInvalidMixin, FormVi
|
||||
news_item = get_object_or_404(News.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
|
||||
news_item.publish(at=form.cleaned_data["published_at"])
|
||||
|
||||
if form.cleaned_data["notify_members"]:
|
||||
# eta in the past (the common, "publish now" case) just runs right
|
||||
# away -- see news.tasks' own module docstring for why there's no
|
||||
# separate immediate/scheduled branch here.
|
||||
notify_news_published.apply_async(args=[str(news_item.pk)], eta=news_item.published_at)
|
||||
|
||||
if news_item.is_scheduled:
|
||||
body = _("“%(news)s” is scheduled to go live on %(date)s.") % {"news": news_item, "date": news_item.published_at}
|
||||
else:
|
||||
|
||||
69
news/tasks.py
Normal file
69
news/tasks.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""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)."
|
||||
@@ -1,13 +1,25 @@
|
||||
import datetime
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core import mail
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.db import IntegrityError
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from club.models import Club
|
||||
from club.models import Club, ClubMembership, Season
|
||||
from members.models import Member
|
||||
from notifications.models import Notification
|
||||
from teams.models import Position, Team, TeamMembership
|
||||
|
||||
from .models import News, NewsPhoto
|
||||
from .tasks import notify_news_published
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def make_season(club, start_year=2026):
|
||||
return Season.objects.create(club=club, start_date=datetime.date(start_year, 8, 1), end_date=datetime.date(start_year + 1, 5, 31))
|
||||
|
||||
|
||||
def make_photo(news_item, *, is_main=False):
|
||||
@@ -121,3 +133,81 @@ class NewsPhotoModelTests(TestCase):
|
||||
make_photo(other_item, is_main=True)
|
||||
|
||||
self.assertEqual(NewsPhoto.objects.filter(is_main=True).count(), 2)
|
||||
|
||||
|
||||
class NotifyNewsPublishedTests(TestCase):
|
||||
"""news.tasks.notify_news_published -- the audience is this item's teams'
|
||||
current rosters, or every active member if it's club-wide."""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
||||
cls.season = make_season(cls.club)
|
||||
cls.position = Position.objects.create(club=cls.club, name="Player", short_name="P")
|
||||
|
||||
def make_member(self, first_name, *, status=ClubMembership.StatusChoices.ACTIVE, kind=ClubMembership.Kind.MEMBER, email=None):
|
||||
member = Member.objects.create(first_name=first_name, last_name="Member", email=email or f"{first_name.lower()}@example.com")
|
||||
if email:
|
||||
User.objects.create_user(email=email, password="pw-secret-123")
|
||||
member.user = User.objects.get(email=email)
|
||||
member.save(update_fields=["user"])
|
||||
ClubMembership.objects.create(club=self.club, member=member, season=self.season, status=status, kind=kind)
|
||||
return member
|
||||
|
||||
def test_club_wide_news_notifies_every_active_member(self):
|
||||
member = self.make_member("Jamie", email="jamie@example.com")
|
||||
news_item = News.objects.create(club=self.club, title="Big news", body="Something happened.", status=News.Status.PUBLISHED, published_at=timezone.now())
|
||||
|
||||
result = notify_news_published(news_item.pk)
|
||||
|
||||
self.assertTrue(Notification.objects.filter(club=self.club, member=member, title="Big news").exists())
|
||||
self.assertIn("Notified 1", result)
|
||||
|
||||
def test_team_scoped_news_only_notifies_that_teams_roster(self):
|
||||
team = Team.objects.create(club=self.club, name="U16", short_name="U16")
|
||||
other_team = Team.objects.create(club=self.club, name="U18", short_name="U18")
|
||||
on_team = self.make_member("Jamie", email="jamie@example.com")
|
||||
off_team = self.make_member("Alex", email="alex@example.com")
|
||||
TeamMembership.objects.create(team=team, member=on_team, season=self.season, position=self.position)
|
||||
TeamMembership.objects.create(team=other_team, member=off_team, season=self.season, position=self.position)
|
||||
news_item = News.objects.create(club=self.club, title="Team news", body="Training moved.", status=News.Status.PUBLISHED, published_at=timezone.now())
|
||||
news_item.teams.add(team)
|
||||
|
||||
notify_news_published(news_item.pk)
|
||||
|
||||
self.assertTrue(Notification.objects.filter(member=on_team).exists())
|
||||
self.assertFalse(Notification.objects.filter(member=off_team).exists())
|
||||
|
||||
def test_excludes_an_inactive_member(self):
|
||||
self.make_member("Jamie", status=ClubMembership.StatusChoices.PENDING, email="jamie@example.com")
|
||||
news_item = News.objects.create(club=self.club, title="News", body="Body.", status=News.Status.PUBLISHED, published_at=timezone.now())
|
||||
|
||||
notify_news_published(news_item.pk)
|
||||
|
||||
self.assertFalse(Notification.objects.exists())
|
||||
|
||||
def test_excludes_a_guardian(self):
|
||||
self.make_member("Alex", kind=ClubMembership.Kind.GUARDIAN, email="alex@example.com")
|
||||
news_item = News.objects.create(club=self.club, title="News", body="Body.", status=News.Status.PUBLISHED, published_at=timezone.now())
|
||||
|
||||
notify_news_published(news_item.pk)
|
||||
|
||||
self.assertFalse(Notification.objects.exists())
|
||||
|
||||
def test_skips_a_news_item_that_is_no_longer_published(self):
|
||||
news_item = News.objects.create(club=self.club, title="News", body="Body.", status=News.Status.DRAFT)
|
||||
|
||||
result = notify_news_published(news_item.pk)
|
||||
|
||||
self.assertEqual(result, "Skipped: not published.")
|
||||
self.assertFalse(Notification.objects.exists())
|
||||
|
||||
def test_the_body_is_plain_text_not_markdown(self):
|
||||
member = self.make_member("Jamie", email="jamie@example.com")
|
||||
news_item = News.objects.create(club=self.club, title="News", body="**Bold** text.", status=News.Status.PUBLISHED, published_at=timezone.now())
|
||||
|
||||
notify_news_published(news_item.pk)
|
||||
|
||||
notification = Notification.objects.get(member=member)
|
||||
self.assertEqual(notification.body, "Bold text.")
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
|
||||
0
notifications/__init__.py
Normal file
0
notifications/__init__.py
Normal file
12
notifications/admin.py
Normal file
12
notifications/admin.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import Notification
|
||||
|
||||
|
||||
@admin.register(Notification)
|
||||
class NotificationAdmin(admin.ModelAdmin):
|
||||
list_display = ["title", "member", "club", "sent_at", "content_type"]
|
||||
list_filter = ["club", "sent_at"]
|
||||
search_fields = ["title", "member__last_name", "member__first_name"]
|
||||
raw_id_fields = ["member"]
|
||||
readonly_fields = ["sent_at", "sent_to_emails"]
|
||||
5
notifications/apps.py
Normal file
5
notifications/apps.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class NotificationsConfig(AppConfig):
|
||||
name = "notifications"
|
||||
42
notifications/migrations/0001_initial.py
Normal file
42
notifications/migrations/0001_initial.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# Generated by Django 6.0.6 on 2026-08-21 07:20
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('club', '0029_duesinvoice'),
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('members', '0006_parentclaim_submitted_by_user'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Notification',
|
||||
fields=[
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
|
||||
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('title', models.CharField(max_length=255, verbose_name='title')),
|
||||
('body', models.TextField(verbose_name='body')),
|
||||
('object_id', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('sent_at', models.DateTimeField(blank=True, null=True, verbose_name='sent at')),
|
||||
('sent_to_emails', models.JSONField(blank=True, default=list, help_text="Every email address this actually went to -- the member's own, and/or a parent/guardian's. Empty means nobody reachable was found.", verbose_name='sent to')),
|
||||
('read_at', models.DateTimeField(blank=True, help_text="Set once there's somewhere for a member to read this -- not used yet.", null=True, verbose_name='read at')),
|
||||
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')),
|
||||
('content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
|
||||
('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='members.member', verbose_name='member')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'notification',
|
||||
'verbose_name_plural': 'notifications',
|
||||
'ordering': ['-created'],
|
||||
'indexes': [models.Index(fields=['content_type', 'object_id'], name='notificatio_content_702c56_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
notifications/migrations/__init__.py
Normal file
0
notifications/migrations/__init__.py
Normal file
49
notifications/models.py
Normal file
49
notifications/models.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from members.models import Member
|
||||
from rosterchief.base import ClubScopedModel, validate_club_scope
|
||||
|
||||
|
||||
class Notification(ClubScopedModel):
|
||||
"""One member's copy of a notification -- "this member should know about
|
||||
X" -- built for news publishing first (see news.tasks.notify_news_published),
|
||||
but `source` is generic on purpose: the whole point is reusing this for
|
||||
other kinds of activity later without a new model each time.
|
||||
|
||||
Always keyed to the Member it's *about*, even though actual delivery may
|
||||
go to a parent/guardian's email instead of (or as well as) the member's
|
||||
own -- see notifications.services.notify_members. That's what a future
|
||||
in-app feed for this member would list, regardless of who was actually
|
||||
emailed; `sent_to_emails` is the audit trail of who that was.
|
||||
"""
|
||||
|
||||
member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name="notifications", verbose_name=_("member"))
|
||||
title = models.CharField(_("title"), max_length=255)
|
||||
body = models.TextField(_("body"))
|
||||
|
||||
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, null=True, blank=True)
|
||||
object_id = models.CharField(max_length=255, null=True, blank=True) # noqa: DJ001 -- GenericForeignKey needs a real NULL, not "", for "no source"
|
||||
source = GenericForeignKey("content_type", "object_id")
|
||||
|
||||
sent_at = models.DateTimeField(_("sent at"), null=True, blank=True)
|
||||
sent_to_emails = models.JSONField(_("sent to"), default=list, blank=True, help_text=_("Every email address this actually went to -- the member's own, and/or a parent/guardian's. Empty means nobody reachable was found."))
|
||||
read_at = models.DateTimeField(_("read at"), null=True, blank=True, help_text=_("Set once there's somewhere for a member to read this -- not used yet."))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("notification")
|
||||
verbose_name_plural = _("notifications")
|
||||
ordering = ["-created"]
|
||||
indexes = [models.Index(fields=["content_type", "object_id"])]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.member} — {self.title}"
|
||||
|
||||
def clean(self):
|
||||
validate_club_scope(self, self.club_id, member_fields=("member",))
|
||||
|
||||
@property
|
||||
def is_sent(self) -> bool:
|
||||
return self.sent_at is not None
|
||||
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
|
||||
@@ -0,0 +1,49 @@
|
||||
{% extends "email/_base.html" %}
|
||||
{% load i18n club_email %}
|
||||
|
||||
{% comment %}
|
||||
HTML sibling of notification.txt -- same content, same context (club,
|
||||
notification, request), laid out for an inbox. Same shell/branding as
|
||||
every other transactional email in this app (club/templates/club/email/*.html,
|
||||
members/.../claim_approved.html) -- see that convention before changing this.
|
||||
{% endcomment %}
|
||||
|
||||
{% block title %}{% blocktrans with club=club.name title=notification.title %}{{ club }} — {{ title }}{% endblocktrans %}{% endblock title %}
|
||||
|
||||
{% block preheader %}{{ notification.title }}{% endblock preheader %}
|
||||
|
||||
{% block header %}
|
||||
{% absolute_media_url club.logo as logo_url %}
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td valign="middle">
|
||||
{% if club.logo %}
|
||||
<img src="{{ logo_url }}" alt="{{ club.name }}" width="48" height="48" style="display:block; width:48px; height:48px; border-radius:24px; object-fit:contain; background-color:#f3f4f6;">
|
||||
{% else %}
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="48" style="width:48px;">
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="48" height="48" bgcolor="{{ club.secondary_color|default:"#ec4899" }}" style="width:48px; height:48px; border-radius:24px; background-color:{{ club.secondary_color|default:"#ec4899" }}; color:{{ club.secondary_color|default:"#ec4899"|contrast_color }}; font-family: Arial, Helvetica, sans-serif; font-size:16px; font-weight:bold;">
|
||||
{{ club.initials }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="padding-left:14px;" valign="middle">
|
||||
<span style="font-family: Arial, Helvetica, sans-serif; font-size:18px; font-weight:bold; color:#111827;">{{ club.name }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% endblock header %}
|
||||
|
||||
{% block content %}
|
||||
<p style="margin:0 0 16px 0; font-size:18px; font-weight:bold; color:#111827;">{{ notification.title }}</p>
|
||||
<div style="margin:0;">{{ notification.body|linebreaks }}</div>
|
||||
{% endblock content %}
|
||||
|
||||
{% block footer %}
|
||||
{% if club.contact_email %}
|
||||
<p style="margin:0 0 8px 0;">{% blocktrans with email=club.contact_email %}Questions? Reply to this note or write to {{ email }}.{% endblocktrans %}</p>
|
||||
{% endif %}
|
||||
<p style="margin:0;">{% blocktrans with club=club.name %}— {{ club }}{% endblocktrans %}</p>
|
||||
{% endblock footer %}
|
||||
@@ -0,0 +1,7 @@
|
||||
{% load i18n %}{{ notification.title }}
|
||||
|
||||
{{ notification.body }}
|
||||
{% if club.contact_email %}
|
||||
{% blocktrans with email=club.contact_email %}Questions? Reply to this note or write to {{ email }}.{% endblocktrans %}
|
||||
{% endif %}
|
||||
{% blocktrans with club=club.name %}— {{ club }}{% endblocktrans %}
|
||||
@@ -0,0 +1 @@
|
||||
{% load i18n %}{% blocktrans with club=club.name title=notification.title %}{{ club }} — {{ title }}{% endblocktrans %}
|
||||
161
notifications/tests.py
Normal file
161
notifications/tests.py
Normal file
@@ -0,0 +1,161 @@
|
||||
import datetime
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core import mail
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase
|
||||
|
||||
from club.models import Club, ClubMembership, Season
|
||||
from members.models import Family, FamilyMembership, Member
|
||||
|
||||
from .models import Notification
|
||||
from .services import notify_members, recipient_emails
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def make_season(club, start_year=2026):
|
||||
return Season.objects.create(club=club, start_date=datetime.date(start_year, 8, 1), end_date=datetime.date(start_year + 1, 5, 31))
|
||||
|
||||
|
||||
class RecipientEmailsTests(TestCase):
|
||||
"""notifications.services.recipient_emails -- the member's own email if
|
||||
they hold a login, plus every parent/guardian's, always."""
|
||||
|
||||
def test_a_member_with_a_login_gets_their_own_email(self):
|
||||
user = User.objects.create_user(email="jamie@example.com", password="pw-secret-123")
|
||||
member = Member.objects.create(user=user, first_name="Jamie", last_name="Doe", email="jamie@example.com")
|
||||
|
||||
self.assertEqual(recipient_emails(member), ["jamie@example.com"])
|
||||
|
||||
def test_a_member_with_no_login_gets_nothing_from_themselves(self):
|
||||
# Roster-imported, never signed up -- see members.models.ParentClaim's
|
||||
# own docstring for why this is routine, not an edge case.
|
||||
member = Member.objects.create(first_name="Jamie", last_name="Doe", email="jamie@example.com")
|
||||
|
||||
self.assertEqual(recipient_emails(member), [])
|
||||
|
||||
def test_guardians_are_always_included_even_with_the_childs_own_login(self):
|
||||
user = User.objects.create_user(email="jamie@example.com", password="pw-secret-123")
|
||||
family = Family.objects.create()
|
||||
child = Member.objects.create(user=user, first_name="Jamie", last_name="Doe", email="jamie@example.com")
|
||||
parent = Member.objects.create(first_name="Alex", last_name="Doe", email="alex@example.com")
|
||||
FamilyMembership.objects.create(family=family, member=child, role=FamilyMembership.FamilyRole.CHILD)
|
||||
FamilyMembership.objects.create(family=family, member=parent, role=FamilyMembership.FamilyRole.PARENT)
|
||||
|
||||
emails = recipient_emails(child)
|
||||
|
||||
self.assertIn("jamie@example.com", emails)
|
||||
self.assertIn("alex@example.com", emails)
|
||||
|
||||
def test_a_child_with_no_login_still_reaches_their_guardian(self):
|
||||
family = Family.objects.create()
|
||||
child = Member.objects.create(first_name="Jamie", last_name="Doe")
|
||||
parent = Member.objects.create(first_name="Alex", last_name="Doe", email="alex@example.com")
|
||||
FamilyMembership.objects.create(family=family, member=child, role=FamilyMembership.FamilyRole.CHILD)
|
||||
FamilyMembership.objects.create(family=family, member=parent, role=FamilyMembership.FamilyRole.PARENT)
|
||||
|
||||
self.assertEqual(recipient_emails(child), ["alex@example.com"])
|
||||
|
||||
def test_duplicate_addresses_are_deduplicated(self):
|
||||
user = User.objects.create_user(email="shared@example.com", password="pw-secret-123")
|
||||
family = Family.objects.create()
|
||||
child = Member.objects.create(user=user, first_name="Jamie", last_name="Doe", email="shared@example.com")
|
||||
parent = Member.objects.create(first_name="Alex", last_name="Doe", email="shared@example.com")
|
||||
FamilyMembership.objects.create(family=family, member=child, role=FamilyMembership.FamilyRole.CHILD)
|
||||
FamilyMembership.objects.create(family=family, member=parent, role=FamilyMembership.FamilyRole.PARENT)
|
||||
|
||||
self.assertEqual(recipient_emails(child), ["shared@example.com"])
|
||||
|
||||
def test_empty_when_nobody_is_reachable(self):
|
||||
member = Member.objects.create(first_name="Jamie", last_name="Doe")
|
||||
|
||||
self.assertEqual(recipient_emails(member), [])
|
||||
|
||||
|
||||
class NotifyMembersTests(TestCase):
|
||||
"""notifications.services.notify_members -- one Notification per member,
|
||||
emailed to whoever recipient_emails() resolves for them."""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
||||
cls.season = make_season(cls.club)
|
||||
|
||||
def make_member(self, *, with_login=True, email="jamie@example.com"):
|
||||
user = User.objects.create_user(email=email, password="pw-secret-123") if with_login else None
|
||||
return Member.objects.create(user=user, first_name="Jamie", last_name="Doe", email=email if with_login else "")
|
||||
|
||||
def test_creates_one_notification_per_member(self):
|
||||
members = [self.make_member(email=f"m{i}@example.com") for i in range(3)]
|
||||
|
||||
notifications = notify_members(members, club=self.club, title="News", body="Something happened.")
|
||||
|
||||
self.assertEqual(len(notifications), 3)
|
||||
self.assertEqual(Notification.objects.filter(club=self.club).count(), 3)
|
||||
|
||||
def test_emails_the_resolved_addresses(self):
|
||||
member = self.make_member(email="jamie@example.com")
|
||||
|
||||
notify_members([member], club=self.club, title="Big win", body="We won 3-0.")
|
||||
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
self.assertEqual(mail.outbox[0].to, ["jamie@example.com"])
|
||||
self.assertIn("Big win", mail.outbox[0].subject)
|
||||
self.assertIn("We won 3-0.", mail.outbox[0].body)
|
||||
|
||||
def test_the_email_carries_an_html_alternative(self):
|
||||
member = self.make_member()
|
||||
|
||||
notify_members([member], club=self.club, title="Big win", body="We won 3-0.")
|
||||
|
||||
[(html_body, mimetype)] = mail.outbox[0].alternatives
|
||||
self.assertEqual(mimetype, "text/html")
|
||||
self.assertIn("Big win", html_body)
|
||||
|
||||
def test_sent_at_and_sent_to_emails_are_recorded(self):
|
||||
member = self.make_member(email="jamie@example.com")
|
||||
|
||||
[notification] = notify_members([member], club=self.club, title="News", body="Body.")
|
||||
|
||||
self.assertIsNotNone(notification.sent_at)
|
||||
self.assertEqual(notification.sent_to_emails, ["jamie@example.com"])
|
||||
self.assertTrue(notification.is_sent)
|
||||
|
||||
def test_a_notification_is_still_created_when_nobody_is_reachable(self):
|
||||
member = self.make_member(with_login=False)
|
||||
|
||||
[notification] = notify_members([member], club=self.club, title="News", body="Body.")
|
||||
|
||||
self.assertEqual(len(mail.outbox), 0)
|
||||
self.assertIsNone(notification.sent_at)
|
||||
self.assertEqual(notification.sent_to_emails, [])
|
||||
self.assertFalse(notification.is_sent)
|
||||
|
||||
def test_the_source_is_recorded_as_a_generic_relation(self):
|
||||
member = self.make_member()
|
||||
|
||||
[notification] = notify_members([member], club=self.club, title="News", body="Body.", source=self.season)
|
||||
|
||||
self.assertEqual(notification.source, self.season)
|
||||
|
||||
|
||||
class NotificationModelTests(TestCase):
|
||||
def test_str_is_member_and_title(self):
|
||||
club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
||||
member = Member.objects.create(first_name="Jamie", last_name="Doe")
|
||||
notification = Notification.objects.create(club=club, member=member, title="Big win", body="Body.")
|
||||
|
||||
self.assertEqual(str(notification), f"{member} — Big win")
|
||||
|
||||
def test_clean_rejects_a_member_with_no_membership_in_this_club(self):
|
||||
club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
||||
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
|
||||
season = make_season(other_club)
|
||||
member = Member.objects.create(first_name="Jamie", last_name="Doe")
|
||||
ClubMembership.objects.create(club=other_club, member=member, season=season, status=ClubMembership.StatusChoices.ACTIVE)
|
||||
|
||||
notification = Notification(club=club, member=member, title="News", body="Body.")
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
notification.clean()
|
||||
@@ -64,7 +64,7 @@ ignore = [
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
known-first-party = [
|
||||
"api", "billing", "authentication", "club", "members", "teams", "events", "formbuilder", "shop", "controlpanel", "management", "news", "pages", "home", "search", "rosterchief"]
|
||||
"api", "billing", "authentication", "club", "members", "teams", "events", "formbuilder", "shop", "controlpanel", "management", "news", "notifications", "pages", "home", "search", "rosterchief"]
|
||||
|
||||
[tool.uv.sources]
|
||||
django-lucide = { git = "https://github.com/bsiebens/lucide" }
|
||||
|
||||
@@ -69,6 +69,7 @@ INSTALLED_APPS = [
|
||||
"teams.apps.TeamsConfig",
|
||||
"events.apps.EventsConfig",
|
||||
"news.apps.NewsConfig",
|
||||
"notifications.apps.NotificationsConfig",
|
||||
"formbuilder.apps.FormbuilderConfig",
|
||||
"shop.apps.ShopConfig",
|
||||
# Platform billing: RosterChief charging the clubs. Not tenant data — see billing/models.py.
|
||||
|
||||
Reference in New Issue
Block a user