feat(players): add origin competition/team model and filtering

This commit is contained in:
Alfredo Di Stasio
2026-03-10 12:29:38 +01:00
parent acfccbea08
commit 4d49d30495
14 changed files with 368 additions and 1 deletions

View File

@ -11,6 +11,7 @@ from apps.competitions.models import Competition, Season
from apps.ingestion.models import IngestionRun from apps.ingestion.models import IngestionRun
from apps.ingestion.services.runs import finish_ingestion_run, log_ingestion_error, start_ingestion_run 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.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.exceptions import ProviderRateLimitError, ProviderTransientError
from apps.providers.registry import get_provider from apps.providers.registry import get_provider
from apps.providers.services.mappings import upsert_external_mapping 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): def _sync_player_careers(provider_namespace: str, payloads: list[dict], run: IngestionRun, summary: SyncSummary):
touched_player_ids: set[int] = set()
for payload in payloads: for payload in payloads:
summary.processed += 1 summary.processed += 1
external_id = payload.get("external_id", "") external_id = payload.get("external_id", "")
@ -380,6 +382,7 @@ def _sync_player_careers(provider_namespace: str, payloads: list[dict], run: Ing
) )
continue continue
touched_player_ids.add(player.id)
_, created = PlayerCareerEntry.objects.update_or_create( _, created = PlayerCareerEntry.objects.update_or_create(
player=player, player=player,
team=team, team=team,
@ -399,6 +402,10 @@ def _sync_player_careers(provider_namespace: str, payloads: list[dict], run: Ing
else: else:
summary.updated += 1 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( def run_sync_job(
*, *,

View File

@ -1,6 +1,8 @@
from django.contrib import admin from django.contrib import admin
from django.contrib import messages
from .models import Nationality, Player, PlayerAlias, PlayerCareerEntry, Position, Role from .models import Nationality, Player, PlayerAlias, PlayerCareerEntry, Position, Role
from .services.origin import refresh_player_origins
@admin.register(Nationality) @admin.register(Nationality)
@ -39,11 +41,26 @@ class PlayerAdmin(admin.ModelAdmin):
"nationality", "nationality",
"nominal_position", "nominal_position",
"inferred_role", "inferred_role",
"origin_competition",
"origin_team",
"is_active", "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") search_fields = ("full_name", "first_name", "last_name")
inlines = (PlayerAliasInline, PlayerCareerEntryInline) 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) @admin.register(PlayerAlias)

View File

@ -25,8 +25,10 @@ class PlayerSearchForm(forms.Form):
nominal_position = forms.ModelChoiceField(queryset=Position.objects.none(), required=False) nominal_position = forms.ModelChoiceField(queryset=Position.objects.none(), required=False)
inferred_role = forms.ModelChoiceField(queryset=Role.objects.none(), required=False) inferred_role = forms.ModelChoiceField(queryset=Role.objects.none(), required=False)
competition = forms.ModelChoiceField(queryset=Competition.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) nationality = forms.ModelChoiceField(queryset=Nationality.objects.none(), required=False)
team = forms.ModelChoiceField(queryset=Team.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) season = forms.ModelChoiceField(queryset=Season.objects.none(), required=False)
age_min = forms.IntegerField(required=False, min_value=0, max_value=60, label="Min age") 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["nominal_position"].queryset = Position.objects.order_by("code")
self.fields["inferred_role"].queryset = Role.objects.order_by("name") self.fields["inferred_role"].queryset = Role.objects.order_by("name")
self.fields["competition"].queryset = Competition.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["nationality"].queryset = Nationality.objects.order_by("name")
self.fields["team"].queryset = Team.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") self.fields["season"].queryset = Season.objects.order_by("-start_date")
def clean(self): def clean(self):

View File

@ -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'),
),
]

View File

@ -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),
]

View File

@ -80,6 +80,20 @@ class Player(TimeStampedModel):
null=True, null=True,
related_name="role_players", 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) height_cm = models.PositiveSmallIntegerField(blank=True, null=True)
weight_kg = models.PositiveSmallIntegerField(blank=True, null=True) weight_kg = models.PositiveSmallIntegerField(blank=True, null=True)
wingspan_cm = 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=["nationality"]),
models.Index(fields=["nominal_position"]), models.Index(fields=["nominal_position"]),
models.Index(fields=["inferred_role"]), models.Index(fields=["inferred_role"]),
models.Index(fields=["origin_competition"]),
models.Index(fields=["origin_team"]),
models.Index(fields=["is_active"]), models.Index(fields=["is_active"]),
models.Index(fields=["height_cm"]), models.Index(fields=["height_cm"]),
] ]

View File

@ -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

View File

@ -47,6 +47,10 @@ def filter_players(queryset, data: dict):
queryset = queryset.filter(inferred_role=data["inferred_role"]) queryset = queryset.filter(inferred_role=data["inferred_role"])
if data.get("nationality"): if data.get("nationality"):
queryset = queryset.filter(nationality=data["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"): if data.get("team"):
queryset = queryset.filter(player_seasons__team=data["team"]) queryset = queryset.filter(player_seasons__team=data["team"])
@ -185,4 +189,6 @@ def base_player_queryset():
"nationality", "nationality",
"nominal_position", "nominal_position",
"inferred_role", "inferred_role",
"origin_competition",
"origin_team",
).prefetch_related("aliases") ).prefetch_related("aliases")

View File

@ -87,6 +87,8 @@ class PlayerDetailView(DetailView):
"nationality", "nationality",
"nominal_position", "nominal_position",
"inferred_role", "inferred_role",
"origin_competition",
"origin_team",
) )
.prefetch_related( .prefetch_related(
"aliases", "aliases",

View File

@ -24,6 +24,8 @@
<div class="detail-card"> <div class="detail-card">
<h2>Summary</h2> <h2>Summary</h2>
<p><strong>Nationality:</strong> {{ player.nationality.name|default:"-" }}</p> <p><strong>Nationality:</strong> {{ player.nationality.name|default:"-" }}</p>
<p><strong>Origin competition:</strong> {{ player.origin_competition.name|default:"-" }}</p>
<p><strong>Origin team:</strong> {{ player.origin_team.name|default:"-" }}</p>
<p><strong>Birth date:</strong> {{ player.birth_date|date:"Y-m-d"|default:"-" }}</p> <p><strong>Birth date:</strong> {{ player.birth_date|date:"Y-m-d"|default:"-" }}</p>
<p><strong>Age:</strong> {{ age|default:"-" }}</p> <p><strong>Age:</strong> {{ age|default:"-" }}</p>
<p><strong>Height:</strong> {{ player.height_cm|default:"-" }} cm</p> <p><strong>Height:</strong> {{ player.height_cm|default:"-" }} cm</p>

View File

@ -42,6 +42,8 @@
<div><label for="id_competition">Competition</label>{{ search_form.competition }}</div> <div><label for="id_competition">Competition</label>{{ search_form.competition }}</div>
<div><label for="id_team">Team</label>{{ search_form.team }}</div> <div><label for="id_team">Team</label>{{ search_form.team }}</div>
<div><label for="id_season">Season</label>{{ search_form.season }}</div> <div><label for="id_season">Season</label>{{ search_form.season }}</div>
<div><label for="id_origin_competition">Origin competition</label>{{ search_form.origin_competition }}</div>
<div><label for="id_origin_team">Origin team</label>{{ search_form.origin_team }}</div>
</div> </div>
<details> <details>

View File

@ -19,6 +19,7 @@
<th>Player</th> <th>Player</th>
<th>Nationality</th> <th>Nationality</th>
<th>Pos / Role</th> <th>Pos / Role</th>
<th>Origin</th>
<th>Height / Weight</th> <th>Height / Weight</th>
<th>Games</th> <th>Games</th>
<th>MPG</th> <th>MPG</th>
@ -39,6 +40,10 @@
{{ player.nominal_position.code|default:"-" }} {{ player.nominal_position.code|default:"-" }}
/ {{ player.inferred_role.name|default:"-" }} / {{ player.inferred_role.name|default:"-" }}
</td> </td>
<td>
{{ player.origin_competition.name|default:"-" }}
{% if player.origin_team %}<br><span class="muted-text">{{ player.origin_team.name }}</span>{% endif %}
</td>
<td>{{ player.height_cm|default:"-" }} / {{ player.weight_kg|default:"-" }}</td> <td>{{ player.height_cm|default:"-" }} / {{ player.weight_kg|default:"-" }}</td>
<td>{{ player.games_played_value|floatformat:0 }}</td> <td>{{ player.games_played_value|floatformat:0 }}</td>
<td>{{ player.mpg_value|floatformat:1 }}</td> <td>{{ player.mpg_value|floatformat:1 }}</td>

View File

@ -25,6 +25,7 @@ def test_run_full_sync_creates_domain_objects(settings):
assert Player.objects.count() >= 1 assert Player.objects.count() >= 1
assert PlayerSeason.objects.count() >= 1 assert PlayerSeason.objects.count() >= 1
assert PlayerSeasonStats.objects.count() >= 1 assert PlayerSeasonStats.objects.count() >= 1
assert Player.objects.filter(origin_competition__isnull=False).exists()
@pytest.mark.django_db @pytest.mark.django_db

190
tests/test_player_origin.py Normal file
View File

@ -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