feat(ingestion): add first public LBA Serie A importer
This commit is contained in:
@ -16,6 +16,7 @@ The current application baseline provides:
|
|||||||
- user-scoped plain-text scouting notes on player detail pages
|
- user-scoped plain-text scouting notes on player detail pages
|
||||||
- user-scoped saved searches (save, rerun, delete)
|
- user-scoped saved searches (save, rerun, delete)
|
||||||
- first real-data ingestion command baseline (`import_hoopdata_demo_competition`) with idempotent source-identity mapping
|
- first real-data ingestion command baseline (`import_hoopdata_demo_competition`) with idempotent source-identity mapping
|
||||||
|
- first public European importer (`import_lba_public_serie_a`) for LBA Serie A player-stat scope with idempotent external-ID binding
|
||||||
|
|
||||||
Accepted technical and product-shaping decisions live in:
|
Accepted technical and product-shaping decisions live in:
|
||||||
- `docs/ARCHITECTURE.md`
|
- `docs/ARCHITECTURE.md`
|
||||||
@ -66,6 +67,12 @@ First real-data importer (ADR-0009 baseline):
|
|||||||
- explicit input path import:
|
- explicit input path import:
|
||||||
`docker compose --env-file .env -f infra/docker-compose.yml exec -T app python manage.py import_hoopdata_demo_competition --input /app/scouting/sample_data/imports/hoopdata_demo_serie_a2_2025_2026.json`
|
`docker compose --env-file .env -f infra/docker-compose.yml exec -T app python manage.py import_hoopdata_demo_competition --input /app/scouting/sample_data/imports/hoopdata_demo_serie_a2_2025_2026.json`
|
||||||
|
|
||||||
|
First public European importer (LBA Serie A scope):
|
||||||
|
- live public-source import (season start year 2025):
|
||||||
|
`docker compose --env-file .env -f infra/docker-compose.yml exec -T app python manage.py import_lba_public_serie_a --season 2025`
|
||||||
|
- deterministic local-fixture import:
|
||||||
|
`docker compose --env-file .env -f infra/docker-compose.yml exec -T app python manage.py import_lba_public_serie_a --season 2025 --fixture /app/scouting/sample_data/imports/lba_public_serie_a_fixture.json`
|
||||||
|
|
||||||
Legacy shared favorites and notes from the pre-auth MVP are cleared by the early-stage ownership migration so the app can move cleanly to user-scoped data.
|
Legacy shared favorites and notes from the pre-auth MVP are cleared by the early-stage ownership migration so the app can move cleanly to user-scoped data.
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|||||||
359
app/scouting/importers/lba_public.py
Normal file
359
app/scouting/importers/lba_public.py
Normal file
@ -0,0 +1,359 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.error import URLError
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
from urllib.request import urlopen
|
||||||
|
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from scouting.models import (
|
||||||
|
Competition,
|
||||||
|
ExternalEntityMapping,
|
||||||
|
Player,
|
||||||
|
PlayerSeason,
|
||||||
|
PlayerSeasonStats,
|
||||||
|
Season,
|
||||||
|
Team,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ImportValidationError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
LBA_SOURCE_NAME = "lba_public"
|
||||||
|
LBA_COMPETITION_EXTERNAL_ID = "lba-serie-a"
|
||||||
|
LBA_COMPETITION_NAME = "Lega Basket Serie A"
|
||||||
|
LBA_COUNTRY = "IT"
|
||||||
|
LBA_LEVEL = "top"
|
||||||
|
LBA_STATS_ENDPOINT = "https://www.legabasket.it/api/statistics/get-players-statistics"
|
||||||
|
LBA_STAT_CATEGORIES = ["points", "assists", "regain_balls", "lost_balls", "plus_minus", "rating_oer"]
|
||||||
|
CATEGORY_TO_MODEL_FIELD = {
|
||||||
|
"points": "points",
|
||||||
|
"assists": "assists",
|
||||||
|
"regain_balls": "steals",
|
||||||
|
"lost_balls": "turnovers",
|
||||||
|
"plus_minus": "plus_minus",
|
||||||
|
"rating_oer": "offensive_rating",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ImportSummary:
|
||||||
|
players_created: int = 0
|
||||||
|
players_updated: int = 0
|
||||||
|
teams_created: int = 0
|
||||||
|
teams_updated: int = 0
|
||||||
|
contexts_created: int = 0
|
||||||
|
contexts_updated: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class LbaPublicStatsSource:
|
||||||
|
def __init__(self, base_url: str = LBA_STATS_ENDPOINT, timeout_sec: int = 30):
|
||||||
|
self.base_url = base_url
|
||||||
|
self.timeout_sec = timeout_sec
|
||||||
|
|
||||||
|
def fetch_category(self, season_start_year: int, category: str) -> dict:
|
||||||
|
query = urlencode({"s": season_start_year, "cat": category})
|
||||||
|
url = f"{self.base_url}?{query}"
|
||||||
|
try:
|
||||||
|
with urlopen(url, timeout=self.timeout_sec) as response:
|
||||||
|
payload = json.loads(response.read().decode("utf-8"))
|
||||||
|
except URLError as exc:
|
||||||
|
raise ImportValidationError(f"Could not fetch LBA source URL '{url}': {exc}") from exc
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ImportValidationError(f"Invalid JSON received from '{url}': {exc}") from exc
|
||||||
|
|
||||||
|
if not isinstance(payload, dict) or "stats" not in payload:
|
||||||
|
raise ImportValidationError(f"LBA source response from '{url}' is missing 'stats'.")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
class LbaFixtureStatsSource:
|
||||||
|
def __init__(self, fixture_path: Path):
|
||||||
|
self.fixture_path = fixture_path
|
||||||
|
try:
|
||||||
|
payload = json.loads(fixture_path.read_text(encoding="utf-8"))
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise ImportValidationError(f"Fixture file not found: {fixture_path}") from exc
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ImportValidationError(f"Invalid fixture JSON at '{fixture_path}': {exc}") from exc
|
||||||
|
|
||||||
|
categories = payload.get("categories")
|
||||||
|
if not isinstance(categories, dict):
|
||||||
|
raise ImportValidationError("Fixture payload must include a 'categories' object.")
|
||||||
|
self.categories = categories
|
||||||
|
|
||||||
|
def fetch_category(self, season_start_year: int, category: str) -> dict:
|
||||||
|
del season_start_year
|
||||||
|
payload = self.categories.get(category)
|
||||||
|
if payload is None:
|
||||||
|
raise ImportValidationError(f"Fixture payload missing category '{category}'.")
|
||||||
|
if not isinstance(payload, dict) or "stats" not in payload:
|
||||||
|
raise ImportValidationError(f"Fixture category '{category}' is missing 'stats'.")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
class LbaSerieAPublicImporter:
|
||||||
|
def __init__(self, *, season_start_year: int, source: LbaPublicStatsSource | LbaFixtureStatsSource):
|
||||||
|
self.season_start_year = season_start_year
|
||||||
|
self.source = source
|
||||||
|
self.summary = ImportSummary()
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def run(self) -> ImportSummary:
|
||||||
|
aggregated = self._collect_players()
|
||||||
|
competition = self._upsert_competition()
|
||||||
|
|
||||||
|
for record in aggregated.values():
|
||||||
|
season = self._upsert_season(record["year"])
|
||||||
|
team = self._upsert_team(record)
|
||||||
|
player = self._upsert_player(record)
|
||||||
|
context = self._upsert_player_season(record, player, team, season, competition)
|
||||||
|
self._upsert_stats(context, record)
|
||||||
|
|
||||||
|
return self.summary
|
||||||
|
|
||||||
|
def _collect_players(self) -> dict:
|
||||||
|
players = {}
|
||||||
|
|
||||||
|
for category in LBA_STAT_CATEGORIES:
|
||||||
|
payload = self.source.fetch_category(self.season_start_year, category)
|
||||||
|
stats = payload.get("stats")
|
||||||
|
if not isinstance(stats, list):
|
||||||
|
raise ImportValidationError(f"Category '{category}' response must include a list in 'stats'.")
|
||||||
|
|
||||||
|
for row in stats:
|
||||||
|
self._validate_stat_row(category, row)
|
||||||
|
key = (row["player_id"], row["team_id"], row["year"])
|
||||||
|
if key not in players:
|
||||||
|
players[key] = {
|
||||||
|
"player_id": row["player_id"],
|
||||||
|
"team_id": row["team_id"],
|
||||||
|
"year": row["year"],
|
||||||
|
"name": row["name"],
|
||||||
|
"surname": row["surname"],
|
||||||
|
"team_name": row["team_name"],
|
||||||
|
"scores": {},
|
||||||
|
}
|
||||||
|
players[key]["scores"][category] = self._to_decimal(row.get("score"))
|
||||||
|
|
||||||
|
if not players:
|
||||||
|
raise ImportValidationError("No player statistics found from LBA source.")
|
||||||
|
return players
|
||||||
|
|
||||||
|
def _validate_stat_row(self, category: str, row: dict) -> None:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
raise ImportValidationError(f"Category '{category}' contains a non-object stat row.")
|
||||||
|
for field in ["player_id", "team_id", "year", "name", "surname", "team_name", "score"]:
|
||||||
|
if row.get(field) in (None, ""):
|
||||||
|
raise ImportValidationError(f"Category '{category}' row missing required field '{field}'.")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _to_decimal(value) -> Decimal:
|
||||||
|
return Decimal(str(value))
|
||||||
|
|
||||||
|
def _mapping_for(self, entity_type: str, external_id: str) -> ExternalEntityMapping | None:
|
||||||
|
return ExternalEntityMapping.objects.filter(
|
||||||
|
source_name=LBA_SOURCE_NAME,
|
||||||
|
entity_type=entity_type,
|
||||||
|
external_id=external_id,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
def _bind_mapping(self, *, entity_type: str, external_id: str, object_id: int) -> None:
|
||||||
|
existing_for_external = ExternalEntityMapping.objects.filter(
|
||||||
|
source_name=LBA_SOURCE_NAME,
|
||||||
|
entity_type=entity_type,
|
||||||
|
external_id=external_id,
|
||||||
|
).first()
|
||||||
|
if existing_for_external and existing_for_external.object_id != object_id:
|
||||||
|
raise ImportValidationError(
|
||||||
|
f"External ID '{external_id}' for {entity_type} is already linked to a different record."
|
||||||
|
)
|
||||||
|
|
||||||
|
existing_for_object = ExternalEntityMapping.objects.filter(
|
||||||
|
source_name=LBA_SOURCE_NAME,
|
||||||
|
entity_type=entity_type,
|
||||||
|
object_id=object_id,
|
||||||
|
).first()
|
||||||
|
if existing_for_object and existing_for_object.external_id != external_id:
|
||||||
|
raise ImportValidationError(
|
||||||
|
f"Conflicting mapping for {entity_type} object {object_id}: "
|
||||||
|
f"'{existing_for_object.external_id}' vs '{external_id}'."
|
||||||
|
)
|
||||||
|
|
||||||
|
ExternalEntityMapping.objects.get_or_create(
|
||||||
|
source_name=LBA_SOURCE_NAME,
|
||||||
|
entity_type=entity_type,
|
||||||
|
external_id=external_id,
|
||||||
|
defaults={"object_id": object_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _upsert_competition(self) -> Competition:
|
||||||
|
mapping = self._mapping_for(ExternalEntityMapping.EntityType.COMPETITION, LBA_COMPETITION_EXTERNAL_ID)
|
||||||
|
defaults = {"country": LBA_COUNTRY, "level": LBA_LEVEL}
|
||||||
|
if mapping:
|
||||||
|
competition = Competition.objects.filter(pk=mapping.object_id).first()
|
||||||
|
if competition is None:
|
||||||
|
raise ImportValidationError("Competition mapping points to a missing record.")
|
||||||
|
Competition.objects.filter(pk=competition.pk).update(name=LBA_COMPETITION_NAME, **defaults)
|
||||||
|
competition.refresh_from_db()
|
||||||
|
else:
|
||||||
|
competition, _ = Competition.objects.get_or_create(name=LBA_COMPETITION_NAME, defaults=defaults)
|
||||||
|
|
||||||
|
self._bind_mapping(
|
||||||
|
entity_type=ExternalEntityMapping.EntityType.COMPETITION,
|
||||||
|
external_id=LBA_COMPETITION_EXTERNAL_ID,
|
||||||
|
object_id=competition.id,
|
||||||
|
)
|
||||||
|
return competition
|
||||||
|
|
||||||
|
def _upsert_season(self, year: int) -> Season:
|
||||||
|
season_name = f"{year}-{year + 1}"
|
||||||
|
season, created = Season.objects.get_or_create(
|
||||||
|
name=season_name,
|
||||||
|
defaults={"start_year": year, "end_year": year + 1},
|
||||||
|
)
|
||||||
|
if not created and (season.start_year != year or season.end_year != year + 1):
|
||||||
|
raise ImportValidationError(
|
||||||
|
f"Season '{season_name}' exists but does not match expected years {year}-{year + 1}."
|
||||||
|
)
|
||||||
|
return season
|
||||||
|
|
||||||
|
def _upsert_team(self, record: dict) -> Team:
|
||||||
|
external_id = str(record["team_id"])
|
||||||
|
mapping = self._mapping_for(ExternalEntityMapping.EntityType.TEAM, external_id)
|
||||||
|
|
||||||
|
if mapping:
|
||||||
|
team = Team.objects.filter(pk=mapping.object_id).first()
|
||||||
|
if team is None:
|
||||||
|
raise ImportValidationError("Team mapping points to a missing record.")
|
||||||
|
updates = []
|
||||||
|
if team.name != record["team_name"]:
|
||||||
|
team.name = record["team_name"]
|
||||||
|
updates.append("name")
|
||||||
|
if team.country != LBA_COUNTRY:
|
||||||
|
team.country = LBA_COUNTRY
|
||||||
|
updates.append("country")
|
||||||
|
if updates:
|
||||||
|
team.save(update_fields=updates + ["updated_at"])
|
||||||
|
self.summary.teams_updated += 1
|
||||||
|
else:
|
||||||
|
team, created = Team.objects.get_or_create(
|
||||||
|
name=record["team_name"],
|
||||||
|
country=LBA_COUNTRY,
|
||||||
|
defaults={},
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
self.summary.teams_created += 1
|
||||||
|
else:
|
||||||
|
self.summary.teams_updated += 1
|
||||||
|
|
||||||
|
self._bind_mapping(
|
||||||
|
entity_type=ExternalEntityMapping.EntityType.TEAM,
|
||||||
|
external_id=external_id,
|
||||||
|
object_id=team.id,
|
||||||
|
)
|
||||||
|
return team
|
||||||
|
|
||||||
|
def _upsert_player(self, record: dict) -> Player:
|
||||||
|
external_id = str(record["player_id"])
|
||||||
|
mapping = self._mapping_for(ExternalEntityMapping.EntityType.PLAYER, external_id)
|
||||||
|
full_name = f"{record['name']} {record['surname']}".strip()
|
||||||
|
|
||||||
|
if mapping:
|
||||||
|
player = Player.objects.filter(pk=mapping.object_id).first()
|
||||||
|
if player is None:
|
||||||
|
raise ImportValidationError("Player mapping points to a missing record.")
|
||||||
|
position = player.position or Player.Position.SG
|
||||||
|
player.full_name = full_name
|
||||||
|
player.first_name = record["name"]
|
||||||
|
player.last_name = record["surname"]
|
||||||
|
player.position = position
|
||||||
|
player.save()
|
||||||
|
self.summary.players_updated += 1
|
||||||
|
else:
|
||||||
|
# LBA stats endpoint does not expose position directly. To satisfy the current
|
||||||
|
# required model field without guessing role/taxonomy data, we use a neutral
|
||||||
|
# default and keep role/specialty ownership untouched.
|
||||||
|
player = Player.objects.create(
|
||||||
|
full_name=full_name,
|
||||||
|
first_name=record["name"],
|
||||||
|
last_name=record["surname"],
|
||||||
|
position=Player.Position.SG,
|
||||||
|
)
|
||||||
|
self.summary.players_created += 1
|
||||||
|
|
||||||
|
self._bind_mapping(
|
||||||
|
entity_type=ExternalEntityMapping.EntityType.PLAYER,
|
||||||
|
external_id=external_id,
|
||||||
|
object_id=player.id,
|
||||||
|
)
|
||||||
|
return player
|
||||||
|
|
||||||
|
def _upsert_player_season(
|
||||||
|
self,
|
||||||
|
record: dict,
|
||||||
|
player: Player,
|
||||||
|
team: Team,
|
||||||
|
season: Season,
|
||||||
|
competition: Competition,
|
||||||
|
) -> PlayerSeason:
|
||||||
|
context_external_id = f"{record['year']}:{record['team_id']}:{record['player_id']}"
|
||||||
|
mapping = self._mapping_for(ExternalEntityMapping.EntityType.PLAYER_SEASON, context_external_id)
|
||||||
|
|
||||||
|
if mapping:
|
||||||
|
context = PlayerSeason.objects.filter(pk=mapping.object_id).first()
|
||||||
|
if context is None:
|
||||||
|
raise ImportValidationError("PlayerSeason mapping points to a missing record.")
|
||||||
|
if (
|
||||||
|
context.player_id != player.id
|
||||||
|
or context.team_id != team.id
|
||||||
|
or context.season_id != season.id
|
||||||
|
or context.competition_id != competition.id
|
||||||
|
):
|
||||||
|
raise ImportValidationError("Mapped player-season context does not match incoming source identity.")
|
||||||
|
self.summary.contexts_updated += 1
|
||||||
|
else:
|
||||||
|
context, created = PlayerSeason.objects.get_or_create(
|
||||||
|
player=player,
|
||||||
|
team=team,
|
||||||
|
season=season,
|
||||||
|
competition=competition,
|
||||||
|
defaults={},
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
self.summary.contexts_created += 1
|
||||||
|
else:
|
||||||
|
self.summary.contexts_updated += 1
|
||||||
|
|
||||||
|
self._bind_mapping(
|
||||||
|
entity_type=ExternalEntityMapping.EntityType.PLAYER_SEASON,
|
||||||
|
external_id=context_external_id,
|
||||||
|
object_id=context.id,
|
||||||
|
)
|
||||||
|
return context
|
||||||
|
|
||||||
|
def _upsert_stats(self, context: PlayerSeason, record: dict) -> None:
|
||||||
|
stats_defaults = {
|
||||||
|
"points": None,
|
||||||
|
"assists": None,
|
||||||
|
"steals": None,
|
||||||
|
"turnovers": None,
|
||||||
|
"blocks": None,
|
||||||
|
"efg_pct": None,
|
||||||
|
"ts_pct": None,
|
||||||
|
"plus_minus": None,
|
||||||
|
"offensive_rating": None,
|
||||||
|
"defensive_rating": None,
|
||||||
|
}
|
||||||
|
for category, value in record["scores"].items():
|
||||||
|
model_field = CATEGORY_TO_MODEL_FIELD.get(category)
|
||||||
|
if model_field:
|
||||||
|
stats_defaults[model_field] = value
|
||||||
|
|
||||||
|
PlayerSeasonStats.objects.update_or_create(player_season=context, defaults=stats_defaults)
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
|
||||||
|
from scouting.importers.lba_public import (
|
||||||
|
LBA_SOURCE_NAME,
|
||||||
|
LbaFixtureStatsSource,
|
||||||
|
LbaPublicStatsSource,
|
||||||
|
LbaSerieAPublicImporter,
|
||||||
|
ImportValidationError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Import public LBA Serie A player statistics for one season using the ADR-0009 baseline flow."
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"--season",
|
||||||
|
type=int,
|
||||||
|
default=2025,
|
||||||
|
help="Season start year to import from LBA public statistics API (default: 2025).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--fixture",
|
||||||
|
default="",
|
||||||
|
help="Optional local fixture JSON path for deterministic/offline runs.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
season_start_year = options["season"]
|
||||||
|
fixture_path = (options.get("fixture") or "").strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if fixture_path:
|
||||||
|
source = LbaFixtureStatsSource(Path(fixture_path))
|
||||||
|
source_label = f"fixture={fixture_path}"
|
||||||
|
else:
|
||||||
|
source = LbaPublicStatsSource()
|
||||||
|
source_label = "public=https://www.legabasket.it/api/statistics/get-players-statistics"
|
||||||
|
|
||||||
|
summary = LbaSerieAPublicImporter(season_start_year=season_start_year, source=source).run()
|
||||||
|
except ImportValidationError as exc:
|
||||||
|
raise CommandError(f"LBA public import failed: {exc}") from exc
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
"Imported LBA Serie A public statistics successfully. "
|
||||||
|
f"Source={LBA_SOURCE_NAME}; season={season_start_year}; "
|
||||||
|
f"players +{summary.players_created}/~{summary.players_updated}, "
|
||||||
|
f"teams +{summary.teams_created}/~{summary.teams_updated}, "
|
||||||
|
f"contexts +{summary.contexts_created}/~{summary.contexts_updated}."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.stdout.write(f"Input: {source_label}")
|
||||||
148
app/scouting/sample_data/imports/lba_public_serie_a_fixture.json
Normal file
148
app/scouting/sample_data/imports/lba_public_serie_a_fixture.json
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
{
|
||||||
|
"categories": {
|
||||||
|
"points": {
|
||||||
|
"stats": [
|
||||||
|
{
|
||||||
|
"category": "points",
|
||||||
|
"player_id": 6609,
|
||||||
|
"name": "Muhammad-Ali",
|
||||||
|
"surname": "Abdur-Rahkman",
|
||||||
|
"team_name": "NutriBullet Treviso Basket",
|
||||||
|
"year": 2025,
|
||||||
|
"score": 19.5,
|
||||||
|
"team_id": 1642
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "points",
|
||||||
|
"player_id": 45,
|
||||||
|
"name": "Nicola",
|
||||||
|
"surname": "Akele",
|
||||||
|
"team_name": "Virtus Olidata Bologna",
|
||||||
|
"year": 2025,
|
||||||
|
"score": 11.2,
|
||||||
|
"team_id": 1715
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"assists": {
|
||||||
|
"stats": [
|
||||||
|
{
|
||||||
|
"category": "assists",
|
||||||
|
"player_id": 6609,
|
||||||
|
"name": "Muhammad-Ali",
|
||||||
|
"surname": "Abdur-Rahkman",
|
||||||
|
"team_name": "NutriBullet Treviso Basket",
|
||||||
|
"year": 2025,
|
||||||
|
"score": 4.4,
|
||||||
|
"team_id": 1642
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "assists",
|
||||||
|
"player_id": 45,
|
||||||
|
"name": "Nicola",
|
||||||
|
"surname": "Akele",
|
||||||
|
"team_name": "Virtus Olidata Bologna",
|
||||||
|
"year": 2025,
|
||||||
|
"score": 2.1,
|
||||||
|
"team_id": 1715
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"regain_balls": {
|
||||||
|
"stats": [
|
||||||
|
{
|
||||||
|
"category": "regain_balls",
|
||||||
|
"player_id": 6609,
|
||||||
|
"name": "Muhammad-Ali",
|
||||||
|
"surname": "Abdur-Rahkman",
|
||||||
|
"team_name": "NutriBullet Treviso Basket",
|
||||||
|
"year": 2025,
|
||||||
|
"score": 1.3,
|
||||||
|
"team_id": 1642
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "regain_balls",
|
||||||
|
"player_id": 45,
|
||||||
|
"name": "Nicola",
|
||||||
|
"surname": "Akele",
|
||||||
|
"team_name": "Virtus Olidata Bologna",
|
||||||
|
"year": 2025,
|
||||||
|
"score": 0.8,
|
||||||
|
"team_id": 1715
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"lost_balls": {
|
||||||
|
"stats": [
|
||||||
|
{
|
||||||
|
"category": "lost_balls",
|
||||||
|
"player_id": 6609,
|
||||||
|
"name": "Muhammad-Ali",
|
||||||
|
"surname": "Abdur-Rahkman",
|
||||||
|
"team_name": "NutriBullet Treviso Basket",
|
||||||
|
"year": 2025,
|
||||||
|
"score": 2.2,
|
||||||
|
"team_id": 1642
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "lost_balls",
|
||||||
|
"player_id": 45,
|
||||||
|
"name": "Nicola",
|
||||||
|
"surname": "Akele",
|
||||||
|
"team_name": "Virtus Olidata Bologna",
|
||||||
|
"year": 2025,
|
||||||
|
"score": 1.0,
|
||||||
|
"team_id": 1715
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"plus_minus": {
|
||||||
|
"stats": [
|
||||||
|
{
|
||||||
|
"category": "plus_minus",
|
||||||
|
"player_id": 6609,
|
||||||
|
"name": "Muhammad-Ali",
|
||||||
|
"surname": "Abdur-Rahkman",
|
||||||
|
"team_name": "NutriBullet Treviso Basket",
|
||||||
|
"year": 2025,
|
||||||
|
"score": 3.1,
|
||||||
|
"team_id": 1642
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "plus_minus",
|
||||||
|
"player_id": 45,
|
||||||
|
"name": "Nicola",
|
||||||
|
"surname": "Akele",
|
||||||
|
"team_name": "Virtus Olidata Bologna",
|
||||||
|
"year": 2025,
|
||||||
|
"score": 1.5,
|
||||||
|
"team_id": 1715
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"rating_oer": {
|
||||||
|
"stats": [
|
||||||
|
{
|
||||||
|
"category": "rating_oer",
|
||||||
|
"player_id": 6609,
|
||||||
|
"name": "Muhammad-Ali",
|
||||||
|
"surname": "Abdur-Rahkman",
|
||||||
|
"team_name": "NutriBullet Treviso Basket",
|
||||||
|
"year": 2025,
|
||||||
|
"score": 111.4,
|
||||||
|
"team_id": 1642
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "rating_oer",
|
||||||
|
"player_id": 45,
|
||||||
|
"name": "Nicola",
|
||||||
|
"surname": "Akele",
|
||||||
|
"team_name": "Virtus Olidata Bologna",
|
||||||
|
"year": 2025,
|
||||||
|
"score": 106.7,
|
||||||
|
"team_id": 1715
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,9 +1,10 @@
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.core.management import call_command
|
from django.core.management import CommandError, call_command
|
||||||
from django.db import connection
|
from django.db import connection
|
||||||
from django.db.migrations.executor import MigrationExecutor
|
from django.db.migrations.executor import MigrationExecutor
|
||||||
from django.test import TestCase, TransactionTestCase
|
from django.test import TestCase, TransactionTestCase
|
||||||
@ -526,6 +527,135 @@ class FirstRealIngestionFlowTests(TestCase):
|
|||||||
self.assertContains(detail_response, "PTS 17.2")
|
self.assertContains(detail_response, "PTS 17.2")
|
||||||
|
|
||||||
|
|
||||||
|
class FirstPublicEuropeanImporterTests(TestCase):
|
||||||
|
COMMAND_NAME = "import_lba_public_serie_a"
|
||||||
|
SOURCE_NAME = "lba_public"
|
||||||
|
FIXTURE_PATH = Path(__file__).resolve().parent / "sample_data" / "imports" / "lba_public_serie_a_fixture.json"
|
||||||
|
|
||||||
|
def run_import(self):
|
||||||
|
call_command(self.COMMAND_NAME, fixture=str(self.FIXTURE_PATH), season=2025)
|
||||||
|
|
||||||
|
def test_importer_command_runs_successfully_for_lba_scope(self):
|
||||||
|
self.run_import()
|
||||||
|
self.assertGreaterEqual(Player.objects.count(), 2)
|
||||||
|
|
||||||
|
def test_importer_creates_expected_entities_contexts_and_stats(self):
|
||||||
|
self.run_import()
|
||||||
|
|
||||||
|
self.assertTrue(Competition.objects.filter(name="Lega Basket Serie A").exists())
|
||||||
|
self.assertTrue(Season.objects.filter(name="2025-2026", start_year=2025, end_year=2026).exists())
|
||||||
|
self.assertTrue(Player.objects.filter(full_name="Muhammad-Ali Abdur-Rahkman").exists())
|
||||||
|
self.assertTrue(Player.objects.filter(full_name="Nicola Akele").exists())
|
||||||
|
self.assertTrue(Team.objects.filter(name="NutriBullet Treviso Basket", country="IT").exists())
|
||||||
|
self.assertTrue(PlayerSeason.objects.filter(player__full_name="Muhammad-Ali Abdur-Rahkman").exists())
|
||||||
|
self.assertTrue(PlayerSeasonStats.objects.filter(player_season__player__full_name="Muhammad-Ali Abdur-Rahkman").exists())
|
||||||
|
|
||||||
|
def test_importer_is_idempotent_for_same_input(self):
|
||||||
|
self.run_import()
|
||||||
|
first_counts = {
|
||||||
|
"players": Player.objects.count(),
|
||||||
|
"teams": Team.objects.count(),
|
||||||
|
"contexts": PlayerSeason.objects.count(),
|
||||||
|
"stats": PlayerSeasonStats.objects.count(),
|
||||||
|
"mappings": ExternalEntityMapping.objects.filter(source_name=self.SOURCE_NAME).count(),
|
||||||
|
}
|
||||||
|
|
||||||
|
self.run_import()
|
||||||
|
|
||||||
|
self.assertEqual(Player.objects.count(), first_counts["players"])
|
||||||
|
self.assertEqual(Team.objects.count(), first_counts["teams"])
|
||||||
|
self.assertEqual(PlayerSeason.objects.count(), first_counts["contexts"])
|
||||||
|
self.assertEqual(PlayerSeasonStats.objects.count(), first_counts["stats"])
|
||||||
|
self.assertEqual(
|
||||||
|
ExternalEntityMapping.objects.filter(source_name=self.SOURCE_NAME).count(),
|
||||||
|
first_counts["mappings"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_importer_respects_external_entity_mapping(self):
|
||||||
|
self.run_import()
|
||||||
|
|
||||||
|
self.assertTrue(
|
||||||
|
ExternalEntityMapping.objects.filter(
|
||||||
|
source_name=self.SOURCE_NAME,
|
||||||
|
entity_type=ExternalEntityMapping.EntityType.COMPETITION,
|
||||||
|
external_id="lba-serie-a",
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
ExternalEntityMapping.objects.filter(
|
||||||
|
source_name=self.SOURCE_NAME,
|
||||||
|
entity_type=ExternalEntityMapping.EntityType.PLAYER,
|
||||||
|
external_id="6609",
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
ExternalEntityMapping.objects.filter(
|
||||||
|
source_name=self.SOURCE_NAME,
|
||||||
|
entity_type=ExternalEntityMapping.EntityType.TEAM,
|
||||||
|
external_id="1642",
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
ExternalEntityMapping.objects.filter(
|
||||||
|
source_name=self.SOURCE_NAME,
|
||||||
|
entity_type=ExternalEntityMapping.EntityType.PLAYER_SEASON,
|
||||||
|
external_id="2025:1642:6609",
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_importer_does_not_overwrite_internal_scouting_enrichment(self):
|
||||||
|
role = Role.objects.create(name="internal role lba", slug="internal-role-lba")
|
||||||
|
specialty = Specialty.objects.create(name="internal specialty lba", slug="internal-specialty-lba")
|
||||||
|
self.run_import()
|
||||||
|
|
||||||
|
player = Player.objects.get(full_name="Muhammad-Ali Abdur-Rahkman")
|
||||||
|
player.roles.add(role)
|
||||||
|
player.specialties.add(specialty)
|
||||||
|
|
||||||
|
self.run_import()
|
||||||
|
player.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertTrue(player.roles.filter(pk=role.pk).exists())
|
||||||
|
self.assertTrue(player.specialties.filter(pk=specialty.pk).exists())
|
||||||
|
|
||||||
|
def test_importer_does_not_interfere_with_user_owned_data(self):
|
||||||
|
self.run_import()
|
||||||
|
user = User.objects.create_user(username="lba_user", password="pass12345")
|
||||||
|
player = Player.objects.get(full_name="Muhammad-Ali Abdur-Rahkman")
|
||||||
|
favorite = FavoritePlayer.objects.create(user=user, player=player)
|
||||||
|
note = PlayerNote.objects.create(user=user, player=player, body="LBA imported profile")
|
||||||
|
saved = SavedSearch.objects.create(user=user, name="LBA Search", params={"name": "Muhammad"})
|
||||||
|
|
||||||
|
self.run_import()
|
||||||
|
|
||||||
|
self.assertTrue(FavoritePlayer.objects.filter(pk=favorite.pk).exists())
|
||||||
|
self.assertTrue(PlayerNote.objects.filter(pk=note.pk).exists())
|
||||||
|
self.assertTrue(SavedSearch.objects.filter(pk=saved.pk).exists())
|
||||||
|
|
||||||
|
def test_imported_data_is_visible_in_search_and_detail_flows(self):
|
||||||
|
self.run_import()
|
||||||
|
|
||||||
|
list_response = self.client.get(reverse("scouting:player_list"), {"name": "Muhammad"})
|
||||||
|
self.assertEqual(list_response.status_code, 200)
|
||||||
|
self.assertContains(list_response, "Muhammad-Ali Abdur-Rahkman")
|
||||||
|
|
||||||
|
player = Player.objects.get(full_name="Muhammad-Ali Abdur-Rahkman")
|
||||||
|
detail_response = self.client.get(reverse("scouting:player_detail", args=[player.id]))
|
||||||
|
self.assertEqual(detail_response.status_code, 200)
|
||||||
|
self.assertContains(detail_response, "Muhammad-Ali Abdur-Rahkman")
|
||||||
|
self.assertContains(detail_response, "PTS 19.5")
|
||||||
|
|
||||||
|
def test_importer_fails_cleanly_for_malformed_fixture(self):
|
||||||
|
broken_fixture = Path(__file__).resolve().parent / "sample_data" / "imports" / "lba_public_broken_fixture.json"
|
||||||
|
broken_fixture.write_text("{\"categories\": {\"points\": {\"stats\": [{\"name\": \"OnlyName\"}]}}}", encoding="utf-8")
|
||||||
|
try:
|
||||||
|
with self.assertRaises(CommandError):
|
||||||
|
call_command(self.COMMAND_NAME, fixture=str(broken_fixture), season=2025)
|
||||||
|
finally:
|
||||||
|
if broken_fixture.exists():
|
||||||
|
broken_fixture.unlink()
|
||||||
|
|
||||||
|
|
||||||
class FavoritePlayerViewsTests(TestCase):
|
class FavoritePlayerViewsTests(TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
|
|||||||
Reference in New Issue
Block a user