feat: add user-scoped favorites and notes

This commit is contained in:
bisco
2026-04-07 18:11:19 +02:00
parent a5e1d841df
commit caa1f8354d
12 changed files with 364 additions and 93 deletions

View File

@ -1,12 +1,17 @@
from datetime import date
from decimal import Decimal
from django.contrib.auth import get_user_model
from django.core.management import call_command
from django.test import TestCase
from django.db import connection
from django.db.migrations.executor import MigrationExecutor
from django.test import TestCase, TransactionTestCase
from django.urls import reverse
from .models import Competition, FavoritePlayer, Player, PlayerNote, PlayerSeason, PlayerSeasonStats, Role, Season, Specialty, Team
User = get_user_model()
class ScoutingSearchViewsTests(TestCase):
@classmethod
@ -416,6 +421,8 @@ class SeedScoutingDataCommandTests(TestCase):
class FavoritePlayerViewsTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(username="scout_a", password="pass12345")
cls.other_user = User.objects.create_user(username="scout_b", password="pass12345")
cls.player = Player.objects.create(
full_name="Favorite Prospect",
birth_date=date(2001, 4, 4),
@ -431,17 +438,31 @@ class FavoritePlayerViewsTests(TestCase):
weight_kg=Decimal("94.00"),
)
def test_adding_player_to_favorites(self):
def test_login_page_loads(self):
response = self.client.get(reverse("login"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Log In")
def test_login_works_for_existing_user(self):
response = self.client.post(
reverse("login"),
{"username": "scout_a", "password": "pass12345"},
)
self.assertEqual(response.status_code, 302)
def test_authenticated_user_can_add_player_to_favorites(self):
self.client.force_login(self.user)
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())
self.assertTrue(FavoritePlayer.objects.filter(user=self.user, player=self.player).exists())
def test_removing_player_from_favorites(self):
FavoritePlayer.objects.create(player=self.player)
def test_authenticated_user_can_remove_player_from_favorites(self):
FavoritePlayer.objects.create(user=self.user, player=self.player)
self.client.force_login(self.user)
response = self.client.post(
reverse("scouting:remove_favorite", args=[self.player.id]),
@ -449,37 +470,55 @@ class FavoritePlayerViewsTests(TestCase):
)
self.assertRedirects(response, reverse("scouting:favorites_list"))
self.assertFalse(FavoritePlayer.objects.filter(player=self.player).exists())
self.assertFalse(FavoritePlayer.objects.filter(user=self.user, player=self.player).exists())
def test_favorites_list_page_loads(self):
FavoritePlayer.objects.create(player=self.player)
def test_unauthenticated_user_cannot_add_or_remove_favorites(self):
add_response = self.client.post(reverse("scouting:add_favorite", args=[self.player.id]))
self.assertRedirects(add_response, f"{reverse('login')}?next={reverse('scouting:add_favorite', args=[self.player.id])}")
FavoritePlayer.objects.create(user=self.user, player=self.player)
remove_response = self.client.post(reverse("scouting:remove_favorite", args=[self.player.id]))
self.assertRedirects(remove_response, f"{reverse('login')}?next={reverse('scouting:remove_favorite', args=[self.player.id])}")
def test_favorites_page_is_user_scoped(self):
FavoritePlayer.objects.create(user=self.user, player=self.player)
FavoritePlayer.objects.create(user=self.other_user, player=self.other_player)
self.client.force_login(self.user)
response = self.client.get(reverse("scouting:favorites_list"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Favorite Prospect")
self.assertNotContains(response, "Other Prospect")
def test_favorite_state_is_visible_on_detail_and_search_pages(self):
FavoritePlayer.objects.create(player=self.player)
def test_favorite_state_is_visible_on_detail_and_search_pages_for_logged_in_user(self):
FavoritePlayer.objects.create(user=self.user, player=self.player)
self.client.force_login(self.user)
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, "On your 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):
def test_search_and_detail_pages_still_load_after_user_scoping(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)
def test_favorites_page_requires_login(self):
response = self.client.get(reverse("scouting:favorites_list"))
self.assertRedirects(response, f"{reverse('login')}?next={reverse('scouting:favorites_list')}")
class PlayerNoteViewsTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(username="note_owner", password="pass12345")
cls.other_user = User.objects.create_user(username="note_other", password="pass12345")
cls.player = Player.objects.create(
full_name="Notes Prospect",
birth_date=date(2001, 8, 8),
@ -488,7 +527,8 @@ class PlayerNoteViewsTests(TestCase):
weight_kg=Decimal("97.00"),
)
def test_adding_note_to_player(self):
def test_authenticated_user_can_add_note_to_player(self):
self.client.force_login(self.user)
response = self.client.post(
reverse("scouting:add_note", args=[self.player.id]),
{
@ -498,11 +538,16 @@ class PlayerNoteViewsTests(TestCase):
)
self.assertRedirects(response, reverse("scouting:player_detail", args=[self.player.id]))
note = PlayerNote.objects.get(player=self.player)
note = PlayerNote.objects.get(player=self.player, user=self.user)
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.")
def test_authenticated_user_can_delete_note(self):
note = PlayerNote.objects.create(
user=self.user,
player=self.player,
body="Needs tighter handle under pressure.",
)
self.client.force_login(self.user)
response = self.client.post(
reverse("scouting:delete_note", args=[self.player.id, note.id]),
@ -512,17 +557,35 @@ class PlayerNoteViewsTests(TestCase):
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.")
def test_unauthenticated_user_cannot_add_or_delete_notes(self):
add_response = self.client.post(
reverse("scouting:add_note", args=[self.player.id]),
{"body": "Test note"},
)
self.assertRedirects(add_response, f"{reverse('login')}?next={reverse('scouting:add_note', args=[self.player.id])}")
note = PlayerNote.objects.create(user=self.user, player=self.player, body="Existing note")
delete_response = self.client.post(reverse("scouting:delete_note", args=[self.player.id, note.id]))
self.assertRedirects(
delete_response,
f"{reverse('login')}?next={reverse('scouting:delete_note', args=[self.player.id, note.id])}",
)
def test_player_detail_page_shows_only_current_users_notes(self):
PlayerNote.objects.create(user=self.user, player=self.player, body="Reliable closeout discipline.")
PlayerNote.objects.create(user=self.other_user, player=self.player, body="Other scout private note.")
self.client.force_login(self.user)
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.")
self.assertNotContains(response, "Other scout private note.")
def test_existing_search_shortlist_and_detail_flows_still_load(self):
FavoritePlayer.objects.create(player=self.player)
FavoritePlayer.objects.create(user=self.user, player=self.player)
self.client.force_login(self.user)
search_response = self.client.get(reverse("scouting:player_list"))
favorites_response = self.client.get(reverse("scouting:favorites_list"))
@ -533,10 +596,44 @@ class PlayerNoteViewsTests(TestCase):
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.")
FavoritePlayer.objects.create(user=self.user, player=self.player)
PlayerNote.objects.create(user=self.user, player=self.player, body="Can defend up a position in small lineups.")
PlayerNote.objects.create(user=self.user, player=self.player, body="Late-clock decision making still inconsistent.")
PlayerNote.objects.create(user=self.other_user, player=self.player, body="Other user's note should not count.")
self.client.force_login(self.user)
response = self.client.get(reverse("scouting:favorites_list"))
self.assertContains(response, "Notes: 2")
class UserScopedMigrationTests(TransactionTestCase):
reset_sequences = True
migrate_from = [("scouting", "0006_playernote")]
migrate_to = [("scouting", "0007_user_scoped_favorites_and_notes")]
def setUp(self):
super().setUp()
self.executor = MigrationExecutor(connection)
self.executor.migrate(self.migrate_from)
old_apps = self.executor.loader.project_state(self.migrate_from).apps
player_model = old_apps.get_model("scouting", "Player")
favorite_model = old_apps.get_model("scouting", "FavoritePlayer")
note_model = old_apps.get_model("scouting", "PlayerNote")
player = player_model.objects.create(full_name="Legacy Shared Player", position="PG")
favorite_model.objects.create(player=player)
note_model.objects.create(player=player, body="Legacy shared note")
self.executor = MigrationExecutor(connection)
self.executor.migrate(self.migrate_to)
def test_legacy_shared_rows_are_cleared_by_user_scope_migration(self):
apps = self.executor.loader.project_state(self.migrate_to).apps
favorite_model = apps.get_model("scouting", "FavoritePlayer")
note_model = apps.get_model("scouting", "PlayerNote")
self.assertEqual(favorite_model.objects.count(), 0)
self.assertEqual(note_model.objects.count(), 0)