Compare commits

..

3 Commits

47 changed files with 2491 additions and 447 deletions

View File

@ -26,13 +26,25 @@ CELERY_RESULT_BACKEND=redis://redis:6379/0
# Runtime behavior # Runtime behavior
AUTO_APPLY_MIGRATIONS=1 AUTO_APPLY_MIGRATIONS=1
AUTO_COLLECTSTATIC=1 AUTO_COLLECTSTATIC=1
AUTO_BUILD_TAILWIND=1
GUNICORN_WORKERS=3 GUNICORN_WORKERS=3
# Providers / ingestion # Providers / ingestion
PROVIDER_DEFAULT_NAMESPACE=mvp_demo PROVIDER_BACKEND=demo
PROVIDER_NAMESPACE_DEMO=mvp_demo
PROVIDER_NAMESPACE_BALLDONTLIE=balldontlie
PROVIDER_DEFAULT_NAMESPACE=
PROVIDER_MVP_DATA_FILE=/app/apps/providers/data/mvp_provider.json 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_BALLDONTLIE_BASE_URL=https://api.balldontlie.io/v1
PROVIDER_BALLDONTLIE_API_KEY=
PROVIDER_BALLDONTLIE_SEASONS=2024
PROVIDER_BALLDONTLIE_PLAYERS_PAGE_LIMIT=5
PROVIDER_BALLDONTLIE_PLAYERS_PER_PAGE=100
PROVIDER_BALLDONTLIE_STATS_PAGE_LIMIT=10
PROVIDER_BALLDONTLIE_STATS_PER_PAGE=100
CELERY_TASK_TIME_LIMIT=1800 CELERY_TASK_TIME_LIMIT=1800
CELERY_TASK_SOFT_TIME_LIMIT=1500 CELERY_TASK_SOFT_TIME_LIMIT=1500
API_THROTTLE_ANON=100/hour API_THROTTLE_ANON=100/hour

3
.gitignore vendored
View File

@ -26,3 +26,6 @@ venv/
.vscode/ .vscode/
.idea/ .idea/
.DS_Store .DS_Store
# Frontend
node_modules/

View File

@ -29,12 +29,15 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
WORKDIR /app WORKDIR /app
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends libpq5 postgresql-client curl \ && apt-get install -y --no-install-recommends libpq5 postgresql-client curl nodejs npm \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY --from=builder /opt/venv /opt/venv COPY --from=builder /opt/venv /opt/venv
COPY . /app COPY . /app
RUN if [ -f package.json ]; then npm install --no-audit --no-fund; fi
RUN if [ -f package.json ]; then npm run build; fi
RUN chmod +x /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh
RUN mkdir -p /app/staticfiles /app/media /app/runtime RUN mkdir -p /app/staticfiles /app/media /app/runtime

View File

@ -9,6 +9,7 @@ A minimal read-only API is included as a secondary integration surface.
- Python 3.12+ - Python 3.12+
- Django - Django
- Django Templates + HTMX - Django Templates + HTMX
- Tailwind CSS (CLI build pipeline)
- PostgreSQL - PostgreSQL
- Redis - Redis
- Celery + Celery Beat - Celery + Celery Beat
@ -45,6 +46,8 @@ A minimal read-only API is included as a secondary integration surface.
├── docs/ ├── docs/
├── nginx/ ├── nginx/
├── requirements/ ├── requirements/
├── package.json
├── tailwind.config.js
├── static/ ├── static/
├── templates/ ├── templates/
├── tests/ ├── tests/
@ -91,8 +94,10 @@ docker compose exec web python manage.py createsuperuser
## Setup and Run Notes ## Setup and Run Notes
- `web` service starts through `entrypoint.sh` and waits for PostgreSQL readiness. - `web` service starts through `entrypoint.sh` and waits for PostgreSQL readiness.
- `web` service also builds Tailwind CSS before `collectstatic` when `AUTO_BUILD_TAILWIND=1`.
- `celery_worker` executes background sync work. - `celery_worker` executes background sync work.
- `celery_beat` supports scheduled jobs (future scheduling strategy can be added per provider). - `celery_beat` supports scheduled jobs (future scheduling strategy can be added per provider).
- `tailwind` service runs watch mode for development (`npm run dev`).
- nginx proxies web traffic and serves static/media volume mounts. - nginx proxies web traffic and serves static/media volume mounts.
## Docker Volumes and Persistence ## Docker Volumes and Persistence
@ -104,6 +109,7 @@ docker compose exec web python manage.py createsuperuser
- `media_data`: user/provider media artifacts - `media_data`: user/provider media artifacts
- `runtime_data`: app runtime files (e.g., celery beat schedule) - `runtime_data`: app runtime files (e.g., celery beat schedule)
- `redis_data`: Redis persistence (`/data` for RDB/AOF files) - `redis_data`: Redis persistence (`/data` for RDB/AOF files)
- `node_modules_data`: Node modules cache for Tailwind builds in containers
This keeps persistent state outside container lifecycles. This keeps persistent state outside container lifecycles.
@ -135,6 +141,22 @@ Run a focused module:
docker compose run --rm web sh -lc 'pip install -r requirements/dev.txt && pytest -q tests/test_api.py' docker compose run --rm web sh -lc 'pip install -r requirements/dev.txt && pytest -q tests/test_api.py'
``` ```
## Frontend Assets (Tailwind)
Build Tailwind once:
```bash
docker compose run --rm web sh -lc 'npm install --no-audit --no-fund && npm run build'
```
Run Tailwind in watch mode during development:
```bash
docker compose up tailwind
```
Source CSS lives in `static/src/tailwind.css` and compiles to `static/css/main.css`.
## Superuser and Auth ## Superuser and Auth
Create superuser: Create superuser:
@ -155,8 +177,8 @@ Default auth routes:
- Open `/admin/` -> `IngestionRun` - Open `/admin/` -> `IngestionRun`
- Use admin actions: - Use admin actions:
- `Queue full MVP sync` - `Queue full sync (default provider)`
- `Queue incremental MVP sync` - `Queue incremental sync (default provider)`
- `Retry selected ingestion runs` - `Retry selected ingestion runs`
### Trigger from shell (manual) ### Trigger from shell (manual)
@ -167,7 +189,7 @@ docker compose exec web python manage.py shell
```python ```python
from apps.ingestion.tasks import trigger_full_sync from apps.ingestion.tasks import trigger_full_sync
trigger_full_sync.delay(provider_namespace="mvp_demo") trigger_full_sync.delay(provider_namespace="balldontlie")
``` ```
### Logs and diagnostics ### Logs and diagnostics
@ -176,6 +198,16 @@ trigger_full_sync.delay(provider_namespace="mvp_demo")
- Structured error records: `IngestionError` - Structured error records: `IngestionError`
- Provider entity mappings + diagnostic payload snippets: `ExternalMapping` - Provider entity mappings + diagnostic payload snippets: `ExternalMapping`
## Provider Backend Selection
Provider backend is selected via environment variables:
- `PROVIDER_BACKEND=demo` uses the local JSON fixture adapter (`mvp_demo`)
- `PROVIDER_BACKEND=balldontlie` uses the HTTP adapter (`balldontlie`)
- `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).
## GitFlow Workflow ## GitFlow Workflow
GitFlow is required in this repository: GitFlow is required in this repository:

View File

@ -1,6 +1,8 @@
from django.contrib import admin from django.contrib import admin
from django.contrib import messages from django.contrib import messages
from apps.providers.registry import get_default_provider_namespace
from .models import IngestionError, IngestionRun from .models import IngestionError, IngestionRun
from .tasks import trigger_full_sync, trigger_incremental_sync from .tasks import trigger_full_sync, trigger_incremental_sync
@ -41,20 +43,22 @@ class IngestionRunAdmin(admin.ModelAdmin):
"created_at", "created_at",
) )
actions = ( actions = (
"enqueue_full_sync_mvp", "enqueue_full_sync_default_provider",
"enqueue_incremental_sync_mvp", "enqueue_incremental_sync_default_provider",
"retry_selected_runs", "retry_selected_runs",
) )
@admin.action(description="Queue full MVP sync") @admin.action(description="Queue full sync (default provider)")
def enqueue_full_sync_mvp(self, request, queryset): def enqueue_full_sync_default_provider(self, request, queryset):
trigger_full_sync.delay(provider_namespace="mvp_demo", triggered_by_id=request.user.id) provider_namespace = get_default_provider_namespace()
self.message_user(request, "Queued full MVP sync task.", level=messages.SUCCESS) trigger_full_sync.delay(provider_namespace=provider_namespace, triggered_by_id=request.user.id)
self.message_user(request, f"Queued full sync task for {provider_namespace}.", level=messages.SUCCESS)
@admin.action(description="Queue incremental MVP sync") @admin.action(description="Queue incremental sync (default provider)")
def enqueue_incremental_sync_mvp(self, request, queryset): def enqueue_incremental_sync_default_provider(self, request, queryset):
trigger_incremental_sync.delay(provider_namespace="mvp_demo", triggered_by_id=request.user.id) provider_namespace = get_default_provider_namespace()
self.message_user(request, "Queued incremental MVP sync task.", level=messages.SUCCESS) trigger_incremental_sync.delay(provider_namespace=provider_namespace, triggered_by_id=request.user.id)
self.message_user(request, f"Queued incremental sync task for {provider_namespace}.", level=messages.SUCCESS)
@admin.action(description="Retry selected ingestion runs") @admin.action(description="Retry selected ingestion runs")
def retry_selected_runs(self, request, queryset): def retry_selected_runs(self, request, queryset):

View File

@ -11,6 +11,7 @@ from apps.competitions.models import Competition, Season
from apps.ingestion.models import IngestionRun from apps.ingestion.models import IngestionRun
from apps.ingestion.services.runs import finish_ingestion_run, log_ingestion_error, start_ingestion_run from apps.ingestion.services.runs import finish_ingestion_run, log_ingestion_error, start_ingestion_run
from apps.players.models import Nationality, Player, PlayerAlias, PlayerCareerEntry, Position, Role from apps.players.models import Nationality, Player, PlayerAlias, PlayerCareerEntry, Position, Role
from apps.players.services.origin import refresh_player_origin
from apps.providers.exceptions import ProviderRateLimitError, ProviderTransientError from apps.providers.exceptions import ProviderRateLimitError, ProviderTransientError
from apps.providers.registry import get_provider from apps.providers.registry import get_provider
from apps.providers.services.mappings import upsert_external_mapping from apps.providers.services.mappings import upsert_external_mapping
@ -358,6 +359,7 @@ def _sync_player_stats(provider_namespace: str, payloads: list[dict], run: Inges
def _sync_player_careers(provider_namespace: str, payloads: list[dict], run: IngestionRun, summary: SyncSummary): def _sync_player_careers(provider_namespace: str, payloads: list[dict], run: IngestionRun, summary: SyncSummary):
touched_player_ids: set[int] = set()
for payload in payloads: for payload in payloads:
summary.processed += 1 summary.processed += 1
external_id = payload.get("external_id", "") external_id = payload.get("external_id", "")
@ -380,6 +382,7 @@ def _sync_player_careers(provider_namespace: str, payloads: list[dict], run: Ing
) )
continue continue
touched_player_ids.add(player.id)
_, created = PlayerCareerEntry.objects.update_or_create( _, created = PlayerCareerEntry.objects.update_or_create(
player=player, player=player,
team=team, team=team,
@ -399,6 +402,10 @@ def _sync_player_careers(provider_namespace: str, payloads: list[dict], run: Ing
else: else:
summary.updated += 1 summary.updated += 1
if touched_player_ids:
for player in Player.objects.filter(id__in=touched_player_ids):
refresh_player_origin(player)
def run_sync_job( def run_sync_job(
*, *,

View File

@ -1,6 +1,8 @@
from django.contrib import admin from django.contrib import admin
from django.contrib import messages
from .models import Nationality, Player, PlayerAlias, PlayerCareerEntry, Position, Role from .models import Nationality, Player, PlayerAlias, PlayerCareerEntry, Position, Role
from .services.origin import refresh_player_origins
@admin.register(Nationality) @admin.register(Nationality)
@ -39,11 +41,26 @@ class PlayerAdmin(admin.ModelAdmin):
"nationality", "nationality",
"nominal_position", "nominal_position",
"inferred_role", "inferred_role",
"origin_competition",
"origin_team",
"is_active", "is_active",
) )
list_filter = ("is_active", "nationality", "nominal_position", "inferred_role") list_filter = (
"is_active",
"nationality",
"nominal_position",
"inferred_role",
"origin_competition",
"origin_team",
)
search_fields = ("full_name", "first_name", "last_name") search_fields = ("full_name", "first_name", "last_name")
inlines = (PlayerAliasInline, PlayerCareerEntryInline) inlines = (PlayerAliasInline, PlayerCareerEntryInline)
actions = ("recompute_origin_fields",)
@admin.action(description="Recompute origin fields")
def recompute_origin_fields(self, request, queryset):
updated = refresh_player_origins(queryset)
self.message_user(request, f"Updated origin fields for {updated} player(s).", level=messages.SUCCESS)
@admin.register(PlayerAlias) @admin.register(PlayerAlias)

View File

@ -25,8 +25,10 @@ class PlayerSearchForm(forms.Form):
nominal_position = forms.ModelChoiceField(queryset=Position.objects.none(), required=False) nominal_position = forms.ModelChoiceField(queryset=Position.objects.none(), required=False)
inferred_role = forms.ModelChoiceField(queryset=Role.objects.none(), required=False) inferred_role = forms.ModelChoiceField(queryset=Role.objects.none(), required=False)
competition = forms.ModelChoiceField(queryset=Competition.objects.none(), required=False) competition = forms.ModelChoiceField(queryset=Competition.objects.none(), required=False)
origin_competition = forms.ModelChoiceField(queryset=Competition.objects.none(), required=False)
nationality = forms.ModelChoiceField(queryset=Nationality.objects.none(), required=False) nationality = forms.ModelChoiceField(queryset=Nationality.objects.none(), required=False)
team = forms.ModelChoiceField(queryset=Team.objects.none(), required=False) team = forms.ModelChoiceField(queryset=Team.objects.none(), required=False)
origin_team = forms.ModelChoiceField(queryset=Team.objects.none(), required=False)
season = forms.ModelChoiceField(queryset=Season.objects.none(), required=False) season = forms.ModelChoiceField(queryset=Season.objects.none(), required=False)
age_min = forms.IntegerField(required=False, min_value=0, max_value=60, label="Min age") age_min = forms.IntegerField(required=False, min_value=0, max_value=60, label="Min age")
@ -86,8 +88,10 @@ class PlayerSearchForm(forms.Form):
self.fields["nominal_position"].queryset = Position.objects.order_by("code") self.fields["nominal_position"].queryset = Position.objects.order_by("code")
self.fields["inferred_role"].queryset = Role.objects.order_by("name") self.fields["inferred_role"].queryset = Role.objects.order_by("name")
self.fields["competition"].queryset = Competition.objects.order_by("name") self.fields["competition"].queryset = Competition.objects.order_by("name")
self.fields["origin_competition"].queryset = Competition.objects.order_by("name")
self.fields["nationality"].queryset = Nationality.objects.order_by("name") self.fields["nationality"].queryset = Nationality.objects.order_by("name")
self.fields["team"].queryset = Team.objects.order_by("name") self.fields["team"].queryset = Team.objects.order_by("name")
self.fields["origin_team"].queryset = Team.objects.order_by("name")
self.fields["season"].queryset = Season.objects.order_by("-start_date") self.fields["season"].queryset = Season.objects.order_by("-start_date")
def clean(self): def clean(self):

View File

@ -0,0 +1,34 @@
# Generated by Django 5.2.12 on 2026-03-10 11:17
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('competitions', '0002_initial'),
('players', '0002_initial'),
('teams', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='player',
name='origin_competition',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='origin_players', to='competitions.competition'),
),
migrations.AddField(
model_name='player',
name='origin_team',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='origin_players', to='teams.team'),
),
migrations.AddIndex(
model_name='player',
index=models.Index(fields=['origin_competition'], name='players_pla_origin__1a711b_idx'),
),
migrations.AddIndex(
model_name='player',
index=models.Index(fields=['origin_team'], name='players_pla_origin__b33403_idx'),
),
]

View File

@ -0,0 +1,35 @@
from django.db import migrations
from django.db.models import F, Q
def backfill_player_origins(apps, schema_editor):
Player = apps.get_model("players", "Player")
PlayerCareerEntry = apps.get_model("players", "PlayerCareerEntry")
for player in Player.objects.all().iterator():
entry = (
PlayerCareerEntry.objects.filter(player=player)
.filter(Q(competition__isnull=False) | Q(team__isnull=False))
.order_by(
F("start_date").asc(nulls_last=True),
F("season__start_date").asc(nulls_last=True),
"id",
)
.first()
)
if entry is None:
continue
player.origin_competition_id = entry.competition_id
player.origin_team_id = entry.team_id
player.save(update_fields=["origin_competition", "origin_team"])
class Migration(migrations.Migration):
dependencies = [
("players", "0003_player_origin_competition_player_origin_team_and_more"),
]
operations = [
migrations.RunPython(backfill_player_origins, migrations.RunPython.noop),
]

View File

@ -80,6 +80,20 @@ class Player(TimeStampedModel):
null=True, null=True,
related_name="role_players", related_name="role_players",
) )
origin_competition = models.ForeignKey(
"competitions.Competition",
on_delete=models.SET_NULL,
blank=True,
null=True,
related_name="origin_players",
)
origin_team = models.ForeignKey(
"teams.Team",
on_delete=models.SET_NULL,
blank=True,
null=True,
related_name="origin_players",
)
height_cm = models.PositiveSmallIntegerField(blank=True, null=True) height_cm = models.PositiveSmallIntegerField(blank=True, null=True)
weight_kg = models.PositiveSmallIntegerField(blank=True, null=True) weight_kg = models.PositiveSmallIntegerField(blank=True, null=True)
wingspan_cm = models.PositiveSmallIntegerField(blank=True, null=True) wingspan_cm = models.PositiveSmallIntegerField(blank=True, null=True)
@ -105,6 +119,8 @@ class Player(TimeStampedModel):
models.Index(fields=["nationality"]), models.Index(fields=["nationality"]),
models.Index(fields=["nominal_position"]), models.Index(fields=["nominal_position"]),
models.Index(fields=["inferred_role"]), models.Index(fields=["inferred_role"]),
models.Index(fields=["origin_competition"]),
models.Index(fields=["origin_team"]),
models.Index(fields=["is_active"]), models.Index(fields=["is_active"]),
models.Index(fields=["height_cm"]), models.Index(fields=["height_cm"]),
] ]

View File

@ -0,0 +1,46 @@
from django.db.models import F, Q, QuerySet
from apps.players.models import Player, PlayerCareerEntry
def get_origin_career_entry(player: Player) -> PlayerCareerEntry | None:
"""Earliest meaningful career entry, ordered by start_date then season start date."""
return (
PlayerCareerEntry.objects.select_related("competition", "team", "season")
.filter(player=player)
.filter(Q(competition__isnull=False) | Q(team__isnull=False))
.order_by(
F("start_date").asc(nulls_last=True),
F("season__start_date").asc(nulls_last=True),
"id",
)
.first()
)
def refresh_player_origin(player: Player, *, save: bool = True) -> bool:
"""Update origin fields from earliest meaningful career entry."""
entry = get_origin_career_entry(player)
origin_competition = entry.competition if entry else None
origin_team = entry.team if entry else None
changed = (
player.origin_competition_id != (origin_competition.id if origin_competition else None)
or player.origin_team_id != (origin_team.id if origin_team else None)
)
if changed:
player.origin_competition = origin_competition
player.origin_team = origin_team
if save:
player.save(update_fields=["origin_competition", "origin_team", "updated_at"])
return changed
def refresh_player_origins(queryset: QuerySet[Player] | None = None) -> int:
"""Backfill/recompute origin fields for players in queryset."""
players = queryset if queryset is not None else Player.objects.all()
updated = 0
for player in players.iterator():
if refresh_player_origin(player):
updated += 1
return updated

View File

@ -47,6 +47,10 @@ def filter_players(queryset, data: dict):
queryset = queryset.filter(inferred_role=data["inferred_role"]) queryset = queryset.filter(inferred_role=data["inferred_role"])
if data.get("nationality"): if data.get("nationality"):
queryset = queryset.filter(nationality=data["nationality"]) queryset = queryset.filter(nationality=data["nationality"])
if data.get("origin_competition"):
queryset = queryset.filter(origin_competition=data["origin_competition"])
if data.get("origin_team"):
queryset = queryset.filter(origin_team=data["origin_team"])
if data.get("team"): if data.get("team"):
queryset = queryset.filter(player_seasons__team=data["team"]) queryset = queryset.filter(player_seasons__team=data["team"])
@ -185,4 +189,6 @@ def base_player_queryset():
"nationality", "nationality",
"nominal_position", "nominal_position",
"inferred_role", "inferred_role",
"origin_competition",
"origin_team",
).prefetch_related("aliases") ).prefetch_related("aliases")

View File

@ -87,6 +87,8 @@ class PlayerDetailView(DetailView):
"nationality", "nationality",
"nominal_position", "nominal_position",
"inferred_role", "inferred_role",
"origin_competition",
"origin_team",
) )
.prefetch_related( .prefetch_related(
"aliases", "aliases",

View File

@ -0,0 +1,147 @@
import logging
from django.conf import settings
from apps.providers.clients import BalldontlieClient
from apps.providers.interfaces import BaseProviderAdapter
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
def search_players(self, *, query: str = "", limit: int = 50, offset: int = 0) -> list[dict]:
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) -> dict | 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[dict]:
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[dict]:
return map_competitions()
def fetch_teams(self) -> list[dict]:
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[dict]:
return map_seasons(self.configured_seasons)
def fetch_player_stats(self) -> list[dict]:
all_rows: list[dict] = []
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)
return player_stats
def fetch_player_careers(self) -> list[dict]:
all_rows: list[dict] = []
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)
return player_careers
def sync_all(self) -> dict:
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: list[dict] = []
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)
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) -> dict:
payload = self.sync_all()
payload["cursor"] = cursor
return payload

View File

@ -0,0 +1,3 @@
from .balldontlie import BalldontlieClient
__all__ = ["BalldontlieClient"]

View File

@ -0,0 +1,128 @@
import logging
import time
from typing import Any
import requests
from django.conf import settings
from apps.providers.exceptions import ProviderRateLimitError, ProviderTransientError
logger = logging.getLogger(__name__)
class BalldontlieClient:
"""HTTP client for balldontlie with timeout/retry/rate-limit handling."""
def __init__(self, session: requests.Session | None = None):
self.base_url = settings.PROVIDER_BALLDONTLIE_BASE_URL.rstrip("/")
self.api_key = settings.PROVIDER_BALLDONTLIE_API_KEY
self.timeout_seconds = settings.PROVIDER_HTTP_TIMEOUT_SECONDS
self.max_retries = settings.PROVIDER_REQUEST_RETRIES
self.retry_sleep_seconds = settings.PROVIDER_REQUEST_RETRY_SLEEP
self.session = session or requests.Session()
def _headers(self) -> dict[str, str]:
headers = {"Accept": "application/json"}
if self.api_key:
headers["Authorization"] = self.api_key
return headers
def get_json(self, path: str, *, params: dict[str, Any] | None = None) -> dict[str, Any]:
url = f"{self.base_url}/{path.lstrip('/')}"
for attempt in range(1, self.max_retries + 1):
try:
response = self.session.get(
url,
params=params,
headers=self._headers(),
timeout=self.timeout_seconds,
)
except requests.Timeout as exc:
logger.warning(
"provider_http_timeout",
extra={"provider": "balldontlie", "url": url, "attempt": attempt},
)
if attempt >= self.max_retries:
raise ProviderTransientError(f"Timeout calling balldontlie: {url}") from exc
time.sleep(self.retry_sleep_seconds * attempt)
continue
except requests.RequestException as exc:
logger.warning(
"provider_http_error",
extra={"provider": "balldontlie", "url": url, "attempt": attempt},
)
if attempt >= self.max_retries:
raise ProviderTransientError(f"Network error calling balldontlie: {url}") from exc
time.sleep(self.retry_sleep_seconds * attempt)
continue
status = response.status_code
if status == 429:
retry_after = int(response.headers.get("Retry-After", "30") or "30")
logger.warning(
"provider_rate_limited",
extra={
"provider": "balldontlie",
"url": url,
"attempt": attempt,
"retry_after": retry_after,
},
)
if attempt >= self.max_retries:
raise ProviderRateLimitError(
"balldontlie rate limit reached",
retry_after_seconds=retry_after,
)
time.sleep(max(retry_after, self.retry_sleep_seconds * attempt))
continue
if status >= 500:
logger.warning(
"provider_server_error",
extra={"provider": "balldontlie", "url": url, "attempt": attempt, "status": status},
)
if attempt >= self.max_retries:
raise ProviderTransientError(f"balldontlie server error: {status}")
time.sleep(self.retry_sleep_seconds * attempt)
continue
if status >= 400:
body_preview = response.text[:240]
raise ProviderTransientError(
f"balldontlie client error status={status} path={path} body={body_preview}"
)
try:
return response.json()
except ValueError as exc:
raise ProviderTransientError(f"Invalid JSON from balldontlie for {path}") from exc
raise ProviderTransientError(f"Failed to call balldontlie path={path}")
def list_paginated(
self,
path: str,
*,
params: dict[str, Any] | None = None,
per_page: int = 100,
page_limit: int = 1,
) -> list[dict[str, Any]]:
page = 1
rows: list[dict[str, Any]] = []
query = dict(params or {})
while page <= page_limit:
query.update({"page": page, "per_page": per_page})
payload = self.get_json(path, params=query)
data = payload.get("data") or []
if isinstance(data, list):
rows.extend(data)
meta = payload.get("meta") or {}
next_page = meta.get("next_page")
if not next_page:
break
page = int(next_page)
return rows

View File

@ -1,16 +1,29 @@
from django.conf import settings from django.conf import settings
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.exceptions import ProviderNotFoundError from apps.providers.exceptions import ProviderNotFoundError
PROVIDER_REGISTRY = { PROVIDER_REGISTRY = {
MvpDemoProviderAdapter.namespace: MvpDemoProviderAdapter, MvpDemoProviderAdapter.namespace: MvpDemoProviderAdapter,
BalldontlieProviderAdapter.namespace: BalldontlieProviderAdapter,
} }
def get_default_provider_namespace() -> str:
if settings.PROVIDER_DEFAULT_NAMESPACE:
return settings.PROVIDER_DEFAULT_NAMESPACE
backend_map = {
"demo": settings.PROVIDER_NAMESPACE_DEMO,
"balldontlie": settings.PROVIDER_NAMESPACE_BALLDONTLIE,
}
return backend_map.get(settings.PROVIDER_BACKEND, settings.PROVIDER_NAMESPACE_DEMO)
def get_provider(namespace: str | None = None): def get_provider(namespace: str | None = None):
provider_namespace = namespace or settings.PROVIDER_DEFAULT_NAMESPACE provider_namespace = namespace or get_default_provider_namespace()
provider_cls = PROVIDER_REGISTRY.get(provider_namespace) provider_cls = PROVIDER_REGISTRY.get(provider_namespace)
if not provider_cls: if not provider_cls:
raise ProviderNotFoundError(f"Unknown provider namespace: {provider_namespace}") raise ProviderNotFoundError(f"Unknown provider namespace: {provider_namespace}")

View File

@ -0,0 +1,260 @@
from __future__ import annotations
from collections import defaultdict
from datetime import date
from typing import Any
from django.utils.text import slugify
def map_competitions() -> list[dict[str, Any]]:
return [
{
"external_id": "competition-nba",
"name": "NBA",
"slug": "nba",
"competition_type": "league",
"gender": "men",
"level": 1,
"country": {"name": "United States", "iso2_code": "US", "iso3_code": "USA"},
"is_active": True,
}
]
def map_teams(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
mapped: list[dict[str, Any]] = []
for row in rows:
team_id = row.get("id")
if not team_id:
continue
full_name = row.get("full_name") or row.get("name") or f"Team {team_id}"
abbreviation = (row.get("abbreviation") or "").strip()
mapped.append(
{
"external_id": f"team-{team_id}",
"name": full_name,
"short_name": abbreviation,
"slug": slugify(full_name) or f"team-{team_id}",
"country": {"name": "United States", "iso2_code": "US", "iso3_code": "USA"},
"is_national_team": False,
}
)
return mapped
def _map_position(position: str | None) -> dict[str, str] | None:
if not position:
return None
normalized = position.upper().strip()
position_map = {
"G": ("PG", "Point Guard"),
"G-F": ("SG", "Shooting Guard"),
"F-G": ("SF", "Small Forward"),
"F": ("PF", "Power Forward"),
"F-C": ("PF", "Power Forward"),
"C-F": ("C", "Center"),
"C": ("C", "Center"),
}
code_name = position_map.get(normalized)
if not code_name:
return None
return {"code": code_name[0], "name": code_name[1]}
def _map_role(position: str | None) -> dict[str, str] | None:
if not position:
return None
normalized = position.upper().strip()
if "G" in normalized:
return {"code": "playmaker", "name": "Playmaker"}
if "F" in normalized:
return {"code": "wing", "name": "Wing"}
if "C" in normalized:
return {"code": "big", "name": "Big"}
return None
def map_players(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
mapped: list[dict[str, Any]] = []
for row in rows:
player_id = row.get("id")
if not player_id:
continue
first_name = row.get("first_name", "")
last_name = row.get("last_name", "")
full_name = f"{first_name} {last_name}".strip() or f"Player {player_id}"
position_value = row.get("position")
team = row.get("team") or {}
mapped.append(
{
"external_id": f"player-{player_id}",
"first_name": first_name,
"last_name": last_name,
"full_name": full_name,
"birth_date": None,
"nationality": {"name": "Unknown", "iso2_code": "ZZ", "iso3_code": "ZZZ"},
"nominal_position": _map_position(position_value),
"inferred_role": _map_role(position_value),
"height_cm": None,
"weight_kg": None,
"dominant_hand": "unknown",
"is_active": True,
"aliases": [],
"current_team_external_id": f"team-{team['id']}" if team.get("id") else None,
}
)
return mapped
def map_seasons(seasons: list[int]) -> list[dict[str, Any]]:
mapped: list[dict[str, Any]] = []
for season in seasons:
mapped.append(
{
"external_id": f"season-{season}",
"label": f"{season}-{season + 1}",
"start_date": date(season, 10, 1).isoformat(),
"end_date": date(season + 1, 6, 30).isoformat(),
"is_current": False,
}
)
return mapped
def _to_float(value: Any) -> float:
if value in (None, ""):
return 0.0
try:
return float(value)
except (TypeError, ValueError):
return 0.0
def _parse_minutes(value: Any) -> int:
if value in (None, ""):
return 0
if isinstance(value, (int, float)):
return int(value)
text = str(value)
if ":" in text:
minutes, _ = text.split(":", 1)
return int(_to_float(minutes))
return int(_to_float(text))
def _pct(value: Any, *, count: int) -> float | None:
if count <= 0:
return None
pct = _to_float(value) / count
if pct <= 1:
pct *= 100
return round(pct, 2)
def map_player_stats(
rows: list[dict[str, Any]],
*,
allowed_seasons: list[int],
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
aggregates: dict[tuple[int, int, int], dict[str, Any]] = defaultdict(
lambda: {
"games": 0,
"minutes": 0,
"points": 0.0,
"rebounds": 0.0,
"assists": 0.0,
"steals": 0.0,
"blocks": 0.0,
"turnovers": 0.0,
"fg_pct_sum": 0.0,
"fg_pct_count": 0,
"three_pct_sum": 0.0,
"three_pct_count": 0,
"ft_pct_sum": 0.0,
"ft_pct_count": 0,
}
)
for row in rows:
game = row.get("game") or {}
season = game.get("season")
player = row.get("player") or {}
team = row.get("team") or {}
player_id = player.get("id")
team_id = team.get("id")
if not (season and player_id and team_id):
continue
if allowed_seasons and season not in allowed_seasons:
continue
key = (season, player_id, team_id)
agg = aggregates[key]
agg["games"] += 1
agg["minutes"] += _parse_minutes(row.get("min"))
agg["points"] += _to_float(row.get("pts"))
agg["rebounds"] += _to_float(row.get("reb"))
agg["assists"] += _to_float(row.get("ast"))
agg["steals"] += _to_float(row.get("stl"))
agg["blocks"] += _to_float(row.get("blk"))
agg["turnovers"] += _to_float(row.get("turnover"))
if row.get("fg_pct") is not None:
agg["fg_pct_sum"] += _to_float(row.get("fg_pct"))
agg["fg_pct_count"] += 1
if row.get("fg3_pct") is not None:
agg["three_pct_sum"] += _to_float(row.get("fg3_pct"))
agg["three_pct_count"] += 1
if row.get("ft_pct") is not None:
agg["ft_pct_sum"] += _to_float(row.get("ft_pct"))
agg["ft_pct_count"] += 1
player_stats: list[dict[str, Any]] = []
player_careers: list[dict[str, Any]] = []
for (season, player_id, team_id), agg in aggregates.items():
games = agg["games"] or 1
player_stats.append(
{
"external_id": f"ps-{season}-{player_id}-{team_id}",
"player_external_id": f"player-{player_id}",
"team_external_id": f"team-{team_id}",
"competition_external_id": "competition-nba",
"season_external_id": f"season-{season}",
"games_played": agg["games"],
"games_started": 0,
"minutes_played": agg["minutes"],
"points": round(agg["points"] / games, 2),
"rebounds": round(agg["rebounds"] / games, 2),
"assists": round(agg["assists"] / games, 2),
"steals": round(agg["steals"] / games, 2),
"blocks": round(agg["blocks"] / games, 2),
"turnovers": round(agg["turnovers"] / games, 2),
"fg_pct": _pct(agg["fg_pct_sum"], count=agg["fg_pct_count"]),
"three_pct": _pct(agg["three_pct_sum"], count=agg["three_pct_count"]),
"ft_pct": _pct(agg["ft_pct_sum"], count=agg["ft_pct_count"]),
"usage_rate": None,
"true_shooting_pct": None,
"player_efficiency_rating": None,
}
)
player_careers.append(
{
"external_id": f"career-{season}-{player_id}-{team_id}",
"player_external_id": f"player-{player_id}",
"team_external_id": f"team-{team_id}",
"competition_external_id": "competition-nba",
"season_external_id": f"season-{season}",
"role_code": "",
"shirt_number": None,
"start_date": date(season, 10, 1).isoformat(),
"end_date": date(season + 1, 6, 30).isoformat(),
"notes": "Imported from balldontlie aggregated box scores",
}
)
return player_stats, player_careers

View File

@ -118,13 +118,28 @@ CELERY_TIMEZONE = TIME_ZONE
CELERY_TASK_TIME_LIMIT = int(os.getenv("CELERY_TASK_TIME_LIMIT", "1800")) CELERY_TASK_TIME_LIMIT = int(os.getenv("CELERY_TASK_TIME_LIMIT", "1800"))
CELERY_TASK_SOFT_TIME_LIMIT = int(os.getenv("CELERY_TASK_SOFT_TIME_LIMIT", "1500")) CELERY_TASK_SOFT_TIME_LIMIT = int(os.getenv("CELERY_TASK_SOFT_TIME_LIMIT", "1500"))
PROVIDER_DEFAULT_NAMESPACE = os.getenv("PROVIDER_DEFAULT_NAMESPACE", "mvp_demo") PROVIDER_BACKEND = os.getenv("PROVIDER_BACKEND", "demo").strip().lower()
PROVIDER_NAMESPACE_DEMO = os.getenv("PROVIDER_NAMESPACE_DEMO", "mvp_demo")
PROVIDER_NAMESPACE_BALLDONTLIE = os.getenv("PROVIDER_NAMESPACE_BALLDONTLIE", "balldontlie")
PROVIDER_DEFAULT_NAMESPACE = os.getenv("PROVIDER_DEFAULT_NAMESPACE", "").strip()
PROVIDER_MVP_DATA_FILE = os.getenv( PROVIDER_MVP_DATA_FILE = os.getenv(
"PROVIDER_MVP_DATA_FILE", "PROVIDER_MVP_DATA_FILE",
str(BASE_DIR / "apps" / "providers" / "data" / "mvp_provider.json"), str(BASE_DIR / "apps" / "providers" / "data" / "mvp_provider.json"),
) )
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_BALLDONTLIE_BASE_URL = os.getenv("PROVIDER_BALLDONTLIE_BASE_URL", "https://api.balldontlie.io/v1")
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_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_PER_PAGE = int(os.getenv("PROVIDER_BALLDONTLIE_STATS_PER_PAGE", "100"))
PROVIDER_BALLDONTLIE_SEASONS = [
int(value.strip())
for value in os.getenv("PROVIDER_BALLDONTLIE_SEASONS", "2024").split(",")
if value.strip().isdigit()
]
REST_FRAMEWORK = { REST_FRAMEWORK = {
"DEFAULT_PERMISSION_CLASSES": [ "DEFAULT_PERMISSION_CLASSES": [

View File

@ -31,6 +31,7 @@ services:
command: gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers ${GUNICORN_WORKERS:-3} command: gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers ${GUNICORN_WORKERS:-3}
volumes: volumes:
- .:/app - .:/app
- node_modules_data:/app/node_modules
- static_data:/app/staticfiles - static_data:/app/staticfiles
- media_data:/app/media - media_data:/app/media
- runtime_data:/app/runtime - runtime_data:/app/runtime
@ -43,6 +44,18 @@ services:
retries: 8 retries: 8
restart: unless-stopped restart: unless-stopped
tailwind:
build:
context: .
dockerfile: Dockerfile
env_file:
- .env
command: npm run dev
volumes:
- .:/app
- node_modules_data:/app/node_modules
restart: unless-stopped
celery_worker: celery_worker:
build: build:
context: . context: .
@ -118,3 +131,4 @@ volumes:
media_data: media_data:
runtime_data: runtime_data:
redis_data: redis_data:
node_modules_data:

View File

@ -14,6 +14,14 @@ if [ "${AUTO_APPLY_MIGRATIONS:-0}" = "1" ] && [ "$1" = "gunicorn" ]; then
fi fi
if [ "${AUTO_COLLECTSTATIC:-0}" = "1" ] && [ "$1" = "gunicorn" ]; then if [ "${AUTO_COLLECTSTATIC:-0}" = "1" ] && [ "$1" = "gunicorn" ]; then
if [ "${AUTO_BUILD_TAILWIND:-1}" = "1" ] && [ -f /app/package.json ]; then
echo "Building Tailwind assets..."
if [ ! -d /app/node_modules ]; then
npm install --no-audit --no-fund
fi
npm run build
fi
echo "Collecting static files..." echo "Collecting static files..."
python manage.py collectstatic --noinput python manage.py collectstatic --noinput
fi fi

858
package-lock.json generated Normal file
View File

@ -0,0 +1,858 @@
{
"name": "hoopscout-frontend",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hoopscout-frontend",
"version": "1.0.0",
"devDependencies": {
"tailwindcss": "^3.4.17"
}
},
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
"run-parallel": "^1.1.9"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/@nodelib/fs.stat": {
"version": "2.0.5",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/@nodelib/fs.walk": {
"version": "1.2.8",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
"fastq": "^1.6.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/any-promise": {
"version": "1.3.0",
"dev": true,
"license": "MIT"
},
"node_modules/anymatch": {
"version": "3.1.3",
"dev": true,
"license": "ISC",
"dependencies": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/arg": {
"version": "5.0.2",
"dev": true,
"license": "MIT"
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/braces": {
"version": "3.0.3",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/camelcase-css": {
"version": "2.0.1",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"dev": true,
"license": "MIT",
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/chokidar/node_modules/glob-parent": {
"version": "5.1.2",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/commander": {
"version": "4.1.1",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"dev": true,
"license": "MIT",
"bin": {
"cssesc": "bin/cssesc"
},
"engines": {
"node": ">=4"
}
},
"node_modules/didyoumean": {
"version": "1.2.2",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/dlv": {
"version": "1.1.3",
"dev": true,
"license": "MIT"
},
"node_modules/fast-glob": {
"version": "3.3.3",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
"glob-parent": "^5.1.2",
"merge2": "^1.3.0",
"micromatch": "^4.0.8"
},
"engines": {
"node": ">=8.6.0"
}
},
"node_modules/fast-glob/node_modules/glob-parent": {
"version": "5.1.2",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fastq": {
"version": "1.20.1",
"dev": true,
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.3"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"dev": true,
"license": "MIT",
"dependencies": {
"binary-extensions": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/is-core-module": {
"version": "2.16.1",
"dev": true,
"license": "MIT",
"dependencies": {
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/jiti": {
"version": "1.21.7",
"dev": true,
"license": "MIT",
"bin": {
"jiti": "bin/jiti.js"
}
},
"node_modules/lilconfig": {
"version": "3.1.3",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/antonk52"
}
},
"node_modules/lines-and-columns": {
"version": "1.2.4",
"dev": true,
"license": "MIT"
},
"node_modules/merge2": {
"version": "1.4.1",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/micromatch": {
"version": "4.0.8",
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
},
"engines": {
"node": ">=8.6"
}
},
"node_modules/mz": {
"version": "2.7.0",
"dev": true,
"license": "MIT",
"dependencies": {
"any-promise": "^1.0.0",
"object-assign": "^4.0.1",
"thenify-all": "^1.0.0"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/normalize-path": {
"version": "3.0.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-hash": {
"version": "3.0.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/path-parse": {
"version": "1.0.7",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.1",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pify": {
"version": "2.3.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/pirates": {
"version": "4.0.7",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/postcss": {
"version": "8.5.8",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postcss-import": {
"version": "15.1.0",
"dev": true,
"license": "MIT",
"dependencies": {
"postcss-value-parser": "^4.0.0",
"read-cache": "^1.0.0",
"resolve": "^1.1.7"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"postcss": "^8.0.0"
}
},
"node_modules/postcss-js": {
"version": "4.1.0",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"camelcase-css": "^2.0.1"
},
"engines": {
"node": "^12 || ^14 || >= 16"
},
"peerDependencies": {
"postcss": "^8.4.21"
}
},
"node_modules/postcss-load-config": {
"version": "6.0.1",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"lilconfig": "^3.1.1"
},
"engines": {
"node": ">= 18"
},
"peerDependencies": {
"jiti": ">=1.21.0",
"postcss": ">=8.0.9",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"jiti": {
"optional": true
},
"postcss": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
},
"node_modules/postcss-nested": {
"version": "6.2.0",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"postcss-selector-parser": "^6.1.1"
},
"engines": {
"node": ">=12.0"
},
"peerDependencies": {
"postcss": "^8.2.14"
}
},
"node_modules/postcss-selector-parser": {
"version": "6.1.2",
"dev": true,
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
},
"engines": {
"node": ">=4"
}
},
"node_modules/postcss-value-parser": {
"version": "4.2.0",
"dev": true,
"license": "MIT"
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/read-cache": {
"version": "1.0.0",
"dev": true,
"license": "MIT",
"dependencies": {
"pify": "^2.3.0"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"dev": true,
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/resolve": {
"version": "1.22.11",
"dev": true,
"license": "MIT",
"dependencies": {
"is-core-module": "^2.16.1",
"path-parse": "^1.0.7",
"supports-preserve-symlinks-flag": "^1.0.0"
},
"bin": {
"resolve": "bin/resolve"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/reusify": {
"version": "1.1.0",
"dev": true,
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
"node": ">=0.10.0"
}
},
"node_modules/run-parallel": {
"version": "1.2.0",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"queue-microtask": "^1.2.2"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/sucrase": {
"version": "3.35.1",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.2",
"commander": "^4.0.0",
"lines-and-columns": "^1.1.6",
"mz": "^2.7.0",
"pirates": "^4.0.1",
"tinyglobby": "^0.2.11",
"ts-interface-checker": "^0.1.9"
},
"bin": {
"sucrase": "bin/sucrase",
"sucrase-node": "bin/sucrase-node"
},
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/tailwindcss": {
"version": "3.4.19",
"dev": true,
"license": "MIT",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
"chokidar": "^3.6.0",
"didyoumean": "^1.2.2",
"dlv": "^1.1.3",
"fast-glob": "^3.3.2",
"glob-parent": "^6.0.2",
"is-glob": "^4.0.3",
"jiti": "^1.21.7",
"lilconfig": "^3.1.3",
"micromatch": "^4.0.8",
"normalize-path": "^3.0.0",
"object-hash": "^3.0.0",
"picocolors": "^1.1.1",
"postcss": "^8.4.47",
"postcss-import": "^15.1.0",
"postcss-js": "^4.0.1",
"postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
"postcss-nested": "^6.2.0",
"postcss-selector-parser": "^6.1.2",
"resolve": "^1.22.8",
"sucrase": "^3.35.0"
},
"bin": {
"tailwind": "lib/cli.js",
"tailwindcss": "lib/cli.js"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/thenify": {
"version": "3.3.1",
"dev": true,
"license": "MIT",
"dependencies": {
"any-promise": "^1.0.0"
}
},
"node_modules/thenify-all": {
"version": "1.6.0",
"dev": true,
"license": "MIT",
"dependencies": {
"thenify": ">= 3.1.0 < 4"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyglobby/node_modules/fdir": {
"version": "6.5.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/ts-interface-checker": {
"version": "0.1.13",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"dev": true,
"license": "MIT"
}
}
}

13
package.json Normal file
View File

@ -0,0 +1,13 @@
{
"name": "hoopscout-frontend",
"version": "1.0.0",
"private": true,
"description": "Tailwind pipeline for HoopScout Django templates",
"scripts": {
"build": "tailwindcss -c tailwind.config.js -i ./static/src/tailwind.css -o ./static/css/main.css --minify",
"dev": "tailwindcss -c tailwind.config.js -i ./static/src/tailwind.css -o ./static/css/main.css --watch"
},
"devDependencies": {
"tailwindcss": "^3.4.17"
}
}

View File

@ -5,3 +5,4 @@ gunicorn>=22.0,<23.0
celery[redis]>=5.4,<6.0 celery[redis]>=5.4,<6.0
redis>=5.2,<6.0 redis>=5.2,<6.0
python-dotenv>=1.0,<2.0 python-dotenv>=1.0,<2.0
requests>=2.32,<3.0

File diff suppressed because one or more lines are too long

94
static/src/tailwind.css Normal file
View File

@ -0,0 +1,94 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-slate-100 text-slate-900 antialiased;
}
h1 {
@apply text-2xl font-semibold tracking-tight text-slate-900;
}
h2 {
@apply text-xl font-semibold tracking-tight text-slate-900;
}
h3 {
@apply text-lg font-semibold text-slate-900;
}
a {
@apply text-brand-700 hover:text-brand-600;
}
label {
@apply mb-1 block text-sm font-medium text-slate-700;
}
input,
select,
textarea {
@apply w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm outline-none ring-brand-600 transition focus:border-brand-600 focus:ring-2;
}
input[type='checkbox'] {
@apply h-4 w-4 rounded border-slate-300 p-0 text-brand-700;
}
summary {
@apply cursor-pointer font-medium text-slate-800;
}
}
@layer components {
.page-container {
@apply mx-auto w-full max-w-6xl px-4 sm:px-6 lg:px-8;
}
.panel {
@apply rounded-xl border border-slate-200 bg-white p-5 shadow-soft;
}
.btn {
@apply inline-flex items-center justify-center rounded-md border border-brand-700 bg-brand-700 px-3 py-2 text-sm font-medium text-white transition hover:bg-brand-600;
}
.btn-secondary {
@apply inline-flex items-center justify-center rounded-md border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50;
}
.table-wrap {
@apply overflow-x-auto rounded-lg border border-slate-200;
}
.data-table {
@apply min-w-full divide-y divide-slate-200 text-sm;
}
.data-table thead {
@apply bg-slate-50;
}
.data-table th {
@apply px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-slate-600;
}
.data-table td {
@apply whitespace-nowrap px-3 py-2 text-slate-700;
}
.empty-state {
@apply rounded-lg border border-dashed border-slate-300 bg-slate-50 p-6 text-center text-sm text-slate-600;
}
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator {
display: block;
}
}

25
tailwind.config.js Normal file
View File

@ -0,0 +1,25 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./templates/**/*.html',
'./apps/**/templates/**/*.html',
'./apps/**/*.py'
],
theme: {
extend: {
colors: {
brand: {
50: '#eef6ff',
100: '#d8e8ff',
600: '#1d63dd',
700: '#184fb3',
900: '#142746'
}
},
boxShadow: {
soft: '0 8px 24px -14px rgba(16, 35, 64, 0.35)'
}
}
},
plugins: []
};

View File

@ -1,6 +1,6 @@
{% load static %} {% load static %}
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en" class="h-full">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
@ -8,31 +8,34 @@
<link rel="stylesheet" href="{% static 'css/main.css' %}"> <link rel="stylesheet" href="{% static 'css/main.css' %}">
<script src="https://unpkg.com/htmx.org@1.9.12" defer></script> <script src="https://unpkg.com/htmx.org@1.9.12" defer></script>
</head> </head>
<body> <body class="min-h-full bg-slate-100 text-slate-900">
<header class="site-header"> <header class="border-b border-slate-200 bg-white">
<div class="container row-between"> <div class="page-container flex flex-wrap items-center justify-between gap-4 py-3">
<a class="brand" href="{% url 'core:home' %}">HoopScout</a> <a class="text-xl font-bold tracking-tight text-slate-900 no-underline" href="{% url 'core:home' %}">HoopScout</a>
<nav class="row-gap"> <nav class="flex flex-wrap items-center gap-2 text-sm">
<a href="{% url 'players:index' %}">Players</a> <a class="rounded-md px-2 py-1 hover:bg-slate-100" href="{% url 'players:index' %}">Players</a>
<a href="{% url 'competitions:index' %}">Competitions</a> <a class="rounded-md px-2 py-1 hover:bg-slate-100" href="{% url 'competitions:index' %}">Competitions</a>
<a href="{% url 'teams:index' %}">Teams</a> <a class="rounded-md px-2 py-1 hover:bg-slate-100" href="{% url 'teams:index' %}">Teams</a>
<a href="{% url 'scouting:index' %}">Scouting</a> <a class="rounded-md px-2 py-1 hover:bg-slate-100" href="{% url 'scouting:index' %}">Scouting</a>
{% if request.user.is_authenticated %} {% if request.user.is_authenticated %}
<a href="{% url 'core:dashboard' %}">Dashboard</a> <a class="rounded-md px-2 py-1 hover:bg-slate-100" href="{% url 'core:dashboard' %}">Dashboard</a>
<form method="post" action="{% url 'users:logout' %}"> <form method="post" action="{% url 'users:logout' %}">
{% csrf_token %} {% csrf_token %}
<button type="submit" class="link-button">Logout</button> <button type="submit" class="btn-secondary px-2 py-1 text-xs">Logout</button>
</form> </form>
{% else %} {% else %}
<a href="{% url 'users:login' %}">Login</a> <a class="rounded-md px-2 py-1 hover:bg-slate-100" href="{% url 'users:login' %}">Login</a>
<a href="{% url 'users:signup' %}">Signup</a> <a class="btn px-2 py-1 text-xs" href="{% url 'users:signup' %}">Signup</a>
{% endif %} {% endif %}
</nav> </nav>
</div> </div>
</header> </header>
<main class="container"> <main class="page-container py-6">
{% include 'partials/messages.html' %} {% include 'partials/messages.html' %}
<div id="htmx-loading" class="htmx-indicator mb-4 rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-600" aria-live="polite">
Loading...
</div>
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
</body> </body>

View File

@ -1,7 +1,13 @@
{% if messages %} {% if messages %}
<section class="messages"> <section class="mb-4 space-y-2" aria-live="polite">
{% for message in messages %} {% for message in messages %}
<div class="message {{ message.tags }}">{{ message }}</div> {% if message.tags == "success" %}
<div role="status" class="rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{{ message }}</div>
{% elif message.tags == "error" %}
<div role="alert" class="rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-800">{{ message }}</div>
{% else %}
<div role="status" class="rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700">{{ message }}</div>
{% endif %}
{% endfor %} {% endfor %}
</section> </section>
{% endif %} {% endif %}

View File

@ -4,48 +4,51 @@
{% block content %} {% block content %}
<section class="panel"> <section class="panel">
<div class="row-between wrap-gap"> <div class="flex flex-wrap items-start justify-between gap-3">
<div> <div>
<h1>{{ player.full_name }}</h1> <h1>{{ player.full_name }}</h1>
<p class="muted-text"> <p class="mt-1 text-sm text-slate-600">{{ player.nominal_position.name|default:"No nominal position" }} · {{ player.inferred_role.name|default:"No inferred role" }}</p>
{{ player.nominal_position.name|default:"No nominal position" }}
· {{ player.inferred_role.name|default:"No inferred role" }}
</p>
</div> </div>
<div class="row-gap"> <div class="flex flex-wrap items-center gap-2">
{% if request.user.is_authenticated %} {% if request.user.is_authenticated %}
{% include "scouting/partials/favorite_button.html" with player=player is_favorite=is_favorite next_url=request.get_full_path %} {% include "scouting/partials/favorite_button.html" with player=player is_favorite=is_favorite next_url=request.get_full_path %}
{% endif %} {% endif %}
<a class="button ghost" href="{% url 'players:index' %}">Back to search</a> <a class="btn-secondary" href="{% url 'players:index' %}">Back to search</a>
</div> </div>
</div> </div>
<div class="detail-grid mt-16"> <div class="mt-4 grid gap-3 md:grid-cols-3">
<div class="detail-card"> <div class="rounded-lg border border-slate-200 p-4">
<h2>Summary</h2> <h2 class="text-base">Summary</h2>
<p><strong>Nationality:</strong> {{ player.nationality.name|default:"-" }}</p> <dl class="mt-2 space-y-1 text-sm">
<p><strong>Birth date:</strong> {{ player.birth_date|date:"Y-m-d"|default:"-" }}</p> <div><dt class="inline font-semibold">Nationality:</dt> <dd class="inline">{{ player.nationality.name|default:"-" }}</dd></div>
<p><strong>Age:</strong> {{ age|default:"-" }}</p> <div><dt class="inline font-semibold">Origin competition:</dt> <dd class="inline">{{ player.origin_competition.name|default:"-" }}</dd></div>
<p><strong>Height:</strong> {{ player.height_cm|default:"-" }} cm</p> <div><dt class="inline font-semibold">Origin team:</dt> <dd class="inline">{{ player.origin_team.name|default:"-" }}</dd></div>
<p><strong>Weight:</strong> {{ player.weight_kg|default:"-" }} kg</p> <div><dt class="inline font-semibold">Birth date:</dt> <dd class="inline">{{ player.birth_date|date:"Y-m-d"|default:"-" }}</dd></div>
<p><strong>Dominant hand:</strong> {{ player.get_dominant_hand_display|default:"-" }}</p> <div><dt class="inline font-semibold">Age:</dt> <dd class="inline">{{ age|default:"-" }}</dd></div>
<div><dt class="inline font-semibold">Height:</dt> <dd class="inline">{{ player.height_cm|default:"-" }} cm</dd></div>
<div><dt class="inline font-semibold">Weight:</dt> <dd class="inline">{{ player.weight_kg|default:"-" }} kg</dd></div>
<div><dt class="inline font-semibold">Dominant hand:</dt> <dd class="inline">{{ player.get_dominant_hand_display|default:"-" }}</dd></div>
</dl>
</div> </div>
<div class="detail-card"> <div class="rounded-lg border border-slate-200 p-4">
<h2>Current Assignment</h2> <h2 class="text-base">Current Assignment</h2>
{% if current_assignment %} {% if current_assignment %}
<p><strong>Team:</strong> {{ current_assignment.team.name|default:"-" }}</p> <dl class="mt-2 space-y-1 text-sm">
<p><strong>Competition:</strong> {{ current_assignment.competition.name|default:"-" }}</p> <div><dt class="inline font-semibold">Team:</dt> <dd class="inline">{{ current_assignment.team.name|default:"-" }}</dd></div>
<p><strong>Season:</strong> {{ current_assignment.season.label|default:"-" }}</p> <div><dt class="inline font-semibold">Competition:</dt> <dd class="inline">{{ current_assignment.competition.name|default:"-" }}</dd></div>
<p><strong>Games:</strong> {{ current_assignment.games_played }}</p> <div><dt class="inline font-semibold">Season:</dt> <dd class="inline">{{ current_assignment.season.label|default:"-" }}</dd></div>
<div><dt class="inline font-semibold">Games:</dt> <dd class="inline">{{ current_assignment.games_played }}</dd></div>
</dl>
{% else %} {% else %}
<p>No active assignment available.</p> <div class="empty-state mt-2">No active assignment available.</div>
{% endif %} {% endif %}
</div> </div>
<div class="detail-card"> <div class="rounded-lg border border-slate-200 p-4">
<h2>Aliases</h2> <h2 class="text-base">Aliases</h2>
<ul> <ul class="mt-2 list-inside list-disc text-sm text-slate-700">
{% for alias in player.aliases.all %} {% for alias in player.aliases.all %}
<li>{{ alias.alias }}{% if alias.source %} ({{ alias.source }}){% endif %}</li> <li>{{ alias.alias }}{% if alias.source %} ({{ alias.source }}){% endif %}</li>
{% empty %} {% empty %}
@ -56,50 +59,33 @@
</div> </div>
</section> </section>
<section class="panel mt-16"> <section class="panel mt-4">
<h2>Team History</h2> <h2>Team History</h2>
{% if season_rows %} {% if season_rows %}
<div class="table-wrap"> <div class="table-wrap mt-3">
<table> <table class="data-table">
<thead> <thead><tr><th>Season</th><th>Team</th><th>Competition</th></tr></thead>
<tr> <tbody class="divide-y divide-slate-100 bg-white">
<th>Season</th>
<th>Team</th>
<th>Competition</th>
</tr>
</thead>
<tbody>
{% for row in season_rows %} {% for row in season_rows %}
<tr> <tr><td>{{ row.season.label|default:"-" }}</td><td>{{ row.team.name|default:"-" }}</td><td>{{ row.competition.name|default:"-" }}</td></tr>
<td>{{ row.season.label|default:"-" }}</td>
<td>{{ row.team.name|default:"-" }}</td>
<td>{{ row.competition.name|default:"-" }}</td>
</tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
{% else %} {% else %}
<p>No team history available.</p> <div class="empty-state mt-3">No team history available.</div>
{% endif %} {% endif %}
</section> </section>
<section class="panel mt-16"> <section class="panel mt-4">
<h2>Career History</h2> <h2>Career History</h2>
{% if career_entries %} {% if career_entries %}
<div class="table-wrap"> <div class="table-wrap mt-3">
<table> <table class="data-table">
<thead> <thead>
<tr> <tr><th>Season</th><th>Team</th><th>Competition</th><th>Role</th><th>From</th><th>To</th></tr>
<th>Season</th>
<th>Team</th>
<th>Competition</th>
<th>Role</th>
<th>From</th>
<th>To</th>
</tr>
</thead> </thead>
<tbody> <tbody class="divide-y divide-slate-100 bg-white">
{% for entry in career_entries %} {% for entry in career_entries %}
<tr> <tr>
<td>{{ entry.season.label|default:"-" }}</td> <td>{{ entry.season.label|default:"-" }}</td>
@ -114,44 +100,28 @@
</table> </table>
</div> </div>
{% else %} {% else %}
<p>No career entries available.</p> <div class="empty-state mt-3">No career entries available.</div>
{% endif %} {% endif %}
</section> </section>
<section class="panel mt-16"> <section class="panel mt-4">
<h2>Season-by-Season Stats</h2> <h2>Season-by-Season Stats</h2>
{% if season_rows %} {% if season_rows %}
<div class="table-wrap"> <div class="table-wrap mt-3">
<table> <table class="data-table">
<thead> <thead>
<tr> <tr>
<th>Season</th> <th>Season</th><th>Team</th><th>Competition</th><th>Games</th><th>MPG</th><th>PPG</th><th>RPG</th><th>APG</th><th>SPG</th><th>BPG</th><th>TOPG</th><th>FG%</th><th>3P%</th><th>FT%</th><th>Impact</th>
<th>Team</th>
<th>Competition</th>
<th>Games</th>
<th>MPG</th>
<th>PPG</th>
<th>RPG</th>
<th>APG</th>
<th>SPG</th>
<th>BPG</th>
<th>TOPG</th>
<th>FG%</th>
<th>3P%</th>
<th>FT%</th>
<th>Impact</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody class="divide-y divide-slate-100 bg-white">
{% for row in season_rows %} {% for row in season_rows %}
<tr> <tr>
<td>{{ row.season.label|default:"-" }}</td> <td>{{ row.season.label|default:"-" }}</td>
<td>{{ row.team.name|default:"-" }}</td> <td>{{ row.team.name|default:"-" }}</td>
<td>{{ row.competition.name|default:"-" }}</td> <td>{{ row.competition.name|default:"-" }}</td>
<td>{{ row.games_played }}</td> <td>{{ row.games_played }}</td>
<td> <td>{% if row.mpg is not None %}{{ row.mpg|floatformat:1 }}{% else %}-{% endif %}</td>
{% if row.mpg is not None %}{{ row.mpg|floatformat:1 }}{% else %}-{% endif %}
</td>
<td>{% if row.stats %}{{ row.stats.points }}{% else %}-{% endif %}</td> <td>{% if row.stats %}{{ row.stats.points }}{% else %}-{% endif %}</td>
<td>{% if row.stats %}{{ row.stats.rebounds }}{% else %}-{% endif %}</td> <td>{% if row.stats %}{{ row.stats.rebounds }}{% else %}-{% endif %}</td>
<td>{% if row.stats %}{{ row.stats.assists }}{% else %}-{% endif %}</td> <td>{% if row.stats %}{{ row.stats.assists }}{% else %}-{% endif %}</td>
@ -168,7 +138,7 @@
</table> </table>
</div> </div>
{% else %} {% else %}
<p>No season stats available.</p> <div class="empty-state mt-3">No season stats available.</div>
{% endif %} {% endif %}
</section> </section>
{% endblock %} {% endblock %}

View File

@ -5,18 +5,19 @@
{% block content %} {% block content %}
<section class="panel"> <section class="panel">
<h1>Player Search</h1> <h1>Player Search</h1>
<p>Filter players by profile, context, and production metrics.</p> <p class="mt-1 text-sm text-slate-600">Filter players by profile, origin, context, and production metrics.</p>
<form <form
method="get" method="get"
class="stack search-form" class="mt-4 space-y-4"
hx-get="{% url 'players:index' %}" hx-get="{% url 'players:index' %}"
hx-target="#player-results" hx-target="#player-results"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-push-url="true" hx-push-url="true"
hx-indicator="#htmx-loading"
hx-trigger="submit, change delay:200ms from:select, keyup changed delay:400ms from:#id_q" hx-trigger="submit, change delay:200ms from:select, keyup changed delay:400ms from:#id_q"
> >
<div class="filter-grid filter-grid-4"> <div class="grid gap-3 md:grid-cols-4">
<div> <div>
<label for="id_q">Name</label> <label for="id_q">Name</label>
{{ search_form.q }} {{ search_form.q }}
@ -29,24 +30,26 @@
<label for="id_page_size">Page size</label> <label for="id_page_size">Page size</label>
{{ search_form.page_size }} {{ search_form.page_size }}
</div> </div>
<div class="filter-actions"> <div class="flex items-end gap-2">
<button type="submit" class="button">Apply</button> <button type="submit" class="btn">Apply</button>
<a class="button ghost" href="{% url 'players:index' %}">Reset</a> <a class="btn-secondary" href="{% url 'players:index' %}">Reset</a>
</div> </div>
</div> </div>
<div class="filter-grid filter-grid-3"> <div class="grid gap-3 md:grid-cols-3">
<div><label for="id_nominal_position">Nominal position</label>{{ search_form.nominal_position }}</div> <div><label for="id_nominal_position">Nominal position</label>{{ search_form.nominal_position }}</div>
<div><label for="id_inferred_role">Inferred role</label>{{ search_form.inferred_role }}</div> <div><label for="id_inferred_role">Inferred role</label>{{ search_form.inferred_role }}</div>
<div><label for="id_nationality">Nationality</label>{{ search_form.nationality }}</div> <div><label for="id_nationality">Nationality</label>{{ search_form.nationality }}</div>
<div><label for="id_competition">Competition</label>{{ search_form.competition }}</div> <div><label for="id_competition">Competition</label>{{ search_form.competition }}</div>
<div><label for="id_team">Team</label>{{ search_form.team }}</div> <div><label for="id_team">Team</label>{{ search_form.team }}</div>
<div><label for="id_season">Season</label>{{ search_form.season }}</div> <div><label for="id_season">Season</label>{{ search_form.season }}</div>
<div><label for="id_origin_competition">Origin competition</label>{{ search_form.origin_competition }}</div>
<div><label for="id_origin_team">Origin team</label>{{ search_form.origin_team }}</div>
</div> </div>
<details> <details class="rounded-lg border border-slate-200 bg-slate-50 p-3">
<summary>Physical and age filters</summary> <summary>Physical and age filters</summary>
<div class="filter-grid filter-grid-4"> <div class="mt-3 grid gap-3 md:grid-cols-4">
<div><label for="id_age_min">Age min</label>{{ search_form.age_min }}</div> <div><label for="id_age_min">Age min</label>{{ search_form.age_min }}</div>
<div><label for="id_age_max">Age max</label>{{ search_form.age_max }}</div> <div><label for="id_age_max">Age max</label>{{ search_form.age_max }}</div>
<div><label for="id_height_min">Height min (cm)</label>{{ search_form.height_min }}</div> <div><label for="id_height_min">Height min (cm)</label>{{ search_form.height_min }}</div>
@ -56,34 +59,29 @@
</div> </div>
</details> </details>
<details> <details class="rounded-lg border border-slate-200 bg-slate-50 p-3">
<summary>Statistical filters</summary> <summary>Statistical filters</summary>
<div class="filter-grid filter-grid-4"> <div class="mt-3 grid gap-3 md:grid-cols-4">
<div><label for="id_games_played_min">Games min</label>{{ search_form.games_played_min }}</div> <div><label for="id_games_played_min">Games min</label>{{ search_form.games_played_min }}</div>
<div><label for="id_games_played_max">Games max</label>{{ search_form.games_played_max }}</div> <div><label for="id_games_played_max">Games max</label>{{ search_form.games_played_max }}</div>
<div><label for="id_minutes_per_game_min">MPG min</label>{{ search_form.minutes_per_game_min }}</div> <div><label for="id_minutes_per_game_min">MPG min</label>{{ search_form.minutes_per_game_min }}</div>
<div><label for="id_minutes_per_game_max">MPG max</label>{{ search_form.minutes_per_game_max }}</div> <div><label for="id_minutes_per_game_max">MPG max</label>{{ search_form.minutes_per_game_max }}</div>
<div><label for="id_points_per_game_min">PPG min</label>{{ search_form.points_per_game_min }}</div> <div><label for="id_points_per_game_min">PPG min</label>{{ search_form.points_per_game_min }}</div>
<div><label for="id_points_per_game_max">PPG max</label>{{ search_form.points_per_game_max }}</div> <div><label for="id_points_per_game_max">PPG max</label>{{ search_form.points_per_game_max }}</div>
<div><label for="id_rebounds_per_game_min">RPG min</label>{{ search_form.rebounds_per_game_min }}</div> <div><label for="id_rebounds_per_game_min">RPG min</label>{{ search_form.rebounds_per_game_min }}</div>
<div><label for="id_rebounds_per_game_max">RPG max</label>{{ search_form.rebounds_per_game_max }}</div> <div><label for="id_rebounds_per_game_max">RPG max</label>{{ search_form.rebounds_per_game_max }}</div>
<div><label for="id_assists_per_game_min">APG min</label>{{ search_form.assists_per_game_min }}</div> <div><label for="id_assists_per_game_min">APG min</label>{{ search_form.assists_per_game_min }}</div>
<div><label for="id_assists_per_game_max">APG max</label>{{ search_form.assists_per_game_max }}</div> <div><label for="id_assists_per_game_max">APG max</label>{{ search_form.assists_per_game_max }}</div>
<div><label for="id_steals_per_game_min">SPG min</label>{{ search_form.steals_per_game_min }}</div> <div><label for="id_steals_per_game_min">SPG min</label>{{ search_form.steals_per_game_min }}</div>
<div><label for="id_steals_per_game_max">SPG max</label>{{ search_form.steals_per_game_max }}</div> <div><label for="id_steals_per_game_max">SPG max</label>{{ search_form.steals_per_game_max }}</div>
<div><label for="id_blocks_per_game_min">BPG min</label>{{ search_form.blocks_per_game_min }}</div> <div><label for="id_blocks_per_game_min">BPG min</label>{{ search_form.blocks_per_game_min }}</div>
<div><label for="id_blocks_per_game_max">BPG max</label>{{ search_form.blocks_per_game_max }}</div> <div><label for="id_blocks_per_game_max">BPG max</label>{{ search_form.blocks_per_game_max }}</div>
<div><label for="id_turnovers_per_game_min">TOPG min</label>{{ search_form.turnovers_per_game_min }}</div> <div><label for="id_turnovers_per_game_min">TOPG min</label>{{ search_form.turnovers_per_game_min }}</div>
<div><label for="id_turnovers_per_game_max">TOPG max</label>{{ search_form.turnovers_per_game_max }}</div> <div><label for="id_turnovers_per_game_max">TOPG max</label>{{ search_form.turnovers_per_game_max }}</div>
<div><label for="id_fg_pct_min">FG% min</label>{{ search_form.fg_pct_min }}</div> <div><label for="id_fg_pct_min">FG% min</label>{{ search_form.fg_pct_min }}</div>
<div><label for="id_fg_pct_max">FG% max</label>{{ search_form.fg_pct_max }}</div> <div><label for="id_fg_pct_max">FG% max</label>{{ search_form.fg_pct_max }}</div>
<div><label for="id_three_pct_min">3P% min</label>{{ search_form.three_pct_min }}</div> <div><label for="id_three_pct_min">3P% min</label>{{ search_form.three_pct_min }}</div>
<div><label for="id_three_pct_max">3P% max</label>{{ search_form.three_pct_max }}</div> <div><label for="id_three_pct_max">3P% max</label>{{ search_form.three_pct_max }}</div>
<div><label for="id_ft_pct_min">FT% min</label>{{ search_form.ft_pct_min }}</div> <div><label for="id_ft_pct_min">FT% min</label>{{ search_form.ft_pct_min }}</div>
<div><label for="id_ft_pct_max">FT% max</label>{{ search_form.ft_pct_max }}</div> <div><label for="id_ft_pct_max">FT% max</label>{{ search_form.ft_pct_max }}</div>
<div><label for="id_efficiency_metric_min">Impact min</label>{{ search_form.efficiency_metric_min }}</div> <div><label for="id_efficiency_metric_min">Impact min</label>{{ search_form.efficiency_metric_min }}</div>
@ -93,7 +91,7 @@
</form> </form>
</section> </section>
<section id="player-results" class="panel mt-16"> <section id="player-results" class="panel mt-4" aria-live="polite">
{% include "players/partials/results.html" %} {% include "players/partials/results.html" %}
</section> </section>
{% endblock %} {% endblock %}

View File

@ -1,8 +1,8 @@
{% load player_query %} {% load player_query %}
<div class="row-between wrap-gap"> <div class="flex flex-wrap items-center justify-between gap-3">
<h2>Results</h2> <h2>Results</h2>
<div class="muted-text"> <div class="text-sm text-slate-600">
{{ page_obj.paginator.count }} player{{ page_obj.paginator.count|pluralize }} found {{ page_obj.paginator.count }} player{{ page_obj.paginator.count|pluralize }} found
</div> </div>
</div> </div>
@ -12,13 +12,14 @@
{% endif %} {% endif %}
{% if players %} {% if players %}
<div class="table-wrap"> <div class="table-wrap mt-4">
<table> <table class="data-table">
<thead> <thead>
<tr> <tr>
<th>Player</th> <th>Player</th>
<th>Nationality</th> <th>Nationality</th>
<th>Pos / Role</th> <th>Pos / Role</th>
<th>Origin</th>
<th>Height / Weight</th> <th>Height / Weight</th>
<th>Games</th> <th>Games</th>
<th>MPG</th> <th>MPG</th>
@ -28,16 +29,15 @@
{% if request.user.is_authenticated %}<th>Watchlist</th>{% endif %} {% if request.user.is_authenticated %}<th>Watchlist</th>{% endif %}
</tr> </tr>
</thead> </thead>
<tbody> <tbody class="divide-y divide-slate-100 bg-white">
{% for player in players %} {% for player in players %}
<tr> <tr>
<td> <td><a class="font-medium" href="{% url 'players:detail' player.pk %}">{{ player.full_name }}</a></td>
<a href="{% url 'players:detail' player.pk %}">{{ player.full_name }}</a>
</td>
<td>{{ player.nationality.name|default:"-" }}</td> <td>{{ player.nationality.name|default:"-" }}</td>
<td>{{ player.nominal_position.code|default:"-" }} / {{ player.inferred_role.name|default:"-" }}</td>
<td> <td>
{{ player.nominal_position.code|default:"-" }} {{ player.origin_competition.name|default:"-" }}
/ {{ player.inferred_role.name|default:"-" }} {% if player.origin_team %}<div class="text-xs text-slate-500">{{ player.origin_team.name }}</div>{% endif %}
</td> </td>
<td>{{ player.height_cm|default:"-" }} / {{ player.weight_kg|default:"-" }}</td> <td>{{ player.height_cm|default:"-" }} / {{ player.weight_kg|default:"-" }}</td>
<td>{{ player.games_played_value|floatformat:0 }}</td> <td>{{ player.games_played_value|floatformat:0 }}</td>
@ -60,37 +60,21 @@
</table> </table>
</div> </div>
<div class="pagination row-gap mt-16"> <div class="mt-4 flex items-center justify-between gap-3">
{% if page_obj.has_previous %} <div>
{% query_transform page=page_obj.previous_page_number as prev_query %} {% if page_obj.has_previous %}
<a {% query_transform page=page_obj.previous_page_number as prev_query %}
class="button ghost" <a class="btn-secondary" href="?{{ prev_query }}" hx-get="?{{ prev_query }}" hx-target="#player-results" hx-swap="innerHTML" hx-push-url="true" hx-indicator="#htmx-loading">Previous</a>
href="?{{ prev_query }}" {% endif %}
hx-get="?{{ prev_query }}" </div>
hx-target="#player-results" <span class="text-sm text-slate-600">Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span>
hx-swap="innerHTML" <div>
hx-push-url="true" {% if page_obj.has_next %}
> {% query_transform page=page_obj.next_page_number as next_query %}
Previous <a class="btn-secondary" href="?{{ next_query }}" hx-get="?{{ next_query }}" hx-target="#player-results" hx-swap="innerHTML" hx-push-url="true" hx-indicator="#htmx-loading">Next</a>
</a> {% endif %}
{% endif %} </div>
<span>Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span>
{% if page_obj.has_next %}
{% query_transform page=page_obj.next_page_number as next_query %}
<a
class="button ghost"
href="?{{ next_query }}"
hx-get="?{{ next_query }}"
hx-target="#player-results"
hx-swap="innerHTML"
hx-push-url="true"
>
Next
</a>
{% endif %}
</div> </div>
{% else %} {% else %}
<p>No players matched the current filters.</p> <div class="empty-state mt-4">No players matched the current filters.</div>
{% endif %} {% endif %}

View File

@ -4,24 +4,24 @@
{% block content %} {% block content %}
<section class="panel"> <section class="panel">
<div class="row-between wrap-gap"> <div class="flex flex-wrap items-start justify-between gap-3">
<div> <div>
<h1>Scouting Workspace</h1> <h1>Scouting Workspace</h1>
<p class="muted-text">Manage saved searches and your player watchlist.</p> <p class="mt-1 text-sm text-slate-600">Manage saved searches and your player watchlist.</p>
</div> </div>
<div class="row-gap"> <div class="flex flex-wrap gap-2">
<a class="button ghost" href="{% url 'scouting:saved_search_list' %}">All saved searches</a> <a class="btn-secondary" href="{% url 'scouting:saved_search_list' %}">All saved searches</a>
<a class="button ghost" href="{% url 'scouting:watchlist' %}">Watchlist</a> <a class="btn-secondary" href="{% url 'scouting:watchlist' %}">Watchlist</a>
</div> </div>
</div> </div>
</section> </section>
<section class="panel mt-16"> <section class="panel mt-4">
<h2>Saved Searches</h2> <h2>Saved Searches</h2>
{% include "scouting/partials/saved_search_table.html" with saved_searches=saved_searches %} {% include "scouting/partials/saved_search_table.html" with saved_searches=saved_searches %}
</section> </section>
<section class="panel mt-16"> <section class="panel mt-4">
<h2>Watchlist</h2> <h2>Watchlist</h2>
{% include "scouting/partials/watchlist_table.html" with favorites=favorites %} {% include "scouting/partials/watchlist_table.html" with favorites=favorites %}
</section> </section>

View File

@ -5,12 +5,13 @@
hx-post="{% url 'scouting:favorite_toggle' player.id %}" hx-post="{% url 'scouting:favorite_toggle' player.id %}"
hx-target="#favorite-form-{{ player.id }}" hx-target="#favorite-form-{{ player.id }}"
hx-swap="outerHTML" hx-swap="outerHTML"
hx-indicator="#htmx-loading"
> >
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="next" value="{{ next_url }}"> <input type="hidden" name="next" value="{{ next_url }}">
{% if is_favorite %} {% if is_favorite %}
<button type="submit" class="button ghost">Remove favorite</button> <button type="submit" class="btn-secondary">Remove favorite</button>
{% else %} {% else %}
<button type="submit" class="button ghost">Add favorite</button> <button type="submit" class="btn-secondary">Add favorite</button>
{% endif %} {% endif %}
</form> </form>

View File

@ -1,3 +1,5 @@
<div class="message {% if ok %}success{% else %}error{% endif %}"> {% if ok %}
{{ message }} <div class="rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800" role="status">{{ message }}</div>
</div> {% else %}
<div class="rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-800" role="alert">{{ message }}</div>
{% endif %}

View File

@ -1,18 +1,23 @@
<div class="panel mt-16"> <div class="mt-4 rounded-lg border border-slate-200 bg-slate-50 p-4">
<h3>Save Current Search</h3> <h3>Save Current Search</h3>
<p class="muted-text">Store current filters and replay them later.</p> <p class="mt-1 text-sm text-slate-600">Store current filters and replay them later.</p>
<form <form
method="post" method="post"
action="{% url 'scouting:saved_search_create' %}" action="{% url 'scouting:saved_search_create' %}"
class="row-gap" class="mt-3 flex flex-wrap items-end gap-3"
hx-post="{% url 'scouting:saved_search_create' %}" hx-post="{% url 'scouting:saved_search_create' %}"
hx-target="#saved-search-feedback" hx-target="#saved-search-feedback"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-indicator="#htmx-loading"
> >
{% csrf_token %} {% csrf_token %}
<input type="text" name="name" placeholder="Search name" required> <div class="min-w-56 flex-1">
<label class="inline-label"> <label for="saved-search-name">Search name</label>
<input id="saved-search-name" type="text" name="name" placeholder="Search name" required>
</div>
<label class="inline-flex items-center gap-2 pb-2 text-sm text-slate-700">
<input type="checkbox" name="is_public"> <input type="checkbox" name="is_public">
Public Public
</label> </label>
@ -21,7 +26,7 @@
<input type="hidden" name="{{ key }}" value="{{ value }}"> <input type="hidden" name="{{ key }}" value="{{ value }}">
{% endfor %} {% endfor %}
<button class="button" type="submit">Save search</button> <button class="btn" type="submit">Save search</button>
</form> </form>
<div id="saved-search-feedback" class="mt-16"></div> <div id="saved-search-feedback" class="mt-3" aria-live="polite"></div>
</div> </div>

View File

@ -1,6 +1,6 @@
{% if saved_searches %} {% if saved_searches %}
<div class="table-wrap mt-16"> <div class="table-wrap mt-4">
<table> <table class="data-table">
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>Name</th>
@ -10,20 +10,20 @@
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody class="divide-y divide-slate-100 bg-white">
{% for saved_search in saved_searches %} {% for saved_search in saved_searches %}
<tr> <tr>
<td>{{ saved_search.name }}</td> <td class="font-medium text-slate-800">{{ saved_search.name }}</td>
<td>{% if saved_search.is_public %}Public{% else %}Private{% endif %}</td> <td>{% if saved_search.is_public %}Public{% else %}Private{% endif %}</td>
<td>{{ saved_search.updated_at|date:"Y-m-d H:i" }}</td> <td>{{ saved_search.updated_at|date:"Y-m-d H:i" }}</td>
<td>{{ saved_search.last_run_at|date:"Y-m-d H:i"|default:"-" }}</td> <td>{{ saved_search.last_run_at|date:"Y-m-d H:i"|default:"-" }}</td>
<td> <td>
<div class="row-gap"> <div class="flex flex-wrap gap-2">
<a class="button ghost" href="{% url 'scouting:saved_search_run' saved_search.pk %}">Run</a> <a class="btn-secondary" href="{% url 'scouting:saved_search_run' saved_search.pk %}">Run</a>
<a class="button ghost" href="{% url 'scouting:saved_search_edit' saved_search.pk %}">Edit</a> <a class="btn-secondary" href="{% url 'scouting:saved_search_edit' saved_search.pk %}">Edit</a>
<form method="post" action="{% url 'scouting:saved_search_delete' saved_search.pk %}"> <form method="post" action="{% url 'scouting:saved_search_delete' saved_search.pk %}">
{% csrf_token %} {% csrf_token %}
<button class="button ghost" type="submit">Delete</button> <button class="btn-secondary" type="submit">Delete</button>
</form> </form>
</div> </div>
</td> </td>
@ -33,5 +33,5 @@
</table> </table>
</div> </div>
{% else %} {% else %}
<p class="mt-16">No saved searches yet.</p> <div class="empty-state mt-4">No saved searches yet.</div>
{% endif %} {% endif %}

View File

@ -1,6 +1,6 @@
{% if favorites %} {% if favorites %}
<div class="table-wrap mt-16"> <div class="table-wrap mt-4">
<table> <table class="data-table">
<thead> <thead>
<tr> <tr>
<th>Player</th> <th>Player</th>
@ -10,15 +10,12 @@
<th>Action</th> <th>Action</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody class="divide-y divide-slate-100 bg-white">
{% for favorite in favorites %} {% for favorite in favorites %}
<tr> <tr>
<td><a href="{% url 'players:detail' favorite.player_id %}">{{ favorite.player.full_name }}</a></td> <td><a class="font-medium" href="{% url 'players:detail' favorite.player_id %}">{{ favorite.player.full_name }}</a></td>
<td>{{ favorite.player.nationality.name|default:"-" }}</td> <td>{{ favorite.player.nationality.name|default:"-" }}</td>
<td> <td>{{ favorite.player.nominal_position.code|default:"-" }} / {{ favorite.player.inferred_role.name|default:"-" }}</td>
{{ favorite.player.nominal_position.code|default:"-" }}
/ {{ favorite.player.inferred_role.name|default:"-" }}
</td>
<td>{{ favorite.created_at|date:"Y-m-d" }}</td> <td>{{ favorite.created_at|date:"Y-m-d" }}</td>
<td> <td>
<div id="favorite-toggle-{{ favorite.player_id }}"> <div id="favorite-toggle-{{ favorite.player_id }}">
@ -31,5 +28,5 @@
</table> </table>
</div> </div>
{% else %} {% else %}
<p class="mt-16">No players in your watchlist yet.</p> <div class="empty-state mt-4">No players in your watchlist yet.</div>
{% endif %} {% endif %}

View File

@ -3,14 +3,14 @@
{% block title %}HoopScout | Edit Saved Search{% endblock %} {% block title %}HoopScout | Edit Saved Search{% endblock %}
{% block content %} {% block content %}
<section class="panel narrow"> <section class="panel mx-auto max-w-lg">
<h1>Edit Saved Search</h1> <h1>Edit Saved Search</h1>
<form method="post" class="stack"> <form method="post" class="mt-4 space-y-4">
{% csrf_token %} {% csrf_token %}
{{ form.as_p }} {{ form.as_p }}
<div class="row-gap"> <div class="flex flex-wrap gap-2">
<button type="submit" class="button">Update</button> <button type="submit" class="btn">Update</button>
<a class="button ghost" href="{% url 'scouting:index' %}">Cancel</a> <a class="btn-secondary" href="{% url 'scouting:index' %}">Cancel</a>
</div> </div>
</form> </form>
</section> </section>

View File

@ -4,9 +4,9 @@
{% block content %} {% block content %}
<section class="panel"> <section class="panel">
<div class="row-between wrap-gap"> <div class="flex flex-wrap items-center justify-between gap-3">
<h1>Saved Searches</h1> <h1>Saved Searches</h1>
<a class="button ghost" href="{% url 'scouting:index' %}">Back to scouting</a> <a class="btn-secondary" href="{% url 'scouting:index' %}">Back to scouting</a>
</div> </div>
{% include "scouting/partials/saved_search_table.html" with saved_searches=saved_searches %} {% include "scouting/partials/saved_search_table.html" with saved_searches=saved_searches %}
</section> </section>

View File

@ -4,9 +4,9 @@
{% block content %} {% block content %}
<section class="panel"> <section class="panel">
<div class="row-between wrap-gap"> <div class="flex flex-wrap items-center justify-between gap-3">
<h1>Watchlist</h1> <h1>Watchlist</h1>
<a class="button ghost" href="{% url 'scouting:index' %}">Back to scouting</a> <a class="btn-secondary" href="{% url 'scouting:index' %}">Back to scouting</a>
</div> </div>
{% include "scouting/partials/watchlist_table.html" with favorites=favorites %} {% include "scouting/partials/watchlist_table.html" with favorites=favorites %}
</section> </section>

View File

@ -3,12 +3,12 @@
{% block title %}HoopScout | Login{% endblock %} {% block title %}HoopScout | Login{% endblock %}
{% block content %} {% block content %}
<section class="panel narrow"> <section class="panel mx-auto max-w-lg">
<h1>Login</h1> <h1>Login</h1>
<form method="post" class="stack"> <form method="post" class="mt-4 space-y-4">
{% csrf_token %} {% csrf_token %}
{{ form.as_p }} {{ form.as_p }}
<button type="submit" class="button">Sign in</button> <button type="submit" class="btn">Sign in</button>
</form> </form>
</section> </section>
{% endblock %} {% endblock %}

View File

@ -3,12 +3,12 @@
{% block title %}HoopScout | Signup{% endblock %} {% block title %}HoopScout | Signup{% endblock %}
{% block content %} {% block content %}
<section class="panel narrow"> <section class="panel mx-auto max-w-lg">
<h1>Create account</h1> <h1>Create account</h1>
<form method="post" class="stack"> <form method="post" class="mt-4 space-y-4">
{% csrf_token %} {% csrf_token %}
{{ form.as_p }} {{ form.as_p }}
<button type="submit" class="button">Create account</button> <button type="submit" class="btn">Create account</button>
</form> </form>
</section> </section>
{% endblock %} {% endblock %}

View File

@ -7,6 +7,7 @@ from apps.ingestion.models import IngestionError, IngestionRun
from apps.ingestion.services.sync import run_sync_job from apps.ingestion.services.sync import run_sync_job
from apps.players.models import Player from apps.players.models import Player
from apps.providers.exceptions import ProviderRateLimitError from apps.providers.exceptions import ProviderRateLimitError
from apps.providers.models import ExternalMapping
from apps.stats.models import PlayerSeason, PlayerSeasonStats from apps.stats.models import PlayerSeason, PlayerSeasonStats
from apps.teams.models import Team from apps.teams.models import Team
@ -24,6 +25,7 @@ def test_run_full_sync_creates_domain_objects(settings):
assert Player.objects.count() >= 1 assert Player.objects.count() >= 1
assert PlayerSeason.objects.count() >= 1 assert PlayerSeason.objects.count() >= 1
assert PlayerSeasonStats.objects.count() >= 1 assert PlayerSeasonStats.objects.count() >= 1
assert Player.objects.filter(origin_competition__isnull=False).exists()
@pytest.mark.django_db @pytest.mark.django_db
@ -81,3 +83,128 @@ def test_run_sync_handles_rate_limit(settings):
assert IngestionError.objects.filter(ingestion_run=run).exists() assert IngestionError.objects.filter(ingestion_run=run).exists()
os.environ.pop("PROVIDER_MVP_FORCE_RATE_LIMIT", None) os.environ.pop("PROVIDER_MVP_FORCE_RATE_LIMIT", None)
@pytest.mark.django_db
def test_balldontlie_sync_idempotency_with_stable_payload(monkeypatch):
class StableProvider:
def sync_all(self):
return {
"competitions": [
{
"external_id": "competition-nba",
"name": "NBA",
"slug": "nba",
"competition_type": "league",
"gender": "men",
"level": 1,
"country": {"name": "United States", "iso2_code": "US", "iso3_code": "USA"},
"is_active": True,
}
],
"teams": [
{
"external_id": "team-14",
"name": "Los Angeles Lakers",
"short_name": "LAL",
"slug": "los-angeles-lakers",
"country": {"name": "United States", "iso2_code": "US", "iso3_code": "USA"},
"is_national_team": False,
}
],
"seasons": [
{
"external_id": "season-2024",
"label": "2024-2025",
"start_date": "2024-10-01",
"end_date": "2025-06-30",
"is_current": False,
}
],
"players": [
{
"external_id": "player-237",
"first_name": "LeBron",
"last_name": "James",
"full_name": "LeBron James",
"birth_date": None,
"nationality": {"name": "United States", "iso2_code": "US", "iso3_code": "USA"},
"nominal_position": {"code": "SF", "name": "Small Forward"},
"inferred_role": {"code": "wing", "name": "Wing"},
"height_cm": None,
"weight_kg": None,
"dominant_hand": "unknown",
"is_active": True,
"aliases": [],
}
],
"player_stats": [
{
"external_id": "ps-2024-237-14",
"player_external_id": "player-237",
"team_external_id": "team-14",
"competition_external_id": "competition-nba",
"season_external_id": "season-2024",
"games_played": 2,
"games_started": 0,
"minutes_played": 68,
"points": 25,
"rebounds": 9,
"assists": 8,
"steals": 1.5,
"blocks": 0.5,
"turnovers": 3.5,
"fg_pct": 55.0,
"three_pct": 45.0,
"ft_pct": 95.0,
"usage_rate": None,
"true_shooting_pct": None,
"player_efficiency_rating": None,
}
],
"player_careers": [
{
"external_id": "career-2024-237-14",
"player_external_id": "player-237",
"team_external_id": "team-14",
"competition_external_id": "competition-nba",
"season_external_id": "season-2024",
"role_code": "",
"shirt_number": None,
"start_date": "2024-10-01",
"end_date": "2025-06-30",
"notes": "Imported from balldontlie aggregated box scores",
}
],
}
def sync_incremental(self, *, cursor: str | None = None):
payload = self.sync_all()
payload["cursor"] = cursor
return payload
monkeypatch.setattr("apps.ingestion.services.sync.get_provider", lambda namespace: StableProvider())
run_sync_job(provider_namespace="balldontlie", job_type=IngestionRun.JobType.FULL_SYNC)
counts_first = {
"competition": Competition.objects.count(),
"team": Team.objects.count(),
"season": Season.objects.count(),
"player": Player.objects.count(),
"player_season": PlayerSeason.objects.count(),
"player_stats": PlayerSeasonStats.objects.count(),
"mapping": ExternalMapping.objects.filter(provider_namespace="balldontlie").count(),
}
run_sync_job(provider_namespace="balldontlie", job_type=IngestionRun.JobType.FULL_SYNC)
counts_second = {
"competition": Competition.objects.count(),
"team": Team.objects.count(),
"season": Season.objects.count(),
"player": Player.objects.count(),
"player_season": PlayerSeason.objects.count(),
"player_stats": PlayerSeasonStats.objects.count(),
"mapping": ExternalMapping.objects.filter(provider_namespace="balldontlie").count(),
}
assert counts_first == counts_second

190
tests/test_player_origin.py Normal file
View File

@ -0,0 +1,190 @@
from datetime import date
import pytest
from django.urls import reverse
from apps.competitions.models import Competition, Season
from apps.players.models import Nationality, Player, PlayerCareerEntry, Position, Role
from apps.players.services.origin import refresh_player_origin, refresh_player_origins
from apps.teams.models import Team
@pytest.mark.django_db
def test_origin_derivation_uses_earliest_meaningful_career_entry():
nationality = Nationality.objects.create(name="Italy", iso2_code="IT", iso3_code="ITA")
position = Position.objects.create(code="PG", name="Point Guard")
role = Role.objects.create(code="playmaker", name="Playmaker")
player = Player.objects.create(
first_name="Marco",
last_name="Rossi",
full_name="Marco Rossi",
birth_date=date(2000, 1, 1),
nationality=nationality,
nominal_position=position,
inferred_role=role,
)
comp_early = Competition.objects.create(
name="Lega 2",
slug="lega-2",
competition_type=Competition.CompetitionType.LEAGUE,
gender=Competition.Gender.MEN,
)
comp_late = Competition.objects.create(
name="Lega 1",
slug="lega-1",
competition_type=Competition.CompetitionType.LEAGUE,
gender=Competition.Gender.MEN,
)
team_early = Team.objects.create(name="Bologna B", slug="bologna-b", country=nationality)
team_late = Team.objects.create(name="Bologna A", slug="bologna-a", country=nationality)
season_early = Season.objects.create(label="2017-2018", start_date=date(2017, 9, 1), end_date=date(2018, 6, 30))
season_late = Season.objects.create(label="2019-2020", start_date=date(2019, 9, 1), end_date=date(2020, 6, 30))
PlayerCareerEntry.objects.create(
player=player,
team=team_late,
competition=comp_late,
season=season_late,
start_date=date(2019, 9, 15),
)
PlayerCareerEntry.objects.create(
player=player,
team=team_early,
competition=comp_early,
season=season_early,
start_date=date(2017, 9, 15),
)
changed = refresh_player_origin(player)
assert changed is True
player.refresh_from_db()
assert player.origin_competition == comp_early
assert player.origin_team == team_early
@pytest.mark.django_db
def test_origin_unknown_when_no_meaningful_career_entries():
nationality = Nationality.objects.create(name="Spain", iso2_code="ES", iso3_code="ESP")
position = Position.objects.create(code="SF", name="Small Forward")
role = Role.objects.create(code="wing", name="Wing")
player = Player.objects.create(
first_name="Juan",
last_name="Perez",
full_name="Juan Perez",
birth_date=date(2001, 5, 1),
nationality=nationality,
nominal_position=position,
inferred_role=role,
)
changed = refresh_player_origin(player)
assert changed is False
player.refresh_from_db()
assert player.origin_competition is None
assert player.origin_team is None
@pytest.mark.django_db
def test_player_search_filters_by_origin_competition(client):
nationality = Nationality.objects.create(name="France", iso2_code="FR", iso3_code="FRA")
position = Position.objects.create(code="SG", name="Shooting Guard")
role = Role.objects.create(code="scorer", name="Scorer")
origin_a = Competition.objects.create(
name="LNB Pro A",
slug="lnb-pro-a-origin",
competition_type=Competition.CompetitionType.LEAGUE,
gender=Competition.Gender.MEN,
)
origin_b = Competition.objects.create(
name="LNB Pro B",
slug="lnb-pro-b-origin",
competition_type=Competition.CompetitionType.LEAGUE,
gender=Competition.Gender.MEN,
)
p1 = Player.objects.create(
first_name="A",
last_name="One",
full_name="A One",
birth_date=date(2000, 1, 1),
nationality=nationality,
nominal_position=position,
inferred_role=role,
origin_competition=origin_a,
)
Player.objects.create(
first_name="B",
last_name="Two",
full_name="B Two",
birth_date=date(2000, 1, 1),
nationality=nationality,
nominal_position=position,
inferred_role=role,
origin_competition=origin_b,
)
response = client.get(reverse("players:index"), data={"origin_competition": origin_a.id})
assert response.status_code == 200
players = list(response.context["players"])
assert len(players) == 1
assert players[0].id == p1.id
@pytest.mark.django_db
def test_backfill_refresh_player_origins_updates_existing_players():
nationality = Nationality.objects.create(name="Germany", iso2_code="DE", iso3_code="DEU")
position = Position.objects.create(code="PF", name="Power Forward")
role = Role.objects.create(code="big", name="Big")
competition = Competition.objects.create(
name="BBL",
slug="bbl-origin",
competition_type=Competition.CompetitionType.LEAGUE,
gender=Competition.Gender.MEN,
)
team = Team.objects.create(name="Berlin", slug="berlin-origin", country=nationality)
season = Season.objects.create(label="2018-2019", start_date=date(2018, 9, 1), end_date=date(2019, 6, 30))
p1 = Player.objects.create(
first_name="F1",
last_name="L1",
full_name="Player One",
birth_date=date(1999, 1, 1),
nationality=nationality,
nominal_position=position,
inferred_role=role,
)
p2 = Player.objects.create(
first_name="F2",
last_name="L2",
full_name="Player Two",
birth_date=date(1998, 1, 1),
nationality=nationality,
nominal_position=position,
inferred_role=role,
)
PlayerCareerEntry.objects.create(
player=p1,
team=team,
competition=competition,
season=season,
start_date=date(2018, 9, 10),
)
updated = refresh_player_origins(Player.objects.filter(id__in=[p1.id, p2.id]))
assert updated == 1
p1.refresh_from_db()
p2.refresh_from_db()
assert p1.origin_competition == competition
assert p1.origin_team == team
assert p2.origin_competition is None
assert p2.origin_team is None

View File

@ -0,0 +1,183 @@
from __future__ import annotations
import time
from typing import Any
import pytest
import requests
from apps.providers.adapters.balldontlie_provider import BalldontlieProviderAdapter
from apps.providers.adapters.mvp_provider import MvpDemoProviderAdapter
from apps.providers.clients.balldontlie import BalldontlieClient
from apps.providers.exceptions import ProviderRateLimitError, ProviderTransientError
from apps.providers.registry import get_default_provider_namespace, get_provider
class _FakeResponse:
def __init__(self, *, status_code: int, payload: dict[str, Any] | None = None, headers: dict[str, str] | None = None, text: str = ""):
self.status_code = status_code
self._payload = payload or {}
self.headers = headers or {}
self.text = text
def json(self):
return self._payload
class _FakeSession:
def __init__(self, responses: list[Any]):
self._responses = responses
def get(self, *args, **kwargs):
item = self._responses.pop(0)
if isinstance(item, Exception):
raise item
return item
class _FakeBalldontlieClient:
def get_json(self, path: str, *, params: dict[str, Any] | None = None) -> dict[str, Any]:
if path == "teams":
return {
"data": [
{
"id": 14,
"full_name": "Los Angeles Lakers",
"abbreviation": "LAL",
}
]
}
return {"data": []}
def list_paginated(
self,
path: str,
*,
params: dict[str, Any] | None = None,
per_page: int = 100,
page_limit: int = 1,
) -> list[dict[str, Any]]:
if path == "players":
return [
{
"id": 237,
"first_name": "LeBron",
"last_name": "James",
"position": "F",
"team": {"id": 14},
}
]
if path == "stats":
return [
{
"pts": 20,
"reb": 8,
"ast": 7,
"stl": 1,
"blk": 1,
"turnover": 3,
"fg_pct": 0.5,
"fg3_pct": 0.4,
"ft_pct": 0.9,
"min": "35:12",
"player": {"id": 237},
"team": {"id": 14},
"game": {"season": 2024},
},
{
"pts": 30,
"reb": 10,
"ast": 9,
"stl": 2,
"blk": 0,
"turnover": 4,
"fg_pct": 0.6,
"fg3_pct": 0.5,
"ft_pct": 1.0,
"min": "33:00",
"player": {"id": 237},
"team": {"id": 14},
"game": {"season": 2024},
},
]
return []
@pytest.mark.django_db
def test_provider_registry_backend_selection(settings):
settings.PROVIDER_DEFAULT_NAMESPACE = ""
settings.PROVIDER_BACKEND = "demo"
assert get_default_provider_namespace() == "mvp_demo"
assert isinstance(get_provider(), MvpDemoProviderAdapter)
settings.PROVIDER_BACKEND = "balldontlie"
assert get_default_provider_namespace() == "balldontlie"
assert isinstance(get_provider(), BalldontlieProviderAdapter)
settings.PROVIDER_DEFAULT_NAMESPACE = "mvp_demo"
assert get_default_provider_namespace() == "mvp_demo"
@pytest.mark.django_db
def test_balldontlie_adapter_maps_payloads(settings):
settings.PROVIDER_BALLDONTLIE_SEASONS = [2024]
adapter = BalldontlieProviderAdapter(client=_FakeBalldontlieClient())
payload = adapter.sync_all()
assert payload["competitions"][0]["external_id"] == "competition-nba"
assert payload["teams"][0]["external_id"] == "team-14"
assert payload["players"][0]["external_id"] == "player-237"
assert payload["seasons"][0]["external_id"] == "season-2024"
assert payload["player_stats"][0]["games_played"] == 2
assert payload["player_stats"][0]["points"] == 25.0
assert payload["player_stats"][0]["fg_pct"] == 55.0
@pytest.mark.django_db
def test_balldontlie_client_retries_after_rate_limit(monkeypatch, settings):
monkeypatch.setattr(time, "sleep", lambda _: None)
settings.PROVIDER_REQUEST_RETRIES = 2
settings.PROVIDER_REQUEST_RETRY_SLEEP = 0
session = _FakeSession(
responses=[
_FakeResponse(status_code=429, headers={"Retry-After": "0"}),
_FakeResponse(status_code=200, payload={"data": []}),
]
)
client = BalldontlieClient(session=session)
payload = client.get_json("players")
assert payload == {"data": []}
@pytest.mark.django_db
def test_balldontlie_client_timeout_retries_then_fails(monkeypatch, settings):
monkeypatch.setattr(time, "sleep", lambda _: None)
settings.PROVIDER_REQUEST_RETRIES = 2
settings.PROVIDER_REQUEST_RETRY_SLEEP = 0
session = _FakeSession(responses=[requests.Timeout("slow"), requests.Timeout("slow")])
client = BalldontlieClient(session=session)
with pytest.raises(ProviderTransientError):
client.get_json("players")
@pytest.mark.django_db
def test_balldontlie_client_raises_rate_limit_after_max_retries(monkeypatch, settings):
monkeypatch.setattr(time, "sleep", lambda _: None)
settings.PROVIDER_REQUEST_RETRIES = 2
settings.PROVIDER_REQUEST_RETRY_SLEEP = 0
session = _FakeSession(
responses=[
_FakeResponse(status_code=429, headers={"Retry-After": "1"}),
_FakeResponse(status_code=429, headers={"Retry-After": "1"}),
]
)
client = BalldontlieClient(session=session)
with pytest.raises(ProviderRateLimitError):
client.get_json("players")