Improve balldontlie query flow and dev container write stability
This commit is contained in:
@ -32,6 +32,9 @@ AUTO_APPLY_MIGRATIONS=1
|
|||||||
AUTO_COLLECTSTATIC=1
|
AUTO_COLLECTSTATIC=1
|
||||||
AUTO_BUILD_TAILWIND=1
|
AUTO_BUILD_TAILWIND=1
|
||||||
GUNICORN_WORKERS=3
|
GUNICORN_WORKERS=3
|
||||||
|
# Development container UID/GID for bind-mounted source write permissions.
|
||||||
|
LOCAL_UID=1000
|
||||||
|
LOCAL_GID=1000
|
||||||
|
|
||||||
# Production-minded security toggles
|
# Production-minded security toggles
|
||||||
DJANGO_SECURE_SSL_REDIRECT=1
|
DJANGO_SECURE_SSL_REDIRECT=1
|
||||||
@ -56,14 +59,18 @@ PROVIDER_MVP_DATA_FILE=/app/apps/providers/data/mvp_provider.json
|
|||||||
PROVIDER_REQUEST_RETRIES=3
|
PROVIDER_REQUEST_RETRIES=3
|
||||||
PROVIDER_REQUEST_RETRY_SLEEP=1
|
PROVIDER_REQUEST_RETRY_SLEEP=1
|
||||||
PROVIDER_HTTP_TIMEOUT_SECONDS=10
|
PROVIDER_HTTP_TIMEOUT_SECONDS=10
|
||||||
PROVIDER_BALLDONTLIE_BASE_URL=https://api.balldontlie.io/v1
|
PROVIDER_BALLDONTLIE_BASE_URL=https://api.balldontlie.io/nba/v1
|
||||||
PROVIDER_BALLDONTLIE_API_KEY=
|
PROVIDER_BALLDONTLIE_API_KEY=
|
||||||
# NBA-centric MVP provider seasons to ingest (comma-separated years).
|
# NBA-centric MVP provider seasons to ingest (comma-separated years).
|
||||||
PROVIDER_BALLDONTLIE_SEASONS=2024
|
PROVIDER_BALLDONTLIE_SEASONS=2024
|
||||||
PROVIDER_BALLDONTLIE_PLAYERS_PAGE_LIMIT=5
|
PROVIDER_BALLDONTLIE_PLAYERS_PAGE_LIMIT=5
|
||||||
PROVIDER_BALLDONTLIE_PLAYERS_PER_PAGE=100
|
PROVIDER_BALLDONTLIE_PLAYERS_PER_PAGE=100
|
||||||
|
PROVIDER_BALLDONTLIE_GAMES_PAGE_LIMIT=5
|
||||||
|
PROVIDER_BALLDONTLIE_GAMES_PER_PAGE=100
|
||||||
PROVIDER_BALLDONTLIE_STATS_PAGE_LIMIT=10
|
PROVIDER_BALLDONTLIE_STATS_PAGE_LIMIT=10
|
||||||
PROVIDER_BALLDONTLIE_STATS_PER_PAGE=100
|
PROVIDER_BALLDONTLIE_STATS_PER_PAGE=100
|
||||||
|
# When 0, a 401 on stats endpoint degrades to players/teams-only sync.
|
||||||
|
PROVIDER_BALLDONTLIE_STATS_STRICT=0
|
||||||
CELERY_TASK_TIME_LIMIT=1800
|
CELERY_TASK_TIME_LIMIT=1800
|
||||||
CELERY_TASK_SOFT_TIME_LIMIT=1500
|
CELERY_TASK_SOFT_TIME_LIMIT=1500
|
||||||
INGESTION_SCHEDULE_ENABLED=0
|
INGESTION_SCHEDULE_ENABLED=0
|
||||||
|
|||||||
10
README.md
10
README.md
@ -73,6 +73,7 @@ docker compose up --build
|
|||||||
```
|
```
|
||||||
|
|
||||||
This starts the development-oriented topology (source bind mounts enabled).
|
This starts the development-oriented topology (source bind mounts enabled).
|
||||||
|
In development, bind-mounted app containers run as `LOCAL_UID`/`LOCAL_GID` from `.env` (set them to your host user/group IDs).
|
||||||
|
|
||||||
3. If `AUTO_APPLY_MIGRATIONS=0`, run migrations manually:
|
3. If `AUTO_APPLY_MIGRATIONS=0`, run migrations manually:
|
||||||
|
|
||||||
@ -118,6 +119,7 @@ Notes:
|
|||||||
- In release-style mode, `web`, `celery_worker`, and `celery_beat` run from the built image filesystem.
|
- In release-style mode, `web`, `celery_worker`, and `celery_beat` run from the built image filesystem.
|
||||||
- `tailwind` is marked as `dev` profile in release override and is not started unless `--profile dev` is used.
|
- `tailwind` is marked as `dev` profile in release override and is not started unless `--profile dev` is used.
|
||||||
- `nginx`, `postgres`, and `redis` service naming remains unchanged.
|
- `nginx`, `postgres`, and `redis` service naming remains unchanged.
|
||||||
|
- Release-style `web`, `celery_worker`, and `celery_beat` explicitly run as container user `10001:10001`.
|
||||||
|
|
||||||
## Setup and Run Notes
|
## Setup and Run Notes
|
||||||
|
|
||||||
@ -192,6 +194,12 @@ Build Tailwind once:
|
|||||||
docker compose run --rm web sh -lc 'npm install --no-audit --no-fund && npm run build'
|
docker compose run --rm web sh -lc 'npm install --no-audit --no-fund && npm run build'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If you see `Permission denied` writing `static/vendor` or `static/css` in development, fix local file ownership once:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo chown -R "$(id -u):$(id -g)" static
|
||||||
|
```
|
||||||
|
|
||||||
Run Tailwind in watch mode during development:
|
Run Tailwind in watch mode during development:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@ -317,6 +325,8 @@ Provider backend is selected via environment variables:
|
|||||||
- `PROVIDER_DEFAULT_NAMESPACE` can override backend mapping explicitly
|
- `PROVIDER_DEFAULT_NAMESPACE` can override backend mapping explicitly
|
||||||
|
|
||||||
The balldontlie adapter is NBA-centric and intended as MVP ingestion only. The provider abstraction remains ready for future multi-league providers (for example Sportradar or FIBA GDAP).
|
The balldontlie adapter is NBA-centric and intended as MVP ingestion only. The provider abstraction remains ready for future multi-league providers (for example Sportradar or FIBA GDAP).
|
||||||
|
The adapter uses balldontlie getting-started query style (`/nba/v1`, cursor pagination, stats by `game_ids[]`).
|
||||||
|
Some balldontlie plans do not include stats endpoints; set `PROVIDER_BALLDONTLIE_STATS_STRICT=0` (default) to ingest players/teams/seasons even when stats are unauthorized.
|
||||||
|
|
||||||
Provider normalization details and explicit adapter assumptions are documented in [docs/provider-normalization.md](docs/provider-normalization.md).
|
Provider normalization details and explicit adapter assumptions are documented in [docs/provider-normalization.md](docs/provider-normalization.md).
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from itertools import islice
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
@ -13,6 +14,7 @@ from apps.providers.contracts import (
|
|||||||
TeamPayload,
|
TeamPayload,
|
||||||
)
|
)
|
||||||
from apps.providers.interfaces import BaseProviderAdapter
|
from apps.providers.interfaces import BaseProviderAdapter
|
||||||
|
from apps.providers.exceptions import ProviderUnauthorizedError
|
||||||
from apps.providers.services.balldontlie_mappings import (
|
from apps.providers.services.balldontlie_mappings import (
|
||||||
map_competitions,
|
map_competitions,
|
||||||
map_player_stats,
|
map_player_stats,
|
||||||
@ -36,6 +38,66 @@ class BalldontlieProviderAdapter(BaseProviderAdapter):
|
|||||||
def configured_seasons(self) -> list[int]:
|
def configured_seasons(self) -> list[int]:
|
||||||
return settings.PROVIDER_BALLDONTLIE_SEASONS
|
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]:
|
def search_players(self, *, query: str = "", limit: int = 50, offset: int = 0) -> list[PlayerPayload]:
|
||||||
params = {"search": query} if query else None
|
params = {"search": query} if query else None
|
||||||
rows = self.client.list_paginated(
|
rows = self.client.list_paginated(
|
||||||
@ -78,30 +140,12 @@ class BalldontlieProviderAdapter(BaseProviderAdapter):
|
|||||||
return map_seasons(self.configured_seasons)
|
return map_seasons(self.configured_seasons)
|
||||||
|
|
||||||
def fetch_player_stats(self) -> list[PlayerStatsPayload]:
|
def fetch_player_stats(self) -> list[PlayerStatsPayload]:
|
||||||
all_rows: list[dict] = []
|
all_rows = self._fetch_stats_rows()
|
||||||
for season in self.configured_seasons:
|
|
||||||
rows = self.client.list_paginated(
|
|
||||||
"stats",
|
|
||||||
params={"seasons[]": season},
|
|
||||||
per_page=settings.PROVIDER_BALLDONTLIE_STATS_PER_PAGE,
|
|
||||||
page_limit=settings.PROVIDER_BALLDONTLIE_STATS_PAGE_LIMIT,
|
|
||||||
)
|
|
||||||
all_rows.extend(rows)
|
|
||||||
|
|
||||||
player_stats, _ = map_player_stats(all_rows, allowed_seasons=self.configured_seasons)
|
player_stats, _ = map_player_stats(all_rows, allowed_seasons=self.configured_seasons)
|
||||||
return player_stats
|
return player_stats
|
||||||
|
|
||||||
def fetch_player_careers(self) -> list[PlayerCareerPayload]:
|
def fetch_player_careers(self) -> list[PlayerCareerPayload]:
|
||||||
all_rows: list[dict] = []
|
all_rows = self._fetch_stats_rows()
|
||||||
for season in self.configured_seasons:
|
|
||||||
rows = self.client.list_paginated(
|
|
||||||
"stats",
|
|
||||||
params={"seasons[]": season},
|
|
||||||
per_page=settings.PROVIDER_BALLDONTLIE_STATS_PER_PAGE,
|
|
||||||
page_limit=settings.PROVIDER_BALLDONTLIE_STATS_PAGE_LIMIT,
|
|
||||||
)
|
|
||||||
all_rows.extend(rows)
|
|
||||||
|
|
||||||
_, player_careers = map_player_stats(all_rows, allowed_seasons=self.configured_seasons)
|
_, player_careers = map_player_stats(all_rows, allowed_seasons=self.configured_seasons)
|
||||||
return player_careers
|
return player_careers
|
||||||
|
|
||||||
@ -115,16 +159,7 @@ class BalldontlieProviderAdapter(BaseProviderAdapter):
|
|||||||
seasons = self.fetch_seasons()
|
seasons = self.fetch_seasons()
|
||||||
players = self.fetch_players()
|
players = self.fetch_players()
|
||||||
|
|
||||||
all_rows: list[dict] = []
|
all_rows = self._fetch_stats_rows()
|
||||||
for season in self.configured_seasons:
|
|
||||||
rows = self.client.list_paginated(
|
|
||||||
"stats",
|
|
||||||
params={"seasons[]": season},
|
|
||||||
per_page=settings.PROVIDER_BALLDONTLIE_STATS_PER_PAGE,
|
|
||||||
page_limit=settings.PROVIDER_BALLDONTLIE_STATS_PAGE_LIMIT,
|
|
||||||
)
|
|
||||||
all_rows.extend(rows)
|
|
||||||
|
|
||||||
player_stats, player_careers = map_player_stats(all_rows, allowed_seasons=self.configured_seasons)
|
player_stats, player_careers = map_player_stats(all_rows, allowed_seasons=self.configured_seasons)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@ -5,7 +5,7 @@ from typing import Any
|
|||||||
import requests
|
import requests
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
from apps.providers.exceptions import ProviderRateLimitError, ProviderTransientError
|
from apps.providers.exceptions import ProviderRateLimitError, ProviderTransientError, ProviderUnauthorizedError
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -89,9 +89,14 @@ class BalldontlieClient:
|
|||||||
|
|
||||||
if status >= 400:
|
if status >= 400:
|
||||||
body_preview = response.text[:240]
|
body_preview = response.text[:240]
|
||||||
raise ProviderTransientError(
|
if status == 401:
|
||||||
f"balldontlie client error status={status} path={path} body={body_preview}"
|
raise ProviderUnauthorizedError(
|
||||||
|
provider="balldontlie",
|
||||||
|
path=path,
|
||||||
|
status_code=status,
|
||||||
|
detail=body_preview,
|
||||||
)
|
)
|
||||||
|
raise ProviderTransientError(f"balldontlie client error status={status} path={path} body={body_preview}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return response.json()
|
return response.json()
|
||||||
@ -109,20 +114,36 @@ class BalldontlieClient:
|
|||||||
page_limit: int = 1,
|
page_limit: int = 1,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
page = 1
|
page = 1
|
||||||
|
cursor = None
|
||||||
rows: list[dict[str, Any]] = []
|
rows: list[dict[str, Any]] = []
|
||||||
query = dict(params or {})
|
query = dict(params or {})
|
||||||
|
|
||||||
while page <= page_limit:
|
while page <= page_limit:
|
||||||
query.update({"page": page, "per_page": per_page})
|
request_query = dict(query)
|
||||||
payload = self.get_json(path, params=query)
|
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 []
|
data = payload.get("data") or []
|
||||||
if isinstance(data, list):
|
if isinstance(data, list):
|
||||||
rows.extend(data)
|
rows.extend(data)
|
||||||
|
|
||||||
meta = payload.get("meta") or {}
|
meta = payload.get("meta") or {}
|
||||||
|
next_cursor = meta.get("next_cursor")
|
||||||
|
if next_cursor:
|
||||||
|
cursor = next_cursor
|
||||||
|
page += 1
|
||||||
|
continue
|
||||||
|
|
||||||
next_page = meta.get("next_page")
|
next_page = meta.get("next_page")
|
||||||
if not next_page:
|
if next_page:
|
||||||
break
|
|
||||||
page = int(next_page)
|
page = int(next_page)
|
||||||
|
continue
|
||||||
|
|
||||||
|
break
|
||||||
|
|
||||||
return rows
|
return rows
|
||||||
|
|||||||
@ -6,6 +6,17 @@ class ProviderTransientError(ProviderError):
|
|||||||
"""Temporary provider failure that can be retried."""
|
"""Temporary provider failure that can be retried."""
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderUnauthorizedError(ProviderError):
|
||||||
|
"""Raised when provider credentials are valid format but not authorized for an endpoint."""
|
||||||
|
|
||||||
|
def __init__(self, *, provider: str, path: str, status_code: int, detail: str = ""):
|
||||||
|
super().__init__(f"{provider} unauthorized status={status_code} path={path} detail={detail}")
|
||||||
|
self.provider = provider
|
||||||
|
self.path = path
|
||||||
|
self.status_code = status_code
|
||||||
|
self.detail = detail
|
||||||
|
|
||||||
|
|
||||||
class ProviderRateLimitError(ProviderTransientError):
|
class ProviderRateLimitError(ProviderTransientError):
|
||||||
"""Raised when provider rate limit is hit."""
|
"""Raised when provider rate limit is hit."""
|
||||||
|
|
||||||
|
|||||||
@ -171,12 +171,15 @@ PROVIDER_MVP_DATA_FILE = os.getenv(
|
|||||||
PROVIDER_REQUEST_RETRIES = int(os.getenv("PROVIDER_REQUEST_RETRIES", "3"))
|
PROVIDER_REQUEST_RETRIES = int(os.getenv("PROVIDER_REQUEST_RETRIES", "3"))
|
||||||
PROVIDER_REQUEST_RETRY_SLEEP = float(os.getenv("PROVIDER_REQUEST_RETRY_SLEEP", "1"))
|
PROVIDER_REQUEST_RETRY_SLEEP = float(os.getenv("PROVIDER_REQUEST_RETRY_SLEEP", "1"))
|
||||||
PROVIDER_HTTP_TIMEOUT_SECONDS = float(os.getenv("PROVIDER_HTTP_TIMEOUT_SECONDS", "10"))
|
PROVIDER_HTTP_TIMEOUT_SECONDS = float(os.getenv("PROVIDER_HTTP_TIMEOUT_SECONDS", "10"))
|
||||||
PROVIDER_BALLDONTLIE_BASE_URL = os.getenv("PROVIDER_BALLDONTLIE_BASE_URL", "https://api.balldontlie.io/v1")
|
PROVIDER_BALLDONTLIE_BASE_URL = os.getenv("PROVIDER_BALLDONTLIE_BASE_URL", "https://api.balldontlie.io/nba/v1")
|
||||||
PROVIDER_BALLDONTLIE_API_KEY = os.getenv("PROVIDER_BALLDONTLIE_API_KEY", "")
|
PROVIDER_BALLDONTLIE_API_KEY = os.getenv("PROVIDER_BALLDONTLIE_API_KEY", "")
|
||||||
PROVIDER_BALLDONTLIE_PLAYERS_PAGE_LIMIT = int(os.getenv("PROVIDER_BALLDONTLIE_PLAYERS_PAGE_LIMIT", "5"))
|
PROVIDER_BALLDONTLIE_PLAYERS_PAGE_LIMIT = int(os.getenv("PROVIDER_BALLDONTLIE_PLAYERS_PAGE_LIMIT", "5"))
|
||||||
PROVIDER_BALLDONTLIE_PLAYERS_PER_PAGE = int(os.getenv("PROVIDER_BALLDONTLIE_PLAYERS_PER_PAGE", "100"))
|
PROVIDER_BALLDONTLIE_PLAYERS_PER_PAGE = int(os.getenv("PROVIDER_BALLDONTLIE_PLAYERS_PER_PAGE", "100"))
|
||||||
PROVIDER_BALLDONTLIE_STATS_PAGE_LIMIT = int(os.getenv("PROVIDER_BALLDONTLIE_STATS_PAGE_LIMIT", "10"))
|
PROVIDER_BALLDONTLIE_STATS_PAGE_LIMIT = int(os.getenv("PROVIDER_BALLDONTLIE_STATS_PAGE_LIMIT", "10"))
|
||||||
PROVIDER_BALLDONTLIE_STATS_PER_PAGE = int(os.getenv("PROVIDER_BALLDONTLIE_STATS_PER_PAGE", "100"))
|
PROVIDER_BALLDONTLIE_STATS_PER_PAGE = int(os.getenv("PROVIDER_BALLDONTLIE_STATS_PER_PAGE", "100"))
|
||||||
|
PROVIDER_BALLDONTLIE_GAMES_PAGE_LIMIT = int(os.getenv("PROVIDER_BALLDONTLIE_GAMES_PAGE_LIMIT", "5"))
|
||||||
|
PROVIDER_BALLDONTLIE_GAMES_PER_PAGE = int(os.getenv("PROVIDER_BALLDONTLIE_GAMES_PER_PAGE", "100"))
|
||||||
|
PROVIDER_BALLDONTLIE_STATS_STRICT = env_bool("PROVIDER_BALLDONTLIE_STATS_STRICT", False)
|
||||||
PROVIDER_BALLDONTLIE_SEASONS = [
|
PROVIDER_BALLDONTLIE_SEASONS = [
|
||||||
int(value.strip())
|
int(value.strip())
|
||||||
for value in os.getenv("PROVIDER_BALLDONTLIE_SEASONS", "2024").split(",")
|
for value in os.getenv("PROVIDER_BALLDONTLIE_SEASONS", "2024").split(",")
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
|
user: "10001:10001"
|
||||||
volumes:
|
volumes:
|
||||||
- static_data:/app/staticfiles
|
- static_data:/app/staticfiles
|
||||||
- media_data:/app/media
|
- media_data:/app/media
|
||||||
@ -9,6 +10,7 @@ services:
|
|||||||
DJANGO_DEBUG: "0"
|
DJANGO_DEBUG: "0"
|
||||||
|
|
||||||
celery_worker:
|
celery_worker:
|
||||||
|
user: "10001:10001"
|
||||||
volumes:
|
volumes:
|
||||||
- runtime_data:/app/runtime
|
- runtime_data:/app/runtime
|
||||||
environment:
|
environment:
|
||||||
@ -16,6 +18,7 @@ services:
|
|||||||
DJANGO_DEBUG: "0"
|
DJANGO_DEBUG: "0"
|
||||||
|
|
||||||
celery_beat:
|
celery_beat:
|
||||||
|
user: "10001:10001"
|
||||||
volumes:
|
volumes:
|
||||||
- runtime_data:/app/runtime
|
- runtime_data:/app/runtime
|
||||||
environment:
|
environment:
|
||||||
@ -25,4 +28,3 @@ services:
|
|||||||
tailwind:
|
tailwind:
|
||||||
profiles:
|
profiles:
|
||||||
- dev
|
- dev
|
||||||
|
|
||||||
|
|||||||
@ -34,6 +34,7 @@ services:
|
|||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
command: gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers ${GUNICORN_WORKERS:-3} --access-logfile - --error-logfile -
|
command: gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers ${GUNICORN_WORKERS:-3} --access-logfile - --error-logfile -
|
||||||
|
user: "${LOCAL_UID:-1000}:${LOCAL_GID:-1000}"
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
- node_modules_data:/app/node_modules
|
- node_modules_data:/app/node_modules
|
||||||
@ -57,6 +58,7 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
command: npm run dev
|
command: npm run dev
|
||||||
|
user: "${LOCAL_UID:-1000}:${LOCAL_GID:-1000}"
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
- node_modules_data:/app/node_modules
|
- node_modules_data:/app/node_modules
|
||||||
@ -74,6 +76,7 @@ services:
|
|||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
command: celery -A config worker -l info
|
command: celery -A config worker -l info
|
||||||
|
user: "${LOCAL_UID:-1000}:${LOCAL_GID:-1000}"
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
- runtime_data:/app/runtime
|
- runtime_data:/app/runtime
|
||||||
@ -97,6 +100,7 @@ services:
|
|||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
command: celery -A config beat -l info --schedule=/app/runtime/celerybeat-schedule
|
command: celery -A config beat -l info --schedule=/app/runtime/celerybeat-schedule
|
||||||
|
user: "${LOCAL_UID:-1000}:${LOCAL_GID:-1000}"
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
- runtime_data:/app/runtime
|
- runtime_data:/app/runtime
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build:vendor": "mkdir -p ./static/vendor && cp ./node_modules/htmx.org/dist/htmx.min.js ./static/vendor/htmx.min.js",
|
"build:vendor": "mkdir -p ./static/vendor && cp ./node_modules/htmx.org/dist/htmx.min.js ./static/vendor/htmx.min.js",
|
||||||
"build": "npm run build:vendor && tailwindcss -c tailwind.config.js -i ./static/src/tailwind.css -o ./static/css/main.css --minify",
|
"build": "npm run build:vendor && tailwindcss -c tailwind.config.js -i ./static/src/tailwind.css -o ./static/css/main.css --minify",
|
||||||
"dev": "npm run build:vendor && tailwindcss -c tailwind.config.js -i ./static/src/tailwind.css -o ./static/css/main.css --watch"
|
"dev": "npm run build:vendor && tailwindcss -c tailwind.config.js -i ./static/src/tailwind.css -o ./static/css/main.css --watch=always"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"htmx.org": "^1.9.12"
|
"htmx.org": "^1.9.12"
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import requests
|
|||||||
from apps.providers.adapters.balldontlie_provider import BalldontlieProviderAdapter
|
from apps.providers.adapters.balldontlie_provider import BalldontlieProviderAdapter
|
||||||
from apps.providers.adapters.mvp_provider import MvpDemoProviderAdapter
|
from apps.providers.adapters.mvp_provider import MvpDemoProviderAdapter
|
||||||
from apps.providers.clients.balldontlie import BalldontlieClient
|
from apps.providers.clients.balldontlie import BalldontlieClient
|
||||||
from apps.providers.exceptions import ProviderRateLimitError, ProviderTransientError
|
from apps.providers.exceptions import ProviderRateLimitError, ProviderTransientError, ProviderUnauthorizedError
|
||||||
from apps.providers.registry import get_default_provider_namespace, get_provider
|
from apps.providers.registry import get_default_provider_namespace, get_provider
|
||||||
from apps.providers.services.balldontlie_mappings import map_seasons
|
from apps.providers.services.balldontlie_mappings import map_seasons
|
||||||
|
|
||||||
@ -28,8 +28,10 @@ class _FakeResponse:
|
|||||||
class _FakeSession:
|
class _FakeSession:
|
||||||
def __init__(self, responses: list[Any]):
|
def __init__(self, responses: list[Any]):
|
||||||
self._responses = responses
|
self._responses = responses
|
||||||
|
self.calls: list[dict[str, Any]] = []
|
||||||
|
|
||||||
def get(self, *args, **kwargs):
|
def get(self, *args, **kwargs):
|
||||||
|
self.calls.append(kwargs)
|
||||||
item = self._responses.pop(0)
|
item = self._responses.pop(0)
|
||||||
if isinstance(item, Exception):
|
if isinstance(item, Exception):
|
||||||
raise item
|
raise item
|
||||||
@ -69,6 +71,9 @@ class _FakeBalldontlieClient:
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
if path == "stats":
|
if path == "stats":
|
||||||
|
requested_ids = (params or {}).get("game_ids[]") or []
|
||||||
|
if requested_ids and 9902 not in requested_ids:
|
||||||
|
return []
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"pts": 20,
|
"pts": 20,
|
||||||
@ -83,7 +88,7 @@ class _FakeBalldontlieClient:
|
|||||||
"min": "35:12",
|
"min": "35:12",
|
||||||
"player": {"id": 237},
|
"player": {"id": 237},
|
||||||
"team": {"id": 14},
|
"team": {"id": 14},
|
||||||
"game": {"season": 2024},
|
"game": {"id": 9901, "season": 2024},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"pts": 30,
|
"pts": 30,
|
||||||
@ -98,9 +103,14 @@ class _FakeBalldontlieClient:
|
|||||||
"min": "33:00",
|
"min": "33:00",
|
||||||
"player": {"id": 237},
|
"player": {"id": 237},
|
||||||
"team": {"id": 14},
|
"team": {"id": 14},
|
||||||
"game": {"season": 2024},
|
"game": {"id": 9902, "season": 2024},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
if path == "games":
|
||||||
|
return [
|
||||||
|
{"id": 9901, "season": 2024},
|
||||||
|
{"id": 9902, "season": 2024},
|
||||||
|
]
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
@ -165,6 +175,30 @@ def test_balldontlie_map_seasons_marks_latest_as_current():
|
|||||||
assert [row["external_id"] for row in seasons] == ["season-2022", "season-2023", "season-2024"]
|
assert [row["external_id"] for row in seasons] == ["season-2022", "season-2023", "season-2024"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
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":
|
||||||
|
raise ProviderUnauthorizedError(
|
||||||
|
provider="balldontlie",
|
||||||
|
path="stats",
|
||||||
|
status_code=401,
|
||||||
|
detail="Unauthorized",
|
||||||
|
)
|
||||||
|
return super().list_paginated(path, params=params, per_page=per_page, page_limit=page_limit)
|
||||||
|
|
||||||
|
settings.PROVIDER_BALLDONTLIE_SEASONS = [2024]
|
||||||
|
settings.PROVIDER_BALLDONTLIE_STATS_STRICT = False
|
||||||
|
adapter = BalldontlieProviderAdapter(client=_UnauthorizedStatsClient())
|
||||||
|
|
||||||
|
payload = adapter.sync_all()
|
||||||
|
assert payload["players"]
|
||||||
|
assert payload["teams"]
|
||||||
|
assert payload["player_stats"] == []
|
||||||
|
assert payload["player_careers"] == []
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_balldontlie_client_retries_after_rate_limit(monkeypatch, settings):
|
def test_balldontlie_client_retries_after_rate_limit(monkeypatch, settings):
|
||||||
monkeypatch.setattr(time, "sleep", lambda _: None)
|
monkeypatch.setattr(time, "sleep", lambda _: None)
|
||||||
@ -212,3 +246,26 @@ def test_balldontlie_client_raises_rate_limit_after_max_retries(monkeypatch, set
|
|||||||
|
|
||||||
with pytest.raises(ProviderRateLimitError):
|
with pytest.raises(ProviderRateLimitError):
|
||||||
client.get_json("players")
|
client.get_json("players")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_balldontlie_client_cursor_pagination(settings):
|
||||||
|
session = _FakeSession(
|
||||||
|
responses=[
|
||||||
|
_FakeResponse(
|
||||||
|
status_code=200,
|
||||||
|
payload={"data": [{"id": 1}], "meta": {"next_cursor": 101}},
|
||||||
|
),
|
||||||
|
_FakeResponse(
|
||||||
|
status_code=200,
|
||||||
|
payload={"data": [{"id": 2}], "meta": {"next_cursor": None}},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
client = BalldontlieClient(session=session)
|
||||||
|
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 "cursor" not in session.calls[0]["params"]
|
||||||
|
assert session.calls[1]["params"]["cursor"] == 101
|
||||||
|
|||||||
Reference in New Issue
Block a user