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
+
+ {% for context in contexts %}
+ -
+ {{ context.season.name }}
+ | Team: {{ context.team.name|default:"-" }}
+ | Competition: {{ context.competition.name|default:"-" }}
+ {% if context.stats %}
+
+ PTS {{ context.stats.points|default:"-" }} |
+ AST {{ context.stats.assists|default:"-" }} |
+ STL {{ context.stats.steals|default:"-" }} |
+ TOV {{ context.stats.turnovers|default:"-" }} |
+ BLK {{ context.stats.blocks|default:"-" }}
+
+
+ eFG% {{ context.stats.efg_pct|default:"-" }} |
+ TS% {{ context.stats.ts_pct|default:"-" }} |
+ +/- {{ context.stats.plus_minus|default:"-" }} |
+ ORtg {{ context.stats.offensive_rating|default:"-" }} |
+ DRtg {{ context.stats.defensive_rating|default:"-" }}
+
+ {% else %}
+ No stats available for this context.
+ {% endif %}
+
+ {% empty %}
+ - No season contexts found.
+ {% endfor %}
+
+
+
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
+
+
+
+ Results ({{ players|length }})
+
+ {% for player in players %}
+ -
+ {{ player.full_name }}
+ ({{ player.position }})
+
+ {% empty %}
+ - No players found.
+ {% endfor %}
+
+
+
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,
+ },
+ )