From 4d49d304955a59b11405bd71933187fa305266dd Mon Sep 17 00:00:00 2001 From: Alfredo Di Stasio Date: Tue, 10 Mar 2026 12:29:38 +0100 Subject: [PATCH] feat(players): add origin competition/team model and filtering --- apps/ingestion/services/sync.py | 7 + apps/players/admin.py | 19 +- apps/players/forms.py | 4 + ...competition_player_origin_team_and_more.py | 34 ++++ .../0004_backfill_player_origins.py | 35 ++++ apps/players/models.py | 16 ++ apps/players/services/origin.py | 46 +++++ apps/players/services/search.py | 6 + apps/players/views.py | 2 + templates/players/detail.html | 2 + templates/players/index.html | 2 + templates/players/partials/results.html | 5 + tests/test_ingestion_sync.py | 1 + tests/test_player_origin.py | 190 ++++++++++++++++++ 14 files changed, 368 insertions(+), 1 deletion(-) create mode 100644 apps/players/migrations/0003_player_origin_competition_player_origin_team_and_more.py create mode 100644 apps/players/migrations/0004_backfill_player_origins.py create mode 100644 apps/players/services/origin.py create mode 100644 tests/test_player_origin.py diff --git a/apps/ingestion/services/sync.py b/apps/ingestion/services/sync.py index 33c6a8d..901e727 100644 --- a/apps/ingestion/services/sync.py +++ b/apps/ingestion/services/sync.py @@ -11,6 +11,7 @@ from apps.competitions.models import Competition, Season from apps.ingestion.models import IngestionRun 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.services.origin import refresh_player_origin from apps.providers.exceptions import ProviderRateLimitError, ProviderTransientError from apps.providers.registry import get_provider 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): + touched_player_ids: set[int] = set() for payload in payloads: summary.processed += 1 external_id = payload.get("external_id", "") @@ -380,6 +382,7 @@ def _sync_player_careers(provider_namespace: str, payloads: list[dict], run: Ing ) continue + touched_player_ids.add(player.id) _, created = PlayerCareerEntry.objects.update_or_create( player=player, team=team, @@ -399,6 +402,10 @@ def _sync_player_careers(provider_namespace: str, payloads: list[dict], run: Ing else: 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( *, diff --git a/apps/players/admin.py b/apps/players/admin.py index 421a38d..64a2dce 100644 --- a/apps/players/admin.py +++ b/apps/players/admin.py @@ -1,6 +1,8 @@ from django.contrib import admin +from django.contrib import messages from .models import Nationality, Player, PlayerAlias, PlayerCareerEntry, Position, Role +from .services.origin import refresh_player_origins @admin.register(Nationality) @@ -39,11 +41,26 @@ class PlayerAdmin(admin.ModelAdmin): "nationality", "nominal_position", "inferred_role", + "origin_competition", + "origin_team", "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") 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) diff --git a/apps/players/forms.py b/apps/players/forms.py index 630920f..ceafae1 100644 --- a/apps/players/forms.py +++ b/apps/players/forms.py @@ -25,8 +25,10 @@ class PlayerSearchForm(forms.Form): nominal_position = forms.ModelChoiceField(queryset=Position.objects.none(), required=False) inferred_role = forms.ModelChoiceField(queryset=Role.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) 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) 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["inferred_role"].queryset = Role.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["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") def clean(self): diff --git a/apps/players/migrations/0003_player_origin_competition_player_origin_team_and_more.py b/apps/players/migrations/0003_player_origin_competition_player_origin_team_and_more.py new file mode 100644 index 0000000..fd5caa0 --- /dev/null +++ b/apps/players/migrations/0003_player_origin_competition_player_origin_team_and_more.py @@ -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'), + ), + ] diff --git a/apps/players/migrations/0004_backfill_player_origins.py b/apps/players/migrations/0004_backfill_player_origins.py new file mode 100644 index 0000000..b12da78 --- /dev/null +++ b/apps/players/migrations/0004_backfill_player_origins.py @@ -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), + ] diff --git a/apps/players/models.py b/apps/players/models.py index fd18bef..87ed802 100644 --- a/apps/players/models.py +++ b/apps/players/models.py @@ -80,6 +80,20 @@ class Player(TimeStampedModel): null=True, 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) weight_kg = 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=["nominal_position"]), models.Index(fields=["inferred_role"]), + models.Index(fields=["origin_competition"]), + models.Index(fields=["origin_team"]), models.Index(fields=["is_active"]), models.Index(fields=["height_cm"]), ] diff --git a/apps/players/services/origin.py b/apps/players/services/origin.py new file mode 100644 index 0000000..d7d2a38 --- /dev/null +++ b/apps/players/services/origin.py @@ -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 diff --git a/apps/players/services/search.py b/apps/players/services/search.py index fdd1674..ad3656e 100644 --- a/apps/players/services/search.py +++ b/apps/players/services/search.py @@ -47,6 +47,10 @@ def filter_players(queryset, data: dict): queryset = queryset.filter(inferred_role=data["inferred_role"]) if data.get("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"): queryset = queryset.filter(player_seasons__team=data["team"]) @@ -185,4 +189,6 @@ def base_player_queryset(): "nationality", "nominal_position", "inferred_role", + "origin_competition", + "origin_team", ).prefetch_related("aliases") diff --git a/apps/players/views.py b/apps/players/views.py index 226eaab..8aac4ad 100644 --- a/apps/players/views.py +++ b/apps/players/views.py @@ -87,6 +87,8 @@ class PlayerDetailView(DetailView): "nationality", "nominal_position", "inferred_role", + "origin_competition", + "origin_team", ) .prefetch_related( "aliases", diff --git a/templates/players/detail.html b/templates/players/detail.html index 83bb028..101f03d 100644 --- a/templates/players/detail.html +++ b/templates/players/detail.html @@ -24,6 +24,8 @@

Summary

Nationality: {{ player.nationality.name|default:"-" }}

+

Origin competition: {{ player.origin_competition.name|default:"-" }}

+

Origin team: {{ player.origin_team.name|default:"-" }}

Birth date: {{ player.birth_date|date:"Y-m-d"|default:"-" }}

Age: {{ age|default:"-" }}

Height: {{ player.height_cm|default:"-" }} cm

diff --git a/templates/players/index.html b/templates/players/index.html index 594cc07..1d85f37 100644 --- a/templates/players/index.html +++ b/templates/players/index.html @@ -42,6 +42,8 @@
{{ search_form.competition }}
{{ search_form.team }}
{{ search_form.season }}
+
{{ search_form.origin_competition }}
+
{{ search_form.origin_team }}
diff --git a/templates/players/partials/results.html b/templates/players/partials/results.html index 0419847..d8a3040 100644 --- a/templates/players/partials/results.html +++ b/templates/players/partials/results.html @@ -19,6 +19,7 @@ Player Nationality Pos / Role + Origin Height / Weight Games MPG @@ -39,6 +40,10 @@ {{ player.nominal_position.code|default:"-" }} / {{ player.inferred_role.name|default:"-" }} + + {{ player.origin_competition.name|default:"-" }} + {% if player.origin_team %}
{{ player.origin_team.name }}{% endif %} + {{ player.height_cm|default:"-" }} / {{ player.weight_kg|default:"-" }} {{ player.games_played_value|floatformat:0 }} {{ player.mpg_value|floatformat:1 }} diff --git a/tests/test_ingestion_sync.py b/tests/test_ingestion_sync.py index 8864796..50057b2 100644 --- a/tests/test_ingestion_sync.py +++ b/tests/test_ingestion_sync.py @@ -25,6 +25,7 @@ def test_run_full_sync_creates_domain_objects(settings): assert Player.objects.count() >= 1 assert PlayerSeason.objects.count() >= 1 assert PlayerSeasonStats.objects.count() >= 1 + assert Player.objects.filter(origin_competition__isnull=False).exists() @pytest.mark.django_db diff --git a/tests/test_player_origin.py b/tests/test_player_origin.py new file mode 100644 index 0000000..ec41946 --- /dev/null +++ b/tests/test_player_origin.py @@ -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