feat(v2): implement scoped player search and detail flows
This commit is contained in:
26
README.md
26
README.md
@ -181,6 +181,32 @@ docker compose exec web python manage.py createsuperuser
|
|||||||
- app health: `/health/`
|
- app health: `/health/`
|
||||||
- nginx healthcheck proxies `/health/` to `web`
|
- 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
|
## GitFlow
|
||||||
|
|
||||||
Required branch model:
|
Required branch model:
|
||||||
|
|||||||
@ -25,10 +25,8 @@ class PlayerSearchForm(forms.Form):
|
|||||||
nominal_position = forms.ModelChoiceField(queryset=Position.objects.none(), required=False)
|
nominal_position = forms.ModelChoiceField(queryset=Position.objects.none(), required=False)
|
||||||
inferred_role = forms.ModelChoiceField(queryset=Role.objects.none(), required=False)
|
inferred_role = forms.ModelChoiceField(queryset=Role.objects.none(), required=False)
|
||||||
competition = forms.ModelChoiceField(queryset=Competition.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)
|
nationality = forms.ModelChoiceField(queryset=Nationality.objects.none(), required=False)
|
||||||
team = forms.ModelChoiceField(queryset=Team.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)
|
season = forms.ModelChoiceField(queryset=Season.objects.none(), required=False)
|
||||||
|
|
||||||
age_min = forms.IntegerField(required=False, min_value=0, max_value=60, label="Min age")
|
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")
|
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_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")
|
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")
|
sort = forms.ChoiceField(choices=SORT_CHOICES, required=False, initial="name_asc")
|
||||||
page_size = forms.TypedChoiceField(
|
page_size = forms.TypedChoiceField(
|
||||||
@ -88,10 +72,8 @@ class PlayerSearchForm(forms.Form):
|
|||||||
self.fields["nominal_position"].queryset = Position.objects.order_by("code")
|
self.fields["nominal_position"].queryset = Position.objects.order_by("code")
|
||||||
self.fields["inferred_role"].queryset = Role.objects.order_by("name")
|
self.fields["inferred_role"].queryset = Role.objects.order_by("name")
|
||||||
self.fields["competition"].queryset = Competition.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["nationality"].queryset = Nationality.objects.order_by("name")
|
||||||
self.fields["team"].queryset = Team.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")
|
self.fields["season"].queryset = Season.objects.order_by("-start_date")
|
||||||
|
|
||||||
def clean(self):
|
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, "fg_pct_min", "fg_pct_max")
|
||||||
self._validate_min_max(cleaned_data, "three_pct_min", "three_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, "ft_pct_min", "ft_pct_max")
|
||||||
self._validate_min_max(cleaned_data, "efficiency_metric_min", "efficiency_metric_max")
|
|
||||||
|
|
||||||
if not cleaned_data.get("sort"):
|
if not cleaned_data.get("sort"):
|
||||||
cleaned_data["sort"] = "name_asc"
|
cleaned_data["sort"] = "name_asc"
|
||||||
|
|||||||
@ -14,7 +14,6 @@ from django.db.models import (
|
|||||||
Value,
|
Value,
|
||||||
When,
|
When,
|
||||||
)
|
)
|
||||||
from django.db.models.functions import Coalesce
|
|
||||||
|
|
||||||
from apps.players.models import Player
|
from apps.players.models import Player
|
||||||
from apps.stats.models import PlayerSeason
|
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_METRIC_SEMANTICS_TEXT = (
|
||||||
"Search metrics are best eligible values per player (max per metric across eligible player-season rows). "
|
"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",
|
"three_pct_max",
|
||||||
"ft_pct_min",
|
"ft_pct_min",
|
||||||
"ft_pct_max",
|
"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"),
|
("fg_pct_min", "fg_pct_max", "stats__fg_pct"),
|
||||||
("three_pct_min", "three_pct_max", "stats__three_pct"),
|
("three_pct_min", "three_pct_max", "stats__three_pct"),
|
||||||
("ft_pct_min", "ft_pct_max", "stats__ft_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:
|
for min_key, max_key, field_name in stat_pairs:
|
||||||
queryset = _apply_min_max_filter(queryset, min_key, max_key, field_name, data)
|
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"),
|
("fg_pct_min", "fg_pct_max", "player_seasons__stats__fg_pct"),
|
||||||
("three_pct_min", "three_pct_max", "player_seasons__stats__three_pct"),
|
("three_pct_min", "three_pct_max", "player_seasons__stats__three_pct"),
|
||||||
("ft_pct_min", "ft_pct_max", "player_seasons__stats__ft_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:
|
for min_key, max_key, field_name in minmax_pairs:
|
||||||
min_value = data.get(min_key)
|
min_value = data.get(min_key)
|
||||||
@ -188,10 +180,6 @@ def filter_players(queryset, data: dict):
|
|||||||
queryset = queryset.filter(inferred_role=data["inferred_role"])
|
queryset = queryset.filter(inferred_role=data["inferred_role"])
|
||||||
if data.get("nationality"):
|
if data.get("nationality"):
|
||||||
queryset = queryset.filter(nationality=data["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, "height_min", "height_max", "height_cm", data)
|
||||||
queryset = _apply_min_max_filter(queryset, "weight_min", "weight_max", "weight_kg", 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(),
|
output_field=FloatField(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
default=Value(0.0),
|
default=Value(None),
|
||||||
output_field=FloatField(),
|
output_field=FloatField(),
|
||||||
)
|
)
|
||||||
|
|
||||||
return queryset.annotate(
|
return queryset.annotate(
|
||||||
games_played_value=Coalesce(
|
games_played_value=Max(
|
||||||
Max("player_seasons__games_played", filter=context_filter),
|
"player_seasons__games_played",
|
||||||
Value(0, output_field=IntegerField()),
|
filter=context_filter,
|
||||||
output_field=IntegerField(),
|
output_field=IntegerField(),
|
||||||
),
|
),
|
||||||
mpg_value=Coalesce(Max(mpg_expression, filter=context_filter), Value(0.0)),
|
mpg_value=Max(mpg_expression, filter=context_filter),
|
||||||
ppg_value=Coalesce(
|
ppg_value=Max(
|
||||||
Max("player_seasons__stats__points", filter=context_filter),
|
"player_seasons__stats__points",
|
||||||
Value(0, output_field=DecimalField(max_digits=6, decimal_places=2)),
|
filter=context_filter,
|
||||||
output_field=DecimalField(max_digits=6, decimal_places=2),
|
output_field=DecimalField(max_digits=6, decimal_places=2),
|
||||||
),
|
),
|
||||||
rpg_value=Coalesce(
|
rpg_value=Max(
|
||||||
Max("player_seasons__stats__rebounds", filter=context_filter),
|
"player_seasons__stats__rebounds",
|
||||||
Value(0, output_field=DecimalField(max_digits=6, decimal_places=2)),
|
filter=context_filter,
|
||||||
output_field=DecimalField(max_digits=6, decimal_places=2),
|
output_field=DecimalField(max_digits=6, decimal_places=2),
|
||||||
),
|
),
|
||||||
apg_value=Coalesce(
|
apg_value=Max(
|
||||||
Max("player_seasons__stats__assists", filter=context_filter),
|
"player_seasons__stats__assists",
|
||||||
Value(0, output_field=DecimalField(max_digits=6, decimal_places=2)),
|
filter=context_filter,
|
||||||
output_field=DecimalField(max_digits=6, decimal_places=2),
|
output_field=DecimalField(max_digits=6, decimal_places=2),
|
||||||
),
|
),
|
||||||
spg_value=Coalesce(
|
spg_value=Max(
|
||||||
Max("player_seasons__stats__steals", filter=context_filter),
|
"player_seasons__stats__steals",
|
||||||
Value(0, output_field=DecimalField(max_digits=6, decimal_places=2)),
|
filter=context_filter,
|
||||||
output_field=DecimalField(max_digits=6, decimal_places=2),
|
output_field=DecimalField(max_digits=6, decimal_places=2),
|
||||||
),
|
),
|
||||||
bpg_value=Coalesce(
|
bpg_value=Max(
|
||||||
Max("player_seasons__stats__blocks", filter=context_filter),
|
"player_seasons__stats__blocks",
|
||||||
Value(0, output_field=DecimalField(max_digits=6, decimal_places=2)),
|
filter=context_filter,
|
||||||
output_field=DecimalField(max_digits=6, decimal_places=2),
|
output_field=DecimalField(max_digits=6, decimal_places=2),
|
||||||
),
|
),
|
||||||
top_efficiency=Coalesce(
|
tov_value=Max(
|
||||||
Max("player_seasons__stats__player_efficiency_rating", filter=context_filter),
|
"player_seasons__stats__turnovers",
|
||||||
Value(0, output_field=DecimalField(max_digits=6, decimal_places=2)),
|
filter=context_filter,
|
||||||
output_field=DecimalField(max_digits=6, decimal_places=2),
|
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),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,7 @@ from apps.scouting.models import FavoritePlayer
|
|||||||
from apps.stats.models import PlayerSeason
|
from apps.stats.models import PlayerSeason
|
||||||
|
|
||||||
from .forms import PlayerSearchForm
|
from .forms import PlayerSearchForm
|
||||||
from .models import Player, PlayerCareerEntry
|
from .models import Player
|
||||||
from .services.search import (
|
from .services.search import (
|
||||||
SEARCH_METRIC_SEMANTICS_TEXT,
|
SEARCH_METRIC_SEMANTICS_TEXT,
|
||||||
annotate_player_metrics,
|
annotate_player_metrics,
|
||||||
@ -92,12 +92,6 @@ class PlayerDetailView(DetailView):
|
|||||||
"competition",
|
"competition",
|
||||||
"stats",
|
"stats",
|
||||||
).order_by("-season__start_date", "-id")
|
).order_by("-season__start_date", "-id")
|
||||||
career_queryset = PlayerCareerEntry.objects.select_related(
|
|
||||||
"team",
|
|
||||||
"competition",
|
|
||||||
"season",
|
|
||||||
"role_snapshot",
|
|
||||||
).order_by("-start_date", "-id")
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
Player.objects.select_related(
|
Player.objects.select_related(
|
||||||
@ -108,9 +102,7 @@ class PlayerDetailView(DetailView):
|
|||||||
"origin_team",
|
"origin_team",
|
||||||
)
|
)
|
||||||
.prefetch_related(
|
.prefetch_related(
|
||||||
"aliases",
|
|
||||||
Prefetch("player_seasons", queryset=season_queryset),
|
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["age"] = calculate_age(player.birth_date)
|
||||||
context["current_assignment"] = current_assignment
|
context["current_assignment"] = current_assignment
|
||||||
context["career_entries"] = player.career_entries.all()
|
|
||||||
context["season_rows"] = season_rows
|
context["season_rows"] = season_rows
|
||||||
context["is_favorite"] = False
|
context["is_favorite"] = False
|
||||||
if self.request.user.is_authenticated:
|
if self.request.user.is_authenticated:
|
||||||
|
|||||||
@ -22,8 +22,6 @@
|
|||||||
<h2 class="text-base">Summary</h2>
|
<h2 class="text-base">Summary</h2>
|
||||||
<dl class="mt-2 space-y-1 text-sm">
|
<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">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">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">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>
|
<div><dt class="inline font-semibold">Height:</dt> <dd class="inline">{{ player.height_cm|default:"-" }} cm</dd></div>
|
||||||
@ -47,14 +45,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg border border-slate-200 p-4">
|
<div class="rounded-lg border border-slate-200 p-4">
|
||||||
<h2 class="text-base">Aliases</h2>
|
<h2 class="text-base">Snapshot Coverage</h2>
|
||||||
<ul class="mt-2 list-inside list-disc text-sm text-slate-700">
|
<dl class="mt-2 space-y-1 text-sm">
|
||||||
{% for alias in player.aliases.all %}
|
<div><dt class="inline font-semibold">Seasons imported:</dt> <dd class="inline">{{ season_rows|length }}</dd></div>
|
||||||
<li>{{ alias.alias }}{% if alias.source %} ({{ alias.source }}){% endif %}</li>
|
<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>
|
||||||
{% empty %}
|
</dl>
|
||||||
<li>No aliases recorded.</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -77,33 +72,6 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</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">
|
<section class="panel mt-4">
|
||||||
<h2>Season-by-Season Stats</h2>
|
<h2>Season-by-Season Stats</h2>
|
||||||
{% if season_rows %}
|
{% if season_rows %}
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h1>Player Search</h1>
|
<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 %}
|
{% if search_has_errors %}
|
||||||
<div class="mt-4 rounded-md border border-rose-200 bg-rose-50 p-3 text-sm text-rose-800">
|
<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>
|
<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_competition">Competition</label>{{ search_form.competition }}</div>
|
||||||
<div><label for="id_team">Team</label>{{ search_form.team }}</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_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>
|
</div>
|
||||||
|
|
||||||
<details class="rounded-lg border border-slate-200 bg-slate-50 p-3">
|
<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_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_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_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>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -36,13 +36,18 @@
|
|||||||
<th>Player</th>
|
<th>Player</th>
|
||||||
<th>Nationality</th>
|
<th>Nationality</th>
|
||||||
<th>Pos / Role</th>
|
<th>Pos / Role</th>
|
||||||
<th>Origin</th>
|
|
||||||
<th>Height / Weight</th>
|
<th>Height / Weight</th>
|
||||||
<th>Best Eligible Games</th>
|
<th>Best Eligible Games</th>
|
||||||
<th>Best Eligible MPG</th>
|
<th>Best Eligible MPG</th>
|
||||||
<th>Best Eligible PPG</th>
|
<th>Best Eligible PPG</th>
|
||||||
<th>Best Eligible RPG</th>
|
<th>Best Eligible RPG</th>
|
||||||
<th>Best Eligible APG</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 %}
|
{% if request.user.is_authenticated %}<th>Watchlist</th>{% endif %}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -52,16 +57,18 @@
|
|||||||
<td><a class="font-medium" href="{% url 'players:detail' player.pk %}">{{ player.full_name }}</a></td>
|
<td><a class="font-medium" href="{% url 'players:detail' player.pk %}">{{ player.full_name }}</a></td>
|
||||||
<td>{{ player.nationality.name|default:"-" }}</td>
|
<td>{{ player.nationality.name|default:"-" }}</td>
|
||||||
<td>{{ player.nominal_position.code|default:"-" }} / {{ player.inferred_role.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.height_cm|default:"-" }} / {{ player.weight_kg|default:"-" }}</td>
|
||||||
<td>{{ player.games_played_value|floatformat:0 }}</td>
|
<td>{% if player.games_played_value is not None %}{{ player.games_played_value|floatformat:0 }}{% else %}-{% endif %}</td>
|
||||||
<td>{{ player.mpg_value|floatformat:1 }}</td>
|
<td>{% if player.mpg_value is not None %}{{ player.mpg_value|floatformat:1 }}{% else %}-{% endif %}</td>
|
||||||
<td>{{ player.ppg_value|floatformat:1 }}</td>
|
<td>{% if player.ppg_value is not None %}{{ player.ppg_value|floatformat:1 }}{% else %}-{% endif %}</td>
|
||||||
<td>{{ player.rpg_value|floatformat:1 }}</td>
|
<td>{% if player.rpg_value is not None %}{{ player.rpg_value|floatformat:1 }}{% else %}-{% endif %}</td>
|
||||||
<td>{{ player.apg_value|floatformat:1 }}</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 %}
|
{% if request.user.is_authenticated %}
|
||||||
<td>
|
<td>
|
||||||
{% if player.id in favorite_player_ids %}
|
{% if player.id in favorite_player_ids %}
|
||||||
|
|||||||
@ -83,8 +83,6 @@ def test_players_api_search_consistent_with_ui_filters(client):
|
|||||||
nationality=nationality,
|
nationality=nationality,
|
||||||
nominal_position=position,
|
nominal_position=position,
|
||||||
inferred_role=role,
|
inferred_role=role,
|
||||||
origin_competition=competition,
|
|
||||||
origin_team=team,
|
|
||||||
)
|
)
|
||||||
ps = PlayerSeason.objects.create(
|
ps = PlayerSeason.objects.create(
|
||||||
player=matching,
|
player=matching,
|
||||||
@ -113,7 +111,7 @@ def test_players_api_search_consistent_with_ui_filters(client):
|
|||||||
)
|
)
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
"origin_competition": competition.id,
|
"competition": competition.id,
|
||||||
"nominal_position": position.id,
|
"nominal_position": position.id,
|
||||||
"points_per_game_min": "10",
|
"points_per_game_min": "10",
|
||||||
"sort": "ppg_desc",
|
"sort": "ppg_desc",
|
||||||
|
|||||||
@ -110,8 +110,6 @@ def test_player_search_combined_filters_sorting_and_pagination(client):
|
|||||||
nationality=nationality,
|
nationality=nationality,
|
||||||
nominal_position=position,
|
nominal_position=position,
|
||||||
inferred_role=role,
|
inferred_role=role,
|
||||||
origin_competition=competition,
|
|
||||||
origin_team=team,
|
|
||||||
)
|
)
|
||||||
player_season = PlayerSeason.objects.create(
|
player_season = PlayerSeason.objects.create(
|
||||||
player=player,
|
player=player,
|
||||||
@ -135,7 +133,7 @@ def test_player_search_combined_filters_sorting_and_pagination(client):
|
|||||||
response = client.get(
|
response = client.get(
|
||||||
reverse("players:index"),
|
reverse("players:index"),
|
||||||
data={
|
data={
|
||||||
"origin_competition": competition.id,
|
"competition": competition.id,
|
||||||
"nominal_position": position.id,
|
"nominal_position": position.id,
|
||||||
"sort": "ppg_desc",
|
"sort": "ppg_desc",
|
||||||
"page_size": 20,
|
"page_size": 20,
|
||||||
@ -152,7 +150,7 @@ def test_player_search_combined_filters_sorting_and_pagination(client):
|
|||||||
page2 = client.get(
|
page2 = client.get(
|
||||||
reverse("players:index"),
|
reverse("players:index"),
|
||||||
data={
|
data={
|
||||||
"origin_competition": competition.id,
|
"competition": competition.id,
|
||||||
"nominal_position": position.id,
|
"nominal_position": position.id,
|
||||||
"sort": "ppg_desc",
|
"sort": "ppg_desc",
|
||||||
"page_size": 20,
|
"page_size": 20,
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import pytest
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from apps.competitions.models import Competition, Season
|
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.stats.models import PlayerSeason, PlayerSeasonStats
|
||||||
from apps.teams.models import Team
|
from apps.teams.models import Team
|
||||||
|
|
||||||
@ -127,14 +127,13 @@ def test_player_detail_page_loads(client):
|
|||||||
height_cm=201,
|
height_cm=201,
|
||||||
weight_kg=95,
|
weight_kg=95,
|
||||||
)
|
)
|
||||||
PlayerAlias.objects.create(player=player, alias="P. Martin")
|
|
||||||
|
|
||||||
response = client.get(reverse("players:detail", kwargs={"pk": player.pk}))
|
response = client.get(reverse("players:detail", kwargs={"pk": player.pk}))
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
body = response.content.decode()
|
body = response.content.decode()
|
||||||
assert "Paul Martin" in body
|
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
|
@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 PPG" in body
|
||||||
assert "Best Eligible MPG" in body
|
assert "Best Eligible MPG" in body
|
||||||
assert "best eligible values per player" in body.lower()
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user