feat: add scouting shortlist mvp

This commit is contained in:
bisco
2026-04-07 17:29:55 +02:00
parent d1b5499a63
commit 99820419c4
10 changed files with 273 additions and 125 deletions

View File

@ -2,6 +2,7 @@ from django.contrib import admin
from .models import (
Competition,
FavoritePlayer,
Player,
PlayerSeason,
PlayerSeasonStats,
@ -68,3 +69,9 @@ class PlayerSeasonStatsAdmin(admin.ModelAdmin):
"blocks",
)
search_fields = ("player_season__player__full_name", "player_season__season__name")
@admin.register(FavoritePlayer)
class FavoritePlayerAdmin(admin.ModelAdmin):
list_display = ("player", "created_at")
search_fields = ("player__full_name",)

View File

@ -0,0 +1,28 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("scouting", "0004_remove_playerseason_uniq_player_season_and_more"),
]
operations = [
migrations.CreateModel(
name="FavoritePlayer",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"player",
models.OneToOneField(
on_delete=models.CASCADE,
related_name="favorite_entry",
to="scouting.player",
),
),
],
options={
"ordering": ["-created_at", "player__full_name"],
},
),
]

View File

@ -161,3 +161,20 @@ class PlayerSeasonStats(models.Model):
def __str__(self) -> str:
return f"Stats for {self.player_season}"
class FavoritePlayer(models.Model):
# Phase-2 MVP uses a single shared development shortlist instead of user-scoped
# favorites so the workflow stays useful without introducing auth complexity yet.
player = models.OneToOneField(
Player,
on_delete=models.CASCADE,
related_name="favorite_entry",
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-created_at", "player__full_name"]
def __str__(self) -> str:
return f"Favorite: {self.player.full_name}"

View File

@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Shortlist</title>
</head>
<body>
<p>
<a href="{% url 'scouting:player_list' %}">Back to search</a>
</p>
<h1>Shared Development Shortlist</h1>
<p>This MVP shortlist is shared across the local development environment.</p>
<ul>
{% for entry in favorites %}
<li>
<a href="{% url 'scouting:player_detail' entry.player.id %}">{{ entry.player.full_name }}</a>
({{ entry.player.position }})
<form method="post" action="{% url 'scouting:remove_favorite' entry.player.id %}">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button type="submit">Remove from shortlist</button>
</form>
</li>
{% empty %}
<li>No shortlisted players yet.</li>
{% endfor %}
</ul>
</body>
</html>

View File

@ -5,9 +5,27 @@
<title>{{ player.full_name }}</title>
</head>
<body>
<p><a href="{% url 'scouting:player_list' %}">Back to search</a></p>
<p>
<a href="{% url 'scouting:player_list' %}">Back to search</a>
| <a href="{% url 'scouting:favorites_list' %}">View shortlist</a>
</p>
<h1>{{ player.full_name }}</h1>
{% if is_favorite %}
<p><strong>On the shared development shortlist.</strong></p>
<form method="post" action="{% url 'scouting:remove_favorite' player.id %}">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button type="submit">Remove from shortlist</button>
</form>
{% else %}
<form method="post" action="{% url 'scouting:add_favorite' player.id %}">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button type="submit">Add to shortlist</button>
</form>
{% endif %}
<p>Position: {{ player.position }}</p>
<p>Nationality: {{ player.nationality|default:"-" }}</p>
<p>Birth date: {{ player.birth_date|default:"-" }}</p>

View File

@ -6,6 +6,7 @@
</head>
<body>
<h1>Scout Search</h1>
<p><a href="{% url 'scouting:favorites_list' %}">View shortlist</a></p>
<form method="get">
<fieldset>
@ -64,6 +65,20 @@
<li>
<a href="{% url 'scouting:player_detail' player.id %}">{{ player.full_name }}</a>
({{ player.position }})
{% if player.is_favorite %}
<strong>Shortlisted</strong>
<form method="post" action="{% url 'scouting:remove_favorite' player.id %}">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button type="submit">Remove from shortlist</button>
</form>
{% else %}
<form method="post" action="{% url 'scouting:add_favorite' player.id %}">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button type="submit">Add to shortlist</button>
</form>
{% endif %}
{% if player.matching_context %}
<div>
Match context:

View File

@ -5,7 +5,7 @@ from django.core.management import call_command
from django.test import TestCase
from django.urls import reverse
from .models import Competition, Player, PlayerSeason, PlayerSeasonStats, Role, Season, Specialty, Team
from .models import Competition, FavoritePlayer, Player, PlayerSeason, PlayerSeasonStats, Role, Season, Specialty, Team
class ScoutingSearchViewsTests(TestCase):
@ -411,3 +411,67 @@ class SeedScoutingDataCommandTests(TestCase):
self.assertEqual(PlayerSeasonStats.objects.count(), first_counts["stats"])
self.assertEqual(Role.objects.count(), first_counts["roles"])
self.assertEqual(Specialty.objects.count(), first_counts["specialties"])
class FavoritePlayerViewsTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.player = Player.objects.create(
full_name="Favorite Prospect",
birth_date=date(2001, 4, 4),
position="PG",
height_cm=Decimal("187.00"),
weight_kg=Decimal("81.00"),
)
cls.other_player = Player.objects.create(
full_name="Other Prospect",
birth_date=date(2000, 6, 6),
position="SF",
height_cm=Decimal("202.00"),
weight_kg=Decimal("94.00"),
)
def test_adding_player_to_favorites(self):
response = self.client.post(
reverse("scouting:add_favorite", args=[self.player.id]),
{"next": reverse("scouting:player_detail", args=[self.player.id])},
)
self.assertRedirects(response, reverse("scouting:player_detail", args=[self.player.id]))
self.assertTrue(FavoritePlayer.objects.filter(player=self.player).exists())
def test_removing_player_from_favorites(self):
FavoritePlayer.objects.create(player=self.player)
response = self.client.post(
reverse("scouting:remove_favorite", args=[self.player.id]),
{"next": reverse("scouting:favorites_list")},
)
self.assertRedirects(response, reverse("scouting:favorites_list"))
self.assertFalse(FavoritePlayer.objects.filter(player=self.player).exists())
def test_favorites_list_page_loads(self):
FavoritePlayer.objects.create(player=self.player)
response = self.client.get(reverse("scouting:favorites_list"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Favorite Prospect")
def test_favorite_state_is_visible_on_detail_and_search_pages(self):
FavoritePlayer.objects.create(player=self.player)
detail_response = self.client.get(reverse("scouting:player_detail", args=[self.player.id]))
list_response = self.client.get(reverse("scouting:player_list"))
self.assertContains(detail_response, "On the shared development shortlist.")
self.assertContains(detail_response, "Remove from shortlist")
self.assertContains(list_response, "Shortlisted")
def test_search_and_detail_pages_still_load_after_favorites_integration(self):
search_response = self.client.get(reverse("scouting:player_list"))
detail_response = self.client.get(reverse("scouting:player_detail", args=[self.player.id]))
self.assertEqual(search_response.status_code, 200)
self.assertEqual(detail_response.status_code, 200)

View File

@ -7,4 +7,7 @@ app_name = "scouting"
urlpatterns = [
path("players/", views.player_list, name="player_list"),
path("players/<int:player_id>/", views.player_detail, name="player_detail"),
path("players/<int:player_id>/favorite/", views.add_favorite, name="add_favorite"),
path("players/<int:player_id>/unfavorite/", views.remove_favorite, name="remove_favorite"),
path("favorites/", views.favorites_list, name="favorites_list"),
]

View File

@ -4,10 +4,13 @@ from decimal import Decimal
from django.core.paginator import Paginator
from django.db.models import Exists, OuterRef, Prefetch
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, render
from django.urls import reverse
from django.views.decorators.http import require_POST
from .forms import PlayerSearchForm
from .models import Player, PlayerSeason
from .models import FavoritePlayer, Player, PlayerSeason
PAGE_SIZE = 20
PLAYER_SORTS = {
@ -25,6 +28,19 @@ CONTEXT_SORTS = {
}
def apply_favorite_state(players):
favorite_ids = set(FavoritePlayer.objects.values_list("player_id", flat=True))
for player in players:
player.is_favorite = player.id in favorite_ids
def redirect_to_next(request, fallback_url):
next_url = request.POST.get("next")
if next_url and next_url.startswith("/"):
return HttpResponseRedirect(next_url)
return HttpResponseRedirect(fallback_url)
def sort_players(players, sort_key: str, context_filters_used: bool):
if sort_key not in PLAYER_SORTS | set(CONTEXT_SORTS):
sort_key = "name_asc"
@ -232,6 +248,7 @@ def player_list(request):
active_sort = sort_players(players, requested_sort, context_filters_used)
paginator = Paginator(players, PAGE_SIZE)
page_obj = paginator.get_page(request.GET.get("page"))
apply_favorite_state(page_obj.object_list)
query_without_page = request.GET.copy()
query_without_page.pop("page", None)
@ -268,5 +285,39 @@ def player_detail(request, player_id: int):
{
"player": player,
"contexts": contexts,
"is_favorite": FavoritePlayer.objects.filter(player=player).exists(),
},
)
@require_POST
def add_favorite(request, player_id: int):
player = get_object_or_404(Player, pk=player_id)
FavoritePlayer.objects.get_or_create(player=player)
return redirect_to_next(request, reverse("scouting:player_detail", args=[player.id]))
@require_POST
def remove_favorite(request, player_id: int):
player = get_object_or_404(Player, pk=player_id)
FavoritePlayer.objects.filter(player=player).delete()
return redirect_to_next(request, reverse("scouting:player_detail", args=[player.id]))
def favorites_list(request):
favorites = list(
FavoritePlayer.objects.select_related("player")
.prefetch_related("player__roles", "player__specialties")
.order_by("-created_at", "player__full_name")
)
for entry in favorites:
entry.player.is_favorite = True
return render(
request,
"scouting/favorites_list.html",
{
"favorites": favorites,
},
)