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:
0
news/__init__.py
Normal file
0
news/__init__.py
Normal file
23
news/admin.py
Normal file
23
news/admin.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import News, NewsPhoto
|
||||
|
||||
|
||||
class NewsPhotoInline(admin.TabularInline):
|
||||
model = NewsPhoto
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(News)
|
||||
class NewsAdmin(admin.ModelAdmin):
|
||||
list_display = ["title", "club", "status", "visibility", "created_by"]
|
||||
list_filter = ["club", "status", "visibility"]
|
||||
search_fields = ["title"]
|
||||
raw_id_fields = ["created_by"]
|
||||
inlines = [NewsPhotoInline]
|
||||
|
||||
|
||||
@admin.register(NewsPhoto)
|
||||
class NewsPhotoAdmin(admin.ModelAdmin):
|
||||
list_display = ["news_item", "is_main", "ordering"]
|
||||
list_filter = ["is_main"]
|
||||
5
news/apps.py
Normal file
5
news/apps.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class NewsConfig(AppConfig):
|
||||
name = "news"
|
||||
67
news/migrations/0001_initial.py
Normal file
67
news/migrations/0001_initial.py
Normal file
@@ -0,0 +1,67 @@
|
||||
# Generated by Django 6.0.6 on 2026-08-03 16:22
|
||||
|
||||
import django.db.models.deletion
|
||||
import news.models
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('club', '0017_club_season_duration_months_club_season_start'),
|
||||
('members', '0003_family_created_family_modified_member_created_and_more'),
|
||||
('teams', '0006_alter_position_ordering'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='News',
|
||||
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')),
|
||||
('slug', models.SlugField(blank=True, max_length=255, verbose_name='slug')),
|
||||
('body', models.TextField(verbose_name='body')),
|
||||
('visibility', models.CharField(choices=[('internal', 'internal'), ('external', 'external'), ('both', 'both')], default='internal', max_length=10, verbose_name='visibility')),
|
||||
('status', models.CharField(choices=[('draft', 'draft'), ('published', 'published')], default='draft', max_length=10, verbose_name='status')),
|
||||
('published_at', models.DateTimeField(blank=True, help_text='When this goes live. In the future to schedule it ahead of time.', null=True, verbose_name='publish date')),
|
||||
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='news_items', to='members.member', verbose_name='created by')),
|
||||
('teams', models.ManyToManyField(blank=True, help_text='Leave empty for club-wide news.', related_name='news_items', to='teams.team', verbose_name='teams')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'news item',
|
||||
'verbose_name_plural': 'news items',
|
||||
'ordering': ['-created'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='NewsPhoto',
|
||||
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)),
|
||||
('image', models.ImageField(upload_to=news.models.news_photo_path, verbose_name='image')),
|
||||
('is_main', models.BooleanField(default=False, verbose_name='main picture')),
|
||||
('ordering', models.PositiveSmallIntegerField(default=0, verbose_name='ordering')),
|
||||
('news_item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='news.news', verbose_name='news item')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'news photo',
|
||||
'verbose_name_plural': 'news photos',
|
||||
'ordering': ['ordering', 'created'],
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='news',
|
||||
constraint=models.UniqueConstraint(fields=('club', 'slug'), name='unique_news_slug_per_club'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='newsphoto',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('is_main', True)), fields=('news_item',), name='unique_main_photo_per_news_item'),
|
||||
),
|
||||
]
|
||||
0
news/migrations/__init__.py
Normal file
0
news/migrations/__init__.py
Normal file
81
news/models.py
Normal file
81
news/models.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from members.models import Member
|
||||
from rosterchief.base import ClubScopedModel, UUIDModel
|
||||
from teams.models import Team
|
||||
|
||||
|
||||
def news_photo_path(instance, filename):
|
||||
return f"clubs/{instance.news_item.club.slug}/news/{instance.news_item.slug}/{filename}"
|
||||
|
||||
|
||||
class News(ClubScopedModel):
|
||||
class Visibility(models.TextChoices):
|
||||
INTERNAL = "internal", _("internal")
|
||||
EXTERNAL = "external", _("external")
|
||||
BOTH = "both", _("both")
|
||||
|
||||
class Status(models.TextChoices):
|
||||
DRAFT = "draft", _("draft")
|
||||
PUBLISHED = "published", _("published")
|
||||
|
||||
title = models.CharField(_("title"), max_length=255)
|
||||
slug = models.SlugField(_("slug"), max_length=255, blank=True)
|
||||
slug_source = "title"
|
||||
|
||||
body = models.TextField(_("body"))
|
||||
teams = models.ManyToManyField(Team, related_name="news_items", blank=True, verbose_name=_("teams"), help_text=_("Leave empty for club-wide news."))
|
||||
|
||||
visibility = models.CharField(_("visibility"), max_length=10, choices=Visibility.choices, default=Visibility.INTERNAL)
|
||||
status = models.CharField(_("status"), max_length=10, choices=Status.choices, default=Status.DRAFT)
|
||||
published_at = models.DateTimeField(_("publish date"), null=True, blank=True, help_text=_("When this goes live. In the future to schedule it ahead of time."))
|
||||
|
||||
created_by = models.ForeignKey(Member, on_delete=models.SET_NULL, null=True, blank=True, related_name="news_items", verbose_name=_("created by"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("news item")
|
||||
verbose_name_plural = _("news items")
|
||||
ordering = ["-created"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["club", "slug"], name="unique_news_slug_per_club"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
def publish(self, at=None):
|
||||
self.status, self.published_at = self.Status.PUBLISHED, at or timezone.now()
|
||||
self.save(update_fields=["status", "published_at"])
|
||||
|
||||
def unpublish(self):
|
||||
self.status, self.published_at = self.Status.DRAFT, None
|
||||
self.save(update_fields=["status", "published_at"])
|
||||
|
||||
@property
|
||||
def is_scheduled(self):
|
||||
"""PUBLISHED (past the editor's release gate) but its publish date hasn't
|
||||
arrived yet -- not actually live. A later consumer (member feed, public
|
||||
API) filters `status=PUBLISHED, published_at__lte=now()`; nothing here
|
||||
needs a cron job to "flip" it at the scheduled moment."""
|
||||
return self.status == self.Status.PUBLISHED and self.published_at is not None and self.published_at > timezone.now()
|
||||
|
||||
|
||||
class NewsPhoto(UUIDModel):
|
||||
news_item = models.ForeignKey(News, on_delete=models.CASCADE, related_name="photos", verbose_name=_("news item"))
|
||||
image = models.ImageField(_("image"), upload_to=news_photo_path)
|
||||
is_main = models.BooleanField(_("main picture"), default=False)
|
||||
ordering = models.PositiveSmallIntegerField(_("ordering"), default=0)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("news photo")
|
||||
verbose_name_plural = _("news photos")
|
||||
ordering = ["ordering", "created"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["news_item"], condition=Q(is_main=True), name="unique_main_photo_per_news_item"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.news_item} — photo"
|
||||
100
news/tests.py
Normal file
100
news/tests.py
Normal 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)
|
||||
Reference in New Issue
Block a user