feat: add shared scouting notes mvp

This commit is contained in:
bisco
2026-04-07 17:41:53 +02:00
parent 4f869c1c02
commit 4651746427
9 changed files with 176 additions and 5 deletions

View File

@ -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

View File

@ -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")

View 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"],
},
),
]

View File

@ -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}"

View File

@ -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 }}">

View File

@ -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 %}

View File

@ -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")

View File

@ -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"),
] ]

View File

@ -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")
) )