Merge branch 'feature/phase-2-player-notes-mvp' into develop
This commit is contained in:
@ -11,6 +11,7 @@ The current application baseline provides:
|
|||||||
- matching season/team/competition context on search results
|
- matching season/team/competition context on search results
|
||||||
- result sorting and pagination
|
- result sorting and pagination
|
||||||
- a shared development shortlist for favorite players
|
- a shared development shortlist for favorite players
|
||||||
|
- shared plain-text scouting notes on player detail pages
|
||||||
|
|
||||||
Accepted technical and product-shaping decisions live in:
|
Accepted technical and product-shaping decisions live in:
|
||||||
- `docs/ARCHITECTURE.md`
|
- `docs/ARCHITECTURE.md`
|
||||||
@ -48,7 +49,8 @@ Accepted technical and product-shaping decisions live in:
|
|||||||
2. Apply migrations with `docker compose --env-file .env -f infra/docker-compose.yml exec -T app python manage.py migrate`.
|
2. Apply migrations with `docker compose --env-file .env -f infra/docker-compose.yml exec -T app python manage.py migrate`.
|
||||||
3. Load sample data with `docker compose --env-file .env -f infra/docker-compose.yml exec -T app python manage.py seed_scouting_data`.
|
3. Load sample data with `docker compose --env-file .env -f infra/docker-compose.yml exec -T app python manage.py seed_scouting_data`.
|
||||||
4. Visit `http://127.0.0.1:8000/players/` to explore the scouting search MVP.
|
4. Visit `http://127.0.0.1:8000/players/` to explore the scouting search MVP.
|
||||||
5. Use `http://127.0.0.1:8000/favorites/` to review the shared development shortlist.
|
5. Use player detail pages to manage shortlist entries and shared scouting notes.
|
||||||
|
6. Use `http://127.0.0.1:8000/favorites/` to review the shared development shortlist.
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@ from .models import (
|
|||||||
Competition,
|
Competition,
|
||||||
FavoritePlayer,
|
FavoritePlayer,
|
||||||
Player,
|
Player,
|
||||||
|
PlayerNote,
|
||||||
PlayerSeason,
|
PlayerSeason,
|
||||||
PlayerSeasonStats,
|
PlayerSeasonStats,
|
||||||
Role,
|
Role,
|
||||||
@ -75,3 +76,9 @@ class PlayerSeasonStatsAdmin(admin.ModelAdmin):
|
|||||||
class FavoritePlayerAdmin(admin.ModelAdmin):
|
class FavoritePlayerAdmin(admin.ModelAdmin):
|
||||||
list_display = ("player", "created_at")
|
list_display = ("player", "created_at")
|
||||||
search_fields = ("player__full_name",)
|
search_fields = ("player__full_name",)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(PlayerNote)
|
||||||
|
class PlayerNoteAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("player", "created_at", "updated_at")
|
||||||
|
search_fields = ("player__full_name", "body")
|
||||||
|
|||||||
30
app/scouting/migrations/0006_playernote.py
Normal file
30
app/scouting/migrations/0006_playernote.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("scouting", "0005_favoriteplayer"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="PlayerNote",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("body", models.TextField()),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
(
|
||||||
|
"player",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="notes",
|
||||||
|
to="scouting.player",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["-created_at", "-id"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -178,3 +178,22 @@ class FavoritePlayer(models.Model):
|
|||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"Favorite: {self.player.full_name}"
|
return f"Favorite: {self.player.full_name}"
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerNote(models.Model):
|
||||||
|
# Phase-2 MVP keeps notes shared within the local development environment so
|
||||||
|
# scouting observations can be used immediately without introducing auth yet.
|
||||||
|
player = models.ForeignKey(
|
||||||
|
Player,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="notes",
|
||||||
|
)
|
||||||
|
body = models.TextField()
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at", "-id"]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"Note for {self.player.full_name}"
|
||||||
|
|||||||
@ -16,6 +16,7 @@
|
|||||||
<li>
|
<li>
|
||||||
<a href="{% url 'scouting:player_detail' entry.player.id %}">{{ entry.player.full_name }}</a>
|
<a href="{% url 'scouting:player_detail' entry.player.id %}">{{ entry.player.full_name }}</a>
|
||||||
({{ entry.player.position }})
|
({{ entry.player.position }})
|
||||||
|
| Notes: {{ entry.note_count }}
|
||||||
<form method="post" action="{% url 'scouting:remove_favorite' entry.player.id %}">
|
<form method="post" action="{% url 'scouting:remove_favorite' entry.player.id %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||||
|
|||||||
@ -51,6 +51,32 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<h2>Scouting Notes</h2>
|
||||||
|
<p>Notes are shared across the local development shortlist workflow in this MVP.</p>
|
||||||
|
<form method="post" action="{% url 'scouting:add_note' player.id %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||||
|
<label for="id_body">Add note</label>
|
||||||
|
<textarea id="id_body" name="body" rows="4" cols="60"></textarea>
|
||||||
|
<button type="submit">Save note</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
{% for note in notes %}
|
||||||
|
<li>
|
||||||
|
<div>{{ note.body|linebreaksbr }}</div>
|
||||||
|
<small>Created: {{ note.created_at }}</small>
|
||||||
|
<form method="post" action="{% url 'scouting:delete_note' player.id note.id %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||||
|
<button type="submit">Delete note</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{% empty %}
|
||||||
|
<li>No notes yet.</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
<h2>Season Contexts</h2>
|
<h2>Season Contexts</h2>
|
||||||
<ul>
|
<ul>
|
||||||
{% for context in contexts %}
|
{% for context in contexts %}
|
||||||
|
|||||||
@ -5,7 +5,7 @@ from django.core.management import call_command
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from .models import Competition, FavoritePlayer, Player, PlayerSeason, PlayerSeasonStats, Role, Season, Specialty, Team
|
from .models import Competition, FavoritePlayer, Player, PlayerNote, PlayerSeason, PlayerSeasonStats, Role, Season, Specialty, Team
|
||||||
|
|
||||||
|
|
||||||
class ScoutingSearchViewsTests(TestCase):
|
class ScoutingSearchViewsTests(TestCase):
|
||||||
@ -475,3 +475,68 @@ class FavoritePlayerViewsTests(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(search_response.status_code, 200)
|
self.assertEqual(search_response.status_code, 200)
|
||||||
self.assertEqual(detail_response.status_code, 200)
|
self.assertEqual(detail_response.status_code, 200)
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerNoteViewsTests(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.player = Player.objects.create(
|
||||||
|
full_name="Notes Prospect",
|
||||||
|
birth_date=date(2001, 8, 8),
|
||||||
|
position="PF",
|
||||||
|
height_cm=Decimal("205.00"),
|
||||||
|
weight_kg=Decimal("97.00"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_adding_note_to_player(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("scouting:add_note", args=[self.player.id]),
|
||||||
|
{
|
||||||
|
"body": "Shows good weak-side help instincts.",
|
||||||
|
"next": reverse("scouting:player_detail", args=[self.player.id]),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertRedirects(response, reverse("scouting:player_detail", args=[self.player.id]))
|
||||||
|
note = PlayerNote.objects.get(player=self.player)
|
||||||
|
self.assertEqual(note.body, "Shows good weak-side help instincts.")
|
||||||
|
|
||||||
|
def test_deleting_note(self):
|
||||||
|
note = PlayerNote.objects.create(player=self.player, body="Needs tighter handle under pressure.")
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("scouting:delete_note", args=[self.player.id, note.id]),
|
||||||
|
{"next": reverse("scouting:player_detail", args=[self.player.id])},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertRedirects(response, reverse("scouting:player_detail", args=[self.player.id]))
|
||||||
|
self.assertFalse(PlayerNote.objects.filter(pk=note.id).exists())
|
||||||
|
|
||||||
|
def test_player_detail_page_shows_notes(self):
|
||||||
|
PlayerNote.objects.create(player=self.player, body="Reliable closeout discipline.")
|
||||||
|
|
||||||
|
response = self.client.get(reverse("scouting:player_detail", args=[self.player.id]))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Scouting Notes")
|
||||||
|
self.assertContains(response, "Reliable closeout discipline.")
|
||||||
|
|
||||||
|
def test_existing_search_shortlist_and_detail_flows_still_load(self):
|
||||||
|
FavoritePlayer.objects.create(player=self.player)
|
||||||
|
|
||||||
|
search_response = self.client.get(reverse("scouting:player_list"))
|
||||||
|
favorites_response = self.client.get(reverse("scouting:favorites_list"))
|
||||||
|
detail_response = self.client.get(reverse("scouting:player_detail", args=[self.player.id]))
|
||||||
|
|
||||||
|
self.assertEqual(search_response.status_code, 200)
|
||||||
|
self.assertEqual(favorites_response.status_code, 200)
|
||||||
|
self.assertEqual(detail_response.status_code, 200)
|
||||||
|
|
||||||
|
def test_favorites_page_shows_note_count(self):
|
||||||
|
FavoritePlayer.objects.create(player=self.player)
|
||||||
|
PlayerNote.objects.create(player=self.player, body="Can defend up a position in small lineups.")
|
||||||
|
PlayerNote.objects.create(player=self.player, body="Late-clock decision making still inconsistent.")
|
||||||
|
|
||||||
|
response = self.client.get(reverse("scouting:favorites_list"))
|
||||||
|
|
||||||
|
self.assertContains(response, "Notes: 2")
|
||||||
|
|||||||
@ -9,5 +9,7 @@ urlpatterns = [
|
|||||||
path("players/<int:player_id>/", views.player_detail, name="player_detail"),
|
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>/favorite/", views.add_favorite, name="add_favorite"),
|
||||||
path("players/<int:player_id>/unfavorite/", views.remove_favorite, name="remove_favorite"),
|
path("players/<int:player_id>/unfavorite/", views.remove_favorite, name="remove_favorite"),
|
||||||
|
path("players/<int:player_id>/notes/add/", views.add_note, name="add_note"),
|
||||||
|
path("players/<int:player_id>/notes/<int:note_id>/delete/", views.delete_note, name="delete_note"),
|
||||||
path("favorites/", views.favorites_list, name="favorites_list"),
|
path("favorites/", views.favorites_list, name="favorites_list"),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -3,14 +3,14 @@ from __future__ import annotations
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
from django.db.models import Exists, OuterRef, Prefetch
|
from django.db.models import Count, Exists, OuterRef, Prefetch
|
||||||
from django.http import HttpResponseRedirect
|
from django.http import HttpResponseRedirect
|
||||||
from django.shortcuts import get_object_or_404, render
|
from django.shortcuts import get_object_or_404, render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.views.decorators.http import require_POST
|
from django.views.decorators.http import require_POST
|
||||||
|
|
||||||
from .forms import PlayerSearchForm
|
from .forms import PlayerSearchForm
|
||||||
from .models import FavoritePlayer, Player, PlayerSeason
|
from .models import FavoritePlayer, Player, PlayerNote, PlayerSeason
|
||||||
|
|
||||||
PAGE_SIZE = 20
|
PAGE_SIZE = 20
|
||||||
PLAYER_SORTS = {
|
PLAYER_SORTS = {
|
||||||
@ -269,7 +269,7 @@ def player_list(request):
|
|||||||
|
|
||||||
def player_detail(request, player_id: int):
|
def player_detail(request, player_id: int):
|
||||||
player = get_object_or_404(
|
player = get_object_or_404(
|
||||||
Player.objects.prefetch_related("roles", "specialties"),
|
Player.objects.prefetch_related("roles", "specialties", "notes"),
|
||||||
pk=player_id,
|
pk=player_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -278,6 +278,7 @@ def player_detail(request, player_id: int):
|
|||||||
.select_related("season", "team", "competition", "stats")
|
.select_related("season", "team", "competition", "stats")
|
||||||
.order_by("-season__start_year", "team__name", "competition__name")
|
.order_by("-season__start_year", "team__name", "competition__name")
|
||||||
)
|
)
|
||||||
|
notes = player.notes.all()
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
@ -285,6 +286,7 @@ def player_detail(request, player_id: int):
|
|||||||
{
|
{
|
||||||
"player": player,
|
"player": player,
|
||||||
"contexts": contexts,
|
"contexts": contexts,
|
||||||
|
"notes": notes,
|
||||||
"is_favorite": FavoritePlayer.objects.filter(player=player).exists(),
|
"is_favorite": FavoritePlayer.objects.filter(player=player).exists(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -304,10 +306,27 @@ def remove_favorite(request, player_id: int):
|
|||||||
return redirect_to_next(request, reverse("scouting:player_detail", args=[player.id]))
|
return redirect_to_next(request, reverse("scouting:player_detail", args=[player.id]))
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
def add_note(request, player_id: int):
|
||||||
|
player = get_object_or_404(Player, pk=player_id)
|
||||||
|
body = (request.POST.get("body") or "").strip()
|
||||||
|
if body:
|
||||||
|
PlayerNote.objects.create(player=player, body=body)
|
||||||
|
return redirect_to_next(request, reverse("scouting:player_detail", args=[player.id]))
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
def delete_note(request, player_id: int, note_id: int):
|
||||||
|
player = get_object_or_404(Player, pk=player_id)
|
||||||
|
PlayerNote.objects.filter(player=player, pk=note_id).delete()
|
||||||
|
return redirect_to_next(request, reverse("scouting:player_detail", args=[player.id]))
|
||||||
|
|
||||||
|
|
||||||
def favorites_list(request):
|
def favorites_list(request):
|
||||||
favorites = list(
|
favorites = list(
|
||||||
FavoritePlayer.objects.select_related("player")
|
FavoritePlayer.objects.select_related("player")
|
||||||
.prefetch_related("player__roles", "player__specialties")
|
.prefetch_related("player__roles", "player__specialties")
|
||||||
|
.annotate(note_count=Count("player__notes"))
|
||||||
.order_by("-created_at", "player__full_name")
|
.order_by("-created_at", "player__full_name")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user