phase5: add saved searches, watchlist, and authenticated htmx flows
This commit is contained in:
@ -3,6 +3,7 @@ from datetime import date
|
||||
from django.db.models import Prefetch
|
||||
from django.views.generic import DetailView, ListView
|
||||
|
||||
from apps.scouting.models import FavoritePlayer
|
||||
from apps.stats.models import PlayerSeason
|
||||
|
||||
from .forms import PlayerSearchForm
|
||||
@ -56,6 +57,15 @@ class PlayerSearchView(ListView):
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["search_form"] = self.get_form()
|
||||
context["favorite_player_ids"] = set()
|
||||
if self.request.user.is_authenticated:
|
||||
player_ids = [player.id for player in context["players"]]
|
||||
context["favorite_player_ids"] = set(
|
||||
FavoritePlayer.objects.filter(
|
||||
user=self.request.user,
|
||||
player_id__in=player_ids,
|
||||
).values_list("player_id", flat=True)
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
@ -122,4 +132,10 @@ class PlayerDetailView(DetailView):
|
||||
context["current_assignment"] = current_assignment
|
||||
context["career_entries"] = player.career_entries.all().order_by("-start_date", "-id")
|
||||
context["season_rows"] = season_rows
|
||||
context["is_favorite"] = False
|
||||
if self.request.user.is_authenticated:
|
||||
context["is_favorite"] = FavoritePlayer.objects.filter(
|
||||
user=self.request.user,
|
||||
player=player,
|
||||
).exists()
|
||||
return context
|
||||
|
||||
12
apps/scouting/forms.py
Normal file
12
apps/scouting/forms.py
Normal 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"}),
|
||||
}
|
||||
38
apps/scouting/services/saved_searches.py
Normal file
38
apps/scouting/services/saved_searches.py
Normal 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)
|
||||
@ -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"),
|
||||
]
|
||||
|
||||
@ -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")
|
||||
|
||||
Reference in New Issue
Block a user