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.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(
*,

View File

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

View File

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

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

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"])
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")

View File

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

View File

@ -24,6 +24,8 @@
<div class="detail-card">
<h2>Summary</h2>
<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>Age:</strong> {{ age|default:"-" }}</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_team">Team</label>{{ search_form.team }}</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>
<details>

View File

@ -19,6 +19,7 @@
<th>Player</th>
<th>Nationality</th>
<th>Pos / Role</th>
<th>Origin</th>
<th>Height / Weight</th>
<th>Games</th>
<th>MPG</th>
@ -39,6 +40,10 @@
{{ player.nominal_position.code|default:"-" }}
/ {{ player.inferred_role.name|default:"-" }}
</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.games_played_value|floatformat:0 }}</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 PlayerSeason.objects.count() >= 1
assert PlayerSeasonStats.objects.count() >= 1
assert Player.objects.filter(origin_competition__isnull=False).exists()
@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