feat(v2): implement scoped player search and detail flows

This commit is contained in:
Alfredo Di Stasio
2026-03-13 14:10:39 +01:00
parent eacff3d25e
commit 6fc583c79f
10 changed files with 137 additions and 129 deletions

View File

@ -25,10 +25,8 @@ 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")
@ -60,20 +58,6 @@ class PlayerSearchForm(forms.Form):
three_pct_max = forms.DecimalField(required=False, min_value=0, decimal_places=2, max_digits=5, label="3P% max")
ft_pct_min = forms.DecimalField(required=False, min_value=0, decimal_places=2, max_digits=5, label="FT% min")
ft_pct_max = forms.DecimalField(required=False, min_value=0, decimal_places=2, max_digits=5, label="FT% max")
efficiency_metric_min = forms.DecimalField(
required=False,
min_value=0,
decimal_places=2,
max_digits=6,
label="Impact metric min",
)
efficiency_metric_max = forms.DecimalField(
required=False,
min_value=0,
decimal_places=2,
max_digits=6,
label="Impact metric max",
)
sort = forms.ChoiceField(choices=SORT_CHOICES, required=False, initial="name_asc")
page_size = forms.TypedChoiceField(
@ -88,10 +72,8 @@ 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):
@ -110,7 +92,6 @@ class PlayerSearchForm(forms.Form):
self._validate_min_max(cleaned_data, "fg_pct_min", "fg_pct_max")
self._validate_min_max(cleaned_data, "three_pct_min", "three_pct_max")
self._validate_min_max(cleaned_data, "ft_pct_min", "ft_pct_max")
self._validate_min_max(cleaned_data, "efficiency_metric_min", "efficiency_metric_max")
if not cleaned_data.get("sort"):
cleaned_data["sort"] = "name_asc"

View File

@ -14,7 +14,6 @@ from django.db.models import (
Value,
When,
)
from django.db.models.functions import Coalesce
from apps.players.models import Player
from apps.stats.models import PlayerSeason
@ -22,7 +21,8 @@ from apps.stats.models import PlayerSeason
METRIC_SORT_KEYS = {"ppg_desc", "ppg_asc", "mpg_desc", "mpg_asc"}
SEARCH_METRIC_SEMANTICS_TEXT = (
"Search metrics are best eligible values per player (max per metric across eligible player-season rows). "
"With season/team/competition/stat filters, eligibility is scoped by those filters."
"With season/team/competition/stat filters, eligibility is scoped by those filters. "
"When no eligible stat exists in the current filter context, metric cells show '-'."
)
@ -73,8 +73,6 @@ def _season_scope_filter_keys() -> tuple[str, ...]:
"three_pct_max",
"ft_pct_min",
"ft_pct_max",
"efficiency_metric_min",
"efficiency_metric_max",
)
@ -121,7 +119,6 @@ def _apply_player_season_scope_filters(queryset, data: dict):
("fg_pct_min", "fg_pct_max", "stats__fg_pct"),
("three_pct_min", "three_pct_max", "stats__three_pct"),
("ft_pct_min", "ft_pct_max", "stats__ft_pct"),
("efficiency_metric_min", "efficiency_metric_max", "stats__player_efficiency_rating"),
)
for min_key, max_key, field_name in stat_pairs:
queryset = _apply_min_max_filter(queryset, min_key, max_key, field_name, data)
@ -149,11 +146,6 @@ def _build_metric_context_filter(data: dict) -> Q:
("fg_pct_min", "fg_pct_max", "player_seasons__stats__fg_pct"),
("three_pct_min", "three_pct_max", "player_seasons__stats__three_pct"),
("ft_pct_min", "ft_pct_max", "player_seasons__stats__ft_pct"),
(
"efficiency_metric_min",
"efficiency_metric_max",
"player_seasons__stats__player_efficiency_rating",
),
)
for min_key, max_key, field_name in minmax_pairs:
min_value = data.get(min_key)
@ -188,10 +180,6 @@ 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"])
queryset = _apply_min_max_filter(queryset, "height_min", "height_max", "height_cm", data)
queryset = _apply_min_max_filter(queryset, "weight_min", "weight_max", "weight_kg", data)
@ -235,47 +223,62 @@ def annotate_player_metrics(queryset, data: dict | None = None):
output_field=FloatField(),
),
),
default=Value(0.0),
default=Value(None),
output_field=FloatField(),
)
return queryset.annotate(
games_played_value=Coalesce(
Max("player_seasons__games_played", filter=context_filter),
Value(0, output_field=IntegerField()),
games_played_value=Max(
"player_seasons__games_played",
filter=context_filter,
output_field=IntegerField(),
),
mpg_value=Coalesce(Max(mpg_expression, filter=context_filter), Value(0.0)),
ppg_value=Coalesce(
Max("player_seasons__stats__points", filter=context_filter),
Value(0, output_field=DecimalField(max_digits=6, decimal_places=2)),
mpg_value=Max(mpg_expression, filter=context_filter),
ppg_value=Max(
"player_seasons__stats__points",
filter=context_filter,
output_field=DecimalField(max_digits=6, decimal_places=2),
),
rpg_value=Coalesce(
Max("player_seasons__stats__rebounds", filter=context_filter),
Value(0, output_field=DecimalField(max_digits=6, decimal_places=2)),
rpg_value=Max(
"player_seasons__stats__rebounds",
filter=context_filter,
output_field=DecimalField(max_digits=6, decimal_places=2),
),
apg_value=Coalesce(
Max("player_seasons__stats__assists", filter=context_filter),
Value(0, output_field=DecimalField(max_digits=6, decimal_places=2)),
apg_value=Max(
"player_seasons__stats__assists",
filter=context_filter,
output_field=DecimalField(max_digits=6, decimal_places=2),
),
spg_value=Coalesce(
Max("player_seasons__stats__steals", filter=context_filter),
Value(0, output_field=DecimalField(max_digits=6, decimal_places=2)),
spg_value=Max(
"player_seasons__stats__steals",
filter=context_filter,
output_field=DecimalField(max_digits=6, decimal_places=2),
),
bpg_value=Coalesce(
Max("player_seasons__stats__blocks", filter=context_filter),
Value(0, output_field=DecimalField(max_digits=6, decimal_places=2)),
bpg_value=Max(
"player_seasons__stats__blocks",
filter=context_filter,
output_field=DecimalField(max_digits=6, decimal_places=2),
),
top_efficiency=Coalesce(
Max("player_seasons__stats__player_efficiency_rating", filter=context_filter),
Value(0, output_field=DecimalField(max_digits=6, decimal_places=2)),
tov_value=Max(
"player_seasons__stats__turnovers",
filter=context_filter,
output_field=DecimalField(max_digits=6, decimal_places=2),
),
fg_pct_value=Max(
"player_seasons__stats__fg_pct",
filter=context_filter,
output_field=DecimalField(max_digits=5, decimal_places=2),
),
three_pct_value=Max(
"player_seasons__stats__three_pct",
filter=context_filter,
output_field=DecimalField(max_digits=5, decimal_places=2),
),
ft_pct_value=Max(
"player_seasons__stats__ft_pct",
filter=context_filter,
output_field=DecimalField(max_digits=5, decimal_places=2),
),
)

View File

@ -7,7 +7,7 @@ from apps.scouting.models import FavoritePlayer
from apps.stats.models import PlayerSeason
from .forms import PlayerSearchForm
from .models import Player, PlayerCareerEntry
from .models import Player
from .services.search import (
SEARCH_METRIC_SEMANTICS_TEXT,
annotate_player_metrics,
@ -92,12 +92,6 @@ class PlayerDetailView(DetailView):
"competition",
"stats",
).order_by("-season__start_date", "-id")
career_queryset = PlayerCareerEntry.objects.select_related(
"team",
"competition",
"season",
"role_snapshot",
).order_by("-start_date", "-id")
return (
Player.objects.select_related(
@ -108,9 +102,7 @@ class PlayerDetailView(DetailView):
"origin_team",
)
.prefetch_related(
"aliases",
Prefetch("player_seasons", queryset=season_queryset),
Prefetch("career_entries", queryset=career_queryset),
)
)
@ -146,7 +138,6 @@ class PlayerDetailView(DetailView):
context["age"] = calculate_age(player.birth_date)
context["current_assignment"] = current_assignment
context["career_entries"] = player.career_entries.all()
context["season_rows"] = season_rows
context["is_favorite"] = False
if self.request.user.is_authenticated: