Files
hoopscout-v2/app/scouting/views.py

513 lines
18 KiB
Python

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)}")