diff --git a/management/email_previews.py b/management/email_previews.py index 609cce7..c4148bc 100644 --- a/management/email_previews.py +++ b/management/email_previews.py @@ -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"), diff --git a/management/forms.py b/management/forms.py index cdbf30c..4c6f2e9 100644 --- a/management/forms.py +++ b/management/forms.py @@ -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): diff --git a/management/tests.py b/management/tests.py index 416f3ec..a882517 100644 --- a/management/tests.py +++ b/management/tests.py @@ -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() diff --git a/management/views.py b/management/views.py index d4623aa..bb18d78 100644 --- a/management/views.py +++ b/management/views.py @@ -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: diff --git a/news/tasks.py b/news/tasks.py new file mode 100644 index 0000000..f46c0f4 --- /dev/null +++ b/news/tasks.py @@ -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)." diff --git a/news/tests.py b/news/tests.py index a2cd60c..9d970f0 100644 --- a/news/tests.py +++ b/news/tests.py @@ -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) diff --git a/notifications/__init__.py b/notifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/notifications/admin.py b/notifications/admin.py new file mode 100644 index 0000000..4124112 --- /dev/null +++ b/notifications/admin.py @@ -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"] diff --git a/notifications/apps.py b/notifications/apps.py new file mode 100644 index 0000000..9eb742d --- /dev/null +++ b/notifications/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class NotificationsConfig(AppConfig): + name = "notifications" diff --git a/notifications/migrations/0001_initial.py b/notifications/migrations/0001_initial.py new file mode 100644 index 0000000..c7ad6dd --- /dev/null +++ b/notifications/migrations/0001_initial.py @@ -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')], + }, + ), + ] diff --git a/notifications/migrations/__init__.py b/notifications/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/notifications/models.py b/notifications/models.py new file mode 100644 index 0000000..2044fa8 --- /dev/null +++ b/notifications/models.py @@ -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 diff --git a/notifications/services.py b/notifications/services.py new file mode 100644 index 0000000..df994ed --- /dev/null +++ b/notifications/services.py @@ -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 diff --git a/notifications/templates/notifications/email/notification.html b/notifications/templates/notifications/email/notification.html new file mode 100644 index 0000000..8b99b0f --- /dev/null +++ b/notifications/templates/notifications/email/notification.html @@ -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 %} + + + + + +
+ {% if club.logo %} + {{ club.name }} + {% else %} + + + + +
+ {{ club.initials }} +
+ {% endif %} +
+ {{ club.name }} +
+{% endblock header %} + +{% block content %} +

{{ notification.title }}

+
{{ notification.body|linebreaks }}
+{% endblock content %} + +{% block footer %} + {% 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 %}

+{% endblock footer %} diff --git a/notifications/templates/notifications/email/notification.txt b/notifications/templates/notifications/email/notification.txt new file mode 100644 index 0000000..c902eec --- /dev/null +++ b/notifications/templates/notifications/email/notification.txt @@ -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 %} diff --git a/notifications/templates/notifications/email/notification_subject.txt b/notifications/templates/notifications/email/notification_subject.txt new file mode 100644 index 0000000..9778f15 --- /dev/null +++ b/notifications/templates/notifications/email/notification_subject.txt @@ -0,0 +1 @@ +{% load i18n %}{% blocktrans with club=club.name title=notification.title %}{{ club }} — {{ title }}{% endblocktrans %} diff --git a/notifications/tests.py b/notifications/tests.py new file mode 100644 index 0000000..430c281 --- /dev/null +++ b/notifications/tests.py @@ -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() diff --git a/pyproject.toml b/pyproject.toml index eacda46..7b56b4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" } diff --git a/rosterchief/settings.py b/rosterchief/settings.py index 65b222b..31115d0 100644 --- a/rosterchief/settings.py +++ b/rosterchief/settings.py @@ -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.