phase4: implement player search filters, htmx results, and detail page
This commit is contained in:
121
apps/players/forms.py
Normal file
121
apps/players/forms.py
Normal 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('_', ' ')}")
|
||||
0
apps/players/services/__init__.py
Normal file
0
apps/players/services/__init__.py
Normal file
149
apps/players/services/search.py
Normal file
149
apps/players/services/search.py
Normal 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")
|
||||
0
apps/players/templatetags/__init__.py
Normal file
0
apps/players/templatetags/__init__.py
Normal file
15
apps/players/templatetags/player_query.py
Normal file
15
apps/players/templatetags/player_query.py
Normal 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()
|
||||
@ -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"),
|
||||
]
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user