From dd2ebe58881d752bb46ce772e7aa85ef3e2c2553 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Tue, 9 Jun 2026 19:36:18 +0200 Subject: [PATCH] Implement date filters in purchase list --- common/components/filters.py | 83 +++++++ e2e/test_date_filter_e2e.py | 164 ++++++++++++++ games/filters.py | 135 ++++++++--- games/static/js/filter_bar.js | 48 +++- games/views/game.py | 2 +- tests/test_filter_bars.py | 80 +++++++ tests/test_filters.py | 413 +++++++++++++++++++++++++++------- tests/test_rendered_pages.py | 149 ++++++++++++ 8 files changed, 939 insertions(+), 135 deletions(-) create mode 100644 e2e/test_date_filter_e2e.py diff --git a/common/components/filters.py b/common/components/filters.py index 68a47c1..7f1612f 100644 --- a/common/components/filters.py +++ b/common/components/filters.py @@ -451,6 +451,69 @@ def RangeSlider( ) +_DATE_RANGE_INPUT_CLASS = ( + "w-full rounded-base border border-default-medium bg-neutral-secondary-medium " + "text-sm text-heading p-1.5 focus:ring-brand focus:border-brand" +) + + +def DateRangeFilter( + *, + label: str, + input_name_prefix: str, + min_value: str = "", + max_value: str = "", + min_placeholder: str = "From", + max_placeholder: str = "To", +) -> SafeText: + """A pair of ```` elements representing a date range. + + Mirrors ``RangeSlider`` in shape (two inputs named ``{prefix}-min`` and + ``{prefix}-max``) but without a slider track — the browser's native date + picker is the UI. Serialized client-side into a ``DateCriterion`` with + ``BETWEEN`` / ``GREATER_THAN`` / ``LESS_THAN`` depending on which bound(s) + the user filled. + """ + min_input_id = f"{input_name_prefix}-min" + max_input_id = f"{input_name_prefix}-max" + return Div( + attributes=[("class", "date-range-block mb-4")], + children=[ + Div( + attributes=[("class", "flex items-center gap-2")], + children=[ + Input( + attributes=[ + ("type", "date"), + ("name", min_input_id), + ("id", min_input_id), + ("value", min_value), + ("placeholder", min_placeholder), + ("aria-label", f"{label} from"), + ("class", _DATE_RANGE_INPUT_CLASS), + ], + ), + Span( + attributes=[("class", "text-body text-sm")], + children=["–"], + ), + Input( + attributes=[ + ("type", "date"), + ("name", max_input_id), + ("id", max_input_id), + ("value", max_value), + ("placeholder", max_placeholder), + ("aria-label", f"{label} to"), + ("class", _DATE_RANGE_INPUT_CLASS), + ], + ), + ], + ), + ], + ) + + _FILTER_FORM_ID = "filter-bar-form" @@ -1097,6 +1160,8 @@ def PurchaseFilterBar( needs_price_update_value = _parse_bool(existing, "needs_price_update") price_currency_value = existing.get("price_currency", {}).get("value", "") converted_currency_value = existing.get("converted_currency", {}).get("value", "") + date_purchased_min, date_purchased_max = _parse_range(existing, "date_purchased") + date_refunded_min, date_refunded_max = _parse_range(existing, "date_refunded") try: price_aggregate = Purchase.objects.aggregate( @@ -1198,6 +1263,24 @@ def PurchaseFilterBar( ), ], ), + _filter_field( + "Purchased", + DateRangeFilter( + label="Purchased", + input_name_prefix="filter-date-purchased", + min_value=date_purchased_min, + max_value=date_purchased_max, + ), + ), + _filter_field( + "Refunded", + DateRangeFilter( + label="Refunded", + input_name_prefix="filter-date-refunded", + min_value=date_refunded_min, + max_value=date_refunded_max, + ), + ), _filter_field( "Price", RangeSlider( diff --git a/e2e/test_date_filter_e2e.py b/e2e/test_date_filter_e2e.py new file mode 100644 index 0000000..ec98a73 --- /dev/null +++ b/e2e/test_date_filter_e2e.py @@ -0,0 +1,164 @@ +"""End-to-end Playwright test for the date-range filter widget's JS submit path. + +Covers the one layer the Django-Client tests in ``test_rendered_pages.py`` +cannot reach: ``filter_bar.js`` reading the two ```` +elements, building a ``DateCriterion`` JSON object, and navigating the +browser to ``?filter=``. + +Renders the bar at its own custom URL so the test doesn't need to auth +against the real app — the bar's JS doesn't care what route serves it. +""" + +import json +import urllib.parse + +import pytest +from django.http import HttpResponse +from django.test import override_settings +from django.urls import path + +from common.components import PurchaseFilterBar + + +def _bar_page(filter_json: str = "") -> str: + return f""" + + + Date filter E2E + + + + + + {PurchaseFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")} + +""" + + +def empty_bar_view(request): + return HttpResponse(_bar_page()) + + +def prefilled_bar_view(request): + filter_json = json.dumps( + { + "date_purchased": { + "value": "2024-03-15", + "value2": "2024-09-20", + "modifier": "BETWEEN", + } + } + ) + return HttpResponse(_bar_page(filter_json)) + + +urlpatterns = [ + path("test-date-filter/", empty_bar_view), + path("test-date-filter-prefilled/", prefilled_bar_view), +] + + +def _filter_from_url(url: str) -> dict: + """Extract and parse the ?filter=... query param from a URL.""" + query = urllib.parse.urlparse(url).query + params = urllib.parse.parse_qs(query) + raw = params.get("filter", [""])[0] + return json.loads(raw) if raw else {} + + +@pytest.mark.django_db +@override_settings(ROOT_URLCONF="e2e.test_date_filter_e2e") +def test_both_dates_serializes_as_between(live_server, page): + page.goto(live_server.url + "/test-date-filter/") + page.locator('input[name="filter-date-purchased-min"]').fill("2024-01-01") + page.locator('input[name="filter-date-purchased-max"]').fill("2024-12-31") + with page.expect_navigation(): + page.evaluate( + "document.getElementById('filter-bar-form')" + ".dispatchEvent(new Event('submit', {cancelable: true}))" + ) + parsed = _filter_from_url(page.url) + assert parsed == { + "date_purchased": { + "value": "2024-01-01", + "value2": "2024-12-31", + "modifier": "BETWEEN", + } + } + + +@pytest.mark.django_db +@override_settings(ROOT_URLCONF="e2e.test_date_filter_e2e") +def test_min_only_serializes_as_greater_than(live_server, page): + page.goto(live_server.url + "/test-date-filter/") + page.locator('input[name="filter-date-purchased-min"]').fill("2024-06-15") + with page.expect_navigation(): + page.evaluate( + "document.getElementById('filter-bar-form')" + ".dispatchEvent(new Event('submit', {cancelable: true}))" + ) + parsed = _filter_from_url(page.url) + assert parsed == { + "date_purchased": {"value": "2024-06-15", "modifier": "GREATER_THAN"} + } + # value2 must not be present when there's no upper bound. + assert "value2" not in parsed["date_purchased"] + + +@pytest.mark.django_db +@override_settings(ROOT_URLCONF="e2e.test_date_filter_e2e") +def test_max_only_serializes_as_less_than(live_server, page): + page.goto(live_server.url + "/test-date-filter/") + page.locator('input[name="filter-date-refunded-max"]').fill("2025-06-30") + with page.expect_navigation(): + page.evaluate( + "document.getElementById('filter-bar-form')" + ".dispatchEvent(new Event('submit', {cancelable: true}))" + ) + parsed = _filter_from_url(page.url) + assert parsed == { + "date_refunded": {"value": "2025-06-30", "modifier": "LESS_THAN"} + } + + +@pytest.mark.django_db +@override_settings(ROOT_URLCONF="e2e.test_date_filter_e2e") +def test_empty_inputs_omit_date_criterion(live_server, page): + """No date typed → the filter JSON simply has no date_purchased / + date_refunded keys (vs. an empty-string crash).""" + page.goto(live_server.url + "/test-date-filter/") + with page.expect_navigation(): + page.evaluate( + "document.getElementById('filter-bar-form')" + ".dispatchEvent(new Event('submit', {cancelable: true}))" + ) + parsed = _filter_from_url(page.url) + assert "date_purchased" not in parsed + assert "date_refunded" not in parsed + + +@pytest.mark.django_db +@override_settings(ROOT_URLCONF="e2e.test_date_filter_e2e") +def test_prefilled_bar_reflects_existing_filter_in_inputs(live_server, page): + """A bar rendered with a BETWEEN filter_json pre-fills the inputs and + re-submits the same bounds unchanged.""" + page.goto(live_server.url + "/test-date-filter-prefilled/") + assert ( + page.locator('input[name="filter-date-purchased-min"]').input_value() + == "2024-03-15" + ) + assert ( + page.locator('input[name="filter-date-purchased-max"]').input_value() + == "2024-09-20" + ) + with page.expect_navigation(): + page.evaluate( + "document.getElementById('filter-bar-form')" + ".dispatchEvent(new Event('submit', {cancelable: true}))" + ) + parsed = _filter_from_url(page.url) + assert parsed["date_purchased"] == { + "value": "2024-03-15", + "value2": "2024-09-20", + "modifier": "BETWEEN", + } diff --git a/games/filters.py b/games/filters.py index 6c3531e..ca44943 100644 --- a/games/filters.py +++ b/games/filters.py @@ -18,6 +18,7 @@ from django.db.models import Q from common.criteria import ( BoolCriterion, ChoiceCriterion, + DateCriterion, FloatCriterion, IntCriterion, Modifier, @@ -132,6 +133,7 @@ class GameFilter(OperatorFilter): from django.db.models import Count from games.models import Game + matching_ids = ( Game.objects.annotate(s_count=Count("sessions", distinct=True)) .filter(self.session_count.to_q("s_count")) @@ -143,6 +145,7 @@ class GameFilter(OperatorFilter): from django.db.models import Avg from games.models import Game + matching_ids = ( Game.objects.annotate(s_avg=Avg("sessions__duration_total")) .filter(self._playtime_to_q_for_field(self.session_average, "s_avg")) @@ -154,6 +157,7 @@ class GameFilter(OperatorFilter): from django.db.models import Count from games.models import Game + matching_ids = ( Game.objects.annotate(p_count=Count("purchases", distinct=True)) .filter(self.purchase_count.to_q("p_count")) @@ -165,6 +169,7 @@ class GameFilter(OperatorFilter): from django.db.models import Count from games.models import Game + matching_ids = ( Game.objects.annotate(pe_count=Count("playevents", distinct=True)) .filter(self.playevent_count.to_q("pe_count")) @@ -176,9 +181,14 @@ class GameFilter(OperatorFilter): from django.db.models import Sum from games.models import Game + matching_ids = ( Game.objects.annotate(s_manual=Sum("sessions__duration_manual")) - .filter(self._playtime_to_q_for_field(self.manual_playtime_minutes, "s_manual")) + .filter( + self._playtime_to_q_for_field( + self.manual_playtime_minutes, "s_manual" + ) + ) .values_list("id", flat=True) ) q &= Q(id__in=matching_ids) @@ -187,31 +197,47 @@ class GameFilter(OperatorFilter): from django.db.models import Sum from games.models import Game + matching_ids = ( Game.objects.annotate(s_calc=Sum("sessions__duration_calculated")) - .filter(self._playtime_to_q_for_field(self.calculated_playtime_minutes, "s_calc")) + .filter( + self._playtime_to_q_for_field( + self.calculated_playtime_minutes, "s_calc" + ) + ) .values_list("id", flat=True) ) q &= Q(id__in=matching_ids) if self.device is not None: from games.models import Session + session_q = self.device.to_q("device_id") - matching_ids = Session.objects.filter(session_q).values_list("game_id", flat=True) + matching_ids = Session.objects.filter(session_q).values_list( + "game_id", flat=True + ) q &= Q(id__in=matching_ids) if self.session_emulated is not None: from games.models import Session - emulated_ids = Session.objects.filter(emulated=self.session_emulated.value).values_list("game_id", flat=True) + + emulated_ids = Session.objects.filter( + emulated=self.session_emulated.value + ).values_list("game_id", flat=True) if self.session_emulated.value: q &= Q(id__in=emulated_ids) else: - emulated_true_ids = Session.objects.filter(emulated=True).values_list("game_id", flat=True) + emulated_true_ids = Session.objects.filter(emulated=True).values_list( + "game_id", flat=True + ) q &= ~Q(id__in=emulated_true_ids) if self.purchase_refunded is not None: from games.models import Purchase - refunded_ids = Purchase.objects.filter(date_refunded__isnull=False).values_list("games__id", flat=True) + + refunded_ids = Purchase.objects.filter( + date_refunded__isnull=False + ).values_list("games__id", flat=True) if self.purchase_refunded.value: q &= Q(id__in=refunded_ids) else: @@ -219,7 +245,10 @@ class GameFilter(OperatorFilter): if self.purchase_infinite is not None: from games.models import Purchase - infinite_ids = Purchase.objects.filter(infinite=True).values_list("games__id", flat=True) + + infinite_ids = Purchase.objects.filter(infinite=True).values_list( + "games__id", flat=True + ) if self.purchase_infinite.value: q &= Q(id__in=infinite_ids) else: @@ -229,6 +258,7 @@ class GameFilter(OperatorFilter): from django.db.models import Sum from games.models import Game + matching_ids = ( Game.objects.annotate(p_total=Sum("purchases__converted_price")) .filter(self.purchase_price_total.to_q("p_total")) @@ -238,20 +268,29 @@ class GameFilter(OperatorFilter): if self.purchase_price_any is not None: from games.models import Purchase + price_q = self.purchase_price_any.to_q("converted_price") - matching_ids = Purchase.objects.filter(price_q).values_list("games__id", flat=True) + matching_ids = Purchase.objects.filter(price_q).values_list( + "games__id", flat=True + ) q &= Q(id__in=matching_ids) if self.purchase_type is not None: from games.models import Purchase + type_q = self.purchase_type.to_q("type") - matching_ids = Purchase.objects.filter(type_q).values_list("games__id", flat=True) + matching_ids = Purchase.objects.filter(type_q).values_list( + "games__id", flat=True + ) q &= Q(id__in=matching_ids) if self.purchase_ownership_type is not None: from games.models import Purchase + ownership_q = self.purchase_ownership_type.to_q("ownership_type") - matching_ids = Purchase.objects.filter(ownership_q).values_list("games__id", flat=True) + matching_ids = Purchase.objects.filter(ownership_q).values_list( + "games__id", flat=True + ) q &= Q(id__in=matching_ids) if self.playevent_note is not None: @@ -271,26 +310,38 @@ class GameFilter(OperatorFilter): # Cross-entity filters if self.session_filter is not None: from games.models import Session + session_q = self.session_filter.to_q() - matching_ids = Session.objects.filter(session_q).values_list("game_id", flat=True) + matching_ids = Session.objects.filter(session_q).values_list( + "game_id", flat=True + ) q &= Q(id__in=matching_ids) if self.purchase_filter is not None: from games.models import Purchase + purchase_q = self.purchase_filter.to_q() - matching_ids = Purchase.objects.filter(purchase_q).values_list("games__id", flat=True) + matching_ids = Purchase.objects.filter(purchase_q).values_list( + "games__id", flat=True + ) q &= Q(id__in=matching_ids) if self.playevent_filter is not None: from games.models import PlayEvent + playevent_q = self.playevent_filter.to_q() - matching_ids = PlayEvent.objects.filter(playevent_q).values_list("game_id", flat=True) + matching_ids = PlayEvent.objects.filter(playevent_q).values_list( + "game_id", flat=True + ) q &= Q(id__in=matching_ids) if self.platform_filter is not None: from games.models import Platform + platform_q = self.platform_filter.to_q() - matching_ids = Platform.objects.filter(platform_q).values_list("id", flat=True) + matching_ids = Platform.objects.filter(platform_q).values_list( + "id", flat=True + ) q &= Q(platform_id__in=matching_ids) # ── AND / OR / NOT sub-filters ── @@ -375,9 +426,9 @@ class GameFilter(OperatorFilter): include_q |= Q(id__in=matching_ids) q &= ~include_q if negate_include else include_q for term in criterion.excludes: - matching_ids = PlayEvent.objects.filter( - note__icontains=term - ).values_list("game_id", flat=True) + matching_ids = PlayEvent.objects.filter(note__icontains=term).values_list( + "game_id", flat=True + ) q &= ~Q(id__in=matching_ids) return q @@ -418,6 +469,7 @@ class SessionFilter(OperatorFilter): def _duration_to_q(self, c: IntCriterion, field: str) -> Q: from datetime import timedelta + q = Q() td_val = timedelta(minutes=c.value) m = c.modifier @@ -473,7 +525,9 @@ class SessionFilter(OperatorFilter): if self.duration_manual_minutes is not None: q &= self._duration_to_q(self.duration_manual_minutes, "duration_manual") if self.duration_calculated_minutes is not None: - q &= self._duration_to_q(self.duration_calculated_minutes, "duration_calculated") + q &= self._duration_to_q( + self.duration_calculated_minutes, "duration_calculated" + ) if self.is_active is not None: if self.is_active.value: q &= Q(timestamp_end__isnull=True) @@ -546,8 +600,8 @@ class PurchaseFilter(OperatorFilter): name: StringCriterion | None = None platform: ChoiceCriterion | None = None # platform_id games: ChoiceCriterion | None = None # games (M2M IDs) - date_purchased: StringCriterion | None = None # date string - date_refunded: StringCriterion | None = None # date string + date_purchased: DateCriterion | None = None + date_refunded: DateCriterion | None = None is_refunded: BoolCriterion | None = None # date_refunded IS NOT NULL price: FloatCriterion | None = None # on price field converted_price: FloatCriterion | None = None @@ -633,7 +687,9 @@ class PurchaseFilter(OperatorFilter): from games.models import Platform platform_q = self.platform_filter.to_q() - matching_ids = Platform.objects.filter(platform_q).values_list("id", flat=True) + matching_ids = Platform.objects.filter(platform_q).values_list( + "id", flat=True + ) q &= Q(platform_id__in=matching_ids) sub = self.sub_filter() @@ -682,9 +738,9 @@ class PurchaseFilter(OperatorFilter): subquery = subquery.filter(games=game_id) if criterion.modifier == Modifier.INCLUDES_ONLY: - extra_ids = Game.objects.exclude( - id__in=criterion.value - ).values_list("id", flat=True) + extra_ids = Game.objects.exclude(id__in=criterion.value).values_list( + "id", flat=True + ) if extra_ids: subquery = subquery.exclude(games__in=extra_ids) @@ -737,9 +793,8 @@ class DeviceFilter(OperatorFilter): # Free-text search if self.search is not None and self.search.value: - search_q = ( - Q(name__icontains=self.search.value) - | Q(type__icontains=self.search.value) + search_q = Q(name__icontains=self.search.value) | Q( + type__icontains=self.search.value ) if self.search.modifier == Modifier.EXCLUDES: search_q = ~search_q @@ -748,8 +803,11 @@ class DeviceFilter(OperatorFilter): # Cross-entity filter: session_filter if self.session_filter is not None: from games.models import Session + session_q = self.session_filter.to_q() - matching_ids = Session.objects.filter(session_q).values_list("device_id", flat=True) + matching_ids = Session.objects.filter(session_q).values_list( + "device_id", flat=True + ) q &= Q(id__in=matching_ids) sub = self.sub_filter() @@ -801,9 +859,8 @@ class PlatformFilter(OperatorFilter): # Free-text search if self.search is not None and self.search.value: - search_q = ( - Q(name__icontains=self.search.value) - | Q(group__icontains=self.search.value) + search_q = Q(name__icontains=self.search.value) | Q( + group__icontains=self.search.value ) if self.search.modifier == Modifier.EXCLUDES: search_q = ~search_q @@ -812,15 +869,21 @@ class PlatformFilter(OperatorFilter): # Cross-entity filter: game_filter if self.game_filter is not None: from games.models import Game + game_q = self.game_filter.to_q() - matching_ids = Game.objects.filter(game_q).values_list("platform_id", flat=True) + matching_ids = Game.objects.filter(game_q).values_list( + "platform_id", flat=True + ) q &= Q(id__in=matching_ids) # Cross-entity filter: purchase_filter if self.purchase_filter is not None: from games.models import Purchase + purchase_q = self.purchase_filter.to_q() - matching_ids = Purchase.objects.filter(purchase_q).values_list("platform_id", flat=True) + matching_ids = Purchase.objects.filter(purchase_q).values_list( + "platform_id", flat=True + ) q &= Q(id__in=matching_ids) sub = self.sub_filter() @@ -877,9 +940,8 @@ class PlayEventFilter(OperatorFilter): # Free-text search if self.search is not None and self.search.value: - search_q = ( - Q(game__name__icontains=self.search.value) - | Q(note__icontains=self.search.value) + search_q = Q(game__name__icontains=self.search.value) | Q( + note__icontains=self.search.value ) if self.search.modifier == Modifier.EXCLUDES: search_q = ~search_q @@ -888,6 +950,7 @@ class PlayEventFilter(OperatorFilter): # Cross-entity filter: game_filter if self.game_filter is not None: from games.models import Game + game_q = self.game_filter.to_q() matching_ids = Game.objects.filter(game_q).values_list("id", flat=True) q &= Q(game_id__in=matching_ids) diff --git a/games/static/js/filter_bar.js b/games/static/js/filter_bar.js index ac43cf8..3c67690 100644 --- a/games/static/js/filter_bar.js +++ b/games/static/js/filter_bar.js @@ -30,6 +30,24 @@ return isNaN(val) ? "" : val; } + /** Read a raw value as string, or "" if not found. */ + function stringValue(form, name) { + var el = form.querySelector('[name="' + name + '"]'); + return el ? el.value : ""; + } + + /** + * Derive a range criterion ({value, value2?, modifier}) from a (min, max) + * pair, or null if both bounds are empty. Shared by the numeric-range and + * date-range serializers. + */ + function buildRangeCriterion(vMin, vMax) { + if (vMin !== "" && vMax !== "") return criterion(vMin, vMax, "BETWEEN"); + if (vMin !== "") return criterion(vMin, null, "GREATER_THAN"); + if (vMax !== "") return criterion(vMax, null, "LESS_THAN"); + return null; + } + /** Read all checked checkboxes with a given name, returning an array of ints. */ function checkedValues(form, name) { var els = form.querySelectorAll('[name="' + name + '"]:checked'); @@ -139,23 +157,31 @@ rangeFields.forEach(function (rf) { var vMin = numberValue(form, rf.prefix + "-min"); var vMax = numberValue(form, rf.prefix + "-max"); - + if (rf.convert) { if (vMin !== "") vMin = rf.convert(vMin); if (vMax !== "") vMax = rf.convert(vMax); } - + if (rf.ignoreZeroZero && vMin === 0 && vMax === 0) { - return; // skip if both are 0 means slider at default - } - - if (vMin !== "" && vMax !== "") { - filter[rf.key] = criterion(vMin, vMax, "BETWEEN"); - } else if (vMin !== "") { - filter[rf.key] = criterion(vMin, null, "GREATER_THAN"); - } else if (vMax !== "") { - filter[rf.key] = criterion(vMax, null, "LESS_THAN"); + return; // both 0 means slider at default } + + var c = buildRangeCriterion(vMin, vMax); + if (c !== null) filter[rf.key] = c; + }); + + // 4. Date Range Fields — ISO date strings from ; no + // numeric coercion. Same modifier derivation as numeric ranges. + var dateRangeFields = [ + { prefix: "filter-date-purchased", key: "date_purchased" }, + { prefix: "filter-date-refunded", key: "date_refunded" }, + ]; + dateRangeFields.forEach(function (df) { + var vMin = stringValue(form, df.prefix + "-min"); + var vMax = stringValue(form, df.prefix + "-max"); + var c = buildRangeCriterion(vMin, vMax); + if (c !== null) filter[df.key] = c; }); return filter; diff --git a/games/views/game.py b/games/views/game.py index 18fe644..bebf0ae 100644 --- a/games/views/game.py +++ b/games/views/game.py @@ -35,7 +35,7 @@ from common.components import ( Ul, paginated_table_content, ) -from common.components.primitives import Li, Span, Strong +from common.components.primitives import Li, P, Span, Strong from common.icons import get_icon from common.layout import render_page from common.time import ( diff --git a/tests/test_filter_bars.py b/tests/test_filter_bars.py index a555ab1..40f18a7 100644 --- a/tests/test_filter_bars.py +++ b/tests/test_filter_bars.py @@ -189,6 +189,7 @@ class FilterBarRenderingTest(TestCase): def test_device_filter_bar(self): from common.components import DeviceFilterBar + html = str( DeviceFilterBar( filter_json="", @@ -200,6 +201,7 @@ class FilterBarRenderingTest(TestCase): def test_platform_filter_bar(self): from common.components import PlatformFilterBar + html = str( PlatformFilterBar( filter_json="", @@ -211,6 +213,7 @@ class FilterBarRenderingTest(TestCase): def test_playevent_filter_bar(self): from common.components import PlayEventFilterBar + html = str( PlayEventFilterBar( filter_json="", @@ -257,3 +260,80 @@ class FilterBarRenderingTest(TestCase): self.assertNotIn('name="filter-has-playevents"', html) # Playtime label renamed self.assertIn("Total playtime", html) + + def test_purchase_filter_bar_renders_date_inputs(self): + """PurchaseFilterBar surfaces date_purchased and date_refunded as + type=date input pairs with -min/-max naming.""" + html = str( + PurchaseFilterBar( + filter_json="", preset_list_url="/l", preset_save_url="/s" + ) + ) + for name in ( + "filter-date-purchased-min", + "filter-date-purchased-max", + "filter-date-refunded-min", + "filter-date-refunded-max", + ): + self.assertIn(f'name="{name}"', html) + self.assertIn(f'id="{name}"', html) + # Inputs are native date pickers, not text. + self.assertIn('type="date"', html) + self.assertNoEscapedTags(html) + + def test_purchase_filter_bar_prepopulates_dates_between(self): + """A BETWEEN filter populates both date bounds via _parse_range.""" + filter_json = json.dumps( + { + "date_purchased": { + "value": "2024-01-01", + "value2": "2024-12-31", + "modifier": "BETWEEN", + } + } + ) + html = str( + PurchaseFilterBar( + filter_json=filter_json, + preset_list_url="/l", + preset_save_url="/s", + ) + ) + self.assertIn( + 'name="filter-date-purchased-min" id="filter-date-purchased-min" ' + 'value="2024-01-01"', + html, + ) + self.assertIn( + 'name="filter-date-purchased-max" id="filter-date-purchased-max" ' + 'value="2024-12-31"', + html, + ) + + def test_purchase_filter_bar_prepopulates_dates_single_bound(self): + """A single-bound (GREATER_THAN) filter populates min only.""" + filter_json = json.dumps( + { + "date_refunded": { + "value": "2024-06-01", + "modifier": "GREATER_THAN", + } + } + ) + html = str( + PurchaseFilterBar( + filter_json=filter_json, + preset_list_url="/l", + preset_save_url="/s", + ) + ) + self.assertIn( + 'name="filter-date-refunded-min" id="filter-date-refunded-min" ' + 'value="2024-06-01"', + html, + ) + # Max input is still present but with empty value. + self.assertIn( + 'name="filter-date-refunded-max" id="filter-date-refunded-max" value=""', + html, + ) diff --git a/tests/test_filters.py b/tests/test_filters.py index b430fb2..38183df 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -8,6 +8,7 @@ from django.db.models import Q from common.criteria import ( BoolCriterion, ChoiceCriterion, + DateCriterion, IntCriterion, Modifier, MultiCriterion, @@ -667,20 +668,30 @@ class TestExpandedFiltersAgainstDB: from datetime import timedelta # 1. Platform & Game - plat, _ = Platform.objects.get_or_create(name="Retro Console", group="Nintendo", icon="retro") - game, _ = Game.objects.get_or_create(name="Super Mario World", defaults={"platform": plat, "status": "f"}) - game2, _ = Game.objects.get_or_create(name="Zelda", defaults={"platform": plat, "status": "u"}) + plat, _ = Platform.objects.get_or_create( + name="Retro Console", group="Nintendo", icon="retro" + ) + game, _ = Game.objects.get_or_create( + name="Super Mario World", defaults={"platform": plat, "status": "f"} + ) + game2, _ = Game.objects.get_or_create( + name="Zelda", defaults={"platform": plat, "status": "u"} + ) # 2. Device & Session dev, _ = Device.objects.get_or_create(name="Super Famicom", type="Console") - + # Session 1: total 40 minutes (30 calc, 10 manual) s1 = Session.objects.create( game=game, device=dev, - timestamp_start=datetime.datetime(2026, 6, 1, 12, 0, 0, tzinfo=datetime.timezone.utc), - timestamp_end=datetime.datetime(2026, 6, 1, 12, 30, 0, tzinfo=datetime.timezone.utc), - duration_manual=timedelta(minutes=10) + timestamp_start=datetime.datetime( + 2026, 6, 1, 12, 0, 0, tzinfo=datetime.timezone.utc + ), + timestamp_end=datetime.datetime( + 2026, 6, 1, 12, 30, 0, tzinfo=datetime.timezone.utc + ), + duration_manual=timedelta(minutes=10), ) # 3. Purchase @@ -692,7 +703,7 @@ class TestExpandedFiltersAgainstDB: price_currency="JPY", converted_price=45.00, converted_currency="USD", - needs_price_update=False + needs_price_update=False, ) pur.games.add(game) @@ -701,7 +712,7 @@ class TestExpandedFiltersAgainstDB: game=game, started=datetime.date(2026, 6, 1), ended=datetime.date(2026, 6, 2), - note="Completed 100%" + note="Completed 100%", ) return { @@ -711,7 +722,7 @@ class TestExpandedFiltersAgainstDB: "dev": dev, "s1": s1, "pur": pur, - "pe": pe + "pe": pe, } def test_device_filter_and_cross_entity(self): @@ -720,13 +731,15 @@ class TestExpandedFiltersAgainstDB: data = self._setup_entities() # Find devices that have sessions on "Super Mario World" - df = DeviceFilter.from_json({ - "session_filter": { - "game_filter": { - "name": {"value": "Super Mario World", "modifier": "EQUALS"} + df = DeviceFilter.from_json( + { + "session_filter": { + "game_filter": { + "name": {"value": "Super Mario World", "modifier": "EQUALS"} + } } } - }) + ) results = list(Device.objects.filter(df.to_q())) assert data["dev"] in results @@ -736,11 +749,9 @@ class TestExpandedFiltersAgainstDB: data = self._setup_entities() # Find platforms with games that are finished - pf = PlatformFilter.from_json({ - "game_filter": { - "status": {"value": ["f"], "modifier": "INCLUDES"} - } - }) + pf = PlatformFilter.from_json( + {"game_filter": {"status": {"value": ["f"], "modifier": "INCLUDES"}}} + ) results = list(Platform.objects.filter(pf.to_q())) assert data["plat"] in results @@ -749,23 +760,23 @@ class TestExpandedFiltersAgainstDB: from games.models import Session data = self._setup_entities() - + # Test duration_total_minutes equals 40 - sf_tot = SessionFilter.from_json({ - "duration_total_minutes": {"value": 40, "modifier": "EQUALS"} - }) + sf_tot = SessionFilter.from_json( + {"duration_total_minutes": {"value": 40, "modifier": "EQUALS"}} + ) assert Session.objects.filter(sf_tot.to_q()).count() == 1 # Test duration_manual_minutes equals 10 - sf_man = SessionFilter.from_json({ - "duration_manual_minutes": {"value": 10, "modifier": "EQUALS"} - }) + sf_man = SessionFilter.from_json( + {"duration_manual_minutes": {"value": 10, "modifier": "EQUALS"}} + ) assert Session.objects.filter(sf_man.to_q()).count() == 1 # Test duration_calculated_minutes equals 30 - sf_calc = SessionFilter.from_json({ - "duration_calculated_minutes": {"value": 30, "modifier": "EQUALS"} - }) + sf_calc = SessionFilter.from_json( + {"duration_calculated_minutes": {"value": 30, "modifier": "EQUALS"}} + ) assert Session.objects.filter(sf_calc.to_q()).count() == 1 def test_purchase_filter_new_fields(self): @@ -774,11 +785,13 @@ class TestExpandedFiltersAgainstDB: data = self._setup_entities() - pf = PurchaseFilter.from_json({ - "infinite": {"value": True, "modifier": "EQUALS"}, - "needs_price_update": {"value": False, "modifier": "EQUALS"}, - "converted_currency": {"value": "USD", "modifier": "EQUALS"} - }) + pf = PurchaseFilter.from_json( + { + "infinite": {"value": True, "modifier": "EQUALS"}, + "needs_price_update": {"value": False, "modifier": "EQUALS"}, + "converted_currency": {"value": "USD", "modifier": "EQUALS"}, + } + ) assert Purchase.objects.filter(pf.to_q()).count() == 1 def test_game_filter_stats_and_existence(self): @@ -788,16 +801,16 @@ class TestExpandedFiltersAgainstDB: data = self._setup_entities() # purchase_count == 1 (replaces removed has_purchases boolean) - gf_pur = GameFilter.from_json({ - "purchase_count": {"value": 1, "modifier": "EQUALS"} - }) + gf_pur = GameFilter.from_json( + {"purchase_count": {"value": 1, "modifier": "EQUALS"}} + ) assert data["game"] in list(Game.objects.filter(gf_pur.to_q())) assert data["game2"] not in list(Game.objects.filter(gf_pur.to_q())) # session_count = 1 - gf_cnt = GameFilter.from_json({ - "session_count": {"value": 1, "modifier": "EQUALS"} - }) + gf_cnt = GameFilter.from_json( + {"session_count": {"value": 1, "modifier": "EQUALS"}} + ) assert data["game"] in list(Game.objects.filter(gf_cnt.to_q())) def test_game_filter_purchase_count_range(self): @@ -807,9 +820,9 @@ class TestExpandedFiltersAgainstDB: data = self._setup_entities() # game has 1 purchase, game2 has 0 - gf = GameFilter.from_json({ - "purchase_count": {"value": 1, "modifier": "EQUALS"} - }) + gf = GameFilter.from_json( + {"purchase_count": {"value": 1, "modifier": "EQUALS"}} + ) results = set(Game.objects.filter(gf.to_q())) assert data["game"] in results assert data["game2"] not in results @@ -819,9 +832,9 @@ class TestExpandedFiltersAgainstDB: from games.models import Game data = self._setup_entities() - gf = GameFilter.from_json({ - "playevent_count": {"value": 1, "modifier": "EQUALS"} - }) + gf = GameFilter.from_json( + {"playevent_count": {"value": 1, "modifier": "EQUALS"}} + ) results = set(Game.objects.filter(gf.to_q())) assert data["game"] in results assert data["game2"] not in results @@ -831,9 +844,9 @@ class TestExpandedFiltersAgainstDB: from games.models import Game data = self._setup_entities() - gf = GameFilter.from_json({ - "device": {"value": [data["dev"].id], "modifier": "INCLUDES"} - }) + gf = GameFilter.from_json( + {"device": {"value": [data["dev"].id], "modifier": "INCLUDES"}} + ) results = set(Game.objects.filter(gf.to_q())) assert data["game"] in results assert data["game2"] not in results @@ -843,9 +856,9 @@ class TestExpandedFiltersAgainstDB: from games.models import Game data = self._setup_entities() - gf = GameFilter.from_json({ - "platform_group": {"value": ["Nintendo"], "modifier": "INCLUDES"} - }) + gf = GameFilter.from_json( + {"platform_group": {"value": ["Nintendo"], "modifier": "INCLUDES"}} + ) results = set(Game.objects.filter(gf.to_q())) # both games are on the same Nintendo platform assert data["game"] in results @@ -861,14 +874,18 @@ class TestExpandedFiltersAgainstDB: Session.objects.create( game=data["game2"], device=data["dev"], - timestamp_start=datetime.datetime(2026, 6, 2, 12, 0, 0, tzinfo=datetime.timezone.utc), - timestamp_end=datetime.datetime(2026, 6, 2, 12, 30, 0, tzinfo=datetime.timezone.utc), + timestamp_start=datetime.datetime( + 2026, 6, 2, 12, 0, 0, tzinfo=datetime.timezone.utc + ), + timestamp_end=datetime.datetime( + 2026, 6, 2, 12, 30, 0, tzinfo=datetime.timezone.utc + ), duration_manual=timedelta(0), emulated=True, ) - gf = GameFilter.from_json({ - "session_emulated": {"value": True, "modifier": "EQUALS"} - }) + gf = GameFilter.from_json( + {"session_emulated": {"value": True, "modifier": "EQUALS"}} + ) results = set(Game.objects.filter(gf.to_q())) assert data["game2"] in results assert data["game"] not in results @@ -880,9 +897,9 @@ class TestExpandedFiltersAgainstDB: data = self._setup_entities() # data["pur"] is infinite=True, non-refunded. - gf_inf = GameFilter.from_json({ - "purchase_infinite": {"value": True, "modifier": "EQUALS"} - }) + gf_inf = GameFilter.from_json( + {"purchase_infinite": {"value": True, "modifier": "EQUALS"}} + ) assert data["game"] in set(Game.objects.filter(gf_inf.to_q())) assert data["game2"] not in set(Game.objects.filter(gf_inf.to_q())) @@ -897,9 +914,9 @@ class TestExpandedFiltersAgainstDB: converted_currency="USD", ) refunded.games.add(data["game2"]) - gf_ref = GameFilter.from_json({ - "purchase_refunded": {"value": True, "modifier": "EQUALS"} - }) + gf_ref = GameFilter.from_json( + {"purchase_refunded": {"value": True, "modifier": "EQUALS"}} + ) results = set(Game.objects.filter(gf_ref.to_q())) assert data["game2"] in results assert data["game"] not in results @@ -910,14 +927,14 @@ class TestExpandedFiltersAgainstDB: data = self._setup_entities() # data["pur"] defaults to type=game, ownership_type=digital - gf = GameFilter.from_json({ - "purchase_type": {"value": ["game"], "modifier": "INCLUDES"} - }) + gf = GameFilter.from_json( + {"purchase_type": {"value": ["game"], "modifier": "INCLUDES"}} + ) assert data["game"] in set(Game.objects.filter(gf.to_q())) - gf = GameFilter.from_json({ - "purchase_ownership_type": {"value": ["di"], "modifier": "INCLUDES"} - }) + gf = GameFilter.from_json( + {"purchase_ownership_type": {"value": ["di"], "modifier": "INCLUDES"}} + ) assert data["game"] in set(Game.objects.filter(gf.to_q())) def test_game_filter_purchase_price_any_and_total(self): @@ -926,16 +943,28 @@ class TestExpandedFiltersAgainstDB: data = self._setup_entities() # data["pur"] has converted_price=45.00 linked to data["game"] - gf_any = GameFilter.from_json({ - "purchase_price_any": {"value": 40.0, "value2": 50.0, "modifier": "BETWEEN"} - }) + gf_any = GameFilter.from_json( + { + "purchase_price_any": { + "value": 40.0, + "value2": 50.0, + "modifier": "BETWEEN", + } + } + ) results = set(Game.objects.filter(gf_any.to_q())) assert data["game"] in results assert data["game2"] not in results - gf_total = GameFilter.from_json({ - "purchase_price_total": {"value": 40.0, "value2": 50.0, "modifier": "BETWEEN"} - }) + gf_total = GameFilter.from_json( + { + "purchase_price_total": { + "value": 40.0, + "value2": 50.0, + "modifier": "BETWEEN", + } + } + ) results = set(Game.objects.filter(gf_total.to_q())) assert data["game"] in results assert data["game2"] not in results @@ -946,12 +975,14 @@ class TestExpandedFiltersAgainstDB: data = self._setup_entities() # data["pe"] has note="Completed 100%" on data["game"] - gf = GameFilter.from_json({ - "playevent_note": { - "value": [{"id": "Completed", "label": "Completed"}], - "modifier": "INCLUDES", + gf = GameFilter.from_json( + { + "playevent_note": { + "value": [{"id": "Completed", "label": "Completed"}], + "modifier": "INCLUDES", + } } - }) + ) results = set(Game.objects.filter(gf.to_q())) assert data["game"] in results assert data["game2"] not in results @@ -962,12 +993,220 @@ class TestExpandedFiltersAgainstDB: data = self._setup_entities() # data["s1"] has 10 minutes manual + 30 minutes calculated - gf_manual = GameFilter.from_json({ - "manual_playtime_minutes": {"value": 10, "modifier": "EQUALS"} - }) + gf_manual = GameFilter.from_json( + {"manual_playtime_minutes": {"value": 10, "modifier": "EQUALS"}} + ) assert data["game"] in set(Game.objects.filter(gf_manual.to_q())) - gf_calc = GameFilter.from_json({ - "calculated_playtime_minutes": {"value": 30, "modifier": "EQUALS"} - }) + gf_calc = GameFilter.from_json( + {"calculated_playtime_minutes": {"value": 30, "modifier": "EQUALS"}} + ) assert data["game"] in set(Game.objects.filter(gf_calc.to_q())) + + +class TestDateCriterion: + def test_equals(self): + c = DateCriterion(value="2025-06-01", modifier=Modifier.EQUALS) + assert c.to_q("date_purchased") == Q(date_purchased="2025-06-01") + + def test_not_equals(self): + c = DateCriterion(value="2025-06-01", modifier=Modifier.NOT_EQUALS) + assert c.to_q("date_purchased") == ~Q(date_purchased="2025-06-01") + + def test_greater_than(self): + c = DateCriterion(value="2025-06-01", modifier=Modifier.GREATER_THAN) + assert c.to_q("date_purchased") == Q(date_purchased__gt="2025-06-01") + + def test_less_than(self): + c = DateCriterion(value="2025-06-01", modifier=Modifier.LESS_THAN) + assert c.to_q("date_purchased") == Q(date_purchased__lt="2025-06-01") + + def test_between(self): + c = DateCriterion( + value="2025-01-01", value2="2025-12-31", modifier=Modifier.BETWEEN + ) + assert c.to_q("date_purchased") == Q( + date_purchased__gte="2025-01-01", date_purchased__lte="2025-12-31" + ) + + def test_between_missing_value2_raises(self): + c = DateCriterion(value="2025-01-01", modifier=Modifier.BETWEEN) + with pytest.raises(ValueError, match="BETWEEN requires value2"): + c.to_q("date_purchased") + + def test_not_between(self): + c = DateCriterion( + value="2025-01-01", value2="2025-12-31", modifier=Modifier.NOT_BETWEEN + ) + assert c.to_q("date_purchased") == Q(date_purchased__lt="2025-01-01") | Q( + date_purchased__gt="2025-12-31" + ) + + def test_not_between_missing_value2_raises(self): + c = DateCriterion(value="2025-01-01", modifier=Modifier.NOT_BETWEEN) + with pytest.raises(ValueError, match="NOT_BETWEEN requires value2"): + c.to_q("date_purchased") + + def test_is_null(self): + c = DateCriterion(value="", modifier=Modifier.IS_NULL) + assert c.to_q("date_refunded") == Q(date_refunded__isnull=True) + + def test_not_null(self): + c = DateCriterion(value="", modifier=Modifier.NOT_NULL) + assert c.to_q("date_refunded") == Q(date_refunded__isnull=False) + + def test_unsupported_modifier_raises(self): + c = DateCriterion(value="2025-06-01", modifier=Modifier.INCLUDES) + with pytest.raises(ValueError, match="Unsupported modifier"): + c.to_q("date_purchased") + + def test_round_trip_json(self): + """Dataclass → dict → dataclass survives unchanged for a full BETWEEN.""" + original = DateCriterion( + value="2025-06-01", value2="2025-12-31", modifier=Modifier.BETWEEN + ) + as_dict = original.to_json() + assert as_dict == { + "value": "2025-06-01", + "value2": "2025-12-31", + "modifier": Modifier.BETWEEN, + } + restored = DateCriterion.from_json( + { + "value": "2025-06-01", + "value2": "2025-12-31", + "modifier": "BETWEEN", + } + ) + assert restored == original + + +class TestPurchaseFilterDates: + """End-to-end: a PurchaseFilter built from JSON narrows the queryset + correctly across the two DateCriterion fields and composes with + BoolCriterion (is_refunded).""" + + def _seed(self): + import datetime + + from games.models import Platform, Purchase + + platform, _ = Platform.objects.get_or_create(name="Test", icon="test") + early = Purchase.objects.create( + platform=platform, date_purchased=datetime.date(2024, 1, 15) + ) + mid = Purchase.objects.create( + platform=platform, + date_purchased=datetime.date(2024, 6, 15), + date_refunded=datetime.date(2024, 7, 1), + ) + late = Purchase.objects.create( + platform=platform, date_purchased=datetime.date(2025, 1, 15) + ) + return {"early": early, "mid": mid, "late": late} + + @pytest.mark.django_db + def test_date_purchased_between(self): + from games.filters import PurchaseFilter + from games.models import Purchase + + seeded = self._seed() + pf = PurchaseFilter.from_json( + { + "date_purchased": { + "value": "2024-01-01", + "value2": "2024-12-31", + "modifier": "BETWEEN", + } + } + ) + results = set(Purchase.objects.filter(pf.to_q())) + assert results == {seeded["early"], seeded["mid"]} + + @pytest.mark.django_db + def test_date_purchased_greater_than(self): + from games.filters import PurchaseFilter + from games.models import Purchase + + seeded = self._seed() + pf = PurchaseFilter.from_json( + { + "date_purchased": { + "value": "2024-06-15", + "modifier": "GREATER_THAN", + } + } + ) + results = set(Purchase.objects.filter(pf.to_q())) + assert results == {seeded["late"]} + + @pytest.mark.django_db + def test_date_refunded_is_null(self): + from games.filters import PurchaseFilter + from games.models import Purchase + + seeded = self._seed() + pf = PurchaseFilter.from_json( + {"date_refunded": {"value": "", "modifier": "IS_NULL"}} + ) + results = set(Purchase.objects.filter(pf.to_q())) + assert results == {seeded["early"], seeded["late"]} + + @pytest.mark.django_db + def test_date_refunded_not_null(self): + from games.filters import PurchaseFilter + from games.models import Purchase + + seeded = self._seed() + pf = PurchaseFilter.from_json( + {"date_refunded": {"value": "", "modifier": "NOT_NULL"}} + ) + results = set(Purchase.objects.filter(pf.to_q())) + assert results == {seeded["mid"]} + + @pytest.mark.django_db + def test_purchased_between_and_refunded_not_null(self): + """AND-composition: only the mid purchase satisfies both.""" + from games.filters import PurchaseFilter + from games.models import Purchase + + seeded = self._seed() + pf = PurchaseFilter.from_json( + { + "date_purchased": { + "value": "2024-01-01", + "value2": "2024-12-31", + "modifier": "BETWEEN", + }, + "date_refunded": {"value": "", "modifier": "NOT_NULL"}, + } + ) + results = set(Purchase.objects.filter(pf.to_q())) + assert results == {seeded["mid"]} + + @pytest.mark.django_db + def test_purchase_filter_json_round_trip(self): + """PurchaseFilter with both DateCriterion fields and is_refunded + survives a json → object → json round-trip — confirms + DateCriterion is dispatched correctly by OperatorFilter.from_json + via the criterion_types lookup.""" + from games.filters import PurchaseFilter + + payload = { + "date_purchased": { + "value": "2024-01-01", + "value2": "2024-12-31", + "modifier": "BETWEEN", + }, + "date_refunded": {"value": "", "modifier": "NOT_NULL"}, + "is_refunded": {"value": True, "modifier": "EQUALS"}, + } + pf = PurchaseFilter.from_json(payload) + assert isinstance(pf.date_purchased, DateCriterion) + assert isinstance(pf.date_refunded, DateCriterion) + # round-trip back out + out = pf.to_json() + assert out["date_purchased"]["value"] == "2024-01-01" + assert out["date_purchased"]["value2"] == "2024-12-31" + assert out["date_purchased"]["modifier"] == Modifier.BETWEEN + assert out["date_refunded"]["modifier"] == Modifier.NOT_NULL diff --git a/tests/test_rendered_pages.py b/tests/test_rendered_pages.py index 5c1aca6..f3f3990 100644 --- a/tests/test_rendered_pages.py +++ b/tests/test_rendered_pages.py @@ -290,3 +290,152 @@ class RenderedPagesTest(TestCase): self.assertNoEscapedTags(html) # The Python builder emits well-formed, balanced markup. self.assertEqual(html.count("")) + + +class PurchaseListDateFilterTest(TestCase): + """End-to-end: GET /tracker/purchase/list?filter=… narrows the rendered + list and pre-fills the date inputs from the URL filter. + + Replaces the manual curl smoke that earlier verified the same path. + """ + + def setUp(self) -> None: + import datetime + + self.user = User.objects.create_superuser( + username="datetester", email="dt@example.com", password="testpass" + ) + self.client.force_login(self.user) + self.platform = Platform.objects.create(name="DateP", icon="datep") + # Markers are placed on the Game name because LinkedPurchase renders + # the linked game's name (purchase.name doesn't surface in the list row). + early_game = Game.objects.create(name="EARLY-MARKER", platform=self.platform) + mid_game = Game.objects.create(name="MID-MARKER", platform=self.platform) + late_game = Game.objects.create(name="LATE-MARKER", platform=self.platform) + self.early = Purchase.objects.create( + platform=self.platform, date_purchased=datetime.date(2024, 1, 15) + ) + self.early.games.add(early_game) + self.mid = Purchase.objects.create( + platform=self.platform, + date_purchased=datetime.date(2024, 6, 15), + date_refunded=datetime.date(2024, 7, 1), + ) + self.mid.games.add(mid_game) + self.late = Purchase.objects.create( + platform=self.platform, date_purchased=datetime.date(2025, 1, 15) + ) + self.late.games.add(late_game) + + def _get(self, filter_obj=None, raw_filter=None): + import json + + from django.urls import reverse + + url = reverse("games:list_purchases") + if raw_filter is not None: + return self.client.get(url, {"filter": raw_filter}) + if filter_obj is not None: + return self.client.get(url, {"filter": json.dumps(filter_obj)}) + return self.client.get(url) + + def test_unfiltered_lists_all_three(self): + html = self._get().content.decode() + self.assertEqual(html.count("EARLY-MARKER"), 1) + self.assertEqual(html.count("MID-MARKER"), 1) + self.assertEqual(html.count("LATE-MARKER"), 1) + + def test_date_purchased_between_narrows_and_prepopulates(self): + """BETWEEN 2024-01-01..2024-12-31 → only early + mid; both date + inputs pre-filled with the filter bounds.""" + response = self._get( + { + "date_purchased": { + "value": "2024-01-01", + "value2": "2024-12-31", + "modifier": "BETWEEN", + } + } + ) + self.assertEqual(response.status_code, 200) + html = response.content.decode() + self.assertIn("EARLY-MARKER", html) + self.assertIn("MID-MARKER", html) + self.assertNotIn("LATE-MARKER", html) + # Pre-populated date inputs round-trip the filter bounds. + self.assertIn( + 'name="filter-date-purchased-min" id="filter-date-purchased-min" ' + 'value="2024-01-01"', + html, + ) + self.assertIn( + 'name="filter-date-purchased-max" id="filter-date-purchased-max" ' + 'value="2024-12-31"', + html, + ) + + def test_date_purchased_greater_than_single_bound(self): + """GREATER_THAN populates min only, leaves max blank.""" + response = self._get( + { + "date_purchased": { + "value": "2024-06-15", + "modifier": "GREATER_THAN", + } + } + ) + self.assertEqual(response.status_code, 200) + html = response.content.decode() + self.assertNotIn("EARLY-MARKER", html) + self.assertNotIn("MID-MARKER", html) + self.assertIn("LATE-MARKER", html) + self.assertIn( + 'name="filter-date-purchased-min" id="filter-date-purchased-min" ' + 'value="2024-06-15"', + html, + ) + self.assertIn( + 'name="filter-date-purchased-max" id="filter-date-purchased-max" ' + 'value=""', + html, + ) + + def test_date_refunded_not_null(self): + response = self._get( + {"date_refunded": {"value": "", "modifier": "NOT_NULL"}} + ) + self.assertEqual(response.status_code, 200) + html = response.content.decode() + self.assertNotIn("EARLY-MARKER", html) + self.assertIn("MID-MARKER", html) + self.assertNotIn("LATE-MARKER", html) + + def test_combined_dates_and_is_refunded(self): + """date_purchased BETWEEN 2024 AND date_refunded NOT_NULL → only the + mid purchase. Confirms AND-composition through the view layer.""" + response = self._get( + { + "date_purchased": { + "value": "2024-01-01", + "value2": "2024-12-31", + "modifier": "BETWEEN", + }, + "date_refunded": {"value": "", "modifier": "NOT_NULL"}, + } + ) + self.assertEqual(response.status_code, 200) + html = response.content.decode() + self.assertNotIn("EARLY-MARKER", html) + self.assertIn("MID-MARKER", html) + self.assertNotIn("LATE-MARKER", html) + + def test_malformed_json_filter_falls_back_to_unfiltered(self): + """parse_purchase_filter returns None on bad JSON → view ignores + the filter and renders the full list (no 500).""" + response = self._get(raw_filter="this is not json") + self.assertEqual(response.status_code, 200) + html = response.content.decode() + # All three purchases are present, same as the unfiltered baseline. + self.assertIn("EARLY-MARKER", html) + self.assertIn("MID-MARKER", html) + self.assertIn("LATE-MARKER", html)