Compare commits

18 Commits

Author SHA1 Message Date
bisco dd46d7aabb merge: apply filters only on refresh 2026-06-03 23:24:18 +02:00
bisco f8de0e644a fix: apply filters only on refresh 2026-06-03 23:24:03 +02:00
bisco 2c7ec7383b merge: allow returning from player detail to results 2026-06-03 23:15:48 +02:00
bisco f87f62f111 fix: allow returning from player detail to results 2026-06-03 23:15:30 +02:00
bisco f44a07f231 merge: use select control for role filter 2026-06-03 23:10:17 +02:00
bisco 8909be9694 fix: use select control for role filter 2026-06-03 23:10:00 +02:00
bisco 03b8762835 merge: remove scouting quick filter bar 2026-06-03 23:05:33 +02:00
bisco 5cc1076225 fix: remove scouting quick filter bar 2026-06-03 23:05:16 +02:00
bisco f2934d7924 merge: fix scouting dashboard controls 2026-06-03 22:56:33 +02:00
bisco 1b7b4259f4 fix: make scouting filters immediate and loading state precise 2026-06-03 22:56:13 +02:00
bisco f8244849a3 merge: improve scouting dashboard and demo data 2026-06-03 22:42:27 +02:00
bisco 7101900e19 feat: improve scouting dashboard and demo data 2026-06-03 22:42:08 +02:00
bisco 22f4a9159a merge: fix frontend API proxy 2026-06-03 21:50:45 +02:00
bisco fe33b983a3 fix: proxy frontend API requests to backend 2026-06-03 21:50:24 +02:00
bisco 363a07c095 merge: fix frontend dev cache permissions 2026-06-03 21:48:05 +02:00
bisco a830c89f99 fix: move Angular cache outside bind mount 2026-06-03 21:47:49 +02:00
bisco eb40053770 merge: bootstrap HoopScout scouting app 2026-06-03 21:37:31 +02:00
bisco cc188468bc feat: bootstrap HoopScout scouting app 2026-06-03 21:37:15 +02:00
54 changed files with 15034 additions and 126 deletions
+7 -6
View File
@@ -4,9 +4,9 @@ Edit this file for each repository.
## Project identity
Project name: `CHANGE_ME`
Project description: `CHANGE_ME`
Primary language/runtime: `CHANGE_ME`
Project name: `HoopScout`
Project description: `Private web application for basketball player scouting across European and selected international leagues.`
Primary language/runtime: `Python 3.13 / Django, TypeScript / Angular`
## Project mode
@@ -14,7 +14,6 @@ Choose one:
```text
project_mode: personal
project_mode: work
```
Rules:
@@ -29,7 +28,6 @@ Enable only the profiles that apply to this repository:
```text
enabled_profiles:
- docker
- ansible
- python
```
@@ -77,7 +75,10 @@ All tests MUST be executed inside Docker containers.
Configure the canonical test command for this repository:
```bash
CHANGE_ME
docker compose run --rm backend ruff check .
docker compose run --rm backend pytest
docker compose run --rm frontend npm test
docker compose config
```
Examples:
+8
View File
@@ -0,0 +1,8 @@
DJANGO_DEBUG=1
DJANGO_SECRET_KEY=replace-with-a-local-secret
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,backend
CORS_ALLOWED_ORIGINS=http://localhost:4200
CSRF_TRUSTED_ORIGINS=http://localhost:4200
POSTGRES_DB=hoopscout
POSTGRES_USER=hoopscout
POSTGRES_PASSWORD=replace-with-a-local-password
+12
View File
@@ -0,0 +1,12 @@
.env
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
.ruff_cache/
htmlcov/
.coverage
node_modules/
dist/
.angular/
npm-debug.log*
+31 -61
View File
@@ -1,73 +1,43 @@
# codex-bootstrap
# HoopScout
A repository template for AI-assisted development with Codex.
HoopScout is a private basketball scouting web application for searching and comparing male players across European and selected international leagues.
This template defines a repeatable workflow for using Codex as an autonomous coding agent that can create branches, modify code, run Docker-based tests, update documentation, write ADRs, and commit changes using Conventional Commits.
The MVP focuses on local usage for a restricted group of users. It provides a Django REST API, PostgreSQL persistence, and an Angular scouting dashboard.
## Purpose
## Stack
Use this template to bootstrap repositories where Codex must operate with clear rules, minimal changes, pragmatic TDD, security guardrails, and explicit documentation requirements.
- Docker Compose
- Django and Django REST Framework
- PostgreSQL
- Angular
## Repository structure
## Local Development
```text
.
├── AGENTS.md
├── README.md
├── .codex/
│ ├── project.md
│ ├── workflow.md
│ ├── security.md
│ ├── quality.md
│ ├── orchestration.md
│ ├── prompts/
│ │ ├── task.md
│ │ ├── bugfix.md
│ │ ├── refactor.md
│ │ ├── security-review.md
│ │ └── documentation.md
│ ├── agents/
│ │ ├── architect.md
│ │ ├── developer.md
│ │ ├── reviewer.md
│ │ ├── security-reviewer.md
│ │ ├── test-engineer.md
│ │ └── documentation-writer.md
│ └── profiles/
│ ├── docker.md
│ ├── ansible.md
│ └── python.md
└── docs/
├── adr/
│ └── 0000-template.md
├── architecture.md
├── deployment.md
├── operations.md
├── security.md
├── testing.md
└── runbook.md
```bash
cp .env.example .env
docker compose up --build
```
## How to use
Run database migrations and seed synthetic local scouting data:
1. Copy this template into a new or existing repository.
2. Edit `.codex/project.md` and configure:
- project mode;
- enabled profiles;
- Docker-based test command;
- branch naming rules if needed.
3. Add project-specific details to the documentation under `docs/`.
4. When asking Codex to work on a task, use one of the prompt templates under `.codex/prompts/`.
```bash
docker compose run --rm backend python manage.py migrate
docker compose run --rm backend python manage.py seed_demo_data
docker compose run --rm backend python manage.py createsuperuser
```
## Core rules
The backend is available at `http://localhost:8000`; the Angular frontend is available at `http://localhost:4200`.
Codex must:
## Tests
- start work from `develop`;
- create a dedicated `feature/`, `fix/`, or `hotfix/` branch;
- use pragmatic TDD;
- keep changes minimal and focused;
- run the configured Docker-based test command before completion;
- update documentation and ADRs when needed;
- produce a final report with summary, tests, risks, and rollback notes;
- commit using Conventional Commits.
All tests are run inside Docker containers:
```bash
docker compose run --rm backend ruff check .
docker compose run --rm backend pytest
docker compose run --rm frontend npm test
docker compose config
```
Demo data is synthetic. RealGM, Proballers, or other external sources must be integrated only through authorized APIs or documented, compliant import workflows.
The demo seed creates a broader scouting board across European leagues plus Australia and New Zealand so filters, sorting, and profile review can be exercised locally.
+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": 50,
}
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,252 @@
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],
"ISBL": League.objects.update_or_create(
code="ISBL",
defaults={"name": "Israeli Basketball Premier League", "region": "Europe", "country": "Israel"},
)[0],
"NBL": League.objects.update_or_create(
code="NBL",
defaults={"name": "National Basketball League", "region": "Oceania", "country": "Australia"},
)[0],
"NZNBL": League.objects.update_or_create(
code="NZNBL",
defaults={"name": "New Zealand NBL", "region": "Oceania", "country": "New Zealand"},
)[0],
}
demo_rows = [
(
"Luca", "Marini", "PG", "Primary ball handler", 2001, 190, 86, "Italy",
"Milano", "LBA", 16.8, 6.2, 3.8, 20.5, 59.2, 25.0,
),
(
"Davide", "Rossi", "SG", "Movement shooter", 2002, 195, 88, "Italy",
"Bologna", "LBA", 14.6, 2.4, 3.1, 15.8, 61.0, 21.4,
),
(
"Nikola", "Petrovic", "PF", "Stretch four", 1998, 206, 103, "Serbia",
"Trieste", "LBA", 12.9, 2.7, 6.8, 17.2, 58.4, 18.8,
),
(
"Mateo", "Santos", "SF", "3 and D wing", 1999, 201, 96, "Spain",
"Madrid", "ACB", 11.2, 2.1, 5.5, 13.7, 55.8, 17.5,
),
(
"Iker", "Varela", "PG", "Tempo guard", 2000, 188, 82, "Spain",
"Valencia", "ACB", 13.8, 5.9, 2.9, 18.1, 57.3, 23.7,
),
(
"Moussa", "Diagne", "C", "Rim protector", 1997, 213, 111, "Senegal",
"Malaga", "ACB", 9.7, 1.1, 8.9, 16.4, 64.8, 15.2,
),
(
"Marko", "Ilic", "PG", "Pick and roll creator", 2001, 192, 87, "Serbia",
"Belgrade", "ABA", 15.1, 6.8, 3.3, 19.6, 56.6, 26.1,
),
(
"Luka", "Horvat", "SF", "Slashing wing", 1999, 202, 94, "Croatia",
"Zadar", "ABA", 13.4, 2.8, 5.9, 16.0, 55.1, 20.5,
),
(
"Amar", "Kovac", "C", "Low-post finisher", 1998, 210, 114, "Bosnia",
"Ljubljana", "ABA", 11.8, 1.6, 7.7, 15.5, 60.9, 18.0,
),
(
"Jonas", "Keller", "C", "Roll man", 2000, 211, 109, "Germany",
"Berlin", "BBL", 13.1, 1.4, 8.6, 18.9, 63.1, 19.0,
),
(
"Tobias", "Weber", "SG", "Secondary creator", 2002, 196, 91, "Germany",
"Munich", "BBL", 15.9, 3.7, 4.2, 17.8, 58.0, 22.9,
),
(
"Leon", "Schmidt", "PF", "Short-roll passer", 1999, 205, 101, "Germany",
"Ulm", "BBL", 10.6, 3.2, 6.4, 14.9, 56.7, 16.8,
),
(
"Emir", "Yilmaz", "PG", "Pressure guard", 2001, 189, 84, "Turkey",
"Istanbul", "BSL", 17.4, 5.5, 3.0, 20.1, 57.8, 27.2,
),
(
"Can", "Demir", "SF", "Transition wing", 2000, 200, 95, "Turkey",
"Ankara", "BSL", 12.7, 2.5, 5.1, 14.4, 54.6, 19.4,
),
(
"Kerem", "Arslan", "C", "Paint anchor", 1998, 212, 116, "Turkey",
"Izmir", "BSL", 10.2, 1.2, 9.3, 17.0, 62.3, 14.8,
),
(
"Noam", "Levi", "SG", "Pull-up shooter", 2002, 194, 88, "Israel",
"Tel Aviv", "ISBL", 16.1, 3.0, 3.5, 16.9, 59.5, 24.1,
),
(
"Amit", "Cohen", "PG", "Drive and kick guard", 2001, 187, 81, "Israel",
"Jerusalem", "ISBL", 14.2, 6.4, 2.7, 18.7, 56.9, 25.5,
),
(
"Eitan", "Mizrahi", "PF", "Switch defender", 1999, 204, 100, "Israel",
"Holon", "ISBL", 9.8, 2.0, 7.1, 13.9, 54.2, 15.7,
),
(
"Theo", "Moreau", "PG", "Change-of-pace guard", 2003, 186, 80, "France",
"Paris", "LNB", 12.5, 5.8, 2.5, 16.8, 55.5, 22.2,
),
(
"Bastien", "Girard", "SF", "Connector wing", 2000, 199, 92, "France",
"Monaco", "LNB", 10.9, 3.6, 4.8, 14.6, 57.1, 16.3,
),
(
"Yanis", "Traore", "C", "Vertical spacer", 2001, 214, 112, "France",
"Lyon", "LNB", 11.4, 1.0, 8.1, 16.7, 65.0, 15.0,
),
(
"Jayden", "Mills", "SG", "Off-screen scorer", 2000, 197, 92, "Australia",
"Sydney", "NBL", 18.2, 2.9, 4.0, 19.4, 60.1, 26.4,
),
(
"Cooper", "Reed", "PF", "Face-up forward", 1999, 206, 102, "Australia",
"Melbourne", "NBL", 13.6, 2.4, 7.3, 17.5, 58.6, 20.0,
),
(
"Hemi", "Walker", "PG", "Paint touch guard", 2002, 191, 85, "New Zealand",
"Auckland", "NZNBL", 15.7, 7.1, 3.9, 21.2, 57.5, 27.0,
),
(
"Tane", "Rangi", "SF", "Defensive playmaker", 2001, 203, 98, "New Zealand",
"Wellington", "NZNBL", 11.6, 3.4, 6.5, 15.9, 55.4, 18.7,
),
(
"Finn", "McKenzie", "C", "Glass cleaner", 1998, 211, 113, "New Zealand",
"Canterbury", "NZNBL", 9.4, 1.3, 10.2, 16.1, 61.8, 13.9,
),
]
for row in demo_rows:
(
first_name,
last_name,
position,
role,
birth_year,
height_cm,
weight_kg,
nationality,
team_name,
league_code,
points,
assists,
rebounds,
efficiency,
true_shooting,
usage,
) = row
games_played = 28
minutes = round(18 + usage * 0.42, 2)
league = leagues[league_code]
team, _ = Team.objects.update_or_create(
name=team_name,
league=league,
defaults={"country": league.country},
)
player, _ = Player.objects.update_or_create(
first_name=first_name,
last_name=last_name,
birth_year=birth_year,
nationality=nationality,
defaults={
"position": position,
"role": role,
"height_cm": height_cm,
"weight_kg": weight_kg,
"current_team": team,
"external_source": "synthetic",
},
)
PlayerSeasonStat.objects.update_or_create(
player=player,
team=team,
league=league,
season=season,
defaults={
"games_played": games_played,
"minutes_per_game": minutes,
"points_per_game": points,
"assists_per_game": assists,
"rebounds_per_game": rebounds,
"steals_per_game": 0.7 + (assists / 10),
"blocks_per_game": 0.2 + (rebounds / 18),
"turnovers_per_game": 1.0 + (usage / 20),
"field_goal_percentage": 42.5 + (true_shooting / 5),
"three_point_percentage": 28.0 + (points / 2.3),
"free_throw_percentage": 70.0 + (usage / 2),
"efficiency_rating": efficiency,
"true_shooting_percentage": true_shooting,
"usage_percentage": usage,
"total_points": int(points * games_played),
"total_assists": int(assists * games_played),
"total_rebounds": int(rebounds * games_played),
},
)
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(points + 10),
"assists": int(assists + 3),
"rebounds": int(rebounds + 2),
"efficiency_rating": efficiency + 10,
},
)
PlayerGameLog.objects.update_or_create(
player=player,
team=team,
league=league,
season=season,
game_date="2026-01-17",
opponent="Physical road opponent",
defaults={
"points": max(0, int(points - 7)),
"assists": max(0, int(assists - 2)),
"rebounds": max(0, int(rebounds - 3)),
"efficiency_rating": max(0, efficiency - 9),
},
)
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"}
@@ -0,0 +1,33 @@
import pytest
from django.core.management import call_command
from scouting.models import League, Player, PlayerSeasonStat
@pytest.mark.django_db
def test_seed_demo_data_creates_a_useful_scouting_board():
call_command("seed_demo_data")
assert Player.objects.count() >= 24
assert PlayerSeasonStat.objects.count() >= 24
assert set(League.objects.values_list("code", flat=True)) >= {
"LBA",
"ACB",
"ABA",
"BBL",
"BSL",
"LNB",
"ISBL",
"NBL",
"NZNBL",
}
@pytest.mark.django_db
def test_seed_demo_data_is_idempotent():
call_command("seed_demo_data")
first_count = Player.objects.count()
call_command("seed_demo_data")
assert Player.objects.count() == first_count
+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})
+63
View File
@@ -0,0 +1,63 @@
services:
db:
image: postgres:17.10-bookworm
environment:
POSTGRES_DB: ${POSTGRES_DB:-hoopscout}
POSTGRES_USER: ${POSTGRES_USER:-hoopscout}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-local-dev-password-change-me}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- hoopscout
backend:
build:
context: ./backend
environment:
DJANGO_DEBUG: ${DJANGO_DEBUG:-1}
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-local-development-secret-key-change-me}
DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,backend}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:4200}
CSRF_TRUSTED_ORIGINS: ${CSRF_TRUSTED_ORIGINS:-http://localhost:4200}
POSTGRES_DB: ${POSTGRES_DB:-hoopscout}
POSTGRES_USER: ${POSTGRES_USER:-hoopscout}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-local-dev-password-change-me}
POSTGRES_HOST: db
POSTGRES_PORT: 5432
RUFF_CACHE_DIR: /tmp/ruff_cache
PYTEST_ADDOPTS: -o cache_dir=/tmp/pytest_cache
depends_on:
db:
condition: service_healthy
ports:
- "8000:8000"
volumes:
- ./backend:/app
networks:
- hoopscout
frontend:
build:
context: ./frontend
depends_on:
- backend
ports:
- "4200:4200"
volumes:
- ./frontend:/app
- frontend_node_modules:/app/node_modules
networks:
- hoopscout
volumes:
postgres_data:
frontend_node_modules:
networks:
hoopscout:
driver: bridge
@@ -0,0 +1,48 @@
# ADR-0001: Bootstrap Stack and Data Boundaries
Date: 2026-06-03
Status: Accepted
## Context
HoopScout needs a private MVP for scouting male basketball players, initially focused on European leagues and a single season. The requested stack is Docker, Django, PostgreSQL, and Angular. Development must follow pragmatic TDD and keep implementation choices simple.
External sources such as RealGM and Proballers may be useful, but the project does not yet have authorized API credentials, provider terms review, or a data license.
## Decision
Use Docker Compose with:
- Django REST Framework for the backend API and Django admin;
- PostgreSQL for persistent relational data;
- Angular for the scouting dashboard;
- synthetic demo data through a Django management command;
- authenticated API access through Django session/basic authentication;
- user profile roles modeled as `admin`, `scout`, and `viewer`.
Do not add automated scraping or copied external datasets in the MVP. Keep provider metadata fields so future authorized imports can preserve source references.
## Consequences
The MVP can be run locally and tested inside containers with a small, understandable architecture. The schema supports player identity, position, optional role, league, team, season averages, totals, advanced statistics, and best/worst game summaries.
Real data ingestion remains a later feature and must be designed around provider authorization and licensing.
## Alternatives considered
- Scrape RealGM or Proballers immediately: rejected because provider authorization and terms are not yet documented.
- Add JWT authentication immediately: deferred because Django session/basic authentication is enough for local restricted use.
- Add notes, exports, and watchlists immediately: deferred because they are outside the initial MVP scope.
## Security impact
All API endpoints require authentication. Secrets are read from environment variables and `.env` is ignored by Git. Containers use non-root users where practical. PostgreSQL is not exposed outside the Compose network.
## Operational impact
The application is local-only. Production deployment, TLS, backup automation, stricter role permissions, and source ingestion jobs require later ADRs.
## Rollback
Revert the bootstrap commit and remove local Docker volumes if database state can be discarded.
+28 -9
View File
@@ -1,13 +1,32 @@
# Architecture
Describe the project architecture here.
HoopScout is a private scouting application composed of three local Docker services:
Include:
- `frontend`: Angular single-page application for player search and profile review.
- `backend`: Django REST Framework API with Django admin for restricted data management.
- `db`: PostgreSQL database for users, leagues, teams, players, season stats, and game logs.
- main components;
- runtime dependencies;
- data flow;
- persistence;
- external integrations;
- deployment topology;
- relevant ADRs.
## Data Flow
Users authenticate through Django session/basic authentication. The Angular application calls `/api/players/` with search and filter query parameters. The backend returns paginated player summaries ranked by efficiency and points, plus detailed player profiles at `/api/players/{id}/`.
## Persistence
The initial schema stores:
- users and user profiles with `admin`, `scout`, and `viewer` roles;
- male player identity, bio, position, optional role, measurements in cm/kg, nationality, current team, and external source metadata;
- leagues and teams, with an initial focus on European leagues;
- one active season;
- per-season averages, totals, and advanced metrics;
- per-game logs for best and worst performance views.
The Angular dashboard applies filters only when the user refreshes the result set, then supports local stat sorting, summary metrics, and an open/close profile panel on desktop.
## External Integrations
No automated external ingestion is included in the MVP. Demo data is synthetic and intentionally broad enough for UI testing. RealGM, Proballers, or similar data providers require a later authorized API/import decision before real data is collected.
## Relevant ADRs
- `docs/adr/0001-bootstrap-stack-and-data-boundaries.md`
+32 -11
View File
@@ -1,15 +1,36 @@
# Deployment
Describe how this project is deployed.
The MVP supports local Docker Compose deployment only.
Include:
## Local Environment
- environments;
- Docker/Compose usage;
- required configuration;
- secrets handling;
- exposed ports;
- volumes;
- networks;
- deployment commands;
- rollback procedure.
```bash
cp .env.example .env
docker compose up --build
```
Initialize the database:
```bash
docker compose run --rm backend python manage.py migrate
docker compose run --rm backend python manage.py seed_demo_data
docker compose run --rm backend python manage.py createsuperuser
```
## Exposed Ports
- `8000`: Django API and admin.
- `4200`: Angular development server.
PostgreSQL is not published to the host.
During local development, the Angular dev server proxies `/api`, `/api-auth`, and `/admin` to the backend container.
## Volumes
- `postgres_data`: PostgreSQL data.
- `frontend_node_modules`: frontend dependencies inside Docker.
## Rollback
For code rollback, revert the relevant Git commit and rebuild the Compose services. For local data rollback, restore a database backup or remove the `postgres_data` volume if disposable demo data is acceptable.
+35 -9
View File
@@ -1,13 +1,39 @@
# Operations
Describe operational procedures.
## Startup
Include:
```bash
docker compose up --build
```
- startup and shutdown;
- health checks;
- logs;
- monitoring;
- backup and restore;
- routine maintenance;
- known operational risks.
## Shutdown
```bash
docker compose down
```
## Health Checks
The PostgreSQL container has a `pg_isready` healthcheck. Backend and frontend smoke checks are manual in the MVP:
- backend: open `http://localhost:8000/admin/`;
- frontend: open `http://localhost:4200/`.
## Logs
```bash
docker compose logs backend
docker compose logs frontend
docker compose logs db
```
## Backup and Restore
No automated backup is configured for the MVP. Use `pg_dump` from the database container before preserving real scouting data.
## Known Operational Risks
- The frontend uses the Angular development server and is not production hardened.
- Role-specific permissions are modeled but not yet enforced per action.
- External data ingestion is intentionally not automated yet.
- The Angular development cache is configured under `/tmp/angular-cache` inside the container so the non-root frontend user does not need write access to the bind-mounted source tree.
+26 -6
View File
@@ -1,19 +1,39 @@
# Runbook
Operational runbook for this project.
## Common tasks
Document routine operational tasks here.
Apply migrations:
```bash
docker compose run --rm backend python manage.py migrate
```
Seed demo data:
```bash
docker compose run --rm backend python manage.py seed_demo_data
```
The seed command is idempotent and refreshes the synthetic scouting board.
Create an admin user:
```bash
docker compose run --rm backend python manage.py createsuperuser
```
## Troubleshooting
Document known issues, symptoms, checks, and remediation steps.
- If the frontend shows an authentication error, sign in through `http://localhost:8000/admin/` or DRF login first.
- If the frontend serves a blank page, check `docker compose logs frontend`. Cache permission errors should be resolved by the configured `/tmp/angular-cache` path; restart with `docker compose up -d frontend`.
- If the frontend loads but player data does not, confirm that `frontend/proxy.conf.json` is active and restart the frontend container.
- If backend startup fails, check database readiness with `docker compose logs db`.
- If dependencies change, rebuild with `docker compose build --no-cache backend frontend`.
## Rollback
Document rollback procedures here.
Revert the Git commit containing the change and rebuild containers. If local database state must also be reverted, restore a database dump or recreate the `postgres_data` volume for disposable data.
## Emergency contacts
Document project-specific escalation paths if appropriate.
Not configured for the local MVP.
+30 -12
View File
@@ -1,16 +1,34 @@
# Security
Describe security assumptions and controls.
HoopScout is initially intended for local or restricted-network use by a small private group.
Include:
## Authentication and Authorization
- authentication;
- authorization;
- network exposure;
- TLS/certificates;
- secrets management;
- logging of sensitive data;
- container privileges;
- filesystem permissions;
- dependency management;
- relevant ADRs.
- API endpoints require an authenticated Django user.
- Django admin is enabled for controlled data management.
- Users have a profile role: `admin`, `scout`, or `viewer`.
- Role-specific authorization is not enforced beyond authentication in the MVP.
## Network Exposure
Local Compose exposes:
- backend on `8000`;
- frontend on `4200`;
- PostgreSQL only inside the Compose network.
## Secrets
`.env.example` contains placeholders only. Real local values must be stored in `.env`, which is ignored by Git.
## Containers
Backend and frontend containers run as non-root users. PostgreSQL uses the official image defaults and a named volume.
## Data Sources
The repository does not include credentials, scraping logic, or copied external datasets. RealGM, Proballers, and other provider data must be integrated only through authorized APIs or a documented compliant import process.
## Known Dependency Findings
`npm audit` reports moderate vulnerabilities through `webpack-dev-server -> sockjs -> uuid` in the Angular development toolchain, with no available fix at the time of implementation. The dev server is intended for local restricted use only and must not be exposed publicly.
+12 -12
View File
@@ -1,23 +1,23 @@
# Testing
Describe how tests are executed.
All tests must run inside Docker containers.
All tests should run inside Docker containers.
## Canonical Test Command
## Canonical test command
Run these commands from the repository root:
```bash
CHANGE_ME
docker compose run --rm backend ruff check .
docker compose run --rm backend pytest
docker compose run --rm frontend npm test
docker compose config
```
## Test categories
Describe applicable categories:
- Backend API tests use `pytest` and `pytest-django`.
- Backend linting uses `ruff`.
- Frontend unit tests use `vitest`.
- Docker Compose validation uses `docker compose config`.
- unit tests;
- integration tests;
- linting;
- formatting checks;
- Ansible syntax checks;
- Docker/Compose validation;
- smoke tests.
The initial TDD coverage verifies authentication requirements, player filtering, default ranking, profile performance summaries, user roles, and frontend filter serialization.
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.angular
npm-debug.log*
+16
View File
@@ -0,0 +1,16 @@
FROM node:22.16.0-bookworm-slim
WORKDIR /app
RUN addgroup --system app && adduser --system --ingroup app app
COPY package.json ./
RUN npm install
COPY . .
USER app
EXPOSE 4200
CMD ["npm", "start"]
+63
View File
@@ -0,0 +1,63 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"cache": {
"path": "/tmp/angular-cache"
}
},
"newProjectRoot": "projects",
"projects": {
"hoopscout-frontend": {
"projectType": "application",
"schematics": {},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/hoopscout-frontend",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": ["zone.js"],
"tsConfig": "tsconfig.app.json",
"assets": [],
"styles": ["src/styles.css"]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "600kB",
"maximumError": "1MB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "development"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "hoopscout-frontend:build:production"
},
"development": {
"buildTarget": "hoopscout-frontend:build:development"
}
},
"defaultConfiguration": "development"
}
}
}
}
}
+11995
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "hoopscout-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"start": "ng serve --host 0.0.0.0 --port 4200 --proxy-config proxy.conf.json",
"build": "ng build",
"test": "tsx --import ./src/test-setup.ts --test \"src/**/*.spec.ts\"",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@angular/animations": "21.2.14",
"@angular/common": "21.2.14",
"@angular/compiler": "21.2.14",
"@angular/core": "21.2.14",
"@angular/forms": "21.2.14",
"@angular/platform-browser": "21.2.14",
"@angular/router": "21.2.14",
"rxjs": "7.8.2",
"tslib": "2.8.1",
"zone.js": "0.15.1"
},
"devDependencies": {
"@angular-devkit/build-angular": "21.2.14",
"@angular/cli": "21.2.14",
"@angular/compiler-cli": "21.2.14",
"@types/node": "22.15.30",
"tsx": "4.20.3",
"typescript": "5.9.2"
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"/api": {
"target": "http://backend:8000",
"secure": false,
"changeOrigin": false
},
"/api-auth": {
"target": "http://backend:8000",
"secure": false,
"changeOrigin": false
},
"/admin": {
"target": "http://backend:8000",
"secure": false,
"changeOrigin": false
}
}
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { buildPlayerSearchParams } from './player-api.service';
describe('buildPlayerSearchParams', () => {
it('keeps only populated filters and maps stat ranges to API lookups', () => {
const params = buildPlayerSearchParams({
q: 'luca',
position: 'PG',
role: '',
league: 'LBA',
minPoints: 15,
minAssists: 5,
minRebounds: null,
minEfficiency: 18,
});
assert.equal(
params.toString(),
'q=luca&position=PG&league=LBA&points_per_game__gte=15&assists_per_game__gte=5&efficiency_rating__gte=18',
);
});
});
@@ -0,0 +1,51 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { PlayerFilters, PlayerSearchResponse } from '../models';
const API_BASE_URL = '/api';
export function buildPlayerSearchParams(filters: PlayerFilters): HttpParams {
let params = new HttpParams();
const textFilters: Array<[string, string]> = [
['q', filters.q],
['position', filters.position],
['role', filters.role],
['league', filters.league],
];
for (const [key, value] of textFilters) {
if (value.trim().length > 0) {
params = params.set(key, value.trim());
}
}
const statFilters: Array<[string, number | null]> = [
['points_per_game__gte', filters.minPoints],
['assists_per_game__gte', filters.minAssists],
['rebounds_per_game__gte', filters.minRebounds],
['efficiency_rating__gte', filters.minEfficiency],
];
for (const [key, value] of statFilters) {
if (value !== null && Number.isFinite(value)) {
params = params.set(key, String(value));
}
}
return params;
}
@Injectable({ providedIn: 'root' })
export class PlayerApiService {
constructor(private readonly http: HttpClient) {}
searchPlayers(filters: PlayerFilters): Observable<PlayerSearchResponse> {
return this.http.get<PlayerSearchResponse>(`${API_BASE_URL}/players/`, {
params: buildPlayerSearchParams(filters),
withCredentials: true,
});
}
}
+407
View File
@@ -0,0 +1,407 @@
.shell {
width: min(1560px, calc(100vw - 28px));
margin: 0 auto;
padding: 18px 0 28px;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
margin-bottom: 14px;
}
.eyebrow {
margin: 0 0 4px;
color: var(--accent);
font-size: 0.75rem;
font-weight: 800;
letter-spacing: 0;
text-transform: uppercase;
}
h1,
h2 {
margin: 0;
letter-spacing: 0;
}
h1 {
font-size: 3.4rem;
line-height: 0.95;
}
h2 {
font-size: 1.6rem;
}
.scoreboard {
display: grid;
grid-template-columns: repeat(3, minmax(112px, 1fr));
gap: 8px;
}
.scoreboard div {
min-width: 112px;
padding: 12px 14px;
color: white;
background: linear-gradient(135deg, var(--accent-strong), #253b55);
border-radius: 8px;
text-align: right;
}
.scoreboard span {
display: block;
font-size: 1.6rem;
font-weight: 800;
}
.scoreboard small {
color: rgba(255, 255, 255, 0.75);
}
.filters {
display: grid;
grid-template-columns: minmax(260px, 1.4fr) repeat(3, minmax(130px, 0.7fr)) repeat(4, minmax(92px, 0.5fr)) auto;
gap: 10px;
align-items: end;
padding: 12px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
}
.search-field {
min-width: 240px;
}
label {
display: grid;
gap: 6px;
color: var(--muted);
font-size: 0.75rem;
font-weight: 700;
}
input,
select {
width: 100%;
min-height: 40px;
padding: 0 10px;
color: var(--ink);
background: #fbfcfa;
border: 1px solid var(--line);
border-radius: 6px;
}
input:focus,
select:focus {
border-color: var(--accent);
outline: 2px solid rgba(23, 107, 100, 0.16);
}
.actions {
display: flex;
gap: 8px;
}
button {
min-height: 40px;
padding: 0 14px;
border: 0;
border-radius: 6px;
cursor: pointer;
font-weight: 800;
}
.primary {
color: white;
background: var(--accent);
}
.secondary {
color: var(--accent-strong);
background: #e7eeeb;
}
.compact {
min-height: 34px;
padding: 0 10px;
white-space: nowrap;
}
.sort {
min-height: 30px;
padding: 0 10px;
color: var(--accent-strong);
background: #e8efec;
border: 1px solid #cad8d2;
white-space: nowrap;
}
.sort.active {
color: white;
background: var(--accent);
border-color: var(--accent);
}
.alert {
margin: 10px 0 0;
padding: 12px 14px;
color: #7a1d17;
background: #fff1ee;
border: 1px solid #f2bbb2;
border-radius: 8px;
}
.workspace {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 16px;
margin-top: 14px;
align-items: start;
}
.workspace.detail-open {
grid-template-columns: minmax(0, 1fr) 390px;
}
.table-wrap,
.detail {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
}
.table-wrap {
max-height: calc(100vh - 238px);
overflow: auto;
}
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
th,
td {
padding: 10px 12px;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: middle;
}
th {
position: sticky;
top: 0;
z-index: 1;
color: var(--muted);
background: #f8faf7;
font-size: 0.72rem;
text-transform: uppercase;
}
tbody tr {
cursor: pointer;
}
tbody tr:hover,
tbody tr.selected {
background: #eef6f3;
}
td strong,
td small {
display: block;
}
td small {
margin-top: 3px;
color: var(--muted);
font-size: 0.75rem;
}
.empty {
padding: 28px;
color: var(--muted);
text-align: center;
}
.detail {
display: grid;
gap: 18px;
padding: 18px;
position: sticky;
top: 14px;
}
.detail-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.role {
margin: 6px 0 0;
color: var(--muted);
}
.profile-strip {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.profile-strip span {
padding: 6px 10px;
color: var(--accent-strong);
background: #e8efec;
border: 1px solid #cad8d2;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 800;
}
.bio-grid,
.metric-grid {
display: grid;
gap: 10px;
margin: 0;
}
.bio-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.metric-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.bio-grid div,
.metric-grid div {
min-height: 82px;
padding: 12px;
background: #f8faf7;
border: 1px solid var(--line);
border-radius: 8px;
}
dt,
.metric-grid span {
color: var(--muted);
font-size: 0.74rem;
font-weight: 800;
text-transform: uppercase;
}
dd {
margin: 6px 0 0;
font-weight: 700;
}
.metric-grid strong {
display: block;
margin-top: 8px;
color: var(--accent-strong);
font-size: 1.45rem;
}
@media (max-width: 1200px) {
.filters {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.search-field {
grid-column: span 2;
}
.actions {
grid-column: span 2;
}
.workspace {
grid-template-columns: 1fr;
}
.workspace.detail-open {
grid-template-columns: 1fr;
}
.table-wrap {
max-height: none;
}
.detail {
position: static;
}
}
@media (max-width: 760px) {
.shell {
width: min(100vw - 20px, 1500px);
padding-top: 14px;
}
.topbar {
align-items: stretch;
display: grid;
grid-template-columns: 1fr;
}
.scoreboard {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.scoreboard div {
min-width: 0;
text-align: left;
}
.filters {
grid-template-columns: 1fr 1fr;
}
.search-field {
grid-column: span 2;
}
.actions {
grid-column: span 2;
}
.table-wrap {
overflow-x: auto;
}
table {
min-width: 820px;
}
h1 {
font-size: 2.6rem;
}
.detail-header {
display: grid;
grid-template-columns: 1fr;
}
}
@media (max-width: 520px) {
.scoreboard,
.filters,
.bio-grid,
.metric-grid {
grid-template-columns: 1fr;
}
.search-field,
.actions {
grid-column: auto;
}
}
+189
View File
@@ -0,0 +1,189 @@
<main class="shell">
<header class="topbar">
<div>
<p class="eyebrow">Private basketball scouting</p>
<h1>HoopScout</h1>
</div>
<div class="scoreboard" aria-label="Scouting summary">
<div>
<span>{{ resultCount }}</span>
<small>matches</small>
</div>
<div>
<span>{{ averagePoints }}</span>
<small>avg PPG</small>
</div>
<div>
<span>{{ averageEfficiency }}</span>
<small>avg EFF</small>
</div>
</div>
</header>
<section class="filters" aria-label="Player filters">
<label class="search-field">
Search
<input
type="search"
[(ngModel)]="filters.q"
placeholder="Name, role, team, nationality"
>
</label>
<label>
Position
<select [(ngModel)]="filters.position">
<option value="">Any</option>
<option *ngFor="let position of positions" [value]="position">{{ position }}</option>
</select>
</label>
<label>
Role
<select [(ngModel)]="filters.role">
<option value="">Any</option>
<option *ngFor="let role of roles" [value]="role">{{ role }}</option>
</select>
</label>
<label>
League
<select [(ngModel)]="filters.league">
<option value="">Any</option>
<option *ngFor="let league of leagues" [value]="league">{{ league }}</option>
</select>
</label>
<label>
Min PPG
<input type="number" [(ngModel)]="filters.minPoints" min="0" step="0.1">
</label>
<label>
Min APG
<input type="number" [(ngModel)]="filters.minAssists" min="0" step="0.1">
</label>
<label>
Min RPG
<input type="number" [(ngModel)]="filters.minRebounds" min="0" step="0.1">
</label>
<label>
Min EFF
<input
type="number"
[(ngModel)]="filters.minEfficiency"
min="0"
step="0.1"
>
</label>
<div class="actions">
<button type="button" class="primary" (click)="search()">Refresh</button>
<button type="button" class="secondary" (click)="clearFilters()">Reset</button>
</div>
</section>
<p *ngIf="errorMessage" class="alert">{{ errorMessage }}</p>
<section class="workspace" [class.detail-open]="selectedPlayer" aria-label="Scouting workspace">
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Player</th>
<th>Pos</th>
<th>League</th>
<th>Team</th>
<th>
<button type="button" class="sort" [class.active]="activeSort === 'points_per_game'" (click)="sortBy('points_per_game')">PPG</button>
</th>
<th>
<button type="button" class="sort" [class.active]="activeSort === 'assists_per_game'" (click)="sortBy('assists_per_game')">APG</button>
</th>
<th>
<button type="button" class="sort" [class.active]="activeSort === 'rebounds_per_game'" (click)="sortBy('rebounds_per_game')">RPG</button>
</th>
<th>
<button type="button" class="sort" [class.active]="activeSort === 'efficiency_rating'" (click)="sortBy('efficiency_rating')">EFF</button>
</th>
</tr>
</thead>
<tbody>
<tr
*ngFor="let player of players"
[class.selected]="selectedPlayer?.id === player.id"
(click)="selectPlayer(player)"
>
<td>
<strong>{{ player.name }}</strong>
<small>{{ player.nationality || '-' }} · {{ player.height_cm || '-' }} cm · {{ player.weight_kg || '-' }} kg</small>
</td>
<td>{{ player.position }}</td>
<td>{{ player.league?.code || '-' }}</td>
<td>{{ player.team?.name || '-' }}</td>
<td>{{ statValue(player, 'points_per_game') }}</td>
<td>{{ statValue(player, 'assists_per_game') }}</td>
<td>{{ statValue(player, 'rebounds_per_game') }}</td>
<td>{{ statValue(player, 'efficiency_rating') }}</td>
</tr>
</tbody>
</table>
<div *ngIf="!loading && players.length === 0" class="empty">No players match the current filters.</div>
<div *ngIf="showLoadingPlaceholder" class="empty">Loading scouting board...</div>
</div>
<aside class="detail" *ngIf="selectedPlayer">
<div class="detail-header">
<div>
<p class="eyebrow">Selected profile</p>
<h2>{{ selectedPlayer.name }}</h2>
<p class="role">{{ selectedPlayer.position }} · {{ selectedPlayer.role || 'Role pending' }}</p>
</div>
<button type="button" class="secondary compact" (click)="returnToResults()">Back to results</button>
</div>
<div class="profile-strip">
<span>{{ selectedPlayer.nationality || '-' }}</span>
<span>{{ selectedPlayer.height_cm || '-' }} cm</span>
<span>{{ selectedPlayer.weight_kg || '-' }} kg</span>
</div>
<dl class="bio-grid">
<div>
<dt>League</dt>
<dd>{{ selectedPlayer.league?.name || '-' }}</dd>
</div>
<div>
<dt>Team</dt>
<dd>{{ selectedPlayer.team?.name || '-' }}</dd>
</div>
<div>
<dt>Born</dt>
<dd>{{ selectedPlayer.birth_year || '-' }}</dd>
</div>
<div>
<dt>Size</dt>
<dd>{{ selectedPlayer.height_cm || '-' }} cm / {{ selectedPlayer.weight_kg || '-' }} kg</dd>
</div>
</dl>
<div class="metric-grid">
<div>
<span>PPG</span>
<strong>{{ statValue(selectedPlayer, 'points_per_game') }}</strong>
</div>
<div>
<span>APG</span>
<strong>{{ statValue(selectedPlayer, 'assists_per_game') }}</strong>
</div>
<div>
<span>RPG</span>
<strong>{{ statValue(selectedPlayer, 'rebounds_per_game') }}</strong>
</div>
<div>
<span>TS%</span>
<strong>{{ statValue(selectedPlayer, 'true_shooting_percentage') }}</strong>
</div>
<div>
<span>USG%</span>
<strong>{{ statValue(selectedPlayer, 'usage_percentage') }}</strong>
</div>
<div>
<span>Games</span>
<strong>{{ statValue(selectedPlayer, 'games_played') }}</strong>
</div>
</div>
</aside>
</section>
</main>
+157
View File
@@ -0,0 +1,157 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { of } from 'rxjs';
import { AppComponent } from './app.component';
import { PlayerApiService } from './api/player-api.service';
describe('AppComponent', () => {
const samplePlayers = [
{
id: 1,
name: 'Luca Marini',
position: 'PG',
role: 'Primary ball handler',
birth_year: 2001,
height_cm: 190,
weight_kg: 86,
nationality: 'Italy',
league: { name: 'Lega Basket Serie A', code: 'LBA', region: 'Europe', country: 'Italy' },
team: { name: 'Milano', country: 'Italy' },
stats: {
games_played: 28,
minutes_per_game: '29.40',
points_per_game: '16.80',
assists_per_game: '6.20',
rebounds_per_game: '3.80',
efficiency_rating: '20.50',
true_shooting_percentage: '59.20',
usage_percentage: '25.00',
},
},
{
id: 2,
name: 'Mateo Santos',
position: 'SF',
role: '3 and D wing',
birth_year: 1999,
height_cm: 201,
weight_kg: 96,
nationality: 'Spain',
league: { name: 'Liga Endesa', code: 'ACB', region: 'Europe', country: 'Spain' },
team: { name: 'Madrid', country: 'Spain' },
stats: {
games_played: 25,
minutes_per_game: '24.10',
points_per_game: '11.20',
assists_per_game: '2.10',
rebounds_per_game: '5.50',
efficiency_rating: '13.70',
true_shooting_percentage: '55.80',
usage_percentage: '17.50',
},
},
];
it('loads players through the API service', () => {
const api = {
searchPlayers: () =>
of({
count: samplePlayers.length,
results: samplePlayers,
}),
} as unknown as PlayerApiService;
const component = new AppComponent(api);
component.search();
assert.equal(component.players.length, 2);
assert.equal(component.players[0].name, 'Luca Marini');
assert.equal(component.resultCount, 2);
assert.equal(component.averagePoints, '14.00');
assert.equal(component.topEfficiencyPlayer?.name, 'Luca Marini');
assert.equal(component.selectedPlayer, null);
});
it('does not apply changed filters until refresh is requested', () => {
let calls = 0;
let requestedLeague = '';
const api = {
searchPlayers: (filters: { league: string }) => {
calls += 1;
requestedLeague = filters.league;
return of({ count: 0, results: [] });
},
} as unknown as PlayerApiService;
const component = new AppComponent(api);
component.filters.league = 'LBA';
component.filters.league = 'ABA';
assert.equal(calls, 0);
component.search();
assert.equal(calls, 1);
assert.equal(requestedLeague, 'ABA');
});
it('exposes role options and sends the selected role as a filter', () => {
let requestedRole = '';
const api = {
searchPlayers: (filters: { role: string }) => {
requestedRole = filters.role;
return of({ count: 0, results: [] });
},
} as unknown as PlayerApiService;
const component = new AppComponent(api);
assert.ok(component.roles.includes('3 and D wing'));
component.filters.role = '3 and D wing';
component.search();
assert.equal(requestedRole, '3 and D wing');
});
it('shows the loading placeholder only before results exist', () => {
const api = {
searchPlayers: () => of({ count: samplePlayers.length, results: samplePlayers }),
} as unknown as PlayerApiService;
const component = new AppComponent(api);
component.loading = true;
assert.equal(component.showLoadingPlaceholder, true);
component.players = samplePlayers;
assert.equal(component.showLoadingPlaceholder, false);
});
it('sorts the visible scouting board by selected stat', () => {
const api = {
searchPlayers: () => of({ count: samplePlayers.length, results: samplePlayers }),
} as unknown as PlayerApiService;
const component = new AppComponent(api);
component.search();
component.sortBy('rebounds_per_game');
assert.equal(component.players[0].name, 'Mateo Santos');
});
it('opens and closes a player profile without losing the filtered list', () => {
const api = {
searchPlayers: () => of({ count: samplePlayers.length, results: samplePlayers }),
} as unknown as PlayerApiService;
const component = new AppComponent(api);
component.search();
component.selectPlayer(samplePlayers[0]);
component.returnToResults();
assert.equal(component.selectedPlayer, null);
assert.equal(component.players.length, 2);
assert.equal(component.resultCount, 2);
});
});
+170
View File
@@ -0,0 +1,170 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { PlayerApiService } from './api/player-api.service';
import { PlayerFilters, PlayerStats, PlayerSummary } from './models';
type SortKey = 'efficiency_rating' | 'points_per_game' | 'assists_per_game' | 'rebounds_per_game' | 'minutes_per_game';
@Component({
selector: 'app-root',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './app.component.html',
styleUrl: './app.component.css',
})
export class AppComponent {
readonly positions = ['PG', 'SG', 'SF', 'PF', 'C'];
readonly leagues = ['LBA', 'ACB', 'ABA', 'BBL', 'BSL', 'LNB', 'ISBL', 'NBL', 'NZNBL'];
readonly roles = [
'3 and D wing',
'Change-of-pace guard',
'Connector wing',
'Defensive playmaker',
'Drive and kick guard',
'Face-up forward',
'Glass cleaner',
'Low-post finisher',
'Movement shooter',
'Off-screen scorer',
'Paint anchor',
'Paint touch guard',
'Pick and roll creator',
'Pressure guard',
'Primary ball handler',
'Pull-up shooter',
'Rim protector',
'Roll man',
'Secondary creator',
'Short-roll passer',
'Slashing wing',
'Stretch four',
'Switch defender',
'Tempo guard',
'Transition wing',
'Vertical spacer',
];
readonly sortOptions: Array<{ key: SortKey; label: string }> = [
{ key: 'efficiency_rating', label: 'EFF' },
{ key: 'points_per_game', label: 'PPG' },
{ key: 'assists_per_game', label: 'APG' },
{ key: 'rebounds_per_game', label: 'RPG' },
{ key: 'minutes_per_game', label: 'MIN' },
];
filters: PlayerFilters = {
q: '',
position: '',
role: '',
league: '',
minPoints: null,
minAssists: null,
minRebounds: null,
minEfficiency: null,
};
players: PlayerSummary[] = [];
selectedPlayer: PlayerSummary | null = null;
resultCount = 0;
loading = false;
errorMessage = '';
activeSort: SortKey = 'efficiency_rating';
constructor(private readonly playerApi: PlayerApiService) {}
ngOnInit(): void {
this.search();
}
search(): void {
this.loading = true;
this.errorMessage = '';
this.playerApi.searchPlayers(this.filters).subscribe({
next: (response) => {
const players = this.sortPlayers(response.results);
this.players = players;
this.resultCount = response.count;
this.selectedPlayer = this.matchSelectedPlayer(players);
this.loading = false;
},
error: () => {
this.errorMessage = 'Sign in is required or the scouting API is unavailable.';
this.loading = false;
},
});
}
clearFilters(): void {
this.filters = {
q: '',
position: '',
role: '',
league: '',
minPoints: null,
minAssists: null,
minRebounds: null,
minEfficiency: null,
};
this.search();
}
sortBy(key: SortKey): void {
this.activeSort = key;
this.players = this.sortPlayers(this.players);
this.selectedPlayer = this.matchSelectedPlayer(this.players);
}
selectPlayer(player: PlayerSummary): void {
this.selectedPlayer = player;
}
returnToResults(): void {
this.selectedPlayer = null;
}
statValue(player: PlayerSummary, key: keyof NonNullable<PlayerSummary['stats']>): string {
const value = player.stats?.[key];
return value === undefined || value === null ? '-' : String(value);
}
get topEfficiencyPlayer(): PlayerSummary | null {
return [...this.players].sort((a, b) => this.numericStat(b, 'efficiency_rating') - this.numericStat(a, 'efficiency_rating'))[0] ?? null;
}
get averagePoints(): string {
if (this.players.length === 0) {
return '0.00';
}
const total = this.players.reduce((sum, player) => sum + this.numericStat(player, 'points_per_game'), 0);
return (total / this.players.length).toFixed(2);
}
get averageEfficiency(): string {
if (this.players.length === 0) {
return '0.00';
}
const total = this.players.reduce((sum, player) => sum + this.numericStat(player, 'efficiency_rating'), 0);
return (total / this.players.length).toFixed(2);
}
get showLoadingPlaceholder(): boolean {
return this.loading && this.players.length === 0;
}
private sortPlayers(players: PlayerSummary[]): PlayerSummary[] {
return [...players].sort((a, b) => this.numericStat(b, this.activeSort) - this.numericStat(a, this.activeSort));
}
private numericStat(player: PlayerSummary, key: keyof PlayerStats): number {
return Number(player.stats?.[key] ?? 0);
}
private matchSelectedPlayer(players: PlayerSummary[]): PlayerSummary | null {
if (!this.selectedPlayer) {
return null;
}
return players.find((player) => player.id === this.selectedPlayer?.id) ?? null;
}
}
+52
View File
@@ -0,0 +1,52 @@
export interface League {
name: string;
code: string;
region: string;
country: string;
}
export interface Team {
name: string;
country: string;
}
export interface PlayerStats {
games_played: number;
minutes_per_game: string;
points_per_game: string;
assists_per_game: string;
rebounds_per_game: string;
efficiency_rating: string;
true_shooting_percentage: string;
usage_percentage: string;
}
export interface PlayerSummary {
id: number;
name: string;
position: string;
role: string;
birth_year: number | null;
height_cm: number | null;
weight_kg: number | null;
nationality: string;
league: League | null;
team: Team | null;
stats: PlayerStats | null;
}
export interface PlayerSearchResponse {
count: number;
results: PlayerSummary[];
}
export interface PlayerFilters {
q: string;
position: string;
role: string;
league: string;
minPoints: number | null;
minAssists: number | null;
minRebounds: number | null;
minEfficiency: number | null;
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>HoopScout</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<app-root></app-root>
</body>
</html>
+17
View File
@@ -0,0 +1,17 @@
import { provideHttpClient, withXsrfConfiguration } from '@angular/common/http';
import { bootstrapApplication } from '@angular/platform-browser';
import { provideAnimations } from '@angular/platform-browser/animations';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, {
providers: [
provideAnimations(),
provideHttpClient(
withXsrfConfiguration({
cookieName: 'csrftoken',
headerName: 'X-CSRFToken',
}),
),
],
}).catch((error) => console.error(error));
+30
View File
@@ -0,0 +1,30 @@
:root {
color-scheme: light;
--bg: #f3f5f1;
--ink: #17211f;
--muted: #66706c;
--panel: #ffffff;
--line: #d9dfd9;
--accent: #176b64;
--accent-strong: #0d4d48;
--gold: #bd8b2f;
--red: #a4473f;
--shadow: 0 18px 50px rgba(25, 38, 35, 0.12);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
color: var(--ink);
background: var(--bg);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
button,
input,
select {
font: inherit;
}
+1
View File
@@ -0,0 +1 @@
import '@angular/compiler';
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": ["src/main.ts"],
"include": ["src/**/*.d.ts"]
}
+29
View File
@@ -0,0 +1,29 @@
{
"compileOnSave": false,
"compilerOptions": {
"baseUrl": "./",
"outDir": "./dist/out-tsc",
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"sourceMap": true,
"declaration": false,
"downlevelIteration": true,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022",
"lib": ["ES2022", "dom"]
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"include": ["src/**/*.ts"]
}