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 @@ + {% if request.user.is_authenticated %}{% endif %} @@ -40,6 +45,15 @@ + {% if request.user.is_authenticated %} + + {% 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 @@ + + {% csrf_token %} + + {% if is_favorite %} + + {% else %} + + {% endif %} + 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.

+ +
+ {% csrf_token %} + + + + {% for key, value in request.GET.items %} + + {% endfor %} + + + +
+
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 %} +
+
PPG RPG APGWatchlist
{{ player.ppg_value|floatformat:1 }} {{ player.rpg_value|floatformat:1 }} {{ player.apg_value|floatformat:1 }} + {% 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 %} +
+ + + + + + + + + + + {% for saved_search in saved_searches %} + + + + + + + + {% endfor %} + +
NameVisibilityUpdatedLast runActions
{{ 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:"-" }} +
+ Run + Edit +
+ {% csrf_token %} + +
+
+
+
+{% 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 %} +
+ + + + + + + + + + + + {% for favorite in favorites %} + + + + + + + + {% endfor %} + +
PlayerNationalityPosition / RoleAddedAction
{{ 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 %} +
+
+
+{% 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 %} +
+

Edit Saved Search

+
+ {% csrf_token %} + {{ form.as_p }} +
+ + Cancel +
+
+
+{% 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 %} +
+
+

Saved Searches

+ Back to scouting +
+ {% 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 %} +
+
+

Watchlist

+ Back to scouting +
+ {% 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()