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

@@ -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"),

View File

@@ -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):

View File

@@ -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()

View File

@@ -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: