From 20d3ee7daec5b766d6cfca5486d601488df53414 Mon Sep 17 00:00:00 2001 From: Alfredo Di Stasio Date: Fri, 13 Mar 2026 14:40:38 +0100 Subject: [PATCH] feat(v2): streamline saved searches and favorites flows --- README.md | 9 ++ apps/scouting/forms.py | 62 +++++++++++++ apps/scouting/views.py | 11 ++- .../scouting/partials/saved_search_table.html | 11 ++- templates/scouting/saved_search_edit.html | 18 +++- tests/test_scouting_views.py | 91 +++++++++++++++++++ 6 files changed, 198 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f97453b..a63738d 100644 --- a/README.md +++ b/README.md @@ -345,6 +345,15 @@ Pagination and sorting: - querystring is preserved - 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 Required branch model: diff --git a/apps/scouting/forms.py b/apps/scouting/forms.py index 8abb582..6e24c9e 100644 --- a/apps/scouting/forms.py +++ b/apps/scouting/forms.py @@ -1,4 +1,8 @@ from django import forms +import json +from decimal import Decimal + +from apps.players.forms import PlayerSearchForm from .models import SavedSearch @@ -10,3 +14,61 @@ class SavedSearchForm(forms.ModelForm): widgets = { "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 diff --git a/apps/scouting/views.py b/apps/scouting/views.py index 4e835d7..3841bb9 100644 --- a/apps/scouting/views.py +++ b/apps/scouting/views.py @@ -7,7 +7,7 @@ from django.utils import timezone from django.views import View from django.views.generic import ListView, TemplateView, UpdateView -from .forms import SavedSearchForm +from .forms import SavedSearchForm, SavedSearchUpdateForm from .models import FavoritePlayer, SavedSearch 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): model = SavedSearch - form_class = SavedSearchForm + form_class = SavedSearchUpdateForm template_name = "scouting/saved_search_edit.html" def get_queryset(self): @@ -96,6 +96,13 @@ class SavedSearchDeleteView(LoginRequiredMixin, View): def post(self, request, pk, *args, **kwargs): saved_search = get_object_or_404(SavedSearch, pk=pk, user=request.user) 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.") return redirect("scouting:index") diff --git a/templates/scouting/partials/saved_search_table.html b/templates/scouting/partials/saved_search_table.html index bc04f99..7733134 100644 --- a/templates/scouting/partials/saved_search_table.html +++ b/templates/scouting/partials/saved_search_table.html @@ -1,3 +1,4 @@ +
{% if saved_searches %}
@@ -21,7 +22,14 @@
Run Edit -
+ {% csrf_token %} @@ -35,3 +43,4 @@ {% else %}
No saved searches yet.
{% endif %} +
diff --git a/templates/scouting/saved_search_edit.html b/templates/scouting/saved_search_edit.html index 5ef25be..5f370c4 100644 --- a/templates/scouting/saved_search_edit.html +++ b/templates/scouting/saved_search_edit.html @@ -7,7 +7,23 @@

Edit Saved Search

{% csrf_token %} - {{ form.as_p }} +
+ + {{ form.name }} + {% for error in form.name.errors %}

{{ error }}

{% endfor %} +
+
+ {{ form.is_public }} + + {% for error in form.is_public.errors %}

{{ error }}

{% endfor %} +
+
+ + {{ form.filters_json }} +

{{ form.filters_json.help_text }}

+ {% for error in form.filters_json.errors %}

{{ error }}

{% endfor %} +
+ {% for error in form.non_field_errors %}

{{ error }}

{% endfor %}
Cancel diff --git a/tests/test_scouting_views.py b/tests/test_scouting_views.py index c84adec..64b7cbf 100644 --- a/tests/test_scouting_views.py +++ b/tests/test_scouting_views.py @@ -15,6 +15,20 @@ def test_scouting_index_requires_login(client): 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 def test_create_saved_search_from_filters(client): 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 +@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 def test_favorite_toggle_adds_and_removes(client): 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 "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()