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 .views import PlayersHomeView
from .views import PlayerDetailView, PlayerSearchView
app_name = "players"
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"
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