phase4: implement player search filters, htmx results, and detail page

This commit is contained in:
Alfredo Di Stasio
2026-03-10 10:52:50 +01:00
parent fc7289a343
commit c83bc96b6c
12 changed files with 982 additions and 7 deletions

121
apps/players/forms.py Normal file
View File

@ -0,0 +1,121 @@
from django import forms
from apps.competitions.models import Competition, Season
from apps.players.models import Nationality, Position, Role
from apps.teams.models import Team
class PlayerSearchForm(forms.Form):
SORT_CHOICES = (
("name_asc", "Name (A-Z)"),
("name_desc", "Name (Z-A)"),
("age_youngest", "Age (Youngest first)"),
("age_oldest", "Age (Oldest first)"),
("height_desc", "Height (Tallest first)"),
("height_asc", "Height (Shortest first)"),
("ppg_desc", "Points per game (High to low)"),
("ppg_asc", "Points per game (Low to high)"),
("mpg_desc", "Minutes per game (High to low)"),
("mpg_asc", "Minutes per game (Low to high)"),
)
PAGE_SIZE_CHOICES = ((20, "20"), (50, "50"), (100, "100"))
q = forms.CharField(required=False, label="Name")
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)
nationality = forms.ModelChoiceField(queryset=Nationality.objects.none(), required=False)
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")
age_max = forms.IntegerField(required=False, min_value=0, max_value=60, label="Max age")
height_min = forms.IntegerField(required=False, min_value=120, max_value=250, label="Min height (cm)")
height_max = forms.IntegerField(required=False, min_value=120, max_value=250, label="Max height (cm)")
weight_min = forms.IntegerField(required=False, min_value=40, max_value=200, label="Min weight (kg)")
weight_max = forms.IntegerField(required=False, min_value=40, max_value=200, label="Max weight (kg)")
games_played_min = forms.IntegerField(required=False, min_value=0)
games_played_max = forms.IntegerField(required=False, min_value=0)
minutes_per_game_min = forms.DecimalField(required=False, min_value=0, decimal_places=2, max_digits=6)
minutes_per_game_max = forms.DecimalField(required=False, min_value=0, decimal_places=2, max_digits=6)
points_per_game_min = forms.DecimalField(required=False, min_value=0, decimal_places=2, max_digits=6)
points_per_game_max = forms.DecimalField(required=False, min_value=0, decimal_places=2, max_digits=6)
rebounds_per_game_min = forms.DecimalField(required=False, min_value=0, decimal_places=2, max_digits=6)
rebounds_per_game_max = forms.DecimalField(required=False, min_value=0, decimal_places=2, max_digits=6)
assists_per_game_min = forms.DecimalField(required=False, min_value=0, decimal_places=2, max_digits=6)
assists_per_game_max = forms.DecimalField(required=False, min_value=0, decimal_places=2, max_digits=6)
steals_per_game_min = forms.DecimalField(required=False, min_value=0, decimal_places=2, max_digits=6)
steals_per_game_max = forms.DecimalField(required=False, min_value=0, decimal_places=2, max_digits=6)
blocks_per_game_min = forms.DecimalField(required=False, min_value=0, decimal_places=2, max_digits=6)
blocks_per_game_max = forms.DecimalField(required=False, min_value=0, decimal_places=2, max_digits=6)
turnovers_per_game_min = forms.DecimalField(required=False, min_value=0, decimal_places=2, max_digits=6)
turnovers_per_game_max = forms.DecimalField(required=False, min_value=0, decimal_places=2, max_digits=6)
fg_pct_min = forms.DecimalField(required=False, min_value=0, decimal_places=2, max_digits=5, label="FG% min")
fg_pct_max = forms.DecimalField(required=False, min_value=0, decimal_places=2, max_digits=5, label="FG% max")
three_pct_min = forms.DecimalField(required=False, min_value=0, decimal_places=2, max_digits=5, label="3P% min")
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(
choices=PAGE_SIZE_CHOICES,
required=False,
coerce=int,
initial=20,
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
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["nationality"].queryset = Nationality.objects.order_by("name")
self.fields["team"].queryset = Team.objects.order_by("name")
self.fields["season"].queryset = Season.objects.order_by("-start_date")
def clean(self):
cleaned_data = super().clean()
self._validate_min_max(cleaned_data, "age_min", "age_max")
self._validate_min_max(cleaned_data, "height_min", "height_max")
self._validate_min_max(cleaned_data, "weight_min", "weight_max")
self._validate_min_max(cleaned_data, "games_played_min", "games_played_max")
self._validate_min_max(cleaned_data, "minutes_per_game_min", "minutes_per_game_max")
self._validate_min_max(cleaned_data, "points_per_game_min", "points_per_game_max")
self._validate_min_max(cleaned_data, "rebounds_per_game_min", "rebounds_per_game_max")
self._validate_min_max(cleaned_data, "assists_per_game_min", "assists_per_game_max")
self._validate_min_max(cleaned_data, "steals_per_game_min", "steals_per_game_max")
self._validate_min_max(cleaned_data, "blocks_per_game_min", "blocks_per_game_max")
self._validate_min_max(cleaned_data, "turnovers_per_game_min", "turnovers_per_game_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, "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"
if not cleaned_data.get("page_size"):
cleaned_data["page_size"] = 20
return cleaned_data
def _validate_min_max(self, cleaned_data: dict, minimum_key: str, maximum_key: str) -> None:
minimum = cleaned_data.get(minimum_key)
maximum = cleaned_data.get(maximum_key)
if minimum is not None and maximum is not None and minimum > maximum:
self.add_error(maximum_key, f"{maximum_key.replace('_', ' ')} must be >= {minimum_key.replace('_', ' ')}")

View File

View File

@ -0,0 +1,149 @@
from datetime import date, timedelta
from django.db.models import Case, ExpressionWrapper, F, FloatField, Max, Q, Value, When
from django.db.models.functions import Coalesce
from apps.players.models import Player
def _years_ago_today(years: int) -> date:
today = date.today()
try:
return today.replace(year=today.year - years)
except ValueError:
# Feb 29 -> Feb 28 when target year is not leap year.
return today.replace(month=2, day=28, year=today.year - years)
def _apply_min_max_filter(queryset, min_key: str, max_key: str, field_name: str, data: dict):
min_value = data.get(min_key)
max_value = data.get(max_key)
if min_value is not None:
queryset = queryset.filter(**{f"{field_name}__gte": min_value})
if max_value is not None:
queryset = queryset.filter(**{f"{field_name}__lte": max_value})
return queryset
def filter_players(queryset, data: dict):
query = data.get("q")
if query:
queryset = queryset.filter(Q(full_name__icontains=query) | Q(aliases__alias__icontains=query))
if data.get("nominal_position"):
queryset = queryset.filter(nominal_position=data["nominal_position"])
if data.get("inferred_role"):
queryset = queryset.filter(inferred_role=data["inferred_role"])
if data.get("nationality"):
queryset = queryset.filter(nationality=data["nationality"])
if data.get("team"):
queryset = queryset.filter(player_seasons__team=data["team"])
if data.get("competition"):
queryset = queryset.filter(player_seasons__competition=data["competition"])
if data.get("season"):
queryset = queryset.filter(player_seasons__season=data["season"])
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)
age_min = data.get("age_min")
age_max = data.get("age_max")
if age_min is not None:
queryset = queryset.filter(birth_date__lte=_years_ago_today(age_min))
if age_max is not None:
earliest_birth = _years_ago_today(age_max + 1) + timedelta(days=1)
queryset = queryset.filter(birth_date__gte=earliest_birth)
queryset = _apply_min_max_filter(
queryset,
"games_played_min",
"games_played_max",
"player_seasons__games_played",
data,
)
mpg_min = data.get("minutes_per_game_min")
mpg_max = data.get("minutes_per_game_max")
if mpg_min is not None:
queryset = queryset.filter(player_seasons__games_played__gt=0).filter(
player_seasons__minutes_played__gte=F("player_seasons__games_played") * mpg_min
)
if mpg_max is not None:
queryset = queryset.filter(player_seasons__games_played__gt=0).filter(
player_seasons__minutes_played__lte=F("player_seasons__games_played") * mpg_max
)
stat_pairs = (
("points_per_game_min", "points_per_game_max", "player_seasons__stats__points"),
("rebounds_per_game_min", "rebounds_per_game_max", "player_seasons__stats__rebounds"),
("assists_per_game_min", "assists_per_game_max", "player_seasons__stats__assists"),
("steals_per_game_min", "steals_per_game_max", "player_seasons__stats__steals"),
("blocks_per_game_min", "blocks_per_game_max", "player_seasons__stats__blocks"),
("turnovers_per_game_min", "turnovers_per_game_max", "player_seasons__stats__turnovers"),
("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 stat_pairs:
queryset = _apply_min_max_filter(queryset, min_key, max_key, field_name, data)
mpg_expression = Case(
When(
player_seasons__games_played__gt=0,
then=ExpressionWrapper(
F("player_seasons__minutes_played") * 1.0 / F("player_seasons__games_played"),
output_field=FloatField(),
),
),
default=Value(0.0),
output_field=FloatField(),
)
queryset = queryset.annotate(
games_played_value=Coalesce(Max("player_seasons__games_played"), Value(0)),
mpg_value=Coalesce(Max(mpg_expression), Value(0.0)),
ppg_value=Coalesce(Max("player_seasons__stats__points"), Value(0.0)),
rpg_value=Coalesce(Max("player_seasons__stats__rebounds"), Value(0.0)),
apg_value=Coalesce(Max("player_seasons__stats__assists"), Value(0.0)),
spg_value=Coalesce(Max("player_seasons__stats__steals"), Value(0.0)),
bpg_value=Coalesce(Max("player_seasons__stats__blocks"), Value(0.0)),
top_efficiency=Coalesce(Max("player_seasons__stats__player_efficiency_rating"), Value(0.0)),
)
return queryset.distinct()
def apply_sorting(queryset, sort_key: str):
if sort_key == "name_desc":
return queryset.order_by("-full_name", "id")
if sort_key == "age_youngest":
return queryset.order_by(F("birth_date").desc(nulls_last=True), "full_name")
if sort_key == "age_oldest":
return queryset.order_by(F("birth_date").asc(nulls_last=True), "full_name")
if sort_key == "height_desc":
return queryset.order_by(F("height_cm").desc(nulls_last=True), "full_name")
if sort_key == "height_asc":
return queryset.order_by(F("height_cm").asc(nulls_last=True), "full_name")
if sort_key == "ppg_desc":
return queryset.order_by(F("ppg_value").desc(nulls_last=True), "full_name")
if sort_key == "ppg_asc":
return queryset.order_by(F("ppg_value").asc(nulls_last=True), "full_name")
if sort_key == "mpg_desc":
return queryset.order_by(F("mpg_value").desc(nulls_last=True), "full_name")
if sort_key == "mpg_asc":
return queryset.order_by(F("mpg_value").asc(nulls_last=True), "full_name")
return queryset.order_by("full_name", "id")
def base_player_queryset():
return Player.objects.select_related(
"nationality",
"nominal_position",
"inferred_role",
).prefetch_related("aliases")

View File

View File

@ -0,0 +1,15 @@
from django import template
register = template.Library()
@register.simple_tag(takes_context=True)
def query_transform(context, **kwargs):
request = context["request"]
query_params = request.GET.copy()
for key, value in kwargs.items():
if value in (None, ""):
query_params.pop(key, None)
else:
query_params[key] = value
return query_params.urlencode()

View File

@ -1,9 +1,10 @@
from django.urls import path from django.urls import path
from .views import PlayersHomeView from .views import PlayerDetailView, PlayerSearchView
app_name = "players" app_name = "players"
urlpatterns = [ urlpatterns = [
path("", PlayersHomeView.as_view(), name="index"), path("", PlayerSearchView.as_view(), name="index"),
path("<int:pk>/", PlayerDetailView.as_view(), name="detail"),
] ]

View File

@ -1,5 +1,125 @@
from django.views.generic import TemplateView from datetime import date
from django.db.models import Prefetch
from django.views.generic import DetailView, ListView
from apps.stats.models import PlayerSeason
from .forms import PlayerSearchForm
from .models import Player
from .services.search import apply_sorting, base_player_queryset, filter_players
class PlayersHomeView(TemplateView): def calculate_age(birth_date):
if not birth_date:
return None
today = date.today()
return today.year - birth_date.year - (
(today.month, today.day) < (birth_date.month, birth_date.day)
)
class PlayerSearchView(ListView):
model = Player
context_object_name = "players"
paginate_by = 20
template_name = "players/index.html" template_name = "players/index.html"
def get_template_names(self):
if self.request.headers.get("HX-Request") == "true":
return ["players/partials/results.html"]
return [self.template_name]
def get_form(self):
if not hasattr(self, "_search_form"):
self._search_form = PlayerSearchForm(self.request.GET or None)
return self._search_form
def get_paginate_by(self, queryset):
form = self.get_form()
if form.is_valid():
return form.cleaned_data.get("page_size") or 20
return self.paginate_by
def get_queryset(self):
form = self.get_form()
queryset = base_player_queryset()
if form.is_valid():
queryset = filter_players(queryset, form.cleaned_data)
queryset = apply_sorting(queryset, form.cleaned_data.get("sort", "name_asc"))
else:
queryset = queryset.order_by("full_name", "id")
return queryset
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["search_form"] = self.get_form()
return context
class PlayerDetailView(DetailView):
model = Player
template_name = "players/detail.html"
context_object_name = "player"
def get_queryset(self):
season_queryset = PlayerSeason.objects.select_related(
"season",
"team",
"competition",
"stats",
).order_by("-season__start_date", "-id")
return (
Player.objects.select_related(
"nationality",
"nominal_position",
"inferred_role",
)
.prefetch_related(
"aliases",
Prefetch("player_seasons", queryset=season_queryset),
"career_entries__team",
"career_entries__competition",
"career_entries__season",
"career_entries__role_snapshot",
)
)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
player = self.object
raw_season_rows = list(player.player_seasons.all())
current_assignment = next(
(row for row in raw_season_rows if row.season and row.season.is_current),
None,
)
if current_assignment is None and raw_season_rows:
current_assignment = raw_season_rows[0]
season_rows = []
for row in raw_season_rows:
try:
stats = row.stats
except PlayerSeason.stats.RelatedObjectDoesNotExist:
stats = None
season_rows.append(
{
"season": row.season,
"team": row.team,
"competition": row.competition,
"games_played": row.games_played,
"minutes_played": row.minutes_played,
"mpg": (row.minutes_played / row.games_played) if row.games_played else None,
"stats": stats,
}
)
context["age"] = calculate_age(player.birth_date)
context["current_assignment"] = current_assignment
context["career_entries"] = player.career_entries.all().order_by("-start_date", "-id")
context["season_rows"] = season_rows
return context

View File

@ -113,3 +113,95 @@ main {
padding: 0.6rem 0.8rem; padding: 0.6rem 0.8rem;
border-radius: 8px; border-radius: 8px;
} }
.mt-16 {
margin-top: 1rem;
}
.wrap-gap {
flex-wrap: wrap;
}
.muted-text {
color: var(--muted);
}
.search-form label {
display: block;
margin-bottom: 0.3rem;
font-weight: 600;
}
.search-form input,
.search-form select {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--line);
border-radius: 8px;
}
.filter-grid {
display: grid;
gap: 0.75rem;
margin-bottom: 0.85rem;
}
.filter-grid-3 {
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
}
.filter-grid-4 {
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
}
.filter-actions {
display: flex;
align-items: end;
gap: 0.5rem;
}
details {
border-top: 1px solid var(--line);
margin-top: 0.8rem;
padding-top: 0.8rem;
}
summary {
cursor: pointer;
font-weight: 600;
margin-bottom: 0.8rem;
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.95rem;
}
th,
td {
text-align: left;
padding: 0.55rem 0.5rem;
border-bottom: 1px solid var(--line);
}
th {
font-weight: 700;
color: var(--muted);
}
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 0.8rem;
}
.detail-card {
border: 1px solid var(--line);
border-radius: 10px;
padding: 0.8rem;
}

View File

@ -0,0 +1,169 @@
{% extends "base.html" %}
{% block title %}HoopScout | {{ player.full_name }}{% endblock %}
{% block content %}
<section class="panel">
<div class="row-between wrap-gap">
<div>
<h1>{{ player.full_name }}</h1>
<p class="muted-text">
{{ player.nominal_position.name|default:"No nominal position" }}
· {{ player.inferred_role.name|default:"No inferred role" }}
</p>
</div>
<a class="button ghost" href="{% url 'players:index' %}">Back to search</a>
</div>
<div class="detail-grid mt-16">
<div class="detail-card">
<h2>Summary</h2>
<p><strong>Nationality:</strong> {{ player.nationality.name|default:"-" }}</p>
<p><strong>Birth date:</strong> {{ player.birth_date|date:"Y-m-d"|default:"-" }}</p>
<p><strong>Age:</strong> {{ age|default:"-" }}</p>
<p><strong>Height:</strong> {{ player.height_cm|default:"-" }} cm</p>
<p><strong>Weight:</strong> {{ player.weight_kg|default:"-" }} kg</p>
<p><strong>Dominant hand:</strong> {{ player.get_dominant_hand_display|default:"-" }}</p>
</div>
<div class="detail-card">
<h2>Current Assignment</h2>
{% if current_assignment %}
<p><strong>Team:</strong> {{ current_assignment.team.name|default:"-" }}</p>
<p><strong>Competition:</strong> {{ current_assignment.competition.name|default:"-" }}</p>
<p><strong>Season:</strong> {{ current_assignment.season.label|default:"-" }}</p>
<p><strong>Games:</strong> {{ current_assignment.games_played }}</p>
{% else %}
<p>No active assignment available.</p>
{% endif %}
</div>
<div class="detail-card">
<h2>Aliases</h2>
<ul>
{% for alias in player.aliases.all %}
<li>{{ alias.alias }}{% if alias.source %} ({{ alias.source }}){% endif %}</li>
{% empty %}
<li>No aliases recorded.</li>
{% endfor %}
</ul>
</div>
</div>
</section>
<section class="panel mt-16">
<h2>Team History</h2>
{% if season_rows %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Season</th>
<th>Team</th>
<th>Competition</th>
</tr>
</thead>
<tbody>
{% for row in season_rows %}
<tr>
<td>{{ row.season.label|default:"-" }}</td>
<td>{{ row.team.name|default:"-" }}</td>
<td>{{ row.competition.name|default:"-" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p>No team history available.</p>
{% endif %}
</section>
<section class="panel mt-16">
<h2>Career History</h2>
{% if career_entries %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Season</th>
<th>Team</th>
<th>Competition</th>
<th>Role</th>
<th>From</th>
<th>To</th>
</tr>
</thead>
<tbody>
{% 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 %}
<p>No career entries available.</p>
{% endif %}
</section>
<section class="panel mt-16">
<h2>Season-by-Season Stats</h2>
{% if season_rows %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Season</th>
<th>Team</th>
<th>Competition</th>
<th>Games</th>
<th>MPG</th>
<th>PPG</th>
<th>RPG</th>
<th>APG</th>
<th>SPG</th>
<th>BPG</th>
<th>TOPG</th>
<th>FG%</th>
<th>3P%</th>
<th>FT%</th>
<th>Impact</th>
</tr>
</thead>
<tbody>
{% for row in season_rows %}
<tr>
<td>{{ row.season.label|default:"-" }}</td>
<td>{{ row.team.name|default:"-" }}</td>
<td>{{ row.competition.name|default:"-" }}</td>
<td>{{ row.games_played }}</td>
<td>
{% if row.mpg is not None %}{{ row.mpg|floatformat:1 }}{% else %}-{% endif %}
</td>
<td>{% if row.stats %}{{ row.stats.points }}{% else %}-{% endif %}</td>
<td>{% if row.stats %}{{ row.stats.rebounds }}{% else %}-{% endif %}</td>
<td>{% if row.stats %}{{ row.stats.assists }}{% else %}-{% endif %}</td>
<td>{% if row.stats %}{{ row.stats.steals }}{% else %}-{% endif %}</td>
<td>{% if row.stats %}{{ row.stats.blocks }}{% else %}-{% endif %}</td>
<td>{% if row.stats %}{{ row.stats.turnovers }}{% else %}-{% endif %}</td>
<td>{% if row.stats %}{{ row.stats.fg_pct }}{% else %}-{% endif %}</td>
<td>{% if row.stats %}{{ row.stats.three_pct }}{% else %}-{% endif %}</td>
<td>{% if row.stats %}{{ row.stats.ft_pct }}{% else %}-{% endif %}</td>
<td>{% if row.stats %}{{ row.stats.player_efficiency_rating }}{% else %}-{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p>No season stats available.</p>
{% endif %}
</section>
{% endblock %}

View File

@ -1,10 +1,99 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}HoopScout | Players{% endblock %} {% block title %}HoopScout | Player Search{% endblock %}
{% block content %} {% block content %}
<section class="panel"> <section class="panel">
<h1>Players</h1> <h1>Player Search</h1>
<p>Players module scaffolding for upcoming phases.</p> <p>Filter players by profile, context, and production metrics.</p>
<form
method="get"
class="stack search-form"
hx-get="{% url 'players:index' %}"
hx-target="#player-results"
hx-swap="innerHTML"
hx-push-url="true"
hx-trigger="submit, change delay:200ms from:select, keyup changed delay:400ms from:#id_q"
>
<div class="filter-grid filter-grid-4">
<div>
<label for="id_q">Name</label>
{{ search_form.q }}
</div>
<div>
<label for="id_sort">Sort</label>
{{ search_form.sort }}
</div>
<div>
<label for="id_page_size">Page size</label>
{{ search_form.page_size }}
</div>
<div class="filter-actions">
<button type="submit" class="button">Apply</button>
<a class="button ghost" href="{% url 'players:index' %}">Reset</a>
</div>
</div>
<div class="filter-grid filter-grid-3">
<div><label for="id_nominal_position">Nominal position</label>{{ search_form.nominal_position }}</div>
<div><label for="id_inferred_role">Inferred role</label>{{ search_form.inferred_role }}</div>
<div><label for="id_nationality">Nationality</label>{{ search_form.nationality }}</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_season">Season</label>{{ search_form.season }}</div>
</div>
<details>
<summary>Physical and age filters</summary>
<div class="filter-grid filter-grid-4">
<div><label for="id_age_min">Age min</label>{{ search_form.age_min }}</div>
<div><label for="id_age_max">Age max</label>{{ search_form.age_max }}</div>
<div><label for="id_height_min">Height min (cm)</label>{{ search_form.height_min }}</div>
<div><label for="id_height_max">Height max (cm)</label>{{ search_form.height_max }}</div>
<div><label for="id_weight_min">Weight min (kg)</label>{{ search_form.weight_min }}</div>
<div><label for="id_weight_max">Weight max (kg)</label>{{ search_form.weight_max }}</div>
</div>
</details>
<details>
<summary>Statistical filters</summary>
<div class="filter-grid filter-grid-4">
<div><label for="id_games_played_min">Games min</label>{{ search_form.games_played_min }}</div>
<div><label for="id_games_played_max">Games max</label>{{ search_form.games_played_max }}</div>
<div><label for="id_minutes_per_game_min">MPG min</label>{{ search_form.minutes_per_game_min }}</div>
<div><label for="id_minutes_per_game_max">MPG max</label>{{ search_form.minutes_per_game_max }}</div>
<div><label for="id_points_per_game_min">PPG min</label>{{ search_form.points_per_game_min }}</div>
<div><label for="id_points_per_game_max">PPG max</label>{{ search_form.points_per_game_max }}</div>
<div><label for="id_rebounds_per_game_min">RPG min</label>{{ search_form.rebounds_per_game_min }}</div>
<div><label for="id_rebounds_per_game_max">RPG max</label>{{ search_form.rebounds_per_game_max }}</div>
<div><label for="id_assists_per_game_min">APG min</label>{{ search_form.assists_per_game_min }}</div>
<div><label for="id_assists_per_game_max">APG max</label>{{ search_form.assists_per_game_max }}</div>
<div><label for="id_steals_per_game_min">SPG min</label>{{ search_form.steals_per_game_min }}</div>
<div><label for="id_steals_per_game_max">SPG max</label>{{ search_form.steals_per_game_max }}</div>
<div><label for="id_blocks_per_game_min">BPG min</label>{{ search_form.blocks_per_game_min }}</div>
<div><label for="id_blocks_per_game_max">BPG max</label>{{ search_form.blocks_per_game_max }}</div>
<div><label for="id_turnovers_per_game_min">TOPG min</label>{{ search_form.turnovers_per_game_min }}</div>
<div><label for="id_turnovers_per_game_max">TOPG max</label>{{ search_form.turnovers_per_game_max }}</div>
<div><label for="id_fg_pct_min">FG% min</label>{{ search_form.fg_pct_min }}</div>
<div><label for="id_fg_pct_max">FG% max</label>{{ search_form.fg_pct_max }}</div>
<div><label for="id_three_pct_min">3P% min</label>{{ search_form.three_pct_min }}</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_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>
</section>
<section id="player-results" class="panel mt-16">
{% include "players/partials/results.html" %}
</section> </section>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,82 @@
{% load player_query %}
<div class="row-between wrap-gap">
<h2>Results</h2>
<div class="muted-text">
{{ page_obj.paginator.count }} player{{ page_obj.paginator.count|pluralize }} found
</div>
</div>
{% if players %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Player</th>
<th>Nationality</th>
<th>Pos / Role</th>
<th>Height / Weight</th>
<th>Games</th>
<th>MPG</th>
<th>PPG</th>
<th>RPG</th>
<th>APG</th>
</tr>
</thead>
<tbody>
{% for player in players %}
<tr>
<td>
<a 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.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>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="pagination row-gap mt-16">
{% if page_obj.has_previous %}
{% query_transform page=page_obj.previous_page_number as prev_query %}
<a
class="button ghost"
href="?{{ prev_query }}"
hx-get="?{{ prev_query }}"
hx-target="#player-results"
hx-swap="innerHTML"
hx-push-url="true"
>
Previous
</a>
{% endif %}
<span>Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span>
{% if page_obj.has_next %}
{% query_transform page=page_obj.next_page_number as next_query %}
<a
class="button ghost"
href="?{{ next_query }}"
hx-get="?{{ next_query }}"
hx-target="#player-results"
hx-swap="innerHTML"
hx-push-url="true"
>
Next
</a>
{% endif %}
</div>
{% else %}
<p>No players matched the current filters.</p>
{% endif %}

137
tests/test_players_views.py Normal file
View File

@ -0,0 +1,137 @@
from datetime import date
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.stats.models import PlayerSeason, PlayerSeasonStats
from apps.teams.models import Team
@pytest.mark.django_db
def test_player_search_filters_by_name_and_position(client):
nationality = Nationality.objects.create(name="Italy", iso2_code="IT", iso3_code="ITA")
guard = Position.objects.create(code="PG", name="Point Guard")
center = Position.objects.create(code="C", name="Center")
role = Role.objects.create(code="playmaker", name="Playmaker")
p1 = Player.objects.create(
first_name="Marco",
last_name="Rossi",
full_name="Marco Rossi",
birth_date=date(2001, 1, 10),
nationality=nationality,
nominal_position=guard,
inferred_role=role,
height_cm=190,
weight_kg=82,
)
PlayerAlias.objects.create(player=p1, alias="M. Rossi")
Player.objects.create(
first_name="Luca",
last_name="Bianchi",
full_name="Luca Bianchi",
birth_date=date(1998, 2, 3),
nationality=nationality,
nominal_position=center,
inferred_role=role,
height_cm=208,
weight_kg=105,
)
response = client.get(
reverse("players:index"),
data={"q": "ross", "nominal_position": guard.id},
)
assert response.status_code == 200
players = response.context["players"]
assert len(players) == 1
assert players[0].full_name == "Marco Rossi"
@pytest.mark.django_db
def test_player_search_htmx_returns_partial(client):
nationality = Nationality.objects.create(name="Spain", iso2_code="ES", iso3_code="ESP")
position = Position.objects.create(code="SG", name="Shooting Guard")
role = Role.objects.create(code="shooter", name="Shooter")
season = Season.objects.create(label="2025-2026", start_date=date(2025, 9, 1), end_date=date(2026, 6, 1))
competition = Competition.objects.create(
name="Liga ACB",
slug="liga-acb",
competition_type=Competition.CompetitionType.LEAGUE,
gender=Competition.Gender.MEN,
country=nationality,
)
team = Team.objects.create(name="Madrid", slug="madrid", country=nationality)
player = Player.objects.create(
first_name="Juan",
last_name="Perez",
full_name="Juan Perez",
birth_date=date(2000, 4, 12),
nationality=nationality,
nominal_position=position,
inferred_role=role,
height_cm=196,
weight_kg=90,
)
season_row = PlayerSeason.objects.create(
player=player,
season=season,
team=team,
competition=competition,
games_played=30,
minutes_played=900,
)
PlayerSeasonStats.objects.create(
player_season=season_row,
points=16.2,
rebounds=4.3,
assists=3.1,
steals=1.4,
blocks=0.3,
turnovers=1.8,
fg_pct=49.5,
three_pct=38.2,
ft_pct=84.1,
player_efficiency_rating=18.7,
)
response = client.get(
reverse("players:index"),
HTTP_HX_REQUEST="true",
data={"points_per_game_min": 15},
)
assert response.status_code == 200
assert "Results" in response.content.decode()
assert "Juan Perez" in response.content.decode()
@pytest.mark.django_db
def test_player_detail_page_loads(client):
nationality = Nationality.objects.create(name="France", iso2_code="FR", iso3_code="FRA")
position = Position.objects.create(code="SF", name="Small Forward")
role = Role.objects.create(code="wing", name="Wing")
player = Player.objects.create(
first_name="Paul",
last_name="Martin",
full_name="Paul Martin",
birth_date=date(1999, 8, 14),
nationality=nationality,
nominal_position=position,
inferred_role=role,
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