phase5: add saved searches, watchlist, and authenticated htmx flows

This commit is contained in:
Alfredo Di Stasio
2026-03-10 10:58:39 +01:00
parent c83bc96b6c
commit f207ffbad8
18 changed files with 543 additions and 6 deletions

12
apps/scouting/forms.py Normal file
View File

@ -0,0 +1,12 @@
from django import forms
from .models import SavedSearch
class SavedSearchForm(forms.ModelForm):
class Meta:
model = SavedSearch
fields = ["name", "is_public"]
widgets = {
"name": forms.TextInput(attrs={"placeholder": "e.g. EuroLeague guards under 24"}),
}

View File

@ -0,0 +1,38 @@
from decimal import Decimal
from urllib.parse import urlencode
from apps.players.forms import PlayerSearchForm
IGNORED_QUERY_KEYS = {"page", "csrfmiddlewaretoken"}
def _serialize_value(value):
if value is None:
return None
if hasattr(value, "pk"):
return value.pk
if isinstance(value, Decimal):
return str(value)
return value
def extract_filters_from_params(params):
payload = params.copy()
for key in IGNORED_QUERY_KEYS:
payload.pop(key, None)
form = PlayerSearchForm(payload)
if not form.is_valid():
return {}
result = {}
for key, value in form.cleaned_data.items():
serialized = _serialize_value(value)
if serialized in (None, ""):
continue
result[key] = serialized
return result
def saved_search_to_querystring(filters: dict) -> str:
return urlencode(filters, doseq=True)

View File

@ -1,9 +1,25 @@
from django.urls import path
from .views import ScoutingHomeView
from .views import (
FavoriteToggleView,
SavedSearchCreateView,
SavedSearchDeleteView,
SavedSearchListView,
SavedSearchRunView,
SavedSearchUpdateView,
ScoutingHomeView,
WatchlistView,
)
app_name = "scouting"
urlpatterns = [
path("", ScoutingHomeView.as_view(), name="index"),
path("saved-searches/", SavedSearchListView.as_view(), name="saved_search_list"),
path("saved-searches/create/", SavedSearchCreateView.as_view(), name="saved_search_create"),
path("saved-searches/<int:pk>/run/", SavedSearchRunView.as_view(), name="saved_search_run"),
path("saved-searches/<int:pk>/edit/", SavedSearchUpdateView.as_view(), name="saved_search_edit"),
path("saved-searches/<int:pk>/delete/", SavedSearchDeleteView.as_view(), name="saved_search_delete"),
path("watchlist/", WatchlistView.as_view(), name="watchlist"),
path("favorites/toggle/<int:player_id>/", FavoriteToggleView.as_view(), name="favorite_toggle"),
]

View File

@ -1,5 +1,159 @@
from django.views.generic import TemplateView
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db import IntegrityError
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from django.views import View
from django.views.generic import ListView, TemplateView, UpdateView
from .forms import SavedSearchForm
from .models import FavoritePlayer, SavedSearch
from .services.saved_searches import extract_filters_from_params, saved_search_to_querystring
class ScoutingHomeView(TemplateView):
class ScoutingHomeView(LoginRequiredMixin, TemplateView):
template_name = "scouting/index.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["saved_searches"] = SavedSearch.objects.filter(user=self.request.user).order_by("-updated_at")
context["favorites"] = (
FavoritePlayer.objects.filter(user=self.request.user)
.select_related("player", "player__nationality", "player__nominal_position", "player__inferred_role")
.order_by("-created_at")
)
return context
class SavedSearchCreateView(LoginRequiredMixin, View):
def post(self, request, *args, **kwargs):
form = SavedSearchForm(request.POST)
if not form.is_valid():
if request.headers.get("HX-Request") == "true":
return render(
request,
"scouting/partials/save_search_feedback.html",
{"ok": False, "message": "Invalid name or visibility values."},
)
messages.error(request, "Could not save search.")
return redirect("players:index")
filters = extract_filters_from_params(request.POST)
if not filters:
message = "No valid filters to save from current search."
if request.headers.get("HX-Request") == "true":
return render(
request,
"scouting/partials/save_search_feedback.html",
{"ok": False, "message": message},
)
messages.error(request, message)
return redirect("players:index")
saved_search = form.save(commit=False)
saved_search.user = request.user
saved_search.filters = filters
try:
saved_search.save()
except IntegrityError:
message = "A saved search with this name already exists."
if request.headers.get("HX-Request") == "true":
return render(
request,
"scouting/partials/save_search_feedback.html",
{"ok": False, "message": message},
)
messages.error(request, message)
return redirect("players:index")
message = f"Saved search '{saved_search.name}' created."
if request.headers.get("HX-Request") == "true":
return render(
request,
"scouting/partials/save_search_feedback.html",
{"ok": True, "message": message},
)
messages.success(request, message)
return redirect("scouting:index")
class SavedSearchUpdateView(LoginRequiredMixin, UpdateView):
model = SavedSearch
form_class = SavedSearchForm
template_name = "scouting/saved_search_edit.html"
def get_queryset(self):
return SavedSearch.objects.filter(user=self.request.user)
def get_success_url(self):
messages.success(self.request, "Saved search updated.")
return reverse("scouting:index")
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()
messages.success(request, "Saved search deleted.")
return redirect("scouting:index")
class SavedSearchRunView(LoginRequiredMixin, View):
def get(self, request, pk, *args, **kwargs):
saved_search = get_object_or_404(SavedSearch, pk=pk, user=request.user)
query = saved_search_to_querystring(saved_search.filters)
saved_search.last_run_at = timezone.now()
saved_search.save(update_fields=["last_run_at"])
target = reverse("players:index")
return redirect(f"{target}?{query}" if query else target)
class WatchlistView(LoginRequiredMixin, ListView):
model = FavoritePlayer
context_object_name = "favorites"
template_name = "scouting/watchlist.html"
def get_queryset(self):
return (
FavoritePlayer.objects.filter(user=self.request.user)
.select_related("player", "player__nationality", "player__nominal_position", "player__inferred_role")
.order_by("-created_at")
)
class FavoriteToggleView(LoginRequiredMixin, View):
def post(self, request, player_id, *args, **kwargs):
from apps.players.models import Player
player = get_object_or_404(Player, pk=player_id)
next_url = request.POST.get("next") or request.META.get("HTTP_REFERER") or reverse("players:index")
favorite, created = FavoritePlayer.objects.get_or_create(user=request.user, player=player)
if not created:
favorite.delete()
is_favorite = created
if request.headers.get("HX-Request") == "true":
return render(
request,
"scouting/partials/favorite_button.html",
{
"player": player,
"is_favorite": is_favorite,
"next_url": next_url,
},
)
return redirect(next_url)
class SavedSearchListView(LoginRequiredMixin, ListView):
model = SavedSearch
context_object_name = "saved_searches"
template_name = "scouting/saved_search_list.html"
def get_queryset(self):
return SavedSearch.objects.filter(user=self.request.user).order_by("-updated_at")