fix(ingestion): stop fabricating missing player positions

This commit is contained in:
bisco
2026-04-11 00:54:19 +02:00
parent 677b5af40d
commit e6081428ae
5 changed files with 63 additions and 7 deletions

View File

@ -72,6 +72,7 @@ First public European importer (LBA Serie A scope):
`docker compose --env-file .env -f infra/docker-compose.yml exec -T app python manage.py import_lba_public_serie_a --season 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: - 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` `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`
- note: when the public source does not expose a player position, HoopScout keeps `position` empty instead of fabricating a value.
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.

View File

@ -269,22 +269,16 @@ class LbaSerieAPublicImporter:
player = Player.objects.filter(pk=mapping.object_id).first() player = Player.objects.filter(pk=mapping.object_id).first()
if player is None: if player is None:
raise ImportValidationError("Player mapping points to a missing record.") raise ImportValidationError("Player mapping points to a missing record.")
position = player.position or Player.Position.SG
player.full_name = full_name player.full_name = full_name
player.first_name = record["name"] player.first_name = record["name"]
player.last_name = record["surname"] player.last_name = record["surname"]
player.position = position
player.save() player.save()
self.summary.players_updated += 1 self.summary.players_updated += 1
else: 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( player = Player.objects.create(
full_name=full_name, full_name=full_name,
first_name=record["name"], first_name=record["name"],
last_name=record["surname"], last_name=record["surname"],
position=Player.Position.SG,
) )
self.summary.players_created += 1 self.summary.players_created += 1

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.2 on 2026-04-10 22:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('scouting', '0009_externalentitymapping'),
]
operations = [
migrations.AlterField(
model_name='player',
name='position',
field=models.CharField(blank=True, choices=[('PG', 'PG'), ('SG', 'SG'), ('SF', 'SF'), ('PF', 'PF'), ('C', 'C')], max_length=2, null=True),
),
]

View File

@ -42,7 +42,7 @@ class Player(models.Model):
height_cm = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True) height_cm = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True)
weight_kg = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True) weight_kg = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True)
wingspan_cm = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True) wingspan_cm = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True)
position = models.CharField(max_length=2, choices=Position.choices) position = models.CharField(max_length=2, choices=Position.choices, null=True, blank=True)
roles = models.ManyToManyField(Role, blank=True, related_name="players") roles = models.ManyToManyField(Role, blank=True, related_name="players")
specialties = models.ManyToManyField(Specialty, blank=True, related_name="players") specialties = models.ManyToManyField(Specialty, blank=True, related_name="players")
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)

View File

@ -66,6 +66,14 @@ class ScoutingSearchViewsTests(TestCase):
cls.player_wing.roles.add(cls.role_3d) cls.player_wing.roles.add(cls.role_3d)
cls.player_wing.specialties.add(cls.specialty_offball) cls.player_wing.specialties.add(cls.specialty_offball)
cls.player_unknown_position = Player.objects.create(
full_name="No Position Prospect",
birth_date=date(2001, 3, 3),
position=None,
height_cm=Decimal("180.00"),
weight_kg=Decimal("88.00"),
)
cls.ctx_pg_good = PlayerSeason.objects.create( cls.ctx_pg_good = PlayerSeason.objects.create(
player=cls.player_pg, player=cls.player_pg,
season=cls.season_2025, season=cls.season_2025,
@ -144,6 +152,24 @@ class ScoutingSearchViewsTests(TestCase):
self.assertContains(response, self.player_pg.full_name) self.assertContains(response, self.player_pg.full_name)
self.assertNotContains(response, self.player_wing.full_name) self.assertNotContains(response, self.player_wing.full_name)
def test_players_with_null_position_are_searchable_by_other_fields(self):
response = self.client.get(
reverse("scouting:player_list"),
{"name": "No Position"},
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, self.player_unknown_position.full_name)
def test_position_filter_excludes_players_with_null_position(self):
response = self.client.get(
reverse("scouting:player_list"),
{"position": "SG"},
)
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, self.player_unknown_position.full_name)
def test_filter_by_wingspan_thresholds(self): def test_filter_by_wingspan_thresholds(self):
response = self.client.get( response = self.client.get(
reverse("scouting:player_list"), reverse("scouting:player_list"),
@ -550,6 +576,23 @@ class FirstPublicEuropeanImporterTests(TestCase):
self.assertTrue(PlayerSeason.objects.filter(player__full_name="Muhammad-Ali Abdur-Rahkman").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()) self.assertTrue(PlayerSeasonStats.objects.filter(player_season__player__full_name="Muhammad-Ali Abdur-Rahkman").exists())
def test_importer_does_not_assign_fake_position_when_source_position_is_missing(self):
self.run_import()
player = Player.objects.get(full_name="Muhammad-Ali Abdur-Rahkman")
self.assertIsNone(player.position)
def test_importer_preserves_existing_real_position_when_source_position_is_missing(self):
self.run_import()
player = Player.objects.get(full_name="Muhammad-Ali Abdur-Rahkman")
player.position = Player.Position.PG
player.save(update_fields=["position", "updated_at"])
self.run_import()
player.refresh_from_db()
self.assertEqual(player.position, Player.Position.PG)
def test_importer_is_idempotent_for_same_input(self): def test_importer_is_idempotent_for_same_input(self):
self.run_import() self.run_import()
first_counts = { first_counts = {