Compare commits
34 Commits
e5261db35b
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| db454371a5 | |||
| e6081428ae | |||
| 677b5af40d | |||
| 88ca5cde10 | |||
| 9524c928e2 | |||
| 154450f516 | |||
| 5c82187b7c | |||
| e1365761c9 | |||
| b27db5e6a3 | |||
| c09aad2d63 | |||
| e44cad6167 | |||
| caa1f8354d | |||
| a5e1d841df | |||
| 4651746427 | |||
| 4f869c1c02 | |||
| 99820419c4 | |||
| d1b5499a63 | |||
| 6d8af021ce | |||
| 6c53cae7a1 | |||
| ff4a3020d5 | |||
| 3e6fb34017 | |||
| dbf218e2fd | |||
| b351d31cf5 | |||
| 6c7d7c1af4 | |||
| 80f6c160f4 | |||
| 065a5f66f1 | |||
| aa283a1235 | |||
| e6390fe664 | |||
| aecbb62376 | |||
| d5b00eee98 | |||
| 2994089f1e | |||
| a9f189cbc7 | |||
| 5ce9214c88 | |||
| 99a0b19bf2 |
8
.env.example
Normal file
8
.env.example
Normal 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
|
||||||
175
README.md
175
README.md
@ -1,40 +1,24 @@
|
|||||||
# HoopScout v2
|
# HoopScout v2
|
||||||
|
|
||||||
HoopScout v2 has completed its phase-0 workflow foundation and is now using accepted phase-1 decisions to guide implementation planning. The repository remains repository-owned, portable across machines, and explicit about how humans and Codex should work.
|
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.
|
||||||
|
|
||||||
The current goal is to maintain:
|
## Current MVP
|
||||||
- Codex-assisted development
|
|
||||||
- custom agent usage
|
|
||||||
- repeatable task execution
|
|
||||||
- repository-owned instructions
|
|
||||||
- machine portability
|
|
||||||
- branch discipline
|
|
||||||
- implementation guidance driven by accepted ADRs
|
|
||||||
|
|
||||||
## Current Phase
|
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
|
||||||
|
|
||||||
Phase 0 established the working method for the repository. Phase 1 has already added accepted technical decisions for:
|
Accepted technical and product-shaping decisions live in:
|
||||||
- architecture principles
|
|
||||||
- technical decision process
|
|
||||||
- runtime and development stack
|
|
||||||
- initial project structure
|
|
||||||
- containerized developer workflow
|
|
||||||
- configuration and environment strategy
|
|
||||||
|
|
||||||
Current work should follow those accepted decisions rather than re-deciding them informally.
|
|
||||||
|
|
||||||
## Workflow Foundation
|
|
||||||
|
|
||||||
The repository still depends on the phase-0 foundation for:
|
|
||||||
- repository workflow
|
|
||||||
- branch policy
|
|
||||||
- Codex project configuration
|
|
||||||
- agent roles
|
|
||||||
- reusable task-closeout behavior
|
|
||||||
- machine setup guidance
|
|
||||||
- documentation discipline
|
|
||||||
|
|
||||||
Key decision references:
|
|
||||||
- `docs/ARCHITECTURE.md`
|
- `docs/ARCHITECTURE.md`
|
||||||
- `docs/ARCHITECTURE_PRINCIPLES.md`
|
- `docs/ARCHITECTURE_PRINCIPLES.md`
|
||||||
- `docs/DECISION_PROCESS.md`
|
- `docs/DECISION_PROCESS.md`
|
||||||
@ -42,105 +26,78 @@ Key decision references:
|
|||||||
|
|
||||||
## Repository Structure
|
## Repository Structure
|
||||||
|
|
||||||
The repository is organized to keep durable workflow guidance and technical decision records in version control and portable across machines.
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
.
|
.
|
||||||
|-- .codex/
|
|-- .codex/
|
||||||
|-- .agents/skills/
|
|-- .agents/skills/
|
||||||
|
|-- app/
|
||||||
|
| |-- hoopscout/
|
||||||
|
| `-- scouting/
|
||||||
|-- docs/
|
|-- docs/
|
||||||
|
|-- infra/
|
||||||
|-- scripts/
|
|-- scripts/
|
||||||
|
|-- tests/
|
||||||
|-- AGENTS.md
|
|-- AGENTS.md
|
||||||
|-- Makefile
|
|-- Makefile
|
||||||
|-- README.md
|
`-- README.md
|
||||||
|-- .editorconfig
|
|
||||||
`-- .gitignore
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- `.codex/` stores repository-scoped Codex configuration and agent definitions.
|
- `app/hoopscout/` contains the Django project settings and root URLs.
|
||||||
- `.agents/skills/` stores reusable skills for repeatable repository workflows.
|
- `app/scouting/` contains the scouting domain models, views, templates, management commands, and tests tied to the app.
|
||||||
- `docs/` stores workflow, architecture, ADRs, machine setup, and task execution guidance.
|
- `infra/` contains the local Docker Compose and image setup.
|
||||||
- `scripts/` stores repository utility scripts such as local checks.
|
- `docs/` contains workflow and ADR documentation.
|
||||||
- `AGENTS.md` defines repository-wide agent behavior and task rules.
|
- `scripts/` contains repository checks such as `make doctor`.
|
||||||
- `Makefile` exposes standard project commands.
|
|
||||||
- `README.md` introduces the repository and current phase.
|
## Local Development
|
||||||
- `.editorconfig` provides shared formatting defaults.
|
|
||||||
- `.gitignore` defines ignored files for the repository.
|
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
|
## Workflow
|
||||||
|
|
||||||
Protected branches:
|
- `main` is the stable branch.
|
||||||
- `main`
|
- `develop` is the integration branch.
|
||||||
- `develop`
|
- normal work goes through `feature/*` branches created from `develop`.
|
||||||
|
- run `make doctor` before or during local setup to confirm the repository foundation is present.
|
||||||
|
|
||||||
Normal work goes through `feature/*` branches created from `develop`. Tasks should be completed on the task branch, committed there, and merged back into `develop` when done.
|
Durable project behavior belongs in the repository, especially:
|
||||||
|
- `AGENTS.md`
|
||||||
|
- `.codex/`
|
||||||
|
- `.agents/skills/`
|
||||||
|
- `docs/`
|
||||||
|
|
||||||
## Working with Codex
|
Local-only responsibilities still include authentication, personal editor setup, shell aliases, and secrets.
|
||||||
|
|
||||||
Durable project behavior should live in the repository so that work remains consistent across machines and contributors.
|
|
||||||
|
|
||||||
Repository-owned configuration examples:
|
|
||||||
- task workflow
|
|
||||||
- branch strategy
|
|
||||||
- coding process
|
|
||||||
- agent roles
|
|
||||||
- reusable skills
|
|
||||||
- machine setup instructions
|
|
||||||
- test and validation instructions
|
|
||||||
|
|
||||||
Local-only configuration examples:
|
|
||||||
- Codex authentication
|
|
||||||
- personal shell aliases
|
|
||||||
- editor preferences
|
|
||||||
- secrets and API keys
|
|
||||||
- machine-specific customizations not documented as shared examples
|
|
||||||
|
|
||||||
## New Machine Setup
|
|
||||||
|
|
||||||
When starting on a new machine:
|
|
||||||
1. Clone the repository.
|
|
||||||
2. Authenticate Codex locally.
|
|
||||||
3. Checkout the correct branch, typically `develop` or the assigned task branch.
|
|
||||||
4. Read `AGENTS.md`, `docs/WORKFLOW.md`, `docs/MACHINE_SETUP.md`, `docs/TASK_TEMPLATE.md`, and the current architecture/ADR documents.
|
|
||||||
5. Run `make doctor` to validate the local repository bootstrap before starting a task.
|
|
||||||
|
|
||||||
## Codex Task Style
|
|
||||||
|
|
||||||
Codex tasks in this repository should follow this order:
|
|
||||||
1. Confirm branch strategy.
|
|
||||||
2. State the branch being used.
|
|
||||||
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 used.
|
|
||||||
8. Confirm the merge target.
|
|
||||||
9. Stop.
|
|
||||||
|
|
||||||
## Local Checks
|
|
||||||
|
|
||||||
Run `make doctor` as part of machine/bootstrap validation to confirm the repository foundation is present and aligned.
|
|
||||||
|
|
||||||
## Current Status
|
|
||||||
|
|
||||||
The repository currently provides:
|
|
||||||
- repository bootstrap and workflow foundation
|
|
||||||
- Codex/agent collaboration setup
|
|
||||||
- portable development baseline
|
|
||||||
- accepted phase-1 technical decisions for future implementation work
|
|
||||||
|
|
||||||
## Decision Baseline
|
|
||||||
|
|
||||||
Future implementation work should follow the accepted ADR baseline unless a later ADR supersedes it.
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
To contribute in the current phase:
|
|
||||||
- read `AGENTS.md`
|
- read `AGENTS.md`
|
||||||
- read `docs/WORKFLOW.md`
|
- read `docs/WORKFLOW.md`
|
||||||
- read the current ADR set in `docs/adr/`
|
- read the current ADR set in `docs/adr/`
|
||||||
- create a task branch from `develop`
|
- create a task branch from `develop`
|
||||||
- keep tasks narrowly scoped
|
- keep tasks narrowly scoped and aligned with accepted decisions
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
0
app/hoopscout/__init__.py
Normal file
0
app/hoopscout/__init__.py
Normal file
7
app/hoopscout/asgi.py
Normal file
7
app/hoopscout/asgi.py
Normal 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
72
app/hoopscout/settings.py
Normal 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
10
app/hoopscout/urls.py
Normal 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
7
app/hoopscout/wsgi.py
Normal 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
14
app/manage.py
Normal 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
2
app/requirements.txt
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
Django==5.2.2
|
||||||
|
psycopg[binary]==3.2.9
|
||||||
0
app/scouting/__init__.py
Normal file
0
app/scouting/__init__.py
Normal file
84
app/scouting/admin.py
Normal file
84
app/scouting/admin.py
Normal 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
6
app/scouting/apps.py
Normal 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
84
app/scouting/forms.py
Normal 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")
|
||||||
1
app/scouting/importers/__init__.py
Normal file
1
app/scouting/importers/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Importer modules for source-scoped real-data ingestion flows."""
|
||||||
330
app/scouting/importers/hoopdata_demo.py
Normal file
330
app/scouting/importers/hoopdata_demo.py
Normal 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")),
|
||||||
|
},
|
||||||
|
)
|
||||||
353
app/scouting/importers/lba_public.py
Normal file
353
app/scouting/importers/lba_public.py
Normal 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)
|
||||||
1
app/scouting/management/__init__.py
Normal file
1
app/scouting/management/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
app/scouting/management/commands/__init__.py
Normal file
1
app/scouting/management/commands/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
@ -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}")
|
||||||
@ -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}")
|
||||||
122
app/scouting/management/commands/seed_scouting_data.py
Normal file
122
app/scouting/management/commands/seed_scouting_data.py
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from scouting.models import (
|
||||||
|
Competition,
|
||||||
|
Player,
|
||||||
|
PlayerSeason,
|
||||||
|
PlayerSeasonStats,
|
||||||
|
Role,
|
||||||
|
Season,
|
||||||
|
Specialty,
|
||||||
|
Team,
|
||||||
|
)
|
||||||
|
from scouting.sample_data.scouting_seed import COMPETITIONS, PLAYERS, ROLES, SEASONS, SPECIALTIES, TEAMS
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Load a curated scouting sample dataset for local development."
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
roles = {}
|
||||||
|
for record in ROLES:
|
||||||
|
role, _ = Role.objects.update_or_create(
|
||||||
|
slug=record["slug"],
|
||||||
|
defaults={
|
||||||
|
"name": record["name"],
|
||||||
|
"description": record.get("description", ""),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
roles[role.name] = role
|
||||||
|
|
||||||
|
specialties = {}
|
||||||
|
for record in SPECIALTIES:
|
||||||
|
specialty, _ = Specialty.objects.update_or_create(
|
||||||
|
slug=record["slug"],
|
||||||
|
defaults={
|
||||||
|
"name": record["name"],
|
||||||
|
"description": record.get("description", ""),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
specialties[specialty.name] = specialty
|
||||||
|
|
||||||
|
competitions = {}
|
||||||
|
for record in COMPETITIONS:
|
||||||
|
competition, _ = Competition.objects.update_or_create(
|
||||||
|
name=record["name"],
|
||||||
|
defaults={
|
||||||
|
"country": record.get("country", ""),
|
||||||
|
"level": record.get("level", ""),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
competitions[competition.name] = competition
|
||||||
|
|
||||||
|
teams = {}
|
||||||
|
for record in TEAMS:
|
||||||
|
team, _ = Team.objects.update_or_create(
|
||||||
|
name=record["name"],
|
||||||
|
country=record.get("country", ""),
|
||||||
|
defaults={},
|
||||||
|
)
|
||||||
|
teams[(team.name, team.country)] = team
|
||||||
|
|
||||||
|
seasons = {}
|
||||||
|
for record in SEASONS:
|
||||||
|
season, _ = Season.objects.update_or_create(
|
||||||
|
name=record["name"],
|
||||||
|
defaults={
|
||||||
|
"start_year": record["start_year"],
|
||||||
|
"end_year": record["end_year"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
seasons[season.name] = season
|
||||||
|
|
||||||
|
for record in PLAYERS:
|
||||||
|
defaults = {
|
||||||
|
"first_name": record.get("first_name", ""),
|
||||||
|
"last_name": record.get("last_name", ""),
|
||||||
|
"birth_date": date.fromisoformat(record["birth_date"]) if record.get("birth_date") else None,
|
||||||
|
"nationality": record.get("nationality", ""),
|
||||||
|
"height_cm": record.get("height_cm"),
|
||||||
|
"weight_kg": record.get("weight_kg"),
|
||||||
|
"wingspan_cm": record.get("wingspan_cm"),
|
||||||
|
"position": record["position"],
|
||||||
|
}
|
||||||
|
player, _ = Player.objects.update_or_create(
|
||||||
|
full_name=record["full_name"],
|
||||||
|
defaults=defaults,
|
||||||
|
)
|
||||||
|
|
||||||
|
player.roles.set([roles[name] for name in record.get("roles", [])])
|
||||||
|
player.specialties.set([specialties[name] for name in record.get("specialties", [])])
|
||||||
|
|
||||||
|
for context_record in record.get("contexts", []):
|
||||||
|
context, _ = PlayerSeason.objects.update_or_create(
|
||||||
|
player=player,
|
||||||
|
season=seasons[context_record["season"]],
|
||||||
|
team=teams[context_record["team"]],
|
||||||
|
competition=competitions[context_record["competition"]],
|
||||||
|
defaults={},
|
||||||
|
)
|
||||||
|
PlayerSeasonStats.objects.update_or_create(
|
||||||
|
player_season=context,
|
||||||
|
defaults=context_record["stats"],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
"Seeded scouting sample data: "
|
||||||
|
f"{Player.objects.count()} players, "
|
||||||
|
f"{PlayerSeason.objects.count()} contexts, "
|
||||||
|
f"{PlayerSeasonStats.objects.count()} stat lines."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.stdout.write(
|
||||||
|
"Suggested filters: "
|
||||||
|
"PG + min assists, C + min blocks, SG/wing + min TS%, or role + specialty combinations."
|
||||||
|
)
|
||||||
145
app/scouting/migrations/0001_initial.py
Normal file
145
app/scouting/migrations/0001_initial.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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',
|
||||||
|
),
|
||||||
|
]
|
||||||
18
app/scouting/migrations/0003_alter_player_position.py
Normal file
18
app/scouting/migrations/0003_alter_player_position.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
28
app/scouting/migrations/0005_favoriteplayer.py
Normal file
28
app/scouting/migrations/0005_favoriteplayer.py
Normal 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"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
30
app/scouting/migrations/0006_playernote.py
Normal file
30
app/scouting/migrations/0006_playernote.py
Normal 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"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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"),
|
||||||
|
),
|
||||||
|
]
|
||||||
31
app/scouting/migrations/0008_savedsearch.py
Normal file
31
app/scouting/migrations/0008_savedsearch.py
Normal 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')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
29
app/scouting/migrations/0009_externalentitymapping.py
Normal file
29
app/scouting/migrations/0009_externalentitymapping.py
Normal 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')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
18
app/scouting/migrations/0010_alter_player_position.py
Normal file
18
app/scouting/migrations/0010_alter_player_position.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
app/scouting/migrations/__init__.py
Normal file
0
app/scouting/migrations/__init__.py
Normal file
261
app/scouting/models.py
Normal file
261
app/scouting/models.py
Normal 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}"
|
||||||
1
app/scouting/sample_data/__init__.py
Normal file
1
app/scouting/sample_data/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
148
app/scouting/sample_data/imports/lba_public_serie_a_fixture.json
Normal file
148
app/scouting/sample_data/imports/lba_public_serie_a_fixture.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
219
app/scouting/sample_data/scouting_seed.py
Normal file
219
app/scouting/sample_data/scouting_seed.py
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
|
||||||
|
ROLES = [
|
||||||
|
{"name": "playmaker", "slug": "playmaker"},
|
||||||
|
{"name": "shooting wing", "slug": "shooting-wing"},
|
||||||
|
{"name": "rim protector", "slug": "rim-protector"},
|
||||||
|
{"name": "stretch four", "slug": "stretch-four"},
|
||||||
|
{"name": "6th man", "slug": "6th-man"},
|
||||||
|
]
|
||||||
|
|
||||||
|
SPECIALTIES = [
|
||||||
|
{"name": "ball handling", "slug": "ball-handling"},
|
||||||
|
{"name": "off ball", "slug": "off-ball"},
|
||||||
|
{"name": "defense", "slug": "defense"},
|
||||||
|
{"name": "clutch", "slug": "clutch"},
|
||||||
|
{"name": "post", "slug": "post"},
|
||||||
|
]
|
||||||
|
|
||||||
|
COMPETITIONS = [
|
||||||
|
{"name": "Euro League", "country": "EU", "level": "top"},
|
||||||
|
{"name": "Italian Serie A", "country": "IT", "level": "top"},
|
||||||
|
{"name": "Spanish ACB", "country": "ES", "level": "top"},
|
||||||
|
]
|
||||||
|
|
||||||
|
TEAMS = [
|
||||||
|
{"name": "Milan Lions", "country": "IT"},
|
||||||
|
{"name": "Rome Falcons", "country": "IT"},
|
||||||
|
{"name": "Madrid Waves", "country": "ES"},
|
||||||
|
{"name": "Berlin Towers", "country": "DE"},
|
||||||
|
]
|
||||||
|
|
||||||
|
SEASONS = [
|
||||||
|
{"name": "2023-2024", "start_year": 2023, "end_year": 2024},
|
||||||
|
{"name": "2024-2025", "start_year": 2024, "end_year": 2025},
|
||||||
|
{"name": "2025-2026", "start_year": 2025, "end_year": 2026},
|
||||||
|
]
|
||||||
|
|
||||||
|
PLAYERS = [
|
||||||
|
{
|
||||||
|
"full_name": "Marco Guard",
|
||||||
|
"first_name": "Marco",
|
||||||
|
"last_name": "Guard",
|
||||||
|
"birth_date": "2002-01-01",
|
||||||
|
"nationality": "IT",
|
||||||
|
"height_cm": Decimal("188.00"),
|
||||||
|
"weight_kg": Decimal("82.00"),
|
||||||
|
"wingspan_cm": Decimal("194.00"),
|
||||||
|
"position": "PG",
|
||||||
|
"roles": ["playmaker"],
|
||||||
|
"specialties": ["ball handling", "clutch"],
|
||||||
|
"contexts": [
|
||||||
|
{
|
||||||
|
"season": "2025-2026",
|
||||||
|
"team": ("Milan Lions", "IT"),
|
||||||
|
"competition": "Euro League",
|
||||||
|
"stats": {
|
||||||
|
"points": Decimal("16.00"),
|
||||||
|
"assists": Decimal("8.20"),
|
||||||
|
"steals": Decimal("1.90"),
|
||||||
|
"turnovers": Decimal("2.40"),
|
||||||
|
"blocks": Decimal("0.20"),
|
||||||
|
"efg_pct": Decimal("53.40"),
|
||||||
|
"ts_pct": Decimal("59.80"),
|
||||||
|
"plus_minus": Decimal("4.60"),
|
||||||
|
"offensive_rating": Decimal("114.00"),
|
||||||
|
"defensive_rating": Decimal("105.00"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"season": "2024-2025",
|
||||||
|
"team": ("Rome Falcons", "IT"),
|
||||||
|
"competition": "Italian Serie A",
|
||||||
|
"stats": {
|
||||||
|
"points": Decimal("13.20"),
|
||||||
|
"assists": Decimal("6.90"),
|
||||||
|
"steals": Decimal("1.40"),
|
||||||
|
"turnovers": Decimal("2.90"),
|
||||||
|
"blocks": Decimal("0.10"),
|
||||||
|
"efg_pct": Decimal("49.80"),
|
||||||
|
"ts_pct": Decimal("55.10"),
|
||||||
|
"plus_minus": Decimal("1.20"),
|
||||||
|
"offensive_rating": Decimal("109.00"),
|
||||||
|
"defensive_rating": Decimal("108.00"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"full_name": "Luca Wing",
|
||||||
|
"first_name": "Luca",
|
||||||
|
"last_name": "Wing",
|
||||||
|
"birth_date": "1999-02-14",
|
||||||
|
"nationality": "IT",
|
||||||
|
"height_cm": Decimal("201.00"),
|
||||||
|
"weight_kg": Decimal("93.00"),
|
||||||
|
"wingspan_cm": Decimal("208.00"),
|
||||||
|
"position": "SF",
|
||||||
|
"roles": ["shooting wing"],
|
||||||
|
"specialties": ["off ball", "clutch"],
|
||||||
|
"contexts": [
|
||||||
|
{
|
||||||
|
"season": "2025-2026",
|
||||||
|
"team": ("Madrid Waves", "ES"),
|
||||||
|
"competition": "Spanish ACB",
|
||||||
|
"stats": {
|
||||||
|
"points": Decimal("17.40"),
|
||||||
|
"assists": Decimal("2.60"),
|
||||||
|
"steals": Decimal("1.30"),
|
||||||
|
"turnovers": Decimal("1.70"),
|
||||||
|
"blocks": Decimal("0.60"),
|
||||||
|
"efg_pct": Decimal("57.20"),
|
||||||
|
"ts_pct": Decimal("62.40"),
|
||||||
|
"plus_minus": Decimal("3.10"),
|
||||||
|
"offensive_rating": Decimal("118.00"),
|
||||||
|
"defensive_rating": Decimal("107.00"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"full_name": "Niko Anchor",
|
||||||
|
"first_name": "Niko",
|
||||||
|
"last_name": "Anchor",
|
||||||
|
"birth_date": "1998-07-03",
|
||||||
|
"nationality": "DE",
|
||||||
|
"height_cm": Decimal("211.00"),
|
||||||
|
"weight_kg": Decimal("109.00"),
|
||||||
|
"wingspan_cm": Decimal("221.00"),
|
||||||
|
"position": "C",
|
||||||
|
"roles": ["rim protector"],
|
||||||
|
"specialties": ["defense", "post"],
|
||||||
|
"contexts": [
|
||||||
|
{
|
||||||
|
"season": "2025-2026",
|
||||||
|
"team": ("Berlin Towers", "DE"),
|
||||||
|
"competition": "Euro League",
|
||||||
|
"stats": {
|
||||||
|
"points": Decimal("11.30"),
|
||||||
|
"assists": Decimal("1.80"),
|
||||||
|
"steals": Decimal("0.90"),
|
||||||
|
"turnovers": Decimal("1.80"),
|
||||||
|
"blocks": Decimal("2.40"),
|
||||||
|
"efg_pct": Decimal("58.30"),
|
||||||
|
"ts_pct": Decimal("61.10"),
|
||||||
|
"plus_minus": Decimal("5.20"),
|
||||||
|
"offensive_rating": Decimal("111.00"),
|
||||||
|
"defensive_rating": Decimal("101.00"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"full_name": "Sandro Forward",
|
||||||
|
"first_name": "Sandro",
|
||||||
|
"last_name": "Forward",
|
||||||
|
"birth_date": "2001-09-20",
|
||||||
|
"nationality": "IT",
|
||||||
|
"height_cm": Decimal("206.00"),
|
||||||
|
"weight_kg": Decimal("98.00"),
|
||||||
|
"wingspan_cm": Decimal("214.00"),
|
||||||
|
"position": "PF",
|
||||||
|
"roles": ["stretch four"],
|
||||||
|
"specialties": ["off ball"],
|
||||||
|
"contexts": [
|
||||||
|
{
|
||||||
|
"season": "2025-2026",
|
||||||
|
"team": ("Rome Falcons", "IT"),
|
||||||
|
"competition": "Italian Serie A",
|
||||||
|
"stats": {
|
||||||
|
"points": Decimal("15.10"),
|
||||||
|
"assists": Decimal("2.90"),
|
||||||
|
"steals": Decimal("0.80"),
|
||||||
|
"turnovers": Decimal("1.60"),
|
||||||
|
"blocks": Decimal("1.10"),
|
||||||
|
"efg_pct": Decimal("56.40"),
|
||||||
|
"ts_pct": Decimal("60.20"),
|
||||||
|
"plus_minus": Decimal("2.70"),
|
||||||
|
"offensive_rating": Decimal("116.00"),
|
||||||
|
"defensive_rating": Decimal("109.00"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"full_name": "Jalen Spark",
|
||||||
|
"first_name": "Jalen",
|
||||||
|
"last_name": "Spark",
|
||||||
|
"birth_date": "2000-11-11",
|
||||||
|
"nationality": "US",
|
||||||
|
"height_cm": Decimal("193.00"),
|
||||||
|
"weight_kg": Decimal("87.00"),
|
||||||
|
"wingspan_cm": Decimal("199.00"),
|
||||||
|
"position": "SG",
|
||||||
|
"roles": ["6th man"],
|
||||||
|
"specialties": ["ball handling", "off ball"],
|
||||||
|
"contexts": [
|
||||||
|
{
|
||||||
|
"season": "2024-2025",
|
||||||
|
"team": ("Milan Lions", "IT"),
|
||||||
|
"competition": "Italian Serie A",
|
||||||
|
"stats": {
|
||||||
|
"points": Decimal("18.60"),
|
||||||
|
"assists": Decimal("3.40"),
|
||||||
|
"steals": Decimal("1.10"),
|
||||||
|
"turnovers": Decimal("2.20"),
|
||||||
|
"blocks": Decimal("0.30"),
|
||||||
|
"efg_pct": Decimal("54.10"),
|
||||||
|
"ts_pct": Decimal("58.70"),
|
||||||
|
"plus_minus": Decimal("1.80"),
|
||||||
|
"offensive_rating": Decimal("113.00"),
|
||||||
|
"defensive_rating": Decimal("111.00"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
24
app/scouting/templates/registration/login.html
Normal file
24
app/scouting/templates/registration/login.html
Normal 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>
|
||||||
36
app/scouting/templates/scouting/favorites_list.html
Normal file
36
app/scouting/templates/scouting/favorites_list.html
Normal 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>
|
||||||
130
app/scouting/templates/scouting/player_detail.html
Normal file
130
app/scouting/templates/scouting/player_detail.html
Normal 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>
|
||||||
185
app/scouting/templates/scouting/player_list.html
Normal file
185
app/scouting/templates/scouting/player_list.html
Normal 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 }}&{% 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 }}&{% endif %}page={{ page_obj.next_page_number }}">Next</a>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1028
app/scouting/tests.py
Normal file
1028
app/scouting/tests.py
Normal file
File diff suppressed because it is too large
Load Diff
17
app/scouting/urls.py
Normal file
17
app/scouting/urls.py
Normal 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
512
app/scouting/views.py
Normal 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)}")
|
||||||
@ -6,7 +6,7 @@ This document will become the central architecture overview for HoopScout v2. It
|
|||||||
|
|
||||||
## Current Status
|
## Current Status
|
||||||
|
|
||||||
This is still a phase-1 architecture overview, but the repository now has its first accepted concrete technical decision in `docs/adr/0001-runtime-and-development-stack.md`. Later implementation work should follow that corrected baseline unless a future ADR supersedes it.
|
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
|
## Decision-Driven Development
|
||||||
|
|
||||||
@ -25,6 +25,9 @@ The current baseline decision is:
|
|||||||
- `ADR-0002`: initial project structure baseline
|
- `ADR-0002`: initial project structure baseline
|
||||||
- `ADR-0003`: containerized developer workflow baseline
|
- `ADR-0003`: containerized developer workflow baseline
|
||||||
- `ADR-0004`: configuration and environment strategy 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:
|
The current baseline assumes:
|
||||||
- Python 3
|
- Python 3
|
||||||
@ -40,6 +43,12 @@ Future runtime and scaffolding work should also follow the developer workflow de
|
|||||||
|
|
||||||
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 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 Sections Placeholder
|
||||||
|
|
||||||
Future versions of this document may include sections such as:
|
Future versions of this document may include sections such as:
|
||||||
|
|||||||
110
docs/adr/0005-scouting-search-domain.md
Normal file
110
docs/adr/0005-scouting-search-domain.md
Normal 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.
|
||||||
156
docs/adr/0006-initial-domain-model.md
Normal file
156
docs/adr/0006-initial-domain-model.md
Normal 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.
|
||||||
146
docs/adr/0009-real-data-ingestion-baseline.md
Normal file
146
docs/adr/0009-real-data-ingestion-baseline.md
Normal 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.
|
||||||
35
infra/docker-compose.yml
Normal file
35
infra/docker-compose.yml
Normal 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
13
infra/docker/Dockerfile
Normal 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"]
|
||||||
Reference in New Issue
Block a user