feat(scouting): add wingspan filters and saved searches mvp

This commit is contained in:
bisco
2026-04-11 00:07:55 +02:00
parent e44cad6167
commit c09aad2d63
8 changed files with 412 additions and 9 deletions

View File

@ -42,6 +42,8 @@ class PlayerSearchForm(forms.Form):
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)
min_wingspan_cm = forms.DecimalField(required=False, max_digits=5, decimal_places=2)
max_wingspan_cm = 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())
@ -76,3 +78,7 @@ class PlayerSearchForm(forms.Form):
def birth_date_lower_bound_for_age(max_age: int) -> date:
today = date.today()
return today.replace(year=today.year - max_age - 1)
class SavedSearchForm(forms.Form):
saved_search_name = forms.CharField(required=True, max_length=120, label="Save current search as")

View File

@ -0,0 +1,31 @@
# Generated by Django 5.2.2 on 2026-04-10 22:04
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('scouting', '0007_user_scoped_favorites_and_notes'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='SavedSearch',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=120)),
('params', models.JSONField(default=dict)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='saved_searches', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['name', '-updated_at', '-id'],
'constraints': [models.UniqueConstraint(fields=('user', 'name'), name='uniq_saved_search_name_per_user')],
},
),
]

View File

@ -207,3 +207,24 @@ class PlayerNote(models.Model):
def __str__(self) -> str:
return f"Note by {self.user} for {self.player.full_name}"
class SavedSearch(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="saved_searches",
)
name = models.CharField(max_length=120)
params = models.JSONField(default=dict)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["name", "-updated_at", "-id"]
constraints = [
models.UniqueConstraint(fields=["user", "name"], name="uniq_saved_search_name_per_user"),
]
def __str__(self) -> str:
return f"{self.user} - {self.name}"

View File

@ -19,6 +19,41 @@
{% endif %}
</p>
{% if request.user.is_authenticated %}
<section>
<h2>Saved Searches</h2>
<form method="post" action="{% url 'scouting:save_search' %}">
{% csrf_token %}
{{ saved_search_form.saved_search_name.label_tag }} {{ saved_search_form.saved_search_name }}
{% for key, value in current_search_params.items %}
<input type="hidden" name="{{ key }}" value="{{ value }}">
{% endfor %}
<button type="submit">Save current search</button>
</form>
<ul>
{% for saved_search in saved_searches %}
<li>
<a href="{% url 'scouting:player_list' %}{% if saved_search.querystring %}?{{ saved_search.querystring }}{% endif %}">{{ saved_search.name }}</a>
{% if saved_search.is_active %}
<strong>(active)</strong>
{% endif %}
<form method="post" action="{% url 'scouting:delete_saved_search' saved_search.id %}" style="display:inline;">
{% csrf_token %}
{% for key, value in current_search_params.items %}
<input type="hidden" name="{{ key }}" value="{{ value }}">
{% endfor %}
<button type="submit">Delete</button>
</form>
</li>
{% empty %}
<li>No saved searches yet.</li>
{% endfor %}
</ul>
</section>
{% else %}
<p><a href="{% url 'login' %}?next={{ request.get_full_path|urlencode }}">Log in to save searches</a></p>
{% endif %}
<form method="get">
<fieldset>
<legend>Result Controls</legend>
@ -41,6 +76,8 @@
{{ 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 }}
{{ form.min_wingspan_cm.label_tag }} {{ form.min_wingspan_cm }}
{{ form.max_wingspan_cm.label_tag }} {{ form.max_wingspan_cm }}
{{ form.position.label_tag }} {{ form.position }}
{{ form.role.label_tag }} {{ form.role }}
{{ form.specialty.label_tag }} {{ form.specialty }}
@ -68,9 +105,19 @@
</fieldset>
<button type="submit">Search</button>
<a href="{% url 'scouting:player_list' %}">Clear filters</a>
</form>
<h2>Results ({{ total_results }})</h2>
<p>Sort: {{ form.sort.value|default:"name_asc" }} | Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</p>
{% if active_filters %}
<p>Active filters:</p>
<ul>
{% for filter in active_filters %}
<li>{{ filter.label }}: {{ filter.value }}</li>
{% endfor %}
</ul>
{% endif %}
<ul>
{% for player in players %}
<li>
@ -113,7 +160,11 @@
{% endif %}
</li>
{% empty %}
<li>No players found.</li>
{% if has_submitted_search %}
<li>No players found for the current search filters.</li>
{% else %}
<li>No players available yet.</li>
{% endif %}
{% endfor %}
</ul>

View File

@ -1,5 +1,6 @@
from datetime import date
from decimal import Decimal
from urllib.parse import urlencode
from django.contrib.auth import get_user_model
from django.core.management import call_command
@ -8,7 +9,19 @@ from django.db.migrations.executor import MigrationExecutor
from django.test import TestCase, TransactionTestCase
from django.urls import reverse
from .models import Competition, FavoritePlayer, Player, PlayerNote, PlayerSeason, PlayerSeasonStats, Role, Season, Specialty, Team
from .models import (
Competition,
FavoritePlayer,
Player,
PlayerNote,
PlayerSeason,
PlayerSeasonStats,
Role,
SavedSearch,
Season,
Specialty,
Team,
)
User = get_user_model()
@ -34,6 +47,7 @@ class ScoutingSearchViewsTests(TestCase):
position="PG",
height_cm=Decimal("188.00"),
weight_kg=Decimal("82.00"),
wingspan_cm=Decimal("194.00"),
nationality="IT",
)
cls.player_pg.roles.add(cls.role_playmaker)
@ -45,6 +59,7 @@ class ScoutingSearchViewsTests(TestCase):
position="SF",
height_cm=Decimal("201.00"),
weight_kg=Decimal("95.00"),
wingspan_cm=Decimal("211.00"),
)
cls.player_wing.roles.add(cls.role_3d)
cls.player_wing.specialties.add(cls.specialty_offball)
@ -127,6 +142,15 @@ class ScoutingSearchViewsTests(TestCase):
self.assertContains(response, self.player_pg.full_name)
self.assertNotContains(response, self.player_wing.full_name)
def test_filter_by_wingspan_thresholds(self):
response = self.client.get(
reverse("scouting:player_list"),
{"min_wingspan_cm": "205"},
)
self.assertContains(response, self.player_wing.full_name)
self.assertNotContains(response, self.player_pg.full_name)
def test_filter_by_context_fields_and_stats(self):
response = self.client.get(
reverse("scouting:player_list"),
@ -514,6 +538,114 @@ class FavoritePlayerViewsTests(TestCase):
self.assertRedirects(response, f"{reverse('login')}?next={reverse('scouting:favorites_list')}")
class SavedSearchViewsTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(username="saved_owner", password="pass12345")
cls.other_user = User.objects.create_user(username="saved_other", password="pass12345")
cls.player_guard = Player.objects.create(
full_name="Saved Guard",
birth_date=date(2002, 7, 7),
position="PG",
height_cm=Decimal("188.00"),
weight_kg=Decimal("82.00"),
wingspan_cm=Decimal("196.00"),
)
cls.player_wing = Player.objects.create(
full_name="Saved Wing",
birth_date=date(2000, 9, 9),
position="SF",
height_cm=Decimal("203.00"),
weight_kg=Decimal("95.00"),
wingspan_cm=Decimal("213.00"),
)
def test_logged_in_user_can_save_search(self):
self.client.force_login(self.user)
response = self.client.post(
reverse("scouting:save_search"),
{
"saved_search_name": "Long Wings",
"position": "SF",
"min_wingspan_cm": "210",
"sort": "height_desc",
},
)
self.assertEqual(response.status_code, 302)
self.assertIn(f"{reverse('scouting:player_list')}?", response["Location"])
self.assertIn("sort=height_desc", response["Location"])
self.assertIn("position=SF", response["Location"])
self.assertIn("min_wingspan_cm=210", response["Location"])
saved = SavedSearch.objects.get(user=self.user, name="Long Wings")
self.assertEqual(saved.params["position"], "SF")
self.assertEqual(saved.params["min_wingspan_cm"], "210")
def test_saved_searches_are_user_scoped(self):
SavedSearch.objects.create(user=self.user, name="Mine", params={"position": "PG"})
SavedSearch.objects.create(user=self.other_user, name="Other", params={"position": "SF"})
self.client.force_login(self.user)
response = self.client.get(reverse("scouting:player_list"))
self.assertContains(response, "Mine")
self.assertNotContains(response, "Other")
def test_logged_in_user_can_rerun_saved_search(self):
saved = SavedSearch.objects.create(user=self.user, name="Wingspan Hunt", params={"min_wingspan_cm": "210"})
self.client.force_login(self.user)
response = self.client.get(f"{reverse('scouting:player_list')}?{urlencode(saved.params)}")
self.assertEqual(response.status_code, 200)
self.assertContains(response, self.player_wing.full_name)
self.assertNotContains(response, self.player_guard.full_name)
def test_logged_in_user_can_delete_saved_search(self):
saved = SavedSearch.objects.create(user=self.user, name="Delete Me", params={"position": "PG"})
self.client.force_login(self.user)
response = self.client.post(
reverse("scouting:delete_saved_search", args=[saved.id]),
{"position": "PG"},
)
self.assertRedirects(response, f"{reverse('scouting:player_list')}?position=PG")
self.assertFalse(SavedSearch.objects.filter(pk=saved.id).exists())
def test_unauthenticated_user_cannot_save_or_delete_saved_searches(self):
save_response = self.client.post(reverse("scouting:save_search"), {"saved_search_name": "No Auth"})
self.assertRedirects(save_response, f"{reverse('login')}?next={reverse('scouting:save_search')}")
saved = SavedSearch.objects.create(user=self.user, name="Auth Required", params={"position": "PG"})
delete_response = self.client.post(reverse("scouting:delete_saved_search", args=[saved.id]))
self.assertRedirects(
delete_response,
f"{reverse('login')}?next={reverse('scouting:delete_saved_search', args=[saved.id])}",
)
def test_combined_filters_saved_search_and_rerun(self):
self.client.force_login(self.user)
save_response = self.client.post(
reverse("scouting:save_search"),
{
"saved_search_name": "SF Long Wings",
"position": "SF",
"min_wingspan_cm": "212",
"sort": "height_desc",
},
)
self.assertEqual(save_response.status_code, 302)
saved = SavedSearch.objects.get(user=self.user, name="SF Long Wings")
rerun_response = self.client.get(f"{reverse('scouting:player_list')}?position=SF&min_wingspan_cm=212&sort=height_desc")
self.assertEqual(rerun_response.status_code, 200)
self.assertContains(rerun_response, self.player_wing.full_name)
self.assertNotContains(rerun_response, self.player_guard.full_name)
self.assertEqual(saved.params["sort"], "height_desc")
class PlayerNoteViewsTests(TestCase):
@classmethod
def setUpTestData(cls):

View File

@ -6,6 +6,8 @@ app_name = "scouting"
urlpatterns = [
path("players/", views.player_list, name="player_list"),
path("players/saved-searches/save/", views.save_search, name="save_search"),
path("players/saved-searches/<int:saved_search_id>/delete/", views.delete_saved_search, name="delete_saved_search"),
path("players/<int:player_id>/", views.player_detail, name="player_detail"),
path("players/<int:player_id>/favorite/", views.add_favorite, name="add_favorite"),
path("players/<int:player_id>/unfavorite/", views.remove_favorite, name="remove_favorite"),

View File

@ -1,6 +1,7 @@
from __future__ import annotations
from decimal import Decimal
from urllib.parse import urlencode
from django.core.paginator import Paginator
from django.contrib.auth.decorators import login_required
@ -10,8 +11,8 @@ from django.shortcuts import get_object_or_404, render
from django.urls import reverse
from django.views.decorators.http import require_POST
from .forms import PlayerSearchForm
from .models import FavoritePlayer, Player, PlayerNote, PlayerSeason
from .forms import PlayerSearchForm, SavedSearchForm
from .models import FavoritePlayer, Player, PlayerNote, PlayerSeason, SavedSearch
PAGE_SIZE = 20
PLAYER_SORTS = {
@ -27,6 +28,100 @@ CONTEXT_SORTS = {
"ts_pct_desc": "ts_pct",
"blocks_desc": "blocks",
}
SEARCH_PARAM_FIELDS = [
"name",
"sort",
"position",
"role",
"specialty",
"min_age",
"max_age",
"min_height_cm",
"max_height_cm",
"min_weight_kg",
"max_weight_kg",
"min_wingspan_cm",
"max_wingspan_cm",
"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",
]
FILTER_LABELS = {
"name": "Name",
"position": "Position",
"role": "Role",
"specialty": "Specialty",
"min_age": "Min age",
"max_age": "Max age",
"min_height_cm": "Min height (cm)",
"max_height_cm": "Max height (cm)",
"min_weight_kg": "Min weight (kg)",
"max_weight_kg": "Max weight (kg)",
"min_wingspan_cm": "Min wingspan (cm)",
"max_wingspan_cm": "Max wingspan (cm)",
"competition": "Competition",
"season": "Season",
"team": "Team",
"min_points": "Min points",
"min_assists": "Min assists",
"min_steals": "Min steals",
"max_turnovers": "Max turnovers",
"min_blocks": "Min blocks",
"min_efg_pct": "Min eFG%",
"min_ts_pct": "Min TS%",
"min_plus_minus": "Min +/-",
"min_offensive_rating": "Min ORtg",
"max_defensive_rating": "Max DRtg",
}
def serialize_search_params(cleaned_data: dict) -> dict[str, str]:
params = {}
for field_name in SEARCH_PARAM_FIELDS:
value = cleaned_data.get(field_name)
if value in (None, ""):
continue
if field_name == "sort" and value == "name_asc":
continue
params[field_name] = str(getattr(value, "pk", value))
return params
def read_search_params_from_payload(payload) -> dict[str, str]:
params = {}
for field_name in SEARCH_PARAM_FIELDS:
value = payload.get(field_name)
if value in (None, ""):
continue
params[field_name] = str(value)
return params
def build_active_filters(form: PlayerSearchForm, cleaned_data: dict) -> list[dict[str, str]]:
active_filters = []
for field_name, label in FILTER_LABELS.items():
value = cleaned_data.get(field_name)
if value in (None, ""):
continue
field = form.fields[field_name]
if getattr(field, "choices", None):
value_label = str(dict(field.choices).get(str(value), value))
elif hasattr(value, "name"):
value_label = value.name
else:
value_label = str(value)
active_filters.append({"label": label, "value": value_label})
return active_filters
def apply_favorite_state(players, user):
@ -103,6 +198,7 @@ def sort_players(players, sort_key: str, context_filters_used: bool):
def player_list(request):
form = PlayerSearchForm(request.GET or None)
saved_search_form = SavedSearchForm()
queryset = (
Player.objects.all()
.prefetch_related("roles", "specialties")
@ -110,10 +206,14 @@ def player_list(request):
)
context_filters_used = False
requested_sort = request.GET.get("sort") or "name_asc"
current_search_params = read_search_params_from_payload(request.GET)
active_filters = []
if form.is_valid():
data = form.cleaned_data
requested_sort = data["sort"] or "name_asc"
current_search_params = serialize_search_params(data)
active_filters = build_active_filters(form, data)
if data["name"]:
queryset = queryset.filter(full_name__icontains=data["name"])
@ -132,6 +232,10 @@ def player_list(request):
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_wingspan_cm"] is not None:
queryset = queryset.filter(wingspan_cm__gte=data["min_wingspan_cm"])
if data["max_wingspan_cm"] is not None:
queryset = queryset.filter(wingspan_cm__lte=data["max_wingspan_cm"])
if data["min_age"] is not None:
cutoff = form.birth_date_upper_bound_for_age(data["min_age"])
@ -253,8 +357,19 @@ def player_list(request):
paginator = Paginator(players, PAGE_SIZE)
page_obj = paginator.get_page(request.GET.get("page"))
apply_favorite_state(page_obj.object_list, request.user)
query_without_page = request.GET.copy()
query_without_page.pop("page", None)
saved_searches = []
if request.user.is_authenticated:
for saved_search in SavedSearch.objects.filter(user=request.user):
querystring = urlencode(saved_search.params)
saved_searches.append(
{
"id": saved_search.id,
"name": saved_search.name,
"querystring": querystring,
"is_active": saved_search.params == current_search_params,
}
)
return render(
request,
@ -265,8 +380,13 @@ def player_list(request):
"page_obj": page_obj,
"active_sort": active_sort,
"total_results": paginator.count,
"query_without_page": query_without_page.urlencode(),
"query_without_page": urlencode(current_search_params),
"current_search_params": current_search_params,
"has_submitted_search": bool(current_search_params),
"active_filters": active_filters,
"context_sorting_enabled": context_filters_used,
"saved_search_form": saved_search_form,
"saved_searches": saved_searches,
},
)
@ -355,3 +475,38 @@ def favorites_list(request):
"favorites": favorites,
},
)
@login_required
@require_POST
def save_search(request):
name_form = SavedSearchForm(request.POST)
player_search_form = PlayerSearchForm(request.POST)
if not name_form.is_valid():
return HttpResponseRedirect(reverse("scouting:player_list"))
if player_search_form.is_valid():
params = serialize_search_params(player_search_form.cleaned_data)
else:
params = read_search_params_from_payload(request.POST)
SavedSearch.objects.update_or_create(
user=request.user,
name=name_form.cleaned_data["saved_search_name"].strip(),
defaults={"params": params},
)
target = reverse("scouting:player_list")
if not params:
return HttpResponseRedirect(target)
return HttpResponseRedirect(f"{target}?{urlencode(params)}")
@login_required
@require_POST
def delete_saved_search(request, saved_search_id: int):
SavedSearch.objects.filter(user=request.user, pk=saved_search_id).delete()
query = read_search_params_from_payload(request.POST)
target = reverse("scouting:player_list")
if not query:
return HttpResponseRedirect(target)
return HttpResponseRedirect(f"{target}?{urlencode(query)}")