Compare commits

..

62 Commits

Author SHA1 Message Date
db454371a5 Merge branch 'feature/phase-2-position-nullability-fix' into develop 2026-04-11 00:54:22 +02:00
e6081428ae fix(ingestion): stop fabricating missing player positions 2026-04-11 00:54:19 +02:00
677b5af40d Merge branch 'feature/phase-2-first-public-european-importer' into develop 2026-04-11 00:45:39 +02:00
88ca5cde10 feat(ingestion): add first public LBA Serie A importer 2026-04-11 00:45:33 +02:00
9524c928e2 Merge branch 'feature/phase-2-first-real-ingestion-flow' into develop 2026-04-11 00:24:48 +02:00
154450f516 feat(ingestion): add first real-data importer flow 2026-04-11 00:24:42 +02:00
5c82187b7c Merge branch 'feature/phase-2-ingestion-baseline-decision' into develop 2026-04-11 00:17:09 +02:00
e1365761c9 docs(adr): define real-data ingestion baseline 2026-04-11 00:17:06 +02:00
b27db5e6a3 Merge branch 'feature/phase-2-saved-searches-and-search-refinement' into develop 2026-04-11 00:08:39 +02:00
c09aad2d63 feat(scouting): add wingspan filters and saved searches mvp 2026-04-11 00:07:55 +02:00
e44cad6167 Merge branch 'feature/phase-2-user-scoped-auth-and-notes' into develop 2026-04-07 18:11:39 +02:00
caa1f8354d feat: add user-scoped favorites and notes 2026-04-07 18:11:19 +02:00
a5e1d841df Merge branch 'feature/phase-2-player-notes-mvp' into develop 2026-04-07 17:42:18 +02:00
4651746427 feat: add shared scouting notes mvp 2026-04-07 17:41:53 +02:00
4f869c1c02 Merge branch 'feature/phase-2-favorites-mvp' into develop 2026-04-07 17:30:25 +02:00
99820419c4 feat: add scouting shortlist mvp 2026-04-07 17:29:55 +02:00
d1b5499a63 Merge branch 'feature/phase-2-search-sorting-pagination' into develop 2026-04-07 17:18:54 +02:00
6d8af021ce feat: add scouting search sorting and pagination 2026-04-07 17:18:36 +02:00
6c53cae7a1 Merge branch 'feature/phase-2-seed-data-baseline' into develop 2026-04-07 16:47:15 +02:00
ff4a3020d5 feat: add scouting sample seed data baseline 2026-04-07 16:46:59 +02:00
3e6fb34017 Merge branch 'feature/phase-2-search-result-context-improvement' into develop 2026-04-07 16:35:11 +02:00
dbf218e2fd feat: show matching season context in player results 2026-04-07 16:31:00 +02:00
b351d31cf5 Merge branch 'feature/phase-2-search-mvp' into develop 2026-04-06 19:44:39 +02:00
6c7d7c1af4 feat: add scouting search MVP list and player detail 2026-04-06 19:44:35 +02:00
80f6c160f4 Merge branch 'feature/phase-2-playerseason-context-fix' into develop 2026-04-06 19:30:58 +02:00
065a5f66f1 fix: make playerseason uniqueness context-aware 2026-04-06 19:30:55 +02:00
aa283a1235 Merge branch 'feature/phase-2-model-semantics-cleanup' into develop 2026-04-06 19:26:14 +02:00
e6390fe664 refactor: move role and specialty ownership to player 2026-04-06 19:26:07 +02:00
aecbb62376 Merge branch 'feature/phase-2-initial-django-models' into develop 2026-04-06 19:07:59 +02:00
d5b00eee98 feat: add initial django scouting domain models baseline 2026-04-06 19:07:53 +02:00
2994089f1e Merge branch 'feature/phase-2-domain-model-decision' into develop 2026-04-06 18:59:15 +02:00
a9f189cbc7 docs: define initial domain model baseline in ADR-0006 2026-04-06 18:58:50 +02:00
5ce9214c88 Merge branch 'feature/phase-2-search-domain-decision' into develop 2026-04-06 18:55:01 +02:00
99a0b19bf2 docs: define scouting search domain baseline in ADR-0005 2026-04-06 18:54:36 +02:00
e5261db35b Merge branch 'feature/phase-1-doc-alignment' into develop 2026-03-20 20:06:36 +01:00
81834f3254 docs: align top-level guidance with phase 1 baseline 2026-03-20 20:06:23 +01:00
b58dff240c Merge branch 'feature/phase-1-configuration-baseline' into develop 2026-03-20 20:00:26 +01:00
87b464aeb3 docs: record configuration baseline 2026-03-20 20:00:17 +01:00
a94c1e9a3d Merge branch 'feature/phase-1-containerized-dev-workflow-decision' into develop 2026-03-20 19:57:17 +01:00
6e5c5f1258 docs: record containerized developer workflow 2026-03-20 19:56:10 +01:00
3eb084cebc Merge branch 'feature/phase-1-project-structure-decision' into develop 2026-03-20 19:53:37 +01:00
6101790adb docs: record initial project structure decision 2026-03-20 19:53:25 +01:00
6e4539d06f Merge branch 'feature/phase-1-containerized-postgres-baseline' into develop 2026-03-20 19:46:12 +01:00
d2bb7c9d4d docs: correct runtime baseline to containers and postgres 2026-03-20 19:45:25 +01:00
0330b98b8b Merge branch 'feature/phase-1-runtime-stack-decision' into develop 2026-03-20 19:37:57 +01:00
1cdf7ac1d1 docs: record baseline runtime stack decision 2026-03-20 19:37:44 +01:00
f19da98778 Merge branch 'feature/phase-1-architecture-principles' into develop 2026-03-20 19:35:05 +01:00
78f45eb113 docs: define architecture principles for phase 1 2026-03-20 19:35:00 +01:00
d80d04b4ac Merge branch 'feature/phase-1-technical-decision-process' into develop 2026-03-20 19:32:33 +01:00
1dbcb5b7fe docs: define phase 1 technical decision process 2026-03-20 19:32:25 +01:00
7b6111f8f9 Merge branch 'feature/phase-1-decision-framework' into develop 2026-03-20 19:29:57 +01:00
0bfe9443e5 docs: add phase 1 decision framework 2026-03-20 19:29:50 +01:00
b5eac40c78 Merge branch 'feature/phase-0-closeout' into develop 2026-03-20 19:23:56 +01:00
44aa06a3a8 docs: close out phase 0 workflow foundation 2026-03-20 19:23:30 +01:00
d65a39fa51 Merge branch 'feature/task-template-doc-phase-0' into develop 2026-03-20 19:18:51 +01:00
d3c58d6166 docs: define task template 2026-03-20 19:18:42 +01:00
76d99d9ffc Merge branch 'feature/machine-setup-doc-phase-0' into develop 2026-03-20 19:16:51 +01:00
f3f7d922db docs: define machine setup guidance 2026-03-20 19:16:39 +01:00
3875b3c8d1 Merge branch 'feature/workflow-doc-phase-0' into develop 2026-03-20 19:12:57 +01:00
02a21bc94e docs: define phase 0 workflow 2026-03-20 19:12:51 +01:00
e2f78d9c59 Merge branch 'feature/readme-phase-0' into develop 2026-03-20 19:08:50 +01:00
ff98ac7ed9 docs: add phase 0 repository readme 2026-03-20 19:08:45 +01:00
64 changed files with 5809 additions and 78 deletions

8
.env.example Normal file
View File

@ -0,0 +1,8 @@
DJANGO_SECRET_KEY=dev-only-change-me
DJANGO_DEBUG=1
DJANGO_ALLOWED_HOSTS=*
POSTGRES_DB=hoopscout
POSTGRES_USER=hoopscout
POSTGRES_PASSWORD=hoopscout
POSTGRES_HOST=db
POSTGRES_PORT=5432

View File

@ -2,15 +2,20 @@
## Scope of this repository ## Scope of this repository
This repository is in **phase 0**. Phase 0 is complete.
The goal of this phase is to define: The repository now has:
- a completed workflow and portability foundation from phase 0
- accepted phase-1 technical decisions recorded through ADRs
- enough documented guidance to support implementation work that follows the accepted baseline
The goal of the current phase is to continue:
- how development with Codex and custom agents works - how development with Codex and custom agents works
- how repository workflow works - how repository workflow works
- how tasks are executed and closed - how technical decisions are documented and applied
- how the project remains portable across machines - how implementation work follows accepted repository decisions
This phase is **not** for deciding the product architecture or implementing application features. This repository is no longer limited to workflow/bootstrap work. Runtime, database, structure, workflow, and configuration baselines have already been accepted through ADRs and must guide future implementation tasks.
Agents must optimize for: Agents must optimize for:
- repeatability - repeatability
@ -170,6 +175,14 @@ Never claim tests passed unless they were actually executed.
--- ---
## ADR Discipline
Implementation work must follow accepted ADRs.
Current accepted decisions live under `docs/adr/` and include the baseline runtime, structure, developer workflow, and configuration strategy. If a task depends on a new major technical choice, document or update the decision explicitly rather than relying on undocumented assumptions.
---
## Documentation discipline ## Documentation discipline
Whenever behavior changes, update docs if relevant. Whenever behavior changes, update docs if relevant.
@ -186,15 +199,9 @@ Do not let docs drift from actual workflow.
## Out of scope in phase 0 ## Out of scope in phase 0
Do not decide yet: Phase 0 no longer blocks technical decision-making. Phase-1 baseline decisions have already been accepted.
- final application architecture
- final domain model
- runtime services beyond workflow tooling
- database technology
- ingestion strategy
- feature list
If a task drifts into product design, stop and ask for a separate explicit decision. Still do not treat product decisions as implicit. If a task drifts into new product scope or a major technical choice that is not yet documented, stop and ask for a separate explicit decision or ADR update.
--- ---
@ -209,3 +216,4 @@ Avoid:
- introducing tools that are not yet justified - introducing tools that are not yet justified
This phase is about building a clean development method first. This phase is about building a clean development method first.
That method now extends to implementation work that follows the accepted ADR baseline.

104
README.md
View File

@ -1,2 +1,104 @@
# hoopscout-v2 # HoopScout v2
HoopScout v2 is a Django/PostgreSQL scouting application developed through a repository-first workflow. The repo keeps both implementation guidance and Codex collaboration rules in version control so the project stays portable across machines.
## Current MVP
The current application baseline provides:
- containerized local development
- curated sample seed data for manual exploration
- player scouting search with player, context, and stat filters
- wingspan-aware player filtering (`min_wingspan_cm` / `max_wingspan_cm`)
- matching season/team/competition context on search results
- result sorting and pagination
- login/logout with Django built-in authentication
- user-scoped shortlist favorites
- user-scoped plain-text scouting notes on player detail pages
- user-scoped saved searches (save, rerun, delete)
- first real-data ingestion command baseline (`import_hoopdata_demo_competition`) with idempotent source-identity mapping
- first public European importer (`import_lba_public_serie_a`) for LBA Serie A player-stat scope with idempotent external-ID binding
Accepted technical and product-shaping decisions live in:
- `docs/ARCHITECTURE.md`
- `docs/ARCHITECTURE_PRINCIPLES.md`
- `docs/DECISION_PROCESS.md`
- `docs/adr/`
## Repository Structure
```text
.
|-- .codex/
|-- .agents/skills/
|-- app/
| |-- hoopscout/
| `-- scouting/
|-- docs/
|-- infra/
|-- scripts/
|-- tests/
|-- AGENTS.md
|-- Makefile
`-- README.md
```
- `app/hoopscout/` contains the Django project settings and root URLs.
- `app/scouting/` contains the scouting domain models, views, templates, management commands, and tests tied to the app.
- `infra/` contains the local Docker Compose and image setup.
- `docs/` contains workflow and ADR documentation.
- `scripts/` contains repository checks such as `make doctor`.
## Local Development
1. Start the stack with `docker compose --env-file .env -f infra/docker-compose.yml up -d --build`.
2. Apply migrations with `docker compose --env-file .env -f infra/docker-compose.yml exec -T app python manage.py migrate`.
3. Load sample data with `docker compose --env-file .env -f infra/docker-compose.yml exec -T app python manage.py seed_scouting_data`.
4. Create a local user with `docker compose --env-file .env -f infra/docker-compose.yml exec -T app python manage.py createsuperuser` if you need a development login.
5. Visit `http://127.0.0.1:8000/players/` to explore the scouting search MVP.
6. Use player-level filters (including wingspan), context filters, and stat filters to run scouting searches with sorting and pagination.
7. Log in at `http://127.0.0.1:8000/accounts/login/` to manage your own shortlist, notes, and saved searches.
8. Save useful filter combinations from the player search page, rerun them later, or delete them.
9. Open player detail pages to review context rows and create user-scoped notes.
10. Use `http://127.0.0.1:8000/favorites/` to review your user-scoped shortlist.
First real-data importer (ADR-0009 baseline):
- default sample snapshot import:
`docker compose --env-file .env -f infra/docker-compose.yml exec -T app python manage.py import_hoopdata_demo_competition`
- explicit input path import:
`docker compose --env-file .env -f infra/docker-compose.yml exec -T app python manage.py import_hoopdata_demo_competition --input /app/scouting/sample_data/imports/hoopdata_demo_serie_a2_2025_2026.json`
First public European importer (LBA Serie A scope):
- live public-source import (season start year 2025):
`docker compose --env-file .env -f infra/docker-compose.yml exec -T app python manage.py import_lba_public_serie_a --season 2025`
- deterministic local-fixture import:
`docker compose --env-file .env -f infra/docker-compose.yml exec -T app python manage.py import_lba_public_serie_a --season 2025 --fixture /app/scouting/sample_data/imports/lba_public_serie_a_fixture.json`
- note: when the public source does not expose a player position, HoopScout keeps `position` empty instead of fabricating a value.
Legacy shared favorites and notes from the pre-auth MVP are cleared by the early-stage ownership migration so the app can move cleanly to user-scoped data.
## Workflow
- `main` is the stable branch.
- `develop` is the integration branch.
- normal work goes through `feature/*` branches created from `develop`.
- run `make doctor` before or during local setup to confirm the repository foundation is present.
Durable project behavior belongs in the repository, especially:
- `AGENTS.md`
- `.codex/`
- `.agents/skills/`
- `docs/`
Local-only responsibilities still include authentication, personal editor setup, shell aliases, and secrets.
## Contributing
- read `AGENTS.md`
- read `docs/WORKFLOW.md`
- read the current ADR set in `docs/adr/`
- create a task branch from `develop`
- keep tasks narrowly scoped and aligned with accepted decisions
## License
License is currently unspecified.

View File

7
app/hoopscout/asgi.py Normal file
View File

@ -0,0 +1,7 @@
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "hoopscout.settings")
application = get_asgi_application()

72
app/hoopscout/settings.py Normal file
View File

@ -0,0 +1,72 @@
from pathlib import Path
import os
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "dev-only-insecure-secret-key")
DEBUG = os.getenv("DJANGO_DEBUG", "1") == "1"
ALLOWED_HOSTS = os.getenv("DJANGO_ALLOWED_HOSTS", "*").split(",")
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"scouting.apps.ScoutingConfig",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "hoopscout.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "hoopscout.wsgi.application"
ASGI_APPLICATION = "hoopscout.asgi.application"
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.getenv("POSTGRES_DB", "hoopscout"),
"USER": os.getenv("POSTGRES_USER", "hoopscout"),
"PASSWORD": os.getenv("POSTGRES_PASSWORD", "hoopscout"),
"HOST": os.getenv("POSTGRES_HOST", "db"),
"PORT": os.getenv("POSTGRES_PORT", "5432"),
}
}
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
STATIC_URL = "static/"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
LOGIN_URL = "login"
LOGIN_REDIRECT_URL = "scouting:player_list"
LOGOUT_REDIRECT_URL = "scouting:player_list"

10
app/hoopscout/urls.py Normal file
View File

@ -0,0 +1,10 @@
from django.contrib import admin
from django.contrib.auth import views as auth_views
from django.urls import include, path
urlpatterns = [
path("admin/", admin.site.urls),
path("accounts/login/", auth_views.LoginView.as_view(template_name="registration/login.html"), name="login"),
path("accounts/logout/", auth_views.LogoutView.as_view(), name="logout"),
path("", include("scouting.urls")),
]

7
app/hoopscout/wsgi.py Normal file
View File

@ -0,0 +1,7 @@
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "hoopscout.settings")
application = get_wsgi_application()

14
app/manage.py Normal file
View File

@ -0,0 +1,14 @@
#!/usr/bin/env python
import os
import sys
def main() -> None:
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "hoopscout.settings")
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()

2
app/requirements.txt Normal file
View File

@ -0,0 +1,2 @@
Django==5.2.2
psycopg[binary]==3.2.9

0
app/scouting/__init__.py Normal file
View File

84
app/scouting/admin.py Normal file
View File

@ -0,0 +1,84 @@
from django.contrib import admin
from .models import (
Competition,
FavoritePlayer,
Player,
PlayerNote,
PlayerSeason,
PlayerSeasonStats,
Role,
Season,
Specialty,
Team,
)
@admin.register(Role)
class RoleAdmin(admin.ModelAdmin):
list_display = ("name", "slug")
search_fields = ("name", "slug")
@admin.register(Specialty)
class SpecialtyAdmin(admin.ModelAdmin):
list_display = ("name", "slug")
search_fields = ("name", "slug")
@admin.register(Player)
class PlayerAdmin(admin.ModelAdmin):
list_display = ("full_name", "position", "nationality", "birth_date")
search_fields = ("full_name", "first_name", "last_name", "nationality")
list_filter = ("position",)
filter_horizontal = ("roles", "specialties")
@admin.register(Competition)
class CompetitionAdmin(admin.ModelAdmin):
list_display = ("name", "country", "level")
search_fields = ("name", "country", "level")
@admin.register(Team)
class TeamAdmin(admin.ModelAdmin):
list_display = ("name", "country")
search_fields = ("name", "country")
@admin.register(Season)
class SeasonAdmin(admin.ModelAdmin):
list_display = ("name", "start_year", "end_year")
search_fields = ("name",)
@admin.register(PlayerSeason)
class PlayerSeasonAdmin(admin.ModelAdmin):
list_display = ("player", "season", "team", "competition")
list_filter = ("season", "competition")
search_fields = ("player__full_name", "team__name", "competition__name")
@admin.register(PlayerSeasonStats)
class PlayerSeasonStatsAdmin(admin.ModelAdmin):
list_display = (
"player_season",
"points",
"assists",
"steals",
"turnovers",
"blocks",
)
search_fields = ("player_season__player__full_name", "player_season__season__name")
@admin.register(FavoritePlayer)
class FavoritePlayerAdmin(admin.ModelAdmin):
list_display = ("user", "player", "created_at")
search_fields = ("user__username", "player__full_name")
@admin.register(PlayerNote)
class PlayerNoteAdmin(admin.ModelAdmin):
list_display = ("user", "player", "created_at", "updated_at")
search_fields = ("user__username", "player__full_name", "body")

6
app/scouting/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class ScoutingConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "scouting"

84
app/scouting/forms.py Normal file
View File

@ -0,0 +1,84 @@
from __future__ import annotations
from datetime import date
from django import forms
from .models import Competition, Role, Season, Specialty, Team
class PlayerSearchForm(forms.Form):
SORT_CHOICES = [
("name_asc", "Name (A-Z)"),
("name_desc", "Name (Z-A)"),
("age_youngest", "Age (youngest first)"),
("height_desc", "Height (tallest first)"),
("weight_desc", "Weight (heaviest first)"),
("points_desc", "Matching context points (high to low)"),
("assists_desc", "Matching context assists (high to low)"),
("ts_pct_desc", "Matching context TS% (high to low)"),
("blocks_desc", "Matching context blocks (high to low)"),
]
name = forms.CharField(required=False, label="Name")
sort = forms.ChoiceField(required=False, choices=SORT_CHOICES, initial="name_asc")
position = forms.ChoiceField(
required=False,
choices=[("", "Any")] + [
("PG", "PG"),
("SG", "SG"),
("SF", "SF"),
("PF", "PF"),
("C", "C"),
],
)
role = forms.ModelChoiceField(required=False, queryset=Role.objects.none())
specialty = forms.ModelChoiceField(required=False, queryset=Specialty.objects.none())
min_age = forms.IntegerField(required=False, min_value=0)
max_age = forms.IntegerField(required=False, min_value=0)
min_height_cm = forms.DecimalField(required=False, max_digits=5, decimal_places=2)
max_height_cm = forms.DecimalField(required=False, max_digits=5, decimal_places=2)
min_weight_kg = forms.DecimalField(required=False, max_digits=5, decimal_places=2)
max_weight_kg = forms.DecimalField(required=False, max_digits=5, decimal_places=2)
min_wingspan_cm = forms.DecimalField(required=False, max_digits=5, decimal_places=2)
max_wingspan_cm = forms.DecimalField(required=False, max_digits=5, decimal_places=2)
competition = forms.ModelChoiceField(required=False, queryset=Competition.objects.none())
season = forms.ModelChoiceField(required=False, queryset=Season.objects.none())
team = forms.ModelChoiceField(required=False, queryset=Team.objects.none())
min_points = forms.DecimalField(required=False, max_digits=6, decimal_places=2)
min_assists = forms.DecimalField(required=False, max_digits=6, decimal_places=2)
min_steals = forms.DecimalField(required=False, max_digits=6, decimal_places=2)
max_turnovers = forms.DecimalField(required=False, max_digits=6, decimal_places=2)
min_blocks = forms.DecimalField(required=False, max_digits=6, decimal_places=2)
min_efg_pct = forms.DecimalField(required=False, max_digits=5, decimal_places=2)
min_ts_pct = forms.DecimalField(required=False, max_digits=5, decimal_places=2)
min_plus_minus = forms.DecimalField(required=False, max_digits=7, decimal_places=2)
min_offensive_rating = forms.DecimalField(required=False, max_digits=7, decimal_places=2)
max_defensive_rating = forms.DecimalField(required=False, max_digits=7, decimal_places=2)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["role"].queryset = Role.objects.order_by("name")
self.fields["specialty"].queryset = Specialty.objects.order_by("name")
self.fields["competition"].queryset = Competition.objects.order_by("name")
self.fields["season"].queryset = Season.objects.order_by("-start_year")
self.fields["team"].queryset = Team.objects.order_by("name")
@staticmethod
def birth_date_upper_bound_for_age(min_age: int) -> date:
today = date.today()
return today.replace(year=today.year - min_age)
@staticmethod
def birth_date_lower_bound_for_age(max_age: int) -> date:
today = date.today()
return today.replace(year=today.year - max_age - 1)
class SavedSearchForm(forms.Form):
saved_search_name = forms.CharField(required=True, max_length=120, label="Save current search as")

View File

@ -0,0 +1 @@
"""Importer modules for source-scoped real-data ingestion flows."""

View File

@ -0,0 +1,330 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from django.db import transaction
from scouting.models import (
Competition,
ExternalEntityMapping,
Player,
PlayerSeason,
PlayerSeasonStats,
Season,
Team,
)
@dataclass
class ImportSummary:
players_created: int = 0
players_updated: int = 0
teams_created: int = 0
teams_updated: int = 0
contexts_created: int = 0
contexts_updated: int = 0
def parse_date(value: str | None) -> date | None:
if not value:
return None
return date.fromisoformat(value)
def parse_decimal(value) -> Decimal | None:
if value in (None, ""):
return None
return Decimal(str(value))
class ImportValidationError(ValueError):
pass
class HoopDataDemoCompetitionImporter:
"""Source-specific MVP importer for one competition snapshot payload."""
EXPECTED_SOURCE_NAME = "hoopdata_demo"
def __init__(self, payload: dict):
self.payload = payload
self.summary = ImportSummary()
@transaction.atomic
def run(self) -> ImportSummary:
self._validate_payload_shape()
source_name = self.payload["source_name"]
competition = self._upsert_competition(source_name, self.payload["competition"])
season = self._upsert_season(self.payload["season"])
for player_record in self.payload["players"]:
team = self._upsert_team(source_name, player_record["team"])
player = self._upsert_player(source_name, player_record)
context = self._upsert_player_season(
source_name=source_name,
competition=competition,
season=season,
team=team,
player=player,
player_record=player_record,
)
self._upsert_player_season_stats(context=context, player_record=player_record)
return self.summary
def _validate_payload_shape(self) -> None:
if self.payload.get("source_name") != self.EXPECTED_SOURCE_NAME:
raise ImportValidationError(
f"Expected source_name='{self.EXPECTED_SOURCE_NAME}', got '{self.payload.get('source_name')}'."
)
required_root_keys = ["competition", "season", "players"]
for key in required_root_keys:
if key not in self.payload:
raise ImportValidationError(f"Missing root key '{key}'.")
if not isinstance(self.payload["players"], list) or not self.payload["players"]:
raise ImportValidationError("Payload must include at least one player record.")
competition = self.payload["competition"]
for field in ["external_id", "name"]:
if not competition.get(field):
raise ImportValidationError(f"Competition requires '{field}'.")
season = self.payload["season"]
for field in ["name", "start_year", "end_year"]:
if season.get(field) in (None, ""):
raise ImportValidationError(f"Season requires '{field}'.")
for index, player_record in enumerate(self.payload["players"], start=1):
for field in ["external_id", "context_external_id", "full_name", "position", "team", "stats"]:
if player_record.get(field) in (None, ""):
raise ImportValidationError(f"Player record #{index} missing '{field}'.")
team_record = player_record["team"]
for field in ["external_id", "name", "country"]:
if team_record.get(field) in (None, ""):
raise ImportValidationError(f"Player record #{index} team missing '{field}'.")
def _mapping_for(self, source_name: str, entity_type: str, external_id: str) -> ExternalEntityMapping | None:
return ExternalEntityMapping.objects.filter(
source_name=source_name,
entity_type=entity_type,
external_id=external_id,
).first()
def _bind_mapping(self, source_name: str, entity_type: str, external_id: str, object_id: int) -> None:
existing_for_external = ExternalEntityMapping.objects.filter(
source_name=source_name,
entity_type=entity_type,
external_id=external_id,
).first()
if existing_for_external and existing_for_external.object_id != object_id:
raise ImportValidationError(
f"External ID '{external_id}' for {entity_type} is already linked to a different record."
)
existing_for_object = ExternalEntityMapping.objects.filter(
source_name=source_name,
entity_type=entity_type,
object_id=object_id,
).first()
if existing_for_object and existing_for_object.external_id != external_id:
raise ImportValidationError(
f"Conflicting mapping for {entity_type} object {object_id}: "
f"'{existing_for_object.external_id}' vs '{external_id}'."
)
ExternalEntityMapping.objects.get_or_create(
source_name=source_name,
entity_type=entity_type,
external_id=external_id,
defaults={"object_id": object_id},
)
def _upsert_competition(self, source_name: str, record: dict) -> Competition:
mapping = self._mapping_for(source_name, ExternalEntityMapping.EntityType.COMPETITION, record["external_id"])
defaults = {
"country": record.get("country", ""),
"level": record.get("level", ""),
}
if mapping:
competition = Competition.objects.filter(pk=mapping.object_id).first()
if competition is None:
raise ImportValidationError("Competition mapping points to a missing record.")
Competition.objects.filter(pk=competition.pk).update(name=record["name"], **defaults)
competition.refresh_from_db()
else:
competition, _ = Competition.objects.get_or_create(name=record["name"], defaults=defaults)
updates = []
for field, value in defaults.items():
if getattr(competition, field) != value:
setattr(competition, field, value)
updates.append(field)
if updates:
competition.save(update_fields=updates + ["updated_at"])
self._bind_mapping(
source_name=source_name,
entity_type=ExternalEntityMapping.EntityType.COMPETITION,
external_id=record["external_id"],
object_id=competition.id,
)
return competition
def _upsert_season(self, record: dict) -> Season:
season, created = Season.objects.get_or_create(
name=record["name"],
defaults={
"start_year": record["start_year"],
"end_year": record["end_year"],
},
)
if not created and (
season.start_year != record["start_year"]
or season.end_year != record["end_year"]
):
raise ImportValidationError(
f"Season '{season.name}' already exists with different years "
f"({season.start_year}-{season.end_year})."
)
return season
def _upsert_team(self, source_name: str, record: dict) -> Team:
mapping = self._mapping_for(source_name, ExternalEntityMapping.EntityType.TEAM, record["external_id"])
if mapping:
team = Team.objects.filter(pk=mapping.object_id).first()
if team is None:
raise ImportValidationError("Team mapping points to a missing record.")
updates = []
for field in ["name", "country"]:
value = record[field]
if getattr(team, field) != value:
setattr(team, field, value)
updates.append(field)
if updates:
team.save(update_fields=updates + ["updated_at"])
self.summary.teams_updated += 1
else:
team, created = Team.objects.get_or_create(name=record["name"], country=record["country"], defaults={})
if created:
self.summary.teams_created += 1
else:
self.summary.teams_updated += 1
self._bind_mapping(
source_name=source_name,
entity_type=ExternalEntityMapping.EntityType.TEAM,
external_id=record["external_id"],
object_id=team.id,
)
return team
def _upsert_player(self, source_name: str, record: dict) -> Player:
mapping = self._mapping_for(source_name, ExternalEntityMapping.EntityType.PLAYER, record["external_id"])
defaults = {
"full_name": record["full_name"],
"first_name": record.get("first_name", ""),
"last_name": record.get("last_name", ""),
"birth_date": parse_date(record.get("birth_date")),
"nationality": record.get("nationality", ""),
"height_cm": parse_decimal(record.get("height_cm")),
"weight_kg": parse_decimal(record.get("weight_kg")),
"wingspan_cm": parse_decimal(record.get("wingspan_cm")),
"position": record["position"],
}
if mapping:
player = Player.objects.filter(pk=mapping.object_id).first()
if player is None:
raise ImportValidationError("Player mapping points to a missing record.")
for field, value in defaults.items():
setattr(player, field, value)
player.save()
self.summary.players_updated += 1
else:
player = Player.objects.create(**defaults)
self.summary.players_created += 1
self._bind_mapping(
source_name=source_name,
entity_type=ExternalEntityMapping.EntityType.PLAYER,
external_id=record["external_id"],
object_id=player.id,
)
return player
def _upsert_player_season(
self,
*,
source_name: str,
competition: Competition,
season: Season,
team: Team,
player: Player,
player_record: dict,
) -> PlayerSeason:
mapping = self._mapping_for(
source_name,
ExternalEntityMapping.EntityType.PLAYER_SEASON,
player_record["context_external_id"],
)
if mapping:
context = PlayerSeason.objects.filter(pk=mapping.object_id).first()
if context is None:
raise ImportValidationError("PlayerSeason mapping points to a missing record.")
if (
context.player_id != player.id
or context.season_id != season.id
or context.team_id != team.id
or context.competition_id != competition.id
):
raise ImportValidationError(
"Mapped player-season context does not match the incoming deterministic context identity."
)
self.summary.contexts_updated += 1
else:
context, created = PlayerSeason.objects.get_or_create(
player=player,
season=season,
team=team,
competition=competition,
defaults={},
)
if created:
self.summary.contexts_created += 1
else:
self.summary.contexts_updated += 1
self._bind_mapping(
source_name=source_name,
entity_type=ExternalEntityMapping.EntityType.PLAYER_SEASON,
external_id=player_record["context_external_id"],
object_id=context.id,
)
return context
def _upsert_player_season_stats(self, *, context: PlayerSeason, player_record: dict) -> None:
stats = player_record["stats"]
PlayerSeasonStats.objects.update_or_create(
player_season=context,
defaults={
"points": parse_decimal(stats.get("points")),
"assists": parse_decimal(stats.get("assists")),
"steals": parse_decimal(stats.get("steals")),
"turnovers": parse_decimal(stats.get("turnovers")),
"blocks": parse_decimal(stats.get("blocks")),
"efg_pct": parse_decimal(stats.get("efg_pct")),
"ts_pct": parse_decimal(stats.get("ts_pct")),
"plus_minus": parse_decimal(stats.get("plus_minus")),
"offensive_rating": parse_decimal(stats.get("offensive_rating")),
"defensive_rating": parse_decimal(stats.get("defensive_rating")),
},
)

View File

@ -0,0 +1,353 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from decimal import Decimal
from pathlib import Path
from urllib.error import URLError
from urllib.parse import urlencode
from urllib.request import urlopen
from django.db import transaction
from scouting.models import (
Competition,
ExternalEntityMapping,
Player,
PlayerSeason,
PlayerSeasonStats,
Season,
Team,
)
class ImportValidationError(ValueError):
pass
LBA_SOURCE_NAME = "lba_public"
LBA_COMPETITION_EXTERNAL_ID = "lba-serie-a"
LBA_COMPETITION_NAME = "Lega Basket Serie A"
LBA_COUNTRY = "IT"
LBA_LEVEL = "top"
LBA_STATS_ENDPOINT = "https://www.legabasket.it/api/statistics/get-players-statistics"
LBA_STAT_CATEGORIES = ["points", "assists", "regain_balls", "lost_balls", "plus_minus", "rating_oer"]
CATEGORY_TO_MODEL_FIELD = {
"points": "points",
"assists": "assists",
"regain_balls": "steals",
"lost_balls": "turnovers",
"plus_minus": "plus_minus",
"rating_oer": "offensive_rating",
}
@dataclass
class ImportSummary:
players_created: int = 0
players_updated: int = 0
teams_created: int = 0
teams_updated: int = 0
contexts_created: int = 0
contexts_updated: int = 0
class LbaPublicStatsSource:
def __init__(self, base_url: str = LBA_STATS_ENDPOINT, timeout_sec: int = 30):
self.base_url = base_url
self.timeout_sec = timeout_sec
def fetch_category(self, season_start_year: int, category: str) -> dict:
query = urlencode({"s": season_start_year, "cat": category})
url = f"{self.base_url}?{query}"
try:
with urlopen(url, timeout=self.timeout_sec) as response:
payload = json.loads(response.read().decode("utf-8"))
except URLError as exc:
raise ImportValidationError(f"Could not fetch LBA source URL '{url}': {exc}") from exc
except json.JSONDecodeError as exc:
raise ImportValidationError(f"Invalid JSON received from '{url}': {exc}") from exc
if not isinstance(payload, dict) or "stats" not in payload:
raise ImportValidationError(f"LBA source response from '{url}' is missing 'stats'.")
return payload
class LbaFixtureStatsSource:
def __init__(self, fixture_path: Path):
self.fixture_path = fixture_path
try:
payload = json.loads(fixture_path.read_text(encoding="utf-8"))
except FileNotFoundError as exc:
raise ImportValidationError(f"Fixture file not found: {fixture_path}") from exc
except json.JSONDecodeError as exc:
raise ImportValidationError(f"Invalid fixture JSON at '{fixture_path}': {exc}") from exc
categories = payload.get("categories")
if not isinstance(categories, dict):
raise ImportValidationError("Fixture payload must include a 'categories' object.")
self.categories = categories
def fetch_category(self, season_start_year: int, category: str) -> dict:
del season_start_year
payload = self.categories.get(category)
if payload is None:
raise ImportValidationError(f"Fixture payload missing category '{category}'.")
if not isinstance(payload, dict) or "stats" not in payload:
raise ImportValidationError(f"Fixture category '{category}' is missing 'stats'.")
return payload
class LbaSerieAPublicImporter:
def __init__(self, *, season_start_year: int, source: LbaPublicStatsSource | LbaFixtureStatsSource):
self.season_start_year = season_start_year
self.source = source
self.summary = ImportSummary()
@transaction.atomic
def run(self) -> ImportSummary:
aggregated = self._collect_players()
competition = self._upsert_competition()
for record in aggregated.values():
season = self._upsert_season(record["year"])
team = self._upsert_team(record)
player = self._upsert_player(record)
context = self._upsert_player_season(record, player, team, season, competition)
self._upsert_stats(context, record)
return self.summary
def _collect_players(self) -> dict:
players = {}
for category in LBA_STAT_CATEGORIES:
payload = self.source.fetch_category(self.season_start_year, category)
stats = payload.get("stats")
if not isinstance(stats, list):
raise ImportValidationError(f"Category '{category}' response must include a list in 'stats'.")
for row in stats:
self._validate_stat_row(category, row)
key = (row["player_id"], row["team_id"], row["year"])
if key not in players:
players[key] = {
"player_id": row["player_id"],
"team_id": row["team_id"],
"year": row["year"],
"name": row["name"],
"surname": row["surname"],
"team_name": row["team_name"],
"scores": {},
}
players[key]["scores"][category] = self._to_decimal(row.get("score"))
if not players:
raise ImportValidationError("No player statistics found from LBA source.")
return players
def _validate_stat_row(self, category: str, row: dict) -> None:
if not isinstance(row, dict):
raise ImportValidationError(f"Category '{category}' contains a non-object stat row.")
for field in ["player_id", "team_id", "year", "name", "surname", "team_name", "score"]:
if row.get(field) in (None, ""):
raise ImportValidationError(f"Category '{category}' row missing required field '{field}'.")
@staticmethod
def _to_decimal(value) -> Decimal:
return Decimal(str(value))
def _mapping_for(self, entity_type: str, external_id: str) -> ExternalEntityMapping | None:
return ExternalEntityMapping.objects.filter(
source_name=LBA_SOURCE_NAME,
entity_type=entity_type,
external_id=external_id,
).first()
def _bind_mapping(self, *, entity_type: str, external_id: str, object_id: int) -> None:
existing_for_external = ExternalEntityMapping.objects.filter(
source_name=LBA_SOURCE_NAME,
entity_type=entity_type,
external_id=external_id,
).first()
if existing_for_external and existing_for_external.object_id != object_id:
raise ImportValidationError(
f"External ID '{external_id}' for {entity_type} is already linked to a different record."
)
existing_for_object = ExternalEntityMapping.objects.filter(
source_name=LBA_SOURCE_NAME,
entity_type=entity_type,
object_id=object_id,
).first()
if existing_for_object and existing_for_object.external_id != external_id:
raise ImportValidationError(
f"Conflicting mapping for {entity_type} object {object_id}: "
f"'{existing_for_object.external_id}' vs '{external_id}'."
)
ExternalEntityMapping.objects.get_or_create(
source_name=LBA_SOURCE_NAME,
entity_type=entity_type,
external_id=external_id,
defaults={"object_id": object_id},
)
def _upsert_competition(self) -> Competition:
mapping = self._mapping_for(ExternalEntityMapping.EntityType.COMPETITION, LBA_COMPETITION_EXTERNAL_ID)
defaults = {"country": LBA_COUNTRY, "level": LBA_LEVEL}
if mapping:
competition = Competition.objects.filter(pk=mapping.object_id).first()
if competition is None:
raise ImportValidationError("Competition mapping points to a missing record.")
Competition.objects.filter(pk=competition.pk).update(name=LBA_COMPETITION_NAME, **defaults)
competition.refresh_from_db()
else:
competition, _ = Competition.objects.get_or_create(name=LBA_COMPETITION_NAME, defaults=defaults)
self._bind_mapping(
entity_type=ExternalEntityMapping.EntityType.COMPETITION,
external_id=LBA_COMPETITION_EXTERNAL_ID,
object_id=competition.id,
)
return competition
def _upsert_season(self, year: int) -> Season:
season_name = f"{year}-{year + 1}"
season, created = Season.objects.get_or_create(
name=season_name,
defaults={"start_year": year, "end_year": year + 1},
)
if not created and (season.start_year != year or season.end_year != year + 1):
raise ImportValidationError(
f"Season '{season_name}' exists but does not match expected years {year}-{year + 1}."
)
return season
def _upsert_team(self, record: dict) -> Team:
external_id = str(record["team_id"])
mapping = self._mapping_for(ExternalEntityMapping.EntityType.TEAM, external_id)
if mapping:
team = Team.objects.filter(pk=mapping.object_id).first()
if team is None:
raise ImportValidationError("Team mapping points to a missing record.")
updates = []
if team.name != record["team_name"]:
team.name = record["team_name"]
updates.append("name")
if team.country != LBA_COUNTRY:
team.country = LBA_COUNTRY
updates.append("country")
if updates:
team.save(update_fields=updates + ["updated_at"])
self.summary.teams_updated += 1
else:
team, created = Team.objects.get_or_create(
name=record["team_name"],
country=LBA_COUNTRY,
defaults={},
)
if created:
self.summary.teams_created += 1
else:
self.summary.teams_updated += 1
self._bind_mapping(
entity_type=ExternalEntityMapping.EntityType.TEAM,
external_id=external_id,
object_id=team.id,
)
return team
def _upsert_player(self, record: dict) -> Player:
external_id = str(record["player_id"])
mapping = self._mapping_for(ExternalEntityMapping.EntityType.PLAYER, external_id)
full_name = f"{record['name']} {record['surname']}".strip()
if mapping:
player = Player.objects.filter(pk=mapping.object_id).first()
if player is None:
raise ImportValidationError("Player mapping points to a missing record.")
player.full_name = full_name
player.first_name = record["name"]
player.last_name = record["surname"]
player.save()
self.summary.players_updated += 1
else:
player = Player.objects.create(
full_name=full_name,
first_name=record["name"],
last_name=record["surname"],
)
self.summary.players_created += 1
self._bind_mapping(
entity_type=ExternalEntityMapping.EntityType.PLAYER,
external_id=external_id,
object_id=player.id,
)
return player
def _upsert_player_season(
self,
record: dict,
player: Player,
team: Team,
season: Season,
competition: Competition,
) -> PlayerSeason:
context_external_id = f"{record['year']}:{record['team_id']}:{record['player_id']}"
mapping = self._mapping_for(ExternalEntityMapping.EntityType.PLAYER_SEASON, context_external_id)
if mapping:
context = PlayerSeason.objects.filter(pk=mapping.object_id).first()
if context is None:
raise ImportValidationError("PlayerSeason mapping points to a missing record.")
if (
context.player_id != player.id
or context.team_id != team.id
or context.season_id != season.id
or context.competition_id != competition.id
):
raise ImportValidationError("Mapped player-season context does not match incoming source identity.")
self.summary.contexts_updated += 1
else:
context, created = PlayerSeason.objects.get_or_create(
player=player,
team=team,
season=season,
competition=competition,
defaults={},
)
if created:
self.summary.contexts_created += 1
else:
self.summary.contexts_updated += 1
self._bind_mapping(
entity_type=ExternalEntityMapping.EntityType.PLAYER_SEASON,
external_id=context_external_id,
object_id=context.id,
)
return context
def _upsert_stats(self, context: PlayerSeason, record: dict) -> None:
stats_defaults = {
"points": None,
"assists": None,
"steals": None,
"turnovers": None,
"blocks": None,
"efg_pct": None,
"ts_pct": None,
"plus_minus": None,
"offensive_rating": None,
"defensive_rating": None,
}
for category, value in record["scores"].items():
model_field = CATEGORY_TO_MODEL_FIELD.get(category)
if model_field:
stats_defaults[model_field] = value
PlayerSeasonStats.objects.update_or_create(player_season=context, defaults=stats_defaults)

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,52 @@
from __future__ import annotations
import json
from pathlib import Path
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from scouting.importers.hoopdata_demo import (
HoopDataDemoCompetitionImporter,
ImportValidationError,
)
class Command(BaseCommand):
help = "Import the first real-data MVP snapshot for the hoopdata_demo source (single competition scope)."
def add_arguments(self, parser):
parser.add_argument(
"--input",
default=str(
Path(settings.BASE_DIR)
/ "scouting"
/ "sample_data"
/ "imports"
/ "hoopdata_demo_serie_a2_2025_2026.json"
),
help="Path to a hoopdata_demo competition snapshot JSON file.",
)
def handle(self, *args, **options):
input_path = Path(options["input"])
if not input_path.exists():
raise CommandError(f"Input file does not exist: {input_path}")
try:
payload = json.loads(input_path.read_text(encoding="utf-8"))
summary = HoopDataDemoCompetitionImporter(payload).run()
except json.JSONDecodeError as exc:
raise CommandError(f"Invalid JSON payload: {exc}") from exc
except ImportValidationError as exc:
raise CommandError(f"Import validation failed: {exc}") from exc
self.stdout.write(
self.style.SUCCESS(
"Imported hoopdata_demo competition snapshot successfully. "
f"Players +{summary.players_created}/~{summary.players_updated}, "
f"Teams +{summary.teams_created}/~{summary.teams_updated}, "
f"Contexts +{summary.contexts_created}/~{summary.contexts_updated}."
)
)
self.stdout.write(f"Source file: {input_path}")

View File

@ -0,0 +1,57 @@
from __future__ import annotations
from pathlib import Path
from django.core.management.base import BaseCommand, CommandError
from scouting.importers.lba_public import (
LBA_SOURCE_NAME,
LbaFixtureStatsSource,
LbaPublicStatsSource,
LbaSerieAPublicImporter,
ImportValidationError,
)
class Command(BaseCommand):
help = "Import public LBA Serie A player statistics for one season using the ADR-0009 baseline flow."
def add_arguments(self, parser):
parser.add_argument(
"--season",
type=int,
default=2025,
help="Season start year to import from LBA public statistics API (default: 2025).",
)
parser.add_argument(
"--fixture",
default="",
help="Optional local fixture JSON path for deterministic/offline runs.",
)
def handle(self, *args, **options):
season_start_year = options["season"]
fixture_path = (options.get("fixture") or "").strip()
try:
if fixture_path:
source = LbaFixtureStatsSource(Path(fixture_path))
source_label = f"fixture={fixture_path}"
else:
source = LbaPublicStatsSource()
source_label = "public=https://www.legabasket.it/api/statistics/get-players-statistics"
summary = LbaSerieAPublicImporter(season_start_year=season_start_year, source=source).run()
except ImportValidationError as exc:
raise CommandError(f"LBA public import failed: {exc}") from exc
self.stdout.write(
self.style.SUCCESS(
"Imported LBA Serie A public statistics successfully. "
f"Source={LBA_SOURCE_NAME}; season={season_start_year}; "
f"players +{summary.players_created}/~{summary.players_updated}, "
f"teams +{summary.teams_created}/~{summary.teams_updated}, "
f"contexts +{summary.contexts_created}/~{summary.contexts_updated}."
)
)
self.stdout.write(f"Input: {source_label}")

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,145 @@
# Generated by Django 5.2.2 on 2026-04-06 17:05
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Competition',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=150, unique=True)),
('country', models.CharField(blank=True, max_length=100)),
('level', models.CharField(blank=True, max_length=100)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Player',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('full_name', models.CharField(max_length=255)),
('first_name', models.CharField(blank=True, max_length=100)),
('last_name', models.CharField(blank=True, max_length=100)),
('birth_date', models.DateField(blank=True, null=True)),
('nationality', models.CharField(blank=True, max_length=100)),
('height_cm', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True)),
('weight_kg', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True)),
('wingspan_cm', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['full_name'],
},
),
migrations.CreateModel(
name='Role',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=120, unique=True)),
('description', models.TextField(blank=True)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Season',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=20, unique=True)),
('start_year', models.PositiveSmallIntegerField()),
('end_year', models.PositiveSmallIntegerField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['-start_year', '-end_year'],
},
),
migrations.CreateModel(
name='Specialty',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=120, unique=True)),
('description', models.TextField(blank=True)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='PlayerSeason',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('position', models.CharField(choices=[('PG', 'PG'), ('SG', 'SG'), ('SF', 'SF'), ('PF', 'PF'), ('C', 'C')], max_length=2)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('competition', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='player_seasons', to='scouting.competition')),
('player', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='player_seasons', to='scouting.player')),
('roles', models.ManyToManyField(blank=True, related_name='player_seasons', to='scouting.role')),
('season', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='player_seasons', to='scouting.season')),
('specialties', models.ManyToManyField(blank=True, related_name='player_seasons', to='scouting.specialty')),
],
options={
'ordering': ['player__full_name', '-season__start_year'],
},
),
migrations.CreateModel(
name='PlayerSeasonStats',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('points', models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True)),
('assists', models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True)),
('steals', models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True)),
('turnovers', models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True)),
('blocks', models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True)),
('efg_pct', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True)),
('ts_pct', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True)),
('plus_minus', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True)),
('offensive_rating', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True)),
('defensive_rating', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('player_season', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='stats', to='scouting.playerseason')),
],
options={
'verbose_name_plural': 'Player season stats',
},
),
migrations.CreateModel(
name='Team',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=150)),
('country', models.CharField(blank=True, max_length=100)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('competition', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='teams', to='scouting.competition')),
],
options={
'ordering': ['name'],
'unique_together': {('name', 'competition')},
},
),
migrations.AddField(
model_name='playerseason',
name='team',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='player_seasons', to='scouting.team'),
),
]

View File

@ -0,0 +1,56 @@
# Generated by Django 5.2.2 on 2026-04-06 17:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('scouting', '0001_initial'),
]
operations = [
migrations.AlterUniqueTogether(
name='team',
unique_together=set(),
),
migrations.RemoveField(
model_name='playerseason',
name='position',
),
migrations.RemoveField(
model_name='playerseason',
name='roles',
),
migrations.RemoveField(
model_name='playerseason',
name='specialties',
),
migrations.AddField(
model_name='player',
name='position',
field=models.CharField(choices=[('PG', 'PG'), ('SG', 'SG'), ('SF', 'SF'), ('PF', 'PF'), ('C', 'C')], default='PG', max_length=2),
),
migrations.AddField(
model_name='player',
name='roles',
field=models.ManyToManyField(blank=True, related_name='players', to='scouting.role'),
),
migrations.AddField(
model_name='player',
name='specialties',
field=models.ManyToManyField(blank=True, related_name='players', to='scouting.specialty'),
),
migrations.AddConstraint(
model_name='playerseason',
constraint=models.UniqueConstraint(fields=('player', 'season'), name='uniq_player_season'),
),
migrations.AddConstraint(
model_name='team',
constraint=models.UniqueConstraint(fields=('name', 'country'), name='uniq_team_name_country'),
),
migrations.RemoveField(
model_name='team',
name='competition',
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.2 on 2026-04-06 17:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('scouting', '0002_alter_team_unique_together_and_more'),
]
operations = [
migrations.AlterField(
model_name='player',
name='position',
field=models.CharField(choices=[('PG', 'PG'), ('SG', 'SG'), ('SF', 'SF'), ('PF', 'PF'), ('C', 'C')], max_length=2),
),
]

View File

@ -0,0 +1,21 @@
# Generated by Django 5.2.2 on 2026-04-06 17:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('scouting', '0003_alter_player_position'),
]
operations = [
migrations.RemoveConstraint(
model_name='playerseason',
name='uniq_player_season',
),
migrations.AddConstraint(
model_name='playerseason',
constraint=models.UniqueConstraint(fields=('player', 'season', 'team', 'competition'), name='uniq_player_season_context', nulls_distinct=False),
),
]

View File

@ -0,0 +1,28 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("scouting", "0004_remove_playerseason_uniq_player_season_and_more"),
]
operations = [
migrations.CreateModel(
name="FavoritePlayer",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"player",
models.OneToOneField(
on_delete=models.CASCADE,
related_name="favorite_entry",
to="scouting.player",
),
),
],
options={
"ordering": ["-created_at", "player__full_name"],
},
),
]

View File

@ -0,0 +1,30 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("scouting", "0005_favoriteplayer"),
]
operations = [
migrations.CreateModel(
name="PlayerNote",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("body", models.TextField()),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"player",
models.ForeignKey(
on_delete=models.CASCADE,
related_name="notes",
to="scouting.player",
),
),
],
options={
"ordering": ["-created_at", "-id"],
},
),
]

View File

@ -0,0 +1,75 @@
from django.conf import settings
from django.db import migrations, models
def clear_shared_shortlist_and_notes(apps, schema_editor):
FavoritePlayer = apps.get_model("scouting", "FavoritePlayer")
PlayerNote = apps.get_model("scouting", "PlayerNote")
FavoritePlayer.objects.all().delete()
PlayerNote.objects.all().delete()
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("scouting", "0006_playernote"),
]
operations = [
migrations.AddField(
model_name="favoriteplayer",
name="user",
field=models.ForeignKey(
null=True,
on_delete=models.CASCADE,
related_name="favorite_players",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="playernote",
name="user",
field=models.ForeignKey(
null=True,
on_delete=models.CASCADE,
related_name="player_notes",
to=settings.AUTH_USER_MODEL,
),
),
migrations.RunPython(clear_shared_shortlist_and_notes, migrations.RunPython.noop),
migrations.RemoveField(
model_name="favoriteplayer",
name="player",
),
migrations.AddField(
model_name="favoriteplayer",
name="player",
field=models.ForeignKey(
on_delete=models.CASCADE,
related_name="favorite_entries",
to="scouting.player",
),
),
migrations.AlterField(
model_name="favoriteplayer",
name="user",
field=models.ForeignKey(
on_delete=models.CASCADE,
related_name="favorite_players",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="playernote",
name="user",
field=models.ForeignKey(
on_delete=models.CASCADE,
related_name="player_notes",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddConstraint(
model_name="favoriteplayer",
constraint=models.UniqueConstraint(fields=("user", "player"), name="uniq_favorite_player_per_user"),
),
]

View File

@ -0,0 +1,31 @@
# Generated by Django 5.2.2 on 2026-04-10 22:04
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('scouting', '0007_user_scoped_favorites_and_notes'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='SavedSearch',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=120)),
('params', models.JSONField(default=dict)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='saved_searches', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['name', '-updated_at', '-id'],
'constraints': [models.UniqueConstraint(fields=('user', 'name'), name='uniq_saved_search_name_per_user')],
},
),
]

View File

@ -0,0 +1,29 @@
# Generated by Django 5.2.2 on 2026-04-10 22:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('scouting', '0008_savedsearch'),
]
operations = [
migrations.CreateModel(
name='ExternalEntityMapping',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('source_name', models.CharField(max_length=80)),
('entity_type', models.CharField(choices=[('player', 'Player'), ('competition', 'Competition'), ('team', 'Team'), ('player_season', 'Player season')], max_length=30)),
('external_id', models.CharField(max_length=140)),
('object_id', models.PositiveBigIntegerField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['source_name', 'entity_type', 'external_id'],
'constraints': [models.UniqueConstraint(fields=('source_name', 'entity_type', 'external_id'), name='uniq_external_entity_mapping'), models.UniqueConstraint(fields=('source_name', 'entity_type', 'object_id'), name='uniq_external_entity_target')],
},
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.2 on 2026-04-10 22:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('scouting', '0009_externalentitymapping'),
]
operations = [
migrations.AlterField(
model_name='player',
name='position',
field=models.CharField(blank=True, choices=[('PG', 'PG'), ('SG', 'SG'), ('SF', 'SF'), ('PF', 'PF'), ('C', 'C')], max_length=2, null=True),
),
]

View File

261
app/scouting/models.py Normal file
View File

@ -0,0 +1,261 @@
from django.conf import settings
from django.db import models
class Role(models.Model):
name = models.CharField(max_length=100, unique=True)
slug = models.SlugField(max_length=120, unique=True)
description = models.TextField(blank=True)
class Meta:
ordering = ["name"]
def __str__(self) -> str:
return self.name
class Specialty(models.Model):
name = models.CharField(max_length=100, unique=True)
slug = models.SlugField(max_length=120, unique=True)
description = models.TextField(blank=True)
class Meta:
ordering = ["name"]
def __str__(self) -> str:
return self.name
class Player(models.Model):
class Position(models.TextChoices):
PG = "PG", "PG"
SG = "SG", "SG"
SF = "SF", "SF"
PF = "PF", "PF"
C = "C", "C"
full_name = models.CharField(max_length=255)
first_name = models.CharField(max_length=100, blank=True)
last_name = models.CharField(max_length=100, blank=True)
birth_date = models.DateField(null=True, blank=True)
nationality = models.CharField(max_length=100, blank=True)
height_cm = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True)
weight_kg = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True)
wingspan_cm = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True)
position = models.CharField(max_length=2, choices=Position.choices, null=True, blank=True)
roles = models.ManyToManyField(Role, blank=True, related_name="players")
specialties = models.ManyToManyField(Specialty, blank=True, related_name="players")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["full_name"]
def __str__(self) -> str:
return self.full_name
class Competition(models.Model):
name = models.CharField(max_length=150, unique=True)
country = models.CharField(max_length=100, blank=True)
level = models.CharField(max_length=100, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["name"]
def __str__(self) -> str:
return self.name
class Team(models.Model):
name = models.CharField(max_length=150)
country = models.CharField(max_length=100, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["name"]
constraints = [
models.UniqueConstraint(fields=["name", "country"], name="uniq_team_name_country"),
]
def __str__(self) -> str:
return self.name
class Season(models.Model):
name = models.CharField(max_length=20, unique=True)
start_year = models.PositiveSmallIntegerField()
end_year = models.PositiveSmallIntegerField()
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-start_year", "-end_year"]
def __str__(self) -> str:
return self.name
class PlayerSeason(models.Model):
player = models.ForeignKey(Player, on_delete=models.CASCADE, related_name="player_seasons")
season = models.ForeignKey(Season, on_delete=models.CASCADE, related_name="player_seasons")
team = models.ForeignKey(
Team,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="player_seasons",
)
competition = models.ForeignKey(
Competition,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="player_seasons",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["player__full_name", "-season__start_year"]
constraints = [
models.UniqueConstraint(
fields=["player", "season", "team", "competition"],
name="uniq_player_season_context",
nulls_distinct=False,
),
]
def __str__(self) -> str:
team_name = self.team.name if self.team else "No team"
competition_name = self.competition.name if self.competition else "No competition"
return f"{self.player.full_name} - {self.season.name} - {team_name} - {competition_name}"
class PlayerSeasonStats(models.Model):
player_season = models.OneToOneField(
PlayerSeason,
on_delete=models.CASCADE,
related_name="stats",
)
points = models.DecimalField(max_digits=6, decimal_places=2, null=True, blank=True)
assists = models.DecimalField(max_digits=6, decimal_places=2, null=True, blank=True)
steals = models.DecimalField(max_digits=6, decimal_places=2, null=True, blank=True)
turnovers = models.DecimalField(max_digits=6, decimal_places=2, null=True, blank=True)
blocks = models.DecimalField(max_digits=6, decimal_places=2, null=True, blank=True)
efg_pct = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True)
ts_pct = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True)
plus_minus = models.DecimalField(max_digits=7, decimal_places=2, null=True, blank=True)
offensive_rating = models.DecimalField(max_digits=7, decimal_places=2, null=True, blank=True)
defensive_rating = models.DecimalField(max_digits=7, decimal_places=2, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name_plural = "Player season stats"
def __str__(self) -> str:
return f"Stats for {self.player_season}"
class FavoritePlayer(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="favorite_players",
)
player = models.ForeignKey(
Player,
on_delete=models.CASCADE,
related_name="favorite_entries",
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-created_at", "player__full_name"]
constraints = [
models.UniqueConstraint(fields=["user", "player"], name="uniq_favorite_player_per_user"),
]
def __str__(self) -> str:
return f"Favorite: {self.user} -> {self.player.full_name}"
class PlayerNote(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="player_notes",
)
player = models.ForeignKey(
Player,
on_delete=models.CASCADE,
related_name="notes",
)
body = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-created_at", "-id"]
def __str__(self) -> str:
return f"Note by {self.user} for {self.player.full_name}"
class SavedSearch(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="saved_searches",
)
name = models.CharField(max_length=120)
params = models.JSONField(default=dict)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["name", "-updated_at", "-id"]
constraints = [
models.UniqueConstraint(fields=["user", "name"], name="uniq_saved_search_name_per_user"),
]
def __str__(self) -> str:
return f"{self.user} - {self.name}"
class ExternalEntityMapping(models.Model):
class EntityType(models.TextChoices):
PLAYER = "player", "Player"
COMPETITION = "competition", "Competition"
TEAM = "team", "Team"
PLAYER_SEASON = "player_season", "Player season"
source_name = models.CharField(max_length=80)
entity_type = models.CharField(max_length=30, choices=EntityType.choices)
external_id = models.CharField(max_length=140)
object_id = models.PositiveBigIntegerField()
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["source_name", "entity_type", "external_id"]
constraints = [
models.UniqueConstraint(
fields=["source_name", "entity_type", "external_id"],
name="uniq_external_entity_mapping",
),
models.UniqueConstraint(
fields=["source_name", "entity_type", "object_id"],
name="uniq_external_entity_target",
),
]
def __str__(self) -> str:
return f"{self.source_name}:{self.entity_type}:{self.external_id} -> {self.object_id}"

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,76 @@
{
"source_name": "hoopdata_demo",
"competition": {
"external_id": "comp-ita2-2025",
"name": "Italian Serie A2",
"country": "IT",
"level": "second"
},
"season": {
"name": "2025-2026",
"start_year": 2025,
"end_year": 2026
},
"players": [
{
"external_id": "player-1001",
"context_external_id": "ctx-1001-2025-ita2-bologna-blaze",
"full_name": "Andrea Pulse",
"first_name": "Andrea",
"last_name": "Pulse",
"birth_date": "2003-03-19",
"nationality": "IT",
"position": "PG",
"height_cm": 186.0,
"weight_kg": 79.0,
"wingspan_cm": 193.0,
"team": {
"external_id": "team-bologna-blaze",
"name": "Bologna Blaze",
"country": "IT"
},
"stats": {
"points": 17.2,
"assists": 6.4,
"steals": 1.7,
"turnovers": 2.5,
"blocks": 0.2,
"efg_pct": 52.1,
"ts_pct": 57.9,
"plus_minus": 3.8,
"offensive_rating": 113.4,
"defensive_rating": 106.9
}
},
{
"external_id": "player-1002",
"context_external_id": "ctx-1002-2025-ita2-venice-harbor",
"full_name": "Matteo Harbor",
"first_name": "Matteo",
"last_name": "Harbor",
"birth_date": "2001-11-02",
"nationality": "IT",
"position": "C",
"height_cm": 211.0,
"weight_kg": 108.0,
"wingspan_cm": 220.0,
"team": {
"external_id": "team-venice-harbor",
"name": "Venice Harbor",
"country": "IT"
},
"stats": {
"points": 12.8,
"assists": 1.6,
"steals": 0.9,
"turnovers": 1.8,
"blocks": 2.1,
"efg_pct": 58.7,
"ts_pct": 61.5,
"plus_minus": 4.1,
"offensive_rating": 111.9,
"defensive_rating": 102.7
}
}
]
}

View File

@ -0,0 +1,148 @@
{
"categories": {
"points": {
"stats": [
{
"category": "points",
"player_id": 6609,
"name": "Muhammad-Ali",
"surname": "Abdur-Rahkman",
"team_name": "NutriBullet Treviso Basket",
"year": 2025,
"score": 19.5,
"team_id": 1642
},
{
"category": "points",
"player_id": 45,
"name": "Nicola",
"surname": "Akele",
"team_name": "Virtus Olidata Bologna",
"year": 2025,
"score": 11.2,
"team_id": 1715
}
]
},
"assists": {
"stats": [
{
"category": "assists",
"player_id": 6609,
"name": "Muhammad-Ali",
"surname": "Abdur-Rahkman",
"team_name": "NutriBullet Treviso Basket",
"year": 2025,
"score": 4.4,
"team_id": 1642
},
{
"category": "assists",
"player_id": 45,
"name": "Nicola",
"surname": "Akele",
"team_name": "Virtus Olidata Bologna",
"year": 2025,
"score": 2.1,
"team_id": 1715
}
]
},
"regain_balls": {
"stats": [
{
"category": "regain_balls",
"player_id": 6609,
"name": "Muhammad-Ali",
"surname": "Abdur-Rahkman",
"team_name": "NutriBullet Treviso Basket",
"year": 2025,
"score": 1.3,
"team_id": 1642
},
{
"category": "regain_balls",
"player_id": 45,
"name": "Nicola",
"surname": "Akele",
"team_name": "Virtus Olidata Bologna",
"year": 2025,
"score": 0.8,
"team_id": 1715
}
]
},
"lost_balls": {
"stats": [
{
"category": "lost_balls",
"player_id": 6609,
"name": "Muhammad-Ali",
"surname": "Abdur-Rahkman",
"team_name": "NutriBullet Treviso Basket",
"year": 2025,
"score": 2.2,
"team_id": 1642
},
{
"category": "lost_balls",
"player_id": 45,
"name": "Nicola",
"surname": "Akele",
"team_name": "Virtus Olidata Bologna",
"year": 2025,
"score": 1.0,
"team_id": 1715
}
]
},
"plus_minus": {
"stats": [
{
"category": "plus_minus",
"player_id": 6609,
"name": "Muhammad-Ali",
"surname": "Abdur-Rahkman",
"team_name": "NutriBullet Treviso Basket",
"year": 2025,
"score": 3.1,
"team_id": 1642
},
{
"category": "plus_minus",
"player_id": 45,
"name": "Nicola",
"surname": "Akele",
"team_name": "Virtus Olidata Bologna",
"year": 2025,
"score": 1.5,
"team_id": 1715
}
]
},
"rating_oer": {
"stats": [
{
"category": "rating_oer",
"player_id": 6609,
"name": "Muhammad-Ali",
"surname": "Abdur-Rahkman",
"team_name": "NutriBullet Treviso Basket",
"year": 2025,
"score": 111.4,
"team_id": 1642
},
{
"category": "rating_oer",
"player_id": 45,
"name": "Nicola",
"surname": "Akele",
"team_name": "Virtus Olidata Bologna",
"year": 2025,
"score": 106.7,
"team_id": 1715
}
]
}
}
}

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

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Log In</title>
</head>
<body>
<p><a href="{% url 'scouting:player_list' %}">Back to search</a></p>
<h1>Log In</h1>
{% if form.errors %}
<p>Your username and password did not match. Please try again.</p>
{% endif %}
<form method="post">
{% csrf_token %}
{{ form.as_p }}
{% if next %}
<input type="hidden" name="next" value="{{ next }}">
{% endif %}
<button type="submit">Log in</button>
</form>
</body>
</html>

View File

@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Shortlist</title>
</head>
<body>
<p>
<a href="{% url 'scouting:player_list' %}">Back to search</a>
| Signed in as {{ request.user.username }}
| <form method="post" action="{% url 'logout' %}" style="display:inline;">
{% csrf_token %}
<button type="submit">Log out</button>
</form>
</p>
<h1>Your Shortlist</h1>
<p>This page shows favorites saved only for your account.</p>
<ul>
{% for entry in favorites %}
<li>
<a href="{% url 'scouting:player_detail' entry.player.id %}">{{ entry.player.full_name }}</a>
({{ entry.player.position }})
| Notes: {{ entry.note_count }}
<form method="post" action="{% url 'scouting:remove_favorite' entry.player.id %}">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button type="submit">Remove from shortlist</button>
</form>
</li>
{% empty %}
<li>No shortlisted players yet.</li>
{% endfor %}
</ul>
</body>
</html>

View File

@ -0,0 +1,130 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{ player.full_name }}</title>
</head>
<body>
<p>
<a href="{% url 'scouting:player_list' %}">Back to search</a>
{% if request.user.is_authenticated %}
| <a href="{% url 'scouting:favorites_list' %}">View shortlist</a>
| Signed in as {{ request.user.username }}
| <form method="post" action="{% url 'logout' %}" style="display:inline;">
{% csrf_token %}
<button type="submit">Log out</button>
</form>
{% else %}
| <a href="{% url 'login' %}?next={{ request.get_full_path|urlencode }}">Log in</a>
{% endif %}
</p>
<h1>{{ player.full_name }}</h1>
{% if request.user.is_authenticated %}
{% if is_favorite %}
<p><strong>On your shortlist.</strong></p>
<form method="post" action="{% url 'scouting:remove_favorite' player.id %}">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button type="submit">Remove from shortlist</button>
</form>
{% else %}
<form method="post" action="{% url 'scouting:add_favorite' player.id %}">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button type="submit">Add to shortlist</button>
</form>
{% endif %}
{% else %}
<p><a href="{% url 'login' %}?next={{ request.get_full_path|urlencode }}">Log in to manage your shortlist</a></p>
{% endif %}
<p>Position: {{ player.position }}</p>
<p>Nationality: {{ player.nationality|default:"-" }}</p>
<p>Birth date: {{ player.birth_date|default:"-" }}</p>
<p>Height (cm): {{ player.height_cm|default:"-" }}</p>
<p>Weight (kg): {{ player.weight_kg|default:"-" }}</p>
<p>Wingspan (cm): {{ player.wingspan_cm|default:"-" }}</p>
<p>
Roles:
{% for role in player.roles.all %}
{{ role.name }}{% if not forloop.last %}, {% endif %}
{% empty %}
-
{% endfor %}
</p>
<p>
Specialties:
{% for specialty in player.specialties.all %}
{{ specialty.name }}{% if not forloop.last %}, {% endif %}
{% empty %}
-
{% endfor %}
</p>
<h2>Scouting Notes</h2>
{% if request.user.is_authenticated %}
<p>These notes are visible only to your account.</p>
<form method="post" action="{% url 'scouting:add_note' player.id %}">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<label for="id_body">Add note</label>
<textarea id="id_body" name="body" rows="4" cols="60"></textarea>
<button type="submit">Save note</button>
</form>
{% else %}
<p><a href="{% url 'login' %}?next={{ request.get_full_path|urlencode }}">Log in to add personal notes</a></p>
{% endif %}
<ul>
{% for note in notes %}
<li>
<div>{{ note.body|linebreaksbr }}</div>
<small>Created: {{ note.created_at }}</small>
{% if request.user.is_authenticated %}
<form method="post" action="{% url 'scouting:delete_note' player.id note.id %}">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button type="submit">Delete note</button>
</form>
{% endif %}
</li>
{% empty %}
<li>No notes yet.</li>
{% endfor %}
</ul>
<h2>Season Contexts</h2>
<ul>
{% for context in contexts %}
<li>
<strong>{{ context.season.name }}</strong>
| Team: {{ context.team.name|default:"-" }}
| Competition: {{ context.competition.name|default:"-" }}
{% if context.stats %}
<div>
PTS {{ context.stats.points|default:"-" }} |
AST {{ context.stats.assists|default:"-" }} |
STL {{ context.stats.steals|default:"-" }} |
TOV {{ context.stats.turnovers|default:"-" }} |
BLK {{ context.stats.blocks|default:"-" }}
</div>
<div>
eFG% {{ context.stats.efg_pct|default:"-" }} |
TS% {{ context.stats.ts_pct|default:"-" }} |
+/- {{ context.stats.plus_minus|default:"-" }} |
ORtg {{ context.stats.offensive_rating|default:"-" }} |
DRtg {{ context.stats.defensive_rating|default:"-" }}
</div>
{% else %}
<div>No stats available for this context.</div>
{% endif %}
</li>
{% empty %}
<li>No season contexts found.</li>
{% endfor %}
</ul>
</body>
</html>

View File

@ -0,0 +1,185 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Player Search</title>
</head>
<body>
<h1>Scout Search</h1>
<p>
{% if request.user.is_authenticated %}
Signed in as {{ request.user.username }} |
<a href="{% url 'scouting:favorites_list' %}">View shortlist</a> |
<form method="post" action="{% url 'logout' %}" style="display:inline;">
{% csrf_token %}
<button type="submit">Log out</button>
</form>
{% else %}
<a href="{% url 'login' %}?next={{ request.get_full_path|urlencode }}">Log in</a>
{% endif %}
</p>
{% if request.user.is_authenticated %}
<section>
<h2>Saved Searches</h2>
<form method="post" action="{% url 'scouting:save_search' %}">
{% csrf_token %}
{{ saved_search_form.saved_search_name.label_tag }} {{ saved_search_form.saved_search_name }}
{% for key, value in current_search_params.items %}
<input type="hidden" name="{{ key }}" value="{{ value }}">
{% endfor %}
<button type="submit">Save current search</button>
</form>
<ul>
{% for saved_search in saved_searches %}
<li>
<a href="{% url 'scouting:player_list' %}{% if saved_search.querystring %}?{{ saved_search.querystring }}{% endif %}">{{ saved_search.name }}</a>
{% if saved_search.is_active %}
<strong>(active)</strong>
{% endif %}
<form method="post" action="{% url 'scouting:delete_saved_search' saved_search.id %}" style="display:inline;">
{% csrf_token %}
{% for key, value in current_search_params.items %}
<input type="hidden" name="{{ key }}" value="{{ value }}">
{% endfor %}
<button type="submit">Delete</button>
</form>
</li>
{% empty %}
<li>No saved searches yet.</li>
{% endfor %}
</ul>
</section>
{% else %}
<p><a href="{% url 'login' %}?next={{ request.get_full_path|urlencode }}">Log in to save searches</a></p>
{% endif %}
<form method="get">
<fieldset>
<legend>Result Controls</legend>
{{ form.sort.label_tag }} {{ form.sort }}
<p>
Context stat sorts use the matching season context selected by the current
season/team/competition/stat filters.
</p>
{% if not context_sorting_enabled %}
<p>Context stat sorting becomes active once context or stat filters are applied.</p>
{% endif %}
</fieldset>
<fieldset>
<legend>Player Filters</legend>
{{ form.name.label_tag }} {{ form.name }}
{{ form.min_age.label_tag }} {{ form.min_age }}
{{ form.max_age.label_tag }} {{ form.max_age }}
{{ form.min_height_cm.label_tag }} {{ form.min_height_cm }}
{{ form.max_height_cm.label_tag }} {{ form.max_height_cm }}
{{ form.min_weight_kg.label_tag }} {{ form.min_weight_kg }}
{{ form.max_weight_kg.label_tag }} {{ form.max_weight_kg }}
{{ form.min_wingspan_cm.label_tag }} {{ form.min_wingspan_cm }}
{{ form.max_wingspan_cm.label_tag }} {{ form.max_wingspan_cm }}
{{ form.position.label_tag }} {{ form.position }}
{{ form.role.label_tag }} {{ form.role }}
{{ form.specialty.label_tag }} {{ form.specialty }}
</fieldset>
<fieldset>
<legend>Context Filters</legend>
{{ form.competition.label_tag }} {{ form.competition }}
{{ form.season.label_tag }} {{ form.season }}
{{ form.team.label_tag }} {{ form.team }}
</fieldset>
<fieldset>
<legend>Stats Filters</legend>
{{ form.min_points.label_tag }} {{ form.min_points }}
{{ form.min_assists.label_tag }} {{ form.min_assists }}
{{ form.min_steals.label_tag }} {{ form.min_steals }}
{{ form.max_turnovers.label_tag }} {{ form.max_turnovers }}
{{ form.min_blocks.label_tag }} {{ form.min_blocks }}
{{ form.min_efg_pct.label_tag }} {{ form.min_efg_pct }}
{{ form.min_ts_pct.label_tag }} {{ form.min_ts_pct }}
{{ form.min_plus_minus.label_tag }} {{ form.min_plus_minus }}
{{ form.min_offensive_rating.label_tag }} {{ form.min_offensive_rating }}
{{ form.max_defensive_rating.label_tag }} {{ form.max_defensive_rating }}
</fieldset>
<button type="submit">Search</button>
<a href="{% url 'scouting:player_list' %}">Clear filters</a>
</form>
<h2>Results ({{ total_results }})</h2>
<p>Sort: {{ form.sort.value|default:"name_asc" }} | Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</p>
{% if active_filters %}
<p>Active filters:</p>
<ul>
{% for filter in active_filters %}
<li>{{ filter.label }}: {{ filter.value }}</li>
{% endfor %}
</ul>
{% endif %}
<ul>
{% for player in players %}
<li>
<a href="{% url 'scouting:player_detail' player.id %}">{{ player.full_name }}</a>
({{ player.position }})
{% if request.user.is_authenticated %}
{% if player.is_favorite %}
<strong>Shortlisted</strong>
<form method="post" action="{% url 'scouting:remove_favorite' player.id %}">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button type="submit">Remove from shortlist</button>
</form>
{% else %}
<form method="post" action="{% url 'scouting:add_favorite' player.id %}">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button type="submit">Add to shortlist</button>
</form>
{% endif %}
{% else %}
<a href="{% url 'login' %}?next={{ request.get_full_path|urlencode }}">Log in to shortlist</a>
{% endif %}
{% if player.matching_context %}
<div>
Match context:
{{ player.matching_context.season.name }}
| Team: {{ player.matching_context.team.name|default:"-" }}
| Competition: {{ player.matching_context.competition.name|default:"-" }}
</div>
{% if player.matching_context.stats %}
<div>
PTS {{ player.matching_context.stats.points|default:"-" }} |
AST {{ player.matching_context.stats.assists|default:"-" }} |
STL {{ player.matching_context.stats.steals|default:"-" }} |
TOV {{ player.matching_context.stats.turnovers|default:"-" }} |
BLK {{ player.matching_context.stats.blocks|default:"-" }}
</div>
{% endif %}
{% endif %}
</li>
{% empty %}
{% if has_submitted_search %}
<li>No players found for the current search filters.</li>
{% else %}
<li>No players available yet.</li>
{% endif %}
{% endfor %}
</ul>
{% if page_obj.paginator.num_pages > 1 %}
<nav aria-label="Pagination">
{% if page_obj.has_previous %}
<a href="?{% if query_without_page %}{{ query_without_page }}&amp;{% endif %}page={{ page_obj.previous_page_number }}">Previous</a>
{% endif %}
<span>Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span>
{% if page_obj.has_next %}
<a href="?{% if query_without_page %}{{ query_without_page }}&amp;{% endif %}page={{ page_obj.next_page_number }}">Next</a>
{% endif %}
</nav>
{% endif %}
</body>
</html>

1028
app/scouting/tests.py Normal file

File diff suppressed because it is too large Load Diff

17
app/scouting/urls.py Normal file
View File

@ -0,0 +1,17 @@
from django.urls import path
from . import views
app_name = "scouting"
urlpatterns = [
path("players/", views.player_list, name="player_list"),
path("players/saved-searches/save/", views.save_search, name="save_search"),
path("players/saved-searches/<int:saved_search_id>/delete/", views.delete_saved_search, name="delete_saved_search"),
path("players/<int:player_id>/", views.player_detail, name="player_detail"),
path("players/<int:player_id>/favorite/", views.add_favorite, name="add_favorite"),
path("players/<int:player_id>/unfavorite/", views.remove_favorite, name="remove_favorite"),
path("players/<int:player_id>/notes/add/", views.add_note, name="add_note"),
path("players/<int:player_id>/notes/<int:note_id>/delete/", views.delete_note, name="delete_note"),
path("favorites/", views.favorites_list, name="favorites_list"),
]

512
app/scouting/views.py Normal file
View File

@ -0,0 +1,512 @@
from __future__ import annotations
from decimal import Decimal
from urllib.parse import urlencode
from django.core.paginator import Paginator
from django.contrib.auth.decorators import login_required
from django.db.models import Count, Exists, OuterRef, Prefetch, Q
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, render
from django.urls import reverse
from django.views.decorators.http import require_POST
from .forms import PlayerSearchForm, SavedSearchForm
from .models import FavoritePlayer, Player, PlayerNote, PlayerSeason, SavedSearch
PAGE_SIZE = 20
PLAYER_SORTS = {
"name_asc",
"name_desc",
"age_youngest",
"height_desc",
"weight_desc",
}
CONTEXT_SORTS = {
"points_desc": "points",
"assists_desc": "assists",
"ts_pct_desc": "ts_pct",
"blocks_desc": "blocks",
}
SEARCH_PARAM_FIELDS = [
"name",
"sort",
"position",
"role",
"specialty",
"min_age",
"max_age",
"min_height_cm",
"max_height_cm",
"min_weight_kg",
"max_weight_kg",
"min_wingspan_cm",
"max_wingspan_cm",
"competition",
"season",
"team",
"min_points",
"min_assists",
"min_steals",
"max_turnovers",
"min_blocks",
"min_efg_pct",
"min_ts_pct",
"min_plus_minus",
"min_offensive_rating",
"max_defensive_rating",
]
FILTER_LABELS = {
"name": "Name",
"position": "Position",
"role": "Role",
"specialty": "Specialty",
"min_age": "Min age",
"max_age": "Max age",
"min_height_cm": "Min height (cm)",
"max_height_cm": "Max height (cm)",
"min_weight_kg": "Min weight (kg)",
"max_weight_kg": "Max weight (kg)",
"min_wingspan_cm": "Min wingspan (cm)",
"max_wingspan_cm": "Max wingspan (cm)",
"competition": "Competition",
"season": "Season",
"team": "Team",
"min_points": "Min points",
"min_assists": "Min assists",
"min_steals": "Min steals",
"max_turnovers": "Max turnovers",
"min_blocks": "Min blocks",
"min_efg_pct": "Min eFG%",
"min_ts_pct": "Min TS%",
"min_plus_minus": "Min +/-",
"min_offensive_rating": "Min ORtg",
"max_defensive_rating": "Max DRtg",
}
def serialize_search_params(cleaned_data: dict) -> dict[str, str]:
params = {}
for field_name in SEARCH_PARAM_FIELDS:
value = cleaned_data.get(field_name)
if value in (None, ""):
continue
if field_name == "sort" and value == "name_asc":
continue
params[field_name] = str(getattr(value, "pk", value))
return params
def read_search_params_from_payload(payload) -> dict[str, str]:
params = {}
for field_name in SEARCH_PARAM_FIELDS:
value = payload.get(field_name)
if value in (None, ""):
continue
params[field_name] = str(value)
return params
def build_active_filters(form: PlayerSearchForm, cleaned_data: dict) -> list[dict[str, str]]:
active_filters = []
for field_name, label in FILTER_LABELS.items():
value = cleaned_data.get(field_name)
if value in (None, ""):
continue
field = form.fields[field_name]
if getattr(field, "choices", None):
value_label = str(dict(field.choices).get(str(value), value))
elif hasattr(value, "name"):
value_label = value.name
else:
value_label = str(value)
active_filters.append({"label": label, "value": value_label})
return active_filters
def apply_favorite_state(players, user):
if not user.is_authenticated:
favorite_ids = set()
else:
favorite_ids = set(FavoritePlayer.objects.filter(user=user).values_list("player_id", flat=True))
for player in players:
player.is_favorite = player.id in favorite_ids
def redirect_to_next(request, fallback_url):
next_url = request.POST.get("next")
if next_url and next_url.startswith("/"):
return HttpResponseRedirect(next_url)
return HttpResponseRedirect(fallback_url)
def sort_players(players, sort_key: str, context_filters_used: bool):
if sort_key not in PLAYER_SORTS | set(CONTEXT_SORTS):
sort_key = "name_asc"
if sort_key == "name_asc":
players.sort(key=lambda player: player.full_name.casefold())
return sort_key
if sort_key == "name_desc":
players.sort(key=lambda player: player.full_name.casefold(), reverse=True)
return sort_key
if sort_key == "age_youngest":
players.sort(
key=lambda player: (
player.birth_date is None,
-(player.birth_date.toordinal()) if player.birth_date else 0,
player.full_name.casefold(),
)
)
return sort_key
if sort_key == "height_desc":
players.sort(
key=lambda player: (
player.height_cm is None,
-(player.height_cm or Decimal("0")),
player.full_name.casefold(),
)
)
return sort_key
if sort_key == "weight_desc":
players.sort(
key=lambda player: (
player.weight_kg is None,
-(player.weight_kg or Decimal("0")),
player.full_name.casefold(),
)
)
return sort_key
if not context_filters_used:
players.sort(key=lambda player: player.full_name.casefold())
return "name_asc"
stat_name = CONTEXT_SORTS[sort_key]
players.sort(
key=lambda player: (
getattr(getattr(getattr(player, "matching_context", None), "stats", None), stat_name, None) is None,
-(
getattr(getattr(getattr(player, "matching_context", None), "stats", None), stat_name)
or Decimal("0")
),
player.full_name.casefold(),
)
)
return sort_key
def player_list(request):
form = PlayerSearchForm(request.GET or None)
saved_search_form = SavedSearchForm()
queryset = (
Player.objects.all()
.prefetch_related("roles", "specialties")
.order_by("full_name")
)
context_filters_used = False
requested_sort = request.GET.get("sort") or "name_asc"
current_search_params = read_search_params_from_payload(request.GET)
active_filters = []
if form.is_valid():
data = form.cleaned_data
requested_sort = data["sort"] or "name_asc"
current_search_params = serialize_search_params(data)
active_filters = build_active_filters(form, data)
if data["name"]:
queryset = queryset.filter(full_name__icontains=data["name"])
if data["position"]:
queryset = queryset.filter(position=data["position"])
if data["role"]:
queryset = queryset.filter(roles=data["role"])
if data["specialty"]:
queryset = queryset.filter(specialties=data["specialty"])
if data["min_height_cm"] is not None:
queryset = queryset.filter(height_cm__gte=data["min_height_cm"])
if data["max_height_cm"] is not None:
queryset = queryset.filter(height_cm__lte=data["max_height_cm"])
if data["min_weight_kg"] is not None:
queryset = queryset.filter(weight_kg__gte=data["min_weight_kg"])
if data["max_weight_kg"] is not None:
queryset = queryset.filter(weight_kg__lte=data["max_weight_kg"])
if data["min_wingspan_cm"] is not None:
queryset = queryset.filter(wingspan_cm__gte=data["min_wingspan_cm"])
if data["max_wingspan_cm"] is not None:
queryset = queryset.filter(wingspan_cm__lte=data["max_wingspan_cm"])
if data["min_age"] is not None:
cutoff = form.birth_date_upper_bound_for_age(data["min_age"])
queryset = queryset.filter(birth_date__lte=cutoff)
if data["max_age"] is not None:
cutoff = form.birth_date_lower_bound_for_age(data["max_age"])
queryset = queryset.filter(birth_date__gte=cutoff)
context_filters_used = any(
data[field] is not None and data[field] != ""
for field in [
"competition",
"season",
"team",
"min_points",
"min_assists",
"min_steals",
"max_turnovers",
"min_blocks",
"min_efg_pct",
"min_ts_pct",
"min_plus_minus",
"min_offensive_rating",
"max_defensive_rating",
]
)
if context_filters_used:
context_qs = PlayerSeason.objects.filter(player=OuterRef("pk"))
matching_contexts = PlayerSeason.objects.all()
if data["competition"]:
context_qs = context_qs.filter(competition=data["competition"])
matching_contexts = matching_contexts.filter(competition=data["competition"])
if data["season"]:
context_qs = context_qs.filter(season=data["season"])
matching_contexts = matching_contexts.filter(season=data["season"])
if data["team"]:
context_qs = context_qs.filter(team=data["team"])
matching_contexts = matching_contexts.filter(team=data["team"])
stats_filters_used = any(
data[field] is not None
for field in [
"min_points",
"min_assists",
"min_steals",
"max_turnovers",
"min_blocks",
"min_efg_pct",
"min_ts_pct",
"min_plus_minus",
"min_offensive_rating",
"max_defensive_rating",
]
)
if stats_filters_used:
context_qs = context_qs.filter(stats__isnull=False)
matching_contexts = matching_contexts.filter(stats__isnull=False)
if data["min_points"] is not None:
context_qs = context_qs.filter(stats__points__gte=data["min_points"])
matching_contexts = matching_contexts.filter(stats__points__gte=data["min_points"])
if data["min_assists"] is not None:
context_qs = context_qs.filter(stats__assists__gte=data["min_assists"])
matching_contexts = matching_contexts.filter(stats__assists__gte=data["min_assists"])
if data["min_steals"] is not None:
context_qs = context_qs.filter(stats__steals__gte=data["min_steals"])
matching_contexts = matching_contexts.filter(stats__steals__gte=data["min_steals"])
if data["max_turnovers"] is not None:
context_qs = context_qs.filter(stats__turnovers__lte=data["max_turnovers"])
matching_contexts = matching_contexts.filter(stats__turnovers__lte=data["max_turnovers"])
if data["min_blocks"] is not None:
context_qs = context_qs.filter(stats__blocks__gte=data["min_blocks"])
matching_contexts = matching_contexts.filter(stats__blocks__gte=data["min_blocks"])
if data["min_efg_pct"] is not None:
context_qs = context_qs.filter(stats__efg_pct__gte=data["min_efg_pct"])
matching_contexts = matching_contexts.filter(stats__efg_pct__gte=data["min_efg_pct"])
if data["min_ts_pct"] is not None:
context_qs = context_qs.filter(stats__ts_pct__gte=data["min_ts_pct"])
matching_contexts = matching_contexts.filter(stats__ts_pct__gte=data["min_ts_pct"])
if data["min_plus_minus"] is not None:
context_qs = context_qs.filter(stats__plus_minus__gte=data["min_plus_minus"])
matching_contexts = matching_contexts.filter(stats__plus_minus__gte=data["min_plus_minus"])
if data["min_offensive_rating"] is not None:
context_qs = context_qs.filter(stats__offensive_rating__gte=data["min_offensive_rating"])
matching_contexts = matching_contexts.filter(
stats__offensive_rating__gte=data["min_offensive_rating"]
)
if data["max_defensive_rating"] is not None:
context_qs = context_qs.filter(stats__defensive_rating__lte=data["max_defensive_rating"])
matching_contexts = matching_contexts.filter(
stats__defensive_rating__lte=data["max_defensive_rating"]
)
queryset = queryset.annotate(has_matching_context=Exists(context_qs)).filter(has_matching_context=True)
# Reuse the same filtered PlayerSeason scope and take the first ordered row
# so the displayed context is deterministic and tied to the actual match.
matching_contexts = (
matching_contexts.select_related("season", "team", "competition", "stats")
.order_by("-season__start_year", "team__name", "competition__name", "pk")
)
queryset = queryset.prefetch_related(
Prefetch(
"player_seasons",
queryset=matching_contexts,
to_attr="matching_contexts",
)
)
queryset = queryset.distinct()
players = list(queryset)
if context_filters_used:
for player in players:
player.matching_context = next(iter(player.matching_contexts), None)
active_sort = sort_players(players, requested_sort, context_filters_used)
paginator = Paginator(players, PAGE_SIZE)
page_obj = paginator.get_page(request.GET.get("page"))
apply_favorite_state(page_obj.object_list, request.user)
saved_searches = []
if request.user.is_authenticated:
for saved_search in SavedSearch.objects.filter(user=request.user):
querystring = urlencode(saved_search.params)
saved_searches.append(
{
"id": saved_search.id,
"name": saved_search.name,
"querystring": querystring,
"is_active": saved_search.params == current_search_params,
}
)
return render(
request,
"scouting/player_list.html",
{
"form": form,
"players": page_obj.object_list,
"page_obj": page_obj,
"active_sort": active_sort,
"total_results": paginator.count,
"query_without_page": urlencode(current_search_params),
"current_search_params": current_search_params,
"has_submitted_search": bool(current_search_params),
"active_filters": active_filters,
"context_sorting_enabled": context_filters_used,
"saved_search_form": saved_search_form,
"saved_searches": saved_searches,
},
)
def player_detail(request, player_id: int):
player = get_object_or_404(
Player.objects.prefetch_related("roles", "specialties"),
pk=player_id,
)
contexts = (
PlayerSeason.objects.filter(player=player)
.select_related("season", "team", "competition", "stats")
.order_by("-season__start_year", "team__name", "competition__name")
)
if request.user.is_authenticated:
notes = player.notes.filter(user=request.user)
is_favorite = FavoritePlayer.objects.filter(user=request.user, player=player).exists()
else:
notes = PlayerNote.objects.none()
is_favorite = False
return render(
request,
"scouting/player_detail.html",
{
"player": player,
"contexts": contexts,
"notes": notes,
"is_favorite": is_favorite,
},
)
@login_required
@require_POST
def add_favorite(request, player_id: int):
player = get_object_or_404(Player, pk=player_id)
FavoritePlayer.objects.get_or_create(user=request.user, player=player)
return redirect_to_next(request, reverse("scouting:player_detail", args=[player.id]))
@login_required
@require_POST
def remove_favorite(request, player_id: int):
player = get_object_or_404(Player, pk=player_id)
FavoritePlayer.objects.filter(user=request.user, player=player).delete()
return redirect_to_next(request, reverse("scouting:player_detail", args=[player.id]))
@login_required
@require_POST
def add_note(request, player_id: int):
player = get_object_or_404(Player, pk=player_id)
body = (request.POST.get("body") or "").strip()
if body:
PlayerNote.objects.create(user=request.user, player=player, body=body)
return redirect_to_next(request, reverse("scouting:player_detail", args=[player.id]))
@login_required
@require_POST
def delete_note(request, player_id: int, note_id: int):
player = get_object_or_404(Player, pk=player_id)
PlayerNote.objects.filter(user=request.user, player=player, pk=note_id).delete()
return redirect_to_next(request, reverse("scouting:player_detail", args=[player.id]))
@login_required
def favorites_list(request):
favorites = list(
FavoritePlayer.objects.filter(user=request.user)
.select_related("player")
.prefetch_related("player__roles", "player__specialties")
.annotate(note_count=Count("player__notes", filter=Q(player__notes__user=request.user)))
.order_by("-created_at", "player__full_name")
)
for entry in favorites:
entry.player.is_favorite = True
return render(
request,
"scouting/favorites_list.html",
{
"favorites": favorites,
},
)
@login_required
@require_POST
def save_search(request):
name_form = SavedSearchForm(request.POST)
player_search_form = PlayerSearchForm(request.POST)
if not name_form.is_valid():
return HttpResponseRedirect(reverse("scouting:player_list"))
if player_search_form.is_valid():
params = serialize_search_params(player_search_form.cleaned_data)
else:
params = read_search_params_from_payload(request.POST)
SavedSearch.objects.update_or_create(
user=request.user,
name=name_form.cleaned_data["saved_search_name"].strip(),
defaults={"params": params},
)
target = reverse("scouting:player_list")
if not params:
return HttpResponseRedirect(target)
return HttpResponseRedirect(f"{target}?{urlencode(params)}")
@login_required
@require_POST
def delete_saved_search(request, saved_search_id: int):
SavedSearch.objects.filter(user=request.user, pk=saved_search_id).delete()
query = read_search_params_from_payload(request.POST)
target = reverse("scouting:player_list")
if not query:
return HttpResponseRedirect(target)
return HttpResponseRedirect(f"{target}?{urlencode(query)}")

60
docs/ARCHITECTURE.md Normal file
View File

@ -0,0 +1,60 @@
# Architecture
## Purpose
This document will become the central architecture overview for HoopScout v2. It should summarize the current approved technical direction and provide a stable starting point for future implementation work.
## Current Status
This architecture overview summarizes the accepted technical and domain-decision baseline for implementation. Future model, filter, and UI work should follow the current ADR set unless a later ADR supersedes it.
## Decision-Driven Development
Architecture in this repository should be decision-driven. Major technical choices should be documented before implementation begins, and implementation should follow documented decisions rather than informal local reasoning.
## Relationship to Architecture Principles
This document should stay consistent with `docs/ARCHITECTURE_PRINCIPLES.md`. Future architecture sections should reflect the guiding principles and constraints defined there before they commit the repository to more concrete technical choices.
## Relationship to ADRs
This document should stay aligned with the ADR set in `docs/adr/`. ADRs capture specific decisions and their rationale. This overview should summarize the accepted decisions at a higher level, not replace the ADR record.
The current baseline decision is:
- `ADR-0001`: runtime and development stack baseline
- `ADR-0002`: initial project structure baseline
- `ADR-0003`: containerized developer workflow baseline
- `ADR-0004`: configuration and environment strategy baseline
- `ADR-0005`: scouting search-domain baseline
- `ADR-0006`: initial domain-model baseline
- `ADR-0009`: real-data ingestion baseline
The current baseline assumes:
- Python 3
- Django
- PostgreSQL
- containerized local development by default
Future implementation work should assume the containerized workflow and PostgreSQL baseline rather than introducing non-containerized local setups or a different default database.
Future scaffolding and implementation work should also follow the initial repository structure defined in `docs/adr/0002-initial-project-structure.md` unless a later ADR supersedes it.
Future runtime and scaffolding work should also follow the developer workflow defined in `docs/adr/0003-containerized-developer-workflow.md`, including app and PostgreSQL containers as the baseline local services and container-run development commands by default.
Future scaffolding should also follow the configuration strategy defined in `docs/adr/0004-configuration-and-environment-strategy.md`, including environment-variable based configuration, a repository-owned `.env.example`, local-only secrets, and a simple initial Django settings approach unless a later ADR supersedes it.
Future search model/filter/UI implementation should follow the domain semantics defined in `docs/adr/0005-scouting-search-domain.md`, including the separation of position vs role vs specialty, MVP filter scope, and optional vs required dimensions.
Future Django domain model work should follow `docs/adr/0006-initial-domain-model.md` as the baseline entity/relationship decision (including player-season stat ownership and taxonomy treatment) unless superseded by a later ADR.
Future importer and ingestion work should follow `docs/adr/0009-real-data-ingestion-baseline.md` as the baseline for importer shape, deterministic identity matching, idempotent re-runs, and imported-vs-internal-vs-user-owned data boundaries unless superseded by a later ADR.
## Future Sections Placeholder
Future versions of this document may include sections such as:
- system context
- major components
- data flow
- deployment/runtime assumptions
- integration boundaries
- operational considerations

View File

@ -0,0 +1,56 @@
# Architecture Principles
## Purpose
This document defines the guiding architectural principles and constraints for phase 1. It exists to give future technical decisions a shared reference before concrete stack and runtime choices are finalized.
## Architectural Principles
Use these principles to guide near-term technical decisions:
- prefer simplicity over premature complexity
- preserve repository portability across machines
- document important technical decisions explicitly
- favor small, reviewable increments
- keep operational complexity low by default
- maintain clear testing and documentation expectations
- support reproducible local development workflow
- avoid lock-in unless it is clearly justified
- favor maintainability over novelty
## Constraints
Future implementation choices must respect these constraints:
- do not assume a single development machine
- do not rely on undocumented local setup
- avoid introducing infrastructure without a clear need
- justify technology choices through the documented decision process
- optimize early implementation for learning and iteration, not scale theatre
## What to Optimize for in the Current Phase
In the current phase, optimize for:
- clear decision-making
- low ambiguity
- portability
- maintainability
- fast feedback from small changes
- a workflow that humans and Codex can follow consistently
## What Not to Optimize for Yet
Do not optimize yet for:
- large-scale deployment assumptions
- advanced infrastructure
- speculative performance work
- premature service decomposition
- novelty for its own sake
## Decision Consequences
These principles narrow the acceptable set of future technical choices. A technology may be viable in general and still be a poor fit for this phase if it adds unnecessary complexity, weakens portability, or depends on undocumented local behavior.
## Relationship to Future Stack and Runtime Choices
This document does not choose the final stack. It defines the constraints those choices must satisfy. Future stack, runtime, and infrastructure decisions should be justified through the documented decision process and recorded in ADRs when they become important enough to guide implementation.

55
docs/DECISION_PROCESS.md Normal file
View File

@ -0,0 +1,55 @@
# Technical Decision Process
## Purpose
This document defines the repeatable process for evaluating technical options during phase 1. It exists to keep decisions consistent, reviewable, and useful for both humans and Codex before implementation begins.
## When to Use This Process
Use this process when a task needs to compare meaningful technical options or establish implementation guidance for later work. It applies to architecture choices, tooling direction, integration patterns, and other decisions that shape future implementation constraints.
## Required Evaluation Criteria
Every significant technical decision should evaluate at least:
- simplicity
- portability across machines
- maintainability
- operational complexity
- testability
- developer experience
- cost
- risk of lock-in
- fit for the current project phase
Additional criteria may be added when a decision requires them, but the standard set should always be considered first.
## Decision Format
Each decision should document:
- the question being decided
- the options considered
- the trade-offs for each option
- the evaluation against the required criteria
- the preferred option
- any open assumptions or follow-up constraints
The result should be recorded in a repository document that is durable and reviewable. Major accepted decisions should be captured in ADRs.
## Acceptance Rule
A decision becomes accepted when:
- the alternatives were compared explicitly
- the trade-offs were documented clearly
- the standard evaluation criteria were addressed
- the chosen option is recorded in the repository
- the outcome is treated as implementation guidance
If those conditions are not met, the decision should remain proposed rather than treated as settled.
## Relationship to ADRs
ADRs are the durable record for important technical decisions. This process explains how to evaluate options before a decision is accepted. The ADR captures the final accepted outcome, context, and consequences once the decision is ready to guide future work.
## Relationship to Implementation Tasks
Implementation tasks should follow accepted decisions, not reopen them informally. If a task depends on an unresolved technical choice, the decision should be evaluated and documented first. If implementation reveals a new major choice, pause and record it through this process before proceeding.

View File

@ -1,37 +1,85 @@
# Machine Setup # Machine Setup
## Goal ## Purpose
Bring a new machine to a usable Codex development state with minimal local-only setup. This document describes the minimum setup needed to continue repository work from a different machine. It is intended as a quick-start handoff for working with HoopScout v2 phase 0 using Codex and repository-defined agent workflows.
## Local-only prerequisites ## What Is Local-Only
Not stored in the repo: These remain local responsibilities and should not be treated as repository-owned configuration:
- Codex authentication - Codex authentication
- personal shell setup - personal shell aliases
- secrets/API keys - personal editor preferences
- local editor preferences - secrets
- API keys
- machine-specific paths unless only documented as examples
## Repository bootstrap ## What Is Repository-Owned
1. Clone the repository These should be read from the repository and kept aligned in version control:
2. Checkout `develop` - workflow rules
3. Read: - branch policy
- AGENTS.md - task format
- docs/WORKFLOW.md - agent roles
- docs/TASK_TEMPLATE.md - setup instructions
4. Ensure Codex can see: - test execution instructions
- AGENTS.md - docs for humans and agents
- .codex/config.toml
- .codex/agents/*
- .agents/skills/*
## Validation checklist ## Minimal Bootstrap Flow
A machine is considered ready when: Use this sequence on a new machine:
- repo is cloned
1. Clone the repository.
2. Checkout `develop`.
3. Authenticate Codex locally.
4. Read:
- `AGENTS.md`
- `docs/WORKFLOW.md`
- `docs/MACHINE_SETUP.md`
- `docs/TASK_TEMPLATE.md`
5. Run `make doctor`.
6. Create a task branch from `develop`.
## Validation Checklist
A machine is ready for repository work when:
- repository is cloned
- correct branch is checked out - correct branch is checked out
- Codex authentication is working - Codex auth is working
- AGENTS.md is present at repo root - `AGENTS.md` is present
- .codex/config.toml is present - `.codex/config.toml` is present
- task branches can be created from develop - `.codex/agents/` is present
- `.agents/skills/` is present
- `docs/WORKFLOW.md` is present
- `docs/MACHINE_SETUP.md` is present
- `docs/TASK_TEMPLATE.md` is present
- `make doctor` works
## Local Checks
Run `make doctor` during bootstrap to verify the repository bootstrap and local working assumptions.
## Troubleshooting
### Wrong Branch Checked Out
Return to `develop`, update local state if needed, and create the correct task branch from there.
### Codex Not Authenticated
Complete Codex authentication locally on the machine before continuing. Authentication is not stored in the repository.
### Missing Repository Files
Confirm the repository was cloned correctly and that required tracked files such as `AGENTS.md`, `.codex/config.toml`, and the `docs/` files are present.
### Local Assumptions Do Not Match Repository Instructions
Follow the repository instructions rather than local habits. If the repository guidance is incomplete or outdated, update the tracked docs instead of relying on undocumented local behavior.
## Related Files
- `AGENTS.md`
- `docs/WORKFLOW.md`
- `docs/TASK_TEMPLATE.md`
- `.codex/config.toml`

59
docs/PHASE_1.md Normal file
View File

@ -0,0 +1,59 @@
# Phase 1
## Purpose
Phase 1 defines the technical decision-making framework that must be completed before implementation begins. This phase is about making and documenting core technical choices in a clear order so future work follows shared decisions rather than assumptions.
## Scope
Phase 1 establishes how technical and architectural decisions will be recorded, reviewed, and used to guide later implementation tasks.
## In Scope
- defining the major technical decisions required before implementation
- documenting architecture decisions in a durable repository-owned form
- establishing the relationship between architecture overview documents and ADRs
- clarifying the minimum decision set required before implementation starts
## Out of Scope
Until the phase-1 decision framework is complete, do not:
- start application implementation
- introduce runtime services
- choose implementation details that depend on unresolved architecture decisions
- expand into feature delivery work
- treat undocumented assumptions as approved decisions
## Expected Outputs
Phase 1 should produce:
- a maintained phase-1 decision plan
- a documented technical decision process
- a documented set of architecture principles and constraints
- a central architecture overview document
- a set of ADRs covering major technical choices
- clear implementation preconditions for the next phase
## Decision Order
Make decisions in this order:
1. Define the required decision areas.
2. Record the technical decision process and evaluation criteria.
3. Define the architecture principles and guiding constraints.
4. Record the ADR process and naming conventions.
5. Establish the architecture overview and its relationship to ADRs.
6. Document major technical decisions in ADRs.
7. Confirm that implementation-critical choices are explicit and aligned.
## Exit Criteria for Phase 1
Phase 1 is complete when:
- the decision framework is documented
- the technical decision process is documented and usable
- the architecture principles and constraints are documented
- the architecture overview exists and points to ADRs
- required major technical decisions are recorded as ADRs
- implementation can begin without relying on undocumented assumptions
The current ADR baseline is sufficient to support the next implementation-oriented phase, provided future work follows the accepted decisions or explicitly supersedes them through later ADRs.

View File

@ -1,14 +1,16 @@
# Task Template # Task Template
Use this structure for Codex implementation tasks. ## Purpose
## Required opening This document defines the preferred structure for implementation and documentation tasks in this repository. It is intended to keep Codex work small, consistent, and easy to review for both humans and agents.
## Required Opening
- confirm branch strategy - confirm branch strategy
- name the branch to use - state the branch to use
- state whether remote update succeeded or local state is being used - say whether local branch state or updated remote state is being used
## Required execution structure ## Required Execution Structure
1. files to change 1. files to change
2. short design explanation 2. short design explanation
@ -17,9 +19,43 @@ Use this structure for Codex implementation tasks.
5. merge target 5. merge target
6. stop 6. stop
## Constraints ## Scope Discipline
- no work on main Each task should solve one clear problem. Unrelated cleanup, speculative refactoring, or adjacent changes should not be included unless they were explicitly requested.
- no work on develop
- no history rewrite ## Branch Discipline
- no broadening scope without approval
- do not work directly on `main`
- do not work directly on `develop`
- create task branches from `develop`
- merge task branches back into `develop`
## Testing and Documentation Expectations
- tests should be updated when behavior changes
- docs should be updated when behavior changes
- no test should be claimed as passed unless it was actually run
## Small Examples
Documentation-only task:
- files to change: `README.md`
- short design explanation: clarify current phase-0 repository purpose
- code/doc/test changes: update documentation only
- commit message: `docs: clarify phase 0 repository purpose`
- merge target: `develop`
- stop
Code-change task:
- files to change: `scripts/doctor.sh`, `Makefile`
- short design explanation: add one validation check and expose it through `make doctor`
- code/doc/test changes: update script, command entrypoint, and docs if behavior changes
- commit message: `build: extend doctor validation`
- merge target: `develop`
- stop
## Related Files
- `AGENTS.md`
- `docs/WORKFLOW.md`
- `docs/MACHINE_SETUP.md`

View File

@ -1,33 +1,95 @@
# Workflow # Workflow
## Branch policy ## Purpose
Protected branches: This document defines the standard repository workflow for humans and coding agents working in HoopScout v2 phase 0. It describes how repository work should be organized, executed, and closed without drifting into product architecture decisions.
- main
- develop
Normal task flow: ## Branch Policy
1. checkout develop
2. create feature/<task-name>
3. implement the task
4. commit changes
5. merge feature branch into develop
6. delete feature branch when appropriate
## When to use an umbrella branch - `main` is the stable branch.
- `develop` is the integration branch.
- Normal work happens on `feature/*` branches created from `develop`.
- `release/*` branches are for release stabilization.
- `hotfix/*` branches are for urgent production fixes.
Use an umbrella branch only for a large coordinated effort that spans multiple dependent feature branches. ## Protected Branch Rules
If used, document: - Do not work directly on `main`.
- Do not work directly on `develop`.
- Do not rewrite history without explicit approval.
- Do not broaden task scope unnecessarily.
## Standard Task Flow
The normal workflow for a focused task is:
1. Checkout `develop`.
2. Pull or otherwise update local state.
3. Create a `feature/*` branch from `develop`.
4. Complete one focused task.
5. Commit the changes.
6. Merge the branch back into `develop`.
7. Delete the feature branch when appropriate.
If a remote fetch or pull is unavailable, continue using local branch state and state that clearly in task communication.
## Codex Task Discipline
Codex work in this repository should follow this structure:
1. Confirm branch strategy.
2. State the branch to use.
3. List the files to change.
4. Explain the design briefly.
5. Make the requested changes.
6. Update tests and docs when relevant.
7. Provide the commit message.
8. Confirm the merge target.
9. Stop.
## Scope Discipline
Each branch should solve one clear problem. Unrelated cleanup, opportunistic refactoring, or scope expansion should not be mixed into the same task unless explicitly approved.
## When To Use an Umbrella Branch
Umbrella branches should be rare. Use one only for a large coordinated effort that requires multiple related task branches to be integrated together before merging back into `develop`.
If an umbrella branch is used, document:
- why it exists - why it exists
- its exact name - the exact branch name
- which child branches merge into it - which task branches merge into it
- when it is expected to merge back into develop - when it is expected to merge back into `develop`
## Task discipline ## Review and Closeout Expectations
Each task should: Before closing a task:
- have one clear goal - changed files should match the task scope
- have one task branch - docs should be updated if behavior changes
- end with a commit and merge target confirmation - tests should be updated if behavior changes
- not silently include unrelated cleanup - the merge target should be explicit
## Machine Portability Note
Durable workflow instructions must live in the repository, not only in local Codex configuration. Shared behavior should be documented in tracked files so contributors can work consistently across different machines.
## Minimal Examples
Example feature branch flow:
```bash
git checkout develop
git pull
# if git pull fails, continue using local branch state and say so explicitly
git checkout -b feature/example-task
# make focused changes
git commit -m "docs: describe example task flow"
git checkout develop
git merge --no-ff feature/example-task
```
## Related Files
- `AGENTS.md`
- `docs/MACHINE_SETUP.md`
- `docs/TASK_TEMPLATE.md`

View File

@ -0,0 +1,110 @@
# ADR-0001: Runtime and Development Stack
- Status: accepted
- Date: 2026-03-20
## Context
HoopScout v2 is moving from phase-1 decision framing toward implementation guidance. The project needs one explicit baseline runtime and development stack decision so future implementation prompts do not depend on undocumented assumptions.
The chosen baseline should fit the current architecture principles:
- keep the stack small and maintainable
- preserve portability across machines
- avoid unnecessary operational complexity
- support fast iteration and learning
This decision covers:
- programming language and runtime family
- web application framework direction
- persistence direction
- whether containerization is part of the baseline developer workflow
- acceptable operational complexity for the current stage
This ADR is an intentional correction to the earlier baseline recorded in this same ADR. The previous non-containerized local workflow and SQLite baseline are rejected because they do not match the actual project requirement for containerized development with PostgreSQL as the default database direction.
## Decision
Use the following baseline unless a future ADR supersedes it:
- language/runtime family: Python 3
- web application framework direction: Django with a single deployable application by default
- persistence direction: PostgreSQL is the baseline database direction
- containerization: development must happen in containers by default
- operational complexity: keep it low, but not at the expense of the required containerized workflow or the PostgreSQL baseline
Practical rule for future implementation work:
- assume Python 3
- assume Django
- assume PostgreSQL
- assume a containerized development workflow
Containerized development is part of the baseline, not an optional convenience.
## Alternatives Considered
### Option A: Python 3 + Django + PostgreSQL + required containerized development
Chosen.
This is the corrected baseline because it matches the actual project requirements and gives future implementation work one explicit, repeatable environment assumption.
### Option B: Python 3 + Django + PostgreSQL + optional or non-containerized development
Not chosen. This is explicitly rejected because development must be containerized. Allowing a non-containerized default would reintroduce machine-specific drift and contradict the project requirement.
### Option C: Python 3 + Django + SQLite + required or optional containers
Not chosen. SQLite is no longer the preferred baseline because the project requires PostgreSQL as the default persistence choice. Once containers are mandatory, the convenience advantage of SQLite is no longer enough to justify choosing a different database than the one future implementation should actually assume.
### Option D: Python 3 + FastAPI + SQLAlchemy + PostgreSQL + required containerized development
Not chosen. This remains a plausible stack in general, but it adds more assembly and more framework decisions than the current phase needs when Django already satisfies the baseline direction.
## Trade-Offs
Evaluation against the standard decision criteria:
- simplicity: acceptable; the required container workflow adds setup structure, but the baseline stays simple by standardizing one framework, one database, and one development path
- portability: strong fit; containerized development reduces machine-specific drift and gives contributors a more consistent environment
- maintainability: good fit; Django and PostgreSQL are conventional, well-understood defaults with clear documentation paths
- operational complexity: moderate but acceptable; containers and PostgreSQL add more moving parts than SQLite, but that complexity is required and bounded
- testability: good fit; the baseline supports realistic database-backed testing against the same database family future work will assume
- developer experience: acceptable to good; containers add overhead, but they reduce local setup ambiguity once the workflow is documented
- fit for the current project phase: good fit; it is more structured than the earlier baseline, but it matches the actual requirement and avoids rework from an unrealistic database choice
- lock-in risk: acceptable; Django shapes application structure and PostgreSQL becomes the assumed database family, but both are justified by the need for a stable, documented baseline
Trade-offs accepted with this decision:
- containerized development adds baseline workflow overhead compared with a purely local setup
- PostgreSQL is heavier than SQLite for the smallest possible local start
- Django remains opinionated, but that is acceptable for a maintainable default direction
Why the earlier baseline is rejected:
- non-containerized development is not acceptable because the project requirement is explicit and future work must assume one standard workflow
- SQLite is not acceptable as the default baseline because future implementation should target PostgreSQL directly rather than split early assumptions from later reality
## Consequences
Future implementation work should assume:
- Python as the default language
- Django as the default application framework direction
- PostgreSQL as the baseline persistence direction
- local development is expected to run through a containerized workflow by default
This correction is required, not optional. Future implementation prompts should assume containers and PostgreSQL unless a later ADR explicitly supersedes this baseline.
This does not lock the project into a final production topology. It establishes the minimum stack direction needed for implementation to begin consistently.
## Follow-Up Decisions Needed
The following decisions are still expected later:
- project layout and repository structure for the application code
- container orchestration details for local development
- Django and PostgreSQL service wiring details
- testing tool and test execution details
- deployment/runtime topology
Still undecided after this correction:
- exact container tooling details and command structure
- exact PostgreSQL version/policy
- how local secrets and environment configuration will be managed inside the containerized workflow

View File

@ -0,0 +1,101 @@
# ADR-0002: Initial Project Structure
- Status: accepted
- Date: 2026-03-20
## Context
The repository now has a documented baseline runtime direction:
- Python 3
- Django
- PostgreSQL
- containerized development by default
Before implementation starts, the project also needs one explicit repository and application structure decision. Future prompts should not guess where application code, infrastructure files, tests, or helper tooling belong.
The initial structure must stay small, phase-appropriate, and easy to understand. It should support containerized development and Django implementation without introducing modularity or infrastructure complexity that the project has not yet earned.
## Decision
Use a single application repository with one initial Django application structure.
Future implementation work should follow this high-level layout:
```text
.
|-- app/ # Django project and application code
|-- tests/ # repository-level test suites as needed
|-- infra/ # container and infrastructure-related files
|-- docs/ # repository and architecture documentation
|-- scripts/ # helper scripts and developer tooling
|-- Makefile
|-- AGENTS.md
`-- README.md
```
Interpretation rules for future work:
- keep the repository as a single application repo for now
- place Django project and application code under `app/`
- place container and infrastructure files under `infra/`
- keep documentation under `docs/`
- keep helper scripts and local tooling under `scripts/`
- place tests in `tests/` unless there is a clear, documented reason to colocate a specific test type later
- optimize for one initial Django project/application structure, not immediate modular decomposition
This structure is the default implementation baseline unless a later ADR supersedes it.
## Alternatives Considered
### Option A: Single repository, `app/` for Django code, `infra/` for container/infrastructure files, `tests/` for tests
Chosen.
This is the best fit for the current phase because it creates a clear, maintainable boundary between application code, infrastructure, documentation, and tooling without forcing early complexity.
### Option B: Keep everything close to the repository root with minimal directory separation
Not chosen. This would reduce early directory count, but it would blur boundaries between application code, infrastructure, and tooling once containerized development begins.
### Option C: Start with a more modular or multi-package layout immediately
Not chosen. This would over-design the repository before implementation has established real module boundaries or scaling needs.
### Option D: Split application and infrastructure into multiple repositories
Not chosen. This would add coordination overhead and reduce clarity at a stage where the project still benefits from one portable repository-owned workflow.
## Trade-Offs
- this structure is slightly more formal than the smallest possible root-only layout
- using a top-level `tests/` directory favors clarity and broad test organization, but some future test colocations may still prove useful
- choosing one initial Django project/application direction defers modularity decisions until real implementation pressure exists
Why this is appropriate for the current phase:
- it gives future implementation prompts a direct, shared structure to follow
- it supports containerized development and PostgreSQL without mixing infrastructure files into application paths
- it keeps the repository understandable across machines and contributors
What is intentionally deferred:
- detailed Django internal app decomposition
- whether later code should be split into multiple Django apps or packages
- exact container file names and orchestration details
- advanced deployment layout decisions
## Consequences
Future scaffolding and implementation work should:
- create Django code under `app/`
- place container and infrastructure assets under `infra/`
- keep docs in `docs/`
- keep helper tooling in `scripts/`
- add tests under `tests/` by default
Future prompts should interpret this ADR as direct structure guidance, not a loose suggestion.
## Follow-Up Decisions Needed
The following decisions are still expected later:
- exact Django project/package names under `app/`
- exact test layout conventions inside `tests/`
- exact container file names and orchestration commands under `infra/`
- environment configuration and secrets handling for the containerized workflow

View File

@ -0,0 +1,96 @@
# ADR-0003: Containerized Developer Workflow
- Status: accepted
- Date: 2026-03-20
## Context
The project has already accepted these baseline decisions:
- Python 3
- Django
- PostgreSQL
- containerized development by default
- a single application repository with application code under `app/` and infrastructure assets under `infra/`
Before implementation starts, the repository needs one explicit developer-workflow decision so future tasks do not guess how local commands, services, or persistence should work. The workflow must be portable across machines, low-ambiguity, and small enough for the current phase.
## Decision
Use a Docker Compose style workflow, or an equivalent container orchestration approach with the same behavior, as the baseline developer workflow.
The baseline developer workflow must include at least:
- one application container for Django development work
- one PostgreSQL container for the baseline database service
The baseline workflow also assumes:
- application source code is mounted into the app container during development
- routine development commands run through containers by default
- persistent local development database data is handled through a container-managed persistent volume or equivalent durable container storage
- host-installed Python is optional and unsupported for normal development workflow
Minimum required developer experience:
- a contributor can start the baseline services through the containerized workflow
- application commands run inside the app container
- the application talks to PostgreSQL through the containerized environment
- the workflow behaves consistently across machines without depending on undocumented local Python setup
Future implementation tasks should treat this as direct workflow guidance, not as an optional convenience.
## Alternatives Considered
### Option A: Docker Compose style baseline with one app container, one PostgreSQL container, mounted source, and container-run commands
Chosen.
This is the best fit for the current phase because it gives the project one clear, repeatable development path while keeping the service set intentionally small.
### Option B: Containerized database, but application commands run on the host machine
Not chosen. This would keep too much variation in local Python setup and weaken the portability goal of mandatory containerized development.
### Option C: Fully local host-based development with optional containers
Not chosen. This conflicts with the already accepted runtime baseline and would reintroduce machine-specific ambiguity.
### Option D: More production-like multi-service developer orchestration from the start
Not chosen. This would add complexity the project has not yet earned and is not required for the current phase.
## Trade-Offs
- this workflow adds container orchestration overhead compared with purely local commands
- mounted source inside the app container improves iteration speed, but requires clear volume and command conventions later
- persistent database data in container-managed storage is more realistic than ephemeral local-only setup, but requires explicit reset/cleanup decisions later
Why this is the best fit for the current phase:
- it matches the already accepted requirement for containerized development
- it keeps the developer baseline limited to the minimum useful services
- it improves portability across machines by standardizing how development commands run
- it avoids premature production-grade complexity
What is intentionally deferred:
- exact Compose file names or orchestration command names
- exact image build details
- reverse proxy or extra service containers
- production deployment orchestration
- detailed data reset and backup workflows for local development
## Consequences
Future implementation tasks should assume:
- local development commands run through containers by default
- the app service and PostgreSQL service both exist in the developer baseline
- source code is mounted into the app container during development
- database persistence is handled by container-managed durable local storage
- host-installed Python should not be the normal path for project development
Future runtime and scaffolding work should follow this workflow unless a later ADR supersedes it.
## Follow-Up Decisions Needed
The following decisions are still expected later:
- exact orchestration file names and locations under `infra/`
- exact command entrypoints for common development tasks
- environment variable and secrets handling for the containerized workflow
- local data reset and cleanup conventions
- whether any additional developer services are needed beyond app and PostgreSQL

View File

@ -0,0 +1,98 @@
# ADR-0004: Configuration and Environment Strategy
- Status: accepted
- Date: 2026-03-20
## Context
The project has already accepted these baseline decisions:
- Python 3
- Django
- PostgreSQL
- containerized development by default
- a Docker Compose style developer workflow
Before implementation starts, the repository needs one explicit configuration strategy so future prompts do not guess how environment-specific values, secrets, or Django settings should be handled.
The baseline must stay simple, portable, and easy to understand across machines. It should work cleanly with containerized development without introducing a complex configuration framework before the application exists.
## Decision
Use environment variables as the baseline mechanism for runtime and application configuration.
The baseline strategy is:
- keep a versioned `.env.example` file in the repository
- never commit real secrets, local-only credentials, or machine-specific private values
- use local-only env files or equivalent local secret sources for actual sensitive values
- have container orchestration provide environment variables through env files, direct environment variable definitions, or both
- keep the initial Django configuration readable with one baseline settings module rather than a split-settings package by default
Future implementation prompts should assume:
- application/runtime configuration enters the app container through environment variables
- `.env.example` documents the expected variable names and non-secret examples
- local-only secrets stay out of git
- container orchestration is responsible for wiring config into containers
- Django settings should remain understandable and maintainable before any more advanced settings split is justified
## Alternatives Considered
### Option A: Environment-variable baseline with a committed `.env.example`, local-only secret files, and a simple initial Django settings module
Chosen.
This is the best fit for the current phase because it is portable, familiar, and easy to document without adding unnecessary configuration layers.
### Option B: Commit more concrete local config files with real environment-specific values
Not chosen. This would create unnecessary risk and conflict with the requirement that secrets and machine-specific values stay local.
### Option C: Adopt a more elaborate split-settings or multi-layer configuration framework immediately
Not chosen. This would add complexity before the project has enough implementation pressure to justify it.
### Option D: Rely primarily on host-local configuration outside the container workflow
Not chosen. This would weaken the clarity and portability of the mandatory containerized development baseline.
## Trade-Offs
- environment variables keep the baseline simple, but they require careful documentation of expected names and meanings
- using `.env.example` improves onboarding clarity, but future work must ensure example values stay non-sensitive
- keeping one initial Django settings module is easier to understand early on, but future settings growth may eventually justify a more structured split
What is expected to live in git:
- `.env.example`
- documented variable names and non-secret defaults/examples
- configuration documentation
- application settings code that reads from environment variables
What must stay local:
- real secrets
- real local credentials
- developer-specific overrides that should not be shared
- machine-specific private paths or values
How developers should expect configuration to enter containers:
- through the container orchestration layer
- using env files, direct environment variable definitions, or both
- without requiring host-installed Python configuration as the normal workflow
## Consequences
Future implementation tasks should:
- define configuration through environment variables
- add a repository-owned `.env.example`
- keep real secrets out of git
- wire application containers to read configuration from container-supplied environment variables
- keep the first Django settings layout simple and understandable
Future scaffolding should follow this configuration strategy unless a later ADR supersedes it.
## Follow-Up Decisions Needed
The following decisions are still expected later:
- exact initial variable names and documented defaults
- exact env file names and placement for local development
- whether any non-secret shared defaults should live in orchestration files directly
- whether and when Django settings should be split into multiple modules
- how production and non-development secret delivery will work later

View File

@ -0,0 +1,110 @@
# ADR-0005: Scouting Search Domain Baseline
## Status
Accepted
## Context
HoopScout v2 is moving from technical foundation decisions to product-domain decisions required before model, filter, and UI implementation. The next implementation prompts need a stable and explicit scouting search vocabulary so we do not conflate objective statistics with internal scouting interpretation.
The product intent is a scouting-first player search engine where users combine multiple dimensions to discover players. This is not a generic basketball encyclopedia.
## Decision
### 1. Search purpose
The search system is defined as a player scouting search engine. Search dimensions are selected to support scouting workflows and shortlist creation, not comprehensive historical/statistical browsing.
### 2. Domain vocabulary and separation of concerns
Search dimensions are separated into distinct layers that must not be conflated:
- Position: standard on-court category (`PG`, `SG`, `SF`, `PF`, `C`).
- Role: tactical/scouting classification (for example `playmaker`, `3-and-D`, `point forward`, `rim protector`, `6th man`).
- Specialty: scouting tag layer (for example `ball handling`, `off ball`, `defense`, `intangibles`, `clutch`, `post`, `dunk`).
Position is a categorical descriptor, role is tactical interpretation, and specialty is a flexible tagging layer. Future implementation must preserve this separation in model semantics, filtering semantics, and UI wording.
### 3. Data classification by search dimension
The following classification defines ownership and data realism.
| Dimension | Classification | MVP handling |
|---|---|---|
| Position (`PG`/`SG`/`SF`/`PF`/`C`) | Hybrid: often source-derived, may need normalization; can be manually overridden when source is inconsistent | In MVP; filterable |
| Role (listed tactical roles) | App-defined/internal taxonomy; assigned via internal scouting judgment; may be inferred from data but not treated as source-native fact | Deferred as mandatory filter; optional/manual enrichment in MVP |
| Specialty tags | App-defined/internal taxonomy; manually curated and optionally inference-assisted; not source-native | Deferred as mandatory filter; optional/manual enrichment in MVP |
| Age | Derived from source date-of-birth and reference date | In MVP; filterable |
| Height | Source-derived objective attribute; normalized units required | In MVP; filterable |
| Weight | Source-derived objective attribute; normalized units required | In MVP; filterable |
| Wingspan | Optional source-derived or manually curated enrichment; sparse in public sources | Not required in MVP; optional if present |
| Points per game | Source-derived objective per-game metric | In MVP; filterable |
| Assists per game | Source-derived objective per-game metric | In MVP; filterable |
| Steals per game | Source-derived objective per-game metric | In MVP; filterable |
| Turnovers per game | Source-derived objective per-game metric | In MVP; filterable |
| Blocks per game | Source-derived objective per-game metric | In MVP; filterable |
| eFG% | Source-derived if provided, otherwise calculated from source box score totals (derived but objective) | In MVP if available/computable |
| TS% | Source-derived if provided, otherwise calculated from source totals (derived but objective) | In MVP if available/computable |
| Plus/minus | Optional source-derived metric with context variance across competitions/sources | Deferred from MVP baseline; include only where source quality is acceptable |
| Offensive rating | Optional source-derived metric; not universally available/consistent | Deferred from MVP baseline |
| Defensive rating | Optional source-derived metric; not universally available/consistent | Deferred from MVP baseline |
### 4. MVP search scope decision
MVP search filters include:
- position
- per-game metrics: points, assists, steals, turnovers, blocks
- objective personal/physical attributes with practical availability: age, height, weight
- advanced percentages when available or reliably computable: eFG%, TS%
MVP does not require role, specialty, wingspan, plus/minus, offensive rating, or defensive rating as baseline filters.
These deferred dimensions are allowed as optional enrichment fields in MVP data ingestion/storage if available, but they are not required for a player to be searchable.
### 5. Data realism decisions for risk-prone dimensions
- Role: public source coverage is inconsistent and mostly interpretive; treat as internal/manual scouting classification in MVP.
- Specialty: public source coverage is not standardized; treat as internal/manual tagging in MVP.
- Wingspan: public coverage is sparse and uneven across leagues; treat as optional enrichment, not a required MVP field.
### 6. Flexibility for future taxonomy growth
Role and specialty taxonomies are repository-owned domain vocabularies. They must be extensible so new role labels and specialty tags can be added without changing the conceptual model.
Future implementation should assume:
- position remains a bounded standard set;
- role remains an app-defined tactical taxonomy that can expand;
- specialty remains an app-defined tag taxonomy that can expand.
### 7. Implementation guidance for future prompts
Future model/filter/UI prompts must assume:
- Search filters are split by semantic layer: position, role, specialty, objective metrics, physical characteristics.
- Position filters operate on normalized categorical values.
- Role and specialty are internal taxonomy-owned dimensions and may be absent for many players in early phases.
- Objective metrics and physical fields remain source-driven (or objectively derived from source stats) and should be treated differently from scouting classifications.
- Optional dimensions must not block ingestion or search eligibility when missing.
- UI wording should avoid presenting role/specialty as objective source facts.
## Alternatives considered
### A. Treat role and specialty as source-native fields in MVP
Rejected. This overstates source objectivity and creates data quality risk because these are primarily scouting interpretations.
### B. Require all listed dimensions in MVP filters
Rejected. This increases delivery risk and couples MVP to low-availability fields (especially wingspan and certain advanced metrics).
### C. Ignore role/specialty until much later
Rejected. Even if deferred as mandatory filters, role/specialty must be conceptually defined now to avoid model ambiguity and rework.
## Trade-offs
- Pros: clear semantic boundaries, realistic MVP scope, lower ingestion risk, explicit taxonomy ownership.
- Cons: initial MVP filter set is narrower than full scouting ambition, and some high-value scouting dimensions are manual/optional at first.
## Consequences
- Near-term implementation can proceed with objective, reliably available filters first.
- Role/specialty workflows will require internal curation processes and possibly reviewer/admin UX later.
- Data pipelines must support missing optional fields without breaking search.
- Future ADRs can refine role/specialty governance and metric computation standards without replacing this baseline separation.
## Follow-up decisions needed
1. Role taxonomy governance: who can define, merge, rename, or deprecate roles.
2. Specialty taxonomy governance: naming rules, hierarchy policy (if any), and duplicate-tag handling.
3. Normalization standards for height/weight units and age reference-date semantics.
4. Metric computation and rounding policy for derived advanced stats (eFG%, TS%).
5. Source quality policy for optional advanced metrics (plus/minus, offensive/defensive rating).
6. Curation workflow for manual scouting classifications and auditability requirements.

View File

@ -0,0 +1,156 @@
# ADR-0006: Initial Domain Model Baseline
## Status
Accepted
## Context
ADR-0005 defined the scouting search domain semantics and MVP filter realism. Before implementing Django models, HoopScout v2 needs an explicit conceptual domain model baseline that translates those search decisions into stable entities, relationships, and ownership boundaries.
The goal of this ADR is to define an MVP domain model that is predictable, implementation-ready, and extensible without premature schema complexity.
## Decision
### 1. Core MVP entities
The initial MVP conceptual model includes these entities:
- Player
- Competition
- Team
- Season
- PlayerSeason
- PlayerSeasonStats
Entity intent:
- Player: canonical person/athlete identity and durable personal/physical profile.
- Competition: league/tournament context in which team and season participation occurs.
- Team: club/franchise identity with competition association in a given season context.
- Season: time-bounded campaign context (for example 2025-2026).
- PlayerSeason: player participation context for one season, optionally linked to a primary team and competition.
- PlayerSeasonStats: objective statistical snapshot attached to one `PlayerSeason`.
MVP split decision:
- Keep `PlayerSeason` and `PlayerSeasonStats` separate.
- `PlayerSeason` owns participation/classification context.
- `PlayerSeasonStats` owns metric values used for search filters.
### 2. Entity relationships (conceptual)
- One `Player` can have many `PlayerSeason` records.
- One `Season` can have many `PlayerSeason` records.
- One `Team` can have many `PlayerSeason` records.
- One `Competition` can have many `Team` and many `PlayerSeason` records.
- One `PlayerSeason` has zero or one `PlayerSeasonStats` record in MVP.
Stats ownership decision:
- Search statistics conceptually belong to the player-in-season unit (`PlayerSeason`) and are stored in `PlayerSeasonStats`.
- Team and competition are contextual links to that player-season record.
### 3. Position, role, specialty modeling approach
- Position:
- Conceptual type: bounded choice set (enum/choice behavior).
- Cardinality in MVP: single-valued on `PlayerSeason`.
- Ownership: normalized source-aligned value with optional internal correction.
- Role:
- Conceptual type: dedicated taxonomy entity (not a hardcoded enum).
- Cardinality in MVP: many-to-many classification from `PlayerSeason` to role taxonomy.
- Ownership: internal scouting taxonomy, manually curated and extensible.
- Specialty:
- Conceptual type: tag-like taxonomy entity.
- Cardinality in MVP: many-to-many classification from `PlayerSeason` to specialty taxonomy.
- Ownership: internal scouting tag vocabulary, multi-valued and extensible.
Rationale:
- Position is stable and bounded; role and specialty are interpretive and expected to evolve.
### 4. Imported vs internal vs inferred boundary
- Imported source data (objective):
- Height, weight, date of birth (source attributes)
- Per-game stats: points, assists, steals, turnovers, blocks
- Advanced metrics when provided by source: eFG%, TS%, plus/minus, offensive rating, defensive rating
- Position when supplied by source (subject to normalization)
- Inferred/derived data:
- Age (derived from date of birth and reference date)
- eFG% and TS% when computed from source totals
- Optional role suggestions generated from rules/models (if introduced later)
- Internal/manual scouting enrichment:
- Role assignments
- Specialty tags
- Position overrides when source labels are inconsistent
- Wingspan when collected manually due to sparse public coverage
Hybrid note:
- Position remains hybrid (source-derived baseline plus internal override path).
### 5. Optional-data handling
Domain-level required vs optional in MVP:
- Required for MVP search baseline:
- Position (normalized single value)
- Age (derived, if DOB available)
- Height, weight (if available from source; model permits null when missing)
- Core per-game stats (points, assists, steals, turnovers, blocks)
- Optional in MVP domain:
- Wingspan
- Role classifications
- Specialty classifications
- Plus/minus, offensive rating, defensive rating
- Any personal/physical field missing from public source coverage
Optionality rule:
- Missing optional fields must not block player ingestion or baseline search eligibility.
### 6. MVP scope discipline (intentionally excluded now)
The initial domain model intentionally excludes:
- manual scouting notes/journal objects
- watchlists and shortlist collaboration objects
- player-to-player comparison artifacts
- transfer-history and contract-history modeling
- multi-source provenance graph complexity beyond basic source attribution needs
- versioned taxonomy governance workflows
### 7. Implementation guidance for future prompts
Future Django model implementation prompts should assume:
- First model wave: `Player`, `Competition`, `Team`, `Season`, `PlayerSeason`, `PlayerSeasonStats`, plus taxonomy models for role and specialty.
- Keep MVP simple: one primary stats record per player-season context.
- Position remains a bounded choice; role and specialty must be extensible taxonomies.
- Role and specialty fields are optional and many-to-many from player-season context.
- Objective/imported metrics and internal scouting classifications remain semantically separate in naming and model structure.
- Domain-level optional fields must be nullable/omissible without breaking ingestion flows.
## Alternatives considered
### A. Single flat Player model with season/stat columns
Rejected. It mixes durable identity with seasonal context and creates update ambiguity.
### B. Attach stats directly to PlayerSeason without a separate stats entity
Rejected for MVP baseline. Keeping a dedicated `PlayerSeasonStats` concept preserves a clean boundary between participation context and metric payload.
### C. Model role and specialty as fixed enums
Rejected. These are internal scouting taxonomies expected to evolve and require extensibility.
### D. Require wingspan, role, and specialty at ingest time
Rejected due to high data-availability risk and poor MVP practicality.
## Trade-offs
- Pros: clear context boundaries, predictable first implementation pass, extensible scouting taxonomies, realistic handling of missing public data.
- Cons: introduces more entities than a flat schema and defers richer provenance/taxonomy governance details to later ADRs.
## Consequences
- Future model implementation can proceed without re-deciding core entity boundaries.
- Search/filter implementation can rely on player-season as the primary statistical context.
- Internal scouting enrichment can evolve independently from imported objective stats.
- Additional complexity (notes, watchlists, provenance depth) remains available for later incremental decisions.
## Follow-up decisions needed
1. Exact uniqueness constraints for `PlayerSeason` identity (player + season + team + competition semantics).
2. Minimal provenance metadata required on imported records in MVP.
3. Normalization standards for team/competition naming and cross-source identity mapping.
4. Taxonomy governance policy for role/specialty lifecycle changes.
5. Rules for handling mid-season team changes in MVP vs post-MVP.
6. Whether advanced metrics should be persisted, computed on read, or both.

View File

@ -0,0 +1,146 @@
# ADR-0009: Real-Data Ingestion Baseline
## Status
Accepted
## Context
The scouting MVP is working with seeded data, but there is no accepted baseline for how real external data should enter the system. Before building the first importer, we need one predictable ingestion shape, clear identity rules, and explicit ownership boundaries so repeated imports are safe and do not damage scouting or user-owned data.
## Decision
### 1. Ingestion strategy baseline
Use a command-oriented, source-specific baseline.
- Each source gets its own Django management command under `scouting`.
- The first importer is single-source and single-competition scoped.
- Command behavior for MVP:
- read a structured input payload for one source (no scraping in this phase)
- normalize to HoopScout field conventions
- validate required identifiers and required MVP fields
- upsert supported imported models in deterministic order inside a transaction
- Keep execution synchronous and local-command driven for now.
This is the smallest safe baseline: simple to run, testable, and repeatable without introducing an ingestion platform.
### 2. Source identity and external identifiers
Imports must use deterministic matching keys. Fuzzy matching is out of scope for MVP imports.
External identifier policy:
- `Player`: require source external player ID.
- `Competition`: require source external competition ID.
- `Team`: require source external team ID.
- `Season`: use internal canonical season identity (`name`, `start_year`, `end_year`) as the MVP match key; source season ID may be added later if needed.
- `PlayerSeason`: require a deterministic context identity built from source IDs for player + season + team + competition.
- `PlayerSeasonStats`: match strictly by the resolved `PlayerSeason` (one stats row per player-season context in current model).
Model-shape guidance for implementation:
- Add source-aware external identity mapping support before first importer write path.
- Preferred MVP shape: small generic mapping table keyed by (`source_name`, `entity_type`, `external_id`) -> internal object reference.
- Avoid source-specific columns spread across core domain tables in MVP.
Duplication/merge safety rules:
- Never merge two internal entities without deterministic ID evidence.
- If deterministic identity is missing, skip row and report validation error (do not guess).
- Re-running the same source payload must update existing mapped records, not create duplicates.
### 3. Data ownership boundary
Three ownership zones are explicit:
- Imported source data (updatable by importer):
- objective player profile fields provided by source
- competition/team/season context fields provided by source
- player-season and stats payload fields provided by source
- Internal scouting enrichment (not overwritten by importer by default):
- roles
- specialties
- future internal scouting metadata unless explicitly marked importer-owned in a later ADR
- User-scoped product data (never touched by importer):
- favorites
- notes
- saved searches
Importer code must not overwrite user-owned or internal enrichment data unless a later decision explicitly allows it.
### 4. Update semantics baseline
Repeated imports are expected and must be idempotent.
- Upsert allowed:
- `Player`, `Competition`, `Team`, `Season`, `PlayerSeason`, `PlayerSeasonStats` for importer-owned fields only.
- Immutable/append-only assumptions for MVP:
- user-owned tables remain untouched
- internal enrichment remains untouched
- import-run audit rows (if added) are append-only
- Optional fields:
- missing optional source fields must not block ingestion; store null/empty where allowed
- Conflict behavior:
- deterministic key mismatch or missing required IDs is a row-level error, not a heuristic merge.
### 5. MVP ingestion scope
The first ingestion implementation must stay narrow:
- one source
- one competition
- one command-oriented flow
- minimal useful fields only (core player identity/profile + player-season context + stats already represented by current model)
No scraping framework, no async worker orchestration, and no multi-source reconciliation engine in this phase.
### 6. Model impact guidance
Before first importer implementation, introduce only the minimum schema support needed for deterministic identity and repeatability:
- required: source/external ID mapping support
- recommended: lightweight import-run tracking for observability and replay confidence
- not required now:
- broad domain redesign
- full provenance graph
- multi-source conflict resolution model
### 7. Implementation guidance for the next prompt
The next ingestion implementation task should assume:
- first importer shape:
- source-specific management command
- structured input -> normalize -> validate -> transactional upsert
- identity matching:
- external ID mapping required for player/team/competition/player-season contexts
- season matched by canonical internal season identity in MVP
- repeatability/idempotency:
- safe to run same payload multiple times with stable results
- data touch boundaries:
- importer updates only importer-owned objective fields
- importer does not modify roles/specialties/favorites/notes/saved searches
## Alternatives considered
### A. Direct ad-hoc scripts that write straight to tables
Rejected. Too hard to verify, repeat, and maintain safely across contributors.
### B. Full ingestion platform first (queues, orchestrator, multi-source reconciliation)
Rejected. Too much complexity for current phase and first real importer scope.
### C. Natural-key-only matching without external identifiers
Rejected. High duplication and ambiguous merge risk across repeated imports.
### D. Source-specific external ID columns on each core model
Not chosen for MVP baseline. It couples core schema to individual sources and scales poorly when adding sources.
## Trade-offs
- Pros:
- deterministic identity and safer repeat imports
- clear ownership boundaries that protect scouting/user data
- minimal implementation surface for first real importer
- Cons:
- requires adding mapping support before importer write path
- strict ID requirements may reject incomplete rows instead of ingesting partial guesses
## Consequences
- Future importer implementation can proceed without re-deciding baseline ingestion shape.
- First importer work will prioritize deterministic mapping and idempotent upserts over source breadth.
- Search/product layers remain stable while objective source data becomes replaceable and repeatable.
## Follow-up decisions needed
1. Exact schema design for source/external ID mapping table and object reference strategy.
2. Whether import-run tracking is mandatory in MVP or phase-2.1.
3. Field-by-field importer ownership matrix (which columns are importer-owned vs internal-only).
4. Error-report output format for skipped/invalid rows.
5. When to add source season external IDs if natural season identity becomes insufficient.

77
docs/adr/README.md Normal file
View File

@ -0,0 +1,77 @@
# ADRs
## What an ADR Is
An ADR, or Architecture Decision Record, is a short document that captures one important technical decision, its context, and its outcome.
## When To Create One
Create an ADR when a decision:
- affects architecture or major technical direction
- changes implementation constraints for future work
- needs durable reviewable documentation in the repository
Before an ADR is accepted, compare alternatives using the technical decision process in `docs/DECISION_PROCESS.md`.
## Minimum ADR Template
Each ADR should include at least:
- title
- status
- date
- context
- options considered
- decision
- consequences
Minimal example:
```md
# 0001-example-decision
- Status: proposed
- Date: YYYY-MM-DD
## Context
What problem or uncertainty is being resolved?
## Options Considered
What meaningful alternatives were compared and why?
## Decision
What was chosen?
## Consequences
What follows from this decision?
```
## Status Values
Use these status values:
- proposed
- accepted
- superseded
- rejected
## Naming Convention
Name ADR files using a numeric prefix and a short slug:
```text
0001-short-decision-name.md
0002-another-decision.md
```
The repository currently follows this convention:
- `0001-runtime-and-development-stack.md`
- `0002-initial-project-structure.md`
- `0003-containerized-developer-workflow.md`
- `0004-configuration-and-environment-strategy.md`
## Relationship to Implementation Tasks
Implementation tasks should follow documented ADRs when they depend on architecture decisions. If an implementation task exposes a new major technical decision, evaluate the options first, then record the accepted outcome in an ADR before implementation proceeds.

35
infra/docker-compose.yml Normal file
View File

@ -0,0 +1,35 @@
services:
db:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
app:
build:
context: ..
dockerfile: infra/docker/Dockerfile
working_dir: /app
command: python manage.py runserver 0.0.0.0:8000
volumes:
- ../app:/app
environment:
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY}
DJANGO_DEBUG: ${DJANGO_DEBUG}
DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS}
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_HOST: ${POSTGRES_HOST}
POSTGRES_PORT: ${POSTGRES_PORT}
ports:
- "8000:8000"
depends_on:
- db
volumes:
postgres_data:

13
infra/docker/Dockerfile Normal file
View File

@ -0,0 +1,13 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY app/requirements.txt /tmp/requirements.txt
RUN pip install --no-cache-dir -r /tmp/requirements.txt
COPY app/ /app/
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]

View File

@ -6,13 +6,17 @@ echo "== Repo doctor =="
test -f AGENTS.md && echo "AGENTS.md: OK" || { echo "AGENTS.md: MISSING"; exit 1; } test -f AGENTS.md && echo "AGENTS.md: OK" || { echo "AGENTS.md: MISSING"; exit 1; }
test -f .codex/config.toml && echo ".codex/config.toml: OK" || { echo ".codex/config.toml: MISSING"; exit 1; } test -f .codex/config.toml && echo ".codex/config.toml: OK" || { echo ".codex/config.toml: MISSING"; exit 1; }
test -d .codex/agents && echo ".codex/agents: OK" || { echo ".codex/agents: MISSING"; exit 1; } test -d .codex/agents && echo ".codex/agents: OK" || { echo ".codex/agents: MISSING"; exit 1; }
test -d docs && echo "docs/: OK" || { echo "docs/: MISSING"; exit 1; } test -d .agents/skills && echo ".agents/skills: OK" || { echo ".agents/skills: MISSING"; exit 1; }
test -f .agents/skills/task-closeout/SKILL.md && echo ".agents/skills/task-closeout/SKILL.md: OK" || { echo ".agents/skills/task-closeout/SKILL.md: MISSING"; exit 1; }
test -f docs/WORKFLOW.md && echo "docs/WORKFLOW.md: OK" || { echo "docs/WORKFLOW.md: MISSING"; exit 1; }
test -f docs/MACHINE_SETUP.md && echo "docs/MACHINE_SETUP.md: OK" || { echo "docs/MACHINE_SETUP.md: MISSING"; exit 1; }
test -f docs/TASK_TEMPLATE.md && echo "docs/TASK_TEMPLATE.md: OK" || { echo "docs/TASK_TEMPLATE.md: MISSING"; exit 1; }
branch="$(git rev-parse --abbrev-ref HEAD)" branch="$(git rev-parse --abbrev-ref HEAD)"
echo "Current branch: ${branch}" echo "Current branch: ${branch}"
if [[ "${branch}" == "main" || "${branch}" == "develop" ]]; then if [[ "${branch}" == "main" || "${branch}" == "develop" ]]; then
echo "Warning: create a task branch before making changes." echo "Warning: do not make changes on ${branch}; create a task branch from develop first."
fi fi
echo "Doctor completed." echo "Doctor completed."