Build M4 News article for the mobile app

A photo-hero permalink for one published news item -- same dark-gradient
hero treatment as M2's event detail, with the club's main_photo where one
exists. Visibility mirrors news.tasks.notify_news_published's own
"actually live" gate, so a scheduled-but-not-yet-published item 404s here
the same way. Shows the English translation only when the request's
active language actually is English; native text otherwise.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
2026-08-21 13:57:50 +02:00
parent f41a255b61
commit 3559c936f7
5 changed files with 216 additions and 3 deletions

View File

@@ -0,0 +1,55 @@
{% extends "mobile/base.html" %}
{% load i18n %}
{% comment %}
M4 -- design_handoff_rosterchief_platform/README.md's M4 section, "News
article". See NewsDetailView's own docstring (mobile/views.py) for the
judgment calls: visibility mirrors news.tasks.notify_news_published's
gate, language is Django's active-language detection (not a member-facing
toggle), and the body is plain user content -- rendered as-is, not
wrapped in {% trans %}.
{% endcomment %}
{% block content %}
<div class="-mx-4 -mt-4 flex h-[330px] flex-col justify-end p-4 text-white"
style="background: linear-gradient(180deg, rgba(11,18,32,.6) 0%, rgba(11,18,32,0) 40%, rgba(11,18,32,.88) 100%){% if news_item.main_photo %}, url('{{ news_item.main_photo.image.url }}') center/cover no-repeat{% else %}, var(--color-navy){% endif %}">
<a class="mb-auto flex h-11 w-11 items-center justify-center rounded-full bg-white/15" href="{% url "mobile:home" %}#news" aria-label="{% trans "Back" %}">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M15 5l-7 7 7 7"/></svg>
</a>
<span class="mb-1.5 inline-block w-fit font-display text-xs font-extrabold tracking-wide text-club uppercase">
{% with team=news_item.teams.first %}
{% if team %}{{ team.short_name }}{% else %}{% trans "Club news" %}{% endif %}
{% endwith %}
&middot; {{ news_item.published_at|date:"d M Y" }}
</span>
<h1 class="font-display text-4xl leading-[.96] font-extrabold uppercase">{{ article_title }}</h1>
</div>
<div class="flex flex-col gap-3.5">
<div class="whitespace-pre-line text-[15px] leading-relaxed text-ink">{{ article_body }}</div>
{% with teams=news_item.teams.all %}
{% if teams %}
<div class="flex flex-wrap gap-2">
{% for team in teams %}
<span class="pill pill-neutral">{{ team.short_name }}</span>
{% endfor %}
</div>
{% else %}
<div>
<span class="pill pill-neutral">{% trans "Club news" %}</span>
</div>
{% endif %}
{% endwith %}
<div class="flex items-center gap-2.5 border-t border-line pt-3.5">
{% if news_item.created_by %}
<span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-navy font-display text-sm font-extrabold text-white">{{ news_item.created_by.first_name|slice:":1" }}{{ news_item.created_by.last_name|slice:":1" }}</span>
<span class="flex-1 text-sm font-semibold text-ink">{% blocktrans with name=news_item.created_by.get_full_name %}Posted by {{ name }}{% endblocktrans %}</span>
{% else %}
<span class="flex-1"></span>
{% endif %}
<button type="button" class="btn btn-secondary h-[38px] px-3.5 text-sm" onclick="navigator.share ? navigator.share({title: '{{ article_title|escapejs }}', url: window.location.href}).catch(() => {}) : null">{% trans "Share" %}</button>
</div>
</div>
{% endblock content %}

View File

@@ -4,7 +4,7 @@ from decimal import Decimal
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone, translation
from club.models import Club, ClubMembership, DuesInvoice, Season from club.models import Club, ClubMembership, DuesInvoice, Season
from events.models import Attendance, Event from events.models import Attendance, Event
@@ -642,3 +642,117 @@ class NotificationsViewTests(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, "No one to show yet") self.assertContains(response, "No one to show yet")
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
class NewsDetailScreenTests(TestCase):
"""M4 -- design_handoff_rosterchief_platform/README.md's M4 section,
"News article". Visibility mirrors news.tasks.notify_news_published's own
gate (see NewsDetailView's docstring); language is Django's own
active-language detection, not a member-facing toggle."""
@classmethod
def setUpTestData(cls):
cls.club = make_club()
today = timezone.localdate()
cls.season = Season.objects.create(club=cls.club, start_date=today - datetime.timedelta(days=30), end_date=today + datetime.timedelta(days=300))
cls.user = User.objects.create_user(email="parent@example.com", password="pw-secret-123")
cls.member = Member.objects.create(first_name="Lars", last_name="Bakker", email="parent@example.com", user=cls.user)
ClubMembership.objects.create(club=cls.club, member=cls.member, season=cls.season)
def _get(self, news_item):
return self.client.get(reverse("mobile:news_detail", kwargs={"slug": news_item.slug}), HTTP_HOST="ajax-united.rosterchief.app")
def test_a_published_item_renders(self):
news_item = News.objects.create(club=self.club, title="Season Kickoff", body="We start training next week.", status=News.Status.PUBLISHED, published_at=timezone.now())
self.client.force_login(self.user)
response = self._get(news_item)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Season Kickoff")
self.assertContains(response, "We start training next week.")
def test_a_draft_item_404s(self):
news_item = News.objects.create(club=self.club, title="Draft item", body="Not live yet.", status=News.Status.DRAFT)
self.client.force_login(self.user)
response = self._get(news_item)
self.assertEqual(response.status_code, 404)
def test_a_future_scheduled_item_404s(self):
news_item = News.objects.create(
club=self.club,
title="Scheduled item",
body="Not live yet.",
status=News.Status.PUBLISHED,
published_at=timezone.now() + datetime.timedelta(days=1),
)
self.client.force_login(self.user)
response = self._get(news_item)
self.assertEqual(response.status_code, 404)
def test_english_fallback_is_not_used_when_no_translation_exists_and_the_active_language_is_dutch(self):
news_item = News.objects.create(club=self.club, title="Seizoensstart", body="We beginnen volgende week.", status=News.Status.PUBLISHED, published_at=timezone.now())
self.client.force_login(self.user)
with translation.override("nl"):
response = self._get(news_item)
self.assertContains(response, "Seizoensstart")
self.assertContains(response, "We beginnen volgende week.")
def test_english_translation_shows_when_the_active_language_is_english(self):
news_item = News.objects.create(
club=self.club,
title="Seizoensstart",
body="We beginnen volgende week.",
title_en="Season kickoff",
body_en="We start next week.",
status=News.Status.PUBLISHED,
published_at=timezone.now(),
)
self.client.force_login(self.user)
with translation.override("en"):
response = self._get(news_item)
self.assertContains(response, "Season kickoff")
self.assertContains(response, "We start next week.")
def test_teams_show_as_tags_when_present(self):
team = Team.objects.create(club=self.club, name="U16", short_name="U16")
news_item = News.objects.create(club=self.club, title="Team news", body="Body.", status=News.Status.PUBLISHED, published_at=timezone.now())
news_item.teams.add(team)
self.client.force_login(self.user)
response = self._get(news_item)
self.assertContains(response, "U16")
self.assertNotContains(response, "Club news")
def test_club_wide_item_shows_club_news_when_no_teams(self):
news_item = News.objects.create(club=self.club, title="Club-wide news", body="Body.", status=News.Status.PUBLISHED, published_at=timezone.now())
self.client.force_login(self.user)
response = self._get(news_item)
self.assertContains(response, "Club news")
def test_posted_by_shows_when_created_by_is_set(self):
news_item = News.objects.create(club=self.club, title="Byline test", body="Body.", status=News.Status.PUBLISHED, published_at=timezone.now(), created_by=self.member)
self.client.force_login(self.user)
response = self._get(news_item)
self.assertContains(response, "Posted by Lars Bakker")
def test_requires_login(self):
news_item = News.objects.create(club=self.club, title="Season Kickoff", body="Body.", status=News.Status.PUBLISHED, published_at=timezone.now())
response = self._get(news_item)
self.assertEqual(response.status_code, 302)

View File

@@ -19,6 +19,7 @@ from django.shortcuts import get_object_or_404
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.translation import get_language
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views import View from django.views import View
from django.views.generic import TemplateView from django.views.generic import TemplateView
@@ -372,10 +373,47 @@ class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
return HttpResponseRedirect(reverse("mobile:home")) return HttpResponseRedirect(reverse("mobile:home"))
class NewsDetailView(_PlaceholderScreen): class NewsDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
"""M4 -- design_handoff_rosterchief_platform/README.md's M4 section: a
photo-hero permalink for a single published News item. Visibility mirrors
news.tasks.notify_news_published's own gate -- PUBLISHED *and* actually
live (published_at in the past) -- so a scheduled-but-not-yet-live item
404s here exactly like it does everywhere else a member could reach it,
rather than leaking a preview of it early.
Language is Django's own active-language detection, not a member-facing
toggle (unlike management's split-view Dutch/English editing tool) --
English shows only when the request's active language actually is "en";
everything else (including no active language at all) shows the native
(Dutch) text.
"""
template_name = "mobile/news_detail.html"
screen_title = _("News") screen_title = _("News")
active_tab = "news" active_tab = "news"
def get_context_data(self, **kwargs):
news_item = get_object_or_404(
News.objects.prefetch_related("teams", "photos"),
club=self.request.club,
slug=self.kwargs["slug"],
status=News.Status.PUBLISHED,
published_at__lte=timezone.now(),
)
if get_language() == "en":
title, body = news_item.effective_title_en, news_item.effective_body_en
else:
title, body = news_item.title, news_item.body
return super().get_context_data(
screen_title=title,
news_item=news_item,
article_title=title,
article_body=body,
**kwargs,
)
class MeView(_PlaceholderScreen): class MeView(_PlaceholderScreen):
screen_title = _("Me") screen_title = _("Me")

View File

@@ -3941,6 +3941,9 @@
.h-\[34px\] { .h-\[34px\] {
height: 34px; height: 34px;
} }
.h-\[38px\] {
height: 38px;
}
.h-\[52px\] { .h-\[52px\] {
height: 52px; height: 52px;
} }
@@ -3962,6 +3965,9 @@
.h-\[220px\] { .h-\[220px\] {
height: 220px; height: 220px;
} }
.h-\[330px\] {
height: 330px;
}
.h-\[560px\] { .h-\[560px\] {
height: 560px; height: 560px;
} }

File diff suppressed because one or more lines are too long