343 lines
13 KiB
Python
343 lines
13 KiB
Python
from __future__ import annotations
|
|
|
|
from decimal import Decimal
|
|
|
|
from django.core.paginator import Paginator
|
|
from django.db.models import Count, 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, PlayerNote, 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", "notes"),
|
|
pk=player_id,
|
|
)
|
|
|
|
contexts = (
|
|
PlayerSeason.objects.filter(player=player)
|
|
.select_related("season", "team", "competition", "stats")
|
|
.order_by("-season__start_year", "team__name", "competition__name")
|
|
)
|
|
notes = player.notes.all()
|
|
|
|
return render(
|
|
request,
|
|
"scouting/player_detail.html",
|
|
{
|
|
"player": player,
|
|
"contexts": contexts,
|
|
"notes": notes,
|
|
"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]))
|
|
|
|
|
|
@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(player=player, body=body)
|
|
return redirect_to_next(request, reverse("scouting:player_detail", args=[player.id]))
|
|
|
|
|
|
@require_POST
|
|
def delete_note(request, player_id: int, note_id: int):
|
|
player = get_object_or_404(Player, pk=player_id)
|
|
PlayerNote.objects.filter(player=player, pk=note_id).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")
|
|
.annotate(note_count=Count("player__notes"))
|
|
.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,
|
|
},
|
|
)
|