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

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:
2026-06-18 23:17:27 +02:00
parent 9851bb8e0d
commit dcfea202ce
9 changed files with 130 additions and 86 deletions
+10 -33
View File
@@ -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.")
@@ -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
View File
@@ -198,12 +198,13 @@ class Purchase(models.Model):
)
type = models.CharField(max_length=255, choices=TYPES, default=GAME)
name = models.CharField(max_length=255, blank=True, default="")
related_purchase = models.ForeignKey(
"self",
related_game = models.ForeignKey(
Game,
on_delete=models.SET_NULL,
default=None,
null=True,
related_name="related_purchases",
blank=True,
related_name="addon_purchases",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@@ -252,9 +253,9 @@ class Purchase(models.Model):
self.save()
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(
f"{self.get_type_display()} must have a related purchase."
f"{self.get_type_display()} must have a related game."
)
super().save(*args, **kwargs)
+2 -19
View File
@@ -1,40 +1,23 @@
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
// 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",
]);
}
-5
View File
@@ -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/<int:game_id>",
-22
View File
@@ -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)