Align balldontlie OpenAPI integration and clarify search metric semantics

This commit is contained in:
Alfredo Di Stasio
2026-03-12 16:37:02 +01:00
parent c9dd10a438
commit dac63f9148
16 changed files with 1562 additions and 82 deletions

View File

@ -128,6 +128,57 @@ def test_players_api_search_consistent_with_ui_filters(client):
assert api_response.json()["results"][0]["id"] == matching.id
@pytest.mark.django_db
def test_players_api_metric_sort_uses_best_eligible_values(client):
nationality = Nationality.objects.create(name="Romania", iso2_code="RO", iso3_code="ROU")
competition = Competition.objects.create(
name="LNBM",
slug="lnbm",
competition_type=Competition.CompetitionType.LEAGUE,
gender=Competition.Gender.MEN,
country=nationality,
)
season_old = Season.objects.create(label="2022-2023", start_date=date(2022, 9, 1), end_date=date(2023, 6, 30))
season_new = Season.objects.create(label="2023-2024", start_date=date(2023, 9, 1), end_date=date(2024, 6, 30))
team = Team.objects.create(name="Bucharest", slug="bucharest", country=nationality)
p1 = Player.objects.create(first_name="Ion", last_name="Low", full_name="Ion Low", nationality=nationality)
p1s = PlayerSeason.objects.create(
player=p1,
season=season_old,
team=team,
competition=competition,
games_played=20,
minutes_played=400,
)
PlayerSeasonStats.objects.create(player_season=p1s, points=13, rebounds=3, assists=2, steals=1, blocks=0.1, turnovers=2)
p2 = Player.objects.create(first_name="Dan", last_name="High", full_name="Dan High", nationality=nationality)
p2s_old = PlayerSeason.objects.create(
player=p2,
season=season_old,
team=team,
competition=competition,
games_played=20,
minutes_played=400,
)
PlayerSeasonStats.objects.create(player_season=p2s_old, points=9, rebounds=3, assists=2, steals=1, blocks=0.1, turnovers=2)
p2s_new = PlayerSeason.objects.create(
player=p2,
season=season_new,
team=team,
competition=competition,
games_played=20,
minutes_played=500,
)
PlayerSeasonStats.objects.create(player_season=p2s_new, points=22, rebounds=5, assists=4, steals=1.3, blocks=0.2, turnovers=2.3)
response = client.get(reverse("api:players"), data={"sort": "ppg_desc"})
assert response.status_code == 200
names = [row["full_name"] for row in response.json()["results"]]
assert names.index("Dan High") < names.index("Ion Low")
@pytest.mark.django_db
def test_player_detail_api_includes_origin_fields(client):
nationality = Nationality.objects.create(name="Greece", iso2_code="GR", iso3_code="GRC")

View File

@ -332,3 +332,207 @@ def test_displayed_metrics_are_scoped_to_filtered_context(client):
row = list(response.context["players"])[0]
assert float(row.ppg_value) == pytest.approx(9.0)
assert float(row.mpg_value) == pytest.approx(25.0)
@pytest.mark.django_db
def test_displayed_metrics_are_scoped_to_team_season_competition_context(client):
nationality = Nationality.objects.create(name="Czechia", iso2_code="CZ", iso3_code="CZE")
position = Position.objects.create(code="SF", name="Small Forward")
role = Role.objects.create(code="wing", name="Wing")
competition_a = Competition.objects.create(
name="NBL",
slug="nbl-cz",
competition_type=Competition.CompetitionType.LEAGUE,
gender=Competition.Gender.MEN,
country=nationality,
)
competition_b = Competition.objects.create(
name="CZ Cup",
slug="cz-cup",
competition_type=Competition.CompetitionType.CUP,
gender=Competition.Gender.MEN,
country=nationality,
)
season = Season.objects.create(label="2024-2025", start_date=date(2024, 9, 1), end_date=date(2025, 6, 30))
team = Team.objects.create(name="Prague", slug="prague", country=nationality)
player = Player.objects.create(
first_name="Adam",
last_name="Scope",
full_name="Adam Scope",
birth_date=date(2002, 2, 2),
nationality=nationality,
nominal_position=position,
inferred_role=role,
)
ps_a = PlayerSeason.objects.create(
player=player,
season=season,
team=team,
competition=competition_a,
games_played=12,
minutes_played=300,
)
PlayerSeasonStats.objects.create(player_season=ps_a, points=11, rebounds=4, assists=3, steals=1, blocks=0.2, turnovers=1.5)
ps_b = PlayerSeason.objects.create(
player=player,
season=season,
team=team,
competition=competition_b,
games_played=6,
minutes_played=210,
)
PlayerSeasonStats.objects.create(player_season=ps_b, points=24, rebounds=6, assists=5, steals=1.5, blocks=0.3, turnovers=2.2)
response = client.get(
reverse("players:index"),
data={"team": team.id, "season": season.id, "competition": competition_a.id},
)
assert response.status_code == 200
row = list(response.context["players"])[0]
assert float(row.ppg_value) == pytest.approx(11.0)
assert float(row.mpg_value) == pytest.approx(25.0)
@pytest.mark.django_db
def test_displayed_metrics_without_season_filter_use_best_eligible_values(client):
nationality = Nationality.objects.create(name="Slovenia", iso2_code="SI", iso3_code="SVN")
position = Position.objects.create(code="PG", name="Point Guard")
role = Role.objects.create(code="playmaker", name="Playmaker")
competition = Competition.objects.create(
name="SBL",
slug="sbl",
competition_type=Competition.CompetitionType.LEAGUE,
gender=Competition.Gender.MEN,
country=nationality,
)
season_a = Season.objects.create(label="2023-2024", start_date=date(2023, 9, 1), end_date=date(2024, 6, 30))
season_b = Season.objects.create(label="2024-2025", start_date=date(2024, 9, 1), end_date=date(2025, 6, 30))
team = Team.objects.create(name="Ljubljana", slug="ljubljana", country=nationality)
player = Player.objects.create(
first_name="Luka",
last_name="Semantics",
full_name="Luka Semantics",
birth_date=date(2001, 3, 3),
nationality=nationality,
nominal_position=position,
inferred_role=role,
)
# High games, lower per-game production.
ps_a = PlayerSeason.objects.create(
player=player,
season=season_a,
team=team,
competition=competition,
games_played=34,
minutes_played=680, # 20 MPG
)
PlayerSeasonStats.objects.create(
player_season=ps_a,
points=10.0,
rebounds=3.0,
assists=4.0,
steals=1.0,
blocks=0.1,
turnovers=2.0,
)
# Lower games, higher per-game production.
ps_b = PlayerSeason.objects.create(
player=player,
season=season_b,
team=team,
competition=competition,
games_played=18,
minutes_played=630, # 35 MPG
)
PlayerSeasonStats.objects.create(
player_season=ps_b,
points=25.0,
rebounds=6.0,
assists=8.0,
steals=1.5,
blocks=0.2,
turnovers=3.0,
)
response = client.get(reverse("players:index"))
assert response.status_code == 200
row = next(item for item in response.context["players"] if item.id == player.id)
assert float(row.games_played_value) == pytest.approx(34.0)
assert float(row.mpg_value) == pytest.approx(35.0)
assert float(row.ppg_value) == pytest.approx(25.0)
@pytest.mark.django_db
def test_ppg_sort_uses_best_eligible_metrics_without_season_filter(client):
nationality = Nationality.objects.create(name="Latvia", iso2_code="LV", iso3_code="LVA")
position = Position.objects.create(code="SG", name="Shooting Guard")
role = Role.objects.create(code="scorer", name="Scorer")
competition = Competition.objects.create(
name="LBL",
slug="lbl",
competition_type=Competition.CompetitionType.LEAGUE,
gender=Competition.Gender.MEN,
country=nationality,
)
season_old = Season.objects.create(label="2022-2023", start_date=date(2022, 9, 1), end_date=date(2023, 6, 30))
season_new = Season.objects.create(label="2023-2024", start_date=date(2023, 9, 1), end_date=date(2024, 6, 30))
team = Team.objects.create(name="Riga", slug="riga", country=nationality)
player_a = Player.objects.create(
first_name="A",
last_name="Sorter",
full_name="A Sorter",
birth_date=date(2000, 1, 1),
nationality=nationality,
nominal_position=position,
inferred_role=role,
)
ps_a = PlayerSeason.objects.create(
player=player_a,
season=season_old,
team=team,
competition=competition,
games_played=20,
minutes_played=400,
)
PlayerSeasonStats.objects.create(player_season=ps_a, points=14, rebounds=4, assists=3, steals=1, blocks=0.1, turnovers=2)
player_b = Player.objects.create(
first_name="B",
last_name="Sorter",
full_name="B Sorter",
birth_date=date(2000, 1, 1),
nationality=nationality,
nominal_position=position,
inferred_role=role,
)
# Lower old season.
ps_b_old = PlayerSeason.objects.create(
player=player_b,
season=season_old,
team=team,
competition=competition,
games_played=20,
minutes_played=400,
)
PlayerSeasonStats.objects.create(player_season=ps_b_old, points=12, rebounds=3, assists=2, steals=1, blocks=0.1, turnovers=2)
# Better newer season; should drive best-eligible ppg sort.
ps_b_new = PlayerSeason.objects.create(
player=player_b,
season=season_new,
team=team,
competition=competition,
games_played=20,
minutes_played=420,
)
PlayerSeasonStats.objects.create(player_season=ps_b_new, points=21, rebounds=5, assists=4, steals=1.2, blocks=0.2, turnovers=2.5)
response = client.get(reverse("players:index"), data={"sort": "ppg_desc"})
assert response.status_code == 200
ordered = [row.full_name for row in response.context["players"] if row.full_name in {"A Sorter", "B Sorter"}]
assert ordered.index("B Sorter") < ordered.index("A Sorter")

View File

@ -193,3 +193,52 @@ def test_player_search_htmx_invalid_filters_return_validation_feedback(client):
body = response.content.decode().lower()
assert "search filters are invalid" in body
assert "points per game min" in body
@pytest.mark.django_db
def test_player_search_results_render_best_eligible_metric_labels(client):
nationality = Nationality.objects.create(name="Ireland", iso2_code="IE", iso3_code="IRL")
position = Position.objects.create(code="PG", name="Point Guard")
role = Role.objects.create(code="playmaker", name="Playmaker")
season = Season.objects.create(label="2025-2026", start_date=date(2025, 9, 1), end_date=date(2026, 6, 30))
competition = Competition.objects.create(
name="Super League",
slug="super-league",
competition_type=Competition.CompetitionType.LEAGUE,
gender=Competition.Gender.MEN,
country=nationality,
)
team = Team.objects.create(name="Dublin", slug="dublin", country=nationality)
player = Player.objects.create(
first_name="Sean",
last_name="Label",
full_name="Sean Label",
birth_date=date(2001, 1, 1),
nationality=nationality,
nominal_position=position,
inferred_role=role,
)
season_row = PlayerSeason.objects.create(
player=player,
season=season,
team=team,
competition=competition,
games_played=10,
minutes_played=250,
)
PlayerSeasonStats.objects.create(
player_season=season_row,
points=12,
rebounds=4,
assists=5,
steals=1,
blocks=0.1,
turnovers=2,
)
response = client.get(reverse("players:index"))
assert response.status_code == 200
body = response.content.decode()
assert "Best Eligible PPG" in body
assert "Best Eligible MPG" in body
assert "best eligible values per player" in body.lower()

View File

@ -40,7 +40,7 @@ class _FakeSession:
class _FakeBalldontlieClient:
def get_json(self, path: str, *, params: dict[str, Any] | None = None) -> dict[str, Any]:
if path == "teams":
if path == "/nba/v1/teams":
return {
"data": [
{
@ -60,7 +60,7 @@ class _FakeBalldontlieClient:
per_page: int = 100,
page_limit: int = 1,
) -> list[dict[str, Any]]:
if path == "players":
if path == "/nba/v1/players":
return [
{
"id": 237,
@ -70,10 +70,7 @@ class _FakeBalldontlieClient:
"team": {"id": 14},
}
]
if path == "stats":
requested_ids = (params or {}).get("game_ids[]") or []
if requested_ids and 9902 not in requested_ids:
return []
if path == "/nba/v1/stats":
return [
{
"pts": 20,
@ -88,7 +85,7 @@ class _FakeBalldontlieClient:
"min": "35:12",
"player": {"id": 237},
"team": {"id": 14},
"game": {"id": 9901, "season": 2024},
"game": {"season": 2024},
},
{
"pts": 30,
@ -103,14 +100,9 @@ class _FakeBalldontlieClient:
"min": "33:00",
"player": {"id": 237},
"team": {"id": 14},
"game": {"id": 9902, "season": 2024},
"game": {"season": 2024},
},
]
if path == "games":
return [
{"id": 9901, "season": 2024},
{"id": 9902, "season": 2024},
]
return []
@ -179,7 +171,7 @@ def test_balldontlie_map_seasons_marks_latest_as_current():
def test_balldontlie_adapter_degrades_when_stats_unauthorized(settings):
class _UnauthorizedStatsClient(_FakeBalldontlieClient):
def list_paginated(self, path: str, *, params=None, per_page=100, page_limit=1):
if path == "stats":
if path == "/nba/v1/stats":
raise ProviderUnauthorizedError(
provider="balldontlie",
path="stats",
@ -266,6 +258,6 @@ def test_balldontlie_client_cursor_pagination(settings):
rows = client.list_paginated("players", per_page=1, page_limit=5)
assert rows == [{"id": 1}, {"id": 2}]
assert session.calls[0]["params"]["page"] == 1
assert "page" not in session.calls[0]["params"]
assert "cursor" not in session.calls[0]["params"]
assert session.calls[1]["params"]["cursor"] == 101