from __future__ import annotations from decimal import Decimal from urllib.parse import urlencode from django.core.paginator import Paginator from django.contrib.auth.decorators import login_required from django.db.models import Count, Exists, OuterRef, Prefetch, Q 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, SavedSearchForm from .models import FavoritePlayer, Player, PlayerNote, PlayerSeason, SavedSearch 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", } SEARCH_PARAM_FIELDS = [ "name", "sort", "position", "role", "specialty", "min_age", "max_age", "min_height_cm", "max_height_cm", "min_weight_kg", "max_weight_kg", "min_wingspan_cm", "max_wingspan_cm", "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", ] FILTER_LABELS = { "name": "Name", "position": "Position", "role": "Role", "specialty": "Specialty", "min_age": "Min age", "max_age": "Max age", "min_height_cm": "Min height (cm)", "max_height_cm": "Max height (cm)", "min_weight_kg": "Min weight (kg)", "max_weight_kg": "Max weight (kg)", "min_wingspan_cm": "Min wingspan (cm)", "max_wingspan_cm": "Max wingspan (cm)", "competition": "Competition", "season": "Season", "team": "Team", "min_points": "Min points", "min_assists": "Min assists", "min_steals": "Min steals", "max_turnovers": "Max turnovers", "min_blocks": "Min blocks", "min_efg_pct": "Min eFG%", "min_ts_pct": "Min TS%", "min_plus_minus": "Min +/-", "min_offensive_rating": "Min ORtg", "max_defensive_rating": "Max DRtg", } def serialize_search_params(cleaned_data: dict) -> dict[str, str]: params = {} for field_name in SEARCH_PARAM_FIELDS: value = cleaned_data.get(field_name) if value in (None, ""): continue if field_name == "sort" and value == "name_asc": continue params[field_name] = str(getattr(value, "pk", value)) return params def read_search_params_from_payload(payload) -> dict[str, str]: params = {} for field_name in SEARCH_PARAM_FIELDS: value = payload.get(field_name) if value in (None, ""): continue params[field_name] = str(value) return params def build_active_filters(form: PlayerSearchForm, cleaned_data: dict) -> list[dict[str, str]]: active_filters = [] for field_name, label in FILTER_LABELS.items(): value = cleaned_data.get(field_name) if value in (None, ""): continue field = form.fields[field_name] if getattr(field, "choices", None): value_label = str(dict(field.choices).get(str(value), value)) elif hasattr(value, "name"): value_label = value.name else: value_label = str(value) active_filters.append({"label": label, "value": value_label}) return active_filters def apply_favorite_state(players, user): if not user.is_authenticated: favorite_ids = set() else: favorite_ids = set(FavoritePlayer.objects.filter(user=user).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) saved_search_form = SavedSearchForm() 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" current_search_params = read_search_params_from_payload(request.GET) active_filters = [] if form.is_valid(): data = form.cleaned_data requested_sort = data["sort"] or "name_asc" current_search_params = serialize_search_params(data) active_filters = build_active_filters(form, data) 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_wingspan_cm"] is not None: queryset = queryset.filter(wingspan_cm__gte=data["min_wingspan_cm"]) if data["max_wingspan_cm"] is not None: queryset = queryset.filter(wingspan_cm__lte=data["max_wingspan_cm"]) 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, request.user) saved_searches = [] if request.user.is_authenticated: for saved_search in SavedSearch.objects.filter(user=request.user): querystring = urlencode(saved_search.params) saved_searches.append( { "id": saved_search.id, "name": saved_search.name, "querystring": querystring, "is_active": saved_search.params == current_search_params, } ) 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": urlencode(current_search_params), "current_search_params": current_search_params, "has_submitted_search": bool(current_search_params), "active_filters": active_filters, "context_sorting_enabled": context_filters_used, "saved_search_form": saved_search_form, "saved_searches": saved_searches, }, ) 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") ) if request.user.is_authenticated: notes = player.notes.filter(user=request.user) is_favorite = FavoritePlayer.objects.filter(user=request.user, player=player).exists() else: notes = PlayerNote.objects.none() is_favorite = False return render( request, "scouting/player_detail.html", { "player": player, "contexts": contexts, "notes": notes, "is_favorite": is_favorite, }, ) @login_required @require_POST def add_favorite(request, player_id: int): player = get_object_or_404(Player, pk=player_id) FavoritePlayer.objects.get_or_create(user=request.user, player=player) return redirect_to_next(request, reverse("scouting:player_detail", args=[player.id])) @login_required @require_POST def remove_favorite(request, player_id: int): player = get_object_or_404(Player, pk=player_id) FavoritePlayer.objects.filter(user=request.user, player=player).delete() return redirect_to_next(request, reverse("scouting:player_detail", args=[player.id])) @login_required @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(user=request.user, player=player, body=body) return redirect_to_next(request, reverse("scouting:player_detail", args=[player.id])) @login_required @require_POST def delete_note(request, player_id: int, note_id: int): player = get_object_or_404(Player, pk=player_id) PlayerNote.objects.filter(user=request.user, player=player, pk=note_id).delete() return redirect_to_next(request, reverse("scouting:player_detail", args=[player.id])) @login_required def favorites_list(request): favorites = list( FavoritePlayer.objects.filter(user=request.user) .select_related("player") .prefetch_related("player__roles", "player__specialties") .annotate(note_count=Count("player__notes", filter=Q(player__notes__user=request.user))) .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, }, ) @login_required @require_POST def save_search(request): name_form = SavedSearchForm(request.POST) player_search_form = PlayerSearchForm(request.POST) if not name_form.is_valid(): return HttpResponseRedirect(reverse("scouting:player_list")) if player_search_form.is_valid(): params = serialize_search_params(player_search_form.cleaned_data) else: params = read_search_params_from_payload(request.POST) SavedSearch.objects.update_or_create( user=request.user, name=name_form.cleaned_data["saved_search_name"].strip(), defaults={"params": params}, ) target = reverse("scouting:player_list") if not params: return HttpResponseRedirect(target) return HttpResponseRedirect(f"{target}?{urlencode(params)}") @login_required @require_POST def delete_saved_search(request, saved_search_id: int): SavedSearch.objects.filter(user=request.user, pk=saved_search_id).delete() query = read_search_params_from_payload(request.POST) target = reverse("scouting:player_list") if not query: return HttpResponseRedirect(target) return HttpResponseRedirect(f"{target}?{urlencode(query)}")