Compare commits
4 Commits
6c53cae7a1
...
4f869c1c02
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f869c1c02 | |||
| 99820419c4 | |||
| d1b5499a63 | |||
| 6d8af021ce |
159
README.md
159
README.md
@ -1,40 +1,18 @@
|
|||||||
# HoopScout v2
|
# HoopScout v2
|
||||||
|
|
||||||
HoopScout v2 has completed its phase-0 workflow foundation and is now using accepted phase-1 decisions to guide implementation planning. The repository remains repository-owned, portable across machines, and explicit about how humans and Codex should work.
|
HoopScout v2 is a Django/PostgreSQL scouting application developed through a repository-first workflow. The repo keeps both implementation guidance and Codex collaboration rules in version control so the project stays portable across machines.
|
||||||
|
|
||||||
The current goal is to maintain:
|
## Current MVP
|
||||||
- Codex-assisted development
|
|
||||||
- custom agent usage
|
|
||||||
- repeatable task execution
|
|
||||||
- repository-owned instructions
|
|
||||||
- machine portability
|
|
||||||
- branch discipline
|
|
||||||
- implementation guidance driven by accepted ADRs
|
|
||||||
|
|
||||||
## Current Phase
|
The current application baseline provides:
|
||||||
|
- containerized local development
|
||||||
|
- curated sample seed data for manual exploration
|
||||||
|
- player scouting search with player, context, and stat filters
|
||||||
|
- matching season/team/competition context on search results
|
||||||
|
- result sorting and pagination
|
||||||
|
- a shared development shortlist for favorite players
|
||||||
|
|
||||||
Phase 0 established the working method for the repository. Phase 1 has already added accepted technical decisions for:
|
Accepted technical and product-shaping decisions live in:
|
||||||
- architecture principles
|
|
||||||
- technical decision process
|
|
||||||
- runtime and development stack
|
|
||||||
- initial project structure
|
|
||||||
- containerized developer workflow
|
|
||||||
- configuration and environment strategy
|
|
||||||
|
|
||||||
Current work should follow those accepted decisions rather than re-deciding them informally.
|
|
||||||
|
|
||||||
## Workflow Foundation
|
|
||||||
|
|
||||||
The repository still depends on the phase-0 foundation for:
|
|
||||||
- repository workflow
|
|
||||||
- branch policy
|
|
||||||
- Codex project configuration
|
|
||||||
- agent roles
|
|
||||||
- reusable task-closeout behavior
|
|
||||||
- machine setup guidance
|
|
||||||
- documentation discipline
|
|
||||||
|
|
||||||
Key decision references:
|
|
||||||
- `docs/ARCHITECTURE.md`
|
- `docs/ARCHITECTURE.md`
|
||||||
- `docs/ARCHITECTURE_PRINCIPLES.md`
|
- `docs/ARCHITECTURE_PRINCIPLES.md`
|
||||||
- `docs/DECISION_PROCESS.md`
|
- `docs/DECISION_PROCESS.md`
|
||||||
@ -42,121 +20,58 @@ Key decision references:
|
|||||||
|
|
||||||
## Repository Structure
|
## Repository Structure
|
||||||
|
|
||||||
The repository is organized to keep durable workflow guidance and technical decision records in version control and portable across machines.
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
.
|
.
|
||||||
|-- .codex/
|
|-- .codex/
|
||||||
|-- .agents/skills/
|
|-- .agents/skills/
|
||||||
|-- app/
|
|-- app/
|
||||||
|
| |-- hoopscout/
|
||||||
|
| `-- scouting/
|
||||||
|-- docs/
|
|-- docs/
|
||||||
|-- infra/
|
|-- infra/
|
||||||
|-- requirements/
|
|
||||||
|-- scripts/
|
|-- scripts/
|
||||||
|-- tests/
|
|-- tests/
|
||||||
|-- AGENTS.md
|
|-- AGENTS.md
|
||||||
|-- Makefile
|
|-- Makefile
|
||||||
|-- README.md
|
`-- README.md
|
||||||
|-- .editorconfig
|
|
||||||
`-- .gitignore
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- `.codex/` stores repository-scoped Codex configuration and agent definitions.
|
- `app/hoopscout/` contains the Django project settings and root URLs.
|
||||||
- `.agents/skills/` stores reusable skills for repeatable repository workflows.
|
- `app/scouting/` contains the scouting domain models, views, templates, management commands, and tests tied to the app.
|
||||||
- `app/` stores the Django project and scouting application code.
|
- `infra/` contains the local Docker Compose and image setup.
|
||||||
- `docs/` stores workflow, architecture, ADRs, machine setup, and task execution guidance.
|
- `docs/` contains workflow and ADR documentation.
|
||||||
- `infra/` stores local container and environment bootstrap files.
|
- `scripts/` contains repository checks such as `make doctor`.
|
||||||
- `requirements/` stores the Python dependency baseline.
|
|
||||||
- `scripts/` stores repository utility scripts such as local checks.
|
## Local Development
|
||||||
- `tests/` stores repository-level testing notes and support files.
|
|
||||||
- `AGENTS.md` defines repository-wide agent behavior and task rules.
|
1. Start the stack with `docker compose --env-file .env -f infra/docker-compose.yml up -d --build`.
|
||||||
- `Makefile` exposes standard project commands.
|
2. Apply migrations with `docker compose --env-file .env -f infra/docker-compose.yml exec -T app python manage.py migrate`.
|
||||||
- `README.md` introduces the repository and current phase.
|
3. Load sample data with `docker compose --env-file .env -f infra/docker-compose.yml exec -T app python manage.py seed_scouting_data`.
|
||||||
- `.editorconfig` provides shared formatting defaults.
|
4. Visit `http://127.0.0.1:8000/players/` to explore the scouting search MVP.
|
||||||
- `.gitignore` defines ignored files for the repository.
|
5. Use `http://127.0.0.1:8000/favorites/` to review the shared development shortlist.
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
Protected branches:
|
- `main` is the stable branch.
|
||||||
- `main`
|
- `develop` is the integration branch.
|
||||||
- `develop`
|
- normal work goes through `feature/*` branches created from `develop`.
|
||||||
|
- run `make doctor` before or during local setup to confirm the repository foundation is present.
|
||||||
|
|
||||||
Normal work goes through `feature/*` branches created from `develop`. Tasks should be completed on the task branch, committed there, and merged back into `develop` when done.
|
Durable project behavior belongs in the repository, especially:
|
||||||
|
- `AGENTS.md`
|
||||||
|
- `.codex/`
|
||||||
|
- `.agents/skills/`
|
||||||
|
- `docs/`
|
||||||
|
|
||||||
## Working with Codex
|
Local-only responsibilities still include authentication, personal editor setup, shell aliases, and secrets.
|
||||||
|
|
||||||
Durable project behavior should live in the repository so that work remains consistent across machines and contributors.
|
|
||||||
|
|
||||||
Repository-owned configuration examples:
|
|
||||||
- task workflow
|
|
||||||
- branch strategy
|
|
||||||
- coding process
|
|
||||||
- agent roles
|
|
||||||
- reusable skills
|
|
||||||
- machine setup instructions
|
|
||||||
- test and validation instructions
|
|
||||||
|
|
||||||
Local-only configuration examples:
|
|
||||||
- Codex authentication
|
|
||||||
- personal shell aliases
|
|
||||||
- editor preferences
|
|
||||||
- secrets and API keys
|
|
||||||
- machine-specific customizations not documented as shared examples
|
|
||||||
|
|
||||||
## New Machine Setup
|
|
||||||
|
|
||||||
When starting on a new machine:
|
|
||||||
1. Clone the repository.
|
|
||||||
2. Authenticate Codex locally.
|
|
||||||
3. Checkout the correct branch, typically `develop` or the assigned task branch.
|
|
||||||
4. Read `AGENTS.md`, `docs/WORKFLOW.md`, `docs/MACHINE_SETUP.md`, `docs/TASK_TEMPLATE.md`, and the current architecture/ADR documents.
|
|
||||||
5. Run `make doctor` to validate the local repository bootstrap before starting a task.
|
|
||||||
|
|
||||||
## Codex Task Style
|
|
||||||
|
|
||||||
Codex tasks in this repository should follow this order:
|
|
||||||
1. Confirm branch strategy.
|
|
||||||
2. State the branch being used.
|
|
||||||
3. List the files to change.
|
|
||||||
4. Explain the design briefly.
|
|
||||||
5. Make the requested changes.
|
|
||||||
6. Update tests and docs when relevant.
|
|
||||||
7. Provide the commit message used.
|
|
||||||
8. Confirm the merge target.
|
|
||||||
9. Stop.
|
|
||||||
|
|
||||||
## Local Checks
|
|
||||||
|
|
||||||
Run `make doctor` as part of machine/bootstrap validation to confirm the repository foundation is present and aligned.
|
|
||||||
|
|
||||||
## Development Bootstrap
|
|
||||||
|
|
||||||
For the current MVP baseline:
|
|
||||||
1. Start the local stack with `docker compose --env-file .env -f infra/docker-compose.yml up -d --build`.
|
|
||||||
2. Run `docker compose --env-file .env -f infra/docker-compose.yml exec -T app python manage.py migrate`.
|
|
||||||
3. Load sample scouting data with `docker compose --env-file .env -f infra/docker-compose.yml exec -T app python manage.py seed_scouting_data`.
|
|
||||||
4. Open `/players/` and use filters such as `PG + min assists` or `team + min TS%` to explore the seeded dataset.
|
|
||||||
|
|
||||||
## Current Status
|
|
||||||
|
|
||||||
The repository currently provides:
|
|
||||||
- repository bootstrap and workflow foundation
|
|
||||||
- Codex/agent collaboration setup
|
|
||||||
- portable development baseline
|
|
||||||
- accepted phase-1 technical decisions for future implementation work
|
|
||||||
|
|
||||||
## Decision Baseline
|
|
||||||
|
|
||||||
Future implementation work should follow the accepted ADR baseline unless a later ADR supersedes it.
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
To contribute in the current phase:
|
|
||||||
- read `AGENTS.md`
|
- read `AGENTS.md`
|
||||||
- read `docs/WORKFLOW.md`
|
- read `docs/WORKFLOW.md`
|
||||||
- read the current ADR set in `docs/adr/`
|
- read the current ADR set in `docs/adr/`
|
||||||
- create a task branch from `develop`
|
- create a task branch from `develop`
|
||||||
- keep tasks narrowly scoped
|
- keep tasks narrowly scoped and aligned with accepted decisions
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ from django.contrib import admin
|
|||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
Competition,
|
Competition,
|
||||||
|
FavoritePlayer,
|
||||||
Player,
|
Player,
|
||||||
PlayerSeason,
|
PlayerSeason,
|
||||||
PlayerSeasonStats,
|
PlayerSeasonStats,
|
||||||
@ -68,3 +69,9 @@ class PlayerSeasonStatsAdmin(admin.ModelAdmin):
|
|||||||
"blocks",
|
"blocks",
|
||||||
)
|
)
|
||||||
search_fields = ("player_season__player__full_name", "player_season__season__name")
|
search_fields = ("player_season__player__full_name", "player_season__season__name")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(FavoritePlayer)
|
||||||
|
class FavoritePlayerAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("player", "created_at")
|
||||||
|
search_fields = ("player__full_name",)
|
||||||
|
|||||||
@ -8,7 +8,20 @@ from .models import Competition, Role, Season, Specialty, Team
|
|||||||
|
|
||||||
|
|
||||||
class PlayerSearchForm(forms.Form):
|
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")
|
name = forms.CharField(required=False, label="Name")
|
||||||
|
sort = forms.ChoiceField(required=False, choices=SORT_CHOICES, initial="name_asc")
|
||||||
|
|
||||||
position = forms.ChoiceField(
|
position = forms.ChoiceField(
|
||||||
required=False,
|
required=False,
|
||||||
|
|||||||
28
app/scouting/migrations/0005_favoriteplayer.py
Normal file
28
app/scouting/migrations/0005_favoriteplayer.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("scouting", "0004_remove_playerseason_uniq_player_season_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="FavoritePlayer",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
(
|
||||||
|
"player",
|
||||||
|
models.OneToOneField(
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="favorite_entry",
|
||||||
|
to="scouting.player",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["-created_at", "player__full_name"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -161,3 +161,20 @@ class PlayerSeasonStats(models.Model):
|
|||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"Stats for {self.player_season}"
|
return f"Stats for {self.player_season}"
|
||||||
|
|
||||||
|
|
||||||
|
class FavoritePlayer(models.Model):
|
||||||
|
# Phase-2 MVP uses a single shared development shortlist instead of user-scoped
|
||||||
|
# favorites so the workflow stays useful without introducing auth complexity yet.
|
||||||
|
player = models.OneToOneField(
|
||||||
|
Player,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="favorite_entry",
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at", "player__full_name"]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"Favorite: {self.player.full_name}"
|
||||||
|
|||||||
30
app/scouting/templates/scouting/favorites_list.html
Normal file
30
app/scouting/templates/scouting/favorites_list.html
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<!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>
|
||||||
|
</p>
|
||||||
|
<h1>Shared Development Shortlist</h1>
|
||||||
|
<p>This MVP shortlist is shared across the local development environment.</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
{% for entry in favorites %}
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'scouting:player_detail' entry.player.id %}">{{ entry.player.full_name }}</a>
|
||||||
|
({{ entry.player.position }})
|
||||||
|
<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>
|
||||||
@ -5,9 +5,27 @@
|
|||||||
<title>{{ player.full_name }}</title>
|
<title>{{ player.full_name }}</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<p><a href="{% url 'scouting:player_list' %}">Back to search</a></p>
|
<p>
|
||||||
|
<a href="{% url 'scouting:player_list' %}">Back to search</a>
|
||||||
|
| <a href="{% url 'scouting:favorites_list' %}">View shortlist</a>
|
||||||
|
</p>
|
||||||
<h1>{{ player.full_name }}</h1>
|
<h1>{{ player.full_name }}</h1>
|
||||||
|
|
||||||
|
{% if is_favorite %}
|
||||||
|
<p><strong>On the shared development 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 %}
|
||||||
|
|
||||||
<p>Position: {{ player.position }}</p>
|
<p>Position: {{ player.position }}</p>
|
||||||
<p>Nationality: {{ player.nationality|default:"-" }}</p>
|
<p>Nationality: {{ player.nationality|default:"-" }}</p>
|
||||||
<p>Birth date: {{ player.birth_date|default:"-" }}</p>
|
<p>Birth date: {{ player.birth_date|default:"-" }}</p>
|
||||||
|
|||||||
@ -6,20 +6,33 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Scout Search</h1>
|
<h1>Scout Search</h1>
|
||||||
|
<p><a href="{% url 'scouting:favorites_list' %}">View shortlist</a></p>
|
||||||
|
|
||||||
<form method="get">
|
<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>
|
<fieldset>
|
||||||
<legend>Player Filters</legend>
|
<legend>Player Filters</legend>
|
||||||
{{ form.name.label_tag }} {{ form.name }}
|
{{ 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.min_age.label_tag }} {{ form.min_age }}
|
||||||
{{ form.max_age.label_tag }} {{ form.max_age }}
|
{{ form.max_age.label_tag }} {{ form.max_age }}
|
||||||
{{ form.min_height_cm.label_tag }} {{ form.min_height_cm }}
|
{{ form.min_height_cm.label_tag }} {{ form.min_height_cm }}
|
||||||
{{ form.max_height_cm.label_tag }} {{ form.max_height_cm }}
|
{{ form.max_height_cm.label_tag }} {{ form.max_height_cm }}
|
||||||
{{ form.min_weight_kg.label_tag }} {{ form.min_weight_kg }}
|
{{ form.min_weight_kg.label_tag }} {{ form.min_weight_kg }}
|
||||||
{{ form.max_weight_kg.label_tag }} {{ form.max_weight_kg }}
|
{{ form.max_weight_kg.label_tag }} {{ form.max_weight_kg }}
|
||||||
|
{{ form.position.label_tag }} {{ form.position }}
|
||||||
|
{{ form.role.label_tag }} {{ form.role }}
|
||||||
|
{{ form.specialty.label_tag }} {{ form.specialty }}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
@ -46,12 +59,26 @@
|
|||||||
<button type="submit">Search</button>
|
<button type="submit">Search</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<h2>Results ({{ players|length }})</h2>
|
<h2>Results ({{ total_results }})</h2>
|
||||||
<ul>
|
<ul>
|
||||||
{% for player in players %}
|
{% for player in players %}
|
||||||
<li>
|
<li>
|
||||||
<a href="{% url 'scouting:player_detail' player.id %}">{{ player.full_name }}</a>
|
<a href="{% url 'scouting:player_detail' player.id %}">{{ player.full_name }}</a>
|
||||||
({{ player.position }})
|
({{ player.position }})
|
||||||
|
{% 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 %}
|
||||||
{% if player.matching_context %}
|
{% if player.matching_context %}
|
||||||
<div>
|
<div>
|
||||||
Match context:
|
Match context:
|
||||||
@ -74,5 +101,19 @@
|
|||||||
<li>No players found.</li>
|
<li>No players found.</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
{% if page_obj.paginator.num_pages > 1 %}
|
||||||
|
<nav aria-label="Pagination">
|
||||||
|
{% if page_obj.has_previous %}
|
||||||
|
<a href="?{% if query_without_page %}{{ query_without_page }}&{% endif %}page={{ page_obj.previous_page_number }}">Previous</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<span>Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span>
|
||||||
|
|
||||||
|
{% if page_obj.has_next %}
|
||||||
|
<a href="?{% if query_without_page %}{{ query_without_page }}&{% endif %}page={{ page_obj.next_page_number }}">Next</a>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -5,7 +5,7 @@ from django.core.management import call_command
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from .models import Competition, Player, PlayerSeason, PlayerSeasonStats, Role, Season, Specialty, Team
|
from .models import Competition, FavoritePlayer, Player, PlayerSeason, PlayerSeasonStats, Role, Season, Specialty, Team
|
||||||
|
|
||||||
|
|
||||||
class ScoutingSearchViewsTests(TestCase):
|
class ScoutingSearchViewsTests(TestCase):
|
||||||
@ -238,6 +238,145 @@ class ScoutingSearchViewsTests(TestCase):
|
|||||||
self.assertContains(response, self.player_pg.full_name)
|
self.assertContains(response, self.player_pg.full_name)
|
||||||
self.assertNotContains(response, self.player_wing.full_name)
|
self.assertNotContains(response, self.player_wing.full_name)
|
||||||
|
|
||||||
|
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):
|
class SeedScoutingDataCommandTests(TestCase):
|
||||||
def test_seed_command_creates_expected_core_objects(self):
|
def test_seed_command_creates_expected_core_objects(self):
|
||||||
@ -272,3 +411,67 @@ class SeedScoutingDataCommandTests(TestCase):
|
|||||||
self.assertEqual(PlayerSeasonStats.objects.count(), first_counts["stats"])
|
self.assertEqual(PlayerSeasonStats.objects.count(), first_counts["stats"])
|
||||||
self.assertEqual(Role.objects.count(), first_counts["roles"])
|
self.assertEqual(Role.objects.count(), first_counts["roles"])
|
||||||
self.assertEqual(Specialty.objects.count(), first_counts["specialties"])
|
self.assertEqual(Specialty.objects.count(), first_counts["specialties"])
|
||||||
|
|
||||||
|
|
||||||
|
class FavoritePlayerViewsTests(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
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_adding_player_to_favorites(self):
|
||||||
|
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(player=self.player).exists())
|
||||||
|
|
||||||
|
def test_removing_player_from_favorites(self):
|
||||||
|
FavoritePlayer.objects.create(player=self.player)
|
||||||
|
|
||||||
|
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(player=self.player).exists())
|
||||||
|
|
||||||
|
def test_favorites_list_page_loads(self):
|
||||||
|
FavoritePlayer.objects.create(player=self.player)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("scouting:favorites_list"))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Favorite Prospect")
|
||||||
|
|
||||||
|
def test_favorite_state_is_visible_on_detail_and_search_pages(self):
|
||||||
|
FavoritePlayer.objects.create(player=self.player)
|
||||||
|
|
||||||
|
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 the shared development shortlist.")
|
||||||
|
self.assertContains(detail_response, "Remove from shortlist")
|
||||||
|
self.assertContains(list_response, "Shortlisted")
|
||||||
|
|
||||||
|
def test_search_and_detail_pages_still_load_after_favorites_integration(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)
|
||||||
|
|||||||
@ -7,4 +7,7 @@ app_name = "scouting"
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("players/", views.player_list, name="player_list"),
|
path("players/", views.player_list, name="player_list"),
|
||||||
path("players/<int:player_id>/", views.player_detail, name="player_detail"),
|
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("favorites/", views.favorites_list, name="favorites_list"),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,10 +1,100 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django.core.paginator import Paginator
|
||||||
from django.db.models import Exists, OuterRef, Prefetch
|
from django.db.models import Exists, OuterRef, Prefetch
|
||||||
|
from django.http import HttpResponseRedirect
|
||||||
from django.shortcuts import get_object_or_404, render
|
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
|
from .forms import PlayerSearchForm
|
||||||
from .models import Player, PlayerSeason
|
from .models import FavoritePlayer, Player, PlayerSeason
|
||||||
|
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_favorite_state(players):
|
||||||
|
favorite_ids = set(FavoritePlayer.objects.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):
|
def player_list(request):
|
||||||
@ -15,9 +105,11 @@ def player_list(request):
|
|||||||
.order_by("full_name")
|
.order_by("full_name")
|
||||||
)
|
)
|
||||||
context_filters_used = False
|
context_filters_used = False
|
||||||
|
requested_sort = request.GET.get("sort") or "name_asc"
|
||||||
|
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
data = form.cleaned_data
|
data = form.cleaned_data
|
||||||
|
requested_sort = data["sort"] or "name_asc"
|
||||||
|
|
||||||
if data["name"]:
|
if data["name"]:
|
||||||
queryset = queryset.filter(full_name__icontains=data["name"])
|
queryset = queryset.filter(full_name__icontains=data["name"])
|
||||||
@ -153,12 +245,24 @@ def player_list(request):
|
|||||||
for player in players:
|
for player in players:
|
||||||
player.matching_context = next(iter(player.matching_contexts), None)
|
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)
|
||||||
|
query_without_page = request.GET.copy()
|
||||||
|
query_without_page.pop("page", None)
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"scouting/player_list.html",
|
"scouting/player_list.html",
|
||||||
{
|
{
|
||||||
"form": form,
|
"form": form,
|
||||||
"players": players,
|
"players": page_obj.object_list,
|
||||||
|
"page_obj": page_obj,
|
||||||
|
"active_sort": active_sort,
|
||||||
|
"total_results": paginator.count,
|
||||||
|
"query_without_page": query_without_page.urlencode(),
|
||||||
|
"context_sorting_enabled": context_filters_used,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -181,5 +285,39 @@ def player_detail(request, player_id: int):
|
|||||||
{
|
{
|
||||||
"player": player,
|
"player": player,
|
||||||
"contexts": contexts,
|
"contexts": contexts,
|
||||||
|
"is_favorite": FavoritePlayer.objects.filter(player=player).exists(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
def add_favorite(request, player_id: int):
|
||||||
|
player = get_object_or_404(Player, pk=player_id)
|
||||||
|
FavoritePlayer.objects.get_or_create(player=player)
|
||||||
|
return redirect_to_next(request, reverse("scouting:player_detail", args=[player.id]))
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
def remove_favorite(request, player_id: int):
|
||||||
|
player = get_object_or_404(Player, pk=player_id)
|
||||||
|
FavoritePlayer.objects.filter(player=player).delete()
|
||||||
|
return redirect_to_next(request, reverse("scouting:player_detail", args=[player.id]))
|
||||||
|
|
||||||
|
|
||||||
|
def favorites_list(request):
|
||||||
|
favorites = list(
|
||||||
|
FavoritePlayer.objects.select_related("player")
|
||||||
|
.prefetch_related("player__roles", "player__specialties")
|
||||||
|
.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,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user