Compare commits

...

4 Commits

11 changed files with 543 additions and 130 deletions

159
README.md
View File

@ -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

View File

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

View File

@ -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,

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

@ -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}"

View 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>

View File

@ -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>

View File

@ -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 }}&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> </body>
</html> </html>

View File

@ -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)

View File

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

View File

@ -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,
}, },
) )