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

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

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
@ -23,6 +22,7 @@ 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. "
"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:

View File

@ -22,8 +22,6 @@
<h2 class="text-base">Summary</h2>
<dl class="mt-2 space-y-1 text-sm">
<div><dt class="inline font-semibold">Nationality:</dt> <dd class="inline">{{ player.nationality.name|default:"-" }}</dd></div>
<div><dt class="inline font-semibold">Origin competition:</dt> <dd class="inline">{{ player.origin_competition.name|default:"-" }}</dd></div>
<div><dt class="inline font-semibold">Origin team:</dt> <dd class="inline">{{ player.origin_team.name|default:"-" }}</dd></div>
<div><dt class="inline font-semibold">Birth date:</dt> <dd class="inline">{{ player.birth_date|date:"Y-m-d"|default:"-" }}</dd></div>
<div><dt class="inline font-semibold">Age:</dt> <dd class="inline">{{ age|default:"-" }}</dd></div>
<div><dt class="inline font-semibold">Height:</dt> <dd class="inline">{{ player.height_cm|default:"-" }} cm</dd></div>
@ -47,14 +45,11 @@
</div>
<div class="rounded-lg border border-slate-200 p-4">
<h2 class="text-base">Aliases</h2>
<ul class="mt-2 list-inside list-disc text-sm text-slate-700">
{% for alias in player.aliases.all %}
<li>{{ alias.alias }}{% if alias.source %} ({{ alias.source }}){% endif %}</li>
{% empty %}
<li>No aliases recorded.</li>
{% endfor %}
</ul>
<h2 class="text-base">Snapshot Coverage</h2>
<dl class="mt-2 space-y-1 text-sm">
<div><dt class="inline font-semibold">Seasons imported:</dt> <dd class="inline">{{ season_rows|length }}</dd></div>
<div><dt class="inline font-semibold">Latest season:</dt> <dd class="inline">{% if season_rows %}{{ season_rows.0.season.label|default:"-" }}{% else %}-{% endif %}</dd></div>
</dl>
</div>
</div>
</section>
@ -77,33 +72,6 @@
{% endif %}
</section>
<section class="panel mt-4">
<h2>Career History</h2>
{% if career_entries %}
<div class="table-wrap mt-3">
<table class="data-table">
<thead>
<tr><th>Season</th><th>Team</th><th>Competition</th><th>Role</th><th>From</th><th>To</th></tr>
</thead>
<tbody class="divide-y divide-slate-100 bg-white">
{% for entry in career_entries %}
<tr>
<td>{{ entry.season.label|default:"-" }}</td>
<td>{{ entry.team.name|default:"-" }}</td>
<td>{{ entry.competition.name|default:"-" }}</td>
<td>{{ entry.role_snapshot.name|default:"-" }}</td>
<td>{{ entry.start_date|date:"Y-m-d"|default:"-" }}</td>
<td>{{ entry.end_date|date:"Y-m-d"|default:"-" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state mt-3">No career entries available.</div>
{% endif %}
</section>
<section class="panel mt-4">
<h2>Season-by-Season Stats</h2>
{% if season_rows %}

View File

@ -5,7 +5,7 @@
{% block content %}
<section class="panel">
<h1>Player Search</h1>
<p class="mt-1 text-sm text-slate-600">Filter players by profile, origin, context, and production metrics.</p>
<p class="mt-1 text-sm text-slate-600">Filter players by profile, team-season context, and production metrics.</p>
{% if search_has_errors %}
<div class="mt-4 rounded-md border border-rose-200 bg-rose-50 p-3 text-sm text-rose-800">
<p class="font-medium">Please correct the highlighted filters.</p>
@ -56,8 +56,6 @@
<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 class="rounded-lg border border-slate-200 bg-slate-50 p-3">
@ -97,8 +95,6 @@
<div><label for="id_three_pct_max">3P% max</label>{{ search_form.three_pct_max }}</div>
<div><label for="id_ft_pct_min">FT% min</label>{{ search_form.ft_pct_min }}</div>
<div><label for="id_ft_pct_max">FT% max</label>{{ search_form.ft_pct_max }}</div>
<div><label for="id_efficiency_metric_min">Impact min</label>{{ search_form.efficiency_metric_min }}</div>
<div><label for="id_efficiency_metric_max">Impact max</label>{{ search_form.efficiency_metric_max }}</div>
</div>
</details>
</form>

View File

@ -36,13 +36,18 @@
<th>Player</th>
<th>Nationality</th>
<th>Pos / Role</th>
<th>Origin</th>
<th>Height / Weight</th>
<th>Best Eligible Games</th>
<th>Best Eligible MPG</th>
<th>Best Eligible PPG</th>
<th>Best Eligible RPG</th>
<th>Best Eligible APG</th>
<th>Best Eligible SPG</th>
<th>Best Eligible BPG</th>
<th>Best Eligible TOV</th>
<th>Best Eligible FG%</th>
<th>Best Eligible 3P%</th>
<th>Best Eligible FT%</th>
{% if request.user.is_authenticated %}<th>Watchlist</th>{% endif %}
</tr>
</thead>
@ -52,16 +57,18 @@
<td><a class="font-medium" href="{% url 'players:detail' player.pk %}">{{ player.full_name }}</a></td>
<td>{{ player.nationality.name|default:"-" }}</td>
<td>{{ player.nominal_position.code|default:"-" }} / {{ player.inferred_role.name|default:"-" }}</td>
<td>
{{ player.origin_competition.name|default:"-" }}
{% if player.origin_team %}<div class="text-xs text-slate-500">{{ player.origin_team.name }}</div>{% 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>
<td>{{ player.ppg_value|floatformat:1 }}</td>
<td>{{ player.rpg_value|floatformat:1 }}</td>
<td>{{ player.apg_value|floatformat:1 }}</td>
<td>{% if player.games_played_value is not None %}{{ player.games_played_value|floatformat:0 }}{% else %}-{% endif %}</td>
<td>{% if player.mpg_value is not None %}{{ player.mpg_value|floatformat:1 }}{% else %}-{% endif %}</td>
<td>{% if player.ppg_value is not None %}{{ player.ppg_value|floatformat:1 }}{% else %}-{% endif %}</td>
<td>{% if player.rpg_value is not None %}{{ player.rpg_value|floatformat:1 }}{% else %}-{% endif %}</td>
<td>{% if player.apg_value is not None %}{{ player.apg_value|floatformat:1 }}{% else %}-{% endif %}</td>
<td>{% if player.spg_value is not None %}{{ player.spg_value|floatformat:1 }}{% else %}-{% endif %}</td>
<td>{% if player.bpg_value is not None %}{{ player.bpg_value|floatformat:1 }}{% else %}-{% endif %}</td>
<td>{% if player.tov_value is not None %}{{ player.tov_value|floatformat:1 }}{% else %}-{% endif %}</td>
<td>{% if player.fg_pct_value is not None %}{{ player.fg_pct_value|floatformat:1 }}{% else %}-{% endif %}</td>
<td>{% if player.three_pct_value is not None %}{{ player.three_pct_value|floatformat:1 }}{% else %}-{% endif %}</td>
<td>{% if player.ft_pct_value is not None %}{{ player.ft_pct_value|floatformat:1 }}{% else %}-{% endif %}</td>
{% if request.user.is_authenticated %}
<td>
{% if player.id in favorite_player_ids %}

View File

@ -83,8 +83,6 @@ def test_players_api_search_consistent_with_ui_filters(client):
nationality=nationality,
nominal_position=position,
inferred_role=role,
origin_competition=competition,
origin_team=team,
)
ps = PlayerSeason.objects.create(
player=matching,
@ -113,7 +111,7 @@ def test_players_api_search_consistent_with_ui_filters(client):
)
params = {
"origin_competition": competition.id,
"competition": competition.id,
"nominal_position": position.id,
"points_per_game_min": "10",
"sort": "ppg_desc",

View File

@ -110,8 +110,6 @@ def test_player_search_combined_filters_sorting_and_pagination(client):
nationality=nationality,
nominal_position=position,
inferred_role=role,
origin_competition=competition,
origin_team=team,
)
player_season = PlayerSeason.objects.create(
player=player,
@ -135,7 +133,7 @@ def test_player_search_combined_filters_sorting_and_pagination(client):
response = client.get(
reverse("players:index"),
data={
"origin_competition": competition.id,
"competition": competition.id,
"nominal_position": position.id,
"sort": "ppg_desc",
"page_size": 20,
@ -152,7 +150,7 @@ def test_player_search_combined_filters_sorting_and_pagination(client):
page2 = client.get(
reverse("players:index"),
data={
"origin_competition": competition.id,
"competition": competition.id,
"nominal_position": position.id,
"sort": "ppg_desc",
"page_size": 20,

View File

@ -4,7 +4,7 @@ import pytest
from django.urls import reverse
from apps.competitions.models import Competition, Season
from apps.players.models import Nationality, Player, PlayerAlias, Position, Role
from apps.players.models import Nationality, Player, Position, Role
from apps.stats.models import PlayerSeason, PlayerSeasonStats
from apps.teams.models import Team
@ -127,14 +127,13 @@ def test_player_detail_page_loads(client):
height_cm=201,
weight_kg=95,
)
PlayerAlias.objects.create(player=player, alias="P. Martin")
response = client.get(reverse("players:detail", kwargs={"pk": player.pk}))
assert response.status_code == 200
body = response.content.decode()
assert "Paul Martin" in body
assert "P. Martin" in body
assert "Summary" in body
assert "Season-by-Season Stats" in body
@pytest.mark.django_db
@ -242,3 +241,44 @@ def test_player_search_results_render_best_eligible_metric_labels(client):
assert "Best Eligible PPG" in body
assert "Best Eligible MPG" in body
assert "best eligible values per player" in body.lower()
@pytest.mark.django_db
def test_player_search_results_render_dash_for_missing_eligible_metrics(client):
nationality = Nationality.objects.create(name="Norway", iso2_code="NO", iso3_code="NOR")
position = Position.objects.create(code="PF", name="Power Forward")
role = Role.objects.create(code="big", name="Big")
season = Season.objects.create(label="2025-2026", start_date=date(2025, 9, 1), end_date=date(2026, 6, 30))
competition = Competition.objects.create(
name="BLNO",
slug="blno",
competition_type=Competition.CompetitionType.LEAGUE,
gender=Competition.Gender.MEN,
country=nationality,
)
team = Team.objects.create(name="Oslo", slug="oslo", country=nationality)
player = Player.objects.create(
first_name="Ole",
last_name="NoStats",
full_name="Ole NoStats",
birth_date=date(2001, 1, 1),
nationality=nationality,
nominal_position=position,
inferred_role=role,
)
PlayerSeason.objects.create(
player=player,
season=season,
team=team,
competition=competition,
games_played=0,
minutes_played=0,
)
response = client.get(reverse("players:index"), data={"season": season.id})
assert response.status_code == 200
body = response.content.decode()
assert "Ole NoStats" in body
# Missing eligible values are rendered as '-' rather than misleading zeros.
assert body.count(">-") > 0