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

View File

@@ -33,7 +33,7 @@ labels to `known-first-party` in `pyproject.toml` when they land. The target dec
| `club` | **built** | Tenant root, **season**, season-scoped affiliation, club roles | `Club`, `Season` *(planned)*, `ClubMembership`, `ClubRole` *(planned)* | | `club` | **built** | Tenant root, **season**, season-scoped affiliation, club roles | `Club`, `Season` *(planned)*, `ClubMembership`, `ClubRole` *(planned)* |
| `teams` | planned | Teams and season rosters | `Team`, `TeamMembership`, `StaffAssignment` | | `teams` | planned | Teams and season rosters | `Team`, `TeamMembership`, `StaffAssignment` |
| `events` | planned | Training / matches / social events + attendance | `Event`, `Attendance` | | `events` | planned | Training / matches / social events + attendance | `Event`, `Attendance` |
| `news` | planned | Editorial news for the public site | `Article`, `Category` | | `news` | **built** | Club news: coach_manager-authored, editor-released | `News`, `NewsPhoto` |
| `pages` | planned | Flat CMS pages for the public site | `Page` | | `pages` | planned | Flat CMS pages for the public site | `Page` |
| `home` | planned | Homepage composition / featured content | `HomeConfig` (per-club) or config-only | | `home` | planned | Homepage composition / featured content | `HomeConfig` (per-club) or config-only |
| `formbuilder` | planned | Admin-defined dynamic forms + submissions + reporting | `Form`, `Field`, `Submission`, `Answer` | | `formbuilder` | planned | Admin-defined dynamic forms + submissions + reporting | `Form`, `Field`, `Submission`, `Answer` |
@@ -229,6 +229,11 @@ ClubRole(ClubScopedModel) # ClubScopedModel -> carries `club` (§2.
| `TREASURER` | Manage that club's `shop`: products, orders, payments, issue/void invoices. | | `TREASURER` | Manage that club's `shop`: products, orders, payments, issue/void invoices. |
| `BOARD` | Full management of that club: members, roles, all of the above. | | `BOARD` | Full management of that club: members, roles, all of the above. |
`news` is the one place a `ClubRole` and a derived role (`COACH_MANAGER`, see below)
share a single workflow rather than each owning a separate permission: drafting is
open to EDITOR/ADMIN *or* any coach_manager, but only EDITOR/ADMIN may publish —
see §5.4.
`COACH` / `TEAM_MANAGER` are deliberately **not** `ClubRole`s — being a coach is always `COACH` / `TEAM_MANAGER` are deliberately **not** `ClubRole`s — being a coach is always
*of a team*, so it lives on `StaffAssignment` (§5.3). "Is this user a coach at this club?" *of a team*, so it lives on `StaffAssignment` (§5.3). "Is this user a coach at this club?"
= "do they have any `StaffAssignment` on a team in this club?". = "do they have any `StaffAssignment` on a team in this club?".
@@ -418,16 +423,30 @@ service/clean().
### 5.4 `news`, `pages`, `home` (public site / editorial) ### 5.4 `news`, `pages`, `home` (public site / editorial)
``` **`news` is built** (as of the coach_manager-authoring / editor-release-flow work) —
news.Article(ClubScopedModel) # -> carries `club` team-tagged instead of categorised, with a two-step release flow rather than a bare
title, slug (SlugField), body (TextField) `is_published` flag:
excerpt (blank), cover_image (ImageField, null)
author FK members.Member (SET_NULL, null, related_name="articles")
category FK news.Category (SET_NULL, null)
is_published BooleanField; published_at DateTimeField (null)
Meta: unique_together (club, slug); ordering = ["-published_at"]
news.Category(ClubScopedModel): name, slug # Meta: unique_together (club, slug) ```
news.News(ClubScopedModel) # -> carries `club`
title, slug (SlugField, auto from title), body (TextField)
teams M2M teams.Team (blank -- empty means club-wide)
visibility CharField (TextChoices: internal | external | both)
status CharField (TextChoices: draft | published)
published_at DateTimeField (null) -- may be in the future: a *scheduled* release,
not a cron-flipped field (see below)
created_by FK members.Member (SET_NULL, null, related_name="news_items")
Meta: unique_together (club, slug); ordering = ["-created"]
news.NewsPhoto(UUIDModel) # club reached via news_item, not directly scoped
news_item FK news.News (CASCADE, related_name="photos")
image ImageField
is_main BooleanField
ordering PositiveSmallIntegerField
Meta: UniqueConstraint(fields=["news_item"], condition=Q(is_main=True))
-- a partial unique index enforcing "at most one main photo per item"
at the DB level, the same trick teams.Position uses for
management_position_implies_staff_position.
pages.Page(ClubScopedModel) # flat CMS pages: "About", "Contact", ... pages.Page(ClubScopedModel) # flat CMS pages: "About", "Contact", ...
title, slug, body (TextField) title, slug, body (TextField)
@@ -439,12 +458,26 @@ home.HomeConfig(ClubScopedModel) # one row PER CLUB: featured articles/teams,
# (unique_together (club,) — one per tenant). May be config-only. # (unique_together (club,) — one per tenant). May be config-only.
``` ```
- **`Article.author` links to `members.Member`** (decision §7 #5) — attribution is to a - **Authoring vs. releasing are deliberately separate authorities**
(`club/services/access.py::can_add_news`/`can_publish_news`/`can_edit_news`): any
current-season coach_manager (management-position `StaffAssignment`), EDITOR, or
ADMIN can draft a `News` item and edit it while it's a draft; only EDITOR/ADMIN can
move it to `published` (or edit it once it is) — a physio or plain staff member can't
post news, and a coach_manager can't push their own draft live.
- **Scheduling needs no cron job.** `published_at` can be set in the future; `status`
already reads `PUBLISHED` (it passed the editor's release gate) but `News.is_scheduled`
is true until that moment passes. A later public/member-facing consumer just filters
`status=PUBLISHED, published_at__lte=now()` — nothing has to flip a row at the
scheduled instant.
- **`created_by` links to `members.Member`** (decision §7 #5) — attribution is to a
club person, not a raw login; `SET_NULL` so deleting a member doesn't erase their posts. club person, not a raw login; `SET_NULL` so deleting a member doesn't erase their posts.
- `slug`s back clean public URLs and feed `search`; they are **unique per club** (§2.4), so - `slug`s back clean public URLs and feed `search`; they are **unique per club** (§2.4), so
two clubs can both have `/news/season-kickoff`. Resolve within the request's club. two clubs can both have `/news/season-kickoff`. Resolve within the request's club.
- `cover_image` / hero images use `ImageField`**media storage must be configured** (§8). - `visibility` (internal/external/both) is stored and enforced nowhere yet — no
If page/news trees grow, consider a tree library later — start flat. member-facing reading page or public API exists. Both are later work; the field is
there so they don't need a backfill when they land.
- `NewsPhoto.image` / hero images use `ImageField`**media storage must be configured**
(§8). If page/news trees grow, consider a tree library later — start flat.
### 5.5 `search` ### 5.5 `search`

View File

@@ -1,7 +1,7 @@
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.http import Http404 from django.http import Http404
from .services.access import has_management_access, is_club_admin, teams_managed_by from .services.access import can_add_news, can_edit_news, can_publish_news, has_management_access, is_club_admin, teams_managed_by
class ClubStaffRequiredMixin(LoginRequiredMixin, UserPassesTestMixin): class ClubStaffRequiredMixin(LoginRequiredMixin, UserPassesTestMixin):
@@ -49,3 +49,31 @@ class TeamManagerRequiredMixin(ClubStaffRequiredMixin):
if is_club_admin(user, club): if is_club_admin(user, club):
return True return True
return teams_managed_by(user, club).filter(pk=self.get_team().pk).exists() return teams_managed_by(user, club).filter(pk=self.get_team().pk).exists()
class NewsAuthorRequiredMixin(ClubStaffRequiredMixin):
"""ADMIN, EDITOR, or a current-season coach_manager -- who's trusted to
author club content in the first place (creating a draft)."""
def test_func(self):
return can_add_news(self.request.user, self.request.club)
class NewsPublisherRequiredMixin(ClubStaffRequiredMixin):
"""ADMIN/EDITOR only -- the release-flow gate for pushing a news item live
(or pulling it back)."""
def test_func(self):
return can_publish_news(self.request.user, self.request.club)
class NewsEditRequiredMixin(ClubStaffRequiredMixin):
"""Whoever may edit *this* news item right now: broad while it's a draft,
editor/admin-only once published. ``self.get_news_item()`` must return the
News the view acts on before ``test_func`` runs."""
def get_news_item(self):
raise NotImplementedError("Subclasses must return the News item this view acts on.")
def test_func(self):
return can_edit_news(self.request.user, self.get_news_item())

View File

@@ -151,3 +151,22 @@ def can_edit_event(user: User, event: Event) -> bool:
def can_manage_shop(user: User, club: Club) -> bool: def can_manage_shop(user: User, club: Club) -> bool:
return is_club_admin(user, club) return is_club_admin(user, club)
def can_add_news(user: User, club: Club) -> bool:
"""ADMIN, EDITOR, or a current-season coach_manager -- who's trusted to
author club content, not just anyone on staff (a physio shouldn't post news)."""
return is_club_admin(user, club) or has_club_role(user, club, ClubRole.Roles.EDITOR) or is_coach_manager(user, club)
def can_publish_news(user: User, club: Club) -> bool:
"""Only ADMIN/EDITOR may push a news item live -- the release-flow gate."""
return is_club_admin(user, club) or has_club_role(user, club, ClubRole.Roles.EDITOR)
def can_edit_news(user: User, news_item) -> bool:
"""Broad while it's a draft (anyone who could create one); editor/admin-only
once published -- an editor is accountable for what's actually live."""
if news_item.status == news_item.Status.PUBLISHED:
return can_publish_news(user, news_item.club)
return can_add_news(user, news_item.club)

View File

@@ -3,9 +3,11 @@ admin-only sections (seasons, positions, roles, shop, forms) from plain staff.
The underlying views are gated regardless (``ClubAdminRequiredMixin``) -- this is The underlying views are gated regardless (``ClubAdminRequiredMixin``) -- this is
purely so the nav doesn't show a link a coach or manager can't actually follow. purely so the nav doesn't show a link a coach or manager can't actually follow.
Same reasoning for ``news_permissions`` below, gating just the "New news item"
action rather than the whole section (``NewsAuthorRequiredMixin``/``can_add_news``).
""" """
from club.services.access import has_management_access, is_club_admin from club.services.access import can_add_news, has_management_access, is_club_admin
#: Every management URL name, mapped to the nav item it should light up -- #: Every management URL name, mapped to the nav item it should light up --
#: management/templates/management/_nav_items.html compares against this. #: management/templates/management/_nav_items.html compares against this.
@@ -47,6 +49,15 @@ _NAV_SECTIONS = {
"team_detail": "team_list", "team_detail": "team_list",
"roster_list": "roster_list", "roster_list": "roster_list",
"staff_list": "staff_list", "staff_list": "staff_list",
"news_list": "news_list",
"news_create": "news_list",
"news_detail": "news_list",
"news_update": "news_list",
"news_publish": "news_list",
"news_unpublish": "news_list",
"news_photo_upload": "news_list",
"news_photo_set_main": "news_list",
"news_photo_delete": "news_list",
"event_list": "event_list", "event_list": "event_list",
"event_series_list": "event_series_list", "event_series_list": "event_series_list",
"location_list": "location_list", "location_list": "location_list",
@@ -88,3 +99,13 @@ def management_link(request):
return {"has_management_access": False} return {"has_management_access": False}
return {"has_management_access": has_management_access(request.user, club)} return {"has_management_access": has_management_access(request.user, club)}
def news_permissions(request):
"""Whether the "New news item" action should show -- viewing the News
section itself is open to any staff, same as Roster/Staff."""
club = getattr(request, "club", None)
if club is None or not request.user.is_authenticated:
return {"can_add_news": False}
return {"can_add_news": can_add_news(request.user, club)}

View File

@@ -2,11 +2,13 @@ from decimal import Decimal
from django import forms from django import forms
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from club.models import ClubMembership, ClubRole, FeePayment from club.models import ClubMembership, ClubRole, FeePayment
from members.models import Family, FamilyMembership, Member from members.models import Family, FamilyMembership, Member
from members.services.family import find_member_by_email from members.services.family import find_member_by_email
from news.models import News
from teams.models import Position, Team from teams.models import Position, Team
User = get_user_model() User = get_user_model()
@@ -146,6 +148,53 @@ class ClubMembershipForm(forms.ModelForm):
fields = ["license", "status", "fee_status", "fee_amount"] fields = ["license", "status", "fee_status", "fee_amount"]
class NewsForm(forms.ModelForm):
"""Title/teams/visibility/body only -- status and published_at are never
directly editable, only through the publish/unpublish actions."""
class Meta:
model = News
fields = ["title", "teams", "visibility", "body"]
widgets = {"teams": forms.CheckboxSelectMultiple, "body": forms.Textarea(attrs={"rows": 8})}
def __init__(self, *args, club=None, **kwargs):
super().__init__(*args, **kwargs)
self.fields["teams"].queryset = Team.objects.filter(club=club)
class MultipleFileInput(forms.ClearableFileInput):
allow_multiple_selected = True
class MultipleFileField(forms.FileField):
"""Django's own documented recipe for a multi-file upload field: the plain
FileField only ever picks up one of several selected files, so clean() has
to iterate the list itself instead."""
def __init__(self, *args, **kwargs):
kwargs.setdefault("widget", MultipleFileInput(attrs={"multiple": True}))
super().__init__(*args, **kwargs)
def clean(self, data, initial=None):
single_file_clean = super().clean
if isinstance(data, (list, tuple)):
return [single_file_clean(item, initial) for item in data]
return [single_file_clean(data, initial)] if data else []
class NewsPhotoUploadForm(forms.Form):
images = MultipleFileField(label=_("Photos"))
class NewsPublishForm(forms.Form):
published_at = forms.DateTimeField(
label=_("Publish date"),
initial=timezone.now,
widget=forms.DateTimeInput(attrs={"type": "datetime-local"}),
help_text=_("Leave as now to publish immediately, or pick a future date/time to schedule it."),
)
class RecordFeePaymentForm(forms.Form): class RecordFeePaymentForm(forms.Form):
"""Money received against one membership's fee -- see club.services.fees.record_payment. """Money received against one membership's fee -- see club.services.fees.record_payment.
Reusable for any amount, partial or the exact remaining balance; "Mark fully Reusable for any amount, partial or the exact remaining balance; "Mark fully

View File

@@ -31,6 +31,9 @@
<li><a class="{% if nav == 'roster_list' %}menu-active{% endif %}" href="{% url 'management:roster_list' %}">{% lucide "clipboard-list" size=16 %} {% trans "Roster" %}</a></li> <li><a class="{% if nav == 'roster_list' %}menu-active{% endif %}" href="{% url 'management:roster_list' %}">{% lucide "clipboard-list" size=16 %} {% trans "Roster" %}</a></li>
<li><a class="{% if nav == 'staff_list' %}menu-active{% endif %}" href="{% url 'management:staff_list' %}">{% lucide "hard-hat" size=16 %} {% trans "Staff" %}</a></li> <li><a class="{% if nav == 'staff_list' %}menu-active{% endif %}" href="{% url 'management:staff_list' %}">{% lucide "hard-hat" size=16 %} {% trans "Staff" %}</a></li>
<li class="menu-title">{% trans "News" %}</li>
<li><a class="{% if nav == 'news_list' %}menu-active{% endif %}" href="{% url 'management:news_list' %}">{% lucide "newspaper" size=16 %} {% trans "News" %}</a></li>
<li class="menu-title">{% trans "Calendar" %}</li> <li class="menu-title">{% trans "Calendar" %}</li>
<li><a class="{% if nav == 'event_list' %}menu-active{% endif %}" href="{% url 'management:event_list' %}">{% lucide "calendar" size=16 %} {% trans "Events" %}</a></li> <li><a class="{% if nav == 'event_list' %}menu-active{% endif %}" href="{% url 'management:event_list' %}">{% lucide "calendar" size=16 %} {% trans "Events" %}</a></li>
<li><a class="{% if nav == 'event_series_list' %}menu-active{% endif %}" href="{% url 'management:event_series_list' %}">{% lucide "repeat" size=16 %} {% trans "Event series" %}</a></li> <li><a class="{% if nav == 'event_series_list' %}menu-active{% endif %}" href="{% url 'management:event_series_list' %}">{% lucide "repeat" size=16 %} {% trans "Event series" %}</a></li>

View File

@@ -0,0 +1,102 @@
{% extends "management/base.html" %}
{% load i18n lucide ui %}
{% block heading %}{{ news_item.title }}{% endblock heading %}
{% block subheading %}
{% if news_item.status == "draft" %}
{% trans "Draft" %}
{% elif news_item.is_scheduled %}
{% blocktrans with date=news_item.published_at %}Scheduled for {{ date }}{% endblocktrans %}
{% else %}
{% blocktrans with date=news_item.published_at %}Published {{ date }}{% endblocktrans %}
{% endif %}
· {{ news_item.get_visibility_display }}
{% endblock subheading %}
{% block actions %}
{% if can_edit %}
<a class="btn btn-outline btn-neutral gap-2" href="{% url 'management:news_update' news_item.pk %}">{% lucide "pencil" size=16 %} {% trans "Edit" %}</a>
{% endif %}
{% if can_publish %}
{% if news_item.status == "draft" %}
<button class="btn btn-primary gap-2" type="button" onclick="document.getElementById('publish_modal').showModal()">{% lucide "upload" size=16 %} {% trans "Publish" %}</button>
{% else %}
<button class="btn btn-outline btn-warning gap-2" type="button" onclick="document.getElementById('unpublish_modal').showModal()">{% lucide "eye-off" size=16 %} {% trans "Unpublish" %}</button>
{% endif %}
{% endif %}
{% endblock actions %}
{% block panel %}
<div class="card bg-base-100 shadow">
<div class="card-body">
{% if news_item.teams.all %}
<div class="flex flex-wrap gap-2 mb-2">
{% for team in news_item.teams.all %}
<span class="badge badge-neutral">{{ team.short_name }}</span>
{% endfor %}
</div>
{% endif %}
<p class="whitespace-pre-line">{{ news_item.body }}</p>
</div>
</div>
<div class="card bg-base-100 shadow mt-4">
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-base">{% trans "Photos" %}</h2>
{% if can_edit %}
<button class="btn btn-outline btn-neutral btn-sm gap-2" type="button" onclick="document.getElementById('add_photos_modal').showModal()">{% lucide "image-plus" size=14 %} {% trans "Add photos" %}</button>
{% endif %}
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mt-2">
{% for photo in news_item.photos.all %}
<div class="relative">
<img class="rounded-box aspect-square object-cover w-full" src="{{ photo.image.url }}" alt="">
{% if photo.is_main %}
<span class="badge badge-success badge-sm absolute top-1 left-1">{% trans "Main" %}</span>
{% endif %}
{% if can_edit %}
<div class="flex gap-1 mt-1">
{% if not photo.is_main %}
<form method="post" action="{% url 'management:news_photo_set_main' news_item.pk photo.pk %}">
{% csrf_token %}
<button class="btn btn-outline btn-neutral btn-xs gap-1" type="submit">{% lucide "star" size=12 %} {% trans "Set main" %}</button>
</form>
{% endif %}
<button class="btn btn-outline btn-error btn-xs gap-1" type="button" onclick="document.getElementById('{{ photo.pk|dom_id:"delete_photo_modal" }}').showModal()">{% lucide "trash-2" size=12 %} {% trans "Delete" %}</button>
</div>
{% url 'management:news_photo_delete' news_item.pk photo.pk as delete_photo_url %}
{% trans "Delete photo" as delete_photo_title %}
{% trans "This photo will be permanently removed." as delete_photo_body %}
{% trans "Delete" as delete_label %}
{% include "controlpanel/_confirm_modal.html" with modal_id=photo.pk|dom_id:"delete_photo_modal" title=delete_photo_title body=delete_photo_body action_url=delete_photo_url submit_label=delete_label submit_icon="trash-2" %}
{% endif %}
</div>
{% empty %}
<p class="opacity-60 col-span-full">{% trans "No photos yet." %}</p>
{% endfor %}
</div>
</div>
</div>
{% if can_edit %}
{% trans "Add photos" as add_photos_label %}
{% url 'management:news_photo_upload' news_item.pk as add_photos_url %}
{% include "controlpanel/_modal_form.html" with modal_id="add_photos_modal" title=add_photos_label form=photo_upload_form action_url=add_photos_url submit_label=add_photos_label submit_icon="image-plus" %}
{% endif %}
{% if can_publish %}
{% if news_item.status == "draft" %}
{% trans "Publish" as publish_label %}
{% url 'management:news_publish' news_item.pk as publish_url %}
{% trans "Leave as now to publish immediately, or pick a future date/time to schedule it." as publish_blurb %}
{% include "controlpanel/_modal_form.html" with modal_id="publish_modal" title=publish_label form=publish_form action_url=publish_url submit_label=publish_label submit_icon="upload" blurb=publish_blurb %}
{% else %}
{% trans "Unpublish" as unpublish_label %}
{% blocktrans asvar unpublish_body %}This pulls “{{ news_item }}” back to a draft. It won't be visible anywhere until it's published again.{% endblocktrans %}
{% url 'management:news_unpublish' news_item.pk as unpublish_url %}
{% include "controlpanel/_confirm_modal.html" with modal_id="unpublish_modal" title=unpublish_label body=unpublish_body action_url=unpublish_url submit_label=unpublish_label submit_icon="eye-off" %}
{% endif %}
{% endif %}
{% endblock panel %}

View File

@@ -0,0 +1,31 @@
{% extends "management/base.html" %}
{% load i18n lucide ui %}
{% block heading %}{% if update_view %}{% blocktrans %}Edit {{ object }}{% endblocktrans %}{% else %}{% trans "New news item" %}{% endif %}{% endblock heading %}
{% block panel %}
<div class="card w-full bg-base-100 shadow">
<div class="card-body">
<form method="post">
{% csrf_token %}
{% for error in form.non_field_errors %}
<div class="alert alert-error my-2">
<span>{{ error }}</span>
</div>
{% endfor %}
<div class="grid grid-cols-1 gap-4">
{% for field in form %}
{% form_field field %}
{% endfor %}
</div>
<div class="card-actions justify-start pt-2 mt-2">
<a class="btn btn-outline btn-neutral gap-2" href="{% if update_view %}{% url "management:news_detail" object.pk %}{% else %}{% url "management:news_list" %}{% endif %}">{% lucide "arrow-left" size=16 %} {% trans "Cancel" %}</a>
<button class="btn btn-primary gap-2" type="submit">{% lucide "save" size=16 %} {% trans "Save" %}</button>
</div>
</form>
</div>
</div>
{% endblock panel %}

View File

@@ -0,0 +1,57 @@
{% extends "management/base.html" %}
{% load i18n lucide %}
{% block heading %}{% trans "News" %}{% endblock heading %}
{% block actions %}
{% if can_add_news %}
<a class="btn btn-primary gap-2" href="{% url 'management:news_create' %}">{% lucide "plus" size=16 %} {% trans "New news item" %}</a>
{% endif %}
{% endblock actions %}
{% block panel %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>{% trans "Title" %}</th>
<th>{% trans "Teams" %}</th>
<th>{% trans "Visibility" %}</th>
<th>{% trans "Status" %}</th>
</tr>
</thead>
<tbody>
{% for news_item in news_items %}
<tr>
<td><a class="link link-hover" href="{% url 'management:news_detail' news_item.pk %}">{{ news_item.title }}</a></td>
<td>
{% for team in news_item.teams.all %}
<span class="badge badge-neutral badge-sm">{{ team.short_name }}</span>
{% empty %}
<span class="opacity-60">{% trans "Club-wide" %}</span>
{% endfor %}
</td>
<td>{{ news_item.get_visibility_display }}</td>
<td>
{% if news_item.status == "draft" %}
<span class="badge badge-neutral badge-sm">{% trans "Draft" %}</span>
{% elif news_item.is_scheduled %}
<span class="badge badge-warning badge-sm">{% blocktrans with date=news_item.published_at %}Scheduled for {{ date }}{% endblocktrans %}</span>
{% else %}
<span class="badge badge-success badge-sm">{% trans "Published" %}</span>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="4" class="text-center opacity-60">{% trans "No news items yet." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock panel %}

View File

@@ -17,6 +17,7 @@ from events.models import Event
from management.bulk_import import TEMPLATE_COLUMNS from management.bulk_import import TEMPLATE_COLUMNS
from management.pdf import PDFExportError, render_pdf from management.pdf import PDFExportError, render_pdf
from members.models import Family, FamilyMembership, Member from members.models import Family, FamilyMembership, Member
from news.models import News, NewsPhoto
from shop.models import Order from shop.models import Order
from teams.models import Position, StaffAssignment, Team, TeamMembership from teams.models import Position, StaffAssignment, Team, TeamMembership
@@ -1710,3 +1711,169 @@ class MemberBulkImportTests(ManagementTestBase):
response = self.club_post("member_import_confirm", {}) response = self.club_post("member_import_confirm", {})
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
class NewsManagementTests(ManagementTestBase):
def setUp(self):
super().setUp()
self.team = Team.objects.create(club=self.club, name="First Team", short_name="1st")
def make_coach_manager(self, email="coach-news@example.com"):
coach_user = User.objects.create_user(email=email, password="pw-secret-123")
coach_member = Member.objects.create(user=coach_user, first_name="Cara", last_name="Coach")
position = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True)
StaffAssignment.objects.create(team=self.team, member=coach_member, season=self.season, position=position)
return coach_user
def make_plain_staff(self, email="physio-news@example.com"):
staff_user = User.objects.create_user(email=email, password="pw-secret-123")
staff_member = Member.objects.create(user=staff_user, first_name="Pat", last_name="Physio")
position = Position.objects.create(club=self.club, name="Physio", short_name="PH", staff_position=True, management_position=False)
StaffAssignment.objects.create(team=self.team, member=staff_member, season=self.season, position=position)
return staff_user
def make_editor(self, email="editor-news@example.com"):
editor_user = User.objects.create_user(email=email, password="pw-secret-123")
editor_member = Member.objects.create(user=editor_user, first_name="Eve", last_name="Editor")
ClubMembership.objects.create(club=self.club, member=editor_member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
ClubRole.objects.filter(club=self.club, member=editor_member).update(role=ClubRole.Roles.EDITOR)
enrol_mfa(editor_user) # ClubRole ADMIN/EDITOR requires a second factor; StaffAssignment-only doesn't.
return editor_user
def test_list_is_scoped_to_the_club(self):
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
News.objects.create(club=other_club, title="Rival news", body="Body.")
self.client.force_login(self.admin_user)
response = self.club_get("news_list")
self.assertNotContains(response, "Rival news")
def test_a_coach_manager_can_create_a_draft(self):
self.client.force_login(self.make_coach_manager())
response = self.club_post("news_create", {"title": "Season Kickoff", "body": "Big news.", "visibility": News.Visibility.INTERNAL, "teams": [str(self.team.pk)]})
item = News.objects.get(club=self.club, title="Season Kickoff")
self.assertRedirects(response, reverse("management:news_detail", args=[item.pk]))
self.assertEqual(item.status, News.Status.DRAFT)
def test_plain_staff_cannot_create_news(self):
self.client.force_login(self.make_plain_staff())
response = self.club_post("news_create", {"title": "Not allowed", "body": "Body.", "visibility": News.Visibility.INTERNAL})
self.assertEqual(response.status_code, 403)
self.assertFalse(News.objects.filter(club=self.club, title="Not allowed").exists())
def test_coach_manager_cannot_publish(self):
item = News.objects.create(club=self.club, title="Draft item", body="Body.")
self.client.force_login(self.make_coach_manager())
response = self.club_post("news_publish", {"published_at": "2026-08-10T10:00"}, item.pk)
self.assertEqual(response.status_code, 403)
item.refresh_from_db()
self.assertEqual(item.status, News.Status.DRAFT)
def test_editor_can_publish(self):
item = News.objects.create(club=self.club, title="Draft item", body="Body.")
self.client.force_login(self.make_editor())
self.club_post("news_publish", {"published_at": "2026-08-10T10:00"}, item.pk)
item.refresh_from_db()
self.assertEqual(item.status, News.Status.PUBLISHED)
def test_publishing_with_a_future_date_leaves_it_scheduled(self):
item = News.objects.create(club=self.club, title="Draft item", body="Body.")
self.client.force_login(self.make_editor())
future = timezone.now() + datetime.timedelta(days=7)
self.club_post("news_publish", {"published_at": future.strftime("%Y-%m-%dT%H:%M")}, item.pk)
item.refresh_from_db()
self.assertTrue(item.is_scheduled)
def test_publishing_with_now_makes_it_live_immediately(self):
item = News.objects.create(club=self.club, title="Draft item", body="Body.")
self.client.force_login(self.make_editor())
self.club_post("news_publish", {"published_at": timezone.now().strftime("%Y-%m-%dT%H:%M")}, item.pk)
item.refresh_from_db()
self.assertFalse(item.is_scheduled)
def test_unpublishing_reverts_to_draft(self):
item = News.objects.create(club=self.club, title="Live item", body="Body.")
item.publish()
self.client.force_login(self.make_editor())
self.club_post("news_unpublish", {}, item.pk)
item.refresh_from_db()
self.assertEqual(item.status, News.Status.DRAFT)
self.assertIsNone(item.published_at)
def test_a_coach_manager_can_edit_someone_elses_draft(self):
item = News.objects.create(club=self.club, title="Old title", body="Body.")
self.client.force_login(self.make_coach_manager())
self.club_post("news_update", {"title": "New title", "body": "Body.", "visibility": News.Visibility.INTERNAL}, item.pk)
item.refresh_from_db()
self.assertEqual(item.title, "New title")
def test_a_coach_manager_cannot_edit_once_published(self):
item = News.objects.create(club=self.club, title="Old title", body="Body.")
item.publish()
self.client.force_login(self.make_coach_manager())
response = self.club_post("news_update", {"title": "New title", "body": "Body.", "visibility": News.Visibility.INTERNAL}, item.pk)
self.assertEqual(response.status_code, 403)
def test_an_editor_can_still_edit_once_published(self):
item = News.objects.create(club=self.club, title="Old title", body="Body.")
item.publish()
self.client.force_login(self.make_editor())
self.club_post("news_update", {"title": "New title", "body": "Body.", "visibility": News.Visibility.INTERNAL}, item.pk)
item.refresh_from_db()
self.assertEqual(item.title, "New title")
def test_uploading_multiple_photos_creates_one_per_file_and_marks_the_first_main(self):
item = News.objects.create(club=self.club, title="Match report", body="Body.")
self.client.force_login(self.make_coach_manager())
images = [
SimpleUploadedFile("one.jpg", b"fake-bytes-one", content_type="image/jpeg"),
SimpleUploadedFile("two.jpg", b"fake-bytes-two", content_type="image/jpeg"),
]
self.club_post("news_photo_upload", {"images": images}, item.pk)
self.assertEqual(item.photos.count(), 2)
self.assertEqual(item.photos.filter(is_main=True).count(), 1)
def test_set_main_moves_the_main_flag(self):
item = News.objects.create(club=self.club, title="Match report", body="Body.")
first = NewsPhoto.objects.create(news_item=item, image=SimpleUploadedFile("one.jpg", b"one", content_type="image/jpeg"), is_main=True)
second = NewsPhoto.objects.create(news_item=item, image=SimpleUploadedFile("two.jpg", b"two", content_type="image/jpeg"), is_main=False)
self.client.force_login(self.make_coach_manager())
self.club_post("news_photo_set_main", {}, item.pk, second.pk)
first.refresh_from_db()
second.refresh_from_db()
self.assertFalse(first.is_main)
self.assertTrue(second.is_main)
def test_deleting_a_photo_removes_it(self):
item = News.objects.create(club=self.club, title="Match report", body="Body.")
photo = NewsPhoto.objects.create(news_item=item, image=SimpleUploadedFile("one.jpg", b"one", content_type="image/jpeg"))
self.client.force_login(self.make_coach_manager())
self.club_post("news_photo_delete", {}, item.pk, photo.pk)
self.assertFalse(NewsPhoto.objects.filter(pk=photo.pk).exists())

View File

@@ -42,6 +42,16 @@ urlpatterns = [
path("teams/<uuid:pk>/edit/", views.TeamUpdateView.as_view(), name="team_update"), path("teams/<uuid:pk>/edit/", views.TeamUpdateView.as_view(), name="team_update"),
path("roster/", views.RosterListView.as_view(), name="roster_list"), path("roster/", views.RosterListView.as_view(), name="roster_list"),
path("staff/", views.StaffListView.as_view(), name="staff_list"), path("staff/", views.StaffListView.as_view(), name="staff_list"),
# News
path("news/", views.NewsListView.as_view(), name="news_list"),
path("news/new/", views.NewsCreateView.as_view(), name="news_create"),
path("news/<uuid:pk>/", views.NewsDetailView.as_view(), name="news_detail"),
path("news/<uuid:pk>/edit/", views.NewsUpdateView.as_view(), name="news_update"),
path("news/<uuid:pk>/publish/", views.NewsPublishView.as_view(), name="news_publish"),
path("news/<uuid:pk>/unpublish/", views.NewsUnpublishView.as_view(), name="news_unpublish"),
path("news/<uuid:pk>/photos/", views.NewsPhotoUploadView.as_view(), name="news_photo_upload"),
path("news/<uuid:pk>/photos/<uuid:photo_pk>/set-main/", views.NewsPhotoSetMainView.as_view(), name="news_photo_set_main"),
path("news/<uuid:pk>/photos/<uuid:photo_pk>/delete/", views.NewsPhotoDeleteView.as_view(), name="news_photo_delete"),
# Calendar # Calendar
path("events/", views.EventListView.as_view(), name="event_list"), path("events/", views.EventListView.as_view(), name="event_list"),
path("event-series/", views.EventSeriesListView.as_view(), name="event_series_list"), path("event-series/", views.EventSeriesListView.as_view(), name="event_series_list"),

View File

@@ -6,11 +6,12 @@ from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.http import url_has_allowed_host_and_scheme from django.utils.http import url_has_allowed_host_and_scheme
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.utils.translation import ngettext
from django.views.generic import CreateView, DetailView, FormView, ListView, TemplateView, UpdateView, View from django.views.generic import CreateView, DetailView, FormView, ListView, TemplateView, UpdateView, View
from club.mixins import ClubAdminRequiredMixin, ClubStaffRequiredMixin from club.mixins import ClubAdminRequiredMixin, ClubStaffRequiredMixin, NewsAuthorRequiredMixin, NewsEditRequiredMixin, NewsPublisherRequiredMixin
from club.models import ClubMembership, ClubRole, Season from club.models import ClubMembership, ClubRole, Season
from club.services.access import current_season, members_visible_to from club.services.access import can_edit_news, can_publish_news, current_season, members_visible_to
from club.services.fees import mark_as_paid, record_payment, remaining_balance from club.services.fees import mark_as_paid, record_payment, remaining_balance
from controlpanel.messages import notify from controlpanel.messages import notify
from controlpanel.mixins import RedirectOnInvalidMixin from controlpanel.mixins import RedirectOnInvalidMixin
@@ -20,6 +21,7 @@ from formbuilder.models import Form as FormBuilderForm
from formbuilder.models import Submission from formbuilder.models import Submission
from members.models import Family, FamilyMembership, Member from members.models import Family, FamilyMembership, Member
from members.services.family import add_child_to_family, add_parent_to_family, attach_to_family, detach_from_family, grant_login, register_family from members.services.family import add_child_to_family, add_parent_to_family, attach_to_family, detach_from_family, grant_login, register_family
from news.models import News, NewsPhoto
from shop.models import Discount, Invoice, Order, Product from shop.models import Discount, Invoice, Order, Product
from teams.models import Position, StaffAssignment, Team, TeamMembership from teams.models import Position, StaffAssignment, Team, TeamMembership
@@ -34,6 +36,9 @@ from .forms import (
GrantLoginForm, GrantLoginForm,
MemberForm, MemberForm,
MemberImportUploadForm, MemberImportUploadForm,
NewsForm,
NewsPhotoUploadForm,
NewsPublishForm,
PositionForm, PositionForm,
RecordFeePaymentForm, RecordFeePaymentForm,
TeamForm, TeamForm,
@@ -723,8 +728,8 @@ class TeamDetailView(ClubStaffRequiredMixin, DetailView):
#: What each non-default role actually grants -- shown on the roles overview so an #: What each non-default role actually grants -- shown on the roles overview so an
#: admin granting one knows what they're handing out. See club/services/access.py. #: admin granting one knows what they're handing out. See club/services/access.py.
ROLE_DESCRIPTIONS = { ROLE_DESCRIPTIONS = {
ClubRole.Roles.ADMIN: _("Full control over the club: memberships, positions, roles, teams, shop, and every event."), ClubRole.Roles.ADMIN: _("Full control over the club: memberships, positions, roles, teams, shop, every event, and news."),
ClubRole.Roles.EDITOR: _("Can create and edit events, but not memberships, positions, roles, or shop settings."), ClubRole.Roles.EDITOR: _("Can create and edit events, and publish news items, but not memberships, positions, roles, or shop settings."),
} }
@@ -950,6 +955,169 @@ class PositionUpdateView(ClubAdminRequiredMixin, UpdateView):
return super().get_context_data(update_view=True, **kwargs) return super().get_context_data(update_view=True, **kwargs)
# --- News: draft/edit is broad (any coach_manager/editor/admin), but only EDITOR/ADMIN
# may publish -- the release flow the news app exists for ---------------------------
class NewsListView(ClubStaffRequiredMixin, ListView):
template_name = "management/news_list.html"
context_object_name = "news_items"
def get_queryset(self):
return News.objects.filter(club=self.request.club).prefetch_related("teams")
class NewsCreateView(NewsAuthorRequiredMixin, CreateView):
model = News
form_class = NewsForm
template_name = "management/news_form.html"
def get_form_kwargs(self):
return super().get_form_kwargs() | {"club": self.request.club}
def form_valid(self, form):
form.instance.club = self.request.club
form.instance.created_by = Member.objects.filter(user=self.request.user).first()
response = super().form_valid(form)
body = _("%(news)s” created.") % {"news": self.object}
notify(self.request, f"s|{_('News item created')}|{body}")
return response
def get_success_url(self):
return reverse("management:news_detail", args=[self.object.pk])
class NewsDetailView(ClubStaffRequiredMixin, DetailView):
template_name = "management/news_detail.html"
context_object_name = "news_item"
def get_queryset(self):
return News.objects.filter(club=self.request.club).prefetch_related("teams", "photos")
def get_context_data(self, **kwargs):
return super().get_context_data(
can_edit=can_edit_news(self.request.user, self.object),
can_publish=can_publish_news(self.request.user, self.request.club),
publish_form=NewsPublishForm(),
photo_upload_form=NewsPhotoUploadForm(),
**kwargs,
)
class NewsUpdateView(NewsEditRequiredMixin, UpdateView):
model = News
form_class = NewsForm
template_name = "management/news_form.html"
def get_news_item(self):
return get_object_or_404(News.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
def get_queryset(self):
return News.objects.filter(club=self.request.club)
def get_form_kwargs(self):
return super().get_form_kwargs() | {"club": self.request.club}
def form_valid(self, form):
response = super().form_valid(form)
body = _("%(news)s” updated.") % {"news": self.object}
notify(self.request, f"s|{_('News item updated')}|{body}")
return response
def get_success_url(self):
return reverse("management:news_detail", args=[self.object.pk])
def get_context_data(self, **kwargs):
return super().get_context_data(update_view=True, **kwargs)
class NewsPublishView(NewsPublisherRequiredMixin, RedirectOnInvalidMixin, FormView):
form_class = NewsPublishForm
http_method_names = ["post"]
invalid_redirect_url_name = "management:news_detail"
def get_invalid_redirect_kwargs(self):
return {"pk": self.kwargs["pk"]}
def form_valid(self, form):
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 news_item.is_scheduled:
body = _("%(news)s” is scheduled to go live on %(date)s.") % {"news": news_item, "date": news_item.published_at}
else:
body = _("%(news)s” is now live.") % {"news": news_item}
notify(self.request, f"s|{_('News item published')}|{body}")
return redirect("management:news_detail", pk=news_item.pk)
class NewsUnpublishView(NewsPublisherRequiredMixin, View):
def post(self, request, pk):
news_item = get_object_or_404(News.objects.filter(club=request.club), pk=pk)
news_item.unpublish()
body = _("%(news)s” is back to a draft.") % {"news": news_item}
notify(request, f"w|{_('News item unpublished')}|{body}")
return redirect("management:news_detail", pk=news_item.pk)
class NewsPhotoUploadView(NewsEditRequiredMixin, RedirectOnInvalidMixin, FormView):
"""One NewsPhoto per uploaded file; if the item had none yet, the first one
in this batch is auto-flagged main so there's always one once any photo exists."""
form_class = NewsPhotoUploadForm
http_method_names = ["post"]
invalid_redirect_url_name = "management:news_detail"
def get_news_item(self):
return get_object_or_404(News.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
def get_invalid_redirect_kwargs(self):
return {"pk": self.kwargs["pk"]}
def form_valid(self, form):
news_item = self.get_news_item()
has_main = news_item.photos.filter(is_main=True).exists()
count = 0
for image in form.cleaned_data["images"]:
NewsPhoto.objects.create(news_item=news_item, image=image, is_main=not has_main)
has_main = True
count += 1
body = ngettext("%(count)d photo added.", "%(count)d photos added.", count) % {"count": count}
notify(self.request, f"s|{_('Photos added')}|{body}")
return redirect("management:news_detail", pk=news_item.pk)
class NewsPhotoSetMainView(NewsEditRequiredMixin, View):
def get_news_item(self):
return get_object_or_404(News.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
def post(self, request, pk, photo_pk):
news_item = self.get_news_item()
photo = get_object_or_404(NewsPhoto, pk=photo_pk, news_item=news_item)
with transaction.atomic():
NewsPhoto.objects.filter(news_item=news_item).update(is_main=False)
photo.is_main = True
photo.save(update_fields=["is_main"])
return redirect("management:news_detail", pk=news_item.pk)
class NewsPhotoDeleteView(NewsEditRequiredMixin, View):
def get_news_item(self):
return get_object_or_404(News.objects.filter(club=self.request.club), pk=self.kwargs["pk"])
def post(self, request, pk, photo_pk):
news_item = self.get_news_item()
photo = get_object_or_404(NewsPhoto, pk=photo_pk, news_item=news_item)
photo.delete()
notify(request, f"w|{_('Photo removed')}|{_('The photo was removed.')}")
return redirect("management:news_detail", pk=news_item.pk)
class RosterListView(ClubStaffRequiredMixin, StubListMixin, ListView): class RosterListView(ClubStaffRequiredMixin, StubListMixin, ListView):
page_title = _("Roster") page_title = _("Roster")

0
news/__init__.py Normal file
View File

23
news/admin.py Normal file
View 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
View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class NewsConfig(AppConfig):
name = "news"

View 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'),
),
]

View File

81
news/models.py Normal file
View 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
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)

View File

@@ -66,6 +66,7 @@ INSTALLED_APPS = [
"members.apps.MembersConfig", "members.apps.MembersConfig",
"teams.apps.TeamsConfig", "teams.apps.TeamsConfig",
"events.apps.EventsConfig", "events.apps.EventsConfig",
"news.apps.NewsConfig",
"formbuilder.apps.FormbuilderConfig", "formbuilder.apps.FormbuilderConfig",
"shop.apps.ShopConfig", "shop.apps.ShopConfig",
# Platform billing: RosterChief charging the clubs. Not tenant data — see billing/models.py. # Platform billing: RosterChief charging the clubs. Not tenant data — see billing/models.py.
@@ -189,6 +190,7 @@ TEMPLATES = [
"management.context_processors.is_admin", "management.context_processors.is_admin",
"management.context_processors.management_link", "management.context_processors.management_link",
"management.context_processors.active_nav_section", "management.context_processors.active_nav_section",
"management.context_processors.news_permissions",
], ],
}, },
}, },