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

@@ -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,