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:
2026-08-21 09:27:55 +02:00
parent 916f9bf8ab
commit 5f7ca98eae
19 changed files with 616 additions and 6 deletions

View File

12
notifications/admin.py Normal file
View 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
View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class NotificationsConfig(AppConfig):
name = "notifications"

View 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')],
},
),
]

View File

49
notifications/models.py Normal file
View 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
View 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

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -0,0 +1 @@
{% load i18n %}{% blocktrans with club=club.name title=notification.title %}{{ club }} — {{ title }}{% endblocktrans %}

161
notifications/tests.py Normal file
View 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()