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 @@ +