generated from bisco/codex-bootstrap
feat: bootstrap HoopScout scouting app
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
.venv
|
||||
__pycache__
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
*.pyc
|
||||
@@ -0,0 +1,20 @@
|
||||
FROM python:3.13.5-slim-bookworm
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN addgroup --system app && adduser --system --ingroup app app
|
||||
|
||||
COPY pyproject.toml ./
|
||||
RUN pip install --no-cache-dir --upgrade pip==25.1.1 \
|
||||
&& pip install --no-cache-dir ".[dev]"
|
||||
|
||||
COPY . .
|
||||
|
||||
USER app
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
||||
|
||||
application = get_asgi_application()
|
||||
@@ -0,0 +1,91 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "local-development-secret-key-change-me")
|
||||
DEBUG = os.environ.get("DJANGO_DEBUG", "0") == "1"
|
||||
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1,backend").split(",")
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"corsheaders",
|
||||
"rest_framework",
|
||||
"scouting.apps.ScoutingConfig",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"corsheaders.middleware.CorsMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "config.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = "config.wsgi.application"
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"NAME": os.environ.get("POSTGRES_DB", "hoopscout"),
|
||||
"USER": os.environ.get("POSTGRES_USER", "hoopscout"),
|
||||
"PASSWORD": os.environ.get("POSTGRES_PASSWORD", "local-dev-password-change-me"),
|
||||
"HOST": os.environ.get("POSTGRES_HOST", "db"),
|
||||
"PORT": os.environ.get("POSTGRES_PORT", "5432"),
|
||||
}
|
||||
}
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
|
||||
]
|
||||
|
||||
LANGUAGE_CODE = "en-us"
|
||||
TIME_ZONE = "UTC"
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
STATIC_URL = "static/"
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": [
|
||||
"rest_framework.authentication.SessionAuthentication",
|
||||
"rest_framework.authentication.BasicAuthentication",
|
||||
],
|
||||
"DEFAULT_PERMISSION_CLASSES": [
|
||||
"rest_framework.permissions.IsAuthenticated",
|
||||
],
|
||||
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
|
||||
"PAGE_SIZE": 25,
|
||||
}
|
||||
|
||||
CORS_ALLOWED_ORIGINS = os.environ.get("CORS_ALLOWED_ORIGINS", "http://localhost:4200").split(",")
|
||||
CSRF_TRUSTED_ORIGINS = os.environ.get("CSRF_TRUSTED_ORIGINS", "http://localhost:4200").split(",")
|
||||
@@ -0,0 +1,17 @@
|
||||
from django.contrib import admin
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from scouting.views import LeagueViewSet, PlayerViewSet, TeamViewSet, me
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register("players", PlayerViewSet, basename="player")
|
||||
router.register("leagues", LeagueViewSet, basename="league")
|
||||
router.register("teams", TeamViewSet, basename="team")
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
path("api/me/", me, name="me"),
|
||||
path("api/", include(router.urls)),
|
||||
path("api-auth/", include("rest_framework.urls")),
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
||||
|
||||
application = get_wsgi_application()
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env python
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main() -> None:
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
||||
from django.core.management import execute_from_command_line
|
||||
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,30 @@
|
||||
[project]
|
||||
name = "hoopscout-backend"
|
||||
version = "0.1.0"
|
||||
description = "Django API for basketball scouting."
|
||||
requires-python = ">=3.13,<3.14"
|
||||
dependencies = [
|
||||
"Django==5.2.14",
|
||||
"django-cors-headers==4.7.0",
|
||||
"djangorestframework==3.16.1",
|
||||
"psycopg[binary]==3.2.9",
|
||||
"gunicorn==23.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest==8.4.1",
|
||||
"pytest-django==4.11.1",
|
||||
"ruff==0.12.0",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
DJANGO_SETTINGS_MODULE = "config.settings"
|
||||
python_files = ["test_*.py", "*_test.py"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py313"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "B"]
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import League, Player, PlayerGameLog, PlayerSeasonStat, Season, Team, UserProfile
|
||||
|
||||
|
||||
@admin.register(UserProfile)
|
||||
class UserProfileAdmin(admin.ModelAdmin):
|
||||
list_display = ("user", "role")
|
||||
list_filter = ("role",)
|
||||
search_fields = ("user__username",)
|
||||
|
||||
|
||||
@admin.register(League)
|
||||
class LeagueAdmin(admin.ModelAdmin):
|
||||
list_display = ("code", "name", "region", "country")
|
||||
list_filter = ("region", "country")
|
||||
search_fields = ("name", "code", "country")
|
||||
|
||||
|
||||
@admin.register(Team)
|
||||
class TeamAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "league", "country")
|
||||
list_filter = ("league", "country")
|
||||
search_fields = ("name",)
|
||||
|
||||
|
||||
@admin.register(Season)
|
||||
class SeasonAdmin(admin.ModelAdmin):
|
||||
list_display = ("label", "is_active")
|
||||
list_filter = ("is_active",)
|
||||
|
||||
|
||||
@admin.register(Player)
|
||||
class PlayerAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "position", "role", "current_team", "height_cm", "weight_kg", "nationality")
|
||||
list_filter = ("position", "current_team__league", "nationality")
|
||||
search_fields = ("first_name", "last_name", "role", "nationality")
|
||||
|
||||
|
||||
@admin.register(PlayerSeasonStat)
|
||||
class PlayerSeasonStatAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"player",
|
||||
"season",
|
||||
"league",
|
||||
"points_per_game",
|
||||
"assists_per_game",
|
||||
"rebounds_per_game",
|
||||
"efficiency_rating",
|
||||
)
|
||||
list_filter = ("season", "league")
|
||||
search_fields = ("player__first_name", "player__last_name")
|
||||
|
||||
|
||||
@admin.register(PlayerGameLog)
|
||||
class PlayerGameLogAdmin(admin.ModelAdmin):
|
||||
list_display = ("player", "game_date", "league", "opponent", "points", "assists", "rebounds", "efficiency_rating")
|
||||
list_filter = ("season", "league")
|
||||
search_fields = ("player__first_name", "player__last_name", "opponent")
|
||||
@@ -0,0 +1,9 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ScoutingConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "scouting"
|
||||
|
||||
def ready(self) -> None:
|
||||
from . import signals # noqa: F401
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from scouting.models import League, Player, PlayerGameLog, PlayerSeasonStat, Season, Team
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Seed synthetic European scouting data for local development."
|
||||
|
||||
def handle(self, *args, **options):
|
||||
season, _ = Season.objects.update_or_create(label="2025-26", defaults={"is_active": True})
|
||||
leagues = {
|
||||
"LBA": League.objects.update_or_create(
|
||||
code="LBA",
|
||||
defaults={"name": "Lega Basket Serie A", "region": "Europe", "country": "Italy"},
|
||||
)[0],
|
||||
"ACB": League.objects.update_or_create(
|
||||
code="ACB",
|
||||
defaults={"name": "Liga Endesa", "region": "Europe", "country": "Spain"},
|
||||
)[0],
|
||||
"ABA": League.objects.update_or_create(
|
||||
code="ABA",
|
||||
defaults={"name": "ABA League", "region": "Europe", "country": "Adriatic"},
|
||||
)[0],
|
||||
"BBL": League.objects.update_or_create(
|
||||
code="BBL",
|
||||
defaults={"name": "Basketball Bundesliga", "region": "Europe", "country": "Germany"},
|
||||
)[0],
|
||||
"BSL": League.objects.update_or_create(
|
||||
code="BSL",
|
||||
defaults={"name": "Basketbol Super Ligi", "region": "Europe", "country": "Turkey"},
|
||||
)[0],
|
||||
"LNB": League.objects.update_or_create(
|
||||
code="LNB",
|
||||
defaults={"name": "LNB Elite", "region": "Europe", "country": "France"},
|
||||
)[0],
|
||||
}
|
||||
|
||||
demo_rows = [
|
||||
{
|
||||
"first_name": "Luca",
|
||||
"last_name": "Marini",
|
||||
"position": "PG",
|
||||
"role": "Primary ball handler",
|
||||
"birth_year": 2001,
|
||||
"height_cm": 190,
|
||||
"weight_kg": 86,
|
||||
"nationality": "Italy",
|
||||
"team": "Milano",
|
||||
"league": "LBA",
|
||||
"points": 16.8,
|
||||
"assists": 6.2,
|
||||
"rebounds": 3.8,
|
||||
"efficiency": 20.5,
|
||||
"ts": 59.2,
|
||||
"usage": 25.0,
|
||||
},
|
||||
{
|
||||
"first_name": "Mateo",
|
||||
"last_name": "Santos",
|
||||
"position": "SF",
|
||||
"role": "3 and D wing",
|
||||
"birth_year": 1999,
|
||||
"height_cm": 201,
|
||||
"weight_kg": 96,
|
||||
"nationality": "Spain",
|
||||
"team": "Madrid",
|
||||
"league": "ACB",
|
||||
"points": 11.2,
|
||||
"assists": 2.1,
|
||||
"rebounds": 5.5,
|
||||
"efficiency": 13.7,
|
||||
"ts": 55.8,
|
||||
"usage": 17.5,
|
||||
},
|
||||
{
|
||||
"first_name": "Jonas",
|
||||
"last_name": "Keller",
|
||||
"position": "C",
|
||||
"role": "Roll man",
|
||||
"birth_year": 2000,
|
||||
"height_cm": 211,
|
||||
"weight_kg": 109,
|
||||
"nationality": "Germany",
|
||||
"team": "Berlin",
|
||||
"league": "BBL",
|
||||
"points": 13.1,
|
||||
"assists": 1.4,
|
||||
"rebounds": 8.6,
|
||||
"efficiency": 18.9,
|
||||
"ts": 63.1,
|
||||
"usage": 19.0,
|
||||
},
|
||||
]
|
||||
|
||||
for row in demo_rows:
|
||||
league = leagues[row["league"]]
|
||||
team, _ = Team.objects.update_or_create(
|
||||
name=row["team"],
|
||||
league=league,
|
||||
defaults={"country": league.country},
|
||||
)
|
||||
player, _ = Player.objects.update_or_create(
|
||||
first_name=row["first_name"],
|
||||
last_name=row["last_name"],
|
||||
birth_year=row["birth_year"],
|
||||
nationality=row["nationality"],
|
||||
defaults={
|
||||
"position": row["position"],
|
||||
"role": row["role"],
|
||||
"height_cm": row["height_cm"],
|
||||
"weight_kg": row["weight_kg"],
|
||||
"current_team": team,
|
||||
"external_source": "synthetic",
|
||||
},
|
||||
)
|
||||
PlayerSeasonStat.objects.update_or_create(
|
||||
player=player,
|
||||
team=team,
|
||||
league=league,
|
||||
season=season,
|
||||
defaults={
|
||||
"games_played": 28,
|
||||
"minutes_per_game": 27.5,
|
||||
"points_per_game": row["points"],
|
||||
"assists_per_game": row["assists"],
|
||||
"rebounds_per_game": row["rebounds"],
|
||||
"steals_per_game": 1.0,
|
||||
"blocks_per_game": 0.4,
|
||||
"turnovers_per_game": 1.8,
|
||||
"field_goal_percentage": 48.0,
|
||||
"three_point_percentage": 37.5,
|
||||
"free_throw_percentage": 81.0,
|
||||
"efficiency_rating": row["efficiency"],
|
||||
"true_shooting_percentage": row["ts"],
|
||||
"usage_percentage": row["usage"],
|
||||
"total_points": int(row["points"] * 28),
|
||||
"total_assists": int(row["assists"] * 28),
|
||||
"total_rebounds": int(row["rebounds"] * 28),
|
||||
},
|
||||
)
|
||||
PlayerGameLog.objects.update_or_create(
|
||||
player=player,
|
||||
team=team,
|
||||
league=league,
|
||||
season=season,
|
||||
game_date="2026-01-10",
|
||||
opponent="Top domestic opponent",
|
||||
defaults={
|
||||
"points": int(row["points"] + 10),
|
||||
"assists": int(row["assists"] + 3),
|
||||
"rebounds": int(row["rebounds"] + 2),
|
||||
"efficiency_rating": row["efficiency"] + 10,
|
||||
},
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Seeded synthetic HoopScout demo data."))
|
||||
@@ -0,0 +1,242 @@
|
||||
# Generated for the HoopScout MVP.
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="League",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("name", models.CharField(max_length=120)),
|
||||
("code", models.CharField(max_length=16, unique=True)),
|
||||
("region", models.CharField(max_length=60)),
|
||||
("country", models.CharField(max_length=80)),
|
||||
],
|
||||
options={"ordering": ["region", "country", "name"]},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Season",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("label", models.CharField(max_length=20, unique=True)),
|
||||
("is_active", models.BooleanField(default=False)),
|
||||
],
|
||||
options={"ordering": ["-label"]},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Team",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("name", models.CharField(max_length=120)),
|
||||
("country", models.CharField(max_length=80)),
|
||||
(
|
||||
"league",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="teams",
|
||||
to="scouting.league",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["name"],
|
||||
"constraints": [
|
||||
models.UniqueConstraint(fields=("name", "league"), name="unique_team_per_league"),
|
||||
],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="UserProfile",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
(
|
||||
"role",
|
||||
models.CharField(
|
||||
choices=[("admin", "Admin"), ("scout", "Scout"), ("viewer", "Viewer")],
|
||||
default="viewer",
|
||||
max_length=16,
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="profile",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Player",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("first_name", models.CharField(max_length=80)),
|
||||
("last_name", models.CharField(max_length=80)),
|
||||
(
|
||||
"position",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("PG", "Point Guard"),
|
||||
("SG", "Shooting Guard"),
|
||||
("SF", "Small Forward"),
|
||||
("PF", "Power Forward"),
|
||||
("C", "Center"),
|
||||
],
|
||||
max_length=2,
|
||||
),
|
||||
),
|
||||
("role", models.CharField(blank=True, max_length=120)),
|
||||
("birth_year", models.PositiveSmallIntegerField(blank=True, null=True)),
|
||||
("height_cm", models.PositiveSmallIntegerField(blank=True, null=True)),
|
||||
("weight_kg", models.PositiveSmallIntegerField(blank=True, null=True)),
|
||||
("nationality", models.CharField(blank=True, max_length=80)),
|
||||
("external_source", models.CharField(blank=True, max_length=80)),
|
||||
("external_id", models.CharField(blank=True, max_length=120)),
|
||||
("profile_url", models.URLField(blank=True)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"current_team",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="players",
|
||||
to="scouting.team",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["last_name", "first_name"],
|
||||
"constraints": [
|
||||
models.UniqueConstraint(
|
||||
fields=("first_name", "last_name", "birth_year", "nationality"),
|
||||
name="unique_player_identity",
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="PlayerGameLog",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("opponent", models.CharField(max_length=120)),
|
||||
("game_date", models.DateField()),
|
||||
("points", models.PositiveSmallIntegerField(default=0)),
|
||||
("assists", models.PositiveSmallIntegerField(default=0)),
|
||||
("rebounds", models.PositiveSmallIntegerField(default=0)),
|
||||
("steals", models.PositiveSmallIntegerField(default=0)),
|
||||
("blocks", models.PositiveSmallIntegerField(default=0)),
|
||||
("turnovers", models.PositiveSmallIntegerField(default=0)),
|
||||
("efficiency_rating", models.DecimalField(decimal_places=2, default=0, max_digits=6)),
|
||||
(
|
||||
"league",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="game_logs",
|
||||
to="scouting.league",
|
||||
),
|
||||
),
|
||||
(
|
||||
"player",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="game_logs",
|
||||
to="scouting.player",
|
||||
),
|
||||
),
|
||||
(
|
||||
"season",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="game_logs",
|
||||
to="scouting.season",
|
||||
),
|
||||
),
|
||||
(
|
||||
"team",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="game_logs",
|
||||
to="scouting.team",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={"ordering": ["-game_date"]},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="PlayerSeasonStat",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("games_played", models.PositiveSmallIntegerField(default=0)),
|
||||
("minutes_per_game", models.DecimalField(decimal_places=2, default=0, max_digits=5)),
|
||||
("points_per_game", models.DecimalField(decimal_places=2, default=0, max_digits=5)),
|
||||
("assists_per_game", models.DecimalField(decimal_places=2, default=0, max_digits=5)),
|
||||
("rebounds_per_game", models.DecimalField(decimal_places=2, default=0, max_digits=5)),
|
||||
("steals_per_game", models.DecimalField(decimal_places=2, default=0, max_digits=5)),
|
||||
("blocks_per_game", models.DecimalField(decimal_places=2, default=0, max_digits=5)),
|
||||
("turnovers_per_game", models.DecimalField(decimal_places=2, default=0, max_digits=5)),
|
||||
("field_goal_percentage", models.DecimalField(decimal_places=2, default=0, max_digits=5)),
|
||||
("three_point_percentage", models.DecimalField(decimal_places=2, default=0, max_digits=5)),
|
||||
("free_throw_percentage", models.DecimalField(decimal_places=2, default=0, max_digits=5)),
|
||||
("efficiency_rating", models.DecimalField(decimal_places=2, default=0, max_digits=6)),
|
||||
("true_shooting_percentage", models.DecimalField(decimal_places=2, default=0, max_digits=5)),
|
||||
("usage_percentage", models.DecimalField(decimal_places=2, default=0, max_digits=5)),
|
||||
("total_points", models.PositiveSmallIntegerField(default=0)),
|
||||
("total_assists", models.PositiveSmallIntegerField(default=0)),
|
||||
("total_rebounds", models.PositiveSmallIntegerField(default=0)),
|
||||
(
|
||||
"league",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="season_stats",
|
||||
to="scouting.league",
|
||||
),
|
||||
),
|
||||
(
|
||||
"player",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="season_stats",
|
||||
to="scouting.player",
|
||||
),
|
||||
),
|
||||
(
|
||||
"season",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="player_stats",
|
||||
to="scouting.season",
|
||||
),
|
||||
),
|
||||
(
|
||||
"team",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="season_stats",
|
||||
to="scouting.team",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-efficiency_rating", "-points_per_game"],
|
||||
"constraints": [
|
||||
models.UniqueConstraint(
|
||||
fields=("player", "team", "league", "season"),
|
||||
name="unique_player_stat_line",
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
|
||||
class UserProfile(models.Model):
|
||||
ROLE_ADMIN = "admin"
|
||||
ROLE_SCOUT = "scout"
|
||||
ROLE_VIEWER = "viewer"
|
||||
ROLE_CHOICES = [
|
||||
(ROLE_ADMIN, "Admin"),
|
||||
(ROLE_SCOUT, "Scout"),
|
||||
(ROLE_VIEWER, "Viewer"),
|
||||
]
|
||||
|
||||
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="profile")
|
||||
role = models.CharField(max_length=16, choices=ROLE_CHOICES, default=ROLE_VIEWER)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.user.username} ({self.role})"
|
||||
|
||||
|
||||
class League(models.Model):
|
||||
name = models.CharField(max_length=120)
|
||||
code = models.CharField(max_length=16, unique=True)
|
||||
region = models.CharField(max_length=60)
|
||||
country = models.CharField(max_length=80)
|
||||
|
||||
class Meta:
|
||||
ordering = ["region", "country", "name"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.code
|
||||
|
||||
|
||||
class Team(models.Model):
|
||||
name = models.CharField(max_length=120)
|
||||
league = models.ForeignKey(League, on_delete=models.PROTECT, related_name="teams")
|
||||
country = models.CharField(max_length=80)
|
||||
|
||||
class Meta:
|
||||
ordering = ["name"]
|
||||
constraints = [models.UniqueConstraint(fields=["name", "league"], name="unique_team_per_league")]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
class Season(models.Model):
|
||||
label = models.CharField(max_length=20, unique=True)
|
||||
is_active = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-label"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.label
|
||||
|
||||
|
||||
class Player(models.Model):
|
||||
POSITION_CHOICES = [
|
||||
("PG", "Point Guard"),
|
||||
("SG", "Shooting Guard"),
|
||||
("SF", "Small Forward"),
|
||||
("PF", "Power Forward"),
|
||||
("C", "Center"),
|
||||
]
|
||||
|
||||
first_name = models.CharField(max_length=80)
|
||||
last_name = models.CharField(max_length=80)
|
||||
position = models.CharField(max_length=2, choices=POSITION_CHOICES)
|
||||
role = models.CharField(max_length=120, blank=True)
|
||||
birth_year = models.PositiveSmallIntegerField(null=True, blank=True)
|
||||
height_cm = models.PositiveSmallIntegerField(null=True, blank=True)
|
||||
weight_kg = models.PositiveSmallIntegerField(null=True, blank=True)
|
||||
nationality = models.CharField(max_length=80, blank=True)
|
||||
current_team = models.ForeignKey(Team, on_delete=models.SET_NULL, null=True, blank=True, related_name="players")
|
||||
external_source = models.CharField(max_length=80, blank=True)
|
||||
external_id = models.CharField(max_length=120, blank=True)
|
||||
profile_url = models.URLField(blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["last_name", "first_name"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["first_name", "last_name", "birth_year", "nationality"],
|
||||
name="unique_player_identity",
|
||||
)
|
||||
]
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return f"{self.first_name} {self.last_name}"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
class PlayerSeasonStat(models.Model):
|
||||
player = models.ForeignKey(Player, on_delete=models.CASCADE, related_name="season_stats")
|
||||
team = models.ForeignKey(Team, on_delete=models.PROTECT, related_name="season_stats")
|
||||
league = models.ForeignKey(League, on_delete=models.PROTECT, related_name="season_stats")
|
||||
season = models.ForeignKey(Season, on_delete=models.PROTECT, related_name="player_stats")
|
||||
games_played = models.PositiveSmallIntegerField(default=0)
|
||||
minutes_per_game = models.DecimalField(max_digits=5, decimal_places=2, default=0)
|
||||
points_per_game = models.DecimalField(max_digits=5, decimal_places=2, default=0)
|
||||
assists_per_game = models.DecimalField(max_digits=5, decimal_places=2, default=0)
|
||||
rebounds_per_game = models.DecimalField(max_digits=5, decimal_places=2, default=0)
|
||||
steals_per_game = models.DecimalField(max_digits=5, decimal_places=2, default=0)
|
||||
blocks_per_game = models.DecimalField(max_digits=5, decimal_places=2, default=0)
|
||||
turnovers_per_game = models.DecimalField(max_digits=5, decimal_places=2, default=0)
|
||||
field_goal_percentage = models.DecimalField(max_digits=5, decimal_places=2, default=0)
|
||||
three_point_percentage = models.DecimalField(max_digits=5, decimal_places=2, default=0)
|
||||
free_throw_percentage = models.DecimalField(max_digits=5, decimal_places=2, default=0)
|
||||
efficiency_rating = models.DecimalField(max_digits=6, decimal_places=2, default=0)
|
||||
true_shooting_percentage = models.DecimalField(max_digits=5, decimal_places=2, default=0)
|
||||
usage_percentage = models.DecimalField(max_digits=5, decimal_places=2, default=0)
|
||||
total_points = models.PositiveSmallIntegerField(default=0)
|
||||
total_assists = models.PositiveSmallIntegerField(default=0)
|
||||
total_rebounds = models.PositiveSmallIntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-efficiency_rating", "-points_per_game"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["player", "team", "league", "season"], name="unique_player_stat_line")
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.player} {self.season} {self.league}"
|
||||
|
||||
|
||||
class PlayerGameLog(models.Model):
|
||||
player = models.ForeignKey(Player, on_delete=models.CASCADE, related_name="game_logs")
|
||||
team = models.ForeignKey(Team, on_delete=models.PROTECT, related_name="game_logs")
|
||||
opponent = models.CharField(max_length=120)
|
||||
league = models.ForeignKey(League, on_delete=models.PROTECT, related_name="game_logs")
|
||||
season = models.ForeignKey(Season, on_delete=models.PROTECT, related_name="game_logs")
|
||||
game_date = models.DateField()
|
||||
points = models.PositiveSmallIntegerField(default=0)
|
||||
assists = models.PositiveSmallIntegerField(default=0)
|
||||
rebounds = models.PositiveSmallIntegerField(default=0)
|
||||
steals = models.PositiveSmallIntegerField(default=0)
|
||||
blocks = models.PositiveSmallIntegerField(default=0)
|
||||
turnovers = models.PositiveSmallIntegerField(default=0)
|
||||
efficiency_rating = models.DecimalField(max_digits=6, decimal_places=2, default=0)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-game_date"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.player} vs {self.opponent} on {self.game_date}"
|
||||
@@ -0,0 +1,113 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from .models import League, Player, PlayerGameLog, PlayerSeasonStat, Team
|
||||
|
||||
|
||||
class LeagueSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = League
|
||||
fields = ["name", "code", "region", "country"]
|
||||
|
||||
|
||||
class TeamSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Team
|
||||
fields = ["name", "country"]
|
||||
|
||||
|
||||
class PlayerSeasonStatSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = PlayerSeasonStat
|
||||
fields = [
|
||||
"games_played",
|
||||
"minutes_per_game",
|
||||
"points_per_game",
|
||||
"assists_per_game",
|
||||
"rebounds_per_game",
|
||||
"steals_per_game",
|
||||
"blocks_per_game",
|
||||
"turnovers_per_game",
|
||||
"field_goal_percentage",
|
||||
"three_point_percentage",
|
||||
"free_throw_percentage",
|
||||
"efficiency_rating",
|
||||
"true_shooting_percentage",
|
||||
"usage_percentage",
|
||||
"total_points",
|
||||
"total_assists",
|
||||
"total_rebounds",
|
||||
]
|
||||
|
||||
|
||||
class PlayerGameLogSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = PlayerGameLog
|
||||
fields = [
|
||||
"game_date",
|
||||
"opponent",
|
||||
"points",
|
||||
"assists",
|
||||
"rebounds",
|
||||
"steals",
|
||||
"blocks",
|
||||
"turnovers",
|
||||
"efficiency_rating",
|
||||
]
|
||||
|
||||
|
||||
class PlayerListSerializer(serializers.ModelSerializer):
|
||||
name = serializers.CharField(read_only=True)
|
||||
league = serializers.SerializerMethodField()
|
||||
team = serializers.SerializerMethodField()
|
||||
stats = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Player
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"position",
|
||||
"role",
|
||||
"birth_year",
|
||||
"height_cm",
|
||||
"weight_kg",
|
||||
"nationality",
|
||||
"league",
|
||||
"team",
|
||||
"stats",
|
||||
]
|
||||
|
||||
def get_league(self, player: Player) -> dict | None:
|
||||
stat = self._current_stat(player)
|
||||
return LeagueSerializer(stat.league).data if stat else None
|
||||
|
||||
def get_team(self, player: Player) -> dict | None:
|
||||
stat = self._current_stat(player)
|
||||
team = stat.team if stat else player.current_team
|
||||
return TeamSerializer(team).data if team else None
|
||||
|
||||
def get_stats(self, player: Player) -> dict | None:
|
||||
stat = self._current_stat(player)
|
||||
return PlayerSeasonStatSerializer(stat).data if stat else None
|
||||
|
||||
def _current_stat(self, player: Player) -> PlayerSeasonStat | None:
|
||||
prefetched = getattr(player, "prefetched_stats", None)
|
||||
if prefetched is not None:
|
||||
return prefetched[0] if prefetched else None
|
||||
return player.season_stats.select_related("league", "team", "season").first()
|
||||
|
||||
|
||||
class PlayerDetailSerializer(PlayerListSerializer):
|
||||
best_game = serializers.SerializerMethodField()
|
||||
worst_game = serializers.SerializerMethodField()
|
||||
|
||||
class Meta(PlayerListSerializer.Meta):
|
||||
fields = PlayerListSerializer.Meta.fields + ["external_source", "profile_url", "best_game", "worst_game"]
|
||||
|
||||
def get_best_game(self, player: Player) -> dict | None:
|
||||
game = player.game_logs.order_by("-efficiency_rating", "-points").first()
|
||||
return PlayerGameLogSerializer(game).data if game else None
|
||||
|
||||
def get_worst_game(self, player: Player) -> dict | None:
|
||||
game = player.game_logs.order_by("efficiency_rating", "points").first()
|
||||
return PlayerGameLogSerializer(game).data if game else None
|
||||
@@ -0,0 +1,11 @@
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from .models import UserProfile
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def ensure_user_profile(sender, instance: User, created: bool, **kwargs) -> None:
|
||||
if created:
|
||||
UserProfile.objects.create(user=instance)
|
||||
@@ -0,0 +1,233 @@
|
||||
import pytest
|
||||
from django.contrib.auth.models import User
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from scouting.models import League, Player, PlayerGameLog, PlayerSeasonStat, Season, Team
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_client():
|
||||
return APIClient()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scout_user(db):
|
||||
user = User.objects.create_user(username="scout", password="test-password")
|
||||
user.profile.role = "scout"
|
||||
user.profile.save(update_fields=["role"])
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_data(db):
|
||||
lba = League.objects.create(name="Lega Basket Serie A", code="LBA", region="Europe", country="Italy")
|
||||
endesa = League.objects.create(name="Liga Endesa", code="ACB", region="Europe", country="Spain")
|
||||
nba = League.objects.create(
|
||||
name="National Basketball Association",
|
||||
code="NBA",
|
||||
region="North America",
|
||||
country="USA",
|
||||
)
|
||||
season = Season.objects.create(label="2025-26", is_active=True)
|
||||
|
||||
milano = Team.objects.create(name="Milano", league=lba, country="Italy")
|
||||
madrid = Team.objects.create(name="Madrid", league=endesa, country="Spain")
|
||||
boston = Team.objects.create(name="Boston", league=nba, country="USA")
|
||||
|
||||
guard = Player.objects.create(
|
||||
first_name="Luca",
|
||||
last_name="Marini",
|
||||
position="PG",
|
||||
role="Primary ball handler",
|
||||
birth_year=2001,
|
||||
height_cm=190,
|
||||
weight_kg=86,
|
||||
nationality="Italy",
|
||||
current_team=milano,
|
||||
)
|
||||
wing = Player.objects.create(
|
||||
first_name="Mateo",
|
||||
last_name="Santos",
|
||||
position="SF",
|
||||
role="3 and D wing",
|
||||
birth_year=1999,
|
||||
height_cm=201,
|
||||
weight_kg=96,
|
||||
nationality="Spain",
|
||||
current_team=madrid,
|
||||
)
|
||||
nba_big = Player.objects.create(
|
||||
first_name="Cole",
|
||||
last_name="Anderson",
|
||||
position="C",
|
||||
role="Rim runner",
|
||||
birth_year=1998,
|
||||
height_cm=211,
|
||||
weight_kg=112,
|
||||
nationality="USA",
|
||||
current_team=boston,
|
||||
)
|
||||
|
||||
PlayerSeasonStat.objects.create(
|
||||
player=guard,
|
||||
team=milano,
|
||||
league=lba,
|
||||
season=season,
|
||||
games_played=28,
|
||||
minutes_per_game=29.4,
|
||||
points_per_game=16.8,
|
||||
assists_per_game=6.2,
|
||||
rebounds_per_game=3.8,
|
||||
steals_per_game=1.4,
|
||||
blocks_per_game=0.1,
|
||||
turnovers_per_game=2.3,
|
||||
field_goal_percentage=48.1,
|
||||
three_point_percentage=39.6,
|
||||
free_throw_percentage=84.2,
|
||||
efficiency_rating=20.5,
|
||||
true_shooting_percentage=59.2,
|
||||
usage_percentage=25.0,
|
||||
total_points=470,
|
||||
total_assists=174,
|
||||
total_rebounds=106,
|
||||
)
|
||||
PlayerSeasonStat.objects.create(
|
||||
player=wing,
|
||||
team=madrid,
|
||||
league=endesa,
|
||||
season=season,
|
||||
games_played=25,
|
||||
minutes_per_game=24.1,
|
||||
points_per_game=11.2,
|
||||
assists_per_game=2.1,
|
||||
rebounds_per_game=5.5,
|
||||
steals_per_game=1.1,
|
||||
blocks_per_game=0.5,
|
||||
turnovers_per_game=1.0,
|
||||
field_goal_percentage=44.0,
|
||||
three_point_percentage=37.4,
|
||||
free_throw_percentage=79.3,
|
||||
efficiency_rating=13.7,
|
||||
true_shooting_percentage=55.8,
|
||||
usage_percentage=17.5,
|
||||
total_points=280,
|
||||
total_assists=52,
|
||||
total_rebounds=138,
|
||||
)
|
||||
PlayerSeasonStat.objects.create(
|
||||
player=nba_big,
|
||||
team=boston,
|
||||
league=nba,
|
||||
season=season,
|
||||
games_played=30,
|
||||
minutes_per_game=18.2,
|
||||
points_per_game=8.4,
|
||||
assists_per_game=1.0,
|
||||
rebounds_per_game=7.8,
|
||||
steals_per_game=0.4,
|
||||
blocks_per_game=1.9,
|
||||
turnovers_per_game=1.2,
|
||||
field_goal_percentage=62.0,
|
||||
three_point_percentage=0,
|
||||
free_throw_percentage=68.0,
|
||||
efficiency_rating=12.1,
|
||||
true_shooting_percentage=63.4,
|
||||
usage_percentage=14.1,
|
||||
total_points=252,
|
||||
total_assists=30,
|
||||
total_rebounds=234,
|
||||
)
|
||||
|
||||
PlayerGameLog.objects.create(
|
||||
player=guard,
|
||||
team=milano,
|
||||
opponent="Bologna",
|
||||
league=lba,
|
||||
season=season,
|
||||
game_date="2026-01-10",
|
||||
points=28,
|
||||
assists=9,
|
||||
rebounds=5,
|
||||
efficiency_rating=32.0,
|
||||
)
|
||||
PlayerGameLog.objects.create(
|
||||
player=guard,
|
||||
team=milano,
|
||||
opponent="Venezia",
|
||||
league=lba,
|
||||
season=season,
|
||||
game_date="2026-01-17",
|
||||
points=6,
|
||||
assists=3,
|
||||
rebounds=1,
|
||||
efficiency_rating=4.0,
|
||||
)
|
||||
|
||||
return {"guard": guard, "wing": wing, "nba_big": nba_big}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_players_api_requires_authentication(api_client):
|
||||
response = api_client.get(reverse("player-list"))
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_players_can_be_filtered_by_name_position_league_and_stats(api_client, scout_user, sample_data):
|
||||
api_client.force_authenticate(scout_user)
|
||||
|
||||
response = api_client.get(
|
||||
reverse("player-list"),
|
||||
{
|
||||
"q": "luca",
|
||||
"position": "PG",
|
||||
"league": "LBA",
|
||||
"points_per_game__gte": "15",
|
||||
"assists_per_game__gte": "5",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["count"] == 1
|
||||
assert payload["results"][0]["name"] == "Luca Marini"
|
||||
assert payload["results"][0]["league"]["code"] == "LBA"
|
||||
assert payload["results"][0]["stats"]["points_per_game"] == "16.80"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_players_are_ranked_by_efficiency_then_points(api_client, scout_user, sample_data):
|
||||
api_client.force_authenticate(scout_user)
|
||||
|
||||
response = api_client.get(reverse("player-list"), {"region": "Europe"})
|
||||
|
||||
assert response.status_code == 200
|
||||
names = [player["name"] for player in response.json()["results"]]
|
||||
assert names == ["Luca Marini", "Mateo Santos"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_player_detail_exposes_best_and_worst_performances(api_client, scout_user, sample_data):
|
||||
api_client.force_authenticate(scout_user)
|
||||
|
||||
response = api_client.get(reverse("player-detail", args=[sample_data["guard"].id]))
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["name"] == "Luca Marini"
|
||||
assert payload["best_game"]["opponent"] == "Bologna"
|
||||
assert payload["best_game"]["efficiency_rating"] == "32.00"
|
||||
assert payload["worst_game"]["opponent"] == "Venezia"
|
||||
assert payload["worst_game"]["efficiency_rating"] == "4.00"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_me_endpoint_returns_user_role(api_client, scout_user):
|
||||
api_client.force_authenticate(scout_user)
|
||||
|
||||
response = api_client.get(reverse("me"))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"username": "scout", "role": "scout"}
|
||||
@@ -0,0 +1,128 @@
|
||||
from django.db.models import Prefetch, Q
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
from .models import League, Player, PlayerSeasonStat, Team
|
||||
from .serializers import (
|
||||
LeagueSerializer,
|
||||
PlayerDetailSerializer,
|
||||
PlayerListSerializer,
|
||||
TeamSerializer,
|
||||
)
|
||||
|
||||
STAT_FILTERS = {
|
||||
"games_played": "season_stats__games_played",
|
||||
"minutes_per_game": "season_stats__minutes_per_game",
|
||||
"points_per_game": "season_stats__points_per_game",
|
||||
"assists_per_game": "season_stats__assists_per_game",
|
||||
"rebounds_per_game": "season_stats__rebounds_per_game",
|
||||
"steals_per_game": "season_stats__steals_per_game",
|
||||
"blocks_per_game": "season_stats__blocks_per_game",
|
||||
"turnovers_per_game": "season_stats__turnovers_per_game",
|
||||
"field_goal_percentage": "season_stats__field_goal_percentage",
|
||||
"three_point_percentage": "season_stats__three_point_percentage",
|
||||
"free_throw_percentage": "season_stats__free_throw_percentage",
|
||||
"efficiency_rating": "season_stats__efficiency_rating",
|
||||
"true_shooting_percentage": "season_stats__true_shooting_percentage",
|
||||
"usage_percentage": "season_stats__usage_percentage",
|
||||
"total_points": "season_stats__total_points",
|
||||
"total_assists": "season_stats__total_assists",
|
||||
"total_rebounds": "season_stats__total_rebounds",
|
||||
}
|
||||
|
||||
PLAYER_FILTERS = {
|
||||
"position": "position__iexact",
|
||||
"role": "role__icontains",
|
||||
"nationality": "nationality__icontains",
|
||||
"height_cm": "height_cm",
|
||||
"weight_kg": "weight_kg",
|
||||
"birth_year": "birth_year",
|
||||
}
|
||||
|
||||
LOOKUPS = {"exact", "gte", "lte", "gt", "lt"}
|
||||
|
||||
|
||||
class PlayerViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "retrieve":
|
||||
return PlayerDetailSerializer
|
||||
return PlayerListSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
stats = PlayerSeasonStat.objects.select_related("league", "team", "season").order_by(
|
||||
"-efficiency_rating", "-points_per_game"
|
||||
)
|
||||
queryset = (
|
||||
Player.objects.select_related("current_team", "current_team__league")
|
||||
.prefetch_related(Prefetch("season_stats", queryset=stats, to_attr="prefetched_stats"))
|
||||
.distinct()
|
||||
)
|
||||
|
||||
params = self.request.query_params
|
||||
if query := params.get("q"):
|
||||
queryset = queryset.filter(
|
||||
Q(first_name__icontains=query)
|
||||
| Q(last_name__icontains=query)
|
||||
| Q(role__icontains=query)
|
||||
| Q(nationality__icontains=query)
|
||||
| Q(current_team__name__icontains=query)
|
||||
)
|
||||
|
||||
if league := params.get("league"):
|
||||
queryset = queryset.filter(
|
||||
Q(season_stats__league__code__iexact=league)
|
||||
| Q(season_stats__league__name__icontains=league)
|
||||
| Q(current_team__league__code__iexact=league)
|
||||
)
|
||||
|
||||
if region := params.get("region"):
|
||||
queryset = queryset.filter(season_stats__league__region__iexact=region)
|
||||
|
||||
if team := params.get("team"):
|
||||
queryset = queryset.filter(
|
||||
Q(season_stats__team__name__icontains=team) | Q(current_team__name__icontains=team)
|
||||
)
|
||||
|
||||
if season := params.get("season"):
|
||||
queryset = queryset.filter(season_stats__season__label=season)
|
||||
|
||||
for param, field in PLAYER_FILTERS.items():
|
||||
if params.get(param):
|
||||
queryset = queryset.filter(**{field: params[param]})
|
||||
for lookup in LOOKUPS - {"exact"}:
|
||||
key = f"{param}__{lookup}"
|
||||
if params.get(key):
|
||||
queryset = queryset.filter(**{f"{param}__{lookup}": params[key]})
|
||||
|
||||
for param, field in STAT_FILTERS.items():
|
||||
if params.get(param):
|
||||
queryset = queryset.filter(**{field: params[param]})
|
||||
for lookup in LOOKUPS - {"exact"}:
|
||||
key = f"{param}__{lookup}"
|
||||
if params.get(key):
|
||||
queryset = queryset.filter(**{f"{field}__{lookup}": params[key]})
|
||||
|
||||
return queryset.order_by("-season_stats__efficiency_rating", "-season_stats__points_per_game", "last_name")
|
||||
|
||||
|
||||
class LeagueViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
queryset = League.objects.all()
|
||||
serializer_class = LeagueSerializer
|
||||
|
||||
|
||||
class TeamViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
queryset = Team.objects.select_related("league").all()
|
||||
serializer_class = TeamSerializer
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def me(request):
|
||||
role = getattr(request.user.profile, "role", "viewer")
|
||||
return Response({"username": request.user.username, "role": role})
|
||||
Reference in New Issue
Block a user