Merge branch 'feature/phase-2-search-sorting-pagination' into develop

This commit is contained in:
bisco
2026-04-07 17:18:54 +02:00
4 changed files with 270 additions and 5 deletions

View File

@ -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,

View File

@ -8,18 +8,30 @@
<h1>Scout Search</h1>
<form method="get">
<fieldset>
<legend>Result Controls</legend>
{{ form.sort.label_tag }} {{ form.sort }}
<p>
Context stat sorts use the matching season context selected by the current
season/team/competition/stat filters.
</p>
{% if not context_sorting_enabled %}
<p>Context stat sorting becomes active once context or stat filters are applied.</p>
{% endif %}
</fieldset>
<fieldset>
<legend>Player Filters</legend>
{{ 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 }}
</fieldset>
<fieldset>
@ -46,7 +58,7 @@
<button type="submit">Search</button>
</form>
<h2>Results ({{ players|length }})</h2>
<h2>Results ({{ total_results }})</h2>
<ul>
{% for player in players %}
<li>
@ -74,5 +86,19 @@
<li>No players found.</li>
{% endfor %}
</ul>
{% if page_obj.paginator.num_pages > 1 %}
<nav aria-label="Pagination">
{% if page_obj.has_previous %}
<a href="?{% if query_without_page %}{{ query_without_page }}&amp;{% endif %}page={{ page_obj.previous_page_number }}">Previous</a>
{% endif %}
<span>Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span>
{% if page_obj.has_next %}
<a href="?{% if query_without_page %}{{ query_without_page }}&amp;{% endif %}page={{ page_obj.next_page_number }}">Next</a>
{% endif %}
</nav>
{% endif %}
</body>
</html>

View File

@ -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):

View File

@ -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,
},
)