Compare commits

...

2 Commits

3 changed files with 126 additions and 3 deletions

View File

@ -52,6 +52,23 @@
<li> <li>
<a href="{% url 'scouting:player_detail' player.id %}">{{ player.full_name }}</a> <a href="{% url 'scouting:player_detail' player.id %}">{{ player.full_name }}</a>
({{ player.position }}) ({{ player.position }})
{% if player.matching_context %}
<div>
Match context:
{{ player.matching_context.season.name }}
| Team: {{ player.matching_context.team.name|default:"-" }}
| Competition: {{ player.matching_context.competition.name|default:"-" }}
</div>
{% if player.matching_context.stats %}
<div>
PTS {{ player.matching_context.stats.points|default:"-" }} |
AST {{ player.matching_context.stats.assists|default:"-" }} |
STL {{ player.matching_context.stats.steals|default:"-" }} |
TOV {{ player.matching_context.stats.turnovers|default:"-" }} |
BLK {{ player.matching_context.stats.blocks|default:"-" }}
</div>
{% endif %}
{% endif %}
</li> </li>
{% empty %} {% empty %}
<li>No players found.</li> <li>No players found.</li>

View File

@ -134,6 +134,37 @@ 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_result_row_shows_matching_context_for_filtered_search(self):
response = self.client.get(
reverse("scouting:player_list"),
{
"competition": self.comp_a.id,
"season": self.season_2025.id,
"team": self.team_a.id,
"min_ts_pct": "57",
},
)
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, "Match context:")
self.assertContains(response, self.season_2025.name)
self.assertContains(response, "PTS 16.00")
def test_result_row_does_not_show_non_matching_context(self):
response = self.client.get(
reverse("scouting:player_list"),
{
"competition": self.comp_a.id,
"season": self.season_2025.id,
"team": self.team_a.id,
"min_ts_pct": "57",
},
)
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.assertNotContains(response, "PTS 10.00")
self.assertNotContains(response, "AST 4.00")
def test_no_false_positive_from_different_context_rows(self): def test_no_false_positive_from_different_context_rows(self):
response = self.client.get( response = self.client.get(
reverse("scouting:player_list"), reverse("scouting:player_list"),
@ -157,6 +188,44 @@ 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_matching_context_selection_is_deterministic_when_multiple_contexts_match(self):
second_matching_context = PlayerSeason.objects.create(
player=self.player_pg,
season=self.season_2024,
team=self.team_a,
competition=self.comp_a,
)
PlayerSeasonStats.objects.create(
player_season=second_matching_context,
points=Decimal("18.00"),
assists=Decimal("6.20"),
steals=Decimal("1.50"),
turnovers=Decimal("2.00"),
blocks=Decimal("0.20"),
efg_pct=Decimal("52.00"),
ts_pct=Decimal("57.50"),
plus_minus=Decimal("3.50"),
offensive_rating=Decimal("110.00"),
defensive_rating=Decimal("105.00"),
)
response = self.client.get(
reverse("scouting:player_list"),
{
"competition": self.comp_a.id,
"team": self.team_a.id,
},
)
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, self.season_2025.name)
self.assertNotContains(response, "PTS 18.00")
def test_player_detail_page_still_loads_after_related_loading_cleanup(self):
response = self.client.get(reverse("scouting:player_detail", args=[self.player_pg.id]))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "PTS 16.00")
def test_combined_team_and_defensive_rating_quality_filter(self): def test_combined_team_and_defensive_rating_quality_filter(self):
response = self.client.get( response = self.client.get(
reverse("scouting:player_list"), reverse("scouting:player_list"),

View File

@ -14,6 +14,7 @@ def player_list(request):
.prefetch_related("roles", "specialties") .prefetch_related("roles", "specialties")
.order_by("full_name") .order_by("full_name")
) )
context_filters_used = False
if form.is_valid(): if form.is_valid():
data = form.cleaned_data data = form.cleaned_data
@ -64,13 +65,17 @@ def player_list(request):
if context_filters_used: if context_filters_used:
context_qs = PlayerSeason.objects.filter(player=OuterRef("pk")) context_qs = PlayerSeason.objects.filter(player=OuterRef("pk"))
matching_contexts = PlayerSeason.objects.all()
if data["competition"]: if data["competition"]:
context_qs = context_qs.filter(competition=data["competition"]) context_qs = context_qs.filter(competition=data["competition"])
matching_contexts = matching_contexts.filter(competition=data["competition"])
if data["season"]: if data["season"]:
context_qs = context_qs.filter(season=data["season"]) context_qs = context_qs.filter(season=data["season"])
matching_contexts = matching_contexts.filter(season=data["season"])
if data["team"]: if data["team"]:
context_qs = context_qs.filter(team=data["team"]) context_qs = context_qs.filter(team=data["team"])
matching_contexts = matching_contexts.filter(team=data["team"])
stats_filters_used = any( stats_filters_used = any(
data[field] is not None data[field] is not None
@ -89,38 +94,71 @@ def player_list(request):
) )
if stats_filters_used: if stats_filters_used:
context_qs = context_qs.filter(stats__isnull=False) context_qs = context_qs.filter(stats__isnull=False)
matching_contexts = matching_contexts.filter(stats__isnull=False)
if data["min_points"] is not None: if data["min_points"] is not None:
context_qs = context_qs.filter(stats__points__gte=data["min_points"]) context_qs = context_qs.filter(stats__points__gte=data["min_points"])
matching_contexts = matching_contexts.filter(stats__points__gte=data["min_points"])
if data["min_assists"] is not None: if data["min_assists"] is not None:
context_qs = context_qs.filter(stats__assists__gte=data["min_assists"]) context_qs = context_qs.filter(stats__assists__gte=data["min_assists"])
matching_contexts = matching_contexts.filter(stats__assists__gte=data["min_assists"])
if data["min_steals"] is not None: if data["min_steals"] is not None:
context_qs = context_qs.filter(stats__steals__gte=data["min_steals"]) context_qs = context_qs.filter(stats__steals__gte=data["min_steals"])
matching_contexts = matching_contexts.filter(stats__steals__gte=data["min_steals"])
if data["max_turnovers"] is not None: if data["max_turnovers"] is not None:
context_qs = context_qs.filter(stats__turnovers__lte=data["max_turnovers"]) context_qs = context_qs.filter(stats__turnovers__lte=data["max_turnovers"])
matching_contexts = matching_contexts.filter(stats__turnovers__lte=data["max_turnovers"])
if data["min_blocks"] is not None: if data["min_blocks"] is not None:
context_qs = context_qs.filter(stats__blocks__gte=data["min_blocks"]) context_qs = context_qs.filter(stats__blocks__gte=data["min_blocks"])
matching_contexts = matching_contexts.filter(stats__blocks__gte=data["min_blocks"])
if data["min_efg_pct"] is not None: if data["min_efg_pct"] is not None:
context_qs = context_qs.filter(stats__efg_pct__gte=data["min_efg_pct"]) context_qs = context_qs.filter(stats__efg_pct__gte=data["min_efg_pct"])
matching_contexts = matching_contexts.filter(stats__efg_pct__gte=data["min_efg_pct"])
if data["min_ts_pct"] is not None: if data["min_ts_pct"] is not None:
context_qs = context_qs.filter(stats__ts_pct__gte=data["min_ts_pct"]) context_qs = context_qs.filter(stats__ts_pct__gte=data["min_ts_pct"])
matching_contexts = matching_contexts.filter(stats__ts_pct__gte=data["min_ts_pct"])
if data["min_plus_minus"] is not None: if data["min_plus_minus"] is not None:
context_qs = context_qs.filter(stats__plus_minus__gte=data["min_plus_minus"]) context_qs = context_qs.filter(stats__plus_minus__gte=data["min_plus_minus"])
matching_contexts = matching_contexts.filter(stats__plus_minus__gte=data["min_plus_minus"])
if data["min_offensive_rating"] is not None: if data["min_offensive_rating"] is not None:
context_qs = context_qs.filter(stats__offensive_rating__gte=data["min_offensive_rating"]) context_qs = context_qs.filter(stats__offensive_rating__gte=data["min_offensive_rating"])
matching_contexts = matching_contexts.filter(
stats__offensive_rating__gte=data["min_offensive_rating"]
)
if data["max_defensive_rating"] is not None: if data["max_defensive_rating"] is not None:
context_qs = context_qs.filter(stats__defensive_rating__lte=data["max_defensive_rating"]) context_qs = context_qs.filter(stats__defensive_rating__lte=data["max_defensive_rating"])
matching_contexts = matching_contexts.filter(
stats__defensive_rating__lte=data["max_defensive_rating"]
)
queryset = queryset.annotate(has_matching_context=Exists(context_qs)).filter(has_matching_context=True) queryset = queryset.annotate(has_matching_context=Exists(context_qs)).filter(has_matching_context=True)
# Reuse the same filtered PlayerSeason scope and take the first ordered row
# so the displayed context is deterministic and tied to the actual match.
matching_contexts = (
matching_contexts.select_related("season", "team", "competition", "stats")
.order_by("-season__start_year", "team__name", "competition__name", "pk")
)
queryset = queryset.prefetch_related(
Prefetch(
"player_seasons",
queryset=matching_contexts,
to_attr="matching_contexts",
)
)
queryset = queryset.distinct() queryset = queryset.distinct()
players = list(queryset)
if context_filters_used:
for player in players:
player.matching_context = next(iter(player.matching_contexts), None)
return render( return render(
request, request,
"scouting/player_list.html", "scouting/player_list.html",
{ {
"form": form, "form": form,
"players": queryset, "players": players,
}, },
) )
@ -133,8 +171,7 @@ def player_detail(request, player_id: int):
contexts = ( contexts = (
PlayerSeason.objects.filter(player=player) PlayerSeason.objects.filter(player=player)
.select_related("season", "team", "competition") .select_related("season", "team", "competition", "stats")
.prefetch_related(Prefetch("stats"))
.order_by("-season__start_year", "team__name", "competition__name") .order_by("-season__start_year", "team__name", "competition__name")
) )