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.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 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"), 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"), ) 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_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 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)