"""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)