Show up to 3 news items on Home, with a link to a full "All news" page

Home's news card now shows up to 3 recent items (lead item with its photo
placeholder, the next two as compact rows) instead of just the latest
one, plus an "All news" link -- the design canvas's own M1 markup already
had that link, just unbuilt until now. Both also now filter by
visibility (internal or both, never external-only -- that's the public
website's own audience), which the teaser never actually enforced before.

New mobile:news_list page is the full archive: every published,
member-visible item for the club, newest first, not narrowed to any
particular team the way Home's own teaser is. Paginated at 20/page. The
bottom tab bar's News tab now links here instead of a dead #news anchor
on Home.

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 15:07:48 +02:00
parent 96fc58e453
commit 0e81e729b3
6 changed files with 208 additions and 20 deletions

View File

@@ -95,7 +95,7 @@
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>
<span class="tab-bar-label">{% trans "Calendar" %}</span>
</a>
<a class="tab-bar-item {% if active_tab == "news" %}tab-bar-item-active{% endif %}" href="{% url "mobile:home" %}#news">
<a class="tab-bar-item {% if active_tab == "news" %}tab-bar-item-active{% endif %}" href="{% url "mobile:news_list" %}">
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 20H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h9l5 5v9a2 2 0 0 1-2 2Z"/><path d="M9 13h6M9 17h6M9 9h1"/></svg>
<span class="tab-bar-label">{% trans "News" %}</span>
</a>

View File

@@ -126,24 +126,40 @@
</div>
{% endif %}
{% if news_item %}
{% if news_items %}
<div>
<div class="mb-2.5 flex items-baseline justify-between">
<span class="font-display text-xs font-extrabold text-muted uppercase tracking-wide">{% trans "Club news" %}</span>
<a class="text-xs font-semibold text-club" href="{% url "mobile:news_list" %}">{% trans "All news" %}</a>
</div>
<a class="m-card block overflow-hidden" href="{% url "mobile:news_detail" news_item.slug %}">
<div class="flex h-[104px] items-center justify-center bg-line text-xs text-dim">{% trans "News photo" %}</div>
<div class="p-3.5">
<div class="font-display text-[11px] font-extrabold text-club uppercase tracking-wide">
{% if news_team %}{{ news_team.name }}{% else %}{% trans "Club news" %}{% endif %} &middot; {{ news_item.published_at|date:"d M" }}
{% with lead=news_items.0 %}
<a class="m-card block overflow-hidden" href="{% url "mobile:news_detail" lead.slug %}">
<div class="flex h-[104px] items-center justify-center bg-line text-xs text-dim">{% trans "News photo" %}</div>
<div class="p-3.5">
<div class="font-display text-[11px] font-extrabold text-club uppercase tracking-wide">
{% with team=lead.teams.first %}{% if team %}{{ team.name }}{% else %}{% trans "Club news" %}{% endif %}{% endwith %} &middot; {{ lead.published_at|date:"d M" }}
</div>
<div class="mt-1 font-display text-xl leading-tight font-extrabold text-ink uppercase">{{ lead.title }}</div>
</div>
<div class="mt-1 font-display text-xl leading-tight font-extrabold text-ink uppercase">{{ news_item.title }}</div>
</a>
{% endwith %}
{% if news_items|length > 1 %}
<div class="m-card mt-2.5 overflow-hidden">
{% for item in news_items|slice:"1:" %}
{% if not forloop.first %}<div class="h-px bg-rule"></div>{% endif %}
<a class="block p-3.5" href="{% url "mobile:news_detail" item.slug %}">
<div class="font-display text-[11px] font-extrabold text-club uppercase tracking-wide">
{% with team=item.teams.first %}{% if team %}{{ team.name }}{% else %}{% trans "Club news" %}{% endif %}{% endwith %} &middot; {{ item.published_at|date:"d M" }}
</div>
<div class="text-sm font-semibold text-ink">{{ item.title }}</div>
</a>
{% endfor %}
</div>
</a>
{% endif %}
</div>
{% endif %}
{% if not hero_attendance and not needs_answer and not dues_rows and not news_item %}
{% if not hero_attendance and not needs_answer and not dues_rows and not news_items %}
<div class="m-card p-6 text-center">
<p class="text-sm text-muted">{% trans "Nothing to show right now." %}</p>
</div>

View File

@@ -0,0 +1,48 @@
{% extends "mobile/base.html" %}
{% load i18n %}
{% comment %}
"All news" -- what Home's own news card links to. Every published,
member-visible (internal or both) news item, newest first, full-width
like Calendar's own agenda rows (see NewsListView's docstring for why
this isn't filtered to just this account's teams the way Home's own
teaser is).
{% endcomment %}
{% block content %}
<div class="-mx-4">
{% if page.object_list %}
<div class="flex flex-col gap-px bg-line">
{% for item in page.object_list %}
<a class="flex flex-col gap-1 bg-white px-4 py-3" href="{% url "mobile:news_detail" item.slug %}">
<div class="font-display text-[11px] font-extrabold text-club uppercase tracking-wide">
{% with team=item.teams.first %}{% if team %}{{ team.name }}{% else %}{% trans "Club news" %}{% endif %}{% endwith %} &middot; {{ item.published_at|date:"d M Y" }}
</div>
<div class="text-[15px] font-semibold text-ink">{{ item.title }}</div>
</a>
{% endfor %}
</div>
{% if page.has_other_pages %}
<div class="flex items-center justify-between px-4 py-3">
{% if page.has_previous %}
<a class="font-display text-xs font-extrabold tracking-wide text-club uppercase" href="?page={{ page.previous_page_number }}">{% trans "Newer" %}</a>
{% else %}
<span></span>
{% endif %}
{% if page.has_next %}
<a class="font-display text-xs font-extrabold tracking-wide text-club uppercase" href="?page={{ page.next_page_number }}">{% trans "Older" %}</a>
{% else %}
<span></span>
{% endif %}
</div>
{% endif %}
{% else %}
<div class="mx-4">
<div class="m-card p-6 text-center">
<p class="text-sm text-muted">{% trans "No news yet." %}</p>
</div>
</div>
{% endif %}
</div>
{% endblock content %}

View File

@@ -296,17 +296,35 @@ class HomeViewTests(TestCase):
self.assertEqual(response.context["dues_rows"], [])
def test_news_teaser_shows_the_latest_published_item(self):
News.objects.create(club=self.club, title="Old news", body="Body.", status=News.Status.PUBLISHED, published_at=timezone.now() - datetime.timedelta(days=5))
def test_news_teaser_shows_the_latest_published_items_newest_first(self):
oldest = News.objects.create(club=self.club, title="Old news", body="Body.", status=News.Status.PUBLISHED, published_at=timezone.now() - datetime.timedelta(days=5))
latest = News.objects.create(club=self.club, title="Signed: New Player", body="Body.", status=News.Status.PUBLISHED, published_at=timezone.now() - datetime.timedelta(days=1))
News.objects.create(club=self.club, title="Still a draft", body="Body.", status=News.Status.DRAFT)
self.client.force_login(self.user)
response = self._get("home")
self.assertEqual(response.context["news_item"], latest)
self.assertEqual(list(response.context["news_items"]), [latest, oldest])
self.assertContains(response, "Signed: New Player")
def test_news_teaser_caps_at_three_with_a_link_to_all_news(self):
for day in range(5):
News.objects.create(club=self.club, title=f"Item {day}", body="Body.", status=News.Status.PUBLISHED, published_at=timezone.now() - datetime.timedelta(days=day))
self.client.force_login(self.user)
response = self._get("home")
self.assertEqual(len(response.context["news_items"]), 3)
self.assertContains(response, 'href="/app/news/"')
def test_news_teaser_excludes_external_only_items(self):
News.objects.create(club=self.club, title="Public site only", body="Body.", status=News.Status.PUBLISHED, published_at=timezone.now(), visibility=News.Visibility.EXTERNAL)
self.client.force_login(self.user)
response = self._get("home")
self.assertEqual(list(response.context["news_items"]), [])
def test_empty_account_gets_a_graceful_empty_state(self):
bare_user = User.objects.create_user(email="new@example.com", password="pw-secret-123")
self.client.force_login(bare_user)
@@ -839,6 +857,76 @@ class NotificationsViewTests(TestCase):
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 NewsListViewTests(TestCase):
""""All news" -- what Home's own news card links to. Every published,
internal-or-both news item, not filtered to any particular team."""
@classmethod
def setUpTestData(cls):
cls.club = make_club()
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", user=cls.user)
def _get(self, **params):
url = reverse("mobile:news_list")
if params:
url += "?" + "&".join(f"{key}={value}" for key, value in params.items())
return self.client.get(url, HTTP_HOST="ajax-united.rosterchief.app")
def test_requires_login(self):
response = self._get()
self.assertEqual(response.status_code, 302)
def test_lists_every_internal_or_both_item_regardless_of_team(self):
team_item = News.objects.create(club=self.club, title="Team news", body="Body.", status=News.Status.PUBLISHED, published_at=timezone.now())
team = Team.objects.create(club=self.club, name="U16", short_name="U16")
team_item.teams.add(team)
club_item = News.objects.create(club=self.club, title="Club-wide news", body="Body.", status=News.Status.PUBLISHED, published_at=timezone.now())
both_item = News.objects.create(club=self.club, title="Both audiences", body="Body.", status=News.Status.PUBLISHED, published_at=timezone.now(), visibility=News.Visibility.BOTH)
self.client.force_login(self.user)
response = self._get()
self.assertEqual(set(response.context["page"].object_list), {team_item, club_item, both_item})
def test_excludes_external_only_draft_and_future_scheduled_items(self):
News.objects.create(club=self.club, title="External only", body="Body.", status=News.Status.PUBLISHED, published_at=timezone.now(), visibility=News.Visibility.EXTERNAL)
News.objects.create(club=self.club, title="Draft", body="Body.", status=News.Status.DRAFT)
News.objects.create(club=self.club, title="Scheduled", body="Body.", status=News.Status.PUBLISHED, published_at=timezone.now() + datetime.timedelta(days=3))
self.client.force_login(self.user)
response = self._get()
self.assertEqual(list(response.context["page"].object_list), [])
def test_paginates_at_twenty_per_page(self):
for day in range(25):
News.objects.create(club=self.club, title=f"Item {day}", body="Body.", status=News.Status.PUBLISHED, published_at=timezone.now() - datetime.timedelta(days=day))
self.client.force_login(self.user)
first_page = self._get()
second_page = self._get(page=2)
self.assertEqual(len(first_page.context["page"].object_list), 20)
self.assertEqual(len(second_page.context["page"].object_list), 5)
def test_empty_state_when_nothing_to_show(self):
self.client.force_login(self.user)
response = self._get()
self.assertContains(response, "No news yet.")
def test_news_tab_is_active(self):
self.client.force_login(self.user)
response = self._get()
self.assertEqual(response.context["active_tab"], "news")
@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,

View File

@@ -14,6 +14,7 @@ urlpatterns = [
path("", views.HomeView.as_view(), name="home"),
path("calendar/", views.CalendarView.as_view(), name="calendar"),
path("events/<uuid:pk>/", views.EventDetailView.as_view(), name="event_detail"),
path("news/", views.NewsListView.as_view(), name="news_list"),
path("news/<slug:slug>/", views.NewsDetailView.as_view(), name="news_detail"),
path("me/", views.MeView.as_view(), name="me"),
path("me/<uuid:member_id>/edit/", views.EditProfileView.as_view(), name="edit_profile"),

View File

@@ -8,6 +8,7 @@ import datetime
import json
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.paginator import Paginator
from django.db.models import Count, Q
from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect, JsonResponse
from django.shortcuts import get_object_or_404
@@ -143,6 +144,8 @@ class HomeView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
#: Keeps the card from crowding the dues/news cards below it off the first
#: screenful -- Calendar is the place to see everything still awaiting a reply.
NEEDS_ANSWER_LIMIT = 5
#: Same reasoning -- mobile:news_list is the place to see everything.
NEWS_LIMIT = 3
def get_context_data(self, **kwargs):
people = self.people_in_scope
@@ -153,7 +156,7 @@ class HomeView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
needs_answer = []
needs_answer_total = 0
dues_rows = []
news_item = None
news_items = []
if people:
upcoming = Attendance.objects.filter(
@@ -190,12 +193,17 @@ class HomeView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
else:
team_ids = []
news_item = (
News.objects.filter(club=self.request.club, status=News.Status.PUBLISHED, published_at__lte=now)
news_items = list(
News.objects.filter(
club=self.request.club,
status=News.Status.PUBLISHED,
published_at__lte=now,
visibility__in=[News.Visibility.INTERNAL, News.Visibility.BOTH],
)
.filter(Q(teams__isnull=True) | Q(teams__id__in=team_ids))
.prefetch_related("teams")
.order_by("-published_at")
.distinct()
.first()
.distinct()[: self.NEWS_LIMIT]
)
return super().get_context_data(
@@ -204,8 +212,7 @@ class HomeView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
needs_answer=needs_answer,
needs_answer_remaining=max(needs_answer_total - len(needs_answer), 0),
dues_rows=dues_rows,
news_item=news_item,
news_team=news_item.teams.first() if news_item is not None else None,
news_items=news_items,
# Club-wide, not person-specific -- shown regardless of managed_people,
# unlike every other card on this screen. Reshuffled on every request
# (see club.services.sponsors.active_sponsors) rather than once per
@@ -373,6 +380,34 @@ class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
return HttpResponseRedirect(reverse("mobile:home"))
class NewsListView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
""""All news" -- what Home's own news card links to once there's more than
NEWS_LIMIT items to show. Unlike Home's own teaser (team-relevant items
only), this is the full browsable archive: every published, member-visible
news item for the club, newest first, regardless of which team it's about.
Same visibility rule as Home -- internal or both, never external-only
(that's the public website's own audience, not this app's)."""
template_name = "mobile/news_list.html"
screen_title = _("News")
active_tab = "news"
PAGE_SIZE = 20
def get_context_data(self, **kwargs):
news_items = (
News.objects.filter(
club=self.request.club,
status=News.Status.PUBLISHED,
published_at__lte=timezone.now(),
visibility__in=[News.Visibility.INTERNAL, News.Visibility.BOTH],
)
.prefetch_related("teams")
.order_by("-published_at")
)
page = Paginator(news_items, self.PAGE_SIZE).get_page(self.request.GET.get("page"))
return super().get_context_data(page=page, **kwargs)
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