diff --git a/api/tests.py b/api/tests.py
index 5d37276..520943b 100644
--- a/api/tests.py
+++ b/api/tests.py
@@ -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''
+ 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''
+ 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}")
diff --git a/club/api.py b/club/api.py
index 71e2e28..84b7179 100644
--- a/club/api.py
+++ b/club/api.py
@@ -21,6 +21,8 @@ class SponsorOut(Schema):
id: uuid.UUID
name: str
logo_url: str | None
+ logo_width: int | None
+ logo_height: int | None
url: str | None
start_date: date
end_date: date | None
@@ -31,6 +33,8 @@ def _to_sponsor_out(sponsor, request) -> SponsorOut:
id=sponsor.pk,
name=sponsor.name,
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,
start_date=sponsor.start_date,
end_date=sponsor.end_date,
diff --git a/club/migrations/0020_sponsor_logo_height_sponsor_logo_width.py b/club/migrations/0020_sponsor_logo_height_sponsor_logo_width.py
new file mode 100644
index 0000000..760fb6a
--- /dev/null
+++ b/club/migrations/0020_sponsor_logo_height_sponsor_logo_width.py
@@ -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),
+ ]
diff --git a/club/models.py b/club/models.py
index 4123466..982ba7b 100644
--- a/club/models.py
+++ b/club/models.py
@@ -174,6 +174,12 @@ class Sponsor(ClubScopedModel):
# Pillow validation can't read those.
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."))
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:
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):
start_date = models.DateField(_("start date"))
diff --git a/club/services/images.py b/club/services/images.py
new file mode 100644
index 0000000..d279c36
--- /dev/null
+++ b/club/services/images.py
@@ -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