Files
RosterChief/news/api.py
Bernard Siebens 5dcffefb28 Round out referee amounts/colors, and add bilingual (nl/en) news content
Referee display: drop the redundant "External" pill from the PDF form, round
the event/dashboard "due" summary to 2 decimals (a fine-grained km rate like
0.083 was pushing the raw total to 3+ decimals), and theme the PDF's accent
colors off the club's own primary/secondary colors instead of a hardcoded
default.

News: title/body stay the club's own-language (Dutch) text; new optional
title_en/body_en carry a translation, with News.effective_title_en/
effective_body_en resolving the fallback to the original on read rather than
copying it in at save time -- so editing the Dutch text later never leaves a
stale English copy behind, and existing rows get correct fallback behaviour
with no backfill. The news form lays both languages out side by side; the
detail page only shows an English section when one was actually added. The
public API returns both languages in one call (title_nl/body_nl/excerpt_nl
alongside title_en/body_en/excerpt_en, the latter never blank) -- a breaking
rename of the previously-unprefixed title/body/excerpt fields.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 20:47:58 +02:00

116 lines
3.7 KiB
Python

"""Public read-only news endpoint -- see api/urls.py for how this is mounted.
Pagination is hand-rolled (limit/offset params) rather than Ninja's built-in
@paginate: a photo's URL has to be made absolute against `request`
(api/urls.py's docstring explains why), and Ninja's response-schema resolvers
don't have request access, so building the page manually here is simpler than
fighting that.
"""
import uuid
from datetime import datetime
from django.utils import timezone
from ninja import Router, Schema
from ninja.errors import HttpError
from api.errors import require_club
from .models import News
from .services import render_body_excerpt, render_body_html
router = Router(tags=["news"])
DEFAULT_LIMIT = 20
MAX_LIMIT = 100
#: Words, not characters -- reads more naturally cut off mid-list than a hard
#: character count, and body is plain text so there's no markup to worry about
#: truncating mid-tag.
EXCERPT_WORDS = 40
class NewsPhotoOut(Schema):
url: str
is_main: bool
ordering: int
class NewsItemOut(Schema):
"""Both languages come back in one object -- title_nl/body_nl/excerpt_nl are
the club's own-language text as authored; title_en/body_en/excerpt_en are the
English text, falling back to the nl content when no translation was added
(News.effective_title_en/effective_body_en) -- so these three are never blank,
even for a news item nobody has translated yet. The consumer picks whichever
it needs."""
id: uuid.UUID
title_nl: str
title_en: str
slug: str
excerpt_nl: str
excerpt_en: str
body_nl: str
body_en: str
published_at: datetime
teams: list[str]
photos: list[NewsPhotoOut]
class NewsListOut(Schema):
count: int
limit: int
offset: int
results: list[NewsItemOut]
def _visible_news(club):
return News.objects.filter(
club=club,
status=News.Status.PUBLISHED,
published_at__lte=timezone.now(),
visibility__in=[News.Visibility.EXTERNAL, News.Visibility.BOTH],
)
def _to_news_item_out(item, request) -> NewsItemOut:
return NewsItemOut(
id=item.pk,
title_nl=item.title,
title_en=item.effective_title_en,
slug=item.slug,
excerpt_nl=render_body_excerpt(item.body, words=EXCERPT_WORDS),
excerpt_en=render_body_excerpt(item.effective_body_en, words=EXCERPT_WORDS),
body_nl=render_body_html(item.body),
body_en=render_body_html(item.effective_body_en),
published_at=item.published_at,
teams=[team.name for team in item.teams.all()],
photos=[NewsPhotoOut(url=request.build_absolute_uri(photo.image.url), is_main=photo.is_main, ordering=photo.ordering) for photo in item.photos.all()],
)
@router.get("/", response=NewsListOut, summary="Published news")
def list_news(request, limit: int = DEFAULT_LIMIT, offset: int = 0):
"""Published news items whose release date has passed and that are marked
visible outside the club (`external` or `both`) -- newest first."""
club = require_club(request)
limit = max(1, min(limit, MAX_LIMIT))
offset = max(0, offset)
queryset = _visible_news(club).prefetch_related("photos", "teams").order_by("-published_at")
count = queryset.count()
page = queryset[offset : offset + limit]
return NewsListOut(count=count, limit=limit, offset=offset, results=[_to_news_item_out(item, request) for item in page])
@router.get("/{slug}/", response=NewsItemOut, summary="Single published news item")
def get_news_item(request, slug: str):
club = require_club(request)
item = _visible_news(club).filter(slug=slug).prefetch_related("photos", "teams").first()
if item is None:
raise HttpError(404, "No such news item.")
return _to_news_item_out(item, request)