diff --git a/README.md b/README.md index 25616a1..6246eb6 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,18 @@ # 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 +- 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: -- 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,121 +20,58 @@ 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/ -|-- requirements/ |-- 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. -- `app/` stores the Django project and scouting application code. -- `docs/` stores workflow, architecture, ADRs, machine setup, and task execution guidance. -- `infra/` stores local container and environment bootstrap files. -- `requirements/` stores the Python dependency baseline. -- `scripts/` stores repository utility scripts such as local checks. -- `tests/` stores repository-level testing notes and support files. -- `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. Visit `http://127.0.0.1:8000/players/` to explore the scouting search MVP. +5. Use `http://127.0.0.1:8000/favorites/` to review the shared development shortlist. ## 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. - -## 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. +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 diff --git a/app/scouting/admin.py b/app/scouting/admin.py index 7d1361f..89b1eab 100644 --- a/app/scouting/admin.py +++ b/app/scouting/admin.py @@ -2,6 +2,7 @@ from django.contrib import admin from .models import ( Competition, + FavoritePlayer, Player, PlayerSeason, PlayerSeasonStats, @@ -68,3 +69,9 @@ 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 = ("player", "created_at") + search_fields = ("player__full_name",) diff --git a/app/scouting/migrations/0005_favoriteplayer.py b/app/scouting/migrations/0005_favoriteplayer.py new file mode 100644 index 0000000..9d5c6d6 --- /dev/null +++ b/app/scouting/migrations/0005_favoriteplayer.py @@ -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"], + }, + ), + ] diff --git a/app/scouting/models.py b/app/scouting/models.py index 1a7334d..a4e46ab 100644 --- a/app/scouting/models.py +++ b/app/scouting/models.py @@ -161,3 +161,20 @@ class PlayerSeasonStats(models.Model): def __str__(self) -> str: 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}" diff --git a/app/scouting/templates/scouting/favorites_list.html b/app/scouting/templates/scouting/favorites_list.html new file mode 100644 index 0000000..3ddb9cf --- /dev/null +++ b/app/scouting/templates/scouting/favorites_list.html @@ -0,0 +1,30 @@ + + + + + Shortlist + + +

+ Back to search +

+

Shared Development Shortlist

+

This MVP shortlist is shared across the local development environment.

+ + + + diff --git a/app/scouting/templates/scouting/player_detail.html b/app/scouting/templates/scouting/player_detail.html index b0d202e..9a7428c 100644 --- a/app/scouting/templates/scouting/player_detail.html +++ b/app/scouting/templates/scouting/player_detail.html @@ -5,9 +5,27 @@ {{ player.full_name }} -

Back to search

+

+ Back to search + | View shortlist +

{{ player.full_name }}

+ {% if is_favorite %} +

On the shared development shortlist.

+
+ {% csrf_token %} + + +
+ {% else %} +
+ {% csrf_token %} + + +
+ {% endif %} +

Position: {{ player.position }}

Nationality: {{ player.nationality|default:"-" }}

Birth date: {{ player.birth_date|default:"-" }}

diff --git a/app/scouting/templates/scouting/player_list.html b/app/scouting/templates/scouting/player_list.html index ae8a05a..a40ba82 100644 --- a/app/scouting/templates/scouting/player_list.html +++ b/app/scouting/templates/scouting/player_list.html @@ -6,6 +6,7 @@

Scout Search

+

View shortlist

@@ -64,6 +65,20 @@
  • {{ player.full_name }} ({{ player.position }}) + {% if player.is_favorite %} + Shortlisted + + {% csrf_token %} + + +
  • + {% else %} +
    + {% csrf_token %} + + +
    + {% endif %} {% if player.matching_context %}
    Match context: diff --git a/app/scouting/tests.py b/app/scouting/tests.py index 9f6ebf4..10d50d5 100644 --- a/app/scouting/tests.py +++ b/app/scouting/tests.py @@ -5,7 +5,7 @@ from django.core.management import call_command from django.test import TestCase 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): @@ -411,3 +411,67 @@ class SeedScoutingDataCommandTests(TestCase): 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 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) diff --git a/app/scouting/urls.py b/app/scouting/urls.py index 5c7e432..70ec4ef 100644 --- a/app/scouting/urls.py +++ b/app/scouting/urls.py @@ -7,4 +7,7 @@ app_name = "scouting" urlpatterns = [ path("players/", views.player_list, name="player_list"), path("players//", views.player_detail, name="player_detail"), + path("players//favorite/", views.add_favorite, name="add_favorite"), + path("players//unfavorite/", views.remove_favorite, name="remove_favorite"), + path("favorites/", views.favorites_list, name="favorites_list"), ] diff --git a/app/scouting/views.py b/app/scouting/views.py index 7479f4a..1043200 100644 --- a/app/scouting/views.py +++ b/app/scouting/views.py @@ -4,10 +4,13 @@ from decimal import Decimal from django.core.paginator import Paginator from django.db.models import Exists, OuterRef, Prefetch +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 -from .models import Player, PlayerSeason +from .models import FavoritePlayer, Player, PlayerSeason PAGE_SIZE = 20 PLAYER_SORTS = { @@ -25,6 +28,19 @@ CONTEXT_SORTS = { } +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" @@ -232,6 +248,7 @@ def player_list(request): 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) @@ -268,5 +285,39 @@ def player_detail(request, player_id: int): { "player": player, "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, }, )