feat(v2): streamline saved searches and favorites flows
This commit is contained in:
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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")
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user