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

View File

@ -3,6 +3,7 @@ from datetime import date
from django.db.models import Prefetch from django.db.models import Prefetch
from django.views.generic import DetailView, ListView from django.views.generic import DetailView, ListView
from apps.scouting.models import FavoritePlayer
from apps.stats.models import PlayerSeason from apps.stats.models import PlayerSeason
from .forms import PlayerSearchForm from .forms import PlayerSearchForm
@ -56,6 +57,15 @@ class PlayerSearchView(ListView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context["search_form"] = self.get_form() 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 return context
@ -122,4 +132,10 @@ class PlayerDetailView(DetailView):
context["current_assignment"] = current_assignment context["current_assignment"] = current_assignment
context["career_entries"] = player.career_entries.all().order_by("-start_date", "-id") context["career_entries"] = player.career_entries.all().order_by("-start_date", "-id")
context["season_rows"] = season_rows 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 return context

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 django.urls import path
from .views import ScoutingHomeView from .views import (
FavoriteToggleView,
SavedSearchCreateView,
SavedSearchDeleteView,
SavedSearchListView,
SavedSearchRunView,
SavedSearchUpdateView,
ScoutingHomeView,
WatchlistView,
)
app_name = "scouting" app_name = "scouting"
urlpatterns = [ urlpatterns = [
path("", ScoutingHomeView.as_view(), name="index"), 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" 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")

View File

@ -114,6 +114,16 @@ main {
border-radius: 8px; border-radius: 8px;
} }
.message.success {
background: #e9f9ef;
border-color: #bde8ca;
}
.message.error {
background: #fff0f0;
border-color: #f0b8b8;
}
.mt-16 { .mt-16 {
margin-top: 1rem; margin-top: 1rem;
} }
@ -205,3 +215,9 @@ th {
border-radius: 10px; border-radius: 10px;
padding: 0.8rem; padding: 0.8rem;
} }
.inline-label {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}

View File

@ -12,7 +12,12 @@
· {{ player.inferred_role.name|default:"No inferred role" }} · {{ player.inferred_role.name|default:"No inferred role" }}
</p> </p>
</div> </div>
<a class="button ghost" href="{% url 'players:index' %}">Back to search</a> <div class="row-gap">
{% if request.user.is_authenticated %}
{% include "scouting/partials/favorite_button.html" with player=player is_favorite=is_favorite next_url=request.get_full_path %}
{% endif %}
<a class="button ghost" href="{% url 'players:index' %}">Back to search</a>
</div>
</div> </div>
<div class="detail-grid mt-16"> <div class="detail-grid mt-16">

View File

@ -7,6 +7,10 @@
</div> </div>
</div> </div>
{% if request.user.is_authenticated %}
{% include "scouting/partials/save_search_form.html" %}
{% endif %}
{% if players %} {% if players %}
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
@ -21,6 +25,7 @@
<th>PPG</th> <th>PPG</th>
<th>RPG</th> <th>RPG</th>
<th>APG</th> <th>APG</th>
{% if request.user.is_authenticated %}<th>Watchlist</th>{% endif %}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -40,6 +45,15 @@
<td>{{ player.ppg_value|floatformat:1 }}</td> <td>{{ player.ppg_value|floatformat:1 }}</td>
<td>{{ player.rpg_value|floatformat:1 }}</td> <td>{{ player.rpg_value|floatformat:1 }}</td>
<td>{{ player.apg_value|floatformat:1 }}</td> <td>{{ player.apg_value|floatformat:1 }}</td>
{% if request.user.is_authenticated %}
<td>
{% if player.id in favorite_player_ids %}
{% include "scouting/partials/favorite_button.html" with player=player is_favorite=True next_url=request.get_full_path %}
{% else %}
{% include "scouting/partials/favorite_button.html" with player=player is_favorite=False next_url=request.get_full_path %}
{% endif %}
</td>
{% endif %}
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View File

@ -4,7 +4,25 @@
{% block content %} {% block content %}
<section class="panel"> <section class="panel">
<h1>Scouting</h1> <div class="row-between wrap-gap">
<p>Scouting module scaffolding for upcoming phases.</p> <div>
<h1>Scouting Workspace</h1>
<p class="muted-text">Manage saved searches and your player watchlist.</p>
</div>
<div class="row-gap">
<a class="button ghost" href="{% url 'scouting:saved_search_list' %}">All saved searches</a>
<a class="button ghost" href="{% url 'scouting:watchlist' %}">Watchlist</a>
</div>
</div>
</section>
<section class="panel mt-16">
<h2>Saved Searches</h2>
{% include "scouting/partials/saved_search_table.html" with saved_searches=saved_searches %}
</section>
<section class="panel mt-16">
<h2>Watchlist</h2>
{% include "scouting/partials/watchlist_table.html" with favorites=favorites %}
</section> </section>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,16 @@
<form
id="favorite-form-{{ player.id }}"
method="post"
action="{% url 'scouting:favorite_toggle' player.id %}"
hx-post="{% url 'scouting:favorite_toggle' player.id %}"
hx-target="#favorite-form-{{ player.id }}"
hx-swap="outerHTML"
>
{% csrf_token %}
<input type="hidden" name="next" value="{{ next_url }}">
{% if is_favorite %}
<button type="submit" class="button ghost">Remove favorite</button>
{% else %}
<button type="submit" class="button ghost">Add favorite</button>
{% endif %}
</form>

View File

@ -0,0 +1,3 @@
<div class="message {% if ok %}success{% else %}error{% endif %}">
{{ message }}
</div>

View File

@ -0,0 +1,27 @@
<div class="panel mt-16">
<h3>Save Current Search</h3>
<p class="muted-text">Store current filters and replay them later.</p>
<form
method="post"
action="{% url 'scouting:saved_search_create' %}"
class="row-gap"
hx-post="{% url 'scouting:saved_search_create' %}"
hx-target="#saved-search-feedback"
hx-swap="innerHTML"
>
{% csrf_token %}
<input type="text" name="name" placeholder="Search name" required>
<label class="inline-label">
<input type="checkbox" name="is_public">
Public
</label>
{% for key, value in request.GET.items %}
<input type="hidden" name="{{ key }}" value="{{ value }}">
{% endfor %}
<button class="button" type="submit">Save search</button>
</form>
<div id="saved-search-feedback" class="mt-16"></div>
</div>

View File

@ -0,0 +1,37 @@
{% if saved_searches %}
<div class="table-wrap mt-16">
<table>
<thead>
<tr>
<th>Name</th>
<th>Visibility</th>
<th>Updated</th>
<th>Last run</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for saved_search in saved_searches %}
<tr>
<td>{{ saved_search.name }}</td>
<td>{% if saved_search.is_public %}Public{% else %}Private{% endif %}</td>
<td>{{ saved_search.updated_at|date:"Y-m-d H:i" }}</td>
<td>{{ saved_search.last_run_at|date:"Y-m-d H:i"|default:"-" }}</td>
<td>
<div class="row-gap">
<a class="button ghost" href="{% url 'scouting:saved_search_run' saved_search.pk %}">Run</a>
<a class="button ghost" href="{% url 'scouting:saved_search_edit' saved_search.pk %}">Edit</a>
<form method="post" action="{% url 'scouting:saved_search_delete' saved_search.pk %}">
{% csrf_token %}
<button class="button ghost" type="submit">Delete</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="mt-16">No saved searches yet.</p>
{% endif %}

View File

@ -0,0 +1,35 @@
{% if favorites %}
<div class="table-wrap mt-16">
<table>
<thead>
<tr>
<th>Player</th>
<th>Nationality</th>
<th>Position / Role</th>
<th>Added</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% for favorite in favorites %}
<tr>
<td><a href="{% url 'players:detail' favorite.player_id %}">{{ favorite.player.full_name }}</a></td>
<td>{{ favorite.player.nationality.name|default:"-" }}</td>
<td>
{{ favorite.player.nominal_position.code|default:"-" }}
/ {{ favorite.player.inferred_role.name|default:"-" }}
</td>
<td>{{ favorite.created_at|date:"Y-m-d" }}</td>
<td>
<div id="favorite-toggle-{{ favorite.player_id }}">
{% include "scouting/partials/favorite_button.html" with player=favorite.player is_favorite=True next_url=request.get_full_path %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="mt-16">No players in your watchlist yet.</p>
{% endif %}

View File

@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block title %}HoopScout | Edit Saved Search{% endblock %}
{% block content %}
<section class="panel narrow">
<h1>Edit Saved Search</h1>
<form method="post" class="stack">
{% csrf_token %}
{{ form.as_p }}
<div class="row-gap">
<button type="submit" class="button">Update</button>
<a class="button ghost" href="{% url 'scouting:index' %}">Cancel</a>
</div>
</form>
</section>
{% endblock %}

View File

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block title %}HoopScout | Saved Searches{% endblock %}
{% block content %}
<section class="panel">
<div class="row-between wrap-gap">
<h1>Saved Searches</h1>
<a class="button ghost" href="{% url 'scouting:index' %}">Back to scouting</a>
</div>
{% include "scouting/partials/saved_search_table.html" with saved_searches=saved_searches %}
</section>
{% endblock %}

View File

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block title %}HoopScout | Watchlist{% endblock %}
{% block content %}
<section class="panel">
<div class="row-between wrap-gap">
<h1>Watchlist</h1>
<a class="button ghost" href="{% url 'scouting:index' %}">Back to scouting</a>
</div>
{% include "scouting/partials/watchlist_table.html" with favorites=favorites %}
</section>
{% endblock %}

View File

@ -0,0 +1,87 @@
from datetime import date
import pytest
from django.contrib.auth.models import User
from django.urls import reverse
from apps.players.models import Nationality, Player, Position, Role
from apps.scouting.models import FavoritePlayer, SavedSearch
@pytest.mark.django_db
def test_scouting_index_requires_login(client):
response = client.get(reverse("scouting:index"))
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")
client.force_login(user)
nationality = Nationality.objects.create(name="Italy", iso2_code="IT", iso3_code="ITA")
position = Position.objects.create(code="PG", name="Point Guard")
response = client.post(
reverse("scouting:saved_search_create"),
data={
"name": "Italian guards",
"is_public": "on",
"q": "marco",
"nominal_position": str(position.id),
"nationality": str(nationality.id),
"age_max": "24",
"sort": "ppg_desc",
},
)
assert response.status_code == 302
saved = SavedSearch.objects.get(user=user, name="Italian guards")
assert saved.filters["q"] == "marco"
assert saved.filters["nominal_position"] == position.id
assert saved.filters["nationality"] == nationality.id
@pytest.mark.django_db
def test_saved_search_run_redirects_to_players(client):
user = User.objects.create_user(username="scout2", password="pass12345")
client.force_login(user)
saved = SavedSearch.objects.create(
user=user,
name="Run me",
filters={"q": "rossi", "sort": "name_asc"},
)
response = client.get(reverse("scouting:saved_search_run", kwargs={"pk": saved.pk}))
assert response.status_code == 302
assert response.url.startswith(reverse("players:index"))
assert "q=rossi" in response.url
@pytest.mark.django_db
def test_favorite_toggle_adds_and_removes(client):
user = User.objects.create_user(username="scout3", password="pass12345")
client.force_login(user)
nationality = Nationality.objects.create(name="Spain", iso2_code="ES", iso3_code="ESP")
position = Position.objects.create(code="SF", name="Small Forward")
role = Role.objects.create(code="wing", name="Wing")
player = Player.objects.create(
first_name="Juan",
last_name="Ramos",
full_name="Juan Ramos",
birth_date=date(2000, 1, 1),
nationality=nationality,
nominal_position=position,
inferred_role=role,
)
add_resp = client.post(reverse("scouting:favorite_toggle", kwargs={"player_id": player.id}))
assert add_resp.status_code == 302
assert FavoritePlayer.objects.filter(user=user, player=player).exists()
remove_resp = client.post(reverse("scouting:favorite_toggle", kwargs={"player_id": player.id}))
assert remove_resp.status_code == 302
assert not FavoritePlayer.objects.filter(user=user, player=player).exists()