From c09aad2d639fbaf1a6ceab8d2e9df9543b62a466 Mon Sep 17 00:00:00 2001 From: bisco Date: Sat, 11 Apr 2026 00:07:55 +0200 Subject: [PATCH] feat(scouting): add wingspan filters and saved searches mvp --- README.md | 9 +- app/scouting/forms.py | 6 + app/scouting/migrations/0008_savedsearch.py | 31 ++++ app/scouting/models.py | 21 +++ .../templates/scouting/player_list.html | 53 +++++- app/scouting/tests.py | 134 +++++++++++++- app/scouting/urls.py | 2 + app/scouting/views.py | 165 +++++++++++++++++- 8 files changed, 412 insertions(+), 9 deletions(-) create mode 100644 app/scouting/migrations/0008_savedsearch.py diff --git a/README.md b/README.md index d88a340..10802a7 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,13 @@ The current application baseline provides: - containerized local development - curated sample seed data for manual exploration - player scouting search with player, context, and stat filters +- wingspan-aware player filtering (`min_wingspan_cm` / `max_wingspan_cm`) - matching season/team/competition context on search results - result sorting and pagination - login/logout with Django built-in authentication - user-scoped shortlist favorites - user-scoped plain-text scouting notes on player detail pages +- user-scoped saved searches (save, rerun, delete) Accepted technical and product-shaping decisions live in: - `docs/ARCHITECTURE.md` @@ -51,8 +53,11 @@ Accepted technical and product-shaping decisions live in: 3. Load sample data with `docker compose --env-file .env -f infra/docker-compose.yml exec -T app python manage.py seed_scouting_data`. 4. Create a local user with `docker compose --env-file .env -f infra/docker-compose.yml exec -T app python manage.py createsuperuser` if you need a development login. 5. Visit `http://127.0.0.1:8000/players/` to explore the scouting search MVP. -6. Log in at `http://127.0.0.1:8000/accounts/login/` to manage your own shortlist and notes. -7. Use `http://127.0.0.1:8000/favorites/` to review your user-scoped shortlist. +6. Use player-level filters (including wingspan), context filters, and stat filters to run scouting searches with sorting and pagination. +7. Log in at `http://127.0.0.1:8000/accounts/login/` to manage your own shortlist, notes, and saved searches. +8. Save useful filter combinations from the player search page, rerun them later, or delete them. +9. Open player detail pages to review context rows and create user-scoped notes. +10. Use `http://127.0.0.1:8000/favorites/` to review your user-scoped shortlist. Legacy shared favorites and notes from the pre-auth MVP are cleared by the early-stage ownership migration so the app can move cleanly to user-scoped data. diff --git a/app/scouting/forms.py b/app/scouting/forms.py index 5d95e93..3da82ef 100644 --- a/app/scouting/forms.py +++ b/app/scouting/forms.py @@ -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") diff --git a/app/scouting/migrations/0008_savedsearch.py b/app/scouting/migrations/0008_savedsearch.py new file mode 100644 index 0000000..0f7a41a --- /dev/null +++ b/app/scouting/migrations/0008_savedsearch.py @@ -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')], + }, + ), + ] diff --git a/app/scouting/models.py b/app/scouting/models.py index 0b4659d..e2a0218 100644 --- a/app/scouting/models.py +++ b/app/scouting/models.py @@ -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}" diff --git a/app/scouting/templates/scouting/player_list.html b/app/scouting/templates/scouting/player_list.html index 43a3e2f..0243387 100644 --- a/app/scouting/templates/scouting/player_list.html +++ b/app/scouting/templates/scouting/player_list.html @@ -19,6 +19,41 @@ {% endif %}

+ {% if request.user.is_authenticated %} +
+

Saved Searches

+
+ {% csrf_token %} + {{ saved_search_form.saved_search_name.label_tag }} {{ saved_search_form.saved_search_name }} + {% for key, value in current_search_params.items %} + + {% endfor %} + +
+ +
+ {% else %} +

Log in to save searches

+ {% endif %} +
Result Controls @@ -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 @@
+ Clear filters

Results ({{ total_results }})

+

Sort: {{ form.sort.value|default:"name_asc" }} | Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}

+ {% if active_filters %} +

Active filters:

+ + {% endif %} diff --git a/app/scouting/tests.py b/app/scouting/tests.py index 455d7d5..abffceb 100644 --- a/app/scouting/tests.py +++ b/app/scouting/tests.py @@ -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): diff --git a/app/scouting/urls.py b/app/scouting/urls.py index 5c9c06d..408544b 100644 --- a/app/scouting/urls.py +++ b/app/scouting/urls.py @@ -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//delete/", views.delete_saved_search, name="delete_saved_search"), path("players//", views.player_detail, name="player_detail"), path("players//favorite/", views.add_favorite, name="add_favorite"), path("players//unfavorite/", views.remove_favorite, name="remove_favorite"), diff --git a/app/scouting/views.py b/app/scouting/views.py index 7f103e7..acb9257 100644 --- a/app/scouting/views.py +++ b/app/scouting/views.py @@ -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)}")