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
|
- 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.
|
||||||
|
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
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:
|
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}"
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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"),
|
||||||
|
|||||||
@ -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)}")
|
||||||
|
|||||||
Reference in New Issue
Block a user