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 @@
  • {% lucide "clipboard-list" size=16 %} {% trans "Roster" %}
  • {% lucide "hard-hat" size=16 %} {% trans "Staff" %}
  • + +
  • {% lucide "newspaper" size=16 %} {% trans "News" %}
  • +
  • {% lucide "calendar" size=16 %} {% trans "Events" %}
  • {% lucide "repeat" size=16 %} {% trans "Event series" %}
  • diff --git a/management/templates/management/news_detail.html b/management/templates/management/news_detail.html new file mode 100644 index 0000000..aafa40c --- /dev/null +++ b/management/templates/management/news_detail.html @@ -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 %} + {% lucide "pencil" size=16 %} {% trans "Edit" %} + {% endif %} + {% if can_publish %} + {% if news_item.status == "draft" %} + + {% else %} + + {% endif %} + {% endif %} +{% endblock actions %} + +{% block panel %} +
    +
    + {% if news_item.teams.all %} +
    + {% 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 %} +
    + {% csrf_token %} + +
    + {% 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 %} +
    +
    +
    + {% csrf_token %} + + {% for error in form.non_field_errors %} +
    + {{ error }} +
    + {% endfor %} + +
    + {% for field in form %} + {% form_field field %} + {% endfor %} +
    + +
    + {% lucide "arrow-left" size=16 %} {% trans "Cancel" %} + +
    +
    +
    +
    +{% endblock panel %} diff --git a/management/templates/management/news_list.html b/management/templates/management/news_list.html new file mode 100644 index 0000000..6590fc1 --- /dev/null +++ b/management/templates/management/news_list.html @@ -0,0 +1,57 @@ +{% extends "management/base.html" %} +{% load i18n lucide %} + +{% block heading %}{% trans "News" %}{% endblock heading %} + +{% block actions %} + {% if can_add_news %} + {% lucide "plus" size=16 %} {% trans "New news item" %} + {% endif %} +{% endblock actions %} + +{% block panel %} +
    +
    +
    + + + + + + + + + + + {% for news_item in news_items %} + + + + + + + {% empty %} + + + + {% endfor %} + +
    {% trans "Title" %}{% trans "Teams" %}{% trans "Visibility" %}{% trans "Status" %}
    {{ news_item.title }} + {% for team in news_item.teams.all %} + {{ team.short_name }} + {% empty %} + {% trans "Club-wide" %} + {% endfor %} + {{ news_item.get_visibility_display }} + {% if news_item.status == "draft" %} + {% trans "Draft" %} + {% elif news_item.is_scheduled %} + {% blocktrans with date=news_item.published_at %}Scheduled for {{ date }}{% endblocktrans %} + {% else %} + {% trans "Published" %} + {% endif %} +
    {% trans "No news items yet." %}
    +
    +
    +
    +{% endblock panel %} diff --git a/management/tests.py b/management/tests.py index ac9880a..2513dcb 100644 --- a/management/tests.py +++ b/management/tests.py @@ -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()) diff --git a/management/urls.py b/management/urls.py index 890f053..98babd9 100644 --- a/management/urls.py +++ b/management/urls.py @@ -42,6 +42,16 @@ urlpatterns = [ path("teams//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//", views.NewsDetailView.as_view(), name="news_detail"), + path("news//edit/", views.NewsUpdateView.as_view(), name="news_update"), + path("news//publish/", views.NewsPublishView.as_view(), name="news_publish"), + path("news//unpublish/", views.NewsUnpublishView.as_view(), name="news_unpublish"), + path("news//photos/", views.NewsPhotoUploadView.as_view(), name="news_photo_upload"), + path("news//photos//set-main/", views.NewsPhotoSetMainView.as_view(), name="news_photo_set_main"), + path("news//photos//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"), diff --git a/management/views.py b/management/views.py index 2661576..9cae475 100644 --- a/management/views.py +++ b/management/views.py @@ -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") diff --git a/news/__init__.py b/news/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/news/admin.py b/news/admin.py new file mode 100644 index 0000000..59c6c48 --- /dev/null +++ b/news/admin.py @@ -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"] diff --git a/news/apps.py b/news/apps.py new file mode 100644 index 0000000..42c63ba --- /dev/null +++ b/news/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class NewsConfig(AppConfig): + name = "news" diff --git a/news/migrations/0001_initial.py b/news/migrations/0001_initial.py new file mode 100644 index 0000000..6fc79e0 --- /dev/null +++ b/news/migrations/0001_initial.py @@ -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'), + ), + ] diff --git a/news/migrations/__init__.py b/news/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/news/models.py b/news/models.py new file mode 100644 index 0000000..c3db193 --- /dev/null +++ b/news/models.py @@ -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" diff --git a/news/tests.py b/news/tests.py new file mode 100644 index 0000000..ff9bb82 --- /dev/null +++ b/news/tests.py @@ -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) diff --git a/rosterchief/settings.py b/rosterchief/settings.py index 90230db..112106b 100644 --- a/rosterchief/settings.py +++ b/rosterchief/settings.py @@ -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", ], }, },