diff --git a/app/scouting/templates/scouting/player_list.html b/app/scouting/templates/scouting/player_list.html index 9ca2826..614d7ec 100644 --- a/app/scouting/templates/scouting/player_list.html +++ b/app/scouting/templates/scouting/player_list.html @@ -52,6 +52,23 @@
  • {{ player.full_name }} ({{ player.position }}) + {% if player.matching_context %} +
    + Match context: + {{ player.matching_context.season.name }} + | Team: {{ player.matching_context.team.name|default:"-" }} + | Competition: {{ player.matching_context.competition.name|default:"-" }} +
    + {% if player.matching_context.stats %} +
    + 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:"-" }} +
    + {% endif %} + {% endif %}
  • {% empty %}
  • No players found.
  • diff --git a/app/scouting/tests.py b/app/scouting/tests.py index 5b4c6bc..f6f9773 100644 --- a/app/scouting/tests.py +++ b/app/scouting/tests.py @@ -134,6 +134,37 @@ class ScoutingSearchViewsTests(TestCase): self.assertContains(response, self.player_pg.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): response = self.client.get( reverse("scouting:player_list"), @@ -157,6 +188,44 @@ class ScoutingSearchViewsTests(TestCase): self.assertContains(response, self.player_pg.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): response = self.client.get( reverse("scouting:player_list"), diff --git a/app/scouting/views.py b/app/scouting/views.py index 43c5d5a..8edb4b6 100644 --- a/app/scouting/views.py +++ b/app/scouting/views.py @@ -14,6 +14,7 @@ def player_list(request): .prefetch_related("roles", "specialties") .order_by("full_name") ) + context_filters_used = False if form.is_valid(): data = form.cleaned_data @@ -64,13 +65,17 @@ def player_list(request): if context_filters_used: context_qs = PlayerSeason.objects.filter(player=OuterRef("pk")) + matching_contexts = PlayerSeason.objects.all() if data["competition"]: context_qs = context_qs.filter(competition=data["competition"]) + matching_contexts = matching_contexts.filter(competition=data["competition"]) if data["season"]: context_qs = context_qs.filter(season=data["season"]) + matching_contexts = matching_contexts.filter(season=data["season"]) if data["team"]: context_qs = context_qs.filter(team=data["team"]) + matching_contexts = matching_contexts.filter(team=data["team"]) stats_filters_used = any( data[field] is not None @@ -89,38 +94,71 @@ def player_list(request): ) if stats_filters_used: context_qs = context_qs.filter(stats__isnull=False) + matching_contexts = matching_contexts.filter(stats__isnull=False) if data["min_points"] is not None: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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) + # 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() + players = list(queryset) + + if context_filters_used: + for player in players: + player.matching_context = next(iter(player.matching_contexts), None) return render( request, "scouting/player_list.html", { "form": form, - "players": queryset, + "players": players, }, ) @@ -133,8 +171,7 @@ def player_detail(request, player_id: int): contexts = ( PlayerSeason.objects.filter(player=player) - .select_related("season", "team", "competition") - .prefetch_related(Prefetch("stats")) + .select_related("season", "team", "competition", "stats") .order_by("-season__start_year", "team__name", "competition__name") )