Add a news app: coach_manager authoring, team tagging, photos, editor release flow

News and NewsPhoto (team-tagged instead of categorised, one photo taggable
as main via a partial unique constraint), gated per club/services/access.py:
any current-season coach_manager, EDITOR, or ADMIN can draft and edit a news
item; only EDITOR/ADMIN can publish it, or edit it once it's live. Publishing
takes a date so it can be scheduled ahead of time rather than only right now.

Authoring/release only for now -- no member-facing reading page or public API
yet, the visibility field (internal/external/both) is there for when those land.
This commit is contained in:
2026-08-03 18:46:37 +02:00
parent ce35348b31
commit 3e0c63ec36
20 changed files with 985 additions and 19 deletions

100
news/tests.py Normal file
View File

@@ -0,0 +1,100 @@
import datetime
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 .models import News, NewsPhoto
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_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)
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)