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):
|
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")
|
name = forms.CharField(required=False, label="Name")
|
||||||
|
sort = forms.ChoiceField(required=False, choices=SORT_CHOICES, initial="name_asc")
|
||||||
|
|
||||||
position = forms.ChoiceField(
|
position = forms.ChoiceField(
|
||||||
required=False,
|
required=False,
|
||||||
|
|||||||
@ -8,18 +8,30 @@
|
|||||||
<h1>Scout Search</h1>
|
<h1>Scout Search</h1>
|
||||||
|
|
||||||
<form method="get">
|
<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>
|
<fieldset>
|
||||||
<legend>Player Filters</legend>
|
<legend>Player Filters</legend>
|
||||||
{{ form.name.label_tag }} {{ form.name }}
|
{{ 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.min_age.label_tag }} {{ form.min_age }}
|
||||||
{{ form.max_age.label_tag }} {{ form.max_age }}
|
{{ form.max_age.label_tag }} {{ form.max_age }}
|
||||||
{{ form.min_height_cm.label_tag }} {{ form.min_height_cm }}
|
{{ form.min_height_cm.label_tag }} {{ form.min_height_cm }}
|
||||||
{{ form.max_height_cm.label_tag }} {{ form.max_height_cm }}
|
{{ form.max_height_cm.label_tag }} {{ form.max_height_cm }}
|
||||||
{{ form.min_weight_kg.label_tag }} {{ form.min_weight_kg }}
|
{{ form.min_weight_kg.label_tag }} {{ form.min_weight_kg }}
|
||||||
{{ form.max_weight_kg.label_tag }} {{ form.max_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>
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
@ -46,7 +58,7 @@
|
|||||||
<button type="submit">Search</button>
|
<button type="submit">Search</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<h2>Results ({{ players|length }})</h2>
|
<h2>Results ({{ total_results }})</h2>
|
||||||
<ul>
|
<ul>
|
||||||
{% for player in players %}
|
{% for player in players %}
|
||||||
<li>
|
<li>
|
||||||
@ -74,5 +86,19 @@
|
|||||||
<li>No players found.</li>
|
<li>No players found.</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -238,6 +238,145 @@ class ScoutingSearchViewsTests(TestCase):
|
|||||||
self.assertContains(response, self.player_pg.full_name)
|
self.assertContains(response, self.player_pg.full_name)
|
||||||
self.assertNotContains(response, self.player_wing.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):
|
class SeedScoutingDataCommandTests(TestCase):
|
||||||
def test_seed_command_creates_expected_core_objects(self):
|
def test_seed_command_creates_expected_core_objects(self):
|
||||||
|
|||||||
@ -1,11 +1,85 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django.core.paginator import Paginator
|
||||||
from django.db.models import Exists, OuterRef, Prefetch
|
from django.db.models import Exists, OuterRef, Prefetch
|
||||||
from django.shortcuts import get_object_or_404, render
|
from django.shortcuts import get_object_or_404, render
|
||||||
|
|
||||||
from .forms import PlayerSearchForm
|
from .forms import PlayerSearchForm
|
||||||
from .models import Player, PlayerSeason
|
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):
|
def player_list(request):
|
||||||
form = PlayerSearchForm(request.GET or None)
|
form = PlayerSearchForm(request.GET or None)
|
||||||
@ -15,9 +89,11 @@ def player_list(request):
|
|||||||
.order_by("full_name")
|
.order_by("full_name")
|
||||||
)
|
)
|
||||||
context_filters_used = False
|
context_filters_used = False
|
||||||
|
requested_sort = request.GET.get("sort") or "name_asc"
|
||||||
|
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
data = form.cleaned_data
|
data = form.cleaned_data
|
||||||
|
requested_sort = data["sort"] or "name_asc"
|
||||||
|
|
||||||
if data["name"]:
|
if data["name"]:
|
||||||
queryset = queryset.filter(full_name__icontains=data["name"])
|
queryset = queryset.filter(full_name__icontains=data["name"])
|
||||||
@ -153,12 +229,23 @@ def player_list(request):
|
|||||||
for player in players:
|
for player in players:
|
||||||
player.matching_context = next(iter(player.matching_contexts), None)
|
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(
|
return render(
|
||||||
request,
|
request,
|
||||||
"scouting/player_list.html",
|
"scouting/player_list.html",
|
||||||
{
|
{
|
||||||
"form": form,
|
"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