Compare commits
3 Commits
f9329df64f
...
3d795991fe
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d795991fe | |||
| 4d49d30495 | |||
| acfccbea08 |
14
.env.example
14
.env.example
@ -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
3
.gitignore
vendored
@ -26,3 +26,6 @@ venv/
|
|||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
node_modules/
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
38
README.md
38
README.md
@ -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:
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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(
|
||||||
*,
|
*,
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
35
apps/players/migrations/0004_backfill_player_origins.py
Normal file
35
apps/players/migrations/0004_backfill_player_origins.py
Normal 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),
|
||||||
|
]
|
||||||
@ -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"]),
|
||||||
]
|
]
|
||||||
|
|||||||
46
apps/players/services/origin.py
Normal file
46
apps/players/services/origin.py
Normal 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
|
||||||
@ -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")
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
147
apps/providers/adapters/balldontlie_provider.py
Normal file
147
apps/providers/adapters/balldontlie_provider.py
Normal 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
|
||||||
3
apps/providers/clients/__init__.py
Normal file
3
apps/providers/clients/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from .balldontlie import BalldontlieClient
|
||||||
|
|
||||||
|
__all__ = ["BalldontlieClient"]
|
||||||
128
apps/providers/clients/balldontlie.py
Normal file
128
apps/providers/clients/balldontlie.py
Normal 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
|
||||||
@ -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}")
|
||||||
|
|||||||
260
apps/providers/services/balldontlie_mappings.py
Normal file
260
apps/providers/services/balldontlie_mappings.py
Normal 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
|
||||||
@ -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": [
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
858
package-lock.json
generated
Normal 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
13
package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
94
static/src/tailwind.css
Normal 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
25
tailwind.config.js
Normal 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: []
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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 %}
|
||||||
|
|||||||
@ -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 %}
|
||||||
|
|||||||
@ -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 %}
|
||||||
|
|||||||
@ -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 %}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 %}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 %}
|
||||||
|
|||||||
@ -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 %}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 %}
|
||||||
|
|||||||
@ -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 %}
|
||||||
|
|||||||
@ -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
190
tests/test_player_origin.py
Normal 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
|
||||||
183
tests/test_provider_balldontlie.py
Normal file
183
tests/test_provider_balldontlie.py
Normal 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")
|
||||||
Reference in New Issue
Block a user