Extend the public API: news excerpts/detail, game team logos, sponsor logo dimensions, player licenses

- news: NewsItemOut gains `excerpt` (truncated body); GET /news/{slug}/
  fetches a single item. slug already auto-populates on save, but a
  data migration backfills any pre-existing blank ones.
- games: home_team/away_team change from plain strings to {id, name,
  logo_url} objects -- home links to the actual Team (logo from the
  club's own logo, since teams have none of their own), away links
  to the actual Opponent (which already had a logo field). Breaking
  change for any existing consumer of the old string shape.
- sponsors: SponsorOut gains logo_width/logo_height, computed in
  Sponsor.save() -- Pillow for raster, a bounded regex read of the
  SVG root tag for vector logos (not a full XML parse, since that's
  exposed to entity-expansion attacks on untrusted uploads). A data
  migration backfills dimensions for existing sponsor logos.
- teams: PlayerOut gains `license`, sourced from ClubMembership (not
  Member -- it's per-club, per-season), batched in one query.
This commit is contained in:
2026-08-07 16:33:49 +02:00
parent fc1942575f
commit 7fd42e6047
9 changed files with 354 additions and 29 deletions

View File

@@ -1,9 +1,12 @@
import datetime import datetime
import io
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from django.utils import timezone from django.utils import timezone
from PIL import Image
from club.models import Club, Season, Sponsor from club.models import Club, ClubMembership, Season, Sponsor
from events.models import Event, Location, Opponent from events.models import Event, Location, Opponent
from members.models import Member from members.models import Member
from news.models import News, NewsPhoto from news.models import News, NewsPhoto
@@ -97,6 +100,47 @@ class NewsApiTests(ApiTestBase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["results"], []) self.assertEqual(response.json()["results"], [])
def test_excerpt_is_a_truncated_prefix_of_the_body(self):
self.make_news(body=" ".join(f"word{i}" for i in range(80)))
excerpt = self.api_get("/news/").json()["results"][0]["excerpt"]
self.assertTrue(excerpt.startswith("word0 word1"))
self.assertTrue(excerpt.endswith(""))
self.assertLess(len(excerpt.split()), 80)
def test_excerpt_is_unchanged_when_the_body_is_already_short(self):
item = self.make_news(body="Short body.")
excerpt = self.api_get("/news/").json()["results"][0]["excerpt"]
self.assertEqual(excerpt, item.body)
def test_slug_is_auto_populated_from_the_title(self):
item = self.make_news(title="Big Win This Weekend")
self.assertEqual(item.slug, "big-win-this-weekend")
def test_get_single_news_item_by_slug(self):
item = self.make_news(title="Big Win This Weekend")
response = self.api_get(f"/news/{item.slug}/")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["id"], str(item.pk))
def test_get_single_news_item_404s_for_an_unknown_slug(self):
response = self.api_get("/news/no-such-item/")
self.assertEqual(response.status_code, 404)
def test_get_single_news_item_respects_visibility(self):
item = self.make_news(visibility=News.Visibility.INTERNAL)
response = self.api_get(f"/news/{item.slug}/")
self.assertEqual(response.status_code, 404)
class TeamsApiTests(ApiTestBase): class TeamsApiTests(ApiTestBase):
def setUp(self): def setUp(self):
@@ -192,6 +236,23 @@ class TeamsApiTests(ApiTestBase):
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
def test_a_players_license_comes_from_their_club_membership(self):
alice = Member.objects.create(first_name="Alice", last_name="Ash")
TeamMembership.objects.create(team=self.team, member=alice, season=self.season, position=self.forward, jersey_number=2)
ClubMembership.objects.create(club=self.club, member=alice, season=self.season, license="BE-12345")
player = self.api_get(f"/teams/{self.team.pk}/roster/").json()["players"][0]["players"][0]
self.assertEqual(player["license"], "BE-12345")
def test_a_players_license_is_null_without_a_club_membership(self):
alice = Member.objects.create(first_name="Alice", last_name="Ash")
TeamMembership.objects.create(team=self.team, member=alice, season=self.season, position=self.forward, jersey_number=2)
player = self.api_get(f"/teams/{self.team.pk}/roster/").json()["players"][0]["players"][0]
self.assertIsNone(player["license"])
class GamesApiTests(ApiTestBase): class GamesApiTests(ApiTestBase):
def setUp(self): def setUp(self):
@@ -212,8 +273,8 @@ class GamesApiTests(ApiTestBase):
games = self.api_get("/games/upcoming/").json() games = self.api_get("/games/upcoming/").json()
self.assertEqual(len(games), 1) self.assertEqual(len(games), 1)
self.assertEqual(games[0]["home_team"], "First Team") self.assertEqual(games[0]["home_team"]["name"], "First Team")
self.assertEqual(games[0]["away_team"], "Rivals FC") self.assertEqual(games[0]["away_team"]["name"], "Rivals FC")
self.assertEqual(games[0]["location"]["name"], "Home Arena") self.assertEqual(games[0]["location"]["name"], "Home Arena")
self.assertEqual(games[0]["status"], "upcoming") self.assertEqual(games[0]["status"], "upcoming")
@@ -289,8 +350,8 @@ class GamesApiTests(ApiTestBase):
games = self.api_get(f"/teams/{self.team.pk}/games/").json() games = self.api_get(f"/teams/{self.team.pk}/games/").json()
self.assertEqual(games[0]["home_team"], "Rivals FC") self.assertEqual(games[0]["home_team"]["name"], "Rivals FC")
self.assertEqual(games[0]["away_team"], "First Team") self.assertEqual(games[0]["away_team"]["name"], "First Team")
self.assertEqual(games[0]["home_score"], 3) self.assertEqual(games[0]["home_score"], 3)
self.assertEqual(games[0]["away_score"], 4) self.assertEqual(games[0]["away_score"], 4)
self.assertEqual(games[0]["status"], "finished") self.assertEqual(games[0]["status"], "finished")
@@ -300,8 +361,8 @@ class GamesApiTests(ApiTestBase):
games = self.api_get(f"/teams/{self.team.pk}/games/").json() games = self.api_get(f"/teams/{self.team.pk}/games/").json()
self.assertEqual(games[0]["home_team"], "First Team") self.assertEqual(games[0]["home_team"]["name"], "First Team")
self.assertEqual(games[0]["away_team"], "Rivals FC") self.assertEqual(games[0]["away_team"]["name"], "Rivals FC")
self.assertEqual(games[0]["home_score"], 4) self.assertEqual(games[0]["home_score"], 4)
self.assertEqual(games[0]["away_score"], 3) self.assertEqual(games[0]["away_score"], 3)
@@ -338,6 +399,36 @@ class GamesApiTests(ApiTestBase):
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
def test_home_team_links_to_the_actual_team_and_the_clubs_logo(self):
# Our own teams have no logo of their own -- they're shown under the club's badge.
self.club.logo = "clubs/ajax-united/logo.png"
self.club.save()
self.make_game(location=self.home_location)
home_team = self.api_get("/games/upcoming/").json()[0]["home_team"]
self.assertEqual(home_team["id"], str(self.team.pk))
self.assertEqual(home_team["name"], "First Team")
self.assertTrue(home_team["logo_url"].startswith("http://ajax-united.rosterchief.app/media/"))
def test_away_team_links_to_the_opponent_and_its_own_logo(self):
self.opponent.logo = "opponents/rivals.png"
self.opponent.save()
self.make_game(location=self.home_location)
away_team = self.api_get("/games/upcoming/").json()[0]["away_team"]
self.assertEqual(away_team["id"], str(self.opponent.pk))
self.assertEqual(away_team["name"], "Rivals FC")
self.assertTrue(away_team["logo_url"].startswith("http://ajax-united.rosterchief.app/media/"))
def test_team_logo_url_is_null_without_a_club_logo(self):
self.make_game(location=self.home_location)
home_team = self.api_get("/games/upcoming/").json()[0]["home_team"]
self.assertIsNone(home_team["logo_url"])
class TenancyAndCorsTests(ApiTestBase): class TenancyAndCorsTests(ApiTestBase):
def test_the_base_domain_404s(self): def test_the_base_domain_404s(self):
@@ -432,6 +523,44 @@ class SponsorApiTests(ApiTestBase):
self.assertIsNone(self.api_get("/sponsors/").json()[0]["logo_url"]) self.assertIsNone(self.api_get("/sponsors/").json()[0]["logo_url"])
def test_logo_dimensions_are_computed_for_a_raster_image(self):
buffer = io.BytesIO()
Image.new("RGB", (300, 150)).save(buffer, format="PNG")
logo = SimpleUploadedFile("logo.png", buffer.getvalue(), content_type="image/png")
self.make_sponsor(logo=logo)
sponsor = self.api_get("/sponsors/").json()[0]
self.assertEqual(sponsor["logo_width"], 300)
self.assertEqual(sponsor["logo_height"], 150)
def test_logo_dimensions_are_computed_for_an_svg_with_width_and_height(self):
svg = b'<svg xmlns="http://www.w3.org/2000/svg" width="120" height="80"></svg>'
logo = SimpleUploadedFile("logo.svg", svg, content_type="image/svg+xml")
self.make_sponsor(logo=logo)
sponsor = self.api_get("/sponsors/").json()[0]
self.assertEqual(sponsor["logo_width"], 120)
self.assertEqual(sponsor["logo_height"], 80)
def test_logo_dimensions_fall_back_to_an_svg_viewbox(self):
svg = b'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 32"></svg>'
logo = SimpleUploadedFile("logo.svg", svg, content_type="image/svg+xml")
self.make_sponsor(logo=logo)
sponsor = self.api_get("/sponsors/").json()[0]
self.assertEqual(sponsor["logo_width"], 64)
self.assertEqual(sponsor["logo_height"], 32)
def test_logo_dimensions_are_null_without_a_logo(self):
self.make_sponsor()
sponsor = self.api_get("/sponsors/").json()[0]
self.assertIsNone(sponsor["logo_width"])
self.assertIsNone(sponsor["logo_height"])
def test_randomize_returns_the_same_set_of_sponsors(self): def test_randomize_returns_the_same_set_of_sponsors(self):
for i in range(5): for i in range(5):
self.make_sponsor(name=f"Sponsor {i}") self.make_sponsor(name=f"Sponsor {i}")

View File

@@ -21,6 +21,8 @@ class SponsorOut(Schema):
id: uuid.UUID id: uuid.UUID
name: str name: str
logo_url: str | None logo_url: str | None
logo_width: int | None
logo_height: int | None
url: str | None url: str | None
start_date: date start_date: date
end_date: date | None end_date: date | None
@@ -31,6 +33,8 @@ def _to_sponsor_out(sponsor, request) -> SponsorOut:
id=sponsor.pk, id=sponsor.pk,
name=sponsor.name, name=sponsor.name,
logo_url=request.build_absolute_uri(sponsor.logo.url) if sponsor.logo else None, logo_url=request.build_absolute_uri(sponsor.logo.url) if sponsor.logo else None,
logo_width=sponsor.logo_width,
logo_height=sponsor.logo_height,
url=sponsor.url or None, url=sponsor.url or None,
start_date=sponsor.start_date, start_date=sponsor.start_date,
end_date=sponsor.end_date, end_date=sponsor.end_date,

View File

@@ -0,0 +1,35 @@
# Generated by Django 6.0.6 on 2026-08-07 14:26
from django.db import migrations, models
from club.services.images import get_image_dimensions
def backfill_logo_dimensions(apps, schema_editor):
"""Existing sponsors uploaded a logo before these fields existed, so
Sponsor.save()'s new dimension computation never ran for them."""
Sponsor = apps.get_model("club", "Sponsor")
for sponsor in Sponsor.objects.exclude(logo=""):
width, height = get_image_dimensions(sponsor.logo)
Sponsor.objects.filter(pk=sponsor.pk).update(logo_width=width, logo_height=height)
class Migration(migrations.Migration):
dependencies = [
('club', '0019_sponsor'),
]
operations = [
migrations.AddField(
model_name='sponsor',
name='logo_height',
field=models.PositiveIntegerField(blank=True, editable=False, null=True, verbose_name='logo height'),
),
migrations.AddField(
model_name='sponsor',
name='logo_width',
field=models.PositiveIntegerField(blank=True, editable=False, null=True, verbose_name='logo width'),
),
migrations.RunPython(backfill_logo_dimensions, migrations.RunPython.noop),
]

View File

@@ -174,6 +174,12 @@ class Sponsor(ClubScopedModel):
# Pillow validation can't read those. # Pillow validation can't read those.
validators=[FileExtensionValidator(allowed_extensions=["png", "jpg", "jpeg", "gif", "webp", "svg"])], validators=[FileExtensionValidator(allowed_extensions=["png", "jpg", "jpeg", "gif", "webp", "svg"])],
) )
# Not user-editable: recomputed from the logo file itself on every save, same reasoning
# NewsPhoto/TeamPhoto don't need this -- FileField (not ImageField) means Django never
# populates width/height on its own. The public API exposes these so a consumer can lay
# out a sponsor strip without waiting on the image to load.
logo_width = models.PositiveIntegerField(_("logo width"), null=True, blank=True, editable=False)
logo_height = models.PositiveIntegerField(_("logo height"), null=True, blank=True, editable=False)
url = models.URLField(_("URL"), blank=True, help_text=_("The sponsor's own website, if they have one.")) url = models.URLField(_("URL"), blank=True, help_text=_("The sponsor's own website, if they have one."))
start_date = models.DateField(_("start date")) start_date = models.DateField(_("start date"))
@@ -191,6 +197,14 @@ class Sponsor(ClubScopedModel):
if self.end_date is not None and self.start_date is not None and self.end_date < self.start_date: if self.end_date is not None and self.start_date is not None and self.end_date < self.start_date:
raise ValidationError({"end_date": _("End date can't be before the start date.")}) raise ValidationError({"end_date": _("End date can't be before the start date.")})
def save(self, *args, **kwargs):
# Deferred: club.services (via its __init__) imports back from club.models, so a
# module-level import here would be circular.
from club.services.images import get_image_dimensions
self.logo_width, self.logo_height = get_image_dimensions(self.logo) if self.logo else (None, None)
super().save(*args, **kwargs)
class Season(ClubScopedModel): class Season(ClubScopedModel):
start_date = models.DateField(_("start date")) start_date = models.DateField(_("start date"))

82
club/services/images.py Normal file
View File

@@ -0,0 +1,82 @@
"""Dimensions for uploads that aren't Django ImageFields.
Logos (Club.logo, Sponsor.logo) are plain FileFields, not ImageFields --
Pillow can't validate SVGs, and crests/sponsor logos are commonly vector
files -- so there's no automatic width_field/height_field the way there
would be on an ImageField. This fills that gap: Pillow for raster formats,
a bounded regex read of the root <svg> tag for vector ones (not a full XML
parse -- this reads untrusted uploads, and a parser is exposed to entity
expansion attacks a plain attribute read never is).
"""
import re
from PIL import Image, UnidentifiedImageError
_SVG_TAG_RE = re.compile(rb"<svg\b[^>]*>", re.IGNORECASE | re.DOTALL)
_WIDTH_RE = re.compile(rb"""\bwidth\s*=\s*["']([^"']+)["']""", re.IGNORECASE)
_HEIGHT_RE = re.compile(rb"""\bheight\s*=\s*["']([^"']+)["']""", re.IGNORECASE)
_VIEWBOX_RE = re.compile(rb"""\bviewBox\s*=\s*["']\s*([\d.+-]+)[ ,]+([\d.+-]+)[ ,]+([\d.+-]+)[ ,]+([\d.+-]+)""", re.IGNORECASE)
_LEADING_NUMBER_RE = re.compile(r"[\d.]+")
#: The root <svg> tag is always near the top of the file -- no need to read
#: (or regex-scan) anything past a small header.
_SVG_HEAD_BYTES = 8192
def _svg_length(raw: bytes) -> int | None:
"""Parse an SVG length attribute (``"200"``, ``"200px"``) to a rounded
int, or None if it's relative (``"100%"``) and so not a real pixel size."""
text = raw.decode("utf-8", errors="ignore").strip()
if text.endswith("%"):
return None
match = _LEADING_NUMBER_RE.match(text)
return round(float(match.group(0))) if match else None
def _svg_dimensions(file) -> tuple[int | None, int | None]:
try:
file.seek(0)
head = file.read(_SVG_HEAD_BYTES)
except OSError:
return None, None
# Reset for whatever reads the file next (e.g. FileField writing it to storage).
file.seek(0)
tag_match = _SVG_TAG_RE.search(head)
svg_tag = tag_match.group(0) if tag_match else head
width_match, height_match = _WIDTH_RE.search(svg_tag), _HEIGHT_RE.search(svg_tag)
if width_match and height_match:
width, height = _svg_length(width_match.group(1)), _svg_length(height_match.group(1))
if width and height:
return width, height
viewbox_match = _VIEWBOX_RE.search(svg_tag)
if viewbox_match:
_, _, width, height = viewbox_match.groups()
return round(float(width)), round(float(height))
return None, None
def get_image_dimensions(file) -> tuple[int | None, int | None]:
"""Best-effort (width, height) for an uploaded logo -- (None, None) if the
file can't be read as an image (corrupt upload, unrecognised format)."""
if not file:
return None, None
name = getattr(file, "name", "") or ""
if name.lower().endswith(".svg"):
return _svg_dimensions(file)
try:
file.seek(0)
with Image.open(file) as image:
size = image.size
# Reset for whatever reads the file next (e.g. FileField writing it to storage) --
# only on the success path, since a failed open/read leaves nothing to rewind.
file.seek(0)
return size
except (OSError, UnidentifiedImageError):
return None, None

View File

@@ -40,12 +40,18 @@ class LocationOut(Schema):
is_home: bool is_home: bool
class TeamRefOut(Schema):
id: uuid.UUID
name: str
logo_url: str | None
class GameOut(Schema): class GameOut(Schema):
id: uuid.UUID id: uuid.UUID
start: datetime start: datetime
location: LocationOut | None location: LocationOut | None
home_team: str | None home_team: TeamRefOut | None
away_team: str | None away_team: TeamRefOut | None
competition: str competition: str
is_live: bool is_live: bool
status: str # "upcoming" | "live" | "finished" status: str # "upcoming" | "live" | "finished"
@@ -53,21 +59,27 @@ class GameOut(Schema):
away_score: int | None away_score: int | None
def _to_game_out(event, team=None) -> GameOut: def _to_team_ref_out(request, *, id, name, logo) -> TeamRefOut:
return TeamRefOut(id=id, name=name, logo_url=request.build_absolute_uri(logo.url) if logo else None)
def _to_game_out(event, request, club, team=None) -> GameOut:
if team is None: if team is None:
# .first() would re-query even with teams prefetched; go through the # .first() would re-query even with teams prefetched; go through the
# prefetch cache instead. # prefetch cache instead.
related_teams = list(event.teams.all()) related_teams = list(event.teams.all())
team = related_teams[0] if related_teams else None team = related_teams[0] if related_teams else None
team_name = team.name if team is not None else None # Our own team has no logo of its own -- it's shown under the club's badge. The
opponent_name = event.opponent.name if event.opponent_id else None # opponent is an events.models.Opponent, which does carry its own logo.
team_ref = _to_team_ref_out(request, id=team.pk, name=team.name, logo=club.logo) if team is not None else None
opponent_ref = _to_team_ref_out(request, id=event.opponent_id, name=event.opponent.name, logo=event.opponent.logo) if event.opponent_id else None
if event.is_home_game: if event.is_home_game:
home_team, away_team = team_name, opponent_name home_team, away_team = team_ref, opponent_ref
home_score, away_score = event.score_for, event.score_against home_score, away_score = event.score_for, event.score_against
else: else:
home_team, away_team = opponent_name, team_name home_team, away_team = opponent_ref, team_ref
home_score, away_score = event.score_against, event.score_for home_score, away_score = event.score_against, event.score_for
if event.is_live: if event.is_live:
@@ -103,7 +115,7 @@ def list_upcoming_games(request, count: int = DEFAULT_UPCOMING_COUNT):
events = Event.objects.filter(club=club, kind__in=UPCOMING_KINDS, cancelled=False, start__gte=timezone.now()).select_related("opponent", "location").prefetch_related("teams").order_by("start")[:count] events = Event.objects.filter(club=club, kind__in=UPCOMING_KINDS, cancelled=False, start__gte=timezone.now()).select_related("opponent", "location").prefetch_related("teams").order_by("start")[:count]
return [_to_game_out(event) for event in events] return [_to_game_out(event, request, club) for event in events]
@router.get("/games/live/", response=list[GameOut], summary="Live games") @router.get("/games/live/", response=list[GameOut], summary="Live games")
@@ -112,7 +124,7 @@ def list_live_games(request):
events = Event.objects.filter(club=club, kind=Event.EventKind.GAME, cancelled=False, is_live=True).select_related("opponent", "location").prefetch_related("teams").order_by("start") events = Event.objects.filter(club=club, kind=Event.EventKind.GAME, cancelled=False, is_live=True).select_related("opponent", "location").prefetch_related("teams").order_by("start")
return [_to_game_out(event) for event in events] return [_to_game_out(event, request, club) for event in events]
@router.get("/teams/{team_id}/games/", response=list[GameOut], summary="A team's current-season games") @router.get("/teams/{team_id}/games/", response=list[GameOut], summary="A team's current-season games")
@@ -136,4 +148,4 @@ def list_team_games(request, team_id: uuid.UUID):
.order_by("start") .order_by("start")
) )
return [_to_game_out(event, team=team) for event in events] return [_to_game_out(event, request, club, team=team) for event in events]

View File

@@ -11,7 +11,9 @@ import uuid
from datetime import datetime from datetime import datetime
from django.utils import timezone from django.utils import timezone
from django.utils.text import Truncator
from ninja import Router, Schema from ninja import Router, Schema
from ninja.errors import HttpError
from api.errors import require_club from api.errors import require_club
@@ -22,6 +24,11 @@ router = Router(tags=["news"])
DEFAULT_LIMIT = 20 DEFAULT_LIMIT = 20
MAX_LIMIT = 100 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): class NewsPhotoOut(Schema):
url: str url: str
@@ -33,6 +40,7 @@ class NewsItemOut(Schema):
id: uuid.UUID id: uuid.UUID
title: str title: str
slug: str slug: str
excerpt: str
body: str body: str
published_at: datetime published_at: datetime
teams: list[str] teams: list[str]
@@ -46,11 +54,21 @@ class NewsListOut(Schema):
results: list[NewsItemOut] 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: def _to_news_item_out(item, request) -> NewsItemOut:
return NewsItemOut( return NewsItemOut(
id=item.pk, id=item.pk,
title=item.title, title=item.title,
slug=item.slug, slug=item.slug,
excerpt=Truncator(item.body).words(EXCERPT_WORDS, truncate=""),
body=item.body, body=item.body,
published_at=item.published_at, published_at=item.published_at,
teams=[team.name for team in item.teams.all()], teams=[team.name for team in item.teams.all()],
@@ -66,18 +84,19 @@ def list_news(request, limit: int = DEFAULT_LIMIT, offset: int = 0):
limit = max(1, min(limit, MAX_LIMIT)) limit = max(1, min(limit, MAX_LIMIT))
offset = max(0, offset) offset = max(0, offset)
queryset = ( queryset = _visible_news(club).prefetch_related("photos", "teams").order_by("-published_at")
News.objects.filter(
club=club,
status=News.Status.PUBLISHED,
published_at__lte=timezone.now(),
visibility__in=[News.Visibility.EXTERNAL, News.Visibility.BOTH],
)
.prefetch_related("photos", "teams")
.order_by("-published_at")
)
count = queryset.count() count = queryset.count()
page = queryset[offset : offset + limit] page = queryset[offset : offset + limit]
return NewsListOut(count=count, limit=limit, offset=offset, results=[_to_news_item_out(item, request) for item in page]) 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)

View File

@@ -0,0 +1,23 @@
from django.db import migrations
from rosterchief.base import unique_slugify
def backfill_slugs(apps, schema_editor):
"""News.save() already auto-populates a blank slug (rosterchief.base.
ClubScopedModel.save), but that only fires on save() -- rows created
before the slug field existed, or via bulk_create, never got one."""
News = apps.get_model("news", "News")
for item in News.objects.filter(slug=""):
item.slug = unique_slugify(item, item.title, scope={"club_id": item.club_id})
item.save(update_fields=["slug"])
class Migration(migrations.Migration):
dependencies = [
("news", "0001_initial"),
]
operations = [
migrations.RunPython(backfill_slugs, migrations.RunPython.noop),
]

View File

@@ -11,6 +11,7 @@ from ninja import Router, Schema
from ninja.errors import HttpError from ninja.errors import HttpError
from api.errors import require_club from api.errors import require_club
from club.models import ClubMembership
from club.services.access import current_season from club.services.access import current_season
from .models import Team, TeamMembership, TeamPhoto from .models import Team, TeamMembership, TeamPhoto
@@ -32,6 +33,7 @@ class PlayerOut(Schema):
jersey_number: int | None jersey_number: int | None
is_captain: bool is_captain: bool
is_alternate_captain: bool is_alternate_captain: bool
license: str | None
class PositionGroupOut(Schema): class PositionGroupOut(Schema):
@@ -58,7 +60,7 @@ def _to_team_out(team, request, photo=None) -> TeamOut:
return TeamOut(id=team.pk, name=team.name, short_name=team.short_name, photo_url=photo_url) return TeamOut(id=team.pk, name=team.name, short_name=team.short_name, photo_url=photo_url)
def _to_player_out(membership) -> PlayerOut: def _to_player_out(membership, license_by_member_id) -> PlayerOut:
return PlayerOut( return PlayerOut(
id=membership.member_id, id=membership.member_id,
first_name=membership.member.first_name, first_name=membership.member.first_name,
@@ -66,6 +68,7 @@ def _to_player_out(membership) -> PlayerOut:
jersey_number=membership.jersey_number, jersey_number=membership.jersey_number,
is_captain=membership.is_captain, is_captain=membership.is_captain,
is_alternate_captain=membership.is_alternate_captain, is_alternate_captain=membership.is_alternate_captain,
license=license_by_member_id.get(membership.member_id) or None,
) )
@@ -84,9 +87,13 @@ def build_roster(team, request) -> RosterOut:
memberships = TeamMembership.objects.filter(team=team, season=season).select_related("member", "position").order_by("position__ordering", "position__name", "jersey_number") memberships = TeamMembership.objects.filter(team=team, season=season).select_related("member", "position").order_by("position__ordering", "position__name", "jersey_number")
assignments = team.staff_assignments.filter(season=season).select_related("member", "position").order_by("position__ordering", "position__name", "member__last_name") assignments = team.staff_assignments.filter(season=season).select_related("member", "position").order_by("position__ordering", "position__name", "member__last_name")
# A player's license lives on their club-wide ClubMembership for the season, not on
# TeamMembership -- one query for all of them rather than one per player.
license_by_member_id = dict(ClubMembership.objects.filter(club=team.club, season=season).values_list("member_id", "license"))
# groupby only groups consecutive runs -- relies on the queryset already # groupby only groups consecutive runs -- relies on the queryset already
# being ordered by position first, which it is. # being ordered by position first, which it is.
players = [PositionGroupOut(position=position_name, players=[_to_player_out(m) for m in members]) for position_name, members in groupby(memberships, key=lambda m: m.position.name)] players = [PositionGroupOut(position=position_name, players=[_to_player_out(m, license_by_member_id) for m in members]) for position_name, members in groupby(memberships, key=lambda m: m.position.name)]
staff = [ staff = [
StaffMemberOut(id=assignment.member_id, first_name=assignment.member.first_name, last_name=assignment.member.last_name, position=assignment.position.name) StaffMemberOut(id=assignment.member_id, first_name=assignment.member.first_name, last_name=assignment.member.last_name, position=assignment.position.name)
for assignment in assignments for assignment in assignments