-
{% 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 %}
+
+ {% else %}
+
+ {% endif %}
+ {% if page.has_next %}
+
+ {% 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