Anchor DLC purchases to a base game instead of a parent purchase
Add-on purchases (DLC, Season Pass, Battle Pass) previously linked to a parent *purchase* via the `related_purchase` self-FK. When the base game was bought inside a multi-game purchase (e.g. a bundle), there was no per-game purchase to point at — only the whole bundle. Replace it with a `related_game` FK (Game -> Game): an add-on belongs to a *game*, which is unambiguous regardless of how the base game was bought. - models: drop `related_purchase`; add `related_game` (SET_NULL, related_name="addon_purchases"); require it for non-GAME types in `save()`. - forms: replace the parent-purchase picker with a flat `related_game` game search (reusing SearchSelectWidget/_game_options); drop the now unused related_purchase_queryset/RelatedPurchaseChoiceField. - views/urls: remove the obsolete related_purchase_by_game endpoint. - add_purchase.js: drop the parent-dropdown refetch; keep platform auto-fill; retarget the type toggle to #id_related_game. - migration 0020: add -> backfill (related_game = parent's first game by sort_name) -> remove related_purchase. - tests: model validation unit tests + an e2e test for the flat picker. related_game is deliberately game->game so it can later be synced from IGDB's parent_game without schema changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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`
|
- **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)
|
- **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)
|
- **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)
|
- **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`
|
- **PlayEvent** — marks when a game was started/finished (separate from Sessions), `days_to_finish` is a `GeneratedField`
|
||||||
|
|||||||
+15
-1
@@ -120,7 +120,7 @@ def test_widgets_initialize_inside_htmx_swapped_content(
|
|||||||
def test_add_purchase_type_toggles_disabled_fields(
|
def test_add_purchase_type_toggles_disabled_fields(
|
||||||
authenticated_page: Page, live_server
|
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."""
|
and re-enables them for other types."""
|
||||||
page = authenticated_page
|
page = authenticated_page
|
||||||
page.goto(f"{live_server.url}{reverse('games:add_purchase')}")
|
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")
|
page.select_option("#id_type", "game")
|
||||||
expect(name_input).to_be_disabled()
|
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")
|
||||||
|
|||||||
+10
-33
@@ -1,6 +1,5 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import OuterRef, Subquery
|
|
||||||
|
|
||||||
from common.components import (
|
from common.components import (
|
||||||
DEFAULT_PREFETCH,
|
DEFAULT_PREFETCH,
|
||||||
@@ -228,31 +227,6 @@ class SessionForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
|||||||
return session
|
return session
|
||||||
|
|
||||||
|
|
||||||
def related_purchase_queryset():
|
|
||||||
"""GAME purchases annotated with their first game's name.
|
|
||||||
|
|
||||||
Rendering the ``related_purchase`` ``<select>`` calls ``str()`` on every
|
|
||||||
option, and ``Purchase.__str__`` falls back to ``first_game`` — one extra
|
|
||||||
query per option (700+ on a large library). Annotating the first game's
|
|
||||||
name via a subquery lets the choice field build labels without those
|
|
||||||
per-row queries.
|
|
||||||
"""
|
|
||||||
first_game_name = Subquery(
|
|
||||||
Game.objects.filter(purchases=OuterRef("pk")).order_by("id").values("name")[:1]
|
|
||||||
)
|
|
||||||
return Purchase.objects.filter(type=Purchase.GAME).annotate(
|
|
||||||
_first_game_name=first_game_name
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class RelatedPurchaseChoiceField(forms.ModelChoiceField):
|
|
||||||
def label_from_instance(self, obj) -> str:
|
|
||||||
# Mirrors Purchase.standardized_name but reads the annotated first-game
|
|
||||||
# name instead of querying first_game per option.
|
|
||||||
name = obj.name or getattr(obj, "_first_game_name", None)
|
|
||||||
return name or obj.standardized_name
|
|
||||||
|
|
||||||
|
|
||||||
class PurchaseForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
class PurchaseForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
@@ -272,9 +246,12 @@ class PurchaseForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
|||||||
search_url="/api/platforms/search", options_resolver=_platform_options
|
search_url="/api/platforms/search", options_resolver=_platform_options
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
related_purchase = RelatedPurchaseChoiceField(
|
related_game = forms.ModelChoiceField(
|
||||||
queryset=related_purchase_queryset(),
|
queryset=Game.objects.order_by("sort_name"),
|
||||||
required=False,
|
required=False,
|
||||||
|
widget=SearchSelectWidget(
|
||||||
|
search_url="/api/games/search", options_resolver=_game_options
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
price_currency = forms.CharField(
|
price_currency = forms.CharField(
|
||||||
@@ -305,14 +282,14 @@ class PurchaseForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
|||||||
"price_currency",
|
"price_currency",
|
||||||
"ownership_type",
|
"ownership_type",
|
||||||
"type",
|
"type",
|
||||||
"related_purchase",
|
"related_game",
|
||||||
"name",
|
"name",
|
||||||
]
|
]
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
cleaned_data = super().clean()
|
cleaned_data = super().clean()
|
||||||
purchase_type = cleaned_data.get("type")
|
purchase_type = cleaned_data.get("type")
|
||||||
related_purchase = cleaned_data.get("related_purchase")
|
related_game = cleaned_data.get("related_game")
|
||||||
name = cleaned_data.get("name")
|
name = cleaned_data.get("name")
|
||||||
|
|
||||||
# Set the type on the instance to use get_type_display()
|
# Set the type on the instance to use get_type_display()
|
||||||
@@ -321,10 +298,10 @@ class PurchaseForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
|||||||
|
|
||||||
if purchase_type != Purchase.GAME:
|
if purchase_type != Purchase.GAME:
|
||||||
type_display = self.instance.get_type_display()
|
type_display = self.instance.get_type_display()
|
||||||
if not related_purchase:
|
if not related_game:
|
||||||
self.add_error(
|
self.add_error(
|
||||||
"related_purchase",
|
"related_game",
|
||||||
f"{type_display} must have a related purchase.",
|
f"{type_display} must have a related game.",
|
||||||
)
|
)
|
||||||
if not name:
|
if not name:
|
||||||
self.add_error("name", f"{type_display} must have a name.")
|
self.add_error("name", f"{type_display} must have a name.")
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Generated by Django 6.0.6 on 2026-06-18 21:03
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_related_game(apps, schema_editor):
|
||||||
|
"""Move each add-on purchase's parent link from the parent *purchase* to a
|
||||||
|
parent *game*. For a parent bought as a multi-game bundle there is no single
|
||||||
|
game, so use the bundle's first game (by sort_name) as the best guess."""
|
||||||
|
Purchase = apps.get_model("games", "Purchase")
|
||||||
|
for purchase in Purchase.objects.filter(related_purchase__isnull=False):
|
||||||
|
parent_game = purchase.related_purchase.games.order_by("sort_name").first()
|
||||||
|
if parent_game is not None:
|
||||||
|
purchase.related_game = parent_game
|
||||||
|
purchase.save(update_fields=["related_game"])
|
||||||
|
|
||||||
|
|
||||||
|
def noop_reverse(apps, schema_editor):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("games", "0019_alter_filterpreset_mode"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="purchase",
|
||||||
|
name="related_game",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
default=None,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="addon_purchases",
|
||||||
|
to="games.game",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(backfill_related_game, noop_reverse),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="purchase",
|
||||||
|
name="related_purchase",
|
||||||
|
),
|
||||||
|
]
|
||||||
+6
-5
@@ -198,12 +198,13 @@ class Purchase(models.Model):
|
|||||||
)
|
)
|
||||||
type = models.CharField(max_length=255, choices=TYPES, default=GAME)
|
type = models.CharField(max_length=255, choices=TYPES, default=GAME)
|
||||||
name = models.CharField(max_length=255, blank=True, default="")
|
name = models.CharField(max_length=255, blank=True, default="")
|
||||||
related_purchase = models.ForeignKey(
|
related_game = models.ForeignKey(
|
||||||
"self",
|
Game,
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
default=None,
|
default=None,
|
||||||
null=True,
|
null=True,
|
||||||
related_name="related_purchases",
|
blank=True,
|
||||||
|
related_name="addon_purchases",
|
||||||
)
|
)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
@@ -252,9 +253,9 @@ class Purchase(models.Model):
|
|||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if self.type != Purchase.GAME and not self.related_purchase:
|
if self.type != Purchase.GAME and not self.related_game:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
f"{self.get_type_display()} must have a related purchase."
|
f"{self.get_type_display()} must have a related game."
|
||||||
)
|
)
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|||||||
@@ -1,40 +1,23 @@
|
|||||||
import { getEl, disableElementsWhenTrue, onSwap } from "./utils.js";
|
import { getEl, disableElementsWhenTrue, onSwap } from "./utils.js";
|
||||||
|
|
||||||
const RELATED_PURCHASE_URL = "/tracker/purchase/related-purchase-by-game";
|
|
||||||
|
|
||||||
// The games field is now a SearchSelect widget (a <div>, not a <select>), so we
|
// The games field is now a SearchSelect widget (a <div>, not a <select>), so we
|
||||||
// react to its custom "search-select:change" event instead of syncing a select.
|
// react to its custom "search-select:change" event instead of syncing a select.
|
||||||
document.addEventListener("search-select:change", (event) => {
|
document.addEventListener("search-select:change", (event) => {
|
||||||
if (event.detail.name !== "games") return;
|
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 last = event.detail.last;
|
||||||
const platformId = last && last.data ? last.data.platform : "";
|
const platformId = last && last.data ? last.data.platform : "";
|
||||||
if (platformId) {
|
if (platformId) {
|
||||||
const platformEl = getEl("#id_platform");
|
const platformEl = getEl("#id_platform");
|
||||||
if (platformEl) platformEl.value = platformId;
|
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() {
|
function setupElementHandlers() {
|
||||||
disableElementsWhenTrue("#id_type", "game", [
|
disableElementsWhenTrue("#id_type", "game", [
|
||||||
"#id_name",
|
"#id_name",
|
||||||
"#id_related_purchase",
|
"#id_related_game",
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -105,11 +105,6 @@ urlpatterns = [
|
|||||||
purchase.refund_purchase,
|
purchase.refund_purchase,
|
||||||
name="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", session.add_session, name="add_session"),
|
||||||
path(
|
path(
|
||||||
"session/add/for-game/<int:game_id>",
|
"session/add/for-game/<int:game_id>",
|
||||||
|
|||||||
@@ -393,25 +393,3 @@ def finish_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
|||||||
game.status = Game.Status.FINISHED
|
game.status = Game.Status.FINISHED
|
||||||
game.save()
|
game.save()
|
||||||
return redirect("games:list_purchases")
|
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)
|
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user