From ff4a3020d59f7d65a576300cd516fe827eddf703 Mon Sep 17 00:00:00 2001 From: bisco Date: Tue, 7 Apr 2026 16:46:59 +0200 Subject: [PATCH] feat: add scouting sample seed data baseline --- README.md | 16 ++ app/scouting/management/__init__.py | 1 + app/scouting/management/commands/__init__.py | 1 + .../management/commands/seed_scouting_data.py | 122 ++++++++++ app/scouting/sample_data/__init__.py | 1 + app/scouting/sample_data/scouting_seed.py | 219 ++++++++++++++++++ app/scouting/tests.py | 36 +++ 7 files changed, 396 insertions(+) create mode 100644 app/scouting/management/__init__.py create mode 100644 app/scouting/management/commands/__init__.py create mode 100644 app/scouting/management/commands/seed_scouting_data.py create mode 100644 app/scouting/sample_data/__init__.py create mode 100644 app/scouting/sample_data/scouting_seed.py diff --git a/README.md b/README.md index f514896..25616a1 100644 --- a/README.md +++ b/README.md @@ -48,8 +48,12 @@ The repository is organized to keep durable workflow guidance and technical deci . |-- .codex/ |-- .agents/skills/ +|-- app/ |-- docs/ +|-- infra/ +|-- requirements/ |-- scripts/ +|-- tests/ |-- AGENTS.md |-- Makefile |-- 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. - `.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. +- `infra/` stores local container and environment bootstrap files. +- `requirements/` stores the Python dependency baseline. - `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. - `Makefile` exposes standard project commands. - `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. +## 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 The repository currently provides: diff --git a/app/scouting/management/__init__.py b/app/scouting/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/scouting/management/__init__.py @@ -0,0 +1 @@ + diff --git a/app/scouting/management/commands/__init__.py b/app/scouting/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/scouting/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/app/scouting/management/commands/seed_scouting_data.py b/app/scouting/management/commands/seed_scouting_data.py new file mode 100644 index 0000000..845c71f --- /dev/null +++ b/app/scouting/management/commands/seed_scouting_data.py @@ -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." + ) diff --git a/app/scouting/sample_data/__init__.py b/app/scouting/sample_data/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/scouting/sample_data/__init__.py @@ -0,0 +1 @@ + diff --git a/app/scouting/sample_data/scouting_seed.py b/app/scouting/sample_data/scouting_seed.py new file mode 100644 index 0000000..75b69f2 --- /dev/null +++ b/app/scouting/sample_data/scouting_seed.py @@ -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"), + }, + } + ], + }, +] diff --git a/app/scouting/tests.py b/app/scouting/tests.py index f6f9773..ec9ea4a 100644 --- a/app/scouting/tests.py +++ b/app/scouting/tests.py @@ -1,6 +1,7 @@ from datetime import date from decimal import Decimal +from django.core.management import call_command from django.test import TestCase from django.urls import reverse @@ -236,3 +237,38 @@ class ScoutingSearchViewsTests(TestCase): ) self.assertContains(response, self.player_pg.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"])