Anchor DLC purchases to a base game instead of a parent purchase
Django CI/CD / test (push) Successful in 3m35s
Staging deployment / deploy (push) Successful in 1m24s
Staging deployment / comment (push) Has been skipped
Staging deployment / teardown (push) Has been skipped
Django CI/CD / build-and-push (push) Has been skipped
Django CI/CD / test (push) Successful in 3m35s
Staging deployment / deploy (push) Successful in 1m24s
Staging deployment / comment (push) Has been skipped
Staging deployment / teardown (push) Has been skipped
Django CI/CD / build-and-push (push) Has been skipped
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:
+10
-33
@@ -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`` ``<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):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -272,9 +246,12 @@ class PurchaseForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
search_url="/api/platforms/search", options_resolver=_platform_options
|
||||
),
|
||||
)
|
||||
related_purchase = RelatedPurchaseChoiceField(
|
||||
queryset=related_purchase_queryset(),
|
||||
related_game = forms.ModelChoiceField(
|
||||
queryset=Game.objects.order_by("sort_name"),
|
||||
required=False,
|
||||
widget=SearchSelectWidget(
|
||||
search_url="/api/games/search", options_resolver=_game_options
|
||||
),
|
||||
)
|
||||
|
||||
price_currency = forms.CharField(
|
||||
@@ -305,14 +282,14 @@ class PurchaseForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
"price_currency",
|
||||
"ownership_type",
|
||||
"type",
|
||||
"related_purchase",
|
||||
"related_game",
|
||||
"name",
|
||||
]
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
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")
|
||||
|
||||
# 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:
|
||||
type_display = self.instance.get_type_display()
|
||||
if not related_purchase:
|
||||
if not related_game:
|
||||
self.add_error(
|
||||
"related_purchase",
|
||||
f"{type_display} must have a related purchase.",
|
||||
"related_game",
|
||||
f"{type_display} must have a related game.",
|
||||
)
|
||||
if not name:
|
||||
self.add_error("name", f"{type_display} must have a name.")
|
||||
|
||||
Reference in New Issue
Block a user