diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 385ab52..d94c8e4 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -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)* |
| `teams` | planned | Teams and season rosters | `Team`, `TeamMembership`, `StaffAssignment` |
| `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` |
| `home` | planned | Homepage composition / featured content | `HomeConfig` (per-club) or config-only |
| `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. |
| `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
*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?".
@@ -418,16 +423,30 @@ service/clean().
### 5.4 `news`, `pages`, `home` (public site / editorial)
-```
-news.Article(ClubScopedModel) # -> carries `club`
- title, slug (SlugField), body (TextField)
- 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` is built** (as of the coach_manager-authoring / editor-release-flow work) —
+team-tagged instead of categorised, with a two-step release flow rather than a bare
+`is_published` flag:
-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", ...
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.
```
-- **`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.
- `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.
-- `cover_image` / hero images use `ImageField` → **media storage must be configured** (§8).
- If page/news trees grow, consider a tree library later — start flat.
+- `visibility` (internal/external/both) is stored and enforced nowhere yet — no
+ 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`
diff --git a/club/mixins.py b/club/mixins.py
index d653570..badfd3a 100644
--- a/club/mixins.py
+++ b/club/mixins.py
@@ -1,7 +1,7 @@
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
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):
@@ -49,3 +49,31 @@ class TeamManagerRequiredMixin(ClubStaffRequiredMixin):
if is_club_admin(user, club):
return True
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())
diff --git a/club/services/access.py b/club/services/access.py
index ecdce87..c5e68b6 100644
--- a/club/services/access.py
+++ b/club/services/access.py
@@ -151,3 +151,22 @@ def can_edit_event(user: User, event: Event) -> bool:
def can_manage_shop(user: User, club: Club) -> bool:
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)
diff --git a/management/context_processors.py b/management/context_processors.py
index c49a5d8..5ed3126 100644
--- a/management/context_processors.py
+++ b/management/context_processors.py
@@ -3,9 +3,11 @@ admin-only sections (seasons, positions, roles, shop, forms) from plain staff.
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.
+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 --
#: management/templates/management/_nav_items.html compares against this.
@@ -47,6 +49,15 @@ _NAV_SECTIONS = {
"team_detail": "team_list",
"roster_list": "roster_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_series_list": "event_series_list",
"location_list": "location_list",
@@ -88,3 +99,13 @@ def management_link(request):
return {"has_management_access": False}
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)}
diff --git a/management/forms.py b/management/forms.py
index fb781a1..6ff8f45 100644
--- a/management/forms.py
+++ b/management/forms.py
@@ -2,11 +2,13 @@ from decimal import Decimal
from django import forms
from django.contrib.auth import get_user_model
+from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from club.models import ClubMembership, ClubRole, FeePayment
from members.models import Family, FamilyMembership, Member
from members.services.family import find_member_by_email
+from news.models import News
from teams.models import Position, Team
User = get_user_model()
@@ -146,6 +148,53 @@ class ClubMembershipForm(forms.ModelForm):
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):
"""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
diff --git a/management/templates/management/_nav_items.html b/management/templates/management/_nav_items.html
index 1724403..47950d6 100644
--- a/management/templates/management/_nav_items.html
+++ b/management/templates/management/_nav_items.html
@@ -31,6 +31,9 @@
+ {% for team in news_item.teams.all %}
+ {{ team.short_name }}
+ {% endfor %}
+
+ {% endif %}
+
{{ news_item.body }}
+
+
+
+
+
+
+
{% trans "Photos" %}
+ {% if can_edit %}
+
+ {% endif %}
+
+
+
+ {% for photo in news_item.photos.all %}
+
+
+ {% if photo.is_main %}
+ {% trans "Main" %}
+ {% endif %}
+ {% if can_edit %}
+
+ {% if not photo.is_main %}
+
+ {% endif %}
+
+
+ {% 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 %}
+
+ {% empty %}
+
{% trans "No photos yet." %}
+ {% endfor %}
+
+
+
+
+ {% 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 %}
diff --git a/management/templates/management/news_form.html b/management/templates/management/news_form.html
new file mode 100644
index 0000000..83918b1
--- /dev/null
+++ b/management/templates/management/news_form.html
@@ -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 %}
+