Compare commits

..

22 Commits

Author SHA1 Message Date
db454371a5 Merge branch 'feature/phase-2-position-nullability-fix' into develop 2026-04-11 00:54:22 +02:00
e6081428ae fix(ingestion): stop fabricating missing player positions 2026-04-11 00:54:19 +02:00
677b5af40d Merge branch 'feature/phase-2-first-public-european-importer' into develop 2026-04-11 00:45:39 +02:00
88ca5cde10 feat(ingestion): add first public LBA Serie A importer 2026-04-11 00:45:33 +02:00
9524c928e2 Merge branch 'feature/phase-2-first-real-ingestion-flow' into develop 2026-04-11 00:24:48 +02:00
154450f516 feat(ingestion): add first real-data importer flow 2026-04-11 00:24:42 +02:00
5c82187b7c Merge branch 'feature/phase-2-ingestion-baseline-decision' into develop 2026-04-11 00:17:09 +02:00
e1365761c9 docs(adr): define real-data ingestion baseline 2026-04-11 00:17:06 +02:00
b27db5e6a3 Merge branch 'feature/phase-2-saved-searches-and-search-refinement' into develop 2026-04-11 00:08:39 +02:00
c09aad2d63 feat(scouting): add wingspan filters and saved searches mvp 2026-04-11 00:07:55 +02:00
e44cad6167 Merge branch 'feature/phase-2-user-scoped-auth-and-notes' into develop 2026-04-07 18:11:39 +02:00
caa1f8354d feat: add user-scoped favorites and notes 2026-04-07 18:11:19 +02:00
a5e1d841df Merge branch 'feature/phase-2-player-notes-mvp' into develop 2026-04-07 17:42:18 +02:00
4651746427 feat: add shared scouting notes mvp 2026-04-07 17:41:53 +02:00
4f869c1c02 Merge branch 'feature/phase-2-favorites-mvp' into develop 2026-04-07 17:30:25 +02:00
99820419c4 feat: add scouting shortlist mvp 2026-04-07 17:29:55 +02:00
d1b5499a63 Merge branch 'feature/phase-2-search-sorting-pagination' into develop 2026-04-07 17:18:54 +02:00
6d8af021ce feat: add scouting search sorting and pagination 2026-04-07 17:18:36 +02:00
6c53cae7a1 Merge branch 'feature/phase-2-seed-data-baseline' into develop 2026-04-07 16:47:15 +02:00
ff4a3020d5 feat: add scouting sample seed data baseline 2026-04-07 16:46:59 +02:00
3e6fb34017 Merge branch 'feature/phase-2-search-result-context-improvement' into develop 2026-04-07 16:35:11 +02:00
dbf218e2fd feat: show matching season context in player results 2026-04-07 16:31:00 +02:00
33 changed files with 3418 additions and 125 deletions

175
README.md
View File

@ -1,40 +1,24 @@
# 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:
- Codex-assisted development
- custom agent usage
- repeatable task execution
- repository-owned instructions
- machine portability
- branch discipline
- implementation guidance driven by accepted ADRs
## Current MVP
## 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:
- 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:
Accepted technical and product-shaping decisions live in:
- `docs/ARCHITECTURE.md`
- `docs/ARCHITECTURE_PRINCIPLES.md`
- `docs/DECISION_PROCESS.md`
@ -42,105 +26,78 @@ Key decision references:
## Repository Structure
The repository is organized to keep durable workflow guidance and technical decision records in version control and portable across machines.
```text
.
|-- .codex/
|-- .agents/skills/
|-- app/
| |-- hoopscout/
| `-- scouting/
|-- docs/
|-- infra/
|-- scripts/
|-- tests/
|-- AGENTS.md
|-- Makefile
|-- README.md
|-- .editorconfig
`-- .gitignore
`-- README.md
```
- `.codex/` stores repository-scoped Codex configuration and agent definitions.
- `.agents/skills/` stores reusable skills for repeatable repository workflows.
- `docs/` stores workflow, architecture, ADRs, machine setup, and task execution guidance.
- `scripts/` stores repository utility scripts such as local checks.
- `AGENTS.md` defines repository-wide agent behavior and task rules.
- `Makefile` exposes standard project commands.
- `README.md` introduces the repository and current phase.
- `.editorconfig` provides shared formatting defaults.
- `.gitignore` defines ignored files for the repository.
- `app/hoopscout/` contains the Django project settings and root URLs.
- `app/scouting/` contains the scouting domain models, views, templates, management commands, and tests tied to the app.
- `infra/` contains the local Docker Compose and image setup.
- `docs/` contains workflow and ADR documentation.
- `scripts/` contains repository checks such as `make doctor`.
## Local Development
1. Start the stack with `docker compose --env-file .env -f infra/docker-compose.yml up -d --build`.
2. Apply migrations with `docker compose --env-file .env -f infra/docker-compose.yml exec -T app python manage.py migrate`.
3. Load sample data with `docker compose --env-file .env -f infra/docker-compose.yml exec -T app python manage.py seed_scouting_data`.
4. Create a local user with `docker compose --env-file .env -f infra/docker-compose.yml exec -T app python manage.py createsuperuser` if you need a development login.
5. Visit `http://127.0.0.1:8000/players/` to explore the scouting search MVP.
6. Use player-level filters (including wingspan), context filters, and stat filters to run scouting searches with sorting and pagination.
7. Log in at `http://127.0.0.1:8000/accounts/login/` to manage your own shortlist, notes, and saved searches.
8. Save useful filter combinations from the player search page, rerun them later, or delete them.
9. Open player detail pages to review context rows and create user-scoped notes.
10. Use `http://127.0.0.1:8000/favorites/` to review your user-scoped shortlist.
First real-data importer (ADR-0009 baseline):
- default sample snapshot import:
`docker compose --env-file .env -f infra/docker-compose.yml exec -T app python manage.py import_hoopdata_demo_competition`
- explicit input path import:
`docker compose --env-file .env -f infra/docker-compose.yml exec -T app python manage.py import_hoopdata_demo_competition --input /app/scouting/sample_data/imports/hoopdata_demo_serie_a2_2025_2026.json`
First public European importer (LBA Serie A scope):
- live public-source import (season start year 2025):
`docker compose --env-file .env -f infra/docker-compose.yml exec -T app python manage.py import_lba_public_serie_a --season 2025`
- deterministic local-fixture import:
`docker compose --env-file .env -f infra/docker-compose.yml exec -T app python manage.py import_lba_public_serie_a --season 2025 --fixture /app/scouting/sample_data/imports/lba_public_serie_a_fixture.json`
- note: when the public source does not expose a player position, HoopScout keeps `position` empty instead of fabricating a value.
Legacy shared favorites and notes from the pre-auth MVP are cleared by the early-stage ownership migration so the app can move cleanly to user-scoped data.
## Workflow
Protected branches:
- `main`
- `develop`
- `main` is the stable branch.
- `develop` is the integration branch.
- normal work goes through `feature/*` branches created from `develop`.
- run `make doctor` before or during local setup to confirm the repository foundation is present.
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
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.
Local-only responsibilities still include authentication, personal editor setup, shell aliases, and secrets.
## Contributing
To contribute in the current phase:
- read `AGENTS.md`
- read `docs/WORKFLOW.md`
- read the current ADR set in `docs/adr/`
- create a task branch from `develop`
- keep tasks narrowly scoped
- keep tasks narrowly scoped and aligned with accepted decisions
## License

View File

@ -66,3 +66,7 @@ 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"

View File

@ -1,7 +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")),
]

View File

@ -2,7 +2,9 @@ from django.contrib import admin
from .models import (
Competition,
FavoritePlayer,
Player,
PlayerNote,
PlayerSeason,
PlayerSeasonStats,
Role,
@ -68,3 +70,15 @@ class PlayerSeasonStatsAdmin(admin.ModelAdmin):
"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")

View File

@ -8,7 +8,20 @@ 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,
@ -29,6 +42,8 @@ class PlayerSearchForm(forms.Form):
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())
@ -63,3 +78,7 @@ class PlayerSearchForm(forms.Form):
def birth_date_lower_bound_for_age(max_age: int) -> date:
today = date.today()
return today.replace(year=today.year - max_age - 1)
class SavedSearchForm(forms.Form):
saved_search_name = forms.CharField(required=True, max_length=120, label="Save current search as")

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,4 @@
from django.conf import settings
from django.db import models
@ -41,7 +42,7 @@ class Player(models.Model):
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)
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)
@ -161,3 +162,100 @@ class PlayerSeasonStats(models.Model):
def __str__(self) -> str:
return f"Stats for {self.player_season}"
class FavoritePlayer(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="favorite_players",
)
player = models.ForeignKey(
Player,
on_delete=models.CASCADE,
related_name="favorite_entries",
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-created_at", "player__full_name"]
constraints = [
models.UniqueConstraint(fields=["user", "player"], name="uniq_favorite_player_per_user"),
]
def __str__(self) -> str:
return f"Favorite: {self.user} -> {self.player.full_name}"
class PlayerNote(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="player_notes",
)
player = models.ForeignKey(
Player,
on_delete=models.CASCADE,
related_name="notes",
)
body = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-created_at", "-id"]
def __str__(self) -> str:
return f"Note by {self.user} for {self.player.full_name}"
class SavedSearch(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="saved_searches",
)
name = models.CharField(max_length=120)
params = models.JSONField(default=dict)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["name", "-updated_at", "-id"]
constraints = [
models.UniqueConstraint(fields=["user", "name"], name="uniq_saved_search_name_per_user"),
]
def __str__(self) -> str:
return f"{self.user} - {self.name}"
class ExternalEntityMapping(models.Model):
class EntityType(models.TextChoices):
PLAYER = "player", "Player"
COMPETITION = "competition", "Competition"
TEAM = "team", "Team"
PLAYER_SEASON = "player_season", "Player season"
source_name = models.CharField(max_length=80)
entity_type = models.CharField(max_length=30, choices=EntityType.choices)
external_id = models.CharField(max_length=140)
object_id = models.PositiveBigIntegerField()
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["source_name", "entity_type", "external_id"]
constraints = [
models.UniqueConstraint(
fields=["source_name", "entity_type", "external_id"],
name="uniq_external_entity_mapping",
),
models.UniqueConstraint(
fields=["source_name", "entity_type", "object_id"],
name="uniq_external_entity_target",
),
]
def __str__(self) -> str:
return f"{self.source_name}:{self.entity_type}:{self.external_id} -> {self.object_id}"

View File

@ -0,0 +1 @@

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,9 +5,40 @@
<title>{{ player.full_name }}</title>
</head>
<body>
<p><a href="{% url 'scouting:player_list' %}">Back to search</a></p>
<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>
@ -33,6 +64,38 @@
{% 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 %}

View File

@ -6,20 +6,81 @@
</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.position.label_tag }} {{ form.position }}
{{ form.role.label_tag }} {{ form.role }}
{{ form.specialty.label_tag }} {{ form.specialty }}
{{ 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>
@ -44,18 +105,81 @@
</fieldset>
<button type="submit">Search</button>
<a href="{% url 'scouting:player_list' %}">Clear filters</a>
</form>
<h2>Results ({{ players|length }})</h2>
<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 %}
<li>No players found.</li>
{% if has_submitted_search %}
<li>No players found for the current search filters.</li>
{% else %}
<li>No players available yet.</li>
{% endif %}
{% endfor %}
</ul>
{% if page_obj.paginator.num_pages > 1 %}
<nav aria-label="Pagination">
{% if page_obj.has_previous %}
<a href="?{% if query_without_page %}{{ query_without_page }}&amp;{% endif %}page={{ page_obj.previous_page_number }}">Previous</a>
{% endif %}
<span>Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span>
{% if page_obj.has_next %}
<a href="?{% if query_without_page %}{{ query_without_page }}&amp;{% endif %}page={{ page_obj.next_page_number }}">Next</a>
{% endif %}
</nav>
{% endif %}
</body>
</html>

View File

@ -1,10 +1,31 @@
from datetime import date
from decimal import Decimal
from pathlib import Path
from urllib.parse import urlencode
from django.test import TestCase
from django.contrib.auth import get_user_model
from django.core.management import CommandError, call_command
from django.db import connection
from django.db.migrations.executor import MigrationExecutor
from django.test import TestCase, TransactionTestCase
from django.urls import reverse
from .models import Competition, Player, PlayerSeason, PlayerSeasonStats, Role, Season, Specialty, Team
from .models import (
Competition,
ExternalEntityMapping,
FavoritePlayer,
Player,
PlayerNote,
PlayerSeason,
PlayerSeasonStats,
Role,
SavedSearch,
Season,
Specialty,
Team,
)
User = get_user_model()
class ScoutingSearchViewsTests(TestCase):
@ -28,6 +49,7 @@ class ScoutingSearchViewsTests(TestCase):
position="PG",
height_cm=Decimal("188.00"),
weight_kg=Decimal("82.00"),
wingspan_cm=Decimal("194.00"),
nationality="IT",
)
cls.player_pg.roles.add(cls.role_playmaker)
@ -39,10 +61,19 @@ class ScoutingSearchViewsTests(TestCase):
position="SF",
height_cm=Decimal("201.00"),
weight_kg=Decimal("95.00"),
wingspan_cm=Decimal("211.00"),
)
cls.player_wing.roles.add(cls.role_3d)
cls.player_wing.specialties.add(cls.specialty_offball)
cls.player_unknown_position = Player.objects.create(
full_name="No Position Prospect",
birth_date=date(2001, 3, 3),
position=None,
height_cm=Decimal("180.00"),
weight_kg=Decimal("88.00"),
)
cls.ctx_pg_good = PlayerSeason.objects.create(
player=cls.player_pg,
season=cls.season_2025,
@ -121,6 +152,33 @@ class ScoutingSearchViewsTests(TestCase):
self.assertContains(response, self.player_pg.full_name)
self.assertNotContains(response, self.player_wing.full_name)
def test_players_with_null_position_are_searchable_by_other_fields(self):
response = self.client.get(
reverse("scouting:player_list"),
{"name": "No Position"},
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, self.player_unknown_position.full_name)
def test_position_filter_excludes_players_with_null_position(self):
response = self.client.get(
reverse("scouting:player_list"),
{"position": "SG"},
)
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, self.player_unknown_position.full_name)
def test_filter_by_wingspan_thresholds(self):
response = self.client.get(
reverse("scouting:player_list"),
{"min_wingspan_cm": "205"},
)
self.assertContains(response, self.player_wing.full_name)
self.assertNotContains(response, self.player_pg.full_name)
def test_filter_by_context_fields_and_stats(self):
response = self.client.get(
reverse("scouting:player_list"),
@ -134,6 +192,37 @@ class ScoutingSearchViewsTests(TestCase):
self.assertContains(response, self.player_pg.full_name)
self.assertNotContains(response, self.player_wing.full_name)
def test_result_row_shows_matching_context_for_filtered_search(self):
response = self.client.get(
reverse("scouting:player_list"),
{
"competition": self.comp_a.id,
"season": self.season_2025.id,
"team": self.team_a.id,
"min_ts_pct": "57",
},
)
player = next(player for player in response.context["players"] if player.id == self.player_pg.id)
self.assertEqual(player.matching_context.id, self.ctx_pg_good.id)
self.assertContains(response, "Match context:")
self.assertContains(response, self.season_2025.name)
self.assertContains(response, "PTS 16.00")
def test_result_row_does_not_show_non_matching_context(self):
response = self.client.get(
reverse("scouting:player_list"),
{
"competition": self.comp_a.id,
"season": self.season_2025.id,
"team": self.team_a.id,
"min_ts_pct": "57",
},
)
player = next(player for player in response.context["players"] if player.id == self.player_pg.id)
self.assertEqual(player.matching_context.id, self.ctx_pg_good.id)
self.assertNotContains(response, "PTS 10.00")
self.assertNotContains(response, "AST 4.00")
def test_no_false_positive_from_different_context_rows(self):
response = self.client.get(
reverse("scouting:player_list"),
@ -157,6 +246,44 @@ class ScoutingSearchViewsTests(TestCase):
self.assertContains(response, self.player_pg.full_name)
self.assertNotContains(response, self.player_wing.full_name)
def test_matching_context_selection_is_deterministic_when_multiple_contexts_match(self):
second_matching_context = PlayerSeason.objects.create(
player=self.player_pg,
season=self.season_2024,
team=self.team_a,
competition=self.comp_a,
)
PlayerSeasonStats.objects.create(
player_season=second_matching_context,
points=Decimal("18.00"),
assists=Decimal("6.20"),
steals=Decimal("1.50"),
turnovers=Decimal("2.00"),
blocks=Decimal("0.20"),
efg_pct=Decimal("52.00"),
ts_pct=Decimal("57.50"),
plus_minus=Decimal("3.50"),
offensive_rating=Decimal("110.00"),
defensive_rating=Decimal("105.00"),
)
response = self.client.get(
reverse("scouting:player_list"),
{
"competition": self.comp_a.id,
"team": self.team_a.id,
},
)
player = next(player for player in response.context["players"] if player.id == self.player_pg.id)
self.assertEqual(player.matching_context.id, self.ctx_pg_good.id)
self.assertContains(response, self.season_2025.name)
self.assertNotContains(response, "PTS 18.00")
def test_player_detail_page_still_loads_after_related_loading_cleanup(self):
response = self.client.get(reverse("scouting:player_detail", args=[self.player_pg.id]))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "PTS 16.00")
def test_combined_team_and_defensive_rating_quality_filter(self):
response = self.client.get(
reverse("scouting:player_list"),
@ -167,3 +294,735 @@ class ScoutingSearchViewsTests(TestCase):
)
self.assertContains(response, self.player_pg.full_name)
self.assertNotContains(response, self.player_wing.full_name)
def test_sorting_by_player_level_field(self):
taller_player = Player.objects.create(
full_name="Big Wing",
birth_date=date(2001, 5, 5),
position="SF",
height_cm=Decimal("208.00"),
weight_kg=Decimal("101.00"),
)
response = self.client.get(reverse("scouting:player_list"), {"sort": "height_desc"})
player_names = [player.full_name for player in response.context["players"]]
self.assertEqual(player_names[:3], [taller_player.full_name, self.player_wing.full_name, self.player_pg.full_name])
def test_sorting_by_matching_context_stat_field(self):
response = self.client.get(
reverse("scouting:player_list"),
{"competition": self.comp_a.id, "sort": "assists_desc"},
)
player_names = [player.full_name for player in response.context["players"]]
self.assertEqual(player_names[:2], [self.player_pg.full_name, self.player_wing.full_name])
def test_context_sorting_preserves_matching_context_semantics(self):
second_context = PlayerSeason.objects.create(
player=self.player_pg,
season=self.season_2024,
team=self.team_b,
competition=self.comp_a,
)
PlayerSeasonStats.objects.create(
player_season=second_context,
points=Decimal("12.00"),
assists=Decimal("9.00"),
steals=Decimal("1.00"),
turnovers=Decimal("2.80"),
blocks=Decimal("0.20"),
efg_pct=Decimal("49.00"),
ts_pct=Decimal("52.00"),
plus_minus=Decimal("1.00"),
offensive_rating=Decimal("107.00"),
defensive_rating=Decimal("109.00"),
)
response = self.client.get(
reverse("scouting:player_list"),
{"competition": self.comp_a.id, "team": self.team_a.id, "sort": "assists_desc"},
)
player = next(player for player in response.context["players"] if player.id == self.player_pg.id)
self.assertEqual(player.matching_context.id, self.ctx_pg_good.id)
self.assertContains(response, "AST 7.50")
self.assertNotContains(response, "AST 9.00")
def test_pagination_works_on_player_list(self):
for index in range(25):
Player.objects.create(
full_name=f"Depth Player {index:02d}",
birth_date=date(2000, 1, 1),
position="SG",
height_cm=Decimal("190.00"),
weight_kg=Decimal("84.00"),
)
response = self.client.get(reverse("scouting:player_list"), {"page": 2})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context["page_obj"].number, 2)
self.assertContains(response, "Page 2 of 2")
self.assertContains(response, "Depth Player 20")
self.assertContains(response, "Depth Player 24")
self.assertNotContains(response, "Depth Player 00")
def test_pagination_preserves_filters_and_sort_order(self):
for index in range(25):
Player.objects.create(
full_name=f"Guard Prospect {index:02d}",
birth_date=date(2001, 1, 1),
position="PG",
height_cm=Decimal("185.00"),
weight_kg=Decimal("80.00"),
)
response = self.client.get(
reverse("scouting:player_list"),
{"position": "PG", "sort": "name_desc"},
)
self.assertContains(response, "position=PG")
self.assertContains(response, "sort=name_desc")
self.assertContains(response, "page=2")
def test_combined_filters_sort_and_pagination(self):
for index in range(25):
player = Player.objects.create(
full_name=f"Playmaker Prospect {index:02d}",
birth_date=date(2003, 1, 1),
position="PG",
height_cm=Decimal("186.00"),
weight_kg=Decimal("79.00"),
)
context = PlayerSeason.objects.create(
player=player,
season=self.season_2025,
team=self.team_a,
competition=self.comp_a,
)
PlayerSeasonStats.objects.create(
player_season=context,
points=Decimal("10.00"),
assists=Decimal(str(30 - index)),
steals=Decimal("1.00"),
turnovers=Decimal("2.00"),
blocks=Decimal("0.10"),
efg_pct=Decimal("50.00"),
ts_pct=Decimal("56.00"),
plus_minus=Decimal("1.00"),
offensive_rating=Decimal("109.00"),
defensive_rating=Decimal("108.00"),
)
response = self.client.get(
reverse("scouting:player_list"),
{
"position": "PG",
"competition": self.comp_a.id,
"sort": "assists_desc",
"page": 2,
},
)
self.assertEqual(response.context["page_obj"].number, 2)
player_names = [player.full_name for player in response.context["players"]]
self.assertNotIn(self.player_wing.full_name, player_names)
self.assertEqual(player_names[0], "Playmaker Prospect 20")
self.assertIn("Marco Guard", player_names)
self.assertContains(response, "sort=assists_desc")
self.assertContains(response, "competition=%s" % self.comp_a.id)
class SeedScoutingDataCommandTests(TestCase):
def test_seed_command_creates_expected_core_objects(self):
call_command("seed_scouting_data")
self.assertGreaterEqual(Competition.objects.count(), 3)
self.assertGreaterEqual(Team.objects.count(), 4)
self.assertGreaterEqual(Season.objects.count(), 3)
self.assertGreaterEqual(Player.objects.count(), 5)
self.assertGreaterEqual(PlayerSeason.objects.count(), 6)
self.assertEqual(PlayerSeason.objects.count(), PlayerSeasonStats.objects.count())
player = Player.objects.get(full_name="Marco Guard")
self.assertEqual(player.position, Player.Position.PG)
self.assertTrue(player.roles.filter(slug="playmaker").exists())
self.assertTrue(player.specialties.filter(slug="ball-handling").exists())
def test_seed_command_is_idempotent_for_repeat_runs(self):
call_command("seed_scouting_data")
first_counts = {
"players": Player.objects.count(),
"contexts": PlayerSeason.objects.count(),
"stats": PlayerSeasonStats.objects.count(),
"roles": Role.objects.count(),
"specialties": Specialty.objects.count(),
}
call_command("seed_scouting_data")
self.assertEqual(Player.objects.count(), first_counts["players"])
self.assertEqual(PlayerSeason.objects.count(), first_counts["contexts"])
self.assertEqual(PlayerSeasonStats.objects.count(), first_counts["stats"])
self.assertEqual(Role.objects.count(), first_counts["roles"])
self.assertEqual(Specialty.objects.count(), first_counts["specialties"])
class FirstRealIngestionFlowTests(TestCase):
COMMAND_NAME = "import_hoopdata_demo_competition"
SOURCE_NAME = "hoopdata_demo"
def test_importer_command_runs_successfully(self):
call_command(self.COMMAND_NAME)
self.assertGreaterEqual(Player.objects.count(), 2)
def test_importer_creates_expected_core_objects(self):
call_command(self.COMMAND_NAME)
self.assertTrue(Competition.objects.filter(name="Italian Serie A2").exists())
self.assertTrue(Season.objects.filter(name="2025-2026", start_year=2025, end_year=2026).exists())
self.assertTrue(Player.objects.filter(full_name="Andrea Pulse", position="PG").exists())
self.assertTrue(Player.objects.filter(full_name="Matteo Harbor", position="C").exists())
self.assertTrue(PlayerSeason.objects.filter(player__full_name="Andrea Pulse").exists())
self.assertTrue(PlayerSeasonStats.objects.filter(player_season__player__full_name="Andrea Pulse").exists())
self.assertEqual(
ExternalEntityMapping.objects.filter(source_name=self.SOURCE_NAME).count(),
7,
)
def test_importer_is_idempotent_for_same_input(self):
call_command(self.COMMAND_NAME)
first_counts = {
"players": Player.objects.count(),
"teams": Team.objects.count(),
"contexts": PlayerSeason.objects.count(),
"stats": PlayerSeasonStats.objects.count(),
"mappings": ExternalEntityMapping.objects.count(),
}
call_command(self.COMMAND_NAME)
self.assertEqual(Player.objects.count(), first_counts["players"])
self.assertEqual(Team.objects.count(), first_counts["teams"])
self.assertEqual(PlayerSeason.objects.count(), first_counts["contexts"])
self.assertEqual(PlayerSeasonStats.objects.count(), first_counts["stats"])
self.assertEqual(ExternalEntityMapping.objects.count(), first_counts["mappings"])
def test_importer_does_not_overwrite_internal_scouting_fields(self):
role = Role.objects.create(name="internal role", slug="internal-role")
specialty = Specialty.objects.create(name="internal specialty", slug="internal-specialty")
call_command(self.COMMAND_NAME)
player = Player.objects.get(full_name="Andrea Pulse")
player.roles.add(role)
player.specialties.add(specialty)
call_command(self.COMMAND_NAME)
player.refresh_from_db()
self.assertTrue(player.roles.filter(pk=role.pk).exists())
self.assertTrue(player.specialties.filter(pk=specialty.pk).exists())
def test_importer_does_not_interfere_with_user_owned_data(self):
call_command(self.COMMAND_NAME)
user = User.objects.create_user(username="ingest_user", password="pass12345")
player = Player.objects.get(full_name="Andrea Pulse")
favorite = FavoritePlayer.objects.create(user=user, player=player)
note = PlayerNote.objects.create(user=user, player=player, body="Tracked after import")
saved = SavedSearch.objects.create(user=user, name="Imported PG", params={"name": "Andrea"})
call_command(self.COMMAND_NAME)
self.assertTrue(FavoritePlayer.objects.filter(pk=favorite.pk).exists())
self.assertTrue(PlayerNote.objects.filter(pk=note.pk).exists())
self.assertTrue(SavedSearch.objects.filter(pk=saved.pk).exists())
def test_imported_data_is_visible_in_search_and_detail_flows(self):
call_command(self.COMMAND_NAME)
list_response = self.client.get(reverse("scouting:player_list"), {"name": "Andrea"})
self.assertEqual(list_response.status_code, 200)
self.assertContains(list_response, "Andrea Pulse")
player = Player.objects.get(full_name="Andrea Pulse")
detail_response = self.client.get(reverse("scouting:player_detail", args=[player.id]))
self.assertEqual(detail_response.status_code, 200)
self.assertContains(detail_response, "Andrea Pulse")
self.assertContains(detail_response, "PTS 17.2")
class FirstPublicEuropeanImporterTests(TestCase):
COMMAND_NAME = "import_lba_public_serie_a"
SOURCE_NAME = "lba_public"
FIXTURE_PATH = Path(__file__).resolve().parent / "sample_data" / "imports" / "lba_public_serie_a_fixture.json"
def run_import(self):
call_command(self.COMMAND_NAME, fixture=str(self.FIXTURE_PATH), season=2025)
def test_importer_command_runs_successfully_for_lba_scope(self):
self.run_import()
self.assertGreaterEqual(Player.objects.count(), 2)
def test_importer_creates_expected_entities_contexts_and_stats(self):
self.run_import()
self.assertTrue(Competition.objects.filter(name="Lega Basket Serie A").exists())
self.assertTrue(Season.objects.filter(name="2025-2026", start_year=2025, end_year=2026).exists())
self.assertTrue(Player.objects.filter(full_name="Muhammad-Ali Abdur-Rahkman").exists())
self.assertTrue(Player.objects.filter(full_name="Nicola Akele").exists())
self.assertTrue(Team.objects.filter(name="NutriBullet Treviso Basket", country="IT").exists())
self.assertTrue(PlayerSeason.objects.filter(player__full_name="Muhammad-Ali Abdur-Rahkman").exists())
self.assertTrue(PlayerSeasonStats.objects.filter(player_season__player__full_name="Muhammad-Ali Abdur-Rahkman").exists())
def test_importer_does_not_assign_fake_position_when_source_position_is_missing(self):
self.run_import()
player = Player.objects.get(full_name="Muhammad-Ali Abdur-Rahkman")
self.assertIsNone(player.position)
def test_importer_preserves_existing_real_position_when_source_position_is_missing(self):
self.run_import()
player = Player.objects.get(full_name="Muhammad-Ali Abdur-Rahkman")
player.position = Player.Position.PG
player.save(update_fields=["position", "updated_at"])
self.run_import()
player.refresh_from_db()
self.assertEqual(player.position, Player.Position.PG)
def test_importer_is_idempotent_for_same_input(self):
self.run_import()
first_counts = {
"players": Player.objects.count(),
"teams": Team.objects.count(),
"contexts": PlayerSeason.objects.count(),
"stats": PlayerSeasonStats.objects.count(),
"mappings": ExternalEntityMapping.objects.filter(source_name=self.SOURCE_NAME).count(),
}
self.run_import()
self.assertEqual(Player.objects.count(), first_counts["players"])
self.assertEqual(Team.objects.count(), first_counts["teams"])
self.assertEqual(PlayerSeason.objects.count(), first_counts["contexts"])
self.assertEqual(PlayerSeasonStats.objects.count(), first_counts["stats"])
self.assertEqual(
ExternalEntityMapping.objects.filter(source_name=self.SOURCE_NAME).count(),
first_counts["mappings"],
)
def test_importer_respects_external_entity_mapping(self):
self.run_import()
self.assertTrue(
ExternalEntityMapping.objects.filter(
source_name=self.SOURCE_NAME,
entity_type=ExternalEntityMapping.EntityType.COMPETITION,
external_id="lba-serie-a",
).exists()
)
self.assertTrue(
ExternalEntityMapping.objects.filter(
source_name=self.SOURCE_NAME,
entity_type=ExternalEntityMapping.EntityType.PLAYER,
external_id="6609",
).exists()
)
self.assertTrue(
ExternalEntityMapping.objects.filter(
source_name=self.SOURCE_NAME,
entity_type=ExternalEntityMapping.EntityType.TEAM,
external_id="1642",
).exists()
)
self.assertTrue(
ExternalEntityMapping.objects.filter(
source_name=self.SOURCE_NAME,
entity_type=ExternalEntityMapping.EntityType.PLAYER_SEASON,
external_id="2025:1642:6609",
).exists()
)
def test_importer_does_not_overwrite_internal_scouting_enrichment(self):
role = Role.objects.create(name="internal role lba", slug="internal-role-lba")
specialty = Specialty.objects.create(name="internal specialty lba", slug="internal-specialty-lba")
self.run_import()
player = Player.objects.get(full_name="Muhammad-Ali Abdur-Rahkman")
player.roles.add(role)
player.specialties.add(specialty)
self.run_import()
player.refresh_from_db()
self.assertTrue(player.roles.filter(pk=role.pk).exists())
self.assertTrue(player.specialties.filter(pk=specialty.pk).exists())
def test_importer_does_not_interfere_with_user_owned_data(self):
self.run_import()
user = User.objects.create_user(username="lba_user", password="pass12345")
player = Player.objects.get(full_name="Muhammad-Ali Abdur-Rahkman")
favorite = FavoritePlayer.objects.create(user=user, player=player)
note = PlayerNote.objects.create(user=user, player=player, body="LBA imported profile")
saved = SavedSearch.objects.create(user=user, name="LBA Search", params={"name": "Muhammad"})
self.run_import()
self.assertTrue(FavoritePlayer.objects.filter(pk=favorite.pk).exists())
self.assertTrue(PlayerNote.objects.filter(pk=note.pk).exists())
self.assertTrue(SavedSearch.objects.filter(pk=saved.pk).exists())
def test_imported_data_is_visible_in_search_and_detail_flows(self):
self.run_import()
list_response = self.client.get(reverse("scouting:player_list"), {"name": "Muhammad"})
self.assertEqual(list_response.status_code, 200)
self.assertContains(list_response, "Muhammad-Ali Abdur-Rahkman")
player = Player.objects.get(full_name="Muhammad-Ali Abdur-Rahkman")
detail_response = self.client.get(reverse("scouting:player_detail", args=[player.id]))
self.assertEqual(detail_response.status_code, 200)
self.assertContains(detail_response, "Muhammad-Ali Abdur-Rahkman")
self.assertContains(detail_response, "PTS 19.5")
def test_importer_fails_cleanly_for_malformed_fixture(self):
broken_fixture = Path(__file__).resolve().parent / "sample_data" / "imports" / "lba_public_broken_fixture.json"
broken_fixture.write_text("{\"categories\": {\"points\": {\"stats\": [{\"name\": \"OnlyName\"}]}}}", encoding="utf-8")
try:
with self.assertRaises(CommandError):
call_command(self.COMMAND_NAME, fixture=str(broken_fixture), season=2025)
finally:
if broken_fixture.exists():
broken_fixture.unlink()
class FavoritePlayerViewsTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(username="scout_a", password="pass12345")
cls.other_user = User.objects.create_user(username="scout_b", password="pass12345")
cls.player = Player.objects.create(
full_name="Favorite Prospect",
birth_date=date(2001, 4, 4),
position="PG",
height_cm=Decimal("187.00"),
weight_kg=Decimal("81.00"),
)
cls.other_player = Player.objects.create(
full_name="Other Prospect",
birth_date=date(2000, 6, 6),
position="SF",
height_cm=Decimal("202.00"),
weight_kg=Decimal("94.00"),
)
def test_login_page_loads(self):
response = self.client.get(reverse("login"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Log In")
def test_login_works_for_existing_user(self):
response = self.client.post(
reverse("login"),
{"username": "scout_a", "password": "pass12345"},
)
self.assertEqual(response.status_code, 302)
def test_authenticated_user_can_add_player_to_favorites(self):
self.client.force_login(self.user)
response = self.client.post(
reverse("scouting:add_favorite", args=[self.player.id]),
{"next": reverse("scouting:player_detail", args=[self.player.id])},
)
self.assertRedirects(response, reverse("scouting:player_detail", args=[self.player.id]))
self.assertTrue(FavoritePlayer.objects.filter(user=self.user, player=self.player).exists())
def test_authenticated_user_can_remove_player_from_favorites(self):
FavoritePlayer.objects.create(user=self.user, player=self.player)
self.client.force_login(self.user)
response = self.client.post(
reverse("scouting:remove_favorite", args=[self.player.id]),
{"next": reverse("scouting:favorites_list")},
)
self.assertRedirects(response, reverse("scouting:favorites_list"))
self.assertFalse(FavoritePlayer.objects.filter(user=self.user, player=self.player).exists())
def test_unauthenticated_user_cannot_add_or_remove_favorites(self):
add_response = self.client.post(reverse("scouting:add_favorite", args=[self.player.id]))
self.assertRedirects(add_response, f"{reverse('login')}?next={reverse('scouting:add_favorite', args=[self.player.id])}")
FavoritePlayer.objects.create(user=self.user, player=self.player)
remove_response = self.client.post(reverse("scouting:remove_favorite", args=[self.player.id]))
self.assertRedirects(remove_response, f"{reverse('login')}?next={reverse('scouting:remove_favorite', args=[self.player.id])}")
def test_favorites_page_is_user_scoped(self):
FavoritePlayer.objects.create(user=self.user, player=self.player)
FavoritePlayer.objects.create(user=self.other_user, player=self.other_player)
self.client.force_login(self.user)
response = self.client.get(reverse("scouting:favorites_list"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Favorite Prospect")
self.assertNotContains(response, "Other Prospect")
def test_favorite_state_is_visible_on_detail_and_search_pages_for_logged_in_user(self):
FavoritePlayer.objects.create(user=self.user, player=self.player)
self.client.force_login(self.user)
detail_response = self.client.get(reverse("scouting:player_detail", args=[self.player.id]))
list_response = self.client.get(reverse("scouting:player_list"))
self.assertContains(detail_response, "On your shortlist.")
self.assertContains(detail_response, "Remove from shortlist")
self.assertContains(list_response, "Shortlisted")
def test_search_and_detail_pages_still_load_after_user_scoping(self):
search_response = self.client.get(reverse("scouting:player_list"))
detail_response = self.client.get(reverse("scouting:player_detail", args=[self.player.id]))
self.assertEqual(search_response.status_code, 200)
self.assertEqual(detail_response.status_code, 200)
def test_favorites_page_requires_login(self):
response = self.client.get(reverse("scouting:favorites_list"))
self.assertRedirects(response, f"{reverse('login')}?next={reverse('scouting:favorites_list')}")
class SavedSearchViewsTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(username="saved_owner", password="pass12345")
cls.other_user = User.objects.create_user(username="saved_other", password="pass12345")
cls.player_guard = Player.objects.create(
full_name="Saved Guard",
birth_date=date(2002, 7, 7),
position="PG",
height_cm=Decimal("188.00"),
weight_kg=Decimal("82.00"),
wingspan_cm=Decimal("196.00"),
)
cls.player_wing = Player.objects.create(
full_name="Saved Wing",
birth_date=date(2000, 9, 9),
position="SF",
height_cm=Decimal("203.00"),
weight_kg=Decimal("95.00"),
wingspan_cm=Decimal("213.00"),
)
def test_logged_in_user_can_save_search(self):
self.client.force_login(self.user)
response = self.client.post(
reverse("scouting:save_search"),
{
"saved_search_name": "Long Wings",
"position": "SF",
"min_wingspan_cm": "210",
"sort": "height_desc",
},
)
self.assertEqual(response.status_code, 302)
self.assertIn(f"{reverse('scouting:player_list')}?", response["Location"])
self.assertIn("sort=height_desc", response["Location"])
self.assertIn("position=SF", response["Location"])
self.assertIn("min_wingspan_cm=210", response["Location"])
saved = SavedSearch.objects.get(user=self.user, name="Long Wings")
self.assertEqual(saved.params["position"], "SF")
self.assertEqual(saved.params["min_wingspan_cm"], "210")
def test_saved_searches_are_user_scoped(self):
SavedSearch.objects.create(user=self.user, name="Mine", params={"position": "PG"})
SavedSearch.objects.create(user=self.other_user, name="Other", params={"position": "SF"})
self.client.force_login(self.user)
response = self.client.get(reverse("scouting:player_list"))
self.assertContains(response, "Mine")
self.assertNotContains(response, "Other")
def test_logged_in_user_can_rerun_saved_search(self):
saved = SavedSearch.objects.create(user=self.user, name="Wingspan Hunt", params={"min_wingspan_cm": "210"})
self.client.force_login(self.user)
response = self.client.get(f"{reverse('scouting:player_list')}?{urlencode(saved.params)}")
self.assertEqual(response.status_code, 200)
self.assertContains(response, self.player_wing.full_name)
self.assertNotContains(response, self.player_guard.full_name)
def test_logged_in_user_can_delete_saved_search(self):
saved = SavedSearch.objects.create(user=self.user, name="Delete Me", params={"position": "PG"})
self.client.force_login(self.user)
response = self.client.post(
reverse("scouting:delete_saved_search", args=[saved.id]),
{"position": "PG"},
)
self.assertRedirects(response, f"{reverse('scouting:player_list')}?position=PG")
self.assertFalse(SavedSearch.objects.filter(pk=saved.id).exists())
def test_unauthenticated_user_cannot_save_or_delete_saved_searches(self):
save_response = self.client.post(reverse("scouting:save_search"), {"saved_search_name": "No Auth"})
self.assertRedirects(save_response, f"{reverse('login')}?next={reverse('scouting:save_search')}")
saved = SavedSearch.objects.create(user=self.user, name="Auth Required", params={"position": "PG"})
delete_response = self.client.post(reverse("scouting:delete_saved_search", args=[saved.id]))
self.assertRedirects(
delete_response,
f"{reverse('login')}?next={reverse('scouting:delete_saved_search', args=[saved.id])}",
)
def test_combined_filters_saved_search_and_rerun(self):
self.client.force_login(self.user)
save_response = self.client.post(
reverse("scouting:save_search"),
{
"saved_search_name": "SF Long Wings",
"position": "SF",
"min_wingspan_cm": "212",
"sort": "height_desc",
},
)
self.assertEqual(save_response.status_code, 302)
saved = SavedSearch.objects.get(user=self.user, name="SF Long Wings")
rerun_response = self.client.get(f"{reverse('scouting:player_list')}?position=SF&min_wingspan_cm=212&sort=height_desc")
self.assertEqual(rerun_response.status_code, 200)
self.assertContains(rerun_response, self.player_wing.full_name)
self.assertNotContains(rerun_response, self.player_guard.full_name)
self.assertEqual(saved.params["sort"], "height_desc")
class PlayerNoteViewsTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(username="note_owner", password="pass12345")
cls.other_user = User.objects.create_user(username="note_other", password="pass12345")
cls.player = Player.objects.create(
full_name="Notes Prospect",
birth_date=date(2001, 8, 8),
position="PF",
height_cm=Decimal("205.00"),
weight_kg=Decimal("97.00"),
)
def test_authenticated_user_can_add_note_to_player(self):
self.client.force_login(self.user)
response = self.client.post(
reverse("scouting:add_note", args=[self.player.id]),
{
"body": "Shows good weak-side help instincts.",
"next": reverse("scouting:player_detail", args=[self.player.id]),
},
)
self.assertRedirects(response, reverse("scouting:player_detail", args=[self.player.id]))
note = PlayerNote.objects.get(player=self.player, user=self.user)
self.assertEqual(note.body, "Shows good weak-side help instincts.")
def test_authenticated_user_can_delete_note(self):
note = PlayerNote.objects.create(
user=self.user,
player=self.player,
body="Needs tighter handle under pressure.",
)
self.client.force_login(self.user)
response = self.client.post(
reverse("scouting:delete_note", args=[self.player.id, note.id]),
{"next": reverse("scouting:player_detail", args=[self.player.id])},
)
self.assertRedirects(response, reverse("scouting:player_detail", args=[self.player.id]))
self.assertFalse(PlayerNote.objects.filter(pk=note.id).exists())
def test_unauthenticated_user_cannot_add_or_delete_notes(self):
add_response = self.client.post(
reverse("scouting:add_note", args=[self.player.id]),
{"body": "Test note"},
)
self.assertRedirects(add_response, f"{reverse('login')}?next={reverse('scouting:add_note', args=[self.player.id])}")
note = PlayerNote.objects.create(user=self.user, player=self.player, body="Existing note")
delete_response = self.client.post(reverse("scouting:delete_note", args=[self.player.id, note.id]))
self.assertRedirects(
delete_response,
f"{reverse('login')}?next={reverse('scouting:delete_note', args=[self.player.id, note.id])}",
)
def test_player_detail_page_shows_only_current_users_notes(self):
PlayerNote.objects.create(user=self.user, player=self.player, body="Reliable closeout discipline.")
PlayerNote.objects.create(user=self.other_user, player=self.player, body="Other scout private note.")
self.client.force_login(self.user)
response = self.client.get(reverse("scouting:player_detail", args=[self.player.id]))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Scouting Notes")
self.assertContains(response, "Reliable closeout discipline.")
self.assertNotContains(response, "Other scout private note.")
def test_existing_search_shortlist_and_detail_flows_still_load(self):
FavoritePlayer.objects.create(user=self.user, player=self.player)
self.client.force_login(self.user)
search_response = self.client.get(reverse("scouting:player_list"))
favorites_response = self.client.get(reverse("scouting:favorites_list"))
detail_response = self.client.get(reverse("scouting:player_detail", args=[self.player.id]))
self.assertEqual(search_response.status_code, 200)
self.assertEqual(favorites_response.status_code, 200)
self.assertEqual(detail_response.status_code, 200)
def test_favorites_page_shows_note_count(self):
FavoritePlayer.objects.create(user=self.user, player=self.player)
PlayerNote.objects.create(user=self.user, player=self.player, body="Can defend up a position in small lineups.")
PlayerNote.objects.create(user=self.user, player=self.player, body="Late-clock decision making still inconsistent.")
PlayerNote.objects.create(user=self.other_user, player=self.player, body="Other user's note should not count.")
self.client.force_login(self.user)
response = self.client.get(reverse("scouting:favorites_list"))
self.assertContains(response, "Notes: 2")
class UserScopedMigrationTests(TransactionTestCase):
reset_sequences = True
migrate_from = [("scouting", "0006_playernote")]
migrate_to = [("scouting", "0007_user_scoped_favorites_and_notes")]
def setUp(self):
super().setUp()
self.executor = MigrationExecutor(connection)
self.executor.migrate(self.migrate_from)
old_apps = self.executor.loader.project_state(self.migrate_from).apps
player_model = old_apps.get_model("scouting", "Player")
favorite_model = old_apps.get_model("scouting", "FavoritePlayer")
note_model = old_apps.get_model("scouting", "PlayerNote")
player = player_model.objects.create(full_name="Legacy Shared Player", position="PG")
favorite_model.objects.create(player=player)
note_model.objects.create(player=player, body="Legacy shared note")
self.executor = MigrationExecutor(connection)
self.executor.migrate(self.migrate_to)
def test_legacy_shared_rows_are_cleared_by_user_scope_migration(self):
apps = self.executor.loader.project_state(self.migrate_to).apps
favorite_model = apps.get_model("scouting", "FavoritePlayer")
note_model = apps.get_model("scouting", "PlayerNote")
self.assertEqual(favorite_model.objects.count(), 0)
self.assertEqual(note_model.objects.count(), 0)

View File

@ -6,5 +6,12 @@ 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"),
]

View File

@ -1,22 +1,219 @@
from __future__ import annotations
from django.db.models import Exists, OuterRef, Prefetch
from django.shortcuts import get_object_or_404, render
from decimal import Decimal
from urllib.parse import urlencode
from .forms import PlayerSearchForm
from .models import Player, PlayerSeason
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"])
@ -35,6 +232,10 @@ def player_list(request):
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"])
@ -64,13 +265,17 @@ def player_list(request):
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
@ -89,38 +294,99 @@ def player_list(request):
)
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": queryset,
"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,
},
)
@ -133,10 +399,15 @@ def player_detail(request, player_id: int):
contexts = (
PlayerSeason.objects.filter(player=player)
.select_related("season", "team", "competition")
.prefetch_related(Prefetch("stats"))
.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,
@ -144,5 +415,98 @@ def player_detail(request, player_id: int):
{
"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)}")

View File

@ -27,6 +27,7 @@ The current baseline decision is:
- `ADR-0004`: configuration and environment strategy baseline
- `ADR-0005`: scouting search-domain baseline
- `ADR-0006`: initial domain-model baseline
- `ADR-0009`: real-data ingestion baseline
The current baseline assumes:
- Python 3
@ -46,6 +47,8 @@ Future search model/filter/UI implementation should follow the domain semantics
Future Django domain model work should follow `docs/adr/0006-initial-domain-model.md` as the baseline entity/relationship decision (including player-season stat ownership and taxonomy treatment) unless superseded by a later ADR.
Future importer and ingestion work should follow `docs/adr/0009-real-data-ingestion-baseline.md` as the baseline for importer shape, deterministic identity matching, idempotent re-runs, and imported-vs-internal-vs-user-owned data boundaries unless superseded by a later ADR.
## Future Sections Placeholder
Future versions of this document may include sections such as:

View File

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