772 lines
32 KiB
Python
772 lines
32 KiB
Python
from datetime import date
|
|
from decimal import Decimal
|
|
from urllib.parse import urlencode
|
|
|
|
from django.contrib.auth import get_user_model
|
|
from django.core.management import call_command
|
|
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,
|
|
SavedSearch,
|
|
Season,
|
|
Specialty,
|
|
Team,
|
|
)
|
|
|
|
User = get_user_model()
|
|
|
|
|
|
class ScoutingSearchViewsTests(TestCase):
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.role_playmaker = Role.objects.create(name="playmaker", slug="playmaker")
|
|
cls.role_3d = Role.objects.create(name="3-and-D", slug="3-and-d")
|
|
cls.specialty_defense = Specialty.objects.create(name="defense", slug="defense")
|
|
cls.specialty_offball = Specialty.objects.create(name="off ball", slug="off-ball")
|
|
|
|
cls.comp_a = Competition.objects.create(name="League A")
|
|
cls.comp_b = Competition.objects.create(name="League B")
|
|
cls.team_a = Team.objects.create(name="Team A", country="IT")
|
|
cls.team_b = Team.objects.create(name="Team B", country="IT")
|
|
cls.season_2025 = Season.objects.create(name="2025-2026", start_year=2025, end_year=2026)
|
|
cls.season_2024 = Season.objects.create(name="2024-2025", start_year=2024, end_year=2025)
|
|
|
|
cls.player_pg = Player.objects.create(
|
|
full_name="Marco Guard",
|
|
birth_date=date(2002, 1, 1),
|
|
position="PG",
|
|
height_cm=Decimal("188.00"),
|
|
weight_kg=Decimal("82.00"),
|
|
wingspan_cm=Decimal("194.00"),
|
|
nationality="IT",
|
|
)
|
|
cls.player_pg.roles.add(cls.role_playmaker)
|
|
cls.player_pg.specialties.add(cls.specialty_defense)
|
|
|
|
cls.player_wing = Player.objects.create(
|
|
full_name="Luca Wing",
|
|
birth_date=date(1998, 2, 2),
|
|
position="SF",
|
|
height_cm=Decimal("201.00"),
|
|
weight_kg=Decimal("95.00"),
|
|
wingspan_cm=Decimal("211.00"),
|
|
)
|
|
cls.player_wing.roles.add(cls.role_3d)
|
|
cls.player_wing.specialties.add(cls.specialty_offball)
|
|
|
|
cls.ctx_pg_good = PlayerSeason.objects.create(
|
|
player=cls.player_pg,
|
|
season=cls.season_2025,
|
|
team=cls.team_a,
|
|
competition=cls.comp_a,
|
|
)
|
|
PlayerSeasonStats.objects.create(
|
|
player_season=cls.ctx_pg_good,
|
|
points=Decimal("16.00"),
|
|
assists=Decimal("7.50"),
|
|
steals=Decimal("1.80"),
|
|
turnovers=Decimal("2.10"),
|
|
blocks=Decimal("0.30"),
|
|
efg_pct=Decimal("53.20"),
|
|
ts_pct=Decimal("58.10"),
|
|
plus_minus=Decimal("4.20"),
|
|
offensive_rating=Decimal("112.00"),
|
|
defensive_rating=Decimal("104.00"),
|
|
)
|
|
|
|
cls.ctx_pg_other = PlayerSeason.objects.create(
|
|
player=cls.player_pg,
|
|
season=cls.season_2024,
|
|
team=cls.team_b,
|
|
competition=cls.comp_b,
|
|
)
|
|
PlayerSeasonStats.objects.create(
|
|
player_season=cls.ctx_pg_other,
|
|
points=Decimal("10.00"),
|
|
assists=Decimal("4.00"),
|
|
steals=Decimal("1.00"),
|
|
turnovers=Decimal("3.50"),
|
|
blocks=Decimal("0.20"),
|
|
efg_pct=Decimal("48.00"),
|
|
ts_pct=Decimal("50.00"),
|
|
plus_minus=Decimal("-2.00"),
|
|
offensive_rating=Decimal("101.00"),
|
|
defensive_rating=Decimal("112.00"),
|
|
)
|
|
|
|
cls.ctx_wing = PlayerSeason.objects.create(
|
|
player=cls.player_wing,
|
|
season=cls.season_2025,
|
|
team=cls.team_b,
|
|
competition=cls.comp_a,
|
|
)
|
|
PlayerSeasonStats.objects.create(
|
|
player_season=cls.ctx_wing,
|
|
points=Decimal("14.00"),
|
|
assists=Decimal("2.00"),
|
|
steals=Decimal("1.20"),
|
|
turnovers=Decimal("1.90"),
|
|
blocks=Decimal("0.70"),
|
|
efg_pct=Decimal("55.00"),
|
|
ts_pct=Decimal("60.00"),
|
|
plus_minus=Decimal("1.50"),
|
|
offensive_rating=Decimal("108.00"),
|
|
defensive_rating=Decimal("106.00"),
|
|
)
|
|
|
|
def test_player_list_page_loads(self):
|
|
response = self.client.get(reverse("scouting:player_list"))
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(response, "Scout Search")
|
|
|
|
def test_player_detail_page_loads(self):
|
|
response = self.client.get(reverse("scouting:player_detail", args=[self.player_pg.id]))
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(response, self.player_pg.full_name)
|
|
|
|
def test_filter_by_player_level_fields(self):
|
|
response = self.client.get(
|
|
reverse("scouting:player_list"),
|
|
{"position": "PG", "role": self.role_playmaker.id, "specialty": self.specialty_defense.id},
|
|
)
|
|
self.assertContains(response, self.player_pg.full_name)
|
|
self.assertNotContains(response, self.player_wing.full_name)
|
|
|
|
def test_filter_by_wingspan_thresholds(self):
|
|
response = self.client.get(
|
|
reverse("scouting:player_list"),
|
|
{"min_wingspan_cm": "205"},
|
|
)
|
|
|
|
self.assertContains(response, self.player_wing.full_name)
|
|
self.assertNotContains(response, self.player_pg.full_name)
|
|
|
|
def test_filter_by_context_fields_and_stats(self):
|
|
response = self.client.get(
|
|
reverse("scouting:player_list"),
|
|
{
|
|
"competition": self.comp_a.id,
|
|
"season": self.season_2025.id,
|
|
"team": self.team_a.id,
|
|
"min_ts_pct": "57",
|
|
},
|
|
)
|
|
self.assertContains(response, self.player_pg.full_name)
|
|
self.assertNotContains(response, self.player_wing.full_name)
|
|
|
|
def test_result_row_shows_matching_context_for_filtered_search(self):
|
|
response = self.client.get(
|
|
reverse("scouting:player_list"),
|
|
{
|
|
"competition": self.comp_a.id,
|
|
"season": self.season_2025.id,
|
|
"team": self.team_a.id,
|
|
"min_ts_pct": "57",
|
|
},
|
|
)
|
|
player = next(player for player in response.context["players"] if player.id == self.player_pg.id)
|
|
self.assertEqual(player.matching_context.id, self.ctx_pg_good.id)
|
|
self.assertContains(response, "Match context:")
|
|
self.assertContains(response, self.season_2025.name)
|
|
self.assertContains(response, "PTS 16.00")
|
|
|
|
def test_result_row_does_not_show_non_matching_context(self):
|
|
response = self.client.get(
|
|
reverse("scouting:player_list"),
|
|
{
|
|
"competition": self.comp_a.id,
|
|
"season": self.season_2025.id,
|
|
"team": self.team_a.id,
|
|
"min_ts_pct": "57",
|
|
},
|
|
)
|
|
player = next(player for player in response.context["players"] if player.id == self.player_pg.id)
|
|
self.assertEqual(player.matching_context.id, self.ctx_pg_good.id)
|
|
self.assertNotContains(response, "PTS 10.00")
|
|
self.assertNotContains(response, "AST 4.00")
|
|
|
|
def test_no_false_positive_from_different_context_rows(self):
|
|
response = self.client.get(
|
|
reverse("scouting:player_list"),
|
|
{
|
|
"team": self.team_b.id,
|
|
"season": self.season_2024.id,
|
|
"min_ts_pct": "57",
|
|
},
|
|
)
|
|
self.assertNotContains(response, self.player_pg.full_name)
|
|
|
|
def test_combined_pg_under_age_with_assists(self):
|
|
response = self.client.get(
|
|
reverse("scouting:player_list"),
|
|
{
|
|
"position": "PG",
|
|
"max_age": "25",
|
|
"min_assists": "7",
|
|
},
|
|
)
|
|
self.assertContains(response, self.player_pg.full_name)
|
|
self.assertNotContains(response, self.player_wing.full_name)
|
|
|
|
def test_matching_context_selection_is_deterministic_when_multiple_contexts_match(self):
|
|
second_matching_context = PlayerSeason.objects.create(
|
|
player=self.player_pg,
|
|
season=self.season_2024,
|
|
team=self.team_a,
|
|
competition=self.comp_a,
|
|
)
|
|
PlayerSeasonStats.objects.create(
|
|
player_season=second_matching_context,
|
|
points=Decimal("18.00"),
|
|
assists=Decimal("6.20"),
|
|
steals=Decimal("1.50"),
|
|
turnovers=Decimal("2.00"),
|
|
blocks=Decimal("0.20"),
|
|
efg_pct=Decimal("52.00"),
|
|
ts_pct=Decimal("57.50"),
|
|
plus_minus=Decimal("3.50"),
|
|
offensive_rating=Decimal("110.00"),
|
|
defensive_rating=Decimal("105.00"),
|
|
)
|
|
|
|
response = self.client.get(
|
|
reverse("scouting:player_list"),
|
|
{
|
|
"competition": self.comp_a.id,
|
|
"team": self.team_a.id,
|
|
},
|
|
)
|
|
player = next(player for player in response.context["players"] if player.id == self.player_pg.id)
|
|
self.assertEqual(player.matching_context.id, self.ctx_pg_good.id)
|
|
self.assertContains(response, self.season_2025.name)
|
|
self.assertNotContains(response, "PTS 18.00")
|
|
|
|
def test_player_detail_page_still_loads_after_related_loading_cleanup(self):
|
|
response = self.client.get(reverse("scouting:player_detail", args=[self.player_pg.id]))
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(response, "PTS 16.00")
|
|
|
|
def test_combined_team_and_defensive_rating_quality_filter(self):
|
|
response = self.client.get(
|
|
reverse("scouting:player_list"),
|
|
{
|
|
"team": self.team_a.id,
|
|
"max_defensive_rating": "105",
|
|
},
|
|
)
|
|
self.assertContains(response, self.player_pg.full_name)
|
|
self.assertNotContains(response, self.player_wing.full_name)
|
|
|
|
def test_sorting_by_player_level_field(self):
|
|
taller_player = Player.objects.create(
|
|
full_name="Big Wing",
|
|
birth_date=date(2001, 5, 5),
|
|
position="SF",
|
|
height_cm=Decimal("208.00"),
|
|
weight_kg=Decimal("101.00"),
|
|
)
|
|
|
|
response = self.client.get(reverse("scouting:player_list"), {"sort": "height_desc"})
|
|
|
|
player_names = [player.full_name for player in response.context["players"]]
|
|
self.assertEqual(player_names[:3], [taller_player.full_name, self.player_wing.full_name, self.player_pg.full_name])
|
|
|
|
def test_sorting_by_matching_context_stat_field(self):
|
|
response = self.client.get(
|
|
reverse("scouting:player_list"),
|
|
{"competition": self.comp_a.id, "sort": "assists_desc"},
|
|
)
|
|
|
|
player_names = [player.full_name for player in response.context["players"]]
|
|
self.assertEqual(player_names[:2], [self.player_pg.full_name, self.player_wing.full_name])
|
|
|
|
def test_context_sorting_preserves_matching_context_semantics(self):
|
|
second_context = PlayerSeason.objects.create(
|
|
player=self.player_pg,
|
|
season=self.season_2024,
|
|
team=self.team_b,
|
|
competition=self.comp_a,
|
|
)
|
|
PlayerSeasonStats.objects.create(
|
|
player_season=second_context,
|
|
points=Decimal("12.00"),
|
|
assists=Decimal("9.00"),
|
|
steals=Decimal("1.00"),
|
|
turnovers=Decimal("2.80"),
|
|
blocks=Decimal("0.20"),
|
|
efg_pct=Decimal("49.00"),
|
|
ts_pct=Decimal("52.00"),
|
|
plus_minus=Decimal("1.00"),
|
|
offensive_rating=Decimal("107.00"),
|
|
defensive_rating=Decimal("109.00"),
|
|
)
|
|
|
|
response = self.client.get(
|
|
reverse("scouting:player_list"),
|
|
{"competition": self.comp_a.id, "team": self.team_a.id, "sort": "assists_desc"},
|
|
)
|
|
|
|
player = next(player for player in response.context["players"] if player.id == self.player_pg.id)
|
|
self.assertEqual(player.matching_context.id, self.ctx_pg_good.id)
|
|
self.assertContains(response, "AST 7.50")
|
|
self.assertNotContains(response, "AST 9.00")
|
|
|
|
def test_pagination_works_on_player_list(self):
|
|
for index in range(25):
|
|
Player.objects.create(
|
|
full_name=f"Depth Player {index:02d}",
|
|
birth_date=date(2000, 1, 1),
|
|
position="SG",
|
|
height_cm=Decimal("190.00"),
|
|
weight_kg=Decimal("84.00"),
|
|
)
|
|
|
|
response = self.client.get(reverse("scouting:player_list"), {"page": 2})
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertEqual(response.context["page_obj"].number, 2)
|
|
self.assertContains(response, "Page 2 of 2")
|
|
self.assertContains(response, "Depth Player 20")
|
|
self.assertContains(response, "Depth Player 24")
|
|
self.assertNotContains(response, "Depth Player 00")
|
|
|
|
def test_pagination_preserves_filters_and_sort_order(self):
|
|
for index in range(25):
|
|
Player.objects.create(
|
|
full_name=f"Guard Prospect {index:02d}",
|
|
birth_date=date(2001, 1, 1),
|
|
position="PG",
|
|
height_cm=Decimal("185.00"),
|
|
weight_kg=Decimal("80.00"),
|
|
)
|
|
|
|
response = self.client.get(
|
|
reverse("scouting:player_list"),
|
|
{"position": "PG", "sort": "name_desc"},
|
|
)
|
|
|
|
self.assertContains(response, "position=PG")
|
|
self.assertContains(response, "sort=name_desc")
|
|
self.assertContains(response, "page=2")
|
|
|
|
def test_combined_filters_sort_and_pagination(self):
|
|
for index in range(25):
|
|
player = Player.objects.create(
|
|
full_name=f"Playmaker Prospect {index:02d}",
|
|
birth_date=date(2003, 1, 1),
|
|
position="PG",
|
|
height_cm=Decimal("186.00"),
|
|
weight_kg=Decimal("79.00"),
|
|
)
|
|
context = PlayerSeason.objects.create(
|
|
player=player,
|
|
season=self.season_2025,
|
|
team=self.team_a,
|
|
competition=self.comp_a,
|
|
)
|
|
PlayerSeasonStats.objects.create(
|
|
player_season=context,
|
|
points=Decimal("10.00"),
|
|
assists=Decimal(str(30 - index)),
|
|
steals=Decimal("1.00"),
|
|
turnovers=Decimal("2.00"),
|
|
blocks=Decimal("0.10"),
|
|
efg_pct=Decimal("50.00"),
|
|
ts_pct=Decimal("56.00"),
|
|
plus_minus=Decimal("1.00"),
|
|
offensive_rating=Decimal("109.00"),
|
|
defensive_rating=Decimal("108.00"),
|
|
)
|
|
|
|
response = self.client.get(
|
|
reverse("scouting:player_list"),
|
|
{
|
|
"position": "PG",
|
|
"competition": self.comp_a.id,
|
|
"sort": "assists_desc",
|
|
"page": 2,
|
|
},
|
|
)
|
|
|
|
self.assertEqual(response.context["page_obj"].number, 2)
|
|
player_names = [player.full_name for player in response.context["players"]]
|
|
self.assertNotIn(self.player_wing.full_name, player_names)
|
|
self.assertEqual(player_names[0], "Playmaker Prospect 20")
|
|
self.assertIn("Marco Guard", player_names)
|
|
self.assertContains(response, "sort=assists_desc")
|
|
self.assertContains(response, "competition=%s" % self.comp_a.id)
|
|
|
|
|
|
class SeedScoutingDataCommandTests(TestCase):
|
|
def test_seed_command_creates_expected_core_objects(self):
|
|
call_command("seed_scouting_data")
|
|
|
|
self.assertGreaterEqual(Competition.objects.count(), 3)
|
|
self.assertGreaterEqual(Team.objects.count(), 4)
|
|
self.assertGreaterEqual(Season.objects.count(), 3)
|
|
self.assertGreaterEqual(Player.objects.count(), 5)
|
|
self.assertGreaterEqual(PlayerSeason.objects.count(), 6)
|
|
self.assertEqual(PlayerSeason.objects.count(), PlayerSeasonStats.objects.count())
|
|
|
|
player = Player.objects.get(full_name="Marco Guard")
|
|
self.assertEqual(player.position, Player.Position.PG)
|
|
self.assertTrue(player.roles.filter(slug="playmaker").exists())
|
|
self.assertTrue(player.specialties.filter(slug="ball-handling").exists())
|
|
|
|
def test_seed_command_is_idempotent_for_repeat_runs(self):
|
|
call_command("seed_scouting_data")
|
|
first_counts = {
|
|
"players": Player.objects.count(),
|
|
"contexts": PlayerSeason.objects.count(),
|
|
"stats": PlayerSeasonStats.objects.count(),
|
|
"roles": Role.objects.count(),
|
|
"specialties": Specialty.objects.count(),
|
|
}
|
|
|
|
call_command("seed_scouting_data")
|
|
|
|
self.assertEqual(Player.objects.count(), first_counts["players"])
|
|
self.assertEqual(PlayerSeason.objects.count(), first_counts["contexts"])
|
|
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.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),
|
|
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_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(user=self.user, player=self.player).exists())
|
|
|
|
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]),
|
|
{"next": reverse("scouting:favorites_list")},
|
|
)
|
|
|
|
self.assertRedirects(response, reverse("scouting:favorites_list"))
|
|
self.assertFalse(FavoritePlayer.objects.filter(user=self.user, player=self.player).exists())
|
|
|
|
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_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 your shortlist.")
|
|
self.assertContains(detail_response, "Remove from shortlist")
|
|
self.assertContains(list_response, "Shortlisted")
|
|
|
|
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 SavedSearchViewsTests(TestCase):
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.user = User.objects.create_user(username="saved_owner", password="pass12345")
|
|
cls.other_user = User.objects.create_user(username="saved_other", password="pass12345")
|
|
cls.player_guard = Player.objects.create(
|
|
full_name="Saved Guard",
|
|
birth_date=date(2002, 7, 7),
|
|
position="PG",
|
|
height_cm=Decimal("188.00"),
|
|
weight_kg=Decimal("82.00"),
|
|
wingspan_cm=Decimal("196.00"),
|
|
)
|
|
cls.player_wing = Player.objects.create(
|
|
full_name="Saved Wing",
|
|
birth_date=date(2000, 9, 9),
|
|
position="SF",
|
|
height_cm=Decimal("203.00"),
|
|
weight_kg=Decimal("95.00"),
|
|
wingspan_cm=Decimal("213.00"),
|
|
)
|
|
|
|
def test_logged_in_user_can_save_search(self):
|
|
self.client.force_login(self.user)
|
|
response = self.client.post(
|
|
reverse("scouting:save_search"),
|
|
{
|
|
"saved_search_name": "Long Wings",
|
|
"position": "SF",
|
|
"min_wingspan_cm": "210",
|
|
"sort": "height_desc",
|
|
},
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertIn(f"{reverse('scouting:player_list')}?", response["Location"])
|
|
self.assertIn("sort=height_desc", response["Location"])
|
|
self.assertIn("position=SF", response["Location"])
|
|
self.assertIn("min_wingspan_cm=210", response["Location"])
|
|
saved = SavedSearch.objects.get(user=self.user, name="Long Wings")
|
|
self.assertEqual(saved.params["position"], "SF")
|
|
self.assertEqual(saved.params["min_wingspan_cm"], "210")
|
|
|
|
def test_saved_searches_are_user_scoped(self):
|
|
SavedSearch.objects.create(user=self.user, name="Mine", params={"position": "PG"})
|
|
SavedSearch.objects.create(user=self.other_user, name="Other", params={"position": "SF"})
|
|
self.client.force_login(self.user)
|
|
|
|
response = self.client.get(reverse("scouting:player_list"))
|
|
|
|
self.assertContains(response, "Mine")
|
|
self.assertNotContains(response, "Other")
|
|
|
|
def test_logged_in_user_can_rerun_saved_search(self):
|
|
saved = SavedSearch.objects.create(user=self.user, name="Wingspan Hunt", params={"min_wingspan_cm": "210"})
|
|
self.client.force_login(self.user)
|
|
|
|
response = self.client.get(f"{reverse('scouting:player_list')}?{urlencode(saved.params)}")
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(response, self.player_wing.full_name)
|
|
self.assertNotContains(response, self.player_guard.full_name)
|
|
|
|
def test_logged_in_user_can_delete_saved_search(self):
|
|
saved = SavedSearch.objects.create(user=self.user, name="Delete Me", params={"position": "PG"})
|
|
self.client.force_login(self.user)
|
|
|
|
response = self.client.post(
|
|
reverse("scouting:delete_saved_search", args=[saved.id]),
|
|
{"position": "PG"},
|
|
)
|
|
|
|
self.assertRedirects(response, f"{reverse('scouting:player_list')}?position=PG")
|
|
self.assertFalse(SavedSearch.objects.filter(pk=saved.id).exists())
|
|
|
|
def test_unauthenticated_user_cannot_save_or_delete_saved_searches(self):
|
|
save_response = self.client.post(reverse("scouting:save_search"), {"saved_search_name": "No Auth"})
|
|
self.assertRedirects(save_response, f"{reverse('login')}?next={reverse('scouting:save_search')}")
|
|
|
|
saved = SavedSearch.objects.create(user=self.user, name="Auth Required", params={"position": "PG"})
|
|
delete_response = self.client.post(reverse("scouting:delete_saved_search", args=[saved.id]))
|
|
self.assertRedirects(
|
|
delete_response,
|
|
f"{reverse('login')}?next={reverse('scouting:delete_saved_search', args=[saved.id])}",
|
|
)
|
|
|
|
def test_combined_filters_saved_search_and_rerun(self):
|
|
self.client.force_login(self.user)
|
|
save_response = self.client.post(
|
|
reverse("scouting:save_search"),
|
|
{
|
|
"saved_search_name": "SF Long Wings",
|
|
"position": "SF",
|
|
"min_wingspan_cm": "212",
|
|
"sort": "height_desc",
|
|
},
|
|
)
|
|
self.assertEqual(save_response.status_code, 302)
|
|
saved = SavedSearch.objects.get(user=self.user, name="SF Long Wings")
|
|
|
|
rerun_response = self.client.get(f"{reverse('scouting:player_list')}?position=SF&min_wingspan_cm=212&sort=height_desc")
|
|
|
|
self.assertEqual(rerun_response.status_code, 200)
|
|
self.assertContains(rerun_response, self.player_wing.full_name)
|
|
self.assertNotContains(rerun_response, self.player_guard.full_name)
|
|
self.assertEqual(saved.params["sort"], "height_desc")
|
|
|
|
|
|
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),
|
|
position="PF",
|
|
height_cm=Decimal("205.00"),
|
|
weight_kg=Decimal("97.00"),
|
|
)
|
|
|
|
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]),
|
|
{
|
|
"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, user=self.user)
|
|
self.assertEqual(note.body, "Shows good weak-side help instincts.")
|
|
|
|
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]),
|
|
{"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_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(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"))
|
|
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(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)
|