Compare commits
4 Commits
5ce9214c88
...
aecbb62376
| Author | SHA1 | Date | |
|---|---|---|---|
| aecbb62376 | |||
| d5b00eee98 | |||
| 2994089f1e | |||
| a9f189cbc7 |
8
.env.example
Normal file
8
.env.example
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
DJANGO_SECRET_KEY=dev-only-change-me
|
||||||
|
DJANGO_DEBUG=1
|
||||||
|
DJANGO_ALLOWED_HOSTS=*
|
||||||
|
POSTGRES_DB=hoopscout
|
||||||
|
POSTGRES_USER=hoopscout
|
||||||
|
POSTGRES_PASSWORD=hoopscout
|
||||||
|
POSTGRES_HOST=db
|
||||||
|
POSTGRES_PORT=5432
|
||||||
0
app/hoopscout/__init__.py
Normal file
0
app/hoopscout/__init__.py
Normal file
7
app/hoopscout/asgi.py
Normal file
7
app/hoopscout/asgi.py
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.asgi import get_asgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "hoopscout.settings")
|
||||||
|
|
||||||
|
application = get_asgi_application()
|
||||||
68
app/hoopscout/settings.py
Normal file
68
app/hoopscout/settings.py
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "dev-only-insecure-secret-key")
|
||||||
|
DEBUG = os.getenv("DJANGO_DEBUG", "1") == "1"
|
||||||
|
ALLOWED_HOSTS = os.getenv("DJANGO_ALLOWED_HOSTS", "*").split(",")
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
"django.contrib.admin",
|
||||||
|
"django.contrib.auth",
|
||||||
|
"django.contrib.contenttypes",
|
||||||
|
"django.contrib.sessions",
|
||||||
|
"django.contrib.messages",
|
||||||
|
"django.contrib.staticfiles",
|
||||||
|
"scouting.apps.ScoutingConfig",
|
||||||
|
]
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
"django.middleware.security.SecurityMiddleware",
|
||||||
|
"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 = "hoopscout.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 = "hoopscout.wsgi.application"
|
||||||
|
ASGI_APPLICATION = "hoopscout.asgi.application"
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
"default": {
|
||||||
|
"ENGINE": "django.db.backends.postgresql",
|
||||||
|
"NAME": os.getenv("POSTGRES_DB", "hoopscout"),
|
||||||
|
"USER": os.getenv("POSTGRES_USER", "hoopscout"),
|
||||||
|
"PASSWORD": os.getenv("POSTGRES_PASSWORD", "hoopscout"),
|
||||||
|
"HOST": os.getenv("POSTGRES_HOST", "db"),
|
||||||
|
"PORT": os.getenv("POSTGRES_PORT", "5432"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LANGUAGE_CODE = "en-us"
|
||||||
|
TIME_ZONE = "UTC"
|
||||||
|
USE_I18N = True
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
STATIC_URL = "static/"
|
||||||
|
|
||||||
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
6
app/hoopscout/urls.py
Normal file
6
app/hoopscout/urls.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("admin/", admin.site.urls),
|
||||||
|
]
|
||||||
7
app/hoopscout/wsgi.py
Normal file
7
app/hoopscout/wsgi.py
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "hoopscout.settings")
|
||||||
|
|
||||||
|
application = get_wsgi_application()
|
||||||
14
app/manage.py
Normal file
14
app/manage.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "hoopscout.settings")
|
||||||
|
from django.core.management import execute_from_command_line
|
||||||
|
|
||||||
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
2
app/requirements.txt
Normal file
2
app/requirements.txt
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
Django==5.2.2
|
||||||
|
psycopg[binary]==3.2.9
|
||||||
0
app/scouting/__init__.py
Normal file
0
app/scouting/__init__.py
Normal file
70
app/scouting/admin.py
Normal file
70
app/scouting/admin.py
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from .models import (
|
||||||
|
Competition,
|
||||||
|
Player,
|
||||||
|
PlayerSeason,
|
||||||
|
PlayerSeasonStats,
|
||||||
|
Role,
|
||||||
|
Season,
|
||||||
|
Specialty,
|
||||||
|
Team,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Role)
|
||||||
|
class RoleAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("name", "slug")
|
||||||
|
search_fields = ("name", "slug")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Specialty)
|
||||||
|
class SpecialtyAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("name", "slug")
|
||||||
|
search_fields = ("name", "slug")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Player)
|
||||||
|
class PlayerAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("full_name", "nationality", "birth_date")
|
||||||
|
search_fields = ("full_name", "first_name", "last_name", "nationality")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Competition)
|
||||||
|
class CompetitionAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("name", "country", "level")
|
||||||
|
search_fields = ("name", "country", "level")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Team)
|
||||||
|
class TeamAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("name", "competition", "country")
|
||||||
|
search_fields = ("name", "country")
|
||||||
|
list_filter = ("competition",)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Season)
|
||||||
|
class SeasonAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("name", "start_year", "end_year")
|
||||||
|
search_fields = ("name",)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(PlayerSeason)
|
||||||
|
class PlayerSeasonAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("player", "season", "team", "competition", "position")
|
||||||
|
list_filter = ("season", "competition", "position")
|
||||||
|
search_fields = ("player__full_name", "team__name", "competition__name")
|
||||||
|
filter_horizontal = ("roles", "specialties")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(PlayerSeasonStats)
|
||||||
|
class PlayerSeasonStatsAdmin(admin.ModelAdmin):
|
||||||
|
list_display = (
|
||||||
|
"player_season",
|
||||||
|
"points",
|
||||||
|
"assists",
|
||||||
|
"steals",
|
||||||
|
"turnovers",
|
||||||
|
"blocks",
|
||||||
|
)
|
||||||
|
search_fields = ("player_season__player__full_name", "player_season__season__name")
|
||||||
6
app/scouting/apps.py
Normal file
6
app/scouting/apps.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ScoutingConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "scouting"
|
||||||
145
app/scouting/migrations/0001_initial.py
Normal file
145
app/scouting/migrations/0001_initial.py
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
# Generated by Django 5.2.2 on 2026-04-06 17:05
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Competition',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=150, unique=True)),
|
||||||
|
('country', models.CharField(blank=True, max_length=100)),
|
||||||
|
('level', models.CharField(blank=True, max_length=100)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Player',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('full_name', models.CharField(max_length=255)),
|
||||||
|
('first_name', models.CharField(blank=True, max_length=100)),
|
||||||
|
('last_name', models.CharField(blank=True, max_length=100)),
|
||||||
|
('birth_date', models.DateField(blank=True, null=True)),
|
||||||
|
('nationality', models.CharField(blank=True, max_length=100)),
|
||||||
|
('height_cm', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True)),
|
||||||
|
('weight_kg', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True)),
|
||||||
|
('wingspan_cm', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['full_name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Role',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=100, unique=True)),
|
||||||
|
('slug', models.SlugField(max_length=120, unique=True)),
|
||||||
|
('description', models.TextField(blank=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Season',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=20, unique=True)),
|
||||||
|
('start_year', models.PositiveSmallIntegerField()),
|
||||||
|
('end_year', models.PositiveSmallIntegerField()),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-start_year', '-end_year'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Specialty',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=100, unique=True)),
|
||||||
|
('slug', models.SlugField(max_length=120, unique=True)),
|
||||||
|
('description', models.TextField(blank=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PlayerSeason',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('position', models.CharField(choices=[('PG', 'PG'), ('SG', 'SG'), ('SF', 'SF'), ('PF', 'PF'), ('C', 'C')], max_length=2)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('competition', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='player_seasons', to='scouting.competition')),
|
||||||
|
('player', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='player_seasons', to='scouting.player')),
|
||||||
|
('roles', models.ManyToManyField(blank=True, related_name='player_seasons', to='scouting.role')),
|
||||||
|
('season', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='player_seasons', to='scouting.season')),
|
||||||
|
('specialties', models.ManyToManyField(blank=True, related_name='player_seasons', to='scouting.specialty')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['player__full_name', '-season__start_year'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PlayerSeasonStats',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('points', models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True)),
|
||||||
|
('assists', models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True)),
|
||||||
|
('steals', models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True)),
|
||||||
|
('turnovers', models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True)),
|
||||||
|
('blocks', models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True)),
|
||||||
|
('efg_pct', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True)),
|
||||||
|
('ts_pct', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True)),
|
||||||
|
('plus_minus', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True)),
|
||||||
|
('offensive_rating', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True)),
|
||||||
|
('defensive_rating', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('player_season', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='stats', to='scouting.playerseason')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name_plural': 'Player season stats',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Team',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=150)),
|
||||||
|
('country', models.CharField(blank=True, max_length=100)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('competition', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='teams', to='scouting.competition')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['name'],
|
||||||
|
'unique_together': {('name', 'competition')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='playerseason',
|
||||||
|
name='team',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='player_seasons', to='scouting.team'),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
app/scouting/migrations/__init__.py
Normal file
0
app/scouting/migrations/__init__.py
Normal file
159
app/scouting/models.py
Normal file
159
app/scouting/models.py
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Role(models.Model):
|
||||||
|
name = models.CharField(max_length=100, unique=True)
|
||||||
|
slug = models.SlugField(max_length=120, unique=True)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["name"]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class Specialty(models.Model):
|
||||||
|
name = models.CharField(max_length=100, unique=True)
|
||||||
|
slug = models.SlugField(max_length=120, unique=True)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["name"]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class Player(models.Model):
|
||||||
|
full_name = models.CharField(max_length=255)
|
||||||
|
first_name = models.CharField(max_length=100, blank=True)
|
||||||
|
last_name = models.CharField(max_length=100, blank=True)
|
||||||
|
birth_date = models.DateField(null=True, blank=True)
|
||||||
|
nationality = models.CharField(max_length=100, blank=True)
|
||||||
|
height_cm = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True)
|
||||||
|
weight_kg = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True)
|
||||||
|
wingspan_cm = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["full_name"]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.full_name
|
||||||
|
|
||||||
|
|
||||||
|
class Competition(models.Model):
|
||||||
|
name = models.CharField(max_length=150, unique=True)
|
||||||
|
country = models.CharField(max_length=100, blank=True)
|
||||||
|
level = models.CharField(max_length=100, blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["name"]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class Team(models.Model):
|
||||||
|
name = models.CharField(max_length=150)
|
||||||
|
competition = models.ForeignKey(
|
||||||
|
Competition,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="teams",
|
||||||
|
)
|
||||||
|
country = models.CharField(max_length=100, blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["name"]
|
||||||
|
unique_together = ("name", "competition")
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class Season(models.Model):
|
||||||
|
name = models.CharField(max_length=20, unique=True)
|
||||||
|
start_year = models.PositiveSmallIntegerField()
|
||||||
|
end_year = models.PositiveSmallIntegerField()
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-start_year", "-end_year"]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerSeason(models.Model):
|
||||||
|
class Position(models.TextChoices):
|
||||||
|
PG = "PG", "PG"
|
||||||
|
SG = "SG", "SG"
|
||||||
|
SF = "SF", "SF"
|
||||||
|
PF = "PF", "PF"
|
||||||
|
C = "C", "C"
|
||||||
|
|
||||||
|
player = models.ForeignKey(Player, on_delete=models.CASCADE, related_name="player_seasons")
|
||||||
|
season = models.ForeignKey(Season, on_delete=models.CASCADE, related_name="player_seasons")
|
||||||
|
team = models.ForeignKey(
|
||||||
|
Team,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="player_seasons",
|
||||||
|
)
|
||||||
|
competition = models.ForeignKey(
|
||||||
|
Competition,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="player_seasons",
|
||||||
|
)
|
||||||
|
position = models.CharField(max_length=2, choices=Position.choices)
|
||||||
|
roles = models.ManyToManyField(Role, blank=True, related_name="player_seasons")
|
||||||
|
specialties = models.ManyToManyField(Specialty, blank=True, related_name="player_seasons")
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["player__full_name", "-season__start_year"]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.player.full_name} - {self.season.name}"
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerSeasonStats(models.Model):
|
||||||
|
player_season = models.OneToOneField(
|
||||||
|
PlayerSeason,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="stats",
|
||||||
|
)
|
||||||
|
|
||||||
|
points = models.DecimalField(max_digits=6, decimal_places=2, null=True, blank=True)
|
||||||
|
assists = models.DecimalField(max_digits=6, decimal_places=2, null=True, blank=True)
|
||||||
|
steals = models.DecimalField(max_digits=6, decimal_places=2, null=True, blank=True)
|
||||||
|
turnovers = models.DecimalField(max_digits=6, decimal_places=2, null=True, blank=True)
|
||||||
|
blocks = models.DecimalField(max_digits=6, decimal_places=2, null=True, blank=True)
|
||||||
|
|
||||||
|
efg_pct = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True)
|
||||||
|
ts_pct = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True)
|
||||||
|
plus_minus = models.DecimalField(max_digits=7, decimal_places=2, null=True, blank=True)
|
||||||
|
offensive_rating = models.DecimalField(max_digits=7, decimal_places=2, null=True, blank=True)
|
||||||
|
defensive_rating = models.DecimalField(max_digits=7, decimal_places=2, null=True, blank=True)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name_plural = "Player season stats"
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"Stats for {self.player_season}"
|
||||||
@ -26,6 +26,7 @@ The current baseline decision is:
|
|||||||
- `ADR-0003`: containerized developer workflow baseline
|
- `ADR-0003`: containerized developer workflow baseline
|
||||||
- `ADR-0004`: configuration and environment strategy baseline
|
- `ADR-0004`: configuration and environment strategy baseline
|
||||||
- `ADR-0005`: scouting search-domain baseline
|
- `ADR-0005`: scouting search-domain baseline
|
||||||
|
- `ADR-0006`: initial domain-model baseline
|
||||||
|
|
||||||
The current baseline assumes:
|
The current baseline assumes:
|
||||||
- Python 3
|
- Python 3
|
||||||
@ -43,6 +44,8 @@ Future scaffolding should also follow the configuration strategy defined in `doc
|
|||||||
|
|
||||||
Future search model/filter/UI implementation should follow the domain semantics defined in `docs/adr/0005-scouting-search-domain.md`, including the separation of position vs role vs specialty, MVP filter scope, and optional vs required dimensions.
|
Future search model/filter/UI implementation should follow the domain semantics defined in `docs/adr/0005-scouting-search-domain.md`, including the separation of position vs role vs specialty, MVP filter scope, and optional vs required dimensions.
|
||||||
|
|
||||||
|
Future Django domain model work should follow `docs/adr/0006-initial-domain-model.md` as the baseline entity/relationship decision (including player-season stat ownership and taxonomy treatment) unless superseded by a later ADR.
|
||||||
|
|
||||||
## Future Sections Placeholder
|
## Future Sections Placeholder
|
||||||
|
|
||||||
Future versions of this document may include sections such as:
|
Future versions of this document may include sections such as:
|
||||||
|
|||||||
156
docs/adr/0006-initial-domain-model.md
Normal file
156
docs/adr/0006-initial-domain-model.md
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
# ADR-0006: Initial Domain Model Baseline
|
||||||
|
|
||||||
|
## Status
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
ADR-0005 defined the scouting search domain semantics and MVP filter realism. Before implementing Django models, HoopScout v2 needs an explicit conceptual domain model baseline that translates those search decisions into stable entities, relationships, and ownership boundaries.
|
||||||
|
|
||||||
|
The goal of this ADR is to define an MVP domain model that is predictable, implementation-ready, and extensible without premature schema complexity.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### 1. Core MVP entities
|
||||||
|
The initial MVP conceptual model includes these entities:
|
||||||
|
|
||||||
|
- Player
|
||||||
|
- Competition
|
||||||
|
- Team
|
||||||
|
- Season
|
||||||
|
- PlayerSeason
|
||||||
|
- PlayerSeasonStats
|
||||||
|
|
||||||
|
Entity intent:
|
||||||
|
- Player: canonical person/athlete identity and durable personal/physical profile.
|
||||||
|
- Competition: league/tournament context in which team and season participation occurs.
|
||||||
|
- Team: club/franchise identity with competition association in a given season context.
|
||||||
|
- Season: time-bounded campaign context (for example 2025-2026).
|
||||||
|
- PlayerSeason: player participation context for one season, optionally linked to a primary team and competition.
|
||||||
|
- PlayerSeasonStats: objective statistical snapshot attached to one `PlayerSeason`.
|
||||||
|
|
||||||
|
MVP split decision:
|
||||||
|
- Keep `PlayerSeason` and `PlayerSeasonStats` separate.
|
||||||
|
- `PlayerSeason` owns participation/classification context.
|
||||||
|
- `PlayerSeasonStats` owns metric values used for search filters.
|
||||||
|
|
||||||
|
### 2. Entity relationships (conceptual)
|
||||||
|
- One `Player` can have many `PlayerSeason` records.
|
||||||
|
- One `Season` can have many `PlayerSeason` records.
|
||||||
|
- One `Team` can have many `PlayerSeason` records.
|
||||||
|
- One `Competition` can have many `Team` and many `PlayerSeason` records.
|
||||||
|
- One `PlayerSeason` has zero or one `PlayerSeasonStats` record in MVP.
|
||||||
|
|
||||||
|
Stats ownership decision:
|
||||||
|
- Search statistics conceptually belong to the player-in-season unit (`PlayerSeason`) and are stored in `PlayerSeasonStats`.
|
||||||
|
- Team and competition are contextual links to that player-season record.
|
||||||
|
|
||||||
|
### 3. Position, role, specialty modeling approach
|
||||||
|
- Position:
|
||||||
|
- Conceptual type: bounded choice set (enum/choice behavior).
|
||||||
|
- Cardinality in MVP: single-valued on `PlayerSeason`.
|
||||||
|
- Ownership: normalized source-aligned value with optional internal correction.
|
||||||
|
|
||||||
|
- Role:
|
||||||
|
- Conceptual type: dedicated taxonomy entity (not a hardcoded enum).
|
||||||
|
- Cardinality in MVP: many-to-many classification from `PlayerSeason` to role taxonomy.
|
||||||
|
- Ownership: internal scouting taxonomy, manually curated and extensible.
|
||||||
|
|
||||||
|
- Specialty:
|
||||||
|
- Conceptual type: tag-like taxonomy entity.
|
||||||
|
- Cardinality in MVP: many-to-many classification from `PlayerSeason` to specialty taxonomy.
|
||||||
|
- Ownership: internal scouting tag vocabulary, multi-valued and extensible.
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
- Position is stable and bounded; role and specialty are interpretive and expected to evolve.
|
||||||
|
|
||||||
|
### 4. Imported vs internal vs inferred boundary
|
||||||
|
- Imported source data (objective):
|
||||||
|
- Height, weight, date of birth (source attributes)
|
||||||
|
- Per-game stats: points, assists, steals, turnovers, blocks
|
||||||
|
- Advanced metrics when provided by source: eFG%, TS%, plus/minus, offensive rating, defensive rating
|
||||||
|
- Position when supplied by source (subject to normalization)
|
||||||
|
|
||||||
|
- Inferred/derived data:
|
||||||
|
- Age (derived from date of birth and reference date)
|
||||||
|
- eFG% and TS% when computed from source totals
|
||||||
|
- Optional role suggestions generated from rules/models (if introduced later)
|
||||||
|
|
||||||
|
- Internal/manual scouting enrichment:
|
||||||
|
- Role assignments
|
||||||
|
- Specialty tags
|
||||||
|
- Position overrides when source labels are inconsistent
|
||||||
|
- Wingspan when collected manually due to sparse public coverage
|
||||||
|
|
||||||
|
Hybrid note:
|
||||||
|
- Position remains hybrid (source-derived baseline plus internal override path).
|
||||||
|
|
||||||
|
### 5. Optional-data handling
|
||||||
|
Domain-level required vs optional in MVP:
|
||||||
|
|
||||||
|
- Required for MVP search baseline:
|
||||||
|
- Position (normalized single value)
|
||||||
|
- Age (derived, if DOB available)
|
||||||
|
- Height, weight (if available from source; model permits null when missing)
|
||||||
|
- Core per-game stats (points, assists, steals, turnovers, blocks)
|
||||||
|
|
||||||
|
- Optional in MVP domain:
|
||||||
|
- Wingspan
|
||||||
|
- Role classifications
|
||||||
|
- Specialty classifications
|
||||||
|
- Plus/minus, offensive rating, defensive rating
|
||||||
|
- Any personal/physical field missing from public source coverage
|
||||||
|
|
||||||
|
Optionality rule:
|
||||||
|
- Missing optional fields must not block player ingestion or baseline search eligibility.
|
||||||
|
|
||||||
|
### 6. MVP scope discipline (intentionally excluded now)
|
||||||
|
The initial domain model intentionally excludes:
|
||||||
|
|
||||||
|
- manual scouting notes/journal objects
|
||||||
|
- watchlists and shortlist collaboration objects
|
||||||
|
- player-to-player comparison artifacts
|
||||||
|
- transfer-history and contract-history modeling
|
||||||
|
- multi-source provenance graph complexity beyond basic source attribution needs
|
||||||
|
- versioned taxonomy governance workflows
|
||||||
|
|
||||||
|
### 7. Implementation guidance for future prompts
|
||||||
|
Future Django model implementation prompts should assume:
|
||||||
|
|
||||||
|
- First model wave: `Player`, `Competition`, `Team`, `Season`, `PlayerSeason`, `PlayerSeasonStats`, plus taxonomy models for role and specialty.
|
||||||
|
- Keep MVP simple: one primary stats record per player-season context.
|
||||||
|
- Position remains a bounded choice; role and specialty must be extensible taxonomies.
|
||||||
|
- Role and specialty fields are optional and many-to-many from player-season context.
|
||||||
|
- Objective/imported metrics and internal scouting classifications remain semantically separate in naming and model structure.
|
||||||
|
- Domain-level optional fields must be nullable/omissible without breaking ingestion flows.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
### A. Single flat Player model with season/stat columns
|
||||||
|
Rejected. It mixes durable identity with seasonal context and creates update ambiguity.
|
||||||
|
|
||||||
|
### B. Attach stats directly to PlayerSeason without a separate stats entity
|
||||||
|
Rejected for MVP baseline. Keeping a dedicated `PlayerSeasonStats` concept preserves a clean boundary between participation context and metric payload.
|
||||||
|
|
||||||
|
### C. Model role and specialty as fixed enums
|
||||||
|
Rejected. These are internal scouting taxonomies expected to evolve and require extensibility.
|
||||||
|
|
||||||
|
### D. Require wingspan, role, and specialty at ingest time
|
||||||
|
Rejected due to high data-availability risk and poor MVP practicality.
|
||||||
|
|
||||||
|
## Trade-offs
|
||||||
|
- Pros: clear context boundaries, predictable first implementation pass, extensible scouting taxonomies, realistic handling of missing public data.
|
||||||
|
- Cons: introduces more entities than a flat schema and defers richer provenance/taxonomy governance details to later ADRs.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
- Future model implementation can proceed without re-deciding core entity boundaries.
|
||||||
|
- Search/filter implementation can rely on player-season as the primary statistical context.
|
||||||
|
- Internal scouting enrichment can evolve independently from imported objective stats.
|
||||||
|
- Additional complexity (notes, watchlists, provenance depth) remains available for later incremental decisions.
|
||||||
|
|
||||||
|
## Follow-up decisions needed
|
||||||
|
1. Exact uniqueness constraints for `PlayerSeason` identity (player + season + team + competition semantics).
|
||||||
|
2. Minimal provenance metadata required on imported records in MVP.
|
||||||
|
3. Normalization standards for team/competition naming and cross-source identity mapping.
|
||||||
|
4. Taxonomy governance policy for role/specialty lifecycle changes.
|
||||||
|
5. Rules for handling mid-season team changes in MVP vs post-MVP.
|
||||||
|
6. Whether advanced metrics should be persisted, computed on read, or both.
|
||||||
35
infra/docker-compose.yml
Normal file
35
infra/docker-compose.yml
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: infra/docker/Dockerfile
|
||||||
|
working_dir: /app
|
||||||
|
command: python manage.py runserver 0.0.0.0:8000
|
||||||
|
volumes:
|
||||||
|
- ../app:/app
|
||||||
|
environment:
|
||||||
|
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY}
|
||||||
|
DJANGO_DEBUG: ${DJANGO_DEBUG}
|
||||||
|
DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
POSTGRES_HOST: ${POSTGRES_HOST}
|
||||||
|
POSTGRES_PORT: ${POSTGRES_PORT}
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
13
infra/docker/Dockerfile
Normal file
13
infra/docker/Dockerfile
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY app/requirements.txt /tmp/requirements.txt
|
||||||
|
RUN pip install --no-cache-dir -r /tmp/requirements.txt
|
||||||
|
|
||||||
|
COPY app/ /app/
|
||||||
|
|
||||||
|
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||||
Reference in New Issue
Block a user