Merge branch 'feature/phase-2-search-sorting-pagination' into develop
This commit is contained in:
@ -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,
|
||||
|
||||
@ -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 }}&{% 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 }}&{% endif %}page={{ page_obj.next_page_number }}">Next</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user