513 lines
18 KiB
Python
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)}")
|