diff --git a/README.md b/README.md index 27a83f3..d8d0fec 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,32 @@ docker compose exec web python manage.py createsuperuser - app health: `/health/` - nginx healthcheck proxies `/health/` to `web` +## Player Search (v2) + +Public player search is server-rendered (Django templates) with HTMX partial updates. + +Supported filters: +- free text name search +- nominal position, inferred role +- competition, season, team +- nationality +- age, height, weight ranges +- stats thresholds: games, MPG, PPG, RPG, APG, SPG, BPG, TOV, FG%, 3P%, FT% + +Search correctness: +- combined team/competition/season/stat filters are applied to the same `PlayerSeason` context (no cross-row false positives) +- filtering happens at database level with Django ORM + +Search metric semantics: +- result columns are labeled as **Best Eligible** +- each displayed metric is `MAX` over eligible player-season rows for that metric in the current filter context +- different metric columns for one player may come from different eligible seasons +- when no eligible value exists for a metric in the current context, the UI shows `-` + +Pagination and sorting: +- querystring is preserved +- HTMX navigation keeps URL state in sync with current filters/page/sort + ## GitFlow Required branch model: diff --git a/apps/players/forms.py b/apps/players/forms.py index f7a0cfe..96dca1a 100644 --- a/apps/players/forms.py +++ b/apps/players/forms.py @@ -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" diff --git a/apps/players/services/search.py b/apps/players/services/search.py index 31fcea8..2a1d596 100644 --- a/apps/players/services/search.py +++ b/apps/players/services/search.py @@ -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), + ), ) diff --git a/apps/players/views.py b/apps/players/views.py index 993e87c..f22b082 100644 --- a/apps/players/views.py +++ b/apps/players/views.py @@ -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: diff --git a/templates/players/detail.html b/templates/players/detail.html index bbeac02..4cd6bbd 100644 --- a/templates/players/detail.html +++ b/templates/players/detail.html @@ -22,8 +22,6 @@
| Season | Team | Competition | Role | From | To |
|---|---|---|---|---|---|
| {{ entry.season.label|default:"-" }} | -{{ entry.team.name|default:"-" }} | -{{ entry.competition.name|default:"-" }} | -{{ entry.role_snapshot.name|default:"-" }} | -{{ entry.start_date|date:"Y-m-d"|default:"-" }} | -{{ entry.end_date|date:"Y-m-d"|default:"-" }} | -
Filter players by profile, origin, context, and production metrics.
+Filter players by profile, team-season context, and production metrics.
{% if search_has_errors %}Please correct the highlighted filters.
@@ -56,8 +56,6 @@