Compare commits

...

2 Commits

7 changed files with 396 additions and 0 deletions

View File

@ -48,8 +48,12 @@ The repository is organized to keep durable workflow guidance and technical deci
. .
|-- .codex/ |-- .codex/
|-- .agents/skills/ |-- .agents/skills/
|-- app/
|-- docs/ |-- docs/
|-- infra/
|-- requirements/
|-- scripts/ |-- scripts/
|-- tests/
|-- AGENTS.md |-- AGENTS.md
|-- Makefile |-- Makefile
|-- README.md |-- README.md
@ -59,8 +63,12 @@ The repository is organized to keep durable workflow guidance and technical deci
- `.codex/` stores repository-scoped Codex configuration and agent definitions. - `.codex/` stores repository-scoped Codex configuration and agent definitions.
- `.agents/skills/` stores reusable skills for repeatable repository workflows. - `.agents/skills/` stores reusable skills for repeatable repository workflows.
- `app/` stores the Django project and scouting application code.
- `docs/` stores workflow, architecture, ADRs, machine setup, and task execution guidance. - `docs/` stores workflow, architecture, ADRs, machine setup, and task execution guidance.
- `infra/` stores local container and environment bootstrap files.
- `requirements/` stores the Python dependency baseline.
- `scripts/` stores repository utility scripts such as local checks. - `scripts/` stores repository utility scripts such as local checks.
- `tests/` stores repository-level testing notes and support files.
- `AGENTS.md` defines repository-wide agent behavior and task rules. - `AGENTS.md` defines repository-wide agent behavior and task rules.
- `Makefile` exposes standard project commands. - `Makefile` exposes standard project commands.
- `README.md` introduces the repository and current phase. - `README.md` introduces the repository and current phase.
@ -121,6 +129,14 @@ Codex tasks in this repository should follow this order:
Run `make doctor` as part of machine/bootstrap validation to confirm the repository foundation is present and aligned. Run `make doctor` as part of machine/bootstrap validation to confirm the repository foundation is present and aligned.
## Development Bootstrap
For the current MVP baseline:
1. Start the local stack with `docker compose --env-file .env -f infra/docker-compose.yml up -d --build`.
2. Run `docker compose --env-file .env -f infra/docker-compose.yml exec -T app python manage.py migrate`.
3. Load sample scouting data with `docker compose --env-file .env -f infra/docker-compose.yml exec -T app python manage.py seed_scouting_data`.
4. Open `/players/` and use filters such as `PG + min assists` or `team + min TS%` to explore the seeded dataset.
## Current Status ## Current Status
The repository currently provides: The repository currently provides:

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,122 @@
from __future__ import annotations
from datetime import date
from django.core.management.base import BaseCommand
from django.db import transaction
from scouting.models import (
Competition,
Player,
PlayerSeason,
PlayerSeasonStats,
Role,
Season,
Specialty,
Team,
)
from scouting.sample_data.scouting_seed import COMPETITIONS, PLAYERS, ROLES, SEASONS, SPECIALTIES, TEAMS
class Command(BaseCommand):
help = "Load a curated scouting sample dataset for local development."
@transaction.atomic
def handle(self, *args, **options):
roles = {}
for record in ROLES:
role, _ = Role.objects.update_or_create(
slug=record["slug"],
defaults={
"name": record["name"],
"description": record.get("description", ""),
},
)
roles[role.name] = role
specialties = {}
for record in SPECIALTIES:
specialty, _ = Specialty.objects.update_or_create(
slug=record["slug"],
defaults={
"name": record["name"],
"description": record.get("description", ""),
},
)
specialties[specialty.name] = specialty
competitions = {}
for record in COMPETITIONS:
competition, _ = Competition.objects.update_or_create(
name=record["name"],
defaults={
"country": record.get("country", ""),
"level": record.get("level", ""),
},
)
competitions[competition.name] = competition
teams = {}
for record in TEAMS:
team, _ = Team.objects.update_or_create(
name=record["name"],
country=record.get("country", ""),
defaults={},
)
teams[(team.name, team.country)] = team
seasons = {}
for record in SEASONS:
season, _ = Season.objects.update_or_create(
name=record["name"],
defaults={
"start_year": record["start_year"],
"end_year": record["end_year"],
},
)
seasons[season.name] = season
for record in PLAYERS:
defaults = {
"first_name": record.get("first_name", ""),
"last_name": record.get("last_name", ""),
"birth_date": date.fromisoformat(record["birth_date"]) if record.get("birth_date") else None,
"nationality": record.get("nationality", ""),
"height_cm": record.get("height_cm"),
"weight_kg": record.get("weight_kg"),
"wingspan_cm": record.get("wingspan_cm"),
"position": record["position"],
}
player, _ = Player.objects.update_or_create(
full_name=record["full_name"],
defaults=defaults,
)
player.roles.set([roles[name] for name in record.get("roles", [])])
player.specialties.set([specialties[name] for name in record.get("specialties", [])])
for context_record in record.get("contexts", []):
context, _ = PlayerSeason.objects.update_or_create(
player=player,
season=seasons[context_record["season"]],
team=teams[context_record["team"]],
competition=competitions[context_record["competition"]],
defaults={},
)
PlayerSeasonStats.objects.update_or_create(
player_season=context,
defaults=context_record["stats"],
)
self.stdout.write(
self.style.SUCCESS(
"Seeded scouting sample data: "
f"{Player.objects.count()} players, "
f"{PlayerSeason.objects.count()} contexts, "
f"{PlayerSeasonStats.objects.count()} stat lines."
)
)
self.stdout.write(
"Suggested filters: "
"PG + min assists, C + min blocks, SG/wing + min TS%, or role + specialty combinations."
)

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,219 @@
from __future__ import annotations
from decimal import Decimal
ROLES = [
{"name": "playmaker", "slug": "playmaker"},
{"name": "shooting wing", "slug": "shooting-wing"},
{"name": "rim protector", "slug": "rim-protector"},
{"name": "stretch four", "slug": "stretch-four"},
{"name": "6th man", "slug": "6th-man"},
]
SPECIALTIES = [
{"name": "ball handling", "slug": "ball-handling"},
{"name": "off ball", "slug": "off-ball"},
{"name": "defense", "slug": "defense"},
{"name": "clutch", "slug": "clutch"},
{"name": "post", "slug": "post"},
]
COMPETITIONS = [
{"name": "Euro League", "country": "EU", "level": "top"},
{"name": "Italian Serie A", "country": "IT", "level": "top"},
{"name": "Spanish ACB", "country": "ES", "level": "top"},
]
TEAMS = [
{"name": "Milan Lions", "country": "IT"},
{"name": "Rome Falcons", "country": "IT"},
{"name": "Madrid Waves", "country": "ES"},
{"name": "Berlin Towers", "country": "DE"},
]
SEASONS = [
{"name": "2023-2024", "start_year": 2023, "end_year": 2024},
{"name": "2024-2025", "start_year": 2024, "end_year": 2025},
{"name": "2025-2026", "start_year": 2025, "end_year": 2026},
]
PLAYERS = [
{
"full_name": "Marco Guard",
"first_name": "Marco",
"last_name": "Guard",
"birth_date": "2002-01-01",
"nationality": "IT",
"height_cm": Decimal("188.00"),
"weight_kg": Decimal("82.00"),
"wingspan_cm": Decimal("194.00"),
"position": "PG",
"roles": ["playmaker"],
"specialties": ["ball handling", "clutch"],
"contexts": [
{
"season": "2025-2026",
"team": ("Milan Lions", "IT"),
"competition": "Euro League",
"stats": {
"points": Decimal("16.00"),
"assists": Decimal("8.20"),
"steals": Decimal("1.90"),
"turnovers": Decimal("2.40"),
"blocks": Decimal("0.20"),
"efg_pct": Decimal("53.40"),
"ts_pct": Decimal("59.80"),
"plus_minus": Decimal("4.60"),
"offensive_rating": Decimal("114.00"),
"defensive_rating": Decimal("105.00"),
},
},
{
"season": "2024-2025",
"team": ("Rome Falcons", "IT"),
"competition": "Italian Serie A",
"stats": {
"points": Decimal("13.20"),
"assists": Decimal("6.90"),
"steals": Decimal("1.40"),
"turnovers": Decimal("2.90"),
"blocks": Decimal("0.10"),
"efg_pct": Decimal("49.80"),
"ts_pct": Decimal("55.10"),
"plus_minus": Decimal("1.20"),
"offensive_rating": Decimal("109.00"),
"defensive_rating": Decimal("108.00"),
},
},
],
},
{
"full_name": "Luca Wing",
"first_name": "Luca",
"last_name": "Wing",
"birth_date": "1999-02-14",
"nationality": "IT",
"height_cm": Decimal("201.00"),
"weight_kg": Decimal("93.00"),
"wingspan_cm": Decimal("208.00"),
"position": "SF",
"roles": ["shooting wing"],
"specialties": ["off ball", "clutch"],
"contexts": [
{
"season": "2025-2026",
"team": ("Madrid Waves", "ES"),
"competition": "Spanish ACB",
"stats": {
"points": Decimal("17.40"),
"assists": Decimal("2.60"),
"steals": Decimal("1.30"),
"turnovers": Decimal("1.70"),
"blocks": Decimal("0.60"),
"efg_pct": Decimal("57.20"),
"ts_pct": Decimal("62.40"),
"plus_minus": Decimal("3.10"),
"offensive_rating": Decimal("118.00"),
"defensive_rating": Decimal("107.00"),
},
}
],
},
{
"full_name": "Niko Anchor",
"first_name": "Niko",
"last_name": "Anchor",
"birth_date": "1998-07-03",
"nationality": "DE",
"height_cm": Decimal("211.00"),
"weight_kg": Decimal("109.00"),
"wingspan_cm": Decimal("221.00"),
"position": "C",
"roles": ["rim protector"],
"specialties": ["defense", "post"],
"contexts": [
{
"season": "2025-2026",
"team": ("Berlin Towers", "DE"),
"competition": "Euro League",
"stats": {
"points": Decimal("11.30"),
"assists": Decimal("1.80"),
"steals": Decimal("0.90"),
"turnovers": Decimal("1.80"),
"blocks": Decimal("2.40"),
"efg_pct": Decimal("58.30"),
"ts_pct": Decimal("61.10"),
"plus_minus": Decimal("5.20"),
"offensive_rating": Decimal("111.00"),
"defensive_rating": Decimal("101.00"),
},
}
],
},
{
"full_name": "Sandro Forward",
"first_name": "Sandro",
"last_name": "Forward",
"birth_date": "2001-09-20",
"nationality": "IT",
"height_cm": Decimal("206.00"),
"weight_kg": Decimal("98.00"),
"wingspan_cm": Decimal("214.00"),
"position": "PF",
"roles": ["stretch four"],
"specialties": ["off ball"],
"contexts": [
{
"season": "2025-2026",
"team": ("Rome Falcons", "IT"),
"competition": "Italian Serie A",
"stats": {
"points": Decimal("15.10"),
"assists": Decimal("2.90"),
"steals": Decimal("0.80"),
"turnovers": Decimal("1.60"),
"blocks": Decimal("1.10"),
"efg_pct": Decimal("56.40"),
"ts_pct": Decimal("60.20"),
"plus_minus": Decimal("2.70"),
"offensive_rating": Decimal("116.00"),
"defensive_rating": Decimal("109.00"),
},
}
],
},
{
"full_name": "Jalen Spark",
"first_name": "Jalen",
"last_name": "Spark",
"birth_date": "2000-11-11",
"nationality": "US",
"height_cm": Decimal("193.00"),
"weight_kg": Decimal("87.00"),
"wingspan_cm": Decimal("199.00"),
"position": "SG",
"roles": ["6th man"],
"specialties": ["ball handling", "off ball"],
"contexts": [
{
"season": "2024-2025",
"team": ("Milan Lions", "IT"),
"competition": "Italian Serie A",
"stats": {
"points": Decimal("18.60"),
"assists": Decimal("3.40"),
"steals": Decimal("1.10"),
"turnovers": Decimal("2.20"),
"blocks": Decimal("0.30"),
"efg_pct": Decimal("54.10"),
"ts_pct": Decimal("58.70"),
"plus_minus": Decimal("1.80"),
"offensive_rating": Decimal("113.00"),
"defensive_rating": Decimal("111.00"),
},
}
],
},
]

View File

@ -1,6 +1,7 @@
from datetime import date from datetime import date
from decimal import Decimal from decimal import Decimal
from django.core.management import call_command
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
@ -236,3 +237,38 @@ class ScoutingSearchViewsTests(TestCase):
) )
self.assertContains(response, self.player_pg.full_name) self.assertContains(response, self.player_pg.full_name)
self.assertNotContains(response, self.player_wing.full_name) self.assertNotContains(response, self.player_wing.full_name)
class SeedScoutingDataCommandTests(TestCase):
def test_seed_command_creates_expected_core_objects(self):
call_command("seed_scouting_data")
self.assertGreaterEqual(Competition.objects.count(), 3)
self.assertGreaterEqual(Team.objects.count(), 4)
self.assertGreaterEqual(Season.objects.count(), 3)
self.assertGreaterEqual(Player.objects.count(), 5)
self.assertGreaterEqual(PlayerSeason.objects.count(), 6)
self.assertEqual(PlayerSeason.objects.count(), PlayerSeasonStats.objects.count())
player = Player.objects.get(full_name="Marco Guard")
self.assertEqual(player.position, Player.Position.PG)
self.assertTrue(player.roles.filter(slug="playmaker").exists())
self.assertTrue(player.specialties.filter(slug="ball-handling").exists())
def test_seed_command_is_idempotent_for_repeat_runs(self):
call_command("seed_scouting_data")
first_counts = {
"players": Player.objects.count(),
"contexts": PlayerSeason.objects.count(),
"stats": PlayerSeasonStats.objects.count(),
"roles": Role.objects.count(),
"specialties": Specialty.objects.count(),
}
call_command("seed_scouting_data")
self.assertEqual(Player.objects.count(), first_counts["players"])
self.assertEqual(PlayerSeason.objects.count(), first_counts["contexts"])
self.assertEqual(PlayerSeasonStats.objects.count(), first_counts["stats"])
self.assertEqual(Role.objects.count(), first_counts["roles"])
self.assertEqual(Specialty.objects.count(), first_counts["specialties"])