feat: bootstrap HoopScout scouting app

This commit is contained in:
bisco
2026-06-03 21:37:15 +02:00
parent c4b1b6ee15
commit cc188468bc
52 changed files with 14505 additions and 126 deletions
+5
View File
@@ -0,0 +1,5 @@
.venv
__pycache__
.pytest_cache
.ruff_cache
*.pyc
+20
View File
@@ -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"]
+1
View File
@@ -0,0 +1 @@
+7
View File
@@ -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()
+91
View File
@@ -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(",")
+17
View File
@@ -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")),
]
+7
View File
@@ -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()
+14
View File
@@ -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()
+30
View File
@@ -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"]
+1
View File
@@ -0,0 +1 @@
+59
View File
@@ -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")
+9
View File
@@ -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
+1
View File
@@ -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."))
+242
View File
@@ -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",
),
],
},
),
]
+1
View File
@@ -0,0 +1 @@
+152
View File
@@ -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}"
+113
View File
@@ -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
+11
View File
@@ -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)
+233
View File
@@ -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"}
+128
View File
@@ -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})