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:
0
api/__init__.py
Normal file
0
api/__init__.py
Normal file
6
api/apps.py
Normal file
6
api/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ApiConfig(AppConfig):
|
||||
name = "api"
|
||||
verbose_name = "Public API"
|
||||
17
api/errors.py
Normal file
17
api/errors.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""Shared error helpers for the public API -- see api/urls.py.
|
||||
|
||||
Every endpoint is club-scoped via the same subdomain-based tenant resolution
|
||||
the rest of the platform uses (club.tenancy.ClubTenantMiddleware sets
|
||||
request.club before any view runs). A request with no club on the host --
|
||||
the bare base domain, an unknown slug, an archived club -- has nothing to
|
||||
serve, so it 404s the same way club.mixins.ClubStaffRequiredMixin already
|
||||
404s the staff-facing app off the base domain.
|
||||
"""
|
||||
|
||||
from ninja.errors import HttpError
|
||||
|
||||
|
||||
def require_club(request):
|
||||
if request.club is None:
|
||||
raise HttpError(404, "No club found for this host.")
|
||||
return request.club
|
||||
31
api/middleware.py
Normal file
31
api/middleware.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""CORS for the public API only.
|
||||
|
||||
Every route under /api/v1/ is public, read-only, and unauthenticated -- no
|
||||
cookies or credentials are ever involved, so there's no CSRF/session risk in
|
||||
answering any origin. That's the whole reason this is a few lines here
|
||||
instead of pulling in django-cors-headers for a handful of GET routes: the
|
||||
rest of the site keeps Django's ordinary same-origin behaviour untouched.
|
||||
"""
|
||||
|
||||
from django.http import HttpResponse
|
||||
|
||||
API_PATH_PREFIX = "/api/v1/"
|
||||
|
||||
|
||||
class PublicApiCorsMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
if not request.path.startswith(API_PATH_PREFIX):
|
||||
return self.get_response(request)
|
||||
|
||||
if request.method == "OPTIONS":
|
||||
response = HttpResponse(status=204)
|
||||
else:
|
||||
response = self.get_response(request)
|
||||
|
||||
response["Access-Control-Allow-Origin"] = "*"
|
||||
response["Access-Control-Allow-Methods"] = "GET, OPTIONS"
|
||||
response["Access-Control-Allow-Headers"] = "Content-Type"
|
||||
return response
|
||||
451
api/tests.py
Normal file
451
api/tests.py
Normal 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"])
|
||||
28
api/urls.py
Normal file
28
api/urls.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""The public read-only API -- see ARCHITECTURE.md and the plan this shipped
|
||||
under. Mounted at /api/v1/ (rosterchief/urls.py), club-scoped by the same
|
||||
subdomain-based tenant resolution every other view uses
|
||||
(club.tenancy.ClubTenantMiddleware sets request.club before this ever runs).
|
||||
|
||||
Each domain app owns its own router and schemas (news/api.py, teams/api.py,
|
||||
events/api.py) -- this module only wires them together, same reasoning as
|
||||
management/controlpanel never owning domain logic themselves.
|
||||
"""
|
||||
|
||||
from ninja import NinjaAPI
|
||||
|
||||
from club.api import router as club_router
|
||||
from events.api import router as events_router
|
||||
from news.api import router as news_router
|
||||
from teams.api import router as teams_router
|
||||
|
||||
api = NinjaAPI(
|
||||
title="RosterChief public API",
|
||||
version="1.0.0",
|
||||
description="Public, read-only data for a club's own external website: news, team rosters, fixtures, and sponsors.",
|
||||
urls_namespace="api",
|
||||
)
|
||||
|
||||
api.add_router("/news", news_router)
|
||||
api.add_router("/teams", teams_router)
|
||||
api.add_router("/", events_router)
|
||||
api.add_router("/sponsors", club_router)
|
||||
Reference in New Issue
Block a user