A family with several kids on a news item's audience now gets one email/one Notification, not one per child -- deduped on resolved recipient emails, so overlapping (not just identical) guardian sets still collapse correctly. Event notifications are untouched: each child still needs their own reply. Home's "Club news" teaser is also decoupled from the person-scope switcher -- a parent with no team of their own still sees their kids' team news when they've picked their own "Me" chip, not just when "All" is selected. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
305 lines
14 KiB
Python
305 lines
14 KiB
Python
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, ClubMembership, ClubRole, Season
|
|
from members.models import Family, FamilyMembership, Member
|
|
from notifications.models import Notification
|
|
from teams.models import Position, Team, TeamMembership
|
|
|
|
from .models import News, NewsPhoto
|
|
from .services import notify_editors_of_pending_review
|
|
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):
|
|
image = SimpleUploadedFile("photo.jpg", b"fake-image-bytes", content_type="image/jpeg")
|
|
return NewsPhoto.objects.create(news_item=news_item, image=image, is_main=is_main)
|
|
|
|
|
|
class NewsModelTests(TestCase):
|
|
def setUp(self):
|
|
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
|
|
|
def test_slug_is_derived_from_title(self):
|
|
item = News.objects.create(club=self.club, title="Season Kickoff", body="Body text.")
|
|
|
|
self.assertEqual(item.slug, "season-kickoff")
|
|
|
|
def test_slug_is_unique_per_club_not_globally(self):
|
|
News.objects.create(club=self.club, title="Season Kickoff", body="First.")
|
|
second = News.objects.create(club=self.club, title="Season Kickoff", body="Second.")
|
|
|
|
self.assertEqual(second.slug, "season-kickoff-2")
|
|
|
|
def test_two_clubs_can_share_the_same_slug(self):
|
|
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
|
|
News.objects.create(club=self.club, title="Season Kickoff", body="First.")
|
|
|
|
other = News.objects.create(club=other_club, title="Season Kickoff", body="Other club.")
|
|
|
|
self.assertEqual(other.slug, "season-kickoff")
|
|
|
|
def test_defaults_to_draft_and_internal(self):
|
|
item = News.objects.create(club=self.club, title="Draft item", body="Body.")
|
|
|
|
self.assertEqual(item.status, News.Status.DRAFT)
|
|
self.assertEqual(item.visibility, News.Visibility.INTERNAL)
|
|
self.assertIsNone(item.published_at)
|
|
|
|
def test_publish_defaults_the_publish_date_to_now(self):
|
|
item = News.objects.create(club=self.club, title="Item", body="Body.")
|
|
|
|
item.publish()
|
|
|
|
self.assertEqual(item.status, News.Status.PUBLISHED)
|
|
self.assertIsNotNone(item.published_at)
|
|
self.assertFalse(item.is_scheduled)
|
|
|
|
def test_publish_accepts_a_future_date_and_is_scheduled(self):
|
|
item = News.objects.create(club=self.club, title="Item", body="Body.")
|
|
future = timezone.now() + datetime.timedelta(days=7)
|
|
|
|
item.publish(at=future)
|
|
|
|
self.assertEqual(item.status, News.Status.PUBLISHED)
|
|
self.assertEqual(item.published_at, future)
|
|
self.assertTrue(item.is_scheduled)
|
|
|
|
def test_submit_for_review_moves_a_draft_to_pending_review(self):
|
|
item = News.objects.create(club=self.club, title="Item", body="Body.")
|
|
|
|
item.submit_for_review()
|
|
|
|
self.assertEqual(item.status, News.Status.PENDING_REVIEW)
|
|
|
|
def test_unpublish_clears_the_publish_date(self):
|
|
item = News.objects.create(club=self.club, title="Item", body="Body.")
|
|
item.publish()
|
|
|
|
item.unpublish()
|
|
|
|
self.assertEqual(item.status, News.Status.DRAFT)
|
|
self.assertIsNone(item.published_at)
|
|
|
|
def test_effective_english_falls_back_to_the_original_when_blank(self):
|
|
item = News.objects.create(club=self.club, title="Seizoensstart", body="We beginnen het seizoen.")
|
|
|
|
self.assertEqual(item.effective_title_en, "Seizoensstart")
|
|
self.assertEqual(item.effective_body_en, "We beginnen het seizoen.")
|
|
|
|
def test_effective_english_uses_its_own_text_when_set(self):
|
|
item = News.objects.create(club=self.club, title="Seizoensstart", body="We beginnen het seizoen.", title_en="Season kickoff", body_en="We're starting the season.")
|
|
|
|
self.assertEqual(item.effective_title_en, "Season kickoff")
|
|
self.assertEqual(item.effective_body_en, "We're starting the season.")
|
|
|
|
def test_effective_english_stays_current_after_the_original_changes(self):
|
|
# Read-time fallback, not copy-on-save: editing the Dutch text later must
|
|
# not leave a stale English "copy" behind.
|
|
item = News.objects.create(club=self.club, title="Seizoensstart", body="We beginnen het seizoen.")
|
|
|
|
item.title, item.body = "Nieuwe titel", "Nieuwe tekst."
|
|
item.save(update_fields=["title", "body"])
|
|
|
|
self.assertEqual(item.effective_title_en, "Nieuwe titel")
|
|
self.assertEqual(item.effective_body_en, "Nieuwe tekst.")
|
|
|
|
|
|
class NewsPhotoModelTests(TestCase):
|
|
def setUp(self):
|
|
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
|
self.item = News.objects.create(club=self.club, title="Match report", body="Body.")
|
|
|
|
def test_a_second_main_photo_is_rejected_at_the_database_level(self):
|
|
make_photo(self.item, is_main=True)
|
|
|
|
with self.assertRaises(IntegrityError):
|
|
make_photo(self.item, is_main=True)
|
|
|
|
def test_two_non_main_photos_are_fine(self):
|
|
make_photo(self.item, is_main=False)
|
|
make_photo(self.item, is_main=False)
|
|
|
|
self.assertEqual(self.item.photos.count(), 2)
|
|
|
|
def test_two_different_news_items_can_each_have_a_main_photo(self):
|
|
other_item = News.objects.create(club=self.club, title="Other item", body="Body.")
|
|
|
|
make_photo(self.item, is_main=True)
|
|
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_siblings_sharing_a_guardian_are_notified_once(self):
|
|
# Two children, no login of their own, both reachable only through the
|
|
# same parent -- one Notification/one email for the family, not two.
|
|
parent_user = User.objects.create_user(email="parent@example.com", password="pw-secret-123")
|
|
parent = Member.objects.create(first_name="Pat", last_name="Parent", email="parent@example.com", user=parent_user)
|
|
family = Family.objects.create(name="Parent family")
|
|
FamilyMembership.objects.create(family=family, member=parent, role=FamilyMembership.FamilyRole.PARENT)
|
|
child_a = Member.objects.create(first_name="Ana", last_name="Parent")
|
|
child_b = Member.objects.create(first_name="Ben", last_name="Parent")
|
|
for child in (child_a, child_b):
|
|
FamilyMembership.objects.create(family=family, member=child, role=FamilyMembership.FamilyRole.CHILD)
|
|
ClubMembership.objects.create(club=self.club, member=child, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
|
|
news_item = News.objects.create(club=self.club, title="Club news", body="Body.", status=News.Status.PUBLISHED, published_at=timezone.now())
|
|
|
|
result = notify_news_published(news_item.pk)
|
|
|
|
self.assertEqual(Notification.objects.filter(club=self.club, title="Club news").count(), 1)
|
|
self.assertIn("Notified 1", result)
|
|
self.assertEqual(len(mail.outbox), 1)
|
|
self.assertEqual(mail.outbox[0].to, ["parent@example.com"])
|
|
|
|
def test_children_with_different_guardians_are_each_notified(self):
|
|
family_one = Family.objects.create(name="First family")
|
|
family_two = Family.objects.create(name="Second family")
|
|
for family_name, family in (("one", family_one), ("two", family_two)):
|
|
parent_user = User.objects.create_user(email=f"parent-{family_name}@example.com", password="pw-secret-123")
|
|
parent = Member.objects.create(first_name=f"Parent{family_name}", last_name="Adult", email=f"parent-{family_name}@example.com", user=parent_user)
|
|
FamilyMembership.objects.create(family=family, member=parent, role=FamilyMembership.FamilyRole.PARENT)
|
|
child = Member.objects.create(first_name=f"Child{family_name}", last_name="Kid")
|
|
FamilyMembership.objects.create(family=family, member=child, role=FamilyMembership.FamilyRole.CHILD)
|
|
ClubMembership.objects.create(club=self.club, member=child, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
|
|
news_item = News.objects.create(club=self.club, title="Club news", body="Body.", status=News.Status.PUBLISHED, published_at=timezone.now())
|
|
|
|
result = notify_news_published(news_item.pk)
|
|
|
|
self.assertEqual(Notification.objects.filter(club=self.club, title="Club news").count(), 2)
|
|
self.assertIn("Notified 2", result)
|
|
self.assertEqual(len(mail.outbox), 2)
|
|
|
|
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)
|
|
|
|
|
|
class NotifyEditorsOfPendingReviewTests(TestCase):
|
|
"""news.services.notify_editors_of_pending_review -- in-app only (no
|
|
email), every ADMIN/EDITOR for the club."""
|
|
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
|
|
|
def make_role(self, first_name, role):
|
|
member = Member.objects.create(first_name=first_name, last_name="Staff")
|
|
ClubRole.objects.create(club=self.club, member=member, role=role)
|
|
return member
|
|
|
|
def test_notifies_admins_and_editors(self):
|
|
admin = self.make_role("Ada", ClubRole.Roles.ADMIN)
|
|
editor = self.make_role("Ed", ClubRole.Roles.EDITOR)
|
|
author = self.make_role("Cara", ClubRole.Roles.MEMBER)
|
|
news_item = News.objects.create(club=self.club, title="Draft item", body="Body.", created_by=author)
|
|
|
|
notify_editors_of_pending_review(news_item)
|
|
|
|
self.assertTrue(Notification.objects.filter(member=admin).exists())
|
|
self.assertTrue(Notification.objects.filter(member=editor).exists())
|
|
self.assertFalse(Notification.objects.filter(member=author).exists())
|
|
|
|
def test_sends_no_email(self):
|
|
self.make_role("Ada", ClubRole.Roles.ADMIN)
|
|
news_item = News.objects.create(club=self.club, title="Draft item", body="Body.")
|
|
|
|
notify_editors_of_pending_review(news_item)
|
|
|
|
self.assertEqual(len(mail.outbox), 0)
|
|
self.assertIsNone(Notification.objects.first().sent_at)
|
|
|
|
def test_the_notification_names_the_author(self):
|
|
self.make_role("Ada", ClubRole.Roles.ADMIN)
|
|
author = Member.objects.create(first_name="Cara", last_name="Coach")
|
|
news_item = News.objects.create(club=self.club, title="Draft item", body="Body.", created_by=author)
|
|
|
|
notify_editors_of_pending_review(news_item)
|
|
|
|
self.assertIn("Cara Coach", Notification.objects.first().body)
|