diff --git a/README.md b/README.md
index 6246eb6..591868e 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,7 @@ The current application baseline provides:
- matching season/team/competition context on search results
- result sorting and pagination
- a shared development shortlist for favorite players
+- shared plain-text scouting notes on player detail pages
Accepted technical and product-shaping decisions live in:
- `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`.
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.
-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
diff --git a/app/scouting/admin.py b/app/scouting/admin.py
index 89b1eab..09aa45c 100644
--- a/app/scouting/admin.py
+++ b/app/scouting/admin.py
@@ -4,6 +4,7 @@ from .models import (
Competition,
FavoritePlayer,
Player,
+ PlayerNote,
PlayerSeason,
PlayerSeasonStats,
Role,
@@ -75,3 +76,9 @@ class PlayerSeasonStatsAdmin(admin.ModelAdmin):
class FavoritePlayerAdmin(admin.ModelAdmin):
list_display = ("player", "created_at")
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")
diff --git a/app/scouting/migrations/0006_playernote.py b/app/scouting/migrations/0006_playernote.py
new file mode 100644
index 0000000..54c876a
--- /dev/null
+++ b/app/scouting/migrations/0006_playernote.py
@@ -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"],
+ },
+ ),
+ ]
diff --git a/app/scouting/models.py b/app/scouting/models.py
index a4e46ab..adfff75 100644
--- a/app/scouting/models.py
+++ b/app/scouting/models.py
@@ -178,3 +178,22 @@ class FavoritePlayer(models.Model):
def __str__(self) -> str:
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}"
diff --git a/app/scouting/templates/scouting/favorites_list.html b/app/scouting/templates/scouting/favorites_list.html
index 3ddb9cf..e26766a 100644
--- a/app/scouting/templates/scouting/favorites_list.html
+++ b/app/scouting/templates/scouting/favorites_list.html
@@ -16,6 +16,7 @@
{{ entry.player.full_name }}
({{ entry.player.position }})
+ | Notes: {{ entry.note_count }}
+
+
+
Season Contexts
{% for context in contexts %}
diff --git a/app/scouting/tests.py b/app/scouting/tests.py
index 10d50d5..e4d557f 100644
--- a/app/scouting/tests.py
+++ b/app/scouting/tests.py
@@ -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, FavoritePlayer, Player, PlayerSeason, PlayerSeasonStats, Role, Season, Specialty, Team
+from .models import Competition, FavoritePlayer, Player, PlayerNote, PlayerSeason, PlayerSeasonStats, Role, Season, Specialty, Team
class ScoutingSearchViewsTests(TestCase):
@@ -475,3 +475,68 @@ class FavoritePlayerViewsTests(TestCase):
self.assertEqual(search_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")
diff --git a/app/scouting/urls.py b/app/scouting/urls.py
index 70ec4ef..5c9c06d 100644
--- a/app/scouting/urls.py
+++ b/app/scouting/urls.py
@@ -9,5 +9,7 @@ urlpatterns = [
path("players//", views.player_detail, name="player_detail"),
path("players//favorite/", views.add_favorite, name="add_favorite"),
path("players//unfavorite/", views.remove_favorite, name="remove_favorite"),
+ path("players//notes/add/", views.add_note, name="add_note"),
+ path("players//notes//delete/", views.delete_note, name="delete_note"),
path("favorites/", views.favorites_list, name="favorites_list"),
]
diff --git a/app/scouting/views.py b/app/scouting/views.py
index 1043200..53f3bda 100644
--- a/app/scouting/views.py
+++ b/app/scouting/views.py
@@ -3,14 +3,14 @@ from __future__ import annotations
from decimal import Decimal
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.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 FavoritePlayer, Player, PlayerSeason
+from .models import FavoritePlayer, Player, PlayerNote, PlayerSeason
PAGE_SIZE = 20
PLAYER_SORTS = {
@@ -269,7 +269,7 @@ def player_list(request):
def player_detail(request, player_id: int):
player = get_object_or_404(
- Player.objects.prefetch_related("roles", "specialties"),
+ Player.objects.prefetch_related("roles", "specialties", "notes"),
pk=player_id,
)
@@ -278,6 +278,7 @@ def player_detail(request, player_id: int):
.select_related("season", "team", "competition", "stats")
.order_by("-season__start_year", "team__name", "competition__name")
)
+ notes = player.notes.all()
return render(
request,
@@ -285,6 +286,7 @@ def player_detail(request, player_id: int):
{
"player": player,
"contexts": contexts,
+ "notes": notes,
"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]))
+@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):
favorites = list(
FavoritePlayer.objects.select_related("player")
.prefetch_related("player__roles", "player__specialties")
+ .annotate(note_count=Count("player__notes"))
.order_by("-created_at", "player__full_name")
)