From d5b00eee98454cb16b068f21ca156e81eeca3d20 Mon Sep 17 00:00:00 2001 From: bisco Date: Mon, 6 Apr 2026 19:07:53 +0200 Subject: [PATCH] feat: add initial django scouting domain models baseline --- .env.example | 8 ++ app/hoopscout/__init__.py | 0 app/hoopscout/asgi.py | 7 ++ app/hoopscout/settings.py | 68 ++++++++++ app/hoopscout/urls.py | 6 + app/hoopscout/wsgi.py | 7 ++ app/manage.py | 14 +++ app/requirements.txt | 2 + app/scouting/__init__.py | 0 app/scouting/admin.py | 70 +++++++++++ app/scouting/apps.py | 6 + app/scouting/migrations/0001_initial.py | 145 +++++++++++++++++++++ app/scouting/migrations/__init__.py | 0 app/scouting/models.py | 159 ++++++++++++++++++++++++ infra/docker-compose.yml | 35 ++++++ infra/docker/Dockerfile | 13 ++ 16 files changed, 540 insertions(+) create mode 100644 .env.example create mode 100644 app/hoopscout/__init__.py create mode 100644 app/hoopscout/asgi.py create mode 100644 app/hoopscout/settings.py create mode 100644 app/hoopscout/urls.py create mode 100644 app/hoopscout/wsgi.py create mode 100644 app/manage.py create mode 100644 app/requirements.txt create mode 100644 app/scouting/__init__.py create mode 100644 app/scouting/admin.py create mode 100644 app/scouting/apps.py create mode 100644 app/scouting/migrations/0001_initial.py create mode 100644 app/scouting/migrations/__init__.py create mode 100644 app/scouting/models.py create mode 100644 infra/docker-compose.yml create mode 100644 infra/docker/Dockerfile diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..dfabbf6 --- /dev/null +++ b/.env.example @@ -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 diff --git a/app/hoopscout/__init__.py b/app/hoopscout/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/hoopscout/asgi.py b/app/hoopscout/asgi.py new file mode 100644 index 0000000..8ebe3ee --- /dev/null +++ b/app/hoopscout/asgi.py @@ -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() diff --git a/app/hoopscout/settings.py b/app/hoopscout/settings.py new file mode 100644 index 0000000..857138e --- /dev/null +++ b/app/hoopscout/settings.py @@ -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" diff --git a/app/hoopscout/urls.py b/app/hoopscout/urls.py new file mode 100644 index 0000000..083932c --- /dev/null +++ b/app/hoopscout/urls.py @@ -0,0 +1,6 @@ +from django.contrib import admin +from django.urls import path + +urlpatterns = [ + path("admin/", admin.site.urls), +] diff --git a/app/hoopscout/wsgi.py b/app/hoopscout/wsgi.py new file mode 100644 index 0000000..14a7217 --- /dev/null +++ b/app/hoopscout/wsgi.py @@ -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() diff --git a/app/manage.py b/app/manage.py new file mode 100644 index 0000000..6e72cf5 --- /dev/null +++ b/app/manage.py @@ -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() diff --git a/app/requirements.txt b/app/requirements.txt new file mode 100644 index 0000000..d29a417 --- /dev/null +++ b/app/requirements.txt @@ -0,0 +1,2 @@ +Django==5.2.2 +psycopg[binary]==3.2.9 diff --git a/app/scouting/__init__.py b/app/scouting/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/scouting/admin.py b/app/scouting/admin.py new file mode 100644 index 0000000..99ba587 --- /dev/null +++ b/app/scouting/admin.py @@ -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") diff --git a/app/scouting/apps.py b/app/scouting/apps.py new file mode 100644 index 0000000..5311077 --- /dev/null +++ b/app/scouting/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ScoutingConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "scouting" diff --git a/app/scouting/migrations/0001_initial.py b/app/scouting/migrations/0001_initial.py new file mode 100644 index 0000000..dd2969f --- /dev/null +++ b/app/scouting/migrations/0001_initial.py @@ -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'), + ), + ] diff --git a/app/scouting/migrations/__init__.py b/app/scouting/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/scouting/models.py b/app/scouting/models.py new file mode 100644 index 0000000..54b6db0 --- /dev/null +++ b/app/scouting/models.py @@ -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}" diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml new file mode 100644 index 0000000..b3d9049 --- /dev/null +++ b/infra/docker-compose.yml @@ -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: diff --git a/infra/docker/Dockerfile b/infra/docker/Dockerfile new file mode 100644 index 0000000..d8cbcce --- /dev/null +++ b/infra/docker/Dockerfile @@ -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"]