diff --git a/README.md b/README.md index 27e934d..0ce6060 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/scouting/importers/lba_public.py b/app/scouting/importers/lba_public.py new file mode 100644 index 0000000..07ec3fc --- /dev/null +++ b/app/scouting/importers/lba_public.py @@ -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) diff --git a/app/scouting/management/commands/import_lba_public_serie_a.py b/app/scouting/management/commands/import_lba_public_serie_a.py new file mode 100644 index 0000000..32774f5 --- /dev/null +++ b/app/scouting/management/commands/import_lba_public_serie_a.py @@ -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}") diff --git a/app/scouting/sample_data/imports/lba_public_serie_a_fixture.json b/app/scouting/sample_data/imports/lba_public_serie_a_fixture.json new file mode 100644 index 0000000..36e1711 --- /dev/null +++ b/app/scouting/sample_data/imports/lba_public_serie_a_fixture.json @@ -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 + } + ] + } + } +} diff --git a/app/scouting/tests.py b/app/scouting/tests.py index 9cc34f1..4683940 100644 --- a/app/scouting/tests.py +++ b/app/scouting/tests.py @@ -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):