From 6d8af021ce5effc500929424e6380c89f853e792 Mon Sep 17 00:00:00 2001 From: bisco Date: Tue, 7 Apr 2026 17:18:36 +0200 Subject: [PATCH] feat: add scouting search sorting and pagination --- app/scouting/forms.py | 13 ++ .../templates/scouting/player_list.html | 34 ++++- app/scouting/tests.py | 139 ++++++++++++++++++ app/scouting/views.py | 89 ++++++++++- 4 files changed, 270 insertions(+), 5 deletions(-) diff --git a/app/scouting/forms.py b/app/scouting/forms.py index a91ad3e..5d95e93 100644 --- a/app/scouting/forms.py +++ b/app/scouting/forms.py @@ -8,7 +8,20 @@ from .models import Competition, Role, Season, Specialty, Team class PlayerSearchForm(forms.Form): + SORT_CHOICES = [ + ("name_asc", "Name (A-Z)"), + ("name_desc", "Name (Z-A)"), + ("age_youngest", "Age (youngest first)"), + ("height_desc", "Height (tallest first)"), + ("weight_desc", "Weight (heaviest first)"), + ("points_desc", "Matching context points (high to low)"), + ("assists_desc", "Matching context assists (high to low)"), + ("ts_pct_desc", "Matching context TS% (high to low)"), + ("blocks_desc", "Matching context blocks (high to low)"), + ] + name = forms.CharField(required=False, label="Name") + sort = forms.ChoiceField(required=False, choices=SORT_CHOICES, initial="name_asc") position = forms.ChoiceField( required=False, diff --git a/app/scouting/templates/scouting/player_list.html b/app/scouting/templates/scouting/player_list.html index 614d7ec..ae8a05a 100644 --- a/app/scouting/templates/scouting/player_list.html +++ b/app/scouting/templates/scouting/player_list.html @@ -8,18 +8,30 @@

Scout Search

+
+ Result Controls + {{ form.sort.label_tag }} {{ form.sort }} +

+ Context stat sorts use the matching season context selected by the current + season/team/competition/stat filters. +

+ {% if not context_sorting_enabled %} +

Context stat sorting becomes active once context or stat filters are applied.

+ {% endif %} +
+
Player Filters {{ form.name.label_tag }} {{ form.name }} - {{ form.position.label_tag }} {{ form.position }} - {{ form.role.label_tag }} {{ form.role }} - {{ form.specialty.label_tag }} {{ form.specialty }} {{ form.min_age.label_tag }} {{ form.min_age }} {{ form.max_age.label_tag }} {{ form.max_age }} {{ form.min_height_cm.label_tag }} {{ form.min_height_cm }} {{ form.max_height_cm.label_tag }} {{ form.max_height_cm }} {{ form.min_weight_kg.label_tag }} {{ form.min_weight_kg }} {{ form.max_weight_kg.label_tag }} {{ form.max_weight_kg }} + {{ form.position.label_tag }} {{ form.position }} + {{ form.role.label_tag }} {{ form.role }} + {{ form.specialty.label_tag }} {{ form.specialty }}
@@ -46,7 +58,7 @@ -

Results ({{ players|length }})

+

Results ({{ total_results }})

    {% for player in players %}
  • @@ -74,5 +86,19 @@
  • No players found.
  • {% endfor %}
+ + {% if page_obj.paginator.num_pages > 1 %} + + {% endif %} diff --git a/app/scouting/tests.py b/app/scouting/tests.py index ec9ea4a..9f6ebf4 100644 --- a/app/scouting/tests.py +++ b/app/scouting/tests.py @@ -238,6 +238,145 @@ class ScoutingSearchViewsTests(TestCase): self.assertContains(response, self.player_pg.full_name) self.assertNotContains(response, self.player_wing.full_name) + def test_sorting_by_player_level_field(self): + taller_player = Player.objects.create( + full_name="Big Wing", + birth_date=date(2001, 5, 5), + position="SF", + height_cm=Decimal("208.00"), + weight_kg=Decimal("101.00"), + ) + + response = self.client.get(reverse("scouting:player_list"), {"sort": "height_desc"}) + + player_names = [player.full_name for player in response.context["players"]] + self.assertEqual(player_names[:3], [taller_player.full_name, self.player_wing.full_name, self.player_pg.full_name]) + + def test_sorting_by_matching_context_stat_field(self): + response = self.client.get( + reverse("scouting:player_list"), + {"competition": self.comp_a.id, "sort": "assists_desc"}, + ) + + player_names = [player.full_name for player in response.context["players"]] + self.assertEqual(player_names[:2], [self.player_pg.full_name, self.player_wing.full_name]) + + def test_context_sorting_preserves_matching_context_semantics(self): + second_context = PlayerSeason.objects.create( + player=self.player_pg, + season=self.season_2024, + team=self.team_b, + competition=self.comp_a, + ) + PlayerSeasonStats.objects.create( + player_season=second_context, + points=Decimal("12.00"), + assists=Decimal("9.00"), + steals=Decimal("1.00"), + turnovers=Decimal("2.80"), + blocks=Decimal("0.20"), + efg_pct=Decimal("49.00"), + ts_pct=Decimal("52.00"), + plus_minus=Decimal("1.00"), + offensive_rating=Decimal("107.00"), + defensive_rating=Decimal("109.00"), + ) + + response = self.client.get( + reverse("scouting:player_list"), + {"competition": self.comp_a.id, "team": self.team_a.id, "sort": "assists_desc"}, + ) + + player = next(player for player in response.context["players"] if player.id == self.player_pg.id) + self.assertEqual(player.matching_context.id, self.ctx_pg_good.id) + self.assertContains(response, "AST 7.50") + self.assertNotContains(response, "AST 9.00") + + def test_pagination_works_on_player_list(self): + for index in range(25): + Player.objects.create( + full_name=f"Depth Player {index:02d}", + birth_date=date(2000, 1, 1), + position="SG", + height_cm=Decimal("190.00"), + weight_kg=Decimal("84.00"), + ) + + response = self.client.get(reverse("scouting:player_list"), {"page": 2}) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context["page_obj"].number, 2) + self.assertContains(response, "Page 2 of 2") + self.assertContains(response, "Depth Player 20") + self.assertContains(response, "Depth Player 24") + self.assertNotContains(response, "Depth Player 00") + + def test_pagination_preserves_filters_and_sort_order(self): + for index in range(25): + Player.objects.create( + full_name=f"Guard Prospect {index:02d}", + birth_date=date(2001, 1, 1), + position="PG", + height_cm=Decimal("185.00"), + weight_kg=Decimal("80.00"), + ) + + response = self.client.get( + reverse("scouting:player_list"), + {"position": "PG", "sort": "name_desc"}, + ) + + self.assertContains(response, "position=PG") + self.assertContains(response, "sort=name_desc") + self.assertContains(response, "page=2") + + def test_combined_filters_sort_and_pagination(self): + for index in range(25): + player = Player.objects.create( + full_name=f"Playmaker Prospect {index:02d}", + birth_date=date(2003, 1, 1), + position="PG", + height_cm=Decimal("186.00"), + weight_kg=Decimal("79.00"), + ) + context = PlayerSeason.objects.create( + player=player, + season=self.season_2025, + team=self.team_a, + competition=self.comp_a, + ) + PlayerSeasonStats.objects.create( + player_season=context, + points=Decimal("10.00"), + assists=Decimal(str(30 - index)), + steals=Decimal("1.00"), + turnovers=Decimal("2.00"), + blocks=Decimal("0.10"), + efg_pct=Decimal("50.00"), + ts_pct=Decimal("56.00"), + plus_minus=Decimal("1.00"), + offensive_rating=Decimal("109.00"), + defensive_rating=Decimal("108.00"), + ) + + response = self.client.get( + reverse("scouting:player_list"), + { + "position": "PG", + "competition": self.comp_a.id, + "sort": "assists_desc", + "page": 2, + }, + ) + + self.assertEqual(response.context["page_obj"].number, 2) + player_names = [player.full_name for player in response.context["players"]] + self.assertNotIn(self.player_wing.full_name, player_names) + self.assertEqual(player_names[0], "Playmaker Prospect 20") + self.assertIn("Marco Guard", player_names) + self.assertContains(response, "sort=assists_desc") + self.assertContains(response, "competition=%s" % self.comp_a.id) + class SeedScoutingDataCommandTests(TestCase): def test_seed_command_creates_expected_core_objects(self): diff --git a/app/scouting/views.py b/app/scouting/views.py index 8edb4b6..7479f4a 100644 --- a/app/scouting/views.py +++ b/app/scouting/views.py @@ -1,11 +1,85 @@ from __future__ import annotations +from decimal import Decimal + +from django.core.paginator import Paginator from django.db.models import Exists, OuterRef, Prefetch from django.shortcuts import get_object_or_404, render from .forms import PlayerSearchForm from .models import Player, PlayerSeason +PAGE_SIZE = 20 +PLAYER_SORTS = { + "name_asc", + "name_desc", + "age_youngest", + "height_desc", + "weight_desc", +} +CONTEXT_SORTS = { + "points_desc": "points", + "assists_desc": "assists", + "ts_pct_desc": "ts_pct", + "blocks_desc": "blocks", +} + + +def sort_players(players, sort_key: str, context_filters_used: bool): + if sort_key not in PLAYER_SORTS | set(CONTEXT_SORTS): + sort_key = "name_asc" + + if sort_key == "name_asc": + players.sort(key=lambda player: player.full_name.casefold()) + return sort_key + if sort_key == "name_desc": + players.sort(key=lambda player: player.full_name.casefold(), reverse=True) + return sort_key + if sort_key == "age_youngest": + players.sort( + key=lambda player: ( + player.birth_date is None, + -(player.birth_date.toordinal()) if player.birth_date else 0, + player.full_name.casefold(), + ) + ) + return sort_key + if sort_key == "height_desc": + players.sort( + key=lambda player: ( + player.height_cm is None, + -(player.height_cm or Decimal("0")), + player.full_name.casefold(), + ) + ) + return sort_key + if sort_key == "weight_desc": + players.sort( + key=lambda player: ( + player.weight_kg is None, + -(player.weight_kg or Decimal("0")), + player.full_name.casefold(), + ) + ) + return sort_key + + if not context_filters_used: + players.sort(key=lambda player: player.full_name.casefold()) + return "name_asc" + + stat_name = CONTEXT_SORTS[sort_key] + players.sort( + key=lambda player: ( + getattr(getattr(getattr(player, "matching_context", None), "stats", None), stat_name, None) is None, + -( + getattr(getattr(getattr(player, "matching_context", None), "stats", None), stat_name) + or Decimal("0") + ), + player.full_name.casefold(), + ) + ) + return sort_key + def player_list(request): form = PlayerSearchForm(request.GET or None) @@ -15,9 +89,11 @@ def player_list(request): .order_by("full_name") ) context_filters_used = False + requested_sort = request.GET.get("sort") or "name_asc" if form.is_valid(): data = form.cleaned_data + requested_sort = data["sort"] or "name_asc" if data["name"]: queryset = queryset.filter(full_name__icontains=data["name"]) @@ -153,12 +229,23 @@ def player_list(request): for player in players: player.matching_context = next(iter(player.matching_contexts), None) + active_sort = sort_players(players, requested_sort, context_filters_used) + paginator = Paginator(players, PAGE_SIZE) + page_obj = paginator.get_page(request.GET.get("page")) + query_without_page = request.GET.copy() + query_without_page.pop("page", None) + return render( request, "scouting/player_list.html", { "form": form, - "players": players, + "players": page_obj.object_list, + "page_obj": page_obj, + "active_sort": active_sort, + "total_results": paginator.count, + "query_without_page": query_without_page.urlencode(), + "context_sorting_enabled": context_filters_used, }, )