Compare commits
2 Commits
3e6fb34017
...
6c53cae7a1
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c53cae7a1 | |||
| ff4a3020d5 |
16
README.md
16
README.md
@ -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:
|
||||||
|
|||||||
1
app/scouting/management/__init__.py
Normal file
1
app/scouting/management/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
app/scouting/management/commands/__init__.py
Normal file
1
app/scouting/management/commands/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
122
app/scouting/management/commands/seed_scouting_data.py
Normal file
122
app/scouting/management/commands/seed_scouting_data.py
Normal 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."
|
||||||
|
)
|
||||||
1
app/scouting/sample_data/__init__.py
Normal file
1
app/scouting/sample_data/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
219
app/scouting/sample_data/scouting_seed.py
Normal file
219
app/scouting/sample_data/scouting_seed.py
Normal 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"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
@ -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"])
|
||||||
|
|||||||
Reference in New Issue
Block a user