feat: add scouting shortlist mvp
This commit is contained in:
@ -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",)
|
||||
|
||||
28
app/scouting/migrations/0005_favoriteplayer.py
Normal file
28
app/scouting/migrations/0005_favoriteplayer.py
Normal 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"],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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}"
|
||||
|
||||
30
app/scouting/templates/scouting/favorites_list.html
Normal file
30
app/scouting/templates/scouting/favorites_list.html
Normal 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>
|
||||
@ -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>
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"),
|
||||
]
|
||||
|
||||
@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user