Merge branch 'feature/phase-2-first-public-european-importer' into develop
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 saved searches (save, rerun, delete)
|
||||
- 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:
|
||||
- `docs/ARCHITECTURE.md`
|
||||
@ -66,6 +67,12 @@ First real-data importer (ADR-0009 baseline):
|
||||
- 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`
|
||||
|
||||
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.
|
||||
|
||||
## 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 decimal import Decimal
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlencode
|
||||
|
||||
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.migrations.executor import MigrationExecutor
|
||||
from django.test import TestCase, TransactionTestCase
|
||||
@ -526,6 +527,135 @@ class FirstRealIngestionFlowTests(TestCase):
|
||||
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):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
Reference in New Issue
Block a user