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 io
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
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 members.models import Member
from news.models import News, NewsPhoto
@@ -97,6 +100,47 @@ class NewsApiTests(ApiTestBase):
self.assertEqual(response.status_code, 200)
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):
def setUp(self):
@@ -192,6 +236,23 @@ class TeamsApiTests(ApiTestBase):
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):
def setUp(self):
@@ -212,8 +273,8 @@ class GamesApiTests(ApiTestBase):
games = self.api_get("/games/upcoming/").json()
self.assertEqual(len(games), 1)
self.assertEqual(games[0]["home_team"], "First Team")
self.assertEqual(games[0]["away_team"], "Rivals FC")
self.assertEqual(games[0]["home_team"]["name"], "First Team")
self.assertEqual(games[0]["away_team"]["name"], "Rivals FC")
self.assertEqual(games[0]["location"]["name"], "Home Arena")
self.assertEqual(games[0]["status"], "upcoming")
@@ -289,8 +350,8 @@ class GamesApiTests(ApiTestBase):
games = self.api_get(f"/teams/{self.team.pk}/games/").json()
self.assertEqual(games[0]["home_team"], "Rivals FC")
self.assertEqual(games[0]["away_team"], "First Team")
self.assertEqual(games[0]["home_team"]["name"], "Rivals FC")
self.assertEqual(games[0]["away_team"]["name"], "First Team")
self.assertEqual(games[0]["home_score"], 3)
self.assertEqual(games[0]["away_score"], 4)
self.assertEqual(games[0]["status"], "finished")
@@ -300,8 +361,8 @@ class GamesApiTests(ApiTestBase):
games = self.api_get(f"/teams/{self.team.pk}/games/").json()
self.assertEqual(games[0]["home_team"], "First Team")
self.assertEqual(games[0]["away_team"], "Rivals FC")
self.assertEqual(games[0]["home_team"]["name"], "First Team")
self.assertEqual(games[0]["away_team"]["name"], "Rivals FC")
self.assertEqual(games[0]["home_score"], 4)
self.assertEqual(games[0]["away_score"], 3)
@@ -338,6 +399,36 @@ class GamesApiTests(ApiTestBase):
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):
def test_the_base_domain_404s(self):
@@ -432,6 +523,44 @@ class SponsorApiTests(ApiTestBase):
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):
for i in range(5):
self.make_sponsor(name=f"Sponsor {i}")