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('_', ' ')}")