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

@ -8,11 +8,13 @@ The current application baseline provides:
- containerized local development - containerized local development
- curated sample seed data for manual exploration - curated sample seed data for manual exploration
- player scouting search with player, context, and stat filters - 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 - matching season/team/competition context on search results
- result sorting and pagination - result sorting and pagination
- login/logout with Django built-in authentication - login/logout with Django built-in authentication
- user-scoped shortlist favorites - user-scoped shortlist favorites
- user-scoped plain-text scouting notes on player detail pages - 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: Accepted technical and product-shaping decisions live in:
- `docs/ARCHITECTURE.md` - `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`. 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. 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. 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. 6. Use player-level filters (including wingspan), context filters, and stat filters to run scouting searches with sorting and pagination.
7. Use `http://127.0.0.1:8000/favorites/` to review your user-scoped shortlist. 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. 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.

View File

@ -42,6 +42,8 @@ class PlayerSearchForm(forms.Form):
max_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) 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) 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()) competition = forms.ModelChoiceField(required=False, queryset=Competition.objects.none())
season = forms.ModelChoiceField(required=False, queryset=Season.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: def birth_date_lower_bound_for_age(max_age: int) -> date:
today = date.today() today = date.today()
return today.replace(year=today.year - max_age - 1) 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: def __str__(self) -> str:
return f"Note by {self.user} for {self.player.full_name}" 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 %} {% endif %}
</p> </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"> <form method="get">
<fieldset> <fieldset>
<legend>Result Controls</legend> <legend>Result Controls</legend>
@ -41,6 +76,8 @@
{{ form.max_height_cm.label_tag }} {{ form.max_height_cm }} {{ form.max_height_cm.label_tag }} {{ form.max_height_cm }}
{{ form.min_weight_kg.label_tag }} {{ form.min_weight_kg }} {{ form.min_weight_kg.label_tag }} {{ form.min_weight_kg }}
{{ form.max_weight_kg.label_tag }} {{ form.max_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.position.label_tag }} {{ form.position }}
{{ form.role.label_tag }} {{ form.role }} {{ form.role.label_tag }} {{ form.role }}
{{ form.specialty.label_tag }} {{ form.specialty }} {{ form.specialty.label_tag }} {{ form.specialty }}
@ -68,9 +105,19 @@
</fieldset> </fieldset>
<button type="submit">Search</button> <button type="submit">Search</button>
<a href="{% url 'scouting:player_list' %}">Clear filters</a>
</form> </form>
<h2>Results ({{ total_results }})</h2> <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> <ul>
{% for player in players %} {% for player in players %}
<li> <li>
@ -113,7 +160,11 @@
{% endif %} {% endif %}
</li> </li>
{% empty %} {% 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 %} {% endfor %}
</ul> </ul>

View File

@ -1,5 +1,6 @@
from datetime import date from datetime import date
from decimal import Decimal from decimal import Decimal
from urllib.parse import urlencode
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core.management import call_command 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.test import TestCase, TransactionTestCase
from django.urls import reverse 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() User = get_user_model()
@ -34,6 +47,7 @@ class ScoutingSearchViewsTests(TestCase):
position="PG", position="PG",
height_cm=Decimal("188.00"), height_cm=Decimal("188.00"),
weight_kg=Decimal("82.00"), weight_kg=Decimal("82.00"),
wingspan_cm=Decimal("194.00"),
nationality="IT", nationality="IT",
) )
cls.player_pg.roles.add(cls.role_playmaker) cls.player_pg.roles.add(cls.role_playmaker)
@ -45,6 +59,7 @@ class ScoutingSearchViewsTests(TestCase):
position="SF", position="SF",
height_cm=Decimal("201.00"), height_cm=Decimal("201.00"),
weight_kg=Decimal("95.00"), weight_kg=Decimal("95.00"),
wingspan_cm=Decimal("211.00"),
) )
cls.player_wing.roles.add(cls.role_3d) cls.player_wing.roles.add(cls.role_3d)
cls.player_wing.specialties.add(cls.specialty_offball) cls.player_wing.specialties.add(cls.specialty_offball)
@ -127,6 +142,15 @@ class ScoutingSearchViewsTests(TestCase):
self.assertContains(response, self.player_pg.full_name) self.assertContains(response, self.player_pg.full_name)
self.assertNotContains(response, self.player_wing.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): def test_filter_by_context_fields_and_stats(self):
response = self.client.get( response = self.client.get(
reverse("scouting:player_list"), reverse("scouting:player_list"),
@ -514,6 +538,114 @@ class FavoritePlayerViewsTests(TestCase):
self.assertRedirects(response, f"{reverse('login')}?next={reverse('scouting:favorites_list')}") 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): class PlayerNoteViewsTests(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):

View File

@ -6,6 +6,8 @@ app_name = "scouting"
urlpatterns = [ urlpatterns = [
path("players/", views.player_list, name="player_list"), 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>/", views.player_detail, name="player_detail"),
path("players/<int:player_id>/favorite/", views.add_favorite, name="add_favorite"), path("players/<int:player_id>/favorite/", views.add_favorite, name="add_favorite"),
path("players/<int:player_id>/unfavorite/", views.remove_favorite, name="remove_favorite"), path("players/<int:player_id>/unfavorite/", views.remove_favorite, name="remove_favorite"),

View File

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from decimal import Decimal from decimal import Decimal
from urllib.parse import urlencode
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.contrib.auth.decorators import login_required 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.urls import reverse
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
from .forms import PlayerSearchForm from .forms import PlayerSearchForm, SavedSearchForm
from .models import FavoritePlayer, Player, PlayerNote, PlayerSeason from .models import FavoritePlayer, Player, PlayerNote, PlayerSeason, SavedSearch
PAGE_SIZE = 20 PAGE_SIZE = 20
PLAYER_SORTS = { PLAYER_SORTS = {
@ -27,6 +28,100 @@ CONTEXT_SORTS = {
"ts_pct_desc": "ts_pct", "ts_pct_desc": "ts_pct",
"blocks_desc": "blocks", "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): 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): def player_list(request):
form = PlayerSearchForm(request.GET or None) form = PlayerSearchForm(request.GET or None)
saved_search_form = SavedSearchForm()
queryset = ( queryset = (
Player.objects.all() Player.objects.all()
.prefetch_related("roles", "specialties") .prefetch_related("roles", "specialties")
@ -110,10 +206,14 @@ def player_list(request):
) )
context_filters_used = False context_filters_used = False
requested_sort = request.GET.get("sort") or "name_asc" 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(): if form.is_valid():
data = form.cleaned_data data = form.cleaned_data
requested_sort = data["sort"] or "name_asc" requested_sort = data["sort"] or "name_asc"
current_search_params = serialize_search_params(data)
active_filters = build_active_filters(form, data)
if data["name"]: if data["name"]:
queryset = queryset.filter(full_name__icontains=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"]) queryset = queryset.filter(weight_kg__gte=data["min_weight_kg"])
if data["max_weight_kg"] is not None: if data["max_weight_kg"] is not None:
queryset = queryset.filter(weight_kg__lte=data["max_weight_kg"]) 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: if data["min_age"] is not None:
cutoff = form.birth_date_upper_bound_for_age(data["min_age"]) cutoff = form.birth_date_upper_bound_for_age(data["min_age"])
@ -253,8 +357,19 @@ def player_list(request):
paginator = Paginator(players, PAGE_SIZE) paginator = Paginator(players, PAGE_SIZE)
page_obj = paginator.get_page(request.GET.get("page")) page_obj = paginator.get_page(request.GET.get("page"))
apply_favorite_state(page_obj.object_list, request.user) 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( return render(
request, request,
@ -265,8 +380,13 @@ def player_list(request):
"page_obj": page_obj, "page_obj": page_obj,
"active_sort": active_sort, "active_sort": active_sort,
"total_results": paginator.count, "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, "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, "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)}")