Build M4 News article for the mobile app
A photo-hero permalink for one published news item -- same dark-gradient hero treatment as M2's event detail, with the club's main_photo where one exists. Visibility mirrors news.tasks.notify_news_published's own "actually live" gate, so a scheduled-but-not-yet-published item 404s here the same way. Shows the English translation only when the request's active language actually is English; native text otherwise. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
55
mobile/templates/mobile/news_detail.html
Normal file
55
mobile/templates/mobile/news_detail.html
Normal file
@@ -0,0 +1,55 @@
|
||||
{% extends "mobile/base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% comment %}
|
||||
M4 -- design_handoff_rosterchief_platform/README.md's M4 section, "News
|
||||
article". See NewsDetailView's own docstring (mobile/views.py) for the
|
||||
judgment calls: visibility mirrors news.tasks.notify_news_published's
|
||||
gate, language is Django's active-language detection (not a member-facing
|
||||
toggle), and the body is plain user content -- rendered as-is, not
|
||||
wrapped in {% trans %}.
|
||||
{% endcomment %}
|
||||
|
||||
{% block content %}
|
||||
<div class="-mx-4 -mt-4 flex h-[330px] flex-col justify-end p-4 text-white"
|
||||
style="background: linear-gradient(180deg, rgba(11,18,32,.6) 0%, rgba(11,18,32,0) 40%, rgba(11,18,32,.88) 100%){% if news_item.main_photo %}, url('{{ news_item.main_photo.image.url }}') center/cover no-repeat{% else %}, var(--color-navy){% endif %}">
|
||||
<a class="mb-auto flex h-11 w-11 items-center justify-center rounded-full bg-white/15" href="{% url "mobile:home" %}#news" aria-label="{% trans "Back" %}">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M15 5l-7 7 7 7"/></svg>
|
||||
</a>
|
||||
<span class="mb-1.5 inline-block w-fit font-display text-xs font-extrabold tracking-wide text-club uppercase">
|
||||
{% with team=news_item.teams.first %}
|
||||
{% if team %}{{ team.short_name }}{% else %}{% trans "Club news" %}{% endif %}
|
||||
{% endwith %}
|
||||
· {{ news_item.published_at|date:"d M Y" }}
|
||||
</span>
|
||||
<h1 class="font-display text-4xl leading-[.96] font-extrabold uppercase">{{ article_title }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3.5">
|
||||
<div class="whitespace-pre-line text-[15px] leading-relaxed text-ink">{{ article_body }}</div>
|
||||
|
||||
{% with teams=news_item.teams.all %}
|
||||
{% if teams %}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% for team in teams %}
|
||||
<span class="pill pill-neutral">{{ team.short_name }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div>
|
||||
<span class="pill pill-neutral">{% trans "Club news" %}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="flex items-center gap-2.5 border-t border-line pt-3.5">
|
||||
{% if news_item.created_by %}
|
||||
<span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-navy font-display text-sm font-extrabold text-white">{{ news_item.created_by.first_name|slice:":1" }}{{ news_item.created_by.last_name|slice:":1" }}</span>
|
||||
<span class="flex-1 text-sm font-semibold text-ink">{% blocktrans with name=news_item.created_by.get_full_name %}Posted by {{ name }}{% endblocktrans %}</span>
|
||||
{% else %}
|
||||
<span class="flex-1"></span>
|
||||
{% endif %}
|
||||
<button type="button" class="btn btn-secondary h-[38px] px-3.5 text-sm" onclick="navigator.share ? navigator.share({title: '{{ article_title|escapejs }}', url: window.location.href}).catch(() => {}) : null">{% trans "Share" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
116
mobile/tests.py
116
mobile/tests.py
@@ -4,7 +4,7 @@ from decimal import Decimal
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils import timezone, translation
|
||||
|
||||
from club.models import Club, ClubMembership, DuesInvoice, Season
|
||||
from events.models import Attendance, Event
|
||||
@@ -642,3 +642,117 @@ class NotificationsViewTests(TestCase):
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
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 NewsDetailScreenTests(TestCase):
|
||||
"""M4 -- design_handoff_rosterchief_platform/README.md's M4 section,
|
||||
"News article". Visibility mirrors news.tasks.notify_news_published's own
|
||||
gate (see NewsDetailView's docstring); language is Django's own
|
||||
active-language detection, not a member-facing toggle."""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.club = make_club()
|
||||
today = timezone.localdate()
|
||||
cls.season = Season.objects.create(club=cls.club, start_date=today - datetime.timedelta(days=30), end_date=today + datetime.timedelta(days=300))
|
||||
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", email="parent@example.com", user=cls.user)
|
||||
ClubMembership.objects.create(club=cls.club, member=cls.member, season=cls.season)
|
||||
|
||||
def _get(self, news_item):
|
||||
return self.client.get(reverse("mobile:news_detail", kwargs={"slug": news_item.slug}), HTTP_HOST="ajax-united.rosterchief.app")
|
||||
|
||||
def test_a_published_item_renders(self):
|
||||
news_item = News.objects.create(club=self.club, title="Season Kickoff", body="We start training next week.", status=News.Status.PUBLISHED, published_at=timezone.now())
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self._get(news_item)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Season Kickoff")
|
||||
self.assertContains(response, "We start training next week.")
|
||||
|
||||
def test_a_draft_item_404s(self):
|
||||
news_item = News.objects.create(club=self.club, title="Draft item", body="Not live yet.", status=News.Status.DRAFT)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self._get(news_item)
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_a_future_scheduled_item_404s(self):
|
||||
news_item = News.objects.create(
|
||||
club=self.club,
|
||||
title="Scheduled item",
|
||||
body="Not live yet.",
|
||||
status=News.Status.PUBLISHED,
|
||||
published_at=timezone.now() + datetime.timedelta(days=1),
|
||||
)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self._get(news_item)
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_english_fallback_is_not_used_when_no_translation_exists_and_the_active_language_is_dutch(self):
|
||||
news_item = News.objects.create(club=self.club, title="Seizoensstart", body="We beginnen volgende week.", status=News.Status.PUBLISHED, published_at=timezone.now())
|
||||
self.client.force_login(self.user)
|
||||
|
||||
with translation.override("nl"):
|
||||
response = self._get(news_item)
|
||||
|
||||
self.assertContains(response, "Seizoensstart")
|
||||
self.assertContains(response, "We beginnen volgende week.")
|
||||
|
||||
def test_english_translation_shows_when_the_active_language_is_english(self):
|
||||
news_item = News.objects.create(
|
||||
club=self.club,
|
||||
title="Seizoensstart",
|
||||
body="We beginnen volgende week.",
|
||||
title_en="Season kickoff",
|
||||
body_en="We start next week.",
|
||||
status=News.Status.PUBLISHED,
|
||||
published_at=timezone.now(),
|
||||
)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
with translation.override("en"):
|
||||
response = self._get(news_item)
|
||||
|
||||
self.assertContains(response, "Season kickoff")
|
||||
self.assertContains(response, "We start next week.")
|
||||
|
||||
def test_teams_show_as_tags_when_present(self):
|
||||
team = Team.objects.create(club=self.club, name="U16", short_name="U16")
|
||||
news_item = News.objects.create(club=self.club, title="Team news", body="Body.", status=News.Status.PUBLISHED, published_at=timezone.now())
|
||||
news_item.teams.add(team)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self._get(news_item)
|
||||
|
||||
self.assertContains(response, "U16")
|
||||
self.assertNotContains(response, "Club news")
|
||||
|
||||
def test_club_wide_item_shows_club_news_when_no_teams(self):
|
||||
news_item = News.objects.create(club=self.club, title="Club-wide news", body="Body.", status=News.Status.PUBLISHED, published_at=timezone.now())
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self._get(news_item)
|
||||
|
||||
self.assertContains(response, "Club news")
|
||||
|
||||
def test_posted_by_shows_when_created_by_is_set(self):
|
||||
news_item = News.objects.create(club=self.club, title="Byline test", body="Body.", status=News.Status.PUBLISHED, published_at=timezone.now(), created_by=self.member)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self._get(news_item)
|
||||
|
||||
self.assertContains(response, "Posted by Lars Bakker")
|
||||
|
||||
def test_requires_login(self):
|
||||
news_item = News.objects.create(club=self.club, title="Season Kickoff", body="Body.", status=News.Status.PUBLISHED, published_at=timezone.now())
|
||||
|
||||
response = self._get(news_item)
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
@@ -19,6 +19,7 @@ from django.shortcuts import get_object_or_404
|
||||
from django.template.loader import render_to_string
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import get_language
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views import View
|
||||
from django.views.generic import TemplateView
|
||||
@@ -372,10 +373,47 @@ class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
||||
return HttpResponseRedirect(reverse("mobile:home"))
|
||||
|
||||
|
||||
class NewsDetailView(_PlaceholderScreen):
|
||||
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
|
||||
news.tasks.notify_news_published's own gate -- PUBLISHED *and* actually
|
||||
live (published_at in the past) -- so a scheduled-but-not-yet-live item
|
||||
404s here exactly like it does everywhere else a member could reach it,
|
||||
rather than leaking a preview of it early.
|
||||
|
||||
Language is Django's own active-language detection, not a member-facing
|
||||
toggle (unlike management's split-view Dutch/English editing tool) --
|
||||
English shows only when the request's active language actually is "en";
|
||||
everything else (including no active language at all) shows the native
|
||||
(Dutch) text.
|
||||
"""
|
||||
|
||||
template_name = "mobile/news_detail.html"
|
||||
screen_title = _("News")
|
||||
active_tab = "news"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
news_item = get_object_or_404(
|
||||
News.objects.prefetch_related("teams", "photos"),
|
||||
club=self.request.club,
|
||||
slug=self.kwargs["slug"],
|
||||
status=News.Status.PUBLISHED,
|
||||
published_at__lte=timezone.now(),
|
||||
)
|
||||
|
||||
if get_language() == "en":
|
||||
title, body = news_item.effective_title_en, news_item.effective_body_en
|
||||
else:
|
||||
title, body = news_item.title, news_item.body
|
||||
|
||||
return super().get_context_data(
|
||||
screen_title=title,
|
||||
news_item=news_item,
|
||||
article_title=title,
|
||||
article_body=body,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
class MeView(_PlaceholderScreen):
|
||||
screen_title = _("Me")
|
||||
|
||||
@@ -3941,6 +3941,9 @@
|
||||
.h-\[34px\] {
|
||||
height: 34px;
|
||||
}
|
||||
.h-\[38px\] {
|
||||
height: 38px;
|
||||
}
|
||||
.h-\[52px\] {
|
||||
height: 52px;
|
||||
}
|
||||
@@ -3962,6 +3965,9 @@
|
||||
.h-\[220px\] {
|
||||
height: 220px;
|
||||
}
|
||||
.h-\[330px\] {
|
||||
height: 330px;
|
||||
}
|
||||
.h-\[560px\] {
|
||||
height: 560px;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user