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

69
news/tasks.py Normal file
View 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)."

View File

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