Model refundable orders as separate purchases; add split action
Django CI/CD / test (push) Successful in 3m41s
Staging deployment / deploy (push) Successful in 1m7s
Staging deployment / comment (push) Has been skipped
Staging deployment / teardown (push) Has been skipped
Django CI/CD / build-and-push (push) Failing after 12m17s

A multi-game Purchase is now treated as an *unsplittable* bundle (one
price, whole-purchase refund). Independently-refundable multi-item orders
(e.g. a Steam cart) are instead recorded as N separate single-game
purchases, so per-game pricing and per-game refunds work with the
existing single-purchase machinery — no through-model needed.

Add-purchase form (single form, single endpoint):
- 1 game: unchanged.
- 2+ games: a "Separate price per game" toggle appears (default off =
  one bundle price). On, the bundle Price hides and one price input per
  game appears; the view creates one single-game Purchase each from
  price_for_game_<id>. `price` is now optional so combined mode still
  validates.

Split action:
- A Split button on multi-game purchase rows opens a confirmation modal
  that replaces the bundle with one single-game purchase per game (price
  split evenly, needs_price_update set), then HX-Redirects to the list.

New general-purpose `selection-fields` custom element renders one synced
form field per selected item of a source SearchSelect (consuming the
existing search-select:change contract); it knows nothing about prices,
so it is reusable. Behavior in ts/elements/selection-fields.ts.

Adds the bundle-vs-separate-purchases convention to CLAUDE.md, a split
icon, and unit + Playwright e2e coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 07:42:44 +02:00
parent f693f8280f
commit 733da3419b
11 changed files with 711 additions and 7 deletions
+1 -1
View File
@@ -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_game` (the base Game the add-on belongs to; reverse accessor `game.addon_purchases`) - **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`). **A multi-game Purchase is an *unsplittable* bundle** (one price, whole-purchase refund — e.g. a Humble Bundle). Independently-refundable multi-item orders (e.g. a Steam cart) are modeled as **separate single-game purchases**, not one bundle: the add-purchase form's "separate price per game" mode (≥2 games) creates them, and the row's **Split** action breaks an existing bundle into per-game purchases (price split evenly as a starting point). This is why per-game refund/price need no through-model — each refundable unit is its own Purchase.
- **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`
+6 -1
View File
@@ -18,7 +18,11 @@ from common.components.core import (
randomid, randomid,
render, render,
) )
from common.components.custom_elements import SessionTimestampButtons, register_element from common.components.custom_elements import (
SelectionFields,
SessionTimestampButtons,
register_element,
)
from common.components.date_range_picker import ( from common.components.date_range_picker import (
DateRangeCalendar, DateRangeCalendar,
DateRangeField, DateRangeField,
@@ -94,6 +98,7 @@ __all__ = [
"truncate", "truncate",
"BaseComponent", "BaseComponent",
"register_element", "register_element",
"SelectionFields",
"SessionTimestampButtons", "SessionTimestampButtons",
"custom_element_builder", "custom_element_builder",
"Element", "Element",
+56 -2
View File
@@ -11,8 +11,14 @@ reader so drift fails ``tsc``.
from dataclasses import dataclass from dataclasses import dataclass
from typing import TypedDict, get_type_hints from typing import TypedDict, get_type_hints
from common.components.core import Media from common.components.core import Node
from common.components.primitives import custom_element_builder from common.components.primitives import (
Div,
Input,
Label,
Template,
custom_element_builder,
)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -125,3 +131,51 @@ _GameStatusSelector = custom_element_builder("game-status-selector")
_SessionDeviceSelector = custom_element_builder("session-device-selector") _SessionDeviceSelector = custom_element_builder("session-device-selector")
_PlayEventRow = custom_element_builder("play-event-row") _PlayEventRow = custom_element_builder("play-event-row")
SessionTimestampButtons = custom_element_builder("session-timestamp-buttons") SessionTimestampButtons = custom_element_builder("session-timestamp-buttons")
class SelectionFieldsProps(TypedDict):
source: str # data-name of the source SearchSelect to mirror
name_prefix: str # each rendered input is named f"{name_prefix}{item_id}"
field_type: str # input type, e.g. "number"
min_items: int # render nothing until at least this many items are selected
active: bool # when false, render nothing (but preserve typed values)
register_element("selection-fields", "SelectionFields", SelectionFieldsProps)
_SelectionFields = custom_element_builder("selection-fields")
def SelectionFields(
*,
source: str,
name_prefix: str,
field_type: str = "text",
min_items: int = 1,
active: bool = False,
input_attributes: list[tuple[str, str]] | None = None,
) -> Node:
"""Render one synced form field per selected item of a source SearchSelect.
General-purpose: it mirrors the SearchSelect named ``source`` and emits an
input named ``f"{name_prefix}{item_id}"`` per selected item. Behavior lives
in ``ts/elements/selection-fields.ts``; this is just the server-rendered
light DOM (an empty rows container + a row ``<template>``). Inputs inherit
the global ``#add-form`` styling, so the markup stays minimal.
"""
row_template = Template(attributes=[("data-selection-fields-row", "")])[
Div(attributes=[("data-selection-fields-row-item", "")])[
Label(attributes=[("data-selection-fields-label", "")]),
Input(type=field_type, attributes=list(input_attributes or [])),
]
]
return _SelectionFields(
source=source,
name_prefix=name_prefix,
field_type=field_type,
min_items=min_items,
active="true" if active else "false",
)[
Div(attributes=[("data-selection-fields-rows", "")]),
row_template,
]
+178
View File
@@ -0,0 +1,178 @@
"""Browser tests for the purchase pricing UX and the split action.
- A synthetic page isolates the general ``selection-fields`` element (no API,
deterministic option values), mirroring ``test_search_select_e2e.py``.
- The real-app tests drive the actual add-purchase form and the split modal
against pytest-django's ``live_server``.
"""
from datetime import date
import pytest
from django.http import HttpResponse
from django.test import override_settings
from django.urls import path, reverse
from playwright.sync_api import Page, expect
from common.components import SearchSelect, SelectionFields
from games.models import Game, Platform, Purchase
def selection_fields_view(request):
html = f"""
<!DOCTYPE html>
<html>
<head>
<script src="/static/js/htmx.min.js"></script>
<script type="module" src="/static/js/search_select.js"></script>
<script type="module" src="/static/js/dist/elements/selection-fields.js"></script>
</head>
<body>
<div style="padding: 50px;">
{
SearchSelect(
name="games",
selected=[],
options=[
{"value": "7", "label": "Game A", "data": {}},
{"value": "8", "label": "Game B", "data": {}},
],
multi_select=True,
)
}
{
SelectionFields(
source="games",
name_prefix="price_for_game_",
field_type="number",
min_items=2,
active=True,
)
}
</div>
</body>
</html>
"""
return HttpResponse(html)
urlpatterns = [
path("sf-test/", selection_fields_view),
]
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_purchase_e2e")
def test_selection_fields_syncs_with_source(live_server, page: Page):
page.goto(live_server.url + "/sf-test/")
games = page.locator('[data-search-select][data-name="games"]')
rows = page.locator("selection-fields [data-selection-fields-rows] input")
# Below min_items (2): nothing rendered.
expect(rows).to_have_count(0)
games.locator("[data-search-select-search]").click()
games.locator('[data-search-select-option][data-value="7"]').click()
expect(rows).to_have_count(0) # only one selected, still below min_items
games.locator("[data-search-select-search]").click()
games.locator('[data-search-select-option][data-value="8"]').click()
expect(rows).to_have_count(2)
# One input per item, named by the prefix + item id.
expect(
page.locator('selection-fields input[name="price_for_game_7"]')
).to_have_count(1)
expect(
page.locator('selection-fields input[name="price_for_game_8"]')
).to_have_count(1)
# Typed values survive removing and re-adding another item.
page.locator('selection-fields input[name="price_for_game_7"]').fill("12")
games.locator('[data-pill][data-value="8"] [data-pill-remove]').click()
expect(rows).to_have_count(0)
games.locator("[data-search-select-search]").click()
games.locator('[data-search-select-option][data-value="8"]').click()
expect(rows).to_have_count(2)
expect(
page.locator('selection-fields input[name="price_for_game_7"]')
).to_have_value("12")
@pytest.fixture
def authenticated_page(live_server, page: Page, django_user_model) -> Page:
django_user_model.objects.create_user(username="tester", password="secret123")
page.goto(f"{live_server.url}{reverse('login')}")
page.fill('input[name="username"]', "tester")
page.fill('input[name="password"]', "secret123")
page.click('input[type="submit"]')
page.wait_for_url(f"{live_server.url}/tracker**")
return page
def _select_two_games(page: Page) -> None:
games = page.locator('[data-search-select][data-name="games"]')
games.locator("[data-search-select-search]").click()
options = games.locator("[data-search-select-option]")
expect(options).to_have_count(2) # prefetched on focus
options.nth(0).click()
options.nth(1).click()
def test_add_purchase_per_game_toggle_reveals_inputs(
authenticated_page: Page, live_server
):
"""The combined/per-game toggle appears only at 2+ games; turning it on
hides the bundle Price and shows one price input per selected game.
(Server-side creation of N purchases is covered by the unit tests.)"""
page = authenticated_page
platform = Platform.objects.create(name="PC", icon="pc", group="PC")
Game.objects.create(name="Alpha Game", platform=platform)
Game.objects.create(name="Beta Game", platform=platform)
page.goto(f"{live_server.url}{reverse('games:add_purchase')}")
checkbox_row = page.locator("#separate-prices-row")
expect(checkbox_row).to_be_hidden()
_select_two_games(page)
expect(checkbox_row).to_be_visible()
page.locator("#id_separate_prices").check()
expect(page.locator("#id_price")).to_be_hidden()
per_game_inputs = page.locator(
"selection-fields [data-selection-fields-rows] input"
)
expect(per_game_inputs).to_have_count(2)
def test_split_purchase_action(authenticated_page: Page, live_server):
page = authenticated_page
platform = Platform.objects.create(name="PC", icon="pc", group="PC")
game_a = Game.objects.create(name="Alpha Game", platform=platform)
game_b = Game.objects.create(name="Beta Game", platform=platform)
bundle = Purchase.objects.create(
price=30.0,
price_currency="USD",
date_purchased=date(2025, 1, 1),
platform=platform,
ownership_type=Purchase.DIGITAL,
type=Purchase.GAME,
)
bundle.games.set([game_a, game_b])
page.goto(f"{live_server.url}{reverse('games:list_purchases')}")
# Before: one bundle row.
expect(page.locator('[id^="purchase-row-"]')).to_have_count(1)
page.locator('[title="Split into per-game purchases"]').click()
modal = page.locator("#split-confirmation-modal")
expect(modal).to_be_visible()
modal.locator('button[type="submit"]', has_text="Split").click()
page.wait_for_url(f"{live_server.url}{reverse('games:list_purchases')}**")
# After: the bundle row is gone, replaced by two per-game rows. Asserted via
# the UI (not the ORM) to avoid live_server/SQLite write-read contention.
expect(page.locator(f"#purchase-row-{bundle.id}")).to_have_count(0)
expect(page.locator('[id^="purchase-row-"]')).to_have_count(2)
+8
View File
@@ -231,6 +231,9 @@ class PurchaseForm(PrimitiveWidgetsMixin, forms.ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields["platform"].queryset = Platform.objects.order_by("name") self.fields["platform"].queryset = Platform.objects.order_by("name")
# The bundle Price is optional: in price-per-game mode it is hidden and
# the per-game inputs carry the prices instead. Empty falls back to 0.
self.fields["price"].required = False
games = MultipleGameChoiceField( games = MultipleGameChoiceField(
queryset=Game.objects.order_by("sort_name"), queryset=Game.objects.order_by("sort_name"),
@@ -305,6 +308,11 @@ class PurchaseForm(PrimitiveWidgetsMixin, forms.ModelForm):
) )
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.")
# An empty bundle Price (price-per-game mode) saves as 0, not NULL.
if cleaned_data.get("price") is None:
cleaned_data["price"] = 0
return cleaned_data return cleaned_data
+34
View File
@@ -1,5 +1,24 @@
import { getEl, disableElementsWhenTrue, onSwap } from "./utils.js"; import { getEl, disableElementsWhenTrue, onSwap } from "./utils.js";
// Switch between a single bundle price and one price per game. The per-game
// inputs are the selection-fields element; this only sets the policy: the
// hidden pricing_mode the view reads, the element's "active" flag, and whether
// the bundle Price field is shown.
function applyPricingMode(separate) {
const pricingMode = getEl("#id_pricing_mode");
if (pricingMode) pricingMode.value = separate ? "per_game" : "combined";
const selectionFields = document.querySelector("selection-fields");
if (selectionFields)
selectionFields.setAttribute("active", separate ? "true" : "false");
const priceInput = getEl("#id_price");
if (priceInput) {
const wrapper = priceInput.closest("div");
if (wrapper) wrapper.classList.toggle("hidden", separate);
}
}
// 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) => {
@@ -12,6 +31,21 @@ document.addEventListener("search-select:change", (event) => {
const platformEl = getEl("#id_platform"); const platformEl = getEl("#id_platform");
if (platformEl) platformEl.value = platformId; if (platformEl) platformEl.value = platformId;
} }
// The combined/per-game choice is only meaningful with 2+ games. Reveal the
// checkbox there; below the threshold, fall back to a single bundle price.
const separateRow = getEl("#separate-prices-row");
const multipleGames = event.detail.values.length >= 2;
if (separateRow) separateRow.classList.toggle("hidden", !multipleGames);
if (!multipleGames) {
const checkbox = getEl("#id_separate_prices");
if (checkbox) checkbox.checked = false;
applyPricingMode(false);
}
});
onSwap("#id_separate_prices", (checkbox) => {
checkbox.addEventListener("change", () => applyPricingMode(checkbox.checked));
}); });
function setupElementHandlers() { function setupElementHandlers() {
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="text-black dark:text-white w-4 h-4">
<path fill="currentColor" d="M14 4l2.29 2.29-2.88 2.88 1.42 1.42 2.88-2.88L20 12V4z M10 4H4v8l2.29-2.29 4.71 4.71V20h2v-8.41l-5.29-5.3z"></path>
</svg>

After

Width:  |  Height:  |  Size: 270 B

+10
View File
@@ -105,6 +105,16 @@ urlpatterns = [
purchase.refund_purchase, purchase.refund_purchase,
name="refund_purchase", name="refund_purchase",
), ),
path(
"purchase/<int:purchase_id>/split/confirm",
purchase.split_purchase_confirmation,
name="split_purchase_confirmation",
),
path(
"purchase/<int:purchase_id>/split",
purchase.split_purchase,
name="split_purchase",
),
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>",
+205 -3
View File
@@ -5,6 +5,7 @@ from django.http import (
HttpResponse, HttpResponse,
HttpResponseRedirect, HttpResponseRedirect,
) )
from django.db import transaction
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.template.defaultfilters import date as date_filter from django.template.defaultfilters import date as date_filter
from django.template.defaultfilters import floatformat from django.template.defaultfilters import floatformat
@@ -17,18 +18,22 @@ from common.components import (
A, A,
AddForm, AddForm,
ButtonGroup, ButtonGroup,
Checkbox,
CsrfInput, CsrfInput,
Div, Div,
Element, Element,
Fragment, Fragment,
GameLink, GameLink,
Icon, Icon,
Input,
LinkedPurchase, LinkedPurchase,
Modal, Modal,
ModuleScript, ModuleScript,
Node, Node,
PriceConverted, PriceConverted,
PurchasePrice, PurchasePrice,
Safe,
SelectionFields,
StyledButton, StyledButton,
TableRow, TableRow,
paginated_table_content, paginated_table_content,
@@ -42,7 +47,7 @@ from games.models import Game, Purchase
from games.views.general import use_custom_redirect from games.views.general import use_custom_redirect
def _render_purchase_buttons(purchase_id, is_refunded): def _render_purchase_buttons(purchase_id, is_refunded, can_split=False):
"""Return button group HTML for a purchase row.""" """Return button group HTML for a purchase row."""
return ButtonGroup( return ButtonGroup(
[ [
@@ -58,6 +63,19 @@ def _render_purchase_buttons(purchase_id, is_refunded):
} }
if not is_refunded if not is_refunded
else {}, else {},
{
"href": "#",
"hx_get": reverse(
"games:split_purchase_confirmation",
args=[purchase_id],
),
"hx_target": "#global-modal-container",
"slot": Icon("split"),
"title": "Split into per-game purchases",
"color": "gray",
}
if can_split
else {},
{ {
"href": reverse("games:edit_purchase", args=[purchase_id]), "href": reverse("games:edit_purchase", args=[purchase_id]),
"slot": Icon("edit"), "slot": Icon("edit"),
@@ -90,7 +108,11 @@ def _render_purchase_row(purchase):
else "-" else "-"
), ),
purchase.created_at.strftime(dateformat), purchase.created_at.strftime(dateformat),
_render_purchase_buttons(purchase.id, bool(purchase.date_refunded)), _render_purchase_buttons(
purchase.id,
bool(purchase.date_refunded),
can_split=purchase.num_purchases > 1,
),
], ],
} }
@@ -166,6 +188,76 @@ def _purchase_additional_row() -> SafeText:
) )
def _pricing_controls() -> Node:
"""Pricing UI for the add-purchase form.
By default the form's own single Price field is the bundle price. When 2+
games are selected and "Separate price per game" is checked, the per-game
inputs (the general ``selection-fields`` element) take over and the bundle
Price is hidden. Toggle/visibility wiring lives in add_purchase.js; the
hidden ``pricing_mode`` tells the view which path to take.
"""
return Div(attributes=[("id", "pricing-controls")])[
Div(attributes=[("id", "separate-prices-row"), ("class", "hidden")])[
Checkbox(
name="separate_prices",
label="Separate price per game",
attributes=[("id", "id_separate_prices")],
),
],
Input(
type="hidden",
attributes=[
("name", "pricing_mode"),
("id", "id_pricing_mode"),
("value", "combined"),
],
),
SelectionFields(
source="games",
name_prefix="price_for_game_",
field_type="number",
min_items=2,
active=False,
input_attributes=[
("step", "0.01"),
("min", "0"),
("inputmode", "decimal"),
("placeholder", "Price"),
],
),
]
@transaction.atomic
def _create_separate_purchases(form: PurchaseForm, post) -> None:
"""Create one single-game Purchase per selected game from the shared form
fields, each priced from its own ``price_for_game_<id>`` input. The
``m2m_changed`` signal sets ``num_purchases``/``price_per_game`` once each
game is attached."""
data = form.cleaned_data
shared = {
"platform": data.get("platform"),
"date_purchased": data["date_purchased"],
"date_refunded": data.get("date_refunded"),
"infinite": data.get("infinite", False),
"price_currency": data["price_currency"],
"ownership_type": data["ownership_type"],
"type": data["type"],
"related_game": data.get("related_game"),
"name": data.get("name") or "",
}
for game in data["games"]:
raw_price = post.get(f"price_for_game_{game.id}", "")
try:
price = float(raw_price) if raw_price not in (None, "") else 0.0
except ValueError:
price = 0.0
purchase = Purchase(price=price, **shared)
purchase.save()
purchase.games.set([game])
@login_required @login_required
def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse: def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse:
initial = {"date_purchased": timezone.now()} initial = {"date_purchased": timezone.now()}
@@ -173,6 +265,9 @@ def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse:
if request.method == "POST": if request.method == "POST":
form = PurchaseForm(request.POST or None, initial=initial) form = PurchaseForm(request.POST or None, initial=initial)
if form.is_valid(): if form.is_valid():
if request.POST.get("pricing_mode") == "per_game":
_create_separate_purchases(form, request.POST)
return redirect("games:list_purchases")
purchase = form.save() purchase = form.save()
if "submit_and_redirect" in request.POST: if "submit_and_redirect" in request.POST:
return HttpResponseRedirect( return HttpResponseRedirect(
@@ -198,7 +293,12 @@ def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse:
return render_page( return render_page(
request, request,
AddForm(form, request=request, additional_row=_purchase_additional_row()), AddForm(
form,
request=request,
fields=Fragment(Safe(form.as_div()), _pricing_controls()),
additional_row=_purchase_additional_row(),
),
title="Add New Purchase", title="Add New Purchase",
scripts=mark_safe( scripts=mark_safe(
ModuleScript("search_select.js") + ModuleScript("add_purchase.js") ModuleScript("search_select.js") + ModuleScript("add_purchase.js")
@@ -386,6 +486,108 @@ def refund_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
return HttpResponse(row_html + modal_close, status=200) return HttpResponse(row_html + modal_close, status=200)
def _split_confirmation_modal(purchase: Purchase, request: HttpRequest) -> Node:
count = purchase.num_purchases
form = Element(
"form",
attributes=[("hx-post", reverse("games:split_purchase", args=[purchase.id]))],
children=[
CsrfInput(request),
P(
attributes=[("class", "dark:text-white text-center mt-3 text-sm")],
children=[
f"Creates {count} separate purchases, one per game, with the "
"price split evenly. Each can then be priced and refunded "
"independently."
],
),
Div(
[("class", "items-center mt-5")],
[
StyledButton(
[("class", "w-full")],
"Split",
color="blue",
size="lg",
type="submit",
),
StyledButton(
[("class", "mt-0 w-full")],
"Cancel",
color="gray",
size="base",
onclick="this.closest('#split-confirmation-modal').remove()",
),
],
),
],
)
return Modal(
"split-confirmation-modal",
children=[
Element(
"h1",
attributes=[
(
"class",
"text-2xl leading-6 font-medium dark:text-white text-center",
)
],
children=["Split purchase"],
),
P(
attributes=[("class", "dark:text-white text-center mt-5")],
children=[
f"Split “{purchase.standardized_name}” into per-game purchases?"
],
),
form,
],
)
@login_required
def split_purchase_confirmation(request: HttpRequest, purchase_id: int) -> HttpResponse:
purchase = get_object_or_404(Purchase, id=purchase_id)
return HttpResponse(_split_confirmation_modal(purchase, request))
@login_required
@require_POST
@transaction.atomic
def split_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
"""Replace one multi-game (unsplittable-style) purchase with one single-game
purchase per game, splitting the price evenly as a starting point. Each new
purchase is then independently priceable and refundable."""
purchase = get_object_or_404(Purchase, id=purchase_id)
games = list(purchase.games.all())
count = len(games)
if count > 1:
share = purchase.price / count
for game in games:
new_purchase = Purchase(
price=share,
price_currency=purchase.price_currency,
date_purchased=purchase.date_purchased,
date_refunded=purchase.date_refunded,
infinite=purchase.infinite,
ownership_type=purchase.ownership_type,
type=purchase.type,
related_game=purchase.related_game,
name=purchase.name,
platform=purchase.platform,
needs_price_update=True,
)
new_purchase.save()
new_purchase.games.set([game])
purchase.delete()
messages.success(request, f"Split into {count} purchases")
response = HttpResponse(status=204)
response["HX-Redirect"] = reverse("games:list_purchases")
return response
@login_required @login_required
def finish_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse: def finish_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
purchase = get_object_or_404(Purchase, id=purchase_id) purchase = get_object_or_404(Purchase, id=purchase_id)
+108
View File
@@ -0,0 +1,108 @@
from datetime import date
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from games.models import Game, Platform, Purchase
class AddPurchasePricingTest(TestCase):
def setUp(self):
self.user = User.objects.create_superuser("u", "u@e.com", "pw")
self.client.force_login(self.user)
self.platform = Platform.objects.create(name="PC", icon="pc", group="PC")
self.game_a = Game.objects.create(name="Game A", platform=self.platform)
self.game_b = Game.objects.create(name="Game B", platform=self.platform)
def _base_data(self, **overrides):
data = {
"games": [self.game_a.id, self.game_b.id],
"platform": self.platform.id,
"date_purchased": "2025-01-01",
"price_currency": "USD",
"ownership_type": Purchase.DIGITAL,
"type": Purchase.GAME,
"name": "",
}
data.update(overrides)
return data
def test_combined_creates_single_bundle(self):
data = self._base_data(pricing_mode="combined", price="30")
response = self.client.post(reverse("games:add_purchase"), data)
self.assertEqual(response.status_code, 302)
self.assertEqual(Purchase.objects.count(), 1)
bundle = Purchase.objects.get()
self.assertEqual(bundle.num_purchases, 2)
self.assertEqual(bundle.price, 30)
def test_per_game_creates_separate_single_game_purchases(self):
data = self._base_data(
pricing_mode="per_game",
**{
f"price_for_game_{self.game_a.id}": "10",
f"price_for_game_{self.game_b.id}": "20",
},
)
response = self.client.post(reverse("games:add_purchase"), data)
self.assertEqual(response.status_code, 302)
self.assertEqual(Purchase.objects.count(), 2)
for purchase in Purchase.objects.all():
self.assertEqual(purchase.num_purchases, 1)
self.assertEqual(sorted(p.price for p in Purchase.objects.all()), [10.0, 20.0])
linked_games = [
list(p.games.values_list("id", flat=True)) for p in Purchase.objects.all()
]
self.assertTrue(all(len(games) == 1 for games in linked_games))
self.assertEqual(
{games[0] for games in linked_games},
{self.game_a.id, self.game_b.id},
)
class SplitPurchaseTest(TestCase):
def setUp(self):
self.user = User.objects.create_superuser("u", "u@e.com", "pw")
self.client.force_login(self.user)
self.platform = Platform.objects.create(name="PC", icon="pc", group="PC")
self.game_a = Game.objects.create(name="Game A", platform=self.platform)
self.game_b = Game.objects.create(name="Game B", platform=self.platform)
def _bundle(self, games, price=30.0):
bundle = Purchase.objects.create(
price=price,
price_currency="USD",
date_purchased=date(2025, 1, 1),
platform=self.platform,
ownership_type=Purchase.DIGITAL,
type=Purchase.GAME,
)
bundle.games.set(games)
return bundle
def test_split_creates_per_game_purchases_and_deletes_original(self):
bundle = self._bundle([self.game_a, self.game_b], price=30.0)
response = self.client.post(reverse("games:split_purchase", args=[bundle.id]))
self.assertEqual(response.status_code, 204)
self.assertEqual(response["HX-Redirect"], reverse("games:list_purchases"))
self.assertFalse(Purchase.objects.filter(id=bundle.id).exists())
self.assertEqual(Purchase.objects.count(), 2)
for purchase in Purchase.objects.all():
self.assertEqual(purchase.num_purchases, 1)
self.assertEqual(purchase.price, 15.0) # 30 / 2, split evenly
self.assertTrue(purchase.needs_price_update)
def test_split_is_noop_for_single_game_purchase(self):
single = self._bundle([self.game_a], price=10.0)
response = self.client.post(reverse("games:split_purchase", args=[single.id]))
self.assertEqual(response.status_code, 204)
self.assertTrue(Purchase.objects.filter(id=single.id).exists())
self.assertEqual(Purchase.objects.count(), 1)
+100
View File
@@ -0,0 +1,100 @@
import { readSelectionFieldsProps, SelectionFieldsProps } from "../generated/props.js";
/**
* Renders one form field per selected item of a source SearchSelect (matched by
* its data-name). Reacts to the SearchSelect's "search-select:change" event and
* to its own "active" attribute. Typed values are preserved (keyed by item id)
* across selection changes and active toggling.
*/
class SelectionFieldsElement extends HTMLElement {
static get observedAttributes(): string[] {
return ["active"];
}
private props!: SelectionFieldsProps;
private source: HTMLElement | null = null;
private typedValues = new Map<string, string>();
private readonly onSourceChange = (event: Event): void => {
const detail = (event as CustomEvent).detail;
if (!detail || detail.name !== this.props.source) return;
this.render();
};
connectedCallback(): void {
this.props = readSelectionFieldsProps(this);
this.source = document.querySelector<HTMLElement>(
`[data-search-select][data-name="${this.props.source}"]`,
);
document.addEventListener("search-select:change", this.onSourceChange);
this.render();
}
disconnectedCallback(): void {
document.removeEventListener("search-select:change", this.onSourceChange);
}
attributeChangedCallback(): void {
// connectedCallback assigns props; ignore the initial pre-connect call.
if (this.props) this.render();
}
private selectedItems(): { value: string; label: string }[] {
if (!this.source) return [];
const pills = this.source.querySelectorAll(
"[data-search-select-pills] [data-pill]",
);
const items: { value: string; label: string }[] = [];
pills.forEach((pill) => {
const value = pill.getAttribute("data-value");
if (!value) return;
const labelElement = pill.querySelector("[data-search-select-label]");
const label = labelElement?.textContent?.trim() || value;
items.push({ value, label });
});
return items;
}
private captureTypedValues(): void {
this.querySelectorAll<HTMLInputElement>(
"[data-selection-fields-rows] input",
).forEach((input) => {
const itemId = input.getAttribute("data-item-id");
if (itemId) this.typedValues.set(itemId, input.value);
});
}
private render(): void {
const rows = this.querySelector<HTMLElement>("[data-selection-fields-rows]");
const template = this.querySelector<HTMLTemplateElement>(
"template[data-selection-fields-row]",
);
if (!rows || !template) return;
this.captureTypedValues();
rows.replaceChildren();
const active = this.getAttribute("active") === "true";
const items = this.selectedItems();
if (!active || items.length < this.props.minItems) return;
const prototype = template.content.firstElementChild;
if (!prototype) return;
items.forEach(({ value, label }) => {
const row = prototype.cloneNode(true) as HTMLElement;
const labelElement = row.querySelector("[data-selection-fields-label]");
const input = row.querySelector<HTMLInputElement>("input");
if (labelElement) labelElement.textContent = label;
if (input) {
input.name = `${this.props.namePrefix}${value}`;
input.setAttribute("data-item-id", value);
const preserved = this.typedValues.get(value);
if (preserved !== undefined) input.value = preserved;
}
rows.appendChild(row);
});
}
}
customElements.define("selection-fields", SelectionFieldsElement);