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
+
+
+ {% for saved_search in saved_searches %}
+ -
+ {{ saved_search.name }}
+ {% if saved_search.is_active %}
+ (active)
+ {% endif %}
+
+
+ {% empty %}
+ - No saved searches yet.
+ {% endfor %}
+
+
+ {% else %}
+ Log in to save searches
+ {% endif %}
+
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:
+
+ {% for filter in active_filters %}
+ - {{ filter.label }}: {{ filter.value }}
+ {% endfor %}
+
+ {% endif %}
{% for player in players %}
-
@@ -113,7 +160,11 @@
{% endif %}
{% empty %}
- - No players found.
+ {% if has_submitted_search %}
+ - No players found for the current search filters.
+ {% else %}
+ - No players available yet.
+ {% endif %}
{% endfor %}
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)}")