From 0e81e729b3729ad41d510f0590e5ee553555359b Mon Sep 17 00:00:00 2001 From: Bernard Siebens Date: Fri, 21 Aug 2026 15:07:48 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9 --- mobile/templates/mobile/base.html | 2 +- mobile/templates/mobile/home.html | 34 +++++++--- mobile/templates/mobile/news_list.html | 48 +++++++++++++ mobile/tests.py | 94 +++++++++++++++++++++++++- mobile/urls.py | 1 + mobile/views.py | 49 ++++++++++++-- 6 files changed, 208 insertions(+), 20 deletions(-) create mode 100644 mobile/templates/mobile/news_list.html diff --git a/mobile/templates/mobile/base.html b/mobile/templates/mobile/base.html index b08e664..3eb8597 100644 --- a/mobile/templates/mobile/base.html +++ b/mobile/templates/mobile/base.html @@ -95,7 +95,7 @@ {% trans "Calendar" %} - + {% trans "News" %} diff --git a/mobile/templates/mobile/home.html b/mobile/templates/mobile/home.html index 694b480..a24ccee 100644 --- a/mobile/templates/mobile/home.html +++ b/mobile/templates/mobile/home.html @@ -126,24 +126,40 @@ {% endif %} - {% if news_item %} + {% if news_items %}
{% trans "Club news" %} + {% trans "All news" %}
- -
{% trans "News photo" %}
-
- {% 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 %}

{% trans "Nothing to show right now." %}

diff --git a/mobile/templates/mobile/news_list.html b/mobile/templates/mobile/news_list.html new file mode 100644 index 0000000..c75d440 --- /dev/null +++ b/mobile/templates/mobile/news_list.html @@ -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 %} +
+ {% if page.object_list %} + + + {% if page.has_other_pages %} +
+ {% if page.has_previous %} + {% trans "Newer" %} + {% else %} + + {% endif %} + {% if page.has_next %} + {% trans "Older" %} + {% else %} + + {% endif %} +
+ {% endif %} + {% else %} +
+
+

{% trans "No news yet." %}

+
+
+ {% endif %} +
+{% endblock content %} diff --git a/mobile/tests.py b/mobile/tests.py index 6804a53..b936eb1 100644 --- a/mobile/tests.py +++ b/mobile/tests.py @@ -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, diff --git a/mobile/urls.py b/mobile/urls.py index 1f767b7..b75f5aa 100644 --- a/mobile/urls.py +++ b/mobile/urls.py @@ -14,6 +14,7 @@ urlpatterns = [ path("", views.HomeView.as_view(), name="home"), path("calendar/", views.CalendarView.as_view(), name="calendar"), path("events//", views.EventDetailView.as_view(), name="event_detail"), + path("news/", views.NewsListView.as_view(), name="news_list"), path("news//", views.NewsDetailView.as_view(), name="news_detail"), path("me/", views.MeView.as_view(), name="me"), path("me//edit/", views.EditProfileView.as_view(), name="edit_profile"), diff --git a/mobile/views.py b/mobile/views.py index a318678..7f219ad 100644 --- a/mobile/views.py +++ b/mobile/views.py @@ -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