From f207ffbad81728737b568861651b631d3c373285 Mon Sep 17 00:00:00 2001
From: Alfredo Di Stasio
Date: Tue, 10 Mar 2026 10:58:39 +0100
Subject: [PATCH] phase5: add saved searches, watchlist, and authenticated htmx
flows
---
apps/players/views.py | 16 ++
apps/scouting/forms.py | 12 ++
apps/scouting/services/saved_searches.py | 38 +++++
apps/scouting/urls.py | 18 +-
apps/scouting/views.py | 158 +++++++++++++++++-
static/css/main.css | 16 ++
templates/players/detail.html | 7 +-
templates/players/partials/results.html | 14 ++
templates/scouting/index.html | 22 ++-
.../scouting/partials/favorite_button.html | 16 ++
.../partials/save_search_feedback.html | 3 +
.../scouting/partials/save_search_form.html | 27 +++
.../scouting/partials/saved_search_table.html | 37 ++++
.../scouting/partials/watchlist_table.html | 35 ++++
templates/scouting/saved_search_edit.html | 17 ++
templates/scouting/saved_search_list.html | 13 ++
templates/scouting/watchlist.html | 13 ++
tests/test_scouting_views.py | 87 ++++++++++
18 files changed, 543 insertions(+), 6 deletions(-)
create mode 100644 apps/scouting/forms.py
create mode 100644 apps/scouting/services/saved_searches.py
create mode 100644 templates/scouting/partials/favorite_button.html
create mode 100644 templates/scouting/partials/save_search_feedback.html
create mode 100644 templates/scouting/partials/save_search_form.html
create mode 100644 templates/scouting/partials/saved_search_table.html
create mode 100644 templates/scouting/partials/watchlist_table.html
create mode 100644 templates/scouting/saved_search_edit.html
create mode 100644 templates/scouting/saved_search_list.html
create mode 100644 templates/scouting/watchlist.html
create mode 100644 tests/test_scouting_views.py
diff --git a/apps/players/views.py b/apps/players/views.py
index c966e81..226eaab 100644
--- a/apps/players/views.py
+++ b/apps/players/views.py
@@ -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
diff --git a/apps/scouting/forms.py b/apps/scouting/forms.py
new file mode 100644
index 0000000..8abb582
--- /dev/null
+++ b/apps/scouting/forms.py
@@ -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"}),
+ }
diff --git a/apps/scouting/services/saved_searches.py b/apps/scouting/services/saved_searches.py
new file mode 100644
index 0000000..2b024de
--- /dev/null
+++ b/apps/scouting/services/saved_searches.py
@@ -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)
diff --git a/apps/scouting/urls.py b/apps/scouting/urls.py
index d76102b..65fa067 100644
--- a/apps/scouting/urls.py
+++ b/apps/scouting/urls.py
@@ -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//run/", SavedSearchRunView.as_view(), name="saved_search_run"),
+ path("saved-searches//edit/", SavedSearchUpdateView.as_view(), name="saved_search_edit"),
+ path("saved-searches//delete/", SavedSearchDeleteView.as_view(), name="saved_search_delete"),
+ path("watchlist/", WatchlistView.as_view(), name="watchlist"),
+ path("favorites/toggle//", FavoriteToggleView.as_view(), name="favorite_toggle"),
]
diff --git a/apps/scouting/views.py b/apps/scouting/views.py
index a39e597..4e835d7 100644
--- a/apps/scouting/views.py
+++ b/apps/scouting/views.py
@@ -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")
diff --git a/static/css/main.css b/static/css/main.css
index 9fc19a3..2899283 100644
--- a/static/css/main.css
+++ b/static/css/main.css
@@ -114,6 +114,16 @@ main {
border-radius: 8px;
}
+.message.success {
+ background: #e9f9ef;
+ border-color: #bde8ca;
+}
+
+.message.error {
+ background: #fff0f0;
+ border-color: #f0b8b8;
+}
+
.mt-16 {
margin-top: 1rem;
}
@@ -205,3 +215,9 @@ th {
border-radius: 10px;
padding: 0.8rem;
}
+
+.inline-label {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+}
diff --git a/templates/players/detail.html b/templates/players/detail.html
index 7f7e788..83bb028 100644
--- a/templates/players/detail.html
+++ b/templates/players/detail.html
@@ -12,7 +12,12 @@
ยท {{ player.inferred_role.name|default:"No inferred role" }}
- Back to search
+
+ {% 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 %}
+
Back to search
+
diff --git a/templates/players/partials/results.html b/templates/players/partials/results.html
index 87455fa..0419847 100644
--- a/templates/players/partials/results.html
+++ b/templates/players/partials/results.html
@@ -7,6 +7,10 @@
+{% if request.user.is_authenticated %}
+ {% include "scouting/partials/save_search_form.html" %}
+{% endif %}
+
{% if players %}
@@ -21,6 +25,7 @@
| PPG |
RPG |
APG |
+ {% if request.user.is_authenticated %}Watchlist | {% endif %}
@@ -40,6 +45,15 @@
{{ player.ppg_value|floatformat:1 }} |
{{ player.rpg_value|floatformat:1 }} |
{{ player.apg_value|floatformat:1 }} |
+ {% if request.user.is_authenticated %}
+
+ {% 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 %}
+ |
+ {% endif %}
{% endfor %}
diff --git a/templates/scouting/index.html b/templates/scouting/index.html
index b6c6bac..269ec18 100644
--- a/templates/scouting/index.html
+++ b/templates/scouting/index.html
@@ -4,7 +4,25 @@
{% block content %}
- Scouting
- Scouting module scaffolding for upcoming phases.
+
+
+
Scouting Workspace
+
Manage saved searches and your player watchlist.
+
+
+
+
+
+
+ Saved Searches
+ {% include "scouting/partials/saved_search_table.html" with saved_searches=saved_searches %}
+
+
+
+ Watchlist
+ {% include "scouting/partials/watchlist_table.html" with favorites=favorites %}
{% endblock %}
diff --git a/templates/scouting/partials/favorite_button.html b/templates/scouting/partials/favorite_button.html
new file mode 100644
index 0000000..83bb4dc
--- /dev/null
+++ b/templates/scouting/partials/favorite_button.html
@@ -0,0 +1,16 @@
+
diff --git a/templates/scouting/partials/save_search_feedback.html b/templates/scouting/partials/save_search_feedback.html
new file mode 100644
index 0000000..7d4a72f
--- /dev/null
+++ b/templates/scouting/partials/save_search_feedback.html
@@ -0,0 +1,3 @@
+
+ {{ message }}
+
diff --git a/templates/scouting/partials/save_search_form.html b/templates/scouting/partials/save_search_form.html
new file mode 100644
index 0000000..4de011d
--- /dev/null
+++ b/templates/scouting/partials/save_search_form.html
@@ -0,0 +1,27 @@
+
+
Save Current Search
+
Store current filters and replay them later.
+
+
+
+
diff --git a/templates/scouting/partials/saved_search_table.html b/templates/scouting/partials/saved_search_table.html
new file mode 100644
index 0000000..28cd983
--- /dev/null
+++ b/templates/scouting/partials/saved_search_table.html
@@ -0,0 +1,37 @@
+{% if saved_searches %}
+
+
+
+
+ | Name |
+ Visibility |
+ Updated |
+ Last run |
+ Actions |
+
+
+
+ {% for saved_search in saved_searches %}
+
+ | {{ saved_search.name }} |
+ {% if saved_search.is_public %}Public{% else %}Private{% endif %} |
+ {{ saved_search.updated_at|date:"Y-m-d H:i" }} |
+ {{ saved_search.last_run_at|date:"Y-m-d H:i"|default:"-" }} |
+
+
+ |
+
+ {% endfor %}
+
+
+
+{% else %}
+ No saved searches yet.
+{% endif %}
diff --git a/templates/scouting/partials/watchlist_table.html b/templates/scouting/partials/watchlist_table.html
new file mode 100644
index 0000000..a923471
--- /dev/null
+++ b/templates/scouting/partials/watchlist_table.html
@@ -0,0 +1,35 @@
+{% if favorites %}
+
+
+
+
+ | Player |
+ Nationality |
+ Position / Role |
+ Added |
+ Action |
+
+
+
+ {% for favorite in favorites %}
+
+ | {{ favorite.player.full_name }} |
+ {{ favorite.player.nationality.name|default:"-" }} |
+
+ {{ favorite.player.nominal_position.code|default:"-" }}
+ / {{ favorite.player.inferred_role.name|default:"-" }}
+ |
+ {{ favorite.created_at|date:"Y-m-d" }} |
+
+
+ {% include "scouting/partials/favorite_button.html" with player=favorite.player is_favorite=True next_url=request.get_full_path %}
+
+ |
+
+ {% endfor %}
+
+
+
+{% else %}
+ No players in your watchlist yet.
+{% endif %}
diff --git a/templates/scouting/saved_search_edit.html b/templates/scouting/saved_search_edit.html
new file mode 100644
index 0000000..c2c02dc
--- /dev/null
+++ b/templates/scouting/saved_search_edit.html
@@ -0,0 +1,17 @@
+{% extends "base.html" %}
+
+{% block title %}HoopScout | Edit Saved Search{% endblock %}
+
+{% block content %}
+
+{% endblock %}
diff --git a/templates/scouting/saved_search_list.html b/templates/scouting/saved_search_list.html
new file mode 100644
index 0000000..26f92c8
--- /dev/null
+++ b/templates/scouting/saved_search_list.html
@@ -0,0 +1,13 @@
+{% extends "base.html" %}
+
+{% block title %}HoopScout | Saved Searches{% endblock %}
+
+{% block content %}
+
+
+ {% include "scouting/partials/saved_search_table.html" with saved_searches=saved_searches %}
+
+{% endblock %}
diff --git a/templates/scouting/watchlist.html b/templates/scouting/watchlist.html
new file mode 100644
index 0000000..db99173
--- /dev/null
+++ b/templates/scouting/watchlist.html
@@ -0,0 +1,13 @@
+{% extends "base.html" %}
+
+{% block title %}HoopScout | Watchlist{% endblock %}
+
+{% block content %}
+
+
+ {% include "scouting/partials/watchlist_table.html" with favorites=favorites %}
+
+{% endblock %}
diff --git a/tests/test_scouting_views.py b/tests/test_scouting_views.py
new file mode 100644
index 0000000..e7de8aa
--- /dev/null
+++ b/tests/test_scouting_views.py
@@ -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()