Compare commits
2 Commits
80f6c160f4
...
b351d31cf5
| Author | SHA1 | Date | |
|---|---|---|---|
| b351d31cf5 | |||
| 6c7d7c1af4 |
@ -1,6 +1,7 @@
|
||||
from django.contrib import admin
|
||||
from django.urls import path
|
||||
from django.urls import include, path
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
path("", include("scouting.urls")),
|
||||
]
|
||||
|
||||
65
app/scouting/forms.py
Normal file
65
app/scouting/forms.py
Normal file
@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from django import forms
|
||||
|
||||
from .models import Competition, Role, Season, Specialty, Team
|
||||
|
||||
|
||||
class PlayerSearchForm(forms.Form):
|
||||
name = forms.CharField(required=False, label="Name")
|
||||
|
||||
position = forms.ChoiceField(
|
||||
required=False,
|
||||
choices=[("", "Any")] + [
|
||||
("PG", "PG"),
|
||||
("SG", "SG"),
|
||||
("SF", "SF"),
|
||||
("PF", "PF"),
|
||||
("C", "C"),
|
||||
],
|
||||
)
|
||||
role = forms.ModelChoiceField(required=False, queryset=Role.objects.none())
|
||||
specialty = forms.ModelChoiceField(required=False, queryset=Specialty.objects.none())
|
||||
|
||||
min_age = forms.IntegerField(required=False, min_value=0)
|
||||
max_age = forms.IntegerField(required=False, min_value=0)
|
||||
min_height_cm = forms.DecimalField(required=False, max_digits=5, decimal_places=2)
|
||||
max_height_cm = forms.DecimalField(required=False, max_digits=5, decimal_places=2)
|
||||
min_weight_kg = forms.DecimalField(required=False, max_digits=5, decimal_places=2)
|
||||
max_weight_kg = forms.DecimalField(required=False, max_digits=5, decimal_places=2)
|
||||
|
||||
competition = forms.ModelChoiceField(required=False, queryset=Competition.objects.none())
|
||||
season = forms.ModelChoiceField(required=False, queryset=Season.objects.none())
|
||||
team = forms.ModelChoiceField(required=False, queryset=Team.objects.none())
|
||||
|
||||
min_points = forms.DecimalField(required=False, max_digits=6, decimal_places=2)
|
||||
min_assists = forms.DecimalField(required=False, max_digits=6, decimal_places=2)
|
||||
min_steals = forms.DecimalField(required=False, max_digits=6, decimal_places=2)
|
||||
max_turnovers = forms.DecimalField(required=False, max_digits=6, decimal_places=2)
|
||||
min_blocks = forms.DecimalField(required=False, max_digits=6, decimal_places=2)
|
||||
|
||||
min_efg_pct = forms.DecimalField(required=False, max_digits=5, decimal_places=2)
|
||||
min_ts_pct = forms.DecimalField(required=False, max_digits=5, decimal_places=2)
|
||||
min_plus_minus = forms.DecimalField(required=False, max_digits=7, decimal_places=2)
|
||||
min_offensive_rating = forms.DecimalField(required=False, max_digits=7, decimal_places=2)
|
||||
max_defensive_rating = forms.DecimalField(required=False, max_digits=7, decimal_places=2)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["role"].queryset = Role.objects.order_by("name")
|
||||
self.fields["specialty"].queryset = Specialty.objects.order_by("name")
|
||||
self.fields["competition"].queryset = Competition.objects.order_by("name")
|
||||
self.fields["season"].queryset = Season.objects.order_by("-start_year")
|
||||
self.fields["team"].queryset = Team.objects.order_by("name")
|
||||
|
||||
@staticmethod
|
||||
def birth_date_upper_bound_for_age(min_age: int) -> date:
|
||||
today = date.today()
|
||||
return today.replace(year=today.year - min_age)
|
||||
|
||||
@staticmethod
|
||||
def birth_date_lower_bound_for_age(max_age: int) -> date:
|
||||
today = date.today()
|
||||
return today.replace(year=today.year - max_age - 1)
|
||||
67
app/scouting/templates/scouting/player_detail.html
Normal file
67
app/scouting/templates/scouting/player_detail.html
Normal file
@ -0,0 +1,67 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{ player.full_name }}</title>
|
||||
</head>
|
||||
<body>
|
||||
<p><a href="{% url 'scouting:player_list' %}">Back to search</a></p>
|
||||
<h1>{{ player.full_name }}</h1>
|
||||
|
||||
<p>Position: {{ player.position }}</p>
|
||||
<p>Nationality: {{ player.nationality|default:"-" }}</p>
|
||||
<p>Birth date: {{ player.birth_date|default:"-" }}</p>
|
||||
<p>Height (cm): {{ player.height_cm|default:"-" }}</p>
|
||||
<p>Weight (kg): {{ player.weight_kg|default:"-" }}</p>
|
||||
<p>Wingspan (cm): {{ player.wingspan_cm|default:"-" }}</p>
|
||||
|
||||
<p>
|
||||
Roles:
|
||||
{% for role in player.roles.all %}
|
||||
{{ role.name }}{% if not forloop.last %}, {% endif %}
|
||||
{% empty %}
|
||||
-
|
||||
{% endfor %}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Specialties:
|
||||
{% for specialty in player.specialties.all %}
|
||||
{{ specialty.name }}{% if not forloop.last %}, {% endif %}
|
||||
{% empty %}
|
||||
-
|
||||
{% endfor %}
|
||||
</p>
|
||||
|
||||
<h2>Season Contexts</h2>
|
||||
<ul>
|
||||
{% for context in contexts %}
|
||||
<li>
|
||||
<strong>{{ context.season.name }}</strong>
|
||||
| Team: {{ context.team.name|default:"-" }}
|
||||
| Competition: {{ context.competition.name|default:"-" }}
|
||||
{% if context.stats %}
|
||||
<div>
|
||||
PTS {{ context.stats.points|default:"-" }} |
|
||||
AST {{ context.stats.assists|default:"-" }} |
|
||||
STL {{ context.stats.steals|default:"-" }} |
|
||||
TOV {{ context.stats.turnovers|default:"-" }} |
|
||||
BLK {{ context.stats.blocks|default:"-" }}
|
||||
</div>
|
||||
<div>
|
||||
eFG% {{ context.stats.efg_pct|default:"-" }} |
|
||||
TS% {{ context.stats.ts_pct|default:"-" }} |
|
||||
+/- {{ context.stats.plus_minus|default:"-" }} |
|
||||
ORtg {{ context.stats.offensive_rating|default:"-" }} |
|
||||
DRtg {{ context.stats.defensive_rating|default:"-" }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div>No stats available for this context.</div>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% empty %}
|
||||
<li>No season contexts found.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
61
app/scouting/templates/scouting/player_list.html
Normal file
61
app/scouting/templates/scouting/player_list.html
Normal file
@ -0,0 +1,61 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Player Search</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Scout Search</h1>
|
||||
|
||||
<form method="get">
|
||||
<fieldset>
|
||||
<legend>Player Filters</legend>
|
||||
{{ form.name.label_tag }} {{ form.name }}
|
||||
{{ form.position.label_tag }} {{ form.position }}
|
||||
{{ form.role.label_tag }} {{ form.role }}
|
||||
{{ form.specialty.label_tag }} {{ form.specialty }}
|
||||
{{ form.min_age.label_tag }} {{ form.min_age }}
|
||||
{{ form.max_age.label_tag }} {{ form.max_age }}
|
||||
{{ form.min_height_cm.label_tag }} {{ form.min_height_cm }}
|
||||
{{ form.max_height_cm.label_tag }} {{ form.max_height_cm }}
|
||||
{{ form.min_weight_kg.label_tag }} {{ form.min_weight_kg }}
|
||||
{{ form.max_weight_kg.label_tag }} {{ form.max_weight_kg }}
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Context Filters</legend>
|
||||
{{ form.competition.label_tag }} {{ form.competition }}
|
||||
{{ form.season.label_tag }} {{ form.season }}
|
||||
{{ form.team.label_tag }} {{ form.team }}
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Stats Filters</legend>
|
||||
{{ form.min_points.label_tag }} {{ form.min_points }}
|
||||
{{ form.min_assists.label_tag }} {{ form.min_assists }}
|
||||
{{ form.min_steals.label_tag }} {{ form.min_steals }}
|
||||
{{ form.max_turnovers.label_tag }} {{ form.max_turnovers }}
|
||||
{{ form.min_blocks.label_tag }} {{ form.min_blocks }}
|
||||
{{ form.min_efg_pct.label_tag }} {{ form.min_efg_pct }}
|
||||
{{ form.min_ts_pct.label_tag }} {{ form.min_ts_pct }}
|
||||
{{ form.min_plus_minus.label_tag }} {{ form.min_plus_minus }}
|
||||
{{ form.min_offensive_rating.label_tag }} {{ form.min_offensive_rating }}
|
||||
{{ form.max_defensive_rating.label_tag }} {{ form.max_defensive_rating }}
|
||||
</fieldset>
|
||||
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
|
||||
<h2>Results ({{ players|length }})</h2>
|
||||
<ul>
|
||||
{% for player in players %}
|
||||
<li>
|
||||
<a href="{% url 'scouting:player_detail' player.id %}">{{ player.full_name }}</a>
|
||||
({{ player.position }})
|
||||
</li>
|
||||
{% empty %}
|
||||
<li>No players found.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
169
app/scouting/tests.py
Normal file
169
app/scouting/tests.py
Normal file
@ -0,0 +1,169 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from .models import Competition, Player, PlayerSeason, PlayerSeasonStats, Role, Season, Specialty, Team
|
||||
|
||||
|
||||
class ScoutingSearchViewsTests(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.role_playmaker = Role.objects.create(name="playmaker", slug="playmaker")
|
||||
cls.role_3d = Role.objects.create(name="3-and-D", slug="3-and-d")
|
||||
cls.specialty_defense = Specialty.objects.create(name="defense", slug="defense")
|
||||
cls.specialty_offball = Specialty.objects.create(name="off ball", slug="off-ball")
|
||||
|
||||
cls.comp_a = Competition.objects.create(name="League A")
|
||||
cls.comp_b = Competition.objects.create(name="League B")
|
||||
cls.team_a = Team.objects.create(name="Team A", country="IT")
|
||||
cls.team_b = Team.objects.create(name="Team B", country="IT")
|
||||
cls.season_2025 = Season.objects.create(name="2025-2026", start_year=2025, end_year=2026)
|
||||
cls.season_2024 = Season.objects.create(name="2024-2025", start_year=2024, end_year=2025)
|
||||
|
||||
cls.player_pg = Player.objects.create(
|
||||
full_name="Marco Guard",
|
||||
birth_date=date(2002, 1, 1),
|
||||
position="PG",
|
||||
height_cm=Decimal("188.00"),
|
||||
weight_kg=Decimal("82.00"),
|
||||
nationality="IT",
|
||||
)
|
||||
cls.player_pg.roles.add(cls.role_playmaker)
|
||||
cls.player_pg.specialties.add(cls.specialty_defense)
|
||||
|
||||
cls.player_wing = Player.objects.create(
|
||||
full_name="Luca Wing",
|
||||
birth_date=date(1998, 2, 2),
|
||||
position="SF",
|
||||
height_cm=Decimal("201.00"),
|
||||
weight_kg=Decimal("95.00"),
|
||||
)
|
||||
cls.player_wing.roles.add(cls.role_3d)
|
||||
cls.player_wing.specialties.add(cls.specialty_offball)
|
||||
|
||||
cls.ctx_pg_good = PlayerSeason.objects.create(
|
||||
player=cls.player_pg,
|
||||
season=cls.season_2025,
|
||||
team=cls.team_a,
|
||||
competition=cls.comp_a,
|
||||
)
|
||||
PlayerSeasonStats.objects.create(
|
||||
player_season=cls.ctx_pg_good,
|
||||
points=Decimal("16.00"),
|
||||
assists=Decimal("7.50"),
|
||||
steals=Decimal("1.80"),
|
||||
turnovers=Decimal("2.10"),
|
||||
blocks=Decimal("0.30"),
|
||||
efg_pct=Decimal("53.20"),
|
||||
ts_pct=Decimal("58.10"),
|
||||
plus_minus=Decimal("4.20"),
|
||||
offensive_rating=Decimal("112.00"),
|
||||
defensive_rating=Decimal("104.00"),
|
||||
)
|
||||
|
||||
cls.ctx_pg_other = PlayerSeason.objects.create(
|
||||
player=cls.player_pg,
|
||||
season=cls.season_2024,
|
||||
team=cls.team_b,
|
||||
competition=cls.comp_b,
|
||||
)
|
||||
PlayerSeasonStats.objects.create(
|
||||
player_season=cls.ctx_pg_other,
|
||||
points=Decimal("10.00"),
|
||||
assists=Decimal("4.00"),
|
||||
steals=Decimal("1.00"),
|
||||
turnovers=Decimal("3.50"),
|
||||
blocks=Decimal("0.20"),
|
||||
efg_pct=Decimal("48.00"),
|
||||
ts_pct=Decimal("50.00"),
|
||||
plus_minus=Decimal("-2.00"),
|
||||
offensive_rating=Decimal("101.00"),
|
||||
defensive_rating=Decimal("112.00"),
|
||||
)
|
||||
|
||||
cls.ctx_wing = PlayerSeason.objects.create(
|
||||
player=cls.player_wing,
|
||||
season=cls.season_2025,
|
||||
team=cls.team_b,
|
||||
competition=cls.comp_a,
|
||||
)
|
||||
PlayerSeasonStats.objects.create(
|
||||
player_season=cls.ctx_wing,
|
||||
points=Decimal("14.00"),
|
||||
assists=Decimal("2.00"),
|
||||
steals=Decimal("1.20"),
|
||||
turnovers=Decimal("1.90"),
|
||||
blocks=Decimal("0.70"),
|
||||
efg_pct=Decimal("55.00"),
|
||||
ts_pct=Decimal("60.00"),
|
||||
plus_minus=Decimal("1.50"),
|
||||
offensive_rating=Decimal("108.00"),
|
||||
defensive_rating=Decimal("106.00"),
|
||||
)
|
||||
|
||||
def test_player_list_page_loads(self):
|
||||
response = self.client.get(reverse("scouting:player_list"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Scout Search")
|
||||
|
||||
def test_player_detail_page_loads(self):
|
||||
response = self.client.get(reverse("scouting:player_detail", args=[self.player_pg.id]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, self.player_pg.full_name)
|
||||
|
||||
def test_filter_by_player_level_fields(self):
|
||||
response = self.client.get(
|
||||
reverse("scouting:player_list"),
|
||||
{"position": "PG", "role": self.role_playmaker.id, "specialty": self.specialty_defense.id},
|
||||
)
|
||||
self.assertContains(response, self.player_pg.full_name)
|
||||
self.assertNotContains(response, self.player_wing.full_name)
|
||||
|
||||
def test_filter_by_context_fields_and_stats(self):
|
||||
response = self.client.get(
|
||||
reverse("scouting:player_list"),
|
||||
{
|
||||
"competition": self.comp_a.id,
|
||||
"season": self.season_2025.id,
|
||||
"team": self.team_a.id,
|
||||
"min_ts_pct": "57",
|
||||
},
|
||||
)
|
||||
self.assertContains(response, self.player_pg.full_name)
|
||||
self.assertNotContains(response, self.player_wing.full_name)
|
||||
|
||||
def test_no_false_positive_from_different_context_rows(self):
|
||||
response = self.client.get(
|
||||
reverse("scouting:player_list"),
|
||||
{
|
||||
"team": self.team_b.id,
|
||||
"season": self.season_2024.id,
|
||||
"min_ts_pct": "57",
|
||||
},
|
||||
)
|
||||
self.assertNotContains(response, self.player_pg.full_name)
|
||||
|
||||
def test_combined_pg_under_age_with_assists(self):
|
||||
response = self.client.get(
|
||||
reverse("scouting:player_list"),
|
||||
{
|
||||
"position": "PG",
|
||||
"max_age": "25",
|
||||
"min_assists": "7",
|
||||
},
|
||||
)
|
||||
self.assertContains(response, self.player_pg.full_name)
|
||||
self.assertNotContains(response, self.player_wing.full_name)
|
||||
|
||||
def test_combined_team_and_defensive_rating_quality_filter(self):
|
||||
response = self.client.get(
|
||||
reverse("scouting:player_list"),
|
||||
{
|
||||
"team": self.team_a.id,
|
||||
"max_defensive_rating": "105",
|
||||
},
|
||||
)
|
||||
self.assertContains(response, self.player_pg.full_name)
|
||||
self.assertNotContains(response, self.player_wing.full_name)
|
||||
10
app/scouting/urls.py
Normal file
10
app/scouting/urls.py
Normal file
@ -0,0 +1,10 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
app_name = "scouting"
|
||||
|
||||
urlpatterns = [
|
||||
path("players/", views.player_list, name="player_list"),
|
||||
path("players/<int:player_id>/", views.player_detail, name="player_detail"),
|
||||
]
|
||||
148
app/scouting/views.py
Normal file
148
app/scouting/views.py
Normal file
@ -0,0 +1,148 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.db.models import Exists, OuterRef, Prefetch
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
|
||||
from .forms import PlayerSearchForm
|
||||
from .models import Player, PlayerSeason
|
||||
|
||||
|
||||
def player_list(request):
|
||||
form = PlayerSearchForm(request.GET or None)
|
||||
queryset = (
|
||||
Player.objects.all()
|
||||
.prefetch_related("roles", "specialties")
|
||||
.order_by("full_name")
|
||||
)
|
||||
|
||||
if form.is_valid():
|
||||
data = form.cleaned_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_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"))
|
||||
|
||||
if data["competition"]:
|
||||
context_qs = context_qs.filter(competition=data["competition"])
|
||||
if data["season"]:
|
||||
context_qs = context_qs.filter(season=data["season"])
|
||||
if data["team"]:
|
||||
context_qs = context_qs.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)
|
||||
|
||||
if data["min_points"] is not None:
|
||||
context_qs = context_qs.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"])
|
||||
if data["min_steals"] is not None:
|
||||
context_qs = context_qs.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"])
|
||||
if data["min_blocks"] is not None:
|
||||
context_qs = context_qs.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"])
|
||||
if data["min_ts_pct"] is not None:
|
||||
context_qs = context_qs.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"])
|
||||
if data["min_offensive_rating"] is not None:
|
||||
context_qs = context_qs.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"])
|
||||
|
||||
queryset = queryset.annotate(has_matching_context=Exists(context_qs)).filter(has_matching_context=True)
|
||||
|
||||
queryset = queryset.distinct()
|
||||
|
||||
return render(
|
||||
request,
|
||||
"scouting/player_list.html",
|
||||
{
|
||||
"form": form,
|
||||
"players": queryset,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
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")
|
||||
.prefetch_related(Prefetch("stats"))
|
||||
.order_by("-season__start_year", "team__name", "competition__name")
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"scouting/player_detail.html",
|
||||
{
|
||||
"player": player,
|
||||
"contexts": contexts,
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user