feat(v2): streamline saved searches and favorites flows

This commit is contained in:
Alfredo Di Stasio
2026-03-13 14:40:38 +01:00
parent 0ed4fc57b8
commit 20d3ee7dae
6 changed files with 198 additions and 4 deletions

View File

@ -345,6 +345,15 @@ Pagination and sorting:
- querystring is preserved - querystring is preserved
- HTMX navigation keeps URL state in sync with current filters/page/sort - HTMX navigation keeps URL state in sync with current filters/page/sort
## Saved Searches and Watchlist (v2)
Authenticated users can:
- save current search filters from the player search page
- re-run saved searches from scouting pages
- rename/update/delete saved searches
- update saved search filters via structured JSON in the edit screen
- add/remove favorite players inline (HTMX-friendly) and browse watchlist
## GitFlow ## GitFlow
Required branch model: Required branch model:

View File

@ -1,4 +1,8 @@
from django import forms from django import forms
import json
from decimal import Decimal
from apps.players.forms import PlayerSearchForm
from .models import SavedSearch from .models import SavedSearch
@ -10,3 +14,61 @@ class SavedSearchForm(forms.ModelForm):
widgets = { widgets = {
"name": forms.TextInput(attrs={"placeholder": "e.g. EuroLeague guards under 24"}), "name": forms.TextInput(attrs={"placeholder": "e.g. EuroLeague guards under 24"}),
} }
class SavedSearchUpdateForm(forms.ModelForm):
filters_json = forms.CharField(
required=False,
label="Filters (JSON)",
widget=forms.Textarea(attrs={"rows": 8, "class": "font-mono"}),
help_text="Structured search filters payload. Leave blank to keep current filters.",
)
class Meta:
model = SavedSearch
fields = ["name", "is_public", "filters_json"]
widgets = {
"name": forms.TextInput(attrs={"placeholder": "e.g. Italian wings - updated"}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance and self.instance.pk and not self.initial.get("filters_json"):
self.initial["filters_json"] = json.dumps(self.instance.filters, indent=2, sort_keys=True)
def clean_filters_json(self):
raw = self.cleaned_data.get("filters_json")
if not raw:
return self.instance.filters
try:
parsed = json.loads(raw)
except json.JSONDecodeError as exc:
raise forms.ValidationError("Invalid JSON format.") from exc
if not isinstance(parsed, dict):
raise forms.ValidationError("Filters JSON must be an object.")
form = PlayerSearchForm(parsed)
if not form.is_valid():
raise forms.ValidationError("Filters JSON contains invalid search parameters.")
validated = {}
for key, value in form.cleaned_data.items():
if value in (None, ""):
continue
if hasattr(value, "pk"):
validated[key] = value.pk
elif isinstance(value, Decimal):
validated[key] = str(value)
else:
validated[key] = value
if not validated:
raise forms.ValidationError("Filters JSON does not contain valid searchable filters.")
return validated
def save(self, commit=True):
instance = super().save(commit=False)
instance.filters = self.cleaned_data["filters_json"]
if commit:
instance.save()
return instance

View File

@ -7,7 +7,7 @@ from django.utils import timezone
from django.views import View from django.views import View
from django.views.generic import ListView, TemplateView, UpdateView from django.views.generic import ListView, TemplateView, UpdateView
from .forms import SavedSearchForm from .forms import SavedSearchForm, SavedSearchUpdateForm
from .models import FavoritePlayer, SavedSearch from .models import FavoritePlayer, SavedSearch
from .services.saved_searches import extract_filters_from_params, saved_search_to_querystring from .services.saved_searches import extract_filters_from_params, saved_search_to_querystring
@ -81,7 +81,7 @@ class SavedSearchCreateView(LoginRequiredMixin, View):
class SavedSearchUpdateView(LoginRequiredMixin, UpdateView): class SavedSearchUpdateView(LoginRequiredMixin, UpdateView):
model = SavedSearch model = SavedSearch
form_class = SavedSearchForm form_class = SavedSearchUpdateForm
template_name = "scouting/saved_search_edit.html" template_name = "scouting/saved_search_edit.html"
def get_queryset(self): def get_queryset(self):
@ -96,6 +96,13 @@ class SavedSearchDeleteView(LoginRequiredMixin, View):
def post(self, request, pk, *args, **kwargs): def post(self, request, pk, *args, **kwargs):
saved_search = get_object_or_404(SavedSearch, pk=pk, user=request.user) saved_search = get_object_or_404(SavedSearch, pk=pk, user=request.user)
saved_search.delete() saved_search.delete()
if request.headers.get("HX-Request") == "true":
saved_searches = SavedSearch.objects.filter(user=request.user).order_by("-updated_at")
return render(
request,
"scouting/partials/saved_search_table.html",
{"saved_searches": saved_searches},
)
messages.success(request, "Saved search deleted.") messages.success(request, "Saved search deleted.")
return redirect("scouting:index") return redirect("scouting:index")

View File

@ -1,3 +1,4 @@
<div id="saved-search-table">
{% if saved_searches %} {% if saved_searches %}
<div class="table-wrap mt-4"> <div class="table-wrap mt-4">
<table class="data-table"> <table class="data-table">
@ -21,7 +22,14 @@
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<a class="btn-secondary" href="{% url 'scouting:saved_search_run' saved_search.pk %}">Run</a> <a class="btn-secondary" href="{% url 'scouting:saved_search_run' saved_search.pk %}">Run</a>
<a class="btn-secondary" href="{% url 'scouting:saved_search_edit' saved_search.pk %}">Edit</a> <a class="btn-secondary" href="{% url 'scouting:saved_search_edit' saved_search.pk %}">Edit</a>
<form method="post" action="{% url 'scouting:saved_search_delete' saved_search.pk %}"> <form
method="post"
action="{% url 'scouting:saved_search_delete' saved_search.pk %}"
hx-post="{% url 'scouting:saved_search_delete' saved_search.pk %}"
hx-target="#saved-search-table"
hx-swap="outerHTML"
hx-indicator="#htmx-loading"
>
{% csrf_token %} {% csrf_token %}
<button class="btn-secondary" type="submit">Delete</button> <button class="btn-secondary" type="submit">Delete</button>
</form> </form>
@ -35,3 +43,4 @@
{% else %} {% else %}
<div class="empty-state mt-4">No saved searches yet.</div> <div class="empty-state mt-4">No saved searches yet.</div>
{% endif %} {% endif %}
</div>

View File

@ -7,7 +7,23 @@
<h1>Edit Saved Search</h1> <h1>Edit Saved Search</h1>
<form method="post" class="mt-4 space-y-4"> <form method="post" class="mt-4 space-y-4">
{% csrf_token %} {% csrf_token %}
{{ form.as_p }} <div>
<label for="{{ form.name.id_for_label }}">{{ form.name.label }}</label>
{{ form.name }}
{% for error in form.name.errors %}<p class="text-sm text-rose-700">{{ error }}</p>{% endfor %}
</div>
<div class="flex items-center gap-2">
{{ form.is_public }}
<label for="{{ form.is_public.id_for_label }}">{{ form.is_public.label }}</label>
{% for error in form.is_public.errors %}<p class="text-sm text-rose-700">{{ error }}</p>{% endfor %}
</div>
<div>
<label for="{{ form.filters_json.id_for_label }}">{{ form.filters_json.label }}</label>
{{ form.filters_json }}
<p class="mt-1 text-xs text-slate-500">{{ form.filters_json.help_text }}</p>
{% for error in form.filters_json.errors %}<p class="text-sm text-rose-700">{{ error }}</p>{% endfor %}
</div>
{% for error in form.non_field_errors %}<p class="text-sm text-rose-700">{{ error }}</p>{% endfor %}
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<button type="submit" class="btn">Update</button> <button type="submit" class="btn">Update</button>
<a class="btn-secondary" href="{% url 'scouting:index' %}">Cancel</a> <a class="btn-secondary" href="{% url 'scouting:index' %}">Cancel</a>

View File

@ -15,6 +15,20 @@ def test_scouting_index_requires_login(client):
assert reverse("users:login") in response.url assert reverse("users:login") in response.url
@pytest.mark.django_db
def test_saved_search_list_requires_login(client):
response = client.get(reverse("scouting:saved_search_list"))
assert response.status_code == 302
assert reverse("users:login") in response.url
@pytest.mark.django_db
def test_watchlist_requires_login(client):
response = client.get(reverse("scouting:watchlist"))
assert response.status_code == 302
assert reverse("users:login") in response.url
@pytest.mark.django_db @pytest.mark.django_db
def test_create_saved_search_from_filters(client): def test_create_saved_search_from_filters(client):
user = User.objects.create_user(username="scout", password="pass12345") user = User.objects.create_user(username="scout", password="pass12345")
@ -60,6 +74,60 @@ def test_saved_search_run_redirects_to_players(client):
assert "q=rossi" in response.url assert "q=rossi" in response.url
@pytest.mark.django_db
def test_saved_search_update_renames_and_updates_filters(client):
user = User.objects.create_user(username="scout-update", password="pass12345")
client.force_login(user)
nationality = Nationality.objects.create(name="Germany", iso2_code="DE", iso3_code="DEU")
saved = SavedSearch.objects.create(
user=user,
name="Old Name",
filters={"q": "old", "sort": "name_asc"},
is_public=False,
)
response = client.post(
reverse("scouting:saved_search_edit", kwargs={"pk": saved.pk}),
data={
"name": "Updated Name",
"is_public": "on",
"filters_json": '{"q": "new", "nationality": %d, "sort": "ppg_desc"}' % nationality.id,
},
)
assert response.status_code == 302
saved.refresh_from_db()
assert saved.name == "Updated Name"
assert saved.is_public is True
assert saved.filters["q"] == "new"
assert saved.filters["nationality"] == nationality.id
assert saved.filters["sort"] == "ppg_desc"
@pytest.mark.django_db
def test_saved_search_delete_removes_entry(client):
user = User.objects.create_user(username="scout-delete", password="pass12345")
client.force_login(user)
saved = SavedSearch.objects.create(user=user, name="Delete Me", filters={"q": "x"})
response = client.post(reverse("scouting:saved_search_delete", kwargs={"pk": saved.pk}))
assert response.status_code == 302
assert not SavedSearch.objects.filter(pk=saved.pk).exists()
@pytest.mark.django_db
def test_saved_search_delete_htmx_renders_table(client):
user = User.objects.create_user(username="scout-delete-htmx", password="pass12345")
client.force_login(user)
saved = SavedSearch.objects.create(user=user, name="Delete HTMX", filters={"q": "x"})
response = client.post(
reverse("scouting:saved_search_delete", kwargs={"pk": saved.pk}),
HTTP_HX_REQUEST="true",
)
assert response.status_code == 200
body = response.content.decode().lower()
assert "no saved searches yet" in body
@pytest.mark.django_db @pytest.mark.django_db
def test_favorite_toggle_adds_and_removes(client): def test_favorite_toggle_adds_and_removes(client):
user = User.objects.create_user(username="scout3", password="pass12345") user = User.objects.create_user(username="scout3", password="pass12345")
@ -128,3 +196,26 @@ def test_save_search_htmx_feedback(client):
assert response.status_code == 200 assert response.status_code == 200
assert "created" in response.content.decode().lower() assert "created" in response.content.decode().lower()
@pytest.mark.django_db
def test_watchlist_page_renders_favorite_player(client):
user = User.objects.create_user(username="watch-user", password="pass12345")
client.force_login(user)
nationality = Nationality.objects.create(name="Poland", iso2_code="PL", iso3_code="POL")
position = Position.objects.create(code="C", name="Center")
role = Role.objects.create(code="rim", name="Rim Protector")
player = Player.objects.create(
first_name="Adam",
last_name="Big",
full_name="Adam Big",
birth_date=date(2001, 1, 1),
nationality=nationality,
nominal_position=position,
inferred_role=role,
)
FavoritePlayer.objects.create(user=user, player=player)
response = client.get(reverse("scouting:watchlist"))
assert response.status_code == 200
assert "Adam Big" in response.content.decode()