192 lines
6.6 KiB
Python
192 lines
6.6 KiB
Python
import logging
|
|
from itertools import islice
|
|
|
|
from django.conf import settings
|
|
|
|
from apps.providers.clients import BalldontlieClient
|
|
from apps.providers.contracts import (
|
|
CompetitionPayload,
|
|
NormalizedSyncPayload,
|
|
PlayerCareerPayload,
|
|
PlayerPayload,
|
|
PlayerStatsPayload,
|
|
SeasonPayload,
|
|
TeamPayload,
|
|
)
|
|
from apps.providers.interfaces import BaseProviderAdapter
|
|
from apps.providers.exceptions import ProviderUnauthorizedError
|
|
from apps.providers.services.balldontlie_mappings import (
|
|
map_competitions,
|
|
map_player_stats,
|
|
map_players,
|
|
map_seasons,
|
|
map_teams,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class BalldontlieProviderAdapter(BaseProviderAdapter):
|
|
"""HTTP MVP adapter for balldontlie (NBA-centric data source)."""
|
|
|
|
namespace = "balldontlie"
|
|
|
|
def __init__(self, client: BalldontlieClient | None = None):
|
|
self.client = client or BalldontlieClient()
|
|
|
|
@property
|
|
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 _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):
|
|
rows = self.client.list_paginated(
|
|
"stats",
|
|
params={"game_ids[]": game_id_chunk},
|
|
per_page=settings.PROVIDER_BALLDONTLIE_STATS_PER_PAGE,
|
|
page_limit=settings.PROVIDER_BALLDONTLIE_STATS_PAGE_LIMIT,
|
|
)
|
|
all_rows.extend(rows)
|
|
except ProviderUnauthorizedError as exc:
|
|
if settings.PROVIDER_BALLDONTLIE_STATS_STRICT:
|
|
raise
|
|
logger.warning(
|
|
"provider_stats_unauthorized_degraded",
|
|
extra={
|
|
"provider": self.namespace,
|
|
"path": exc.path,
|
|
"status_code": exc.status_code,
|
|
"detail": exc.detail,
|
|
},
|
|
)
|
|
return []
|
|
|
|
return all_rows
|
|
|
|
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",
|
|
params=params,
|
|
per_page=min(limit, settings.PROVIDER_BALLDONTLIE_PLAYERS_PER_PAGE),
|
|
page_limit=1,
|
|
)
|
|
mapped = map_players(rows)
|
|
return mapped[offset : offset + limit]
|
|
|
|
def fetch_player(self, *, external_player_id: str) -> PlayerPayload | None:
|
|
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}")
|
|
data = payload.get("data")
|
|
if not isinstance(data, dict):
|
|
return None
|
|
mapped = map_players([data])
|
|
return mapped[0] if mapped else None
|
|
|
|
def fetch_players(self) -> list[PlayerPayload]:
|
|
rows = self.client.list_paginated(
|
|
"players",
|
|
per_page=settings.PROVIDER_BALLDONTLIE_PLAYERS_PER_PAGE,
|
|
page_limit=settings.PROVIDER_BALLDONTLIE_PLAYERS_PAGE_LIMIT,
|
|
)
|
|
return map_players(rows)
|
|
|
|
def fetch_competitions(self) -> list[CompetitionPayload]:
|
|
return map_competitions()
|
|
|
|
def fetch_teams(self) -> list[TeamPayload]:
|
|
payload = self.client.get_json("teams")
|
|
rows = payload.get("data") or []
|
|
return map_teams(rows if isinstance(rows, list) else [])
|
|
|
|
def fetch_seasons(self) -> list[SeasonPayload]:
|
|
return map_seasons(self.configured_seasons)
|
|
|
|
def fetch_player_stats(self) -> list[PlayerStatsPayload]:
|
|
all_rows = self._fetch_stats_rows()
|
|
player_stats, _ = map_player_stats(all_rows, allowed_seasons=self.configured_seasons)
|
|
return player_stats
|
|
|
|
def fetch_player_careers(self) -> list[PlayerCareerPayload]:
|
|
all_rows = self._fetch_stats_rows()
|
|
_, player_careers = map_player_stats(all_rows, allowed_seasons=self.configured_seasons)
|
|
return player_careers
|
|
|
|
def sync_all(self) -> NormalizedSyncPayload:
|
|
logger.info(
|
|
"provider_sync_start",
|
|
extra={"provider": self.namespace, "seasons": self.configured_seasons},
|
|
)
|
|
competitions = self.fetch_competitions()
|
|
teams = self.fetch_teams()
|
|
seasons = self.fetch_seasons()
|
|
players = self.fetch_players()
|
|
|
|
all_rows = self._fetch_stats_rows()
|
|
player_stats, player_careers = map_player_stats(all_rows, allowed_seasons=self.configured_seasons)
|
|
|
|
logger.info(
|
|
"provider_sync_complete",
|
|
extra={
|
|
"provider": self.namespace,
|
|
"competitions": len(competitions),
|
|
"teams": len(teams),
|
|
"seasons": len(seasons),
|
|
"players": len(players),
|
|
"player_stats": len(player_stats),
|
|
"player_careers": len(player_careers),
|
|
},
|
|
)
|
|
|
|
return {
|
|
"players": players,
|
|
"competitions": competitions,
|
|
"teams": teams,
|
|
"seasons": seasons,
|
|
"player_stats": player_stats,
|
|
"player_careers": player_careers,
|
|
"cursor": None,
|
|
}
|
|
|
|
def sync_incremental(self, *, cursor: str | None = None) -> NormalizedSyncPayload:
|
|
payload = self.sync_all()
|
|
payload["cursor"] = cursor
|
|
return payload
|