Compare commits

..

2 Commits

7 changed files with 522 additions and 1 deletions

View File

@ -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")),
]

65
app/scouting/forms.py Normal file
View File

@ -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)

View File

@ -0,0 +1,67 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{ player.full_name }}</title>
</head>
<body>
<p><a href="{% url 'scouting:player_list' %}">Back to search</a></p>
<h1>{{ player.full_name }}</h1>
<p>Position: {{ player.position }}</p>
<p>Nationality: {{ player.nationality|default:"-" }}</p>
<p>Birth date: {{ player.birth_date|default:"-" }}</p>
<p>Height (cm): {{ player.height_cm|default:"-" }}</p>
<p>Weight (kg): {{ player.weight_kg|default:"-" }}</p>
<p>Wingspan (cm): {{ player.wingspan_cm|default:"-" }}</p>
<p>
Roles:
{% for role in player.roles.all %}
{{ role.name }}{% if not forloop.last %}, {% endif %}
{% empty %}
-
{% endfor %}
</p>
<p>
Specialties:
{% for specialty in player.specialties.all %}
{{ specialty.name }}{% if not forloop.last %}, {% endif %}
{% empty %}
-
{% endfor %}
</p>
<h2>Season Contexts</h2>
<ul>
{% for context in contexts %}
<li>
<strong>{{ context.season.name }}</strong>
| Team: {{ context.team.name|default:"-" }}
| Competition: {{ context.competition.name|default:"-" }}
{% if context.stats %}
<div>
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:"-" }}
</div>
<div>
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:"-" }}
</div>
{% else %}
<div>No stats available for this context.</div>
{% endif %}
</li>
{% empty %}
<li>No season contexts found.</li>
{% endfor %}
</ul>
</body>
</html>

View File

@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Player Search</title>
</head>
<body>
<h1>Scout Search</h1>
<form method="get">
<fieldset>
<legend>Player Filters</legend>
{{ 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 }}
</fieldset>
<fieldset>
<legend>Context Filters</legend>
{{ form.competition.label_tag }} {{ form.competition }}
{{ form.season.label_tag }} {{ form.season }}
{{ form.team.label_tag }} {{ form.team }}
</fieldset>
<fieldset>
<legend>Stats Filters</legend>
{{ 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 }}
</fieldset>
<button type="submit">Search</button>
</form>
<h2>Results ({{ players|length }})</h2>
<ul>
{% for player in players %}
<li>
<a href="{% url 'scouting:player_detail' player.id %}">{{ player.full_name }}</a>
({{ player.position }})
</li>
{% empty %}
<li>No players found.</li>
{% endfor %}
</ul>
</body>
</html>

169
app/scouting/tests.py Normal file
View File

@ -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)

10
app/scouting/urls.py Normal file
View File

@ -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/<int:player_id>/", views.player_detail, name="player_detail"),
]

148
app/scouting/views.py Normal file
View File

@ -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,
},
)