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 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"),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
169
templates/players/detail.html
Normal file
169
templates/players/detail.html
Normal 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 %}
|
||||||
@ -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 %}
|
||||||
|
|||||||
82
templates/players/partials/results.html
Normal file
82
templates/players/partials/results.html
Normal 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
137
tests/test_players_views.py
Normal 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
|
||||||
Reference in New Issue
Block a user