Add billing-ending banner, events CRUD, RBIHF import, public API, team photos, and sponsors

A large batch of club-management features built up over one session:

- Club dashboard banner warning admins 1 month before billing ends
- Full Events/EventSeries CRUD (recurrence builder, occurrence lifecycle,
  per-team permissions), with match->game rename and game-specific fields
  (score, competition, live status, external game ID)
- Django-admin competition dropdown, gated per-club by feature flag
- Auto-import of RBIHF fixtures (scrape -> diff -> preview -> confirm),
  with location/opponent dropdowns suggested from existing club data
- Feature-flag-gated Shop/Forms nav sections, reusing the same flag
  machinery for the RBIHF import button
- Team roster now scoped to members active this season or next, sorted and
  grouped by position
- Club sport type (ice hockey / other), shown in the control panel's club
  subtitle
- Per-season team photo upload from the team page
- New public read-only API (Django Ninja) at /api/v1/: news, team rosters,
  upcoming/live/per-team games, and sponsors -- auto-documented via Swagger
  UI, CORS-enabled for a club's own external website
- Club sponsors: admin-only CRUD (logo, URL, active date window) plus a
  date-windowed, optionally randomized API endpoint
- Assorted fixes: NullBooleanField dropdown rendering, cross-club event
  validation timing, searchable-select chip placement, btn-neutral ->
  default button style sweep, calendar-month chart windows

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1gj3J1QPfP38XWpnpbFpy
This commit is contained in:
2026-08-06 17:36:04 +02:00
parent 6ad0d6658c
commit 98b8002a04
101 changed files with 6165 additions and 135 deletions

451
api/tests.py Normal file
View File

@@ -0,0 +1,451 @@
import datetime
from django.test import TestCase, override_settings
from django.utils import timezone
from club.models import Club, Season, Sponsor
from events.models import Event, Location, Opponent
from members.models import Member
from news.models import News, NewsPhoto
from teams.models import Position, StaffAssignment, Team, TeamMembership, TeamPhoto
@override_settings(
ROSTERCHIEF_BASE_DOMAIN="rosterchief.app",
ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "rival-fc.rosterchief.app", "testserver"],
)
class ApiTestBase(TestCase):
def setUp(self):
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
today = timezone.localdate()
self.season = Season.objects.create(club=self.club, start_date=today - datetime.timedelta(days=30), end_date=today + datetime.timedelta(days=300))
self.team = Team.objects.create(club=self.club, name="First Team", short_name="1st")
def api_get(self, path, **params):
return self.client.get(f"/api/v1{path}", params, HTTP_HOST="ajax-united.rosterchief.app")
def api_get_base_domain(self, path, **params):
return self.client.get(f"/api/v1{path}", params, HTTP_HOST="rosterchief.app")
class NewsApiTests(ApiTestBase):
def make_news(self, **overrides):
defaults = {"club": self.club, "title": "News", "body": "body", "status": News.Status.PUBLISHED, "published_at": timezone.now() - datetime.timedelta(hours=1), "visibility": News.Visibility.EXTERNAL}
defaults.update(overrides)
return News.objects.create(**defaults)
def test_a_draft_is_excluded(self):
self.make_news(status=News.Status.DRAFT, published_at=None)
self.assertEqual(self.api_get("/news/").json()["count"], 0)
def test_a_scheduled_but_not_yet_released_item_is_excluded(self):
self.make_news(published_at=timezone.now() + datetime.timedelta(days=1))
self.assertEqual(self.api_get("/news/").json()["count"], 0)
def test_an_internal_only_item_is_excluded(self):
self.make_news(visibility=News.Visibility.INTERNAL)
self.assertEqual(self.api_get("/news/").json()["count"], 0)
def test_an_external_item_is_included(self):
item = self.make_news(visibility=News.Visibility.EXTERNAL)
data = self.api_get("/news/").json()
self.assertEqual(data["count"], 1)
self.assertEqual(data["results"][0]["id"], str(item.pk))
def test_a_both_visibility_item_is_included(self):
self.make_news(visibility=News.Visibility.BOTH)
self.assertEqual(self.api_get("/news/").json()["count"], 1)
def test_newest_first(self):
older = self.make_news(title="Older", published_at=timezone.now() - datetime.timedelta(days=2))
newer = self.make_news(title="Newer", published_at=timezone.now() - datetime.timedelta(hours=1))
results = self.api_get("/news/").json()["results"]
self.assertEqual([r["id"] for r in results], [str(newer.pk), str(older.pk)])
def test_photos_get_absolute_urls(self):
item = self.make_news()
NewsPhoto.objects.create(news_item=item, image="clubs/ajax-united/news/x/pic.jpg", is_main=True)
photo = self.api_get("/news/").json()["results"][0]["photos"][0]
self.assertTrue(photo["url"].startswith("http://ajax-united.rosterchief.app/media/"))
self.assertTrue(photo["is_main"])
def test_pagination_limit_and_offset(self):
for i in range(3):
self.make_news(title=f"Item {i}", published_at=timezone.now() - datetime.timedelta(hours=1, minutes=i))
data = self.api_get("/news/", limit=1, offset=1).json()
self.assertEqual(data["count"], 3)
self.assertEqual(len(data["results"]), 1)
def test_limit_is_capped(self):
self.assertEqual(self.api_get("/news/", limit=1000).json()["limit"], 100)
def test_no_news_is_an_empty_list_not_an_error(self):
response = self.api_get("/news/")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["results"], [])
class TeamsApiTests(ApiTestBase):
def setUp(self):
super().setUp()
self.forward = Position.objects.create(club=self.club, name="Forward", short_name="FW", ordering=1)
self.defense = Position.objects.create(club=self.club, name="Defense", short_name="DF", ordering=2)
self.coach_position = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True, management_position=True)
def test_list_teams(self):
data = self.api_get("/teams/").json()
self.assertEqual(data, [{"id": str(self.team.pk), "name": "First Team", "short_name": "1st", "photo_url": None}])
def test_roster_groups_players_by_position(self):
alice = Member.objects.create(first_name="Alice", last_name="Ash")
bob = Member.objects.create(first_name="Bob", last_name="Birch")
carol = Member.objects.create(first_name="Carol", last_name="Cedar")
# Two forwards (ordering 1) at different jersey numbers, one defense (ordering 2).
TeamMembership.objects.create(team=self.team, member=bob, season=self.season, position=self.forward, jersey_number=9)
TeamMembership.objects.create(team=self.team, member=alice, season=self.season, position=self.forward, jersey_number=2)
TeamMembership.objects.create(team=self.team, member=carol, season=self.season, position=self.defense, jersey_number=1)
groups = self.api_get(f"/teams/{self.team.pk}/roster/").json()["players"]
# Groups in Position.ordering order (Forward before Defense); within a
# group, sorted by jersey number.
self.assertEqual([g["position"] for g in groups], ["Forward", "Defense"])
self.assertEqual([p["first_name"] for p in groups[0]["players"]], ["Alice", "Bob"])
self.assertEqual([p["first_name"] for p in groups[1]["players"]], ["Carol"])
def test_a_player_entry_no_longer_repeats_its_position(self):
# The position is now the group key, not a per-player field.
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.assertNotIn("position", player)
def test_roster_includes_staff(self):
dana = Member.objects.create(first_name="Dana", last_name="Dean")
StaffAssignment.objects.create(team=self.team, member=dana, season=self.season, position=self.coach_position)
staff = self.api_get(f"/teams/{self.team.pk}/roster/").json()["staff"]
self.assertEqual(staff, [{"id": str(dana.pk), "first_name": "Dana", "last_name": "Dean", "position": "Head Coach"}])
def test_roster_is_current_season_only(self):
other_season = Season.objects.create(club=self.club, start_date=datetime.date(2000, 1, 1), end_date=datetime.date(2000, 12, 31))
eve = Member.objects.create(first_name="Eve", last_name="Elm")
TeamMembership.objects.create(team=self.team, member=eve, season=other_season, position=self.forward, jersey_number=1)
players = self.api_get(f"/teams/{self.team.pk}/roster/").json()["players"]
self.assertEqual(players, [])
def test_roster_is_empty_with_no_current_season(self):
self.season.delete()
team_without_season = self.team
data = self.api_get(f"/teams/{team_without_season.pk}/roster/").json()
self.assertEqual(data["season"], None)
self.assertEqual(data["players"], [])
self.assertEqual(data["staff"], [])
def test_list_teams_includes_the_current_seasons_photo(self):
TeamPhoto.objects.create(team=self.team, season=self.season, image="clubs/ajax-united/teams/x/26-27/pic.jpg")
photo_url = self.api_get("/teams/").json()[0]["photo_url"]
self.assertTrue(photo_url.startswith("http://ajax-united.rosterchief.app/media/"))
def test_roster_includes_the_current_seasons_photo(self):
TeamPhoto.objects.create(team=self.team, season=self.season, image="clubs/ajax-united/teams/x/26-27/pic.jpg")
photo_url = self.api_get(f"/teams/{self.team.pk}/roster/").json()["team"]["photo_url"]
self.assertTrue(photo_url.startswith("http://ajax-united.rosterchief.app/media/"))
def test_a_photo_from_a_different_season_does_not_leak(self):
other_season = Season.objects.create(club=self.club, start_date=datetime.date(2000, 1, 1), end_date=datetime.date(2000, 12, 31))
TeamPhoto.objects.create(team=self.team, season=other_season, image="clubs/ajax-united/teams/x/00-00/pic.jpg")
self.assertIsNone(self.api_get("/teams/").json()[0]["photo_url"])
self.assertIsNone(self.api_get(f"/teams/{self.team.pk}/roster/").json()["team"]["photo_url"])
def test_a_team_from_another_club_404s(self):
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
other_team = Team.objects.create(club=other_club, name="Rival Team", short_name="RIV")
response = self.api_get(f"/teams/{other_team.pk}/roster/")
self.assertEqual(response.status_code, 404)
class GamesApiTests(ApiTestBase):
def setUp(self):
super().setUp()
self.home_location = Location.objects.create(club=self.club, name="Home Arena", address="1 St", city="Town", zip_code="1000", country="BE", is_home=True)
self.opponent = Opponent.objects.create(club=self.club, name="Rivals FC")
def make_game(self, **overrides):
defaults = {"club": self.club, "title": "Game", "kind": Event.EventKind.GAME, "start": timezone.now() + datetime.timedelta(days=1), "opponent": self.opponent}
defaults.update(overrides)
event = Event.objects.create(**defaults)
event.teams.add(self.team)
return event
def test_upcoming_games_are_listed_with_location_and_teams(self):
self.make_game(location=self.home_location)
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]["location"]["name"], "Home Arena")
self.assertEqual(games[0]["status"], "upcoming")
def test_upcoming_excludes_cancelled_games(self):
self.make_game(cancelled=True)
self.assertEqual(self.api_get("/games/upcoming/").json(), [])
def test_upcoming_excludes_past_games(self):
self.make_game(start=timezone.now() - datetime.timedelta(days=1))
self.assertEqual(self.api_get("/games/upcoming/").json(), [])
def test_upcoming_includes_tournaments(self):
self.make_game(kind=Event.EventKind.TOURNAMENT)
self.assertEqual(len(self.api_get("/games/upcoming/").json()), 1)
def test_upcoming_excludes_other_kinds(self):
self.make_game(kind=Event.EventKind.TRAINING)
self.make_game(kind=Event.EventKind.SOCIAL)
self.assertEqual(self.api_get("/games/upcoming/").json(), [])
def test_live_endpoint_stays_game_only(self):
# is_live/scores are game-specific -- a tournament wouldn't have
# anything meaningful to show here even if flagged live.
self.make_game(kind=Event.EventKind.TOURNAMENT, start=timezone.now() - datetime.timedelta(minutes=10), is_live=True)
self.assertEqual(self.api_get("/games/live/").json(), [])
def test_count_is_respected(self):
for i in range(3):
self.make_game(start=timezone.now() + datetime.timedelta(days=i + 1))
self.assertEqual(len(self.api_get("/games/upcoming/", count=2).json()), 2)
def test_count_is_capped(self):
for i in range(3):
self.make_game(start=timezone.now() + datetime.timedelta(days=i + 1))
# Cap is 50, well above the 3 created -- just confirm an oversized
# request doesn't error and doesn't somehow exceed what exists.
response = self.api_get("/games/upcoming/", count=1000)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()), 3)
def test_live_games_are_listed_with_scores(self):
self.make_game(start=timezone.now() - datetime.timedelta(minutes=10), is_live=True, score_for=2, score_against=1, location=self.home_location)
games = self.api_get("/games/live/").json()
self.assertEqual(len(games), 1)
self.assertEqual(games[0]["status"], "live")
self.assertEqual(games[0]["home_score"], 2)
self.assertEqual(games[0]["away_score"], 1)
def test_live_excludes_cancelled_games(self):
self.make_game(is_live=True, cancelled=True)
self.assertEqual(self.api_get("/games/live/").json(), [])
def test_non_live_games_are_excluded_from_live_endpoint(self):
self.make_game()
self.assertEqual(self.api_get("/games/live/").json(), [])
def test_score_relabelling_for_an_away_game(self):
# No location (or a non-home one) -- is_home_game is False, so our
# team's score_for/score_against map to the away side.
self.make_game(start=timezone.now() - datetime.timedelta(days=1), score_for=4, score_against=3)
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_score"], 3)
self.assertEqual(games[0]["away_score"], 4)
self.assertEqual(games[0]["status"], "finished")
def test_score_relabelling_for_a_home_game(self):
self.make_game(start=timezone.now() - datetime.timedelta(days=1), score_for=4, score_against=3, location=self.home_location)
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_score"], 4)
self.assertEqual(games[0]["away_score"], 3)
def test_team_games_includes_past_and_upcoming_for_the_current_season(self):
self.make_game(title="Past", start=timezone.now() - datetime.timedelta(days=1), score_for=1, score_against=0)
self.make_game(title="Future", start=timezone.now() + datetime.timedelta(days=1))
games = self.api_get(f"/teams/{self.team.pk}/games/").json()
self.assertEqual(len(games), 2)
def test_team_games_excludes_a_different_season(self):
other_season = Season.objects.create(club=self.club, start_date=datetime.date(2000, 1, 1), end_date=datetime.date(2000, 12, 31))
self.make_game(start=datetime.datetime(2000, 6, 1, tzinfo=datetime.UTC), season=other_season)
self.assertEqual(self.api_get(f"/teams/{self.team.pk}/games/").json(), [])
def test_team_games_excludes_cancelled(self):
self.make_game(cancelled=True)
self.assertEqual(self.api_get(f"/teams/{self.team.pk}/games/").json(), [])
def test_team_games_is_empty_with_no_current_season(self):
self.make_game()
self.season.delete()
self.assertEqual(self.api_get(f"/teams/{self.team.pk}/games/").json(), [])
def test_a_team_from_another_club_404s_on_games(self):
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
other_team = Team.objects.create(club=other_club, name="Rival Team", short_name="RIV")
response = self.api_get(f"/teams/{other_team.pk}/games/")
self.assertEqual(response.status_code, 404)
class TenancyAndCorsTests(ApiTestBase):
def test_the_base_domain_404s(self):
response = self.api_get_base_domain("/news/")
self.assertEqual(response.status_code, 404)
def test_a_get_response_carries_the_cors_header(self):
response = self.api_get("/news/")
self.assertEqual(response["Access-Control-Allow-Origin"], "*")
def test_an_options_preflight_gets_a_204_with_cors_headers(self):
response = self.client.options("/api/v1/news/", HTTP_HOST="ajax-united.rosterchief.app")
self.assertEqual(response.status_code, 204)
self.assertEqual(response["Access-Control-Allow-Origin"], "*")
self.assertIn("GET", response["Access-Control-Allow-Methods"])
def test_cors_headers_are_not_added_outside_the_api(self):
response = self.client.get("/", HTTP_HOST="ajax-united.rosterchief.app")
self.assertNotIn("Access-Control-Allow-Origin", response)
def test_docs_page_resolves(self):
self.assertEqual(self.api_get("/docs").status_code, 200)
def test_openapi_schema_resolves(self):
self.assertEqual(self.api_get("/openapi.json").status_code, 200)
class SponsorApiTests(ApiTestBase):
def make_sponsor(self, **overrides):
today = timezone.localdate()
defaults = {"club": self.club, "name": "Acme Corp", "start_date": today - datetime.timedelta(days=10), "end_date": today + datetime.timedelta(days=10)}
defaults.update(overrides)
return Sponsor.objects.create(**defaults)
def test_a_sponsor_covering_today_is_included(self):
self.make_sponsor()
data = self.api_get("/sponsors/").json()
self.assertEqual(len(data), 1)
self.assertEqual(data[0]["name"], "Acme Corp")
def test_a_sponsor_starting_in_the_future_is_excluded(self):
today = timezone.localdate()
self.make_sponsor(start_date=today + datetime.timedelta(days=1), end_date=None)
self.assertEqual(self.api_get("/sponsors/").json(), [])
def test_a_sponsor_that_already_ended_is_excluded(self):
today = timezone.localdate()
self.make_sponsor(start_date=today - datetime.timedelta(days=20), end_date=today - datetime.timedelta(days=1))
self.assertEqual(self.api_get("/sponsors/").json(), [])
def test_a_sponsor_with_no_end_date_and_a_past_start_is_included(self):
today = timezone.localdate()
self.make_sponsor(start_date=today - datetime.timedelta(days=100), end_date=None)
self.assertEqual(len(self.api_get("/sponsors/").json()), 1)
def test_a_sponsor_starting_today_is_included(self):
today = timezone.localdate()
self.make_sponsor(start_date=today, end_date=None)
self.assertEqual(len(self.api_get("/sponsors/").json()), 1)
def test_a_sponsor_ending_today_is_included(self):
today = timezone.localdate()
self.make_sponsor(start_date=today - datetime.timedelta(days=10), end_date=today)
self.assertEqual(len(self.api_get("/sponsors/").json()), 1)
def test_another_clubs_sponsor_never_leaks_in(self):
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
self.make_sponsor(club=other_club)
self.assertEqual(self.api_get("/sponsors/").json(), [])
def test_logo_url_is_absolute_when_set(self):
self.make_sponsor(logo="clubs/ajax-united/sponsors/x/logo.png")
logo_url = self.api_get("/sponsors/").json()[0]["logo_url"]
self.assertTrue(logo_url.startswith("http://ajax-united.rosterchief.app/media/"))
def test_logo_url_is_null_when_not_set(self):
self.make_sponsor()
self.assertIsNone(self.api_get("/sponsors/").json()[0]["logo_url"])
def test_randomize_returns_the_same_set_of_sponsors(self):
for i in range(5):
self.make_sponsor(name=f"Sponsor {i}")
stable = {s["id"] for s in self.api_get("/sponsors/").json()}
randomized = {s["id"] for s in self.api_get("/sponsors/", randomize="true").json()}
self.assertEqual(stable, randomized)
self.assertEqual(len(stable), 5)
def test_default_order_is_stable_and_alphabetical(self):
self.make_sponsor(name="Zulu Corp")
self.make_sponsor(name="Acme Corp")
names = [s["name"] for s in self.api_get("/sponsors/").json()]
self.assertEqual(names, ["Acme Corp", "Zulu Corp"])