feat(scouting): add wingspan filters and saved searches mvp
This commit is contained in:
@ -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.
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
31
app/scouting/migrations/0008_savedsearch.py
Normal file
31
app/scouting/migrations/0008_savedsearch.py
Normal 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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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}"
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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)}")
|
||||
|
||||
Reference in New Issue
Block a user