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/`
|
||||
- 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:
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user