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:
@@ -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`
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 == '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><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>
|
||||
|
||||
102
management/templates/management/news_detail.html
Normal file
102
management/templates/management/news_detail.html
Normal 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 %}
|
||||
31
management/templates/management/news_form.html
Normal file
31
management/templates/management/news_form.html
Normal 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 %}
|
||||
57
management/templates/management/news_list.html
Normal file
57
management/templates/management/news_list.html
Normal 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 %}
|
||||
@@ -17,6 +17,7 @@ from events.models import Event
|
||||
from management.bulk_import import TEMPLATE_COLUMNS
|
||||
from management.pdf import PDFExportError, render_pdf
|
||||
from members.models import Family, FamilyMembership, Member
|
||||
from news.models import News, NewsPhoto
|
||||
from shop.models import Order
|
||||
from teams.models import Position, StaffAssignment, Team, TeamMembership
|
||||
|
||||
@@ -1710,3 +1711,169 @@ class MemberBulkImportTests(ManagementTestBase):
|
||||
response = self.club_post("member_import_confirm", {})
|
||||
|
||||
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())
|
||||
|
||||
@@ -42,6 +42,16 @@ urlpatterns = [
|
||||
path("teams/<uuid:pk>/edit/", views.TeamUpdateView.as_view(), name="team_update"),
|
||||
path("roster/", views.RosterListView.as_view(), name="roster_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
|
||||
path("events/", views.EventListView.as_view(), name="event_list"),
|
||||
path("event-series/", views.EventSeriesListView.as_view(), name="event_series_list"),
|
||||
|
||||
@@ -6,11 +6,12 @@ from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
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 club.mixins import ClubAdminRequiredMixin, ClubStaffRequiredMixin
|
||||
from club.mixins import ClubAdminRequiredMixin, ClubStaffRequiredMixin, NewsAuthorRequiredMixin, NewsEditRequiredMixin, NewsPublisherRequiredMixin
|
||||
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 controlpanel.messages import notify
|
||||
from controlpanel.mixins import RedirectOnInvalidMixin
|
||||
@@ -20,6 +21,7 @@ from formbuilder.models import Form as FormBuilderForm
|
||||
from formbuilder.models import Submission
|
||||
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 news.models import News, NewsPhoto
|
||||
from shop.models import Discount, Invoice, Order, Product
|
||||
from teams.models import Position, StaffAssignment, Team, TeamMembership
|
||||
|
||||
@@ -34,6 +36,9 @@ from .forms import (
|
||||
GrantLoginForm,
|
||||
MemberForm,
|
||||
MemberImportUploadForm,
|
||||
NewsForm,
|
||||
NewsPhotoUploadForm,
|
||||
NewsPublishForm,
|
||||
PositionForm,
|
||||
RecordFeePaymentForm,
|
||||
TeamForm,
|
||||
@@ -723,8 +728,8 @@ class TeamDetailView(ClubStaffRequiredMixin, DetailView):
|
||||
#: 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.
|
||||
ROLE_DESCRIPTIONS = {
|
||||
ClubRole.Roles.ADMIN: _("Full control over the club: memberships, positions, roles, teams, shop, and every event."),
|
||||
ClubRole.Roles.EDITOR: _("Can create and edit events, but not memberships, positions, roles, or shop settings."),
|
||||
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, 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)
|
||||
|
||||
|
||||
# --- 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):
|
||||
page_title = _("Roster")
|
||||
|
||||
|
||||
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)
|
||||
@@ -66,6 +66,7 @@ INSTALLED_APPS = [
|
||||
"members.apps.MembersConfig",
|
||||
"teams.apps.TeamsConfig",
|
||||
"events.apps.EventsConfig",
|
||||
"news.apps.NewsConfig",
|
||||
"formbuilder.apps.FormbuilderConfig",
|
||||
"shop.apps.ShopConfig",
|
||||
# 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.management_link",
|
||||
"management.context_processors.active_nav_section",
|
||||
"management.context_processors.news_permissions",
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user