diff --git a/apps/players/forms.py b/apps/players/forms.py new file mode 100644 index 0000000..630920f --- /dev/null +++ b/apps/players/forms.py @@ -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('_', ' ')}") diff --git a/apps/players/services/__init__.py b/apps/players/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/players/services/search.py b/apps/players/services/search.py new file mode 100644 index 0000000..25248f2 --- /dev/null +++ b/apps/players/services/search.py @@ -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") diff --git a/apps/players/templatetags/__init__.py b/apps/players/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/players/templatetags/player_query.py b/apps/players/templatetags/player_query.py new file mode 100644 index 0000000..fe314a1 --- /dev/null +++ b/apps/players/templatetags/player_query.py @@ -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() diff --git a/apps/players/urls.py b/apps/players/urls.py index fd4ee83..776f69b 100644 --- a/apps/players/urls.py +++ b/apps/players/urls.py @@ -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("/", PlayerDetailView.as_view(), name="detail"), ] diff --git a/apps/players/views.py b/apps/players/views.py index 17a6890..c966e81 100644 --- a/apps/players/views.py +++ b/apps/players/views.py @@ -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 diff --git a/static/css/main.css b/static/css/main.css index dc01508..9fc19a3 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -113,3 +113,95 @@ main { padding: 0.6rem 0.8rem; 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; +} diff --git a/templates/players/detail.html b/templates/players/detail.html new file mode 100644 index 0000000..7f7e788 --- /dev/null +++ b/templates/players/detail.html @@ -0,0 +1,169 @@ +{% extends "base.html" %} + +{% block title %}HoopScout | {{ player.full_name }}{% endblock %} + +{% block content %} +
+
+
+

{{ player.full_name }}

+

+ {{ player.nominal_position.name|default:"No nominal position" }} + ยท {{ player.inferred_role.name|default:"No inferred role" }} +

+
+ Back to search +
+ +
+
+

Summary

+

Nationality: {{ player.nationality.name|default:"-" }}

+

Birth date: {{ player.birth_date|date:"Y-m-d"|default:"-" }}

+

Age: {{ age|default:"-" }}

+

Height: {{ player.height_cm|default:"-" }} cm

+

Weight: {{ player.weight_kg|default:"-" }} kg

+

Dominant hand: {{ player.get_dominant_hand_display|default:"-" }}

+
+ +
+

Current Assignment

+ {% if current_assignment %} +

Team: {{ current_assignment.team.name|default:"-" }}

+

Competition: {{ current_assignment.competition.name|default:"-" }}

+

Season: {{ current_assignment.season.label|default:"-" }}

+

Games: {{ current_assignment.games_played }}

+ {% else %} +

No active assignment available.

+ {% endif %} +
+ +
+

Aliases

+
    + {% for alias in player.aliases.all %} +
  • {{ alias.alias }}{% if alias.source %} ({{ alias.source }}){% endif %}
  • + {% empty %} +
  • No aliases recorded.
  • + {% endfor %} +
+
+
+
+ +
+

Team History

+ {% if season_rows %} +
+ + + + + + + + + + {% for row in season_rows %} + + + + + + {% endfor %} + +
SeasonTeamCompetition
{{ row.season.label|default:"-" }}{{ row.team.name|default:"-" }}{{ row.competition.name|default:"-" }}
+
+ {% else %} +

No team history available.

+ {% endif %} +
+ +
+

Career History

+ {% if career_entries %} +
+ + + + + + + + + + + + + {% for entry in career_entries %} + + + + + + + + + {% endfor %} + +
SeasonTeamCompetitionRoleFromTo
{{ entry.season.label|default:"-" }}{{ entry.team.name|default:"-" }}{{ entry.competition.name|default:"-" }}{{ entry.role_snapshot.name|default:"-" }}{{ entry.start_date|date:"Y-m-d"|default:"-" }}{{ entry.end_date|date:"Y-m-d"|default:"-" }}
+
+ {% else %} +

No career entries available.

+ {% endif %} +
+ +
+

Season-by-Season Stats

+ {% if season_rows %} +
+ + + + + + + + + + + + + + + + + + + + + + {% for row in season_rows %} + + + + + + + + + + + + + + + + + + {% endfor %} + +
SeasonTeamCompetitionGamesMPGPPGRPGAPGSPGBPGTOPGFG%3P%FT%Impact
{{ row.season.label|default:"-" }}{{ row.team.name|default:"-" }}{{ row.competition.name|default:"-" }}{{ row.games_played }} + {% if row.mpg is not None %}{{ row.mpg|floatformat:1 }}{% else %}-{% endif %} + {% if row.stats %}{{ row.stats.points }}{% else %}-{% endif %}{% if row.stats %}{{ row.stats.rebounds }}{% else %}-{% endif %}{% if row.stats %}{{ row.stats.assists }}{% else %}-{% endif %}{% if row.stats %}{{ row.stats.steals }}{% else %}-{% endif %}{% if row.stats %}{{ row.stats.blocks }}{% else %}-{% endif %}{% if row.stats %}{{ row.stats.turnovers }}{% else %}-{% endif %}{% if row.stats %}{{ row.stats.fg_pct }}{% else %}-{% endif %}{% if row.stats %}{{ row.stats.three_pct }}{% else %}-{% endif %}{% if row.stats %}{{ row.stats.ft_pct }}{% else %}-{% endif %}{% if row.stats %}{{ row.stats.player_efficiency_rating }}{% else %}-{% endif %}
+
+ {% else %} +

No season stats available.

+ {% endif %} +
+{% endblock %} diff --git a/templates/players/index.html b/templates/players/index.html index 8ce17a9..594cc07 100644 --- a/templates/players/index.html +++ b/templates/players/index.html @@ -1,10 +1,99 @@ {% extends "base.html" %} -{% block title %}HoopScout | Players{% endblock %} +{% block title %}HoopScout | Player Search{% endblock %} {% block content %}
-

Players

-

Players module scaffolding for upcoming phases.

+

Player Search

+

Filter players by profile, context, and production metrics.

+ +
+
+
+ + {{ search_form.q }} +
+
+ + {{ search_form.sort }} +
+
+ + {{ search_form.page_size }} +
+
+ + Reset +
+
+ +
+
{{ search_form.nominal_position }}
+
{{ search_form.inferred_role }}
+
{{ search_form.nationality }}
+
{{ search_form.competition }}
+
{{ search_form.team }}
+
{{ search_form.season }}
+
+ +
+ Physical and age filters +
+
{{ search_form.age_min }}
+
{{ search_form.age_max }}
+
{{ search_form.height_min }}
+
{{ search_form.height_max }}
+
{{ search_form.weight_min }}
+
{{ search_form.weight_max }}
+
+
+ +
+ Statistical filters +
+
{{ search_form.games_played_min }}
+
{{ search_form.games_played_max }}
+
{{ search_form.minutes_per_game_min }}
+
{{ search_form.minutes_per_game_max }}
+ +
{{ search_form.points_per_game_min }}
+
{{ search_form.points_per_game_max }}
+
{{ search_form.rebounds_per_game_min }}
+
{{ search_form.rebounds_per_game_max }}
+ +
{{ search_form.assists_per_game_min }}
+
{{ search_form.assists_per_game_max }}
+
{{ search_form.steals_per_game_min }}
+
{{ search_form.steals_per_game_max }}
+ +
{{ search_form.blocks_per_game_min }}
+
{{ search_form.blocks_per_game_max }}
+
{{ search_form.turnovers_per_game_min }}
+
{{ search_form.turnovers_per_game_max }}
+ +
{{ search_form.fg_pct_min }}
+
{{ search_form.fg_pct_max }}
+
{{ search_form.three_pct_min }}
+
{{ search_form.three_pct_max }}
+ +
{{ search_form.ft_pct_min }}
+
{{ search_form.ft_pct_max }}
+
{{ search_form.efficiency_metric_min }}
+
{{ search_form.efficiency_metric_max }}
+
+
+
+
+ +
+ {% include "players/partials/results.html" %}
{% endblock %} diff --git a/templates/players/partials/results.html b/templates/players/partials/results.html new file mode 100644 index 0000000..87455fa --- /dev/null +++ b/templates/players/partials/results.html @@ -0,0 +1,82 @@ +{% load player_query %} + +
+

Results

+
+ {{ page_obj.paginator.count }} player{{ page_obj.paginator.count|pluralize }} found +
+
+ +{% if players %} +
+ + + + + + + + + + + + + + + + {% for player in players %} + + + + + + + + + + + + {% endfor %} + +
PlayerNationalityPos / RoleHeight / WeightGamesMPGPPGRPGAPG
+ {{ player.full_name }} + {{ player.nationality.name|default:"-" }} + {{ player.nominal_position.code|default:"-" }} + / {{ player.inferred_role.name|default:"-" }} + {{ player.height_cm|default:"-" }} / {{ player.weight_kg|default:"-" }}{{ player.games_played_value|floatformat:0 }}{{ player.mpg_value|floatformat:1 }}{{ player.ppg_value|floatformat:1 }}{{ player.rpg_value|floatformat:1 }}{{ player.apg_value|floatformat:1 }}
+
+ + +{% else %} +

No players matched the current filters.

+{% endif %} diff --git a/tests/test_players_views.py b/tests/test_players_views.py new file mode 100644 index 0000000..5748634 --- /dev/null +++ b/tests/test_players_views.py @@ -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