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