from __future__ import annotations from decimal import Decimal from django.core.paginator import Paginator from django.db.models import 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 PAGE_SIZE = 20 PLAYER_SORTS = { "name_asc", "name_desc", "age_youngest", "height_desc", "weight_desc", } CONTEXT_SORTS = { "points_desc": "points", "assists_desc": "assists", "ts_pct_desc": "ts_pct", "blocks_desc": "blocks", } def apply_favorite_state(players): favorite_ids = set(FavoritePlayer.objects.values_list("player_id", flat=True)) for player in players: player.is_favorite = player.id in favorite_ids def redirect_to_next(request, fallback_url): next_url = request.POST.get("next") if next_url and next_url.startswith("/"): return HttpResponseRedirect(next_url) return HttpResponseRedirect(fallback_url) def sort_players(players, sort_key: str, context_filters_used: bool): if sort_key not in PLAYER_SORTS | set(CONTEXT_SORTS): sort_key = "name_asc" if sort_key == "name_asc": players.sort(key=lambda player: player.full_name.casefold()) return sort_key if sort_key == "name_desc": players.sort(key=lambda player: player.full_name.casefold(), reverse=True) return sort_key if sort_key == "age_youngest": players.sort( key=lambda player: ( player.birth_date is None, -(player.birth_date.toordinal()) if player.birth_date else 0, player.full_name.casefold(), ) ) return sort_key if sort_key == "height_desc": players.sort( key=lambda player: ( player.height_cm is None, -(player.height_cm or Decimal("0")), player.full_name.casefold(), ) ) return sort_key if sort_key == "weight_desc": players.sort( key=lambda player: ( player.weight_kg is None, -(player.weight_kg or Decimal("0")), player.full_name.casefold(), ) ) return sort_key if not context_filters_used: players.sort(key=lambda player: player.full_name.casefold()) return "name_asc" stat_name = CONTEXT_SORTS[sort_key] players.sort( key=lambda player: ( getattr(getattr(getattr(player, "matching_context", None), "stats", None), stat_name, None) is None, -( getattr(getattr(getattr(player, "matching_context", None), "stats", None), stat_name) or Decimal("0") ), player.full_name.casefold(), ) ) return sort_key def player_list(request): form = PlayerSearchForm(request.GET or None) queryset = ( Player.objects.all() .prefetch_related("roles", "specialties") .order_by("full_name") ) context_filters_used = False requested_sort = request.GET.get("sort") or "name_asc" if form.is_valid(): data = form.cleaned_data requested_sort = data["sort"] or "name_asc" if data["name"]: queryset = queryset.filter(full_name__icontains=data["name"]) if data["position"]: queryset = queryset.filter(position=data["position"]) if data["role"]: queryset = queryset.filter(roles=data["role"]) if data["specialty"]: queryset = queryset.filter(specialties=data["specialty"]) if data["min_height_cm"] is not None: queryset = queryset.filter(height_cm__gte=data["min_height_cm"]) if data["max_height_cm"] is not None: queryset = queryset.filter(height_cm__lte=data["max_height_cm"]) if data["min_weight_kg"] is not None: queryset = queryset.filter(weight_kg__gte=data["min_weight_kg"]) if data["max_weight_kg"] is not None: queryset = queryset.filter(weight_kg__lte=data["max_weight_kg"]) if data["min_age"] is not None: cutoff = form.birth_date_upper_bound_for_age(data["min_age"]) queryset = queryset.filter(birth_date__lte=cutoff) if data["max_age"] is not None: cutoff = form.birth_date_lower_bound_for_age(data["max_age"]) queryset = queryset.filter(birth_date__gte=cutoff) context_filters_used = any( data[field] is not None and data[field] != "" for field in [ "competition", "season", "team", "min_points", "min_assists", "min_steals", "max_turnovers", "min_blocks", "min_efg_pct", "min_ts_pct", "min_plus_minus", "min_offensive_rating", "max_defensive_rating", ] ) if context_filters_used: context_qs = PlayerSeason.objects.filter(player=OuterRef("pk")) matching_contexts = PlayerSeason.objects.all() if data["competition"]: context_qs = context_qs.filter(competition=data["competition"]) matching_contexts = matching_contexts.filter(competition=data["competition"]) if data["season"]: context_qs = context_qs.filter(season=data["season"]) matching_contexts = matching_contexts.filter(season=data["season"]) if data["team"]: context_qs = context_qs.filter(team=data["team"]) matching_contexts = matching_contexts.filter(team=data["team"]) stats_filters_used = any( data[field] is not None for field in [ "min_points", "min_assists", "min_steals", "max_turnovers", "min_blocks", "min_efg_pct", "min_ts_pct", "min_plus_minus", "min_offensive_rating", "max_defensive_rating", ] ) if stats_filters_used: context_qs = context_qs.filter(stats__isnull=False) matching_contexts = matching_contexts.filter(stats__isnull=False) if data["min_points"] is not None: context_qs = context_qs.filter(stats__points__gte=data["min_points"]) matching_contexts = matching_contexts.filter(stats__points__gte=data["min_points"]) if data["min_assists"] is not None: context_qs = context_qs.filter(stats__assists__gte=data["min_assists"]) matching_contexts = matching_contexts.filter(stats__assists__gte=data["min_assists"]) if data["min_steals"] is not None: context_qs = context_qs.filter(stats__steals__gte=data["min_steals"]) matching_contexts = matching_contexts.filter(stats__steals__gte=data["min_steals"]) if data["max_turnovers"] is not None: context_qs = context_qs.filter(stats__turnovers__lte=data["max_turnovers"]) matching_contexts = matching_contexts.filter(stats__turnovers__lte=data["max_turnovers"]) if data["min_blocks"] is not None: context_qs = context_qs.filter(stats__blocks__gte=data["min_blocks"]) matching_contexts = matching_contexts.filter(stats__blocks__gte=data["min_blocks"]) if data["min_efg_pct"] is not None: context_qs = context_qs.filter(stats__efg_pct__gte=data["min_efg_pct"]) matching_contexts = matching_contexts.filter(stats__efg_pct__gte=data["min_efg_pct"]) if data["min_ts_pct"] is not None: context_qs = context_qs.filter(stats__ts_pct__gte=data["min_ts_pct"]) matching_contexts = matching_contexts.filter(stats__ts_pct__gte=data["min_ts_pct"]) if data["min_plus_minus"] is not None: context_qs = context_qs.filter(stats__plus_minus__gte=data["min_plus_minus"]) matching_contexts = matching_contexts.filter(stats__plus_minus__gte=data["min_plus_minus"]) if data["min_offensive_rating"] is not None: context_qs = context_qs.filter(stats__offensive_rating__gte=data["min_offensive_rating"]) matching_contexts = matching_contexts.filter( stats__offensive_rating__gte=data["min_offensive_rating"] ) if data["max_defensive_rating"] is not None: context_qs = context_qs.filter(stats__defensive_rating__lte=data["max_defensive_rating"]) matching_contexts = matching_contexts.filter( stats__defensive_rating__lte=data["max_defensive_rating"] ) queryset = queryset.annotate(has_matching_context=Exists(context_qs)).filter(has_matching_context=True) # Reuse the same filtered PlayerSeason scope and take the first ordered row # so the displayed context is deterministic and tied to the actual match. matching_contexts = ( matching_contexts.select_related("season", "team", "competition", "stats") .order_by("-season__start_year", "team__name", "competition__name", "pk") ) queryset = queryset.prefetch_related( Prefetch( "player_seasons", queryset=matching_contexts, to_attr="matching_contexts", ) ) queryset = queryset.distinct() players = list(queryset) if context_filters_used: for player in players: player.matching_context = next(iter(player.matching_contexts), None) active_sort = sort_players(players, requested_sort, context_filters_used) paginator = Paginator(players, PAGE_SIZE) page_obj = paginator.get_page(request.GET.get("page")) apply_favorite_state(page_obj.object_list) query_without_page = request.GET.copy() query_without_page.pop("page", None) return render( request, "scouting/player_list.html", { "form": form, "players": page_obj.object_list, "page_obj": page_obj, "active_sort": active_sort, "total_results": paginator.count, "query_without_page": query_without_page.urlencode(), "context_sorting_enabled": context_filters_used, }, ) def player_detail(request, player_id: int): player = get_object_or_404( Player.objects.prefetch_related("roles", "specialties"), pk=player_id, ) contexts = ( PlayerSeason.objects.filter(player=player) .select_related("season", "team", "competition", "stats") .order_by("-season__start_year", "team__name", "competition__name") ) return render( request, "scouting/player_detail.html", { "player": player, "contexts": contexts, "is_favorite": FavoritePlayer.objects.filter(player=player).exists(), }, ) @require_POST def add_favorite(request, player_id: int): player = get_object_or_404(Player, pk=player_id) FavoritePlayer.objects.get_or_create(player=player) return redirect_to_next(request, reverse("scouting:player_detail", args=[player.id])) @require_POST def remove_favorite(request, player_id: int): player = get_object_or_404(Player, pk=player_id) FavoritePlayer.objects.filter(player=player).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") .order_by("-created_at", "player__full_name") ) for entry in favorites: entry.player.is_favorite = True return render( request, "scouting/favorites_list.html", { "favorites": favorites, }, )