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:
@@ -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>
|
||||
|
||||
@@ -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 %} · {{ 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 %} · {{ 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 %} · {{ 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>
|
||||
|
||||
48
mobile/templates/mobile/news_list.html
Normal file
48
mobile/templates/mobile/news_list.html
Normal 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 %} · {{ 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 %}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user