From 6c7d7c1af45f1a9614ecd48814d1c1882c7a2d17 Mon Sep 17 00:00:00 2001 From: bisco Date: Mon, 6 Apr 2026 19:44:35 +0200 Subject: [PATCH] feat: add scouting search MVP list and player detail --- app/hoopscout/urls.py | 3 +- app/scouting/forms.py | 65 +++++++ .../templates/scouting/player_detail.html | 67 +++++++ .../templates/scouting/player_list.html | 61 +++++++ app/scouting/tests.py | 169 ++++++++++++++++++ app/scouting/urls.py | 10 ++ app/scouting/views.py | 148 +++++++++++++++ 7 files changed, 522 insertions(+), 1 deletion(-) create mode 100644 app/scouting/forms.py create mode 100644 app/scouting/templates/scouting/player_detail.html create mode 100644 app/scouting/templates/scouting/player_list.html create mode 100644 app/scouting/tests.py create mode 100644 app/scouting/urls.py create mode 100644 app/scouting/views.py diff --git a/app/hoopscout/urls.py b/app/hoopscout/urls.py index 083932c..4ded0db 100644 --- a/app/hoopscout/urls.py +++ b/app/hoopscout/urls.py @@ -1,6 +1,7 @@ from django.contrib import admin -from django.urls import path +from django.urls import include, path urlpatterns = [ path("admin/", admin.site.urls), + path("", include("scouting.urls")), ] diff --git a/app/scouting/forms.py b/app/scouting/forms.py new file mode 100644 index 0000000..a91ad3e --- /dev/null +++ b/app/scouting/forms.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from datetime import date + +from django import forms + +from .models import Competition, Role, Season, Specialty, Team + + +class PlayerSearchForm(forms.Form): + name = forms.CharField(required=False, label="Name") + + position = forms.ChoiceField( + required=False, + choices=[("", "Any")] + [ + ("PG", "PG"), + ("SG", "SG"), + ("SF", "SF"), + ("PF", "PF"), + ("C", "C"), + ], + ) + role = forms.ModelChoiceField(required=False, queryset=Role.objects.none()) + specialty = forms.ModelChoiceField(required=False, queryset=Specialty.objects.none()) + + min_age = forms.IntegerField(required=False, min_value=0) + max_age = forms.IntegerField(required=False, min_value=0) + min_height_cm = forms.DecimalField(required=False, max_digits=5, decimal_places=2) + max_height_cm = forms.DecimalField(required=False, max_digits=5, decimal_places=2) + min_weight_kg = forms.DecimalField(required=False, max_digits=5, decimal_places=2) + max_weight_kg = forms.DecimalField(required=False, max_digits=5, decimal_places=2) + + competition = forms.ModelChoiceField(required=False, queryset=Competition.objects.none()) + season = forms.ModelChoiceField(required=False, queryset=Season.objects.none()) + team = forms.ModelChoiceField(required=False, queryset=Team.objects.none()) + + min_points = forms.DecimalField(required=False, max_digits=6, decimal_places=2) + min_assists = forms.DecimalField(required=False, max_digits=6, decimal_places=2) + min_steals = forms.DecimalField(required=False, max_digits=6, decimal_places=2) + max_turnovers = forms.DecimalField(required=False, max_digits=6, decimal_places=2) + min_blocks = forms.DecimalField(required=False, max_digits=6, decimal_places=2) + + min_efg_pct = forms.DecimalField(required=False, max_digits=5, decimal_places=2) + min_ts_pct = forms.DecimalField(required=False, max_digits=5, decimal_places=2) + min_plus_minus = forms.DecimalField(required=False, max_digits=7, decimal_places=2) + min_offensive_rating = forms.DecimalField(required=False, max_digits=7, decimal_places=2) + max_defensive_rating = forms.DecimalField(required=False, max_digits=7, decimal_places=2) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["role"].queryset = Role.objects.order_by("name") + self.fields["specialty"].queryset = Specialty.objects.order_by("name") + self.fields["competition"].queryset = Competition.objects.order_by("name") + self.fields["season"].queryset = Season.objects.order_by("-start_year") + self.fields["team"].queryset = Team.objects.order_by("name") + + @staticmethod + def birth_date_upper_bound_for_age(min_age: int) -> date: + today = date.today() + return today.replace(year=today.year - min_age) + + @staticmethod + def birth_date_lower_bound_for_age(max_age: int) -> date: + today = date.today() + return today.replace(year=today.year - max_age - 1) diff --git a/app/scouting/templates/scouting/player_detail.html b/app/scouting/templates/scouting/player_detail.html new file mode 100644 index 0000000..b0d202e --- /dev/null +++ b/app/scouting/templates/scouting/player_detail.html @@ -0,0 +1,67 @@ + + + + + {{ player.full_name }} + + +

Back to search

+

{{ player.full_name }}

+ +

Position: {{ player.position }}

+

Nationality: {{ player.nationality|default:"-" }}

+

Birth date: {{ player.birth_date|default:"-" }}

+

Height (cm): {{ player.height_cm|default:"-" }}

+

Weight (kg): {{ player.weight_kg|default:"-" }}

+

Wingspan (cm): {{ player.wingspan_cm|default:"-" }}

+ +

+ Roles: + {% for role in player.roles.all %} + {{ role.name }}{% if not forloop.last %}, {% endif %} + {% empty %} + - + {% endfor %} +

+ +

+ Specialties: + {% for specialty in player.specialties.all %} + {{ specialty.name }}{% if not forloop.last %}, {% endif %} + {% empty %} + - + {% endfor %} +

+ +

Season Contexts

+ + + diff --git a/app/scouting/templates/scouting/player_list.html b/app/scouting/templates/scouting/player_list.html new file mode 100644 index 0000000..9ca2826 --- /dev/null +++ b/app/scouting/templates/scouting/player_list.html @@ -0,0 +1,61 @@ + + + + + Player Search + + +

Scout Search

+ +
+
+ Player Filters + {{ 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 }} +
+ +
+ Context Filters + {{ form.competition.label_tag }} {{ form.competition }} + {{ form.season.label_tag }} {{ form.season }} + {{ form.team.label_tag }} {{ form.team }} +
+ +
+ Stats Filters + {{ form.min_points.label_tag }} {{ form.min_points }} + {{ form.min_assists.label_tag }} {{ form.min_assists }} + {{ form.min_steals.label_tag }} {{ form.min_steals }} + {{ form.max_turnovers.label_tag }} {{ form.max_turnovers }} + {{ form.min_blocks.label_tag }} {{ form.min_blocks }} + {{ form.min_efg_pct.label_tag }} {{ form.min_efg_pct }} + {{ form.min_ts_pct.label_tag }} {{ form.min_ts_pct }} + {{ form.min_plus_minus.label_tag }} {{ form.min_plus_minus }} + {{ form.min_offensive_rating.label_tag }} {{ form.min_offensive_rating }} + {{ form.max_defensive_rating.label_tag }} {{ form.max_defensive_rating }} +
+ + +
+ +

Results ({{ players|length }})

+ + + diff --git a/app/scouting/tests.py b/app/scouting/tests.py new file mode 100644 index 0000000..5b4c6bc --- /dev/null +++ b/app/scouting/tests.py @@ -0,0 +1,169 @@ +from datetime import date +from decimal import Decimal + +from django.test import TestCase +from django.urls import reverse + +from .models import Competition, Player, PlayerSeason, PlayerSeasonStats, Role, Season, Specialty, Team + + +class ScoutingSearchViewsTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.role_playmaker = Role.objects.create(name="playmaker", slug="playmaker") + cls.role_3d = Role.objects.create(name="3-and-D", slug="3-and-d") + cls.specialty_defense = Specialty.objects.create(name="defense", slug="defense") + cls.specialty_offball = Specialty.objects.create(name="off ball", slug="off-ball") + + cls.comp_a = Competition.objects.create(name="League A") + cls.comp_b = Competition.objects.create(name="League B") + cls.team_a = Team.objects.create(name="Team A", country="IT") + cls.team_b = Team.objects.create(name="Team B", country="IT") + cls.season_2025 = Season.objects.create(name="2025-2026", start_year=2025, end_year=2026) + cls.season_2024 = Season.objects.create(name="2024-2025", start_year=2024, end_year=2025) + + cls.player_pg = Player.objects.create( + full_name="Marco Guard", + birth_date=date(2002, 1, 1), + position="PG", + height_cm=Decimal("188.00"), + weight_kg=Decimal("82.00"), + nationality="IT", + ) + cls.player_pg.roles.add(cls.role_playmaker) + cls.player_pg.specialties.add(cls.specialty_defense) + + cls.player_wing = Player.objects.create( + full_name="Luca Wing", + birth_date=date(1998, 2, 2), + position="SF", + height_cm=Decimal("201.00"), + weight_kg=Decimal("95.00"), + ) + cls.player_wing.roles.add(cls.role_3d) + cls.player_wing.specialties.add(cls.specialty_offball) + + cls.ctx_pg_good = PlayerSeason.objects.create( + player=cls.player_pg, + season=cls.season_2025, + team=cls.team_a, + competition=cls.comp_a, + ) + PlayerSeasonStats.objects.create( + player_season=cls.ctx_pg_good, + points=Decimal("16.00"), + assists=Decimal("7.50"), + steals=Decimal("1.80"), + turnovers=Decimal("2.10"), + blocks=Decimal("0.30"), + efg_pct=Decimal("53.20"), + ts_pct=Decimal("58.10"), + plus_minus=Decimal("4.20"), + offensive_rating=Decimal("112.00"), + defensive_rating=Decimal("104.00"), + ) + + cls.ctx_pg_other = PlayerSeason.objects.create( + player=cls.player_pg, + season=cls.season_2024, + team=cls.team_b, + competition=cls.comp_b, + ) + PlayerSeasonStats.objects.create( + player_season=cls.ctx_pg_other, + points=Decimal("10.00"), + assists=Decimal("4.00"), + steals=Decimal("1.00"), + turnovers=Decimal("3.50"), + blocks=Decimal("0.20"), + efg_pct=Decimal("48.00"), + ts_pct=Decimal("50.00"), + plus_minus=Decimal("-2.00"), + offensive_rating=Decimal("101.00"), + defensive_rating=Decimal("112.00"), + ) + + cls.ctx_wing = PlayerSeason.objects.create( + player=cls.player_wing, + season=cls.season_2025, + team=cls.team_b, + competition=cls.comp_a, + ) + PlayerSeasonStats.objects.create( + player_season=cls.ctx_wing, + points=Decimal("14.00"), + assists=Decimal("2.00"), + steals=Decimal("1.20"), + turnovers=Decimal("1.90"), + blocks=Decimal("0.70"), + efg_pct=Decimal("55.00"), + ts_pct=Decimal("60.00"), + plus_minus=Decimal("1.50"), + offensive_rating=Decimal("108.00"), + defensive_rating=Decimal("106.00"), + ) + + def test_player_list_page_loads(self): + response = self.client.get(reverse("scouting:player_list")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Scout Search") + + def test_player_detail_page_loads(self): + response = self.client.get(reverse("scouting:player_detail", args=[self.player_pg.id])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.player_pg.full_name) + + def test_filter_by_player_level_fields(self): + response = self.client.get( + reverse("scouting:player_list"), + {"position": "PG", "role": self.role_playmaker.id, "specialty": self.specialty_defense.id}, + ) + self.assertContains(response, self.player_pg.full_name) + self.assertNotContains(response, self.player_wing.full_name) + + def test_filter_by_context_fields_and_stats(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", + }, + ) + self.assertContains(response, self.player_pg.full_name) + self.assertNotContains(response, self.player_wing.full_name) + + def test_no_false_positive_from_different_context_rows(self): + response = self.client.get( + reverse("scouting:player_list"), + { + "team": self.team_b.id, + "season": self.season_2024.id, + "min_ts_pct": "57", + }, + ) + self.assertNotContains(response, self.player_pg.full_name) + + def test_combined_pg_under_age_with_assists(self): + response = self.client.get( + reverse("scouting:player_list"), + { + "position": "PG", + "max_age": "25", + "min_assists": "7", + }, + ) + self.assertContains(response, self.player_pg.full_name) + self.assertNotContains(response, self.player_wing.full_name) + + def test_combined_team_and_defensive_rating_quality_filter(self): + response = self.client.get( + reverse("scouting:player_list"), + { + "team": self.team_a.id, + "max_defensive_rating": "105", + }, + ) + self.assertContains(response, self.player_pg.full_name) + self.assertNotContains(response, self.player_wing.full_name) diff --git a/app/scouting/urls.py b/app/scouting/urls.py new file mode 100644 index 0000000..5c7e432 --- /dev/null +++ b/app/scouting/urls.py @@ -0,0 +1,10 @@ +from django.urls import path + +from . import views + +app_name = "scouting" + +urlpatterns = [ + path("players/", views.player_list, name="player_list"), + path("players//", views.player_detail, name="player_detail"), +] diff --git a/app/scouting/views.py b/app/scouting/views.py new file mode 100644 index 0000000..43c5d5a --- /dev/null +++ b/app/scouting/views.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +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 + + +def player_list(request): + form = PlayerSearchForm(request.GET or None) + queryset = ( + Player.objects.all() + .prefetch_related("roles", "specialties") + .order_by("full_name") + ) + + if form.is_valid(): + data = form.cleaned_data + + if data["name"]: + queryset = queryset.filter(full_name__icontains=data["name"]) + if data["position"]: + queryset = queryset.filter(position=data["position"]) + if data["role"]: + queryset = queryset.filter(roles=data["role"]) + if data["specialty"]: + queryset = queryset.filter(specialties=data["specialty"]) + + if data["min_height_cm"] is not None: + queryset = queryset.filter(height_cm__gte=data["min_height_cm"]) + if data["max_height_cm"] is not None: + queryset = queryset.filter(height_cm__lte=data["max_height_cm"]) + if data["min_weight_kg"] is not None: + queryset = queryset.filter(weight_kg__gte=data["min_weight_kg"]) + if data["max_weight_kg"] is not None: + queryset = queryset.filter(weight_kg__lte=data["max_weight_kg"]) + + if data["min_age"] is not None: + cutoff = form.birth_date_upper_bound_for_age(data["min_age"]) + queryset = queryset.filter(birth_date__lte=cutoff) + if data["max_age"] is not None: + cutoff = form.birth_date_lower_bound_for_age(data["max_age"]) + queryset = queryset.filter(birth_date__gte=cutoff) + + context_filters_used = any( + data[field] is not None and data[field] != "" + for field in [ + "competition", + "season", + "team", + "min_points", + "min_assists", + "min_steals", + "max_turnovers", + "min_blocks", + "min_efg_pct", + "min_ts_pct", + "min_plus_minus", + "min_offensive_rating", + "max_defensive_rating", + ] + ) + + if context_filters_used: + context_qs = PlayerSeason.objects.filter(player=OuterRef("pk")) + + if data["competition"]: + context_qs = context_qs.filter(competition=data["competition"]) + if data["season"]: + context_qs = context_qs.filter(season=data["season"]) + if data["team"]: + context_qs = context_qs.filter(team=data["team"]) + + stats_filters_used = any( + data[field] is not None + for field in [ + "min_points", + "min_assists", + "min_steals", + "max_turnovers", + "min_blocks", + "min_efg_pct", + "min_ts_pct", + "min_plus_minus", + "min_offensive_rating", + "max_defensive_rating", + ] + ) + if stats_filters_used: + context_qs = context_qs.filter(stats__isnull=False) + + if data["min_points"] is not None: + context_qs = context_qs.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"]) + if data["min_steals"] is not None: + context_qs = context_qs.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"]) + if data["min_blocks"] is not None: + context_qs = context_qs.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"]) + if data["min_ts_pct"] is not None: + context_qs = context_qs.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"]) + if data["min_offensive_rating"] is not None: + context_qs = context_qs.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"]) + + queryset = queryset.annotate(has_matching_context=Exists(context_qs)).filter(has_matching_context=True) + + queryset = queryset.distinct() + + return render( + request, + "scouting/player_list.html", + { + "form": form, + "players": queryset, + }, + ) + + +def player_detail(request, player_id: int): + player = get_object_or_404( + Player.objects.prefetch_related("roles", "specialties"), + pk=player_id, + ) + + contexts = ( + PlayerSeason.objects.filter(player=player) + .select_related("season", "team", "competition") + .prefetch_related(Prefetch("stats")) + .order_by("-season__start_year", "team__name", "competition__name") + ) + + return render( + request, + "scouting/player_detail.html", + { + "player": player, + "contexts": contexts, + }, + )