diff --git a/CLAUDE.md b/CLAUDE.md index bfafbd4..5c58a5f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,7 +44,7 @@ docs/ — Additional documentation - **Game** — `name`, `platform` (FK), `status` (u/p/f/r/a), `mastered`, `playtime` (DurationField updated via signal), `year_released`, `sort_name`, `wikidata` - **Platform** — `name`, `group`, `icon` (slug, auto-generated from name) -- **Purchase** — ownership type, prices, currency conversion (`converted_price`, `price_per_game` is a `GeneratedField`), links to Game via M2M. `num_purchases` counts linked games. DLC/SeasonPass/BattlePass must have a `related_purchase` +- **Purchase** — ownership type, prices, currency conversion (`converted_price`, `price_per_game` is a `GeneratedField`), links to Game via M2M. `num_purchases` counts linked games. DLC/SeasonPass/BattlePass must have a `related_game` (the base Game the add-on belongs to; reverse accessor `game.addon_purchases`) - **Session** — `timestamp_start`/`timestamp_end`, `duration_manual`, `device` (FK), `note`, `emulated`. `duration_calculated` and `duration_total` are `GeneratedField`s (cannot be written directly) - **Device** — `name`, `type` (PC/Console/Handheld/Mobile/SBC/Unknown) - **PlayEvent** — marks when a game was started/finished (separate from Sessions), `days_to_finish` is a `GeneratedField` diff --git a/e2e/test_widgets_e2e.py b/e2e/test_widgets_e2e.py index 4e95e88..ae328e7 100644 --- a/e2e/test_widgets_e2e.py +++ b/e2e/test_widgets_e2e.py @@ -120,7 +120,7 @@ def test_widgets_initialize_inside_htmx_swapped_content( def test_add_purchase_type_toggles_disabled_fields( authenticated_page: Page, live_server ): - """add_purchase.js disables name/related-purchase while type is "game" + """add_purchase.js disables name/related-game while type is "game" and re-enables them for other types.""" page = authenticated_page page.goto(f"{live_server.url}{reverse('games:add_purchase')}") @@ -133,3 +133,17 @@ def test_add_purchase_type_toggles_disabled_fields( page.select_option("#id_type", "game") expect(name_input).to_be_disabled() + + +def test_add_purchase_related_game_is_flat_game_search( + authenticated_page: Page, live_server +): + """The DLC/Season-Pass anchor is now a flat game search (related_game), + wired to the games search API and present regardless of which games are + selected — not the old parent-purchase dropdown filtered by chosen games.""" + page = authenticated_page + page.goto(f"{live_server.url}{reverse('games:add_purchase')}") + + related = page.locator('[data-search-select][data-name="related_game"]') + expect(related).to_have_count(1) + expect(related).to_have_attribute("data-search-url", "/api/games/search") diff --git a/games/forms.py b/games/forms.py index f3b1c67..b384a38 100644 --- a/games/forms.py +++ b/games/forms.py @@ -1,6 +1,5 @@ from django import forms from django.db import transaction -from django.db.models import OuterRef, Subquery from common.components import ( DEFAULT_PREFETCH, @@ -228,31 +227,6 @@ class SessionForm(PrimitiveWidgetsMixin, forms.ModelForm): return session -def related_purchase_queryset(): - """GAME purchases annotated with their first game's name. - - Rendering the ``related_purchase`` ``), so we // react to its custom "search-select:change" event instead of syncing a select. document.addEventListener("search-select:change", (event) => { if (event.detail.name !== "games") return; - // (a) Auto-fill platform from the clicked option's data-platform. + // Auto-fill platform from the clicked option's data-platform. const last = event.detail.last; const platformId = last && last.data ? last.data.platform : ""; if (platformId) { const platformEl = getEl("#id_platform"); if (platformEl) platformEl.value = platformId; } - - // (b) Refresh #id_related_purchase for the currently selected games. - const query = event.detail.values - .map((value) => "games=" + encodeURIComponent(value)) - .join("&"); - fetch(RELATED_PURCHASE_URL + "?" + query, { credentials: "same-origin" }) - .then((response) => { - if (response.status === 204) return null; - return response.text(); - }) - .then((html) => { - if (html === null) return; - const target = getEl("#id_related_purchase"); - if (target) target.outerHTML = html; - }); }); function setupElementHandlers() { disableElementsWhenTrue("#id_type", "game", [ "#id_name", - "#id_related_purchase", + "#id_related_game", ]); } diff --git a/games/urls.py b/games/urls.py index c9e8ada..beb431f 100644 --- a/games/urls.py +++ b/games/urls.py @@ -105,11 +105,6 @@ urlpatterns = [ purchase.refund_purchase, name="refund_purchase", ), - path( - "purchase/related-purchase-by-game", - purchase.related_purchase_by_game, - name="related_purchase_by_game", - ), path("session/add", session.add_session, name="add_session"), path( "session/add/for-game/", diff --git a/games/views/purchase.py b/games/views/purchase.py index 6b3c4a1..14cd131 100644 --- a/games/views/purchase.py +++ b/games/views/purchase.py @@ -393,25 +393,3 @@ def finish_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse: game.status = Game.Status.FINISHED game.save() return redirect("games:list_purchases") - - -def related_purchase_by_game(request: HttpRequest) -> HttpResponse: - games: list[str] = request.GET.getlist("games") - if games: - from games.forms import related_purchase_queryset - - form = PurchaseForm() - qs = ( - related_purchase_queryset() - .filter(games__in=games) - .order_by("games__sort_name") - ) - - form.fields["related_purchase"].queryset = qs - first_option = qs.first() - if first_option: - form.fields["related_purchase"].initial = first_option.id - return HttpResponse(str(form["related_purchase"])) - else: - # abort swap - return HttpResponse(status=204) diff --git a/tests/test_purchase_related_game.py b/tests/test_purchase_related_game.py new file mode 100644 index 0000000..8ca830d --- /dev/null +++ b/tests/test_purchase_related_game.py @@ -0,0 +1,50 @@ +from datetime import date + +from django.core.exceptions import ValidationError +from django.test import TestCase + +from games.models import Game, Platform, Purchase + + +class PurchaseRelatedGameTest(TestCase): + def setUp(self): + self.platform = Platform.objects.create(name="PC", icon="pc", group="PC") + self.base_game = Game.objects.create(name="Base Game", platform=self.platform) + self.dlc_game = Game.objects.create(name="The DLC", platform=self.platform) + + def test_non_game_purchase_requires_related_game(self): + purchase = Purchase( + price=10.0, + price_currency="USD", + date_purchased=date(2025, 1, 1), + type=Purchase.SEASONPASS, + name="Season Pass", + ) + with self.assertRaises(ValidationError): + purchase.save() + + def test_non_game_purchase_saves_with_related_game(self): + purchase = Purchase( + price=10.0, + price_currency="USD", + date_purchased=date(2025, 1, 1), + type=Purchase.SEASONPASS, + name="Season Pass", + related_game=self.base_game, + ) + purchase.save() + purchase.games.add(self.dlc_game) + + self.assertEqual(purchase.related_game, self.base_game) + # Reverse accessor: the base game lists its add-on purchases. + self.assertIn(purchase, self.base_game.addon_purchases.all()) + + def test_plain_game_purchase_needs_no_related_game(self): + purchase = Purchase( + price=50.0, + price_currency="USD", + date_purchased=date(2025, 1, 1), + type=Purchase.GAME, + ) + purchase.save() # must not raise + self.assertIsNone(purchase.related_game)