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

@ -38,6 +38,13 @@ class ReadOnlyBaseAPIView:
class PlayerSearchApiView(ReadOnlyBaseAPIView, generics.ListAPIView):
"""
Read-only player search API.
Metric sorts (`ppg_*`, `mpg_*`) follow the same best-eligible semantics as UI search:
max metric value across eligible player-season rows after applying search filters.
"""
serializer_class = PlayerListSerializer
pagination_class = ApiPagination

View File

@ -13,10 +13,10 @@ class PlayerSearchForm(forms.Form):
("age_oldest", "Age (Oldest first)"),
("height_desc", "Height (Tallest first)"),
("height_asc", "Height (Shortest first)"),
("ppg_desc", "Points per game (High to low)"),
("ppg_asc", "Points per game (Low to high)"),
("mpg_desc", "Minutes per game (High to low)"),
("mpg_asc", "Minutes per game (Low to high)"),
("ppg_desc", "Best eligible PPG (High to low)"),
("ppg_asc", "Best eligible PPG (Low to high)"),
("mpg_desc", "Best eligible MPG (High to low)"),
("mpg_asc", "Best eligible MPG (Low to high)"),
)
PAGE_SIZE_CHOICES = ((20, "20"), (50, "50"), (100, "100"))

View File

@ -20,6 +20,10 @@ from apps.players.models import Player
from apps.stats.models import PlayerSeason
METRIC_SORT_KEYS = {"ppg_desc", "ppg_asc", "mpg_desc", "mpg_asc"}
SEARCH_METRIC_SEMANTICS_TEXT = (
"Search metrics are best eligible values per player (max per metric across eligible player-season rows). "
"With season/team/competition/stat filters, eligibility is scoped by those filters."
)
def _years_ago_today(years: int) -> date:
@ -213,6 +217,13 @@ def filter_players(queryset, data: dict):
def annotate_player_metrics(queryset, data: dict | None = None):
"""
Annotate player list metrics using best-eligible semantics.
Each metric is computed as MAX over eligible player-season rows. This is intentionally
not a single-row projection; different displayed metrics for one player can come from
different eligible player-season rows.
"""
data = data or {}
context_filter = _build_metric_context_filter(data)

View File

@ -8,7 +8,13 @@ from apps.stats.models import PlayerSeason
from .forms import PlayerSearchForm
from .models import Player, PlayerCareerEntry
from .services.search import annotate_player_metrics, apply_sorting, base_player_queryset, filter_players
from .services.search import (
SEARCH_METRIC_SEMANTICS_TEXT,
annotate_player_metrics,
apply_sorting,
base_player_queryset,
filter_players,
)
def calculate_age(birth_date):
@ -61,6 +67,7 @@ class PlayerSearchView(ListView):
search_form = self.get_form()
context["search_form"] = search_form
context["search_has_errors"] = search_form.is_bound and bool(search_form.errors)
context["search_metric_semantics"] = SEARCH_METRIC_SEMANTICS_TEXT
context["favorite_player_ids"] = set()
if self.request.user.is_authenticated:
player_ids = [player.id for player in context["players"]]

View File

@ -1,5 +1,4 @@
import logging
from itertools import islice
from django.conf import settings
@ -30,6 +29,7 @@ class BalldontlieProviderAdapter(BaseProviderAdapter):
"""HTTP MVP adapter for balldontlie (NBA-centric data source)."""
namespace = "balldontlie"
nba_prefix = "/nba/v1"
def __init__(self, client: BalldontlieClient | None = None):
self.client = client or BalldontlieClient()
@ -38,46 +38,23 @@ class BalldontlieProviderAdapter(BaseProviderAdapter):
def configured_seasons(self) -> list[int]:
return settings.PROVIDER_BALLDONTLIE_SEASONS
@staticmethod
def _chunked(values: list[int], size: int):
iterator = iter(values)
while True:
chunk = list(islice(iterator, size))
if not chunk:
return
yield chunk
def _fetch_game_ids(self) -> list[int]:
game_ids: set[int] = set()
for season in self.configured_seasons:
rows = self.client.list_paginated(
"games",
params={"seasons[]": season},
per_page=settings.PROVIDER_BALLDONTLIE_GAMES_PER_PAGE,
page_limit=settings.PROVIDER_BALLDONTLIE_GAMES_PAGE_LIMIT,
)
for row in rows:
game_id = row.get("id")
if isinstance(game_id, int):
game_ids.add(game_id)
return sorted(game_ids)
def _api_path(self, path: str) -> str:
# Support both base URL variants:
# - https://api.balldontlie.io
# - https://api.balldontlie.io/nba/v1
base = getattr(self.client, "base_url", "").rstrip("/")
if base.endswith("/nba/v1"):
return path.lstrip("/")
return f"{self.nba_prefix}/{path.lstrip('/')}"
def _fetch_stats_rows(self) -> list[dict]:
game_ids = self._fetch_game_ids()
if not game_ids:
logger.info(
"provider_stats_skipped_no_games",
extra={"provider": self.namespace, "seasons": self.configured_seasons},
)
return []
all_rows: list[dict] = []
try:
# Use game_ids[] query as documented in balldontlie getting-started flow.
for game_id_chunk in self._chunked(game_ids, 25):
# OpenAPI supports seasons[] directly for /nba/v1/stats.
for season in self.configured_seasons:
rows = self.client.list_paginated(
"stats",
params={"game_ids[]": game_id_chunk},
self._api_path("stats"),
params={"seasons[]": season},
per_page=settings.PROVIDER_BALLDONTLIE_STATS_PER_PAGE,
page_limit=settings.PROVIDER_BALLDONTLIE_STATS_PAGE_LIMIT,
)
@ -101,7 +78,7 @@ class BalldontlieProviderAdapter(BaseProviderAdapter):
def search_players(self, *, query: str = "", limit: int = 50, offset: int = 0) -> list[PlayerPayload]:
params = {"search": query} if query else None
rows = self.client.list_paginated(
"players",
self._api_path("players"),
params=params,
per_page=min(limit, settings.PROVIDER_BALLDONTLIE_PLAYERS_PER_PAGE),
page_limit=1,
@ -113,7 +90,7 @@ class BalldontlieProviderAdapter(BaseProviderAdapter):
if not external_player_id.startswith("player-"):
return None
player_id = external_player_id.replace("player-", "", 1)
payload = self.client.get_json(f"players/{player_id}")
payload = self.client.get_json(self._api_path(f"players/{player_id}"))
data = payload.get("data")
if not isinstance(data, dict):
return None
@ -122,7 +99,7 @@ class BalldontlieProviderAdapter(BaseProviderAdapter):
def fetch_players(self) -> list[PlayerPayload]:
rows = self.client.list_paginated(
"players",
self._api_path("players"),
per_page=settings.PROVIDER_BALLDONTLIE_PLAYERS_PER_PAGE,
page_limit=settings.PROVIDER_BALLDONTLIE_PLAYERS_PAGE_LIMIT,
)
@ -132,7 +109,7 @@ class BalldontlieProviderAdapter(BaseProviderAdapter):
return map_competitions()
def fetch_teams(self) -> list[TeamPayload]:
payload = self.client.get_json("teams")
payload = self.client.get_json(self._api_path("teams"))
rows = payload.get("data") or []
return map_teams(rows if isinstance(rows, list) else [])

View File

@ -123,9 +123,6 @@ class BalldontlieClient:
request_query["per_page"] = per_page
if cursor is not None:
request_query["cursor"] = cursor
else:
# Keep backwards compatibility for endpoints still supporting page-based pagination.
request_query["page"] = page
payload = self.get_json(path, params=request_query)
data = payload.get("data") or []
@ -139,11 +136,6 @@ class BalldontlieClient:
page += 1
continue
next_page = meta.get("next_page")
if next_page:
page = int(next_page)
continue
break
return rows