Implement date filters in purchase list
This commit is contained in:
@@ -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 ``<input type="date">`` 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"
|
_FILTER_FORM_ID = "filter-bar-form"
|
||||||
|
|
||||||
|
|
||||||
@@ -1097,6 +1160,8 @@ def PurchaseFilterBar(
|
|||||||
needs_price_update_value = _parse_bool(existing, "needs_price_update")
|
needs_price_update_value = _parse_bool(existing, "needs_price_update")
|
||||||
price_currency_value = existing.get("price_currency", {}).get("value", "")
|
price_currency_value = existing.get("price_currency", {}).get("value", "")
|
||||||
converted_currency_value = existing.get("converted_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:
|
try:
|
||||||
price_aggregate = Purchase.objects.aggregate(
|
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(
|
_filter_field(
|
||||||
"Price",
|
"Price",
|
||||||
RangeSlider(
|
RangeSlider(
|
||||||
|
|||||||
@@ -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 ``<input type="date">``
|
||||||
|
elements, building a ``DateCriterion`` JSON object, and navigating the
|
||||||
|
browser to ``?filter=<encoded>``.
|
||||||
|
|
||||||
|
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"""<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Date filter E2E</title>
|
||||||
|
<script src="/static/js/range_slider.js" defer></script>
|
||||||
|
<script src="/static/js/search_select.js" defer></script>
|
||||||
|
<script src="/static/js/filter_bar.js" defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{PurchaseFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
|
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",
|
||||||
|
}
|
||||||
+99
-36
@@ -18,6 +18,7 @@ from django.db.models import Q
|
|||||||
from common.criteria import (
|
from common.criteria import (
|
||||||
BoolCriterion,
|
BoolCriterion,
|
||||||
ChoiceCriterion,
|
ChoiceCriterion,
|
||||||
|
DateCriterion,
|
||||||
FloatCriterion,
|
FloatCriterion,
|
||||||
IntCriterion,
|
IntCriterion,
|
||||||
Modifier,
|
Modifier,
|
||||||
@@ -132,6 +133,7 @@ class GameFilter(OperatorFilter):
|
|||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
|
|
||||||
from games.models import Game
|
from games.models import Game
|
||||||
|
|
||||||
matching_ids = (
|
matching_ids = (
|
||||||
Game.objects.annotate(s_count=Count("sessions", distinct=True))
|
Game.objects.annotate(s_count=Count("sessions", distinct=True))
|
||||||
.filter(self.session_count.to_q("s_count"))
|
.filter(self.session_count.to_q("s_count"))
|
||||||
@@ -143,6 +145,7 @@ class GameFilter(OperatorFilter):
|
|||||||
from django.db.models import Avg
|
from django.db.models import Avg
|
||||||
|
|
||||||
from games.models import Game
|
from games.models import Game
|
||||||
|
|
||||||
matching_ids = (
|
matching_ids = (
|
||||||
Game.objects.annotate(s_avg=Avg("sessions__duration_total"))
|
Game.objects.annotate(s_avg=Avg("sessions__duration_total"))
|
||||||
.filter(self._playtime_to_q_for_field(self.session_average, "s_avg"))
|
.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 django.db.models import Count
|
||||||
|
|
||||||
from games.models import Game
|
from games.models import Game
|
||||||
|
|
||||||
matching_ids = (
|
matching_ids = (
|
||||||
Game.objects.annotate(p_count=Count("purchases", distinct=True))
|
Game.objects.annotate(p_count=Count("purchases", distinct=True))
|
||||||
.filter(self.purchase_count.to_q("p_count"))
|
.filter(self.purchase_count.to_q("p_count"))
|
||||||
@@ -165,6 +169,7 @@ class GameFilter(OperatorFilter):
|
|||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
|
|
||||||
from games.models import Game
|
from games.models import Game
|
||||||
|
|
||||||
matching_ids = (
|
matching_ids = (
|
||||||
Game.objects.annotate(pe_count=Count("playevents", distinct=True))
|
Game.objects.annotate(pe_count=Count("playevents", distinct=True))
|
||||||
.filter(self.playevent_count.to_q("pe_count"))
|
.filter(self.playevent_count.to_q("pe_count"))
|
||||||
@@ -176,9 +181,14 @@ class GameFilter(OperatorFilter):
|
|||||||
from django.db.models import Sum
|
from django.db.models import Sum
|
||||||
|
|
||||||
from games.models import Game
|
from games.models import Game
|
||||||
|
|
||||||
matching_ids = (
|
matching_ids = (
|
||||||
Game.objects.annotate(s_manual=Sum("sessions__duration_manual"))
|
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)
|
.values_list("id", flat=True)
|
||||||
)
|
)
|
||||||
q &= Q(id__in=matching_ids)
|
q &= Q(id__in=matching_ids)
|
||||||
@@ -187,31 +197,47 @@ class GameFilter(OperatorFilter):
|
|||||||
from django.db.models import Sum
|
from django.db.models import Sum
|
||||||
|
|
||||||
from games.models import Game
|
from games.models import Game
|
||||||
|
|
||||||
matching_ids = (
|
matching_ids = (
|
||||||
Game.objects.annotate(s_calc=Sum("sessions__duration_calculated"))
|
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)
|
.values_list("id", flat=True)
|
||||||
)
|
)
|
||||||
q &= Q(id__in=matching_ids)
|
q &= Q(id__in=matching_ids)
|
||||||
|
|
||||||
if self.device is not None:
|
if self.device is not None:
|
||||||
from games.models import Session
|
from games.models import Session
|
||||||
|
|
||||||
session_q = self.device.to_q("device_id")
|
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)
|
q &= Q(id__in=matching_ids)
|
||||||
|
|
||||||
if self.session_emulated is not None:
|
if self.session_emulated is not None:
|
||||||
from games.models import Session
|
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:
|
if self.session_emulated.value:
|
||||||
q &= Q(id__in=emulated_ids)
|
q &= Q(id__in=emulated_ids)
|
||||||
else:
|
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)
|
q &= ~Q(id__in=emulated_true_ids)
|
||||||
|
|
||||||
if self.purchase_refunded is not None:
|
if self.purchase_refunded is not None:
|
||||||
from games.models import Purchase
|
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:
|
if self.purchase_refunded.value:
|
||||||
q &= Q(id__in=refunded_ids)
|
q &= Q(id__in=refunded_ids)
|
||||||
else:
|
else:
|
||||||
@@ -219,7 +245,10 @@ class GameFilter(OperatorFilter):
|
|||||||
|
|
||||||
if self.purchase_infinite is not None:
|
if self.purchase_infinite is not None:
|
||||||
from games.models import Purchase
|
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:
|
if self.purchase_infinite.value:
|
||||||
q &= Q(id__in=infinite_ids)
|
q &= Q(id__in=infinite_ids)
|
||||||
else:
|
else:
|
||||||
@@ -229,6 +258,7 @@ class GameFilter(OperatorFilter):
|
|||||||
from django.db.models import Sum
|
from django.db.models import Sum
|
||||||
|
|
||||||
from games.models import Game
|
from games.models import Game
|
||||||
|
|
||||||
matching_ids = (
|
matching_ids = (
|
||||||
Game.objects.annotate(p_total=Sum("purchases__converted_price"))
|
Game.objects.annotate(p_total=Sum("purchases__converted_price"))
|
||||||
.filter(self.purchase_price_total.to_q("p_total"))
|
.filter(self.purchase_price_total.to_q("p_total"))
|
||||||
@@ -238,20 +268,29 @@ class GameFilter(OperatorFilter):
|
|||||||
|
|
||||||
if self.purchase_price_any is not None:
|
if self.purchase_price_any is not None:
|
||||||
from games.models import Purchase
|
from games.models import Purchase
|
||||||
|
|
||||||
price_q = self.purchase_price_any.to_q("converted_price")
|
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)
|
q &= Q(id__in=matching_ids)
|
||||||
|
|
||||||
if self.purchase_type is not None:
|
if self.purchase_type is not None:
|
||||||
from games.models import Purchase
|
from games.models import Purchase
|
||||||
|
|
||||||
type_q = self.purchase_type.to_q("type")
|
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)
|
q &= Q(id__in=matching_ids)
|
||||||
|
|
||||||
if self.purchase_ownership_type is not None:
|
if self.purchase_ownership_type is not None:
|
||||||
from games.models import Purchase
|
from games.models import Purchase
|
||||||
|
|
||||||
ownership_q = self.purchase_ownership_type.to_q("ownership_type")
|
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)
|
q &= Q(id__in=matching_ids)
|
||||||
|
|
||||||
if self.playevent_note is not None:
|
if self.playevent_note is not None:
|
||||||
@@ -271,26 +310,38 @@ class GameFilter(OperatorFilter):
|
|||||||
# Cross-entity filters
|
# Cross-entity filters
|
||||||
if self.session_filter is not None:
|
if self.session_filter is not None:
|
||||||
from games.models import Session
|
from games.models import Session
|
||||||
|
|
||||||
session_q = self.session_filter.to_q()
|
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)
|
q &= Q(id__in=matching_ids)
|
||||||
|
|
||||||
if self.purchase_filter is not None:
|
if self.purchase_filter is not None:
|
||||||
from games.models import Purchase
|
from games.models import Purchase
|
||||||
|
|
||||||
purchase_q = self.purchase_filter.to_q()
|
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)
|
q &= Q(id__in=matching_ids)
|
||||||
|
|
||||||
if self.playevent_filter is not None:
|
if self.playevent_filter is not None:
|
||||||
from games.models import PlayEvent
|
from games.models import PlayEvent
|
||||||
|
|
||||||
playevent_q = self.playevent_filter.to_q()
|
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)
|
q &= Q(id__in=matching_ids)
|
||||||
|
|
||||||
if self.platform_filter is not None:
|
if self.platform_filter is not None:
|
||||||
from games.models import Platform
|
from games.models import Platform
|
||||||
|
|
||||||
platform_q = self.platform_filter.to_q()
|
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)
|
q &= Q(platform_id__in=matching_ids)
|
||||||
|
|
||||||
# ── AND / OR / NOT sub-filters ──
|
# ── AND / OR / NOT sub-filters ──
|
||||||
@@ -375,9 +426,9 @@ class GameFilter(OperatorFilter):
|
|||||||
include_q |= Q(id__in=matching_ids)
|
include_q |= Q(id__in=matching_ids)
|
||||||
q &= ~include_q if negate_include else include_q
|
q &= ~include_q if negate_include else include_q
|
||||||
for term in criterion.excludes:
|
for term in criterion.excludes:
|
||||||
matching_ids = PlayEvent.objects.filter(
|
matching_ids = PlayEvent.objects.filter(note__icontains=term).values_list(
|
||||||
note__icontains=term
|
"game_id", flat=True
|
||||||
).values_list("game_id", flat=True)
|
)
|
||||||
q &= ~Q(id__in=matching_ids)
|
q &= ~Q(id__in=matching_ids)
|
||||||
return q
|
return q
|
||||||
|
|
||||||
@@ -418,6 +469,7 @@ class SessionFilter(OperatorFilter):
|
|||||||
|
|
||||||
def _duration_to_q(self, c: IntCriterion, field: str) -> Q:
|
def _duration_to_q(self, c: IntCriterion, field: str) -> Q:
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
q = Q()
|
q = Q()
|
||||||
td_val = timedelta(minutes=c.value)
|
td_val = timedelta(minutes=c.value)
|
||||||
m = c.modifier
|
m = c.modifier
|
||||||
@@ -473,7 +525,9 @@ class SessionFilter(OperatorFilter):
|
|||||||
if self.duration_manual_minutes is not None:
|
if self.duration_manual_minutes is not None:
|
||||||
q &= self._duration_to_q(self.duration_manual_minutes, "duration_manual")
|
q &= self._duration_to_q(self.duration_manual_minutes, "duration_manual")
|
||||||
if self.duration_calculated_minutes is not None:
|
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 is not None:
|
||||||
if self.is_active.value:
|
if self.is_active.value:
|
||||||
q &= Q(timestamp_end__isnull=True)
|
q &= Q(timestamp_end__isnull=True)
|
||||||
@@ -546,8 +600,8 @@ class PurchaseFilter(OperatorFilter):
|
|||||||
name: StringCriterion | None = None
|
name: StringCriterion | None = None
|
||||||
platform: ChoiceCriterion | None = None # platform_id
|
platform: ChoiceCriterion | None = None # platform_id
|
||||||
games: ChoiceCriterion | None = None # games (M2M IDs)
|
games: ChoiceCriterion | None = None # games (M2M IDs)
|
||||||
date_purchased: StringCriterion | None = None # date string
|
date_purchased: DateCriterion | None = None
|
||||||
date_refunded: StringCriterion | None = None # date string
|
date_refunded: DateCriterion | None = None
|
||||||
is_refunded: BoolCriterion | None = None # date_refunded IS NOT NULL
|
is_refunded: BoolCriterion | None = None # date_refunded IS NOT NULL
|
||||||
price: FloatCriterion | None = None # on price field
|
price: FloatCriterion | None = None # on price field
|
||||||
converted_price: FloatCriterion | None = None
|
converted_price: FloatCriterion | None = None
|
||||||
@@ -633,7 +687,9 @@ class PurchaseFilter(OperatorFilter):
|
|||||||
from games.models import Platform
|
from games.models import Platform
|
||||||
|
|
||||||
platform_q = self.platform_filter.to_q()
|
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)
|
q &= Q(platform_id__in=matching_ids)
|
||||||
|
|
||||||
sub = self.sub_filter()
|
sub = self.sub_filter()
|
||||||
@@ -682,9 +738,9 @@ class PurchaseFilter(OperatorFilter):
|
|||||||
subquery = subquery.filter(games=game_id)
|
subquery = subquery.filter(games=game_id)
|
||||||
|
|
||||||
if criterion.modifier == Modifier.INCLUDES_ONLY:
|
if criterion.modifier == Modifier.INCLUDES_ONLY:
|
||||||
extra_ids = Game.objects.exclude(
|
extra_ids = Game.objects.exclude(id__in=criterion.value).values_list(
|
||||||
id__in=criterion.value
|
"id", flat=True
|
||||||
).values_list("id", flat=True)
|
)
|
||||||
if extra_ids:
|
if extra_ids:
|
||||||
subquery = subquery.exclude(games__in=extra_ids)
|
subquery = subquery.exclude(games__in=extra_ids)
|
||||||
|
|
||||||
@@ -737,9 +793,8 @@ class DeviceFilter(OperatorFilter):
|
|||||||
|
|
||||||
# Free-text search
|
# Free-text search
|
||||||
if self.search is not None and self.search.value:
|
if self.search is not None and self.search.value:
|
||||||
search_q = (
|
search_q = Q(name__icontains=self.search.value) | Q(
|
||||||
Q(name__icontains=self.search.value)
|
type__icontains=self.search.value
|
||||||
| Q(type__icontains=self.search.value)
|
|
||||||
)
|
)
|
||||||
if self.search.modifier == Modifier.EXCLUDES:
|
if self.search.modifier == Modifier.EXCLUDES:
|
||||||
search_q = ~search_q
|
search_q = ~search_q
|
||||||
@@ -748,8 +803,11 @@ class DeviceFilter(OperatorFilter):
|
|||||||
# Cross-entity filter: session_filter
|
# Cross-entity filter: session_filter
|
||||||
if self.session_filter is not None:
|
if self.session_filter is not None:
|
||||||
from games.models import Session
|
from games.models import Session
|
||||||
|
|
||||||
session_q = self.session_filter.to_q()
|
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)
|
q &= Q(id__in=matching_ids)
|
||||||
|
|
||||||
sub = self.sub_filter()
|
sub = self.sub_filter()
|
||||||
@@ -801,9 +859,8 @@ class PlatformFilter(OperatorFilter):
|
|||||||
|
|
||||||
# Free-text search
|
# Free-text search
|
||||||
if self.search is not None and self.search.value:
|
if self.search is not None and self.search.value:
|
||||||
search_q = (
|
search_q = Q(name__icontains=self.search.value) | Q(
|
||||||
Q(name__icontains=self.search.value)
|
group__icontains=self.search.value
|
||||||
| Q(group__icontains=self.search.value)
|
|
||||||
)
|
)
|
||||||
if self.search.modifier == Modifier.EXCLUDES:
|
if self.search.modifier == Modifier.EXCLUDES:
|
||||||
search_q = ~search_q
|
search_q = ~search_q
|
||||||
@@ -812,15 +869,21 @@ class PlatformFilter(OperatorFilter):
|
|||||||
# Cross-entity filter: game_filter
|
# Cross-entity filter: game_filter
|
||||||
if self.game_filter is not None:
|
if self.game_filter is not None:
|
||||||
from games.models import Game
|
from games.models import Game
|
||||||
|
|
||||||
game_q = self.game_filter.to_q()
|
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)
|
q &= Q(id__in=matching_ids)
|
||||||
|
|
||||||
# Cross-entity filter: purchase_filter
|
# Cross-entity filter: purchase_filter
|
||||||
if self.purchase_filter is not None:
|
if self.purchase_filter is not None:
|
||||||
from games.models import Purchase
|
from games.models import Purchase
|
||||||
|
|
||||||
purchase_q = self.purchase_filter.to_q()
|
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)
|
q &= Q(id__in=matching_ids)
|
||||||
|
|
||||||
sub = self.sub_filter()
|
sub = self.sub_filter()
|
||||||
@@ -877,9 +940,8 @@ class PlayEventFilter(OperatorFilter):
|
|||||||
|
|
||||||
# Free-text search
|
# Free-text search
|
||||||
if self.search is not None and self.search.value:
|
if self.search is not None and self.search.value:
|
||||||
search_q = (
|
search_q = Q(game__name__icontains=self.search.value) | Q(
|
||||||
Q(game__name__icontains=self.search.value)
|
note__icontains=self.search.value
|
||||||
| Q(note__icontains=self.search.value)
|
|
||||||
)
|
)
|
||||||
if self.search.modifier == Modifier.EXCLUDES:
|
if self.search.modifier == Modifier.EXCLUDES:
|
||||||
search_q = ~search_q
|
search_q = ~search_q
|
||||||
@@ -888,6 +950,7 @@ class PlayEventFilter(OperatorFilter):
|
|||||||
# Cross-entity filter: game_filter
|
# Cross-entity filter: game_filter
|
||||||
if self.game_filter is not None:
|
if self.game_filter is not None:
|
||||||
from games.models import Game
|
from games.models import Game
|
||||||
|
|
||||||
game_q = self.game_filter.to_q()
|
game_q = self.game_filter.to_q()
|
||||||
matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
|
matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
|
||||||
q &= Q(game_id__in=matching_ids)
|
q &= Q(game_id__in=matching_ids)
|
||||||
|
|||||||
@@ -30,6 +30,24 @@
|
|||||||
return isNaN(val) ? "" : val;
|
return isNaN(val) ? "" : val;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Read a raw <input> 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. */
|
/** Read all checked checkboxes with a given name, returning an array of ints. */
|
||||||
function checkedValues(form, name) {
|
function checkedValues(form, name) {
|
||||||
var els = form.querySelectorAll('[name="' + name + '"]:checked');
|
var els = form.querySelectorAll('[name="' + name + '"]:checked');
|
||||||
@@ -139,23 +157,31 @@
|
|||||||
rangeFields.forEach(function (rf) {
|
rangeFields.forEach(function (rf) {
|
||||||
var vMin = numberValue(form, rf.prefix + "-min");
|
var vMin = numberValue(form, rf.prefix + "-min");
|
||||||
var vMax = numberValue(form, rf.prefix + "-max");
|
var vMax = numberValue(form, rf.prefix + "-max");
|
||||||
|
|
||||||
if (rf.convert) {
|
if (rf.convert) {
|
||||||
if (vMin !== "") vMin = rf.convert(vMin);
|
if (vMin !== "") vMin = rf.convert(vMin);
|
||||||
if (vMax !== "") vMax = rf.convert(vMax);
|
if (vMax !== "") vMax = rf.convert(vMax);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rf.ignoreZeroZero && vMin === 0 && vMax === 0) {
|
if (rf.ignoreZeroZero && vMin === 0 && vMax === 0) {
|
||||||
return; // skip if both are 0 means slider at default
|
return; // both 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");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var c = buildRangeCriterion(vMin, vMax);
|
||||||
|
if (c !== null) filter[rf.key] = c;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Date Range Fields — ISO date strings from <input type="date">; 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;
|
return filter;
|
||||||
|
|||||||
+1
-1
@@ -35,7 +35,7 @@ from common.components import (
|
|||||||
Ul,
|
Ul,
|
||||||
paginated_table_content,
|
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.icons import get_icon
|
||||||
from common.layout import render_page
|
from common.layout import render_page
|
||||||
from common.time import (
|
from common.time import (
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ class FilterBarRenderingTest(TestCase):
|
|||||||
|
|
||||||
def test_device_filter_bar(self):
|
def test_device_filter_bar(self):
|
||||||
from common.components import DeviceFilterBar
|
from common.components import DeviceFilterBar
|
||||||
|
|
||||||
html = str(
|
html = str(
|
||||||
DeviceFilterBar(
|
DeviceFilterBar(
|
||||||
filter_json="",
|
filter_json="",
|
||||||
@@ -200,6 +201,7 @@ class FilterBarRenderingTest(TestCase):
|
|||||||
|
|
||||||
def test_platform_filter_bar(self):
|
def test_platform_filter_bar(self):
|
||||||
from common.components import PlatformFilterBar
|
from common.components import PlatformFilterBar
|
||||||
|
|
||||||
html = str(
|
html = str(
|
||||||
PlatformFilterBar(
|
PlatformFilterBar(
|
||||||
filter_json="",
|
filter_json="",
|
||||||
@@ -211,6 +213,7 @@ class FilterBarRenderingTest(TestCase):
|
|||||||
|
|
||||||
def test_playevent_filter_bar(self):
|
def test_playevent_filter_bar(self):
|
||||||
from common.components import PlayEventFilterBar
|
from common.components import PlayEventFilterBar
|
||||||
|
|
||||||
html = str(
|
html = str(
|
||||||
PlayEventFilterBar(
|
PlayEventFilterBar(
|
||||||
filter_json="",
|
filter_json="",
|
||||||
@@ -257,3 +260,80 @@ class FilterBarRenderingTest(TestCase):
|
|||||||
self.assertNotIn('name="filter-has-playevents"', html)
|
self.assertNotIn('name="filter-has-playevents"', html)
|
||||||
# Playtime label renamed
|
# Playtime label renamed
|
||||||
self.assertIn("Total playtime", html)
|
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,
|
||||||
|
)
|
||||||
|
|||||||
+326
-87
@@ -8,6 +8,7 @@ from django.db.models import Q
|
|||||||
from common.criteria import (
|
from common.criteria import (
|
||||||
BoolCriterion,
|
BoolCriterion,
|
||||||
ChoiceCriterion,
|
ChoiceCriterion,
|
||||||
|
DateCriterion,
|
||||||
IntCriterion,
|
IntCriterion,
|
||||||
Modifier,
|
Modifier,
|
||||||
MultiCriterion,
|
MultiCriterion,
|
||||||
@@ -667,20 +668,30 @@ class TestExpandedFiltersAgainstDB:
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
# 1. Platform & Game
|
# 1. Platform & Game
|
||||||
plat, _ = Platform.objects.get_or_create(name="Retro Console", group="Nintendo", icon="retro")
|
plat, _ = Platform.objects.get_or_create(
|
||||||
game, _ = Game.objects.get_or_create(name="Super Mario World", defaults={"platform": plat, "status": "f"})
|
name="Retro Console", group="Nintendo", icon="retro"
|
||||||
game2, _ = Game.objects.get_or_create(name="Zelda", defaults={"platform": plat, "status": "u"})
|
)
|
||||||
|
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
|
# 2. Device & Session
|
||||||
dev, _ = Device.objects.get_or_create(name="Super Famicom", type="Console")
|
dev, _ = Device.objects.get_or_create(name="Super Famicom", type="Console")
|
||||||
|
|
||||||
# Session 1: total 40 minutes (30 calc, 10 manual)
|
# Session 1: total 40 minutes (30 calc, 10 manual)
|
||||||
s1 = Session.objects.create(
|
s1 = Session.objects.create(
|
||||||
game=game,
|
game=game,
|
||||||
device=dev,
|
device=dev,
|
||||||
timestamp_start=datetime.datetime(2026, 6, 1, 12, 0, 0, tzinfo=datetime.timezone.utc),
|
timestamp_start=datetime.datetime(
|
||||||
timestamp_end=datetime.datetime(2026, 6, 1, 12, 30, 0, tzinfo=datetime.timezone.utc),
|
2026, 6, 1, 12, 0, 0, tzinfo=datetime.timezone.utc
|
||||||
duration_manual=timedelta(minutes=10)
|
),
|
||||||
|
timestamp_end=datetime.datetime(
|
||||||
|
2026, 6, 1, 12, 30, 0, tzinfo=datetime.timezone.utc
|
||||||
|
),
|
||||||
|
duration_manual=timedelta(minutes=10),
|
||||||
)
|
)
|
||||||
|
|
||||||
# 3. Purchase
|
# 3. Purchase
|
||||||
@@ -692,7 +703,7 @@ class TestExpandedFiltersAgainstDB:
|
|||||||
price_currency="JPY",
|
price_currency="JPY",
|
||||||
converted_price=45.00,
|
converted_price=45.00,
|
||||||
converted_currency="USD",
|
converted_currency="USD",
|
||||||
needs_price_update=False
|
needs_price_update=False,
|
||||||
)
|
)
|
||||||
pur.games.add(game)
|
pur.games.add(game)
|
||||||
|
|
||||||
@@ -701,7 +712,7 @@ class TestExpandedFiltersAgainstDB:
|
|||||||
game=game,
|
game=game,
|
||||||
started=datetime.date(2026, 6, 1),
|
started=datetime.date(2026, 6, 1),
|
||||||
ended=datetime.date(2026, 6, 2),
|
ended=datetime.date(2026, 6, 2),
|
||||||
note="Completed 100%"
|
note="Completed 100%",
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -711,7 +722,7 @@ class TestExpandedFiltersAgainstDB:
|
|||||||
"dev": dev,
|
"dev": dev,
|
||||||
"s1": s1,
|
"s1": s1,
|
||||||
"pur": pur,
|
"pur": pur,
|
||||||
"pe": pe
|
"pe": pe,
|
||||||
}
|
}
|
||||||
|
|
||||||
def test_device_filter_and_cross_entity(self):
|
def test_device_filter_and_cross_entity(self):
|
||||||
@@ -720,13 +731,15 @@ class TestExpandedFiltersAgainstDB:
|
|||||||
|
|
||||||
data = self._setup_entities()
|
data = self._setup_entities()
|
||||||
# Find devices that have sessions on "Super Mario World"
|
# Find devices that have sessions on "Super Mario World"
|
||||||
df = DeviceFilter.from_json({
|
df = DeviceFilter.from_json(
|
||||||
"session_filter": {
|
{
|
||||||
"game_filter": {
|
"session_filter": {
|
||||||
"name": {"value": "Super Mario World", "modifier": "EQUALS"}
|
"game_filter": {
|
||||||
|
"name": {"value": "Super Mario World", "modifier": "EQUALS"}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
results = list(Device.objects.filter(df.to_q()))
|
results = list(Device.objects.filter(df.to_q()))
|
||||||
assert data["dev"] in results
|
assert data["dev"] in results
|
||||||
|
|
||||||
@@ -736,11 +749,9 @@ class TestExpandedFiltersAgainstDB:
|
|||||||
|
|
||||||
data = self._setup_entities()
|
data = self._setup_entities()
|
||||||
# Find platforms with games that are finished
|
# Find platforms with games that are finished
|
||||||
pf = PlatformFilter.from_json({
|
pf = PlatformFilter.from_json(
|
||||||
"game_filter": {
|
{"game_filter": {"status": {"value": ["f"], "modifier": "INCLUDES"}}}
|
||||||
"status": {"value": ["f"], "modifier": "INCLUDES"}
|
)
|
||||||
}
|
|
||||||
})
|
|
||||||
results = list(Platform.objects.filter(pf.to_q()))
|
results = list(Platform.objects.filter(pf.to_q()))
|
||||||
assert data["plat"] in results
|
assert data["plat"] in results
|
||||||
|
|
||||||
@@ -749,23 +760,23 @@ class TestExpandedFiltersAgainstDB:
|
|||||||
from games.models import Session
|
from games.models import Session
|
||||||
|
|
||||||
data = self._setup_entities()
|
data = self._setup_entities()
|
||||||
|
|
||||||
# Test duration_total_minutes equals 40
|
# Test duration_total_minutes equals 40
|
||||||
sf_tot = SessionFilter.from_json({
|
sf_tot = SessionFilter.from_json(
|
||||||
"duration_total_minutes": {"value": 40, "modifier": "EQUALS"}
|
{"duration_total_minutes": {"value": 40, "modifier": "EQUALS"}}
|
||||||
})
|
)
|
||||||
assert Session.objects.filter(sf_tot.to_q()).count() == 1
|
assert Session.objects.filter(sf_tot.to_q()).count() == 1
|
||||||
|
|
||||||
# Test duration_manual_minutes equals 10
|
# Test duration_manual_minutes equals 10
|
||||||
sf_man = SessionFilter.from_json({
|
sf_man = SessionFilter.from_json(
|
||||||
"duration_manual_minutes": {"value": 10, "modifier": "EQUALS"}
|
{"duration_manual_minutes": {"value": 10, "modifier": "EQUALS"}}
|
||||||
})
|
)
|
||||||
assert Session.objects.filter(sf_man.to_q()).count() == 1
|
assert Session.objects.filter(sf_man.to_q()).count() == 1
|
||||||
|
|
||||||
# Test duration_calculated_minutes equals 30
|
# Test duration_calculated_minutes equals 30
|
||||||
sf_calc = SessionFilter.from_json({
|
sf_calc = SessionFilter.from_json(
|
||||||
"duration_calculated_minutes": {"value": 30, "modifier": "EQUALS"}
|
{"duration_calculated_minutes": {"value": 30, "modifier": "EQUALS"}}
|
||||||
})
|
)
|
||||||
assert Session.objects.filter(sf_calc.to_q()).count() == 1
|
assert Session.objects.filter(sf_calc.to_q()).count() == 1
|
||||||
|
|
||||||
def test_purchase_filter_new_fields(self):
|
def test_purchase_filter_new_fields(self):
|
||||||
@@ -774,11 +785,13 @@ class TestExpandedFiltersAgainstDB:
|
|||||||
|
|
||||||
data = self._setup_entities()
|
data = self._setup_entities()
|
||||||
|
|
||||||
pf = PurchaseFilter.from_json({
|
pf = PurchaseFilter.from_json(
|
||||||
"infinite": {"value": True, "modifier": "EQUALS"},
|
{
|
||||||
"needs_price_update": {"value": False, "modifier": "EQUALS"},
|
"infinite": {"value": True, "modifier": "EQUALS"},
|
||||||
"converted_currency": {"value": "USD", "modifier": "EQUALS"}
|
"needs_price_update": {"value": False, "modifier": "EQUALS"},
|
||||||
})
|
"converted_currency": {"value": "USD", "modifier": "EQUALS"},
|
||||||
|
}
|
||||||
|
)
|
||||||
assert Purchase.objects.filter(pf.to_q()).count() == 1
|
assert Purchase.objects.filter(pf.to_q()).count() == 1
|
||||||
|
|
||||||
def test_game_filter_stats_and_existence(self):
|
def test_game_filter_stats_and_existence(self):
|
||||||
@@ -788,16 +801,16 @@ class TestExpandedFiltersAgainstDB:
|
|||||||
data = self._setup_entities()
|
data = self._setup_entities()
|
||||||
|
|
||||||
# purchase_count == 1 (replaces removed has_purchases boolean)
|
# purchase_count == 1 (replaces removed has_purchases boolean)
|
||||||
gf_pur = GameFilter.from_json({
|
gf_pur = GameFilter.from_json(
|
||||||
"purchase_count": {"value": 1, "modifier": "EQUALS"}
|
{"purchase_count": {"value": 1, "modifier": "EQUALS"}}
|
||||||
})
|
)
|
||||||
assert data["game"] in list(Game.objects.filter(gf_pur.to_q()))
|
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()))
|
assert data["game2"] not in list(Game.objects.filter(gf_pur.to_q()))
|
||||||
|
|
||||||
# session_count = 1
|
# session_count = 1
|
||||||
gf_cnt = GameFilter.from_json({
|
gf_cnt = GameFilter.from_json(
|
||||||
"session_count": {"value": 1, "modifier": "EQUALS"}
|
{"session_count": {"value": 1, "modifier": "EQUALS"}}
|
||||||
})
|
)
|
||||||
assert data["game"] in list(Game.objects.filter(gf_cnt.to_q()))
|
assert data["game"] in list(Game.objects.filter(gf_cnt.to_q()))
|
||||||
|
|
||||||
def test_game_filter_purchase_count_range(self):
|
def test_game_filter_purchase_count_range(self):
|
||||||
@@ -807,9 +820,9 @@ class TestExpandedFiltersAgainstDB:
|
|||||||
data = self._setup_entities()
|
data = self._setup_entities()
|
||||||
|
|
||||||
# game has 1 purchase, game2 has 0
|
# game has 1 purchase, game2 has 0
|
||||||
gf = GameFilter.from_json({
|
gf = GameFilter.from_json(
|
||||||
"purchase_count": {"value": 1, "modifier": "EQUALS"}
|
{"purchase_count": {"value": 1, "modifier": "EQUALS"}}
|
||||||
})
|
)
|
||||||
results = set(Game.objects.filter(gf.to_q()))
|
results = set(Game.objects.filter(gf.to_q()))
|
||||||
assert data["game"] in results
|
assert data["game"] in results
|
||||||
assert data["game2"] not in results
|
assert data["game2"] not in results
|
||||||
@@ -819,9 +832,9 @@ class TestExpandedFiltersAgainstDB:
|
|||||||
from games.models import Game
|
from games.models import Game
|
||||||
|
|
||||||
data = self._setup_entities()
|
data = self._setup_entities()
|
||||||
gf = GameFilter.from_json({
|
gf = GameFilter.from_json(
|
||||||
"playevent_count": {"value": 1, "modifier": "EQUALS"}
|
{"playevent_count": {"value": 1, "modifier": "EQUALS"}}
|
||||||
})
|
)
|
||||||
results = set(Game.objects.filter(gf.to_q()))
|
results = set(Game.objects.filter(gf.to_q()))
|
||||||
assert data["game"] in results
|
assert data["game"] in results
|
||||||
assert data["game2"] not in results
|
assert data["game2"] not in results
|
||||||
@@ -831,9 +844,9 @@ class TestExpandedFiltersAgainstDB:
|
|||||||
from games.models import Game
|
from games.models import Game
|
||||||
|
|
||||||
data = self._setup_entities()
|
data = self._setup_entities()
|
||||||
gf = GameFilter.from_json({
|
gf = GameFilter.from_json(
|
||||||
"device": {"value": [data["dev"].id], "modifier": "INCLUDES"}
|
{"device": {"value": [data["dev"].id], "modifier": "INCLUDES"}}
|
||||||
})
|
)
|
||||||
results = set(Game.objects.filter(gf.to_q()))
|
results = set(Game.objects.filter(gf.to_q()))
|
||||||
assert data["game"] in results
|
assert data["game"] in results
|
||||||
assert data["game2"] not in results
|
assert data["game2"] not in results
|
||||||
@@ -843,9 +856,9 @@ class TestExpandedFiltersAgainstDB:
|
|||||||
from games.models import Game
|
from games.models import Game
|
||||||
|
|
||||||
data = self._setup_entities()
|
data = self._setup_entities()
|
||||||
gf = GameFilter.from_json({
|
gf = GameFilter.from_json(
|
||||||
"platform_group": {"value": ["Nintendo"], "modifier": "INCLUDES"}
|
{"platform_group": {"value": ["Nintendo"], "modifier": "INCLUDES"}}
|
||||||
})
|
)
|
||||||
results = set(Game.objects.filter(gf.to_q()))
|
results = set(Game.objects.filter(gf.to_q()))
|
||||||
# both games are on the same Nintendo platform
|
# both games are on the same Nintendo platform
|
||||||
assert data["game"] in results
|
assert data["game"] in results
|
||||||
@@ -861,14 +874,18 @@ class TestExpandedFiltersAgainstDB:
|
|||||||
Session.objects.create(
|
Session.objects.create(
|
||||||
game=data["game2"],
|
game=data["game2"],
|
||||||
device=data["dev"],
|
device=data["dev"],
|
||||||
timestamp_start=datetime.datetime(2026, 6, 2, 12, 0, 0, tzinfo=datetime.timezone.utc),
|
timestamp_start=datetime.datetime(
|
||||||
timestamp_end=datetime.datetime(2026, 6, 2, 12, 30, 0, tzinfo=datetime.timezone.utc),
|
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),
|
duration_manual=timedelta(0),
|
||||||
emulated=True,
|
emulated=True,
|
||||||
)
|
)
|
||||||
gf = GameFilter.from_json({
|
gf = GameFilter.from_json(
|
||||||
"session_emulated": {"value": True, "modifier": "EQUALS"}
|
{"session_emulated": {"value": True, "modifier": "EQUALS"}}
|
||||||
})
|
)
|
||||||
results = set(Game.objects.filter(gf.to_q()))
|
results = set(Game.objects.filter(gf.to_q()))
|
||||||
assert data["game2"] in results
|
assert data["game2"] in results
|
||||||
assert data["game"] not in results
|
assert data["game"] not in results
|
||||||
@@ -880,9 +897,9 @@ class TestExpandedFiltersAgainstDB:
|
|||||||
|
|
||||||
data = self._setup_entities()
|
data = self._setup_entities()
|
||||||
# data["pur"] is infinite=True, non-refunded.
|
# data["pur"] is infinite=True, non-refunded.
|
||||||
gf_inf = GameFilter.from_json({
|
gf_inf = GameFilter.from_json(
|
||||||
"purchase_infinite": {"value": True, "modifier": "EQUALS"}
|
{"purchase_infinite": {"value": True, "modifier": "EQUALS"}}
|
||||||
})
|
)
|
||||||
assert data["game"] in set(Game.objects.filter(gf_inf.to_q()))
|
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()))
|
assert data["game2"] not in set(Game.objects.filter(gf_inf.to_q()))
|
||||||
|
|
||||||
@@ -897,9 +914,9 @@ class TestExpandedFiltersAgainstDB:
|
|||||||
converted_currency="USD",
|
converted_currency="USD",
|
||||||
)
|
)
|
||||||
refunded.games.add(data["game2"])
|
refunded.games.add(data["game2"])
|
||||||
gf_ref = GameFilter.from_json({
|
gf_ref = GameFilter.from_json(
|
||||||
"purchase_refunded": {"value": True, "modifier": "EQUALS"}
|
{"purchase_refunded": {"value": True, "modifier": "EQUALS"}}
|
||||||
})
|
)
|
||||||
results = set(Game.objects.filter(gf_ref.to_q()))
|
results = set(Game.objects.filter(gf_ref.to_q()))
|
||||||
assert data["game2"] in results
|
assert data["game2"] in results
|
||||||
assert data["game"] not in results
|
assert data["game"] not in results
|
||||||
@@ -910,14 +927,14 @@ class TestExpandedFiltersAgainstDB:
|
|||||||
|
|
||||||
data = self._setup_entities()
|
data = self._setup_entities()
|
||||||
# data["pur"] defaults to type=game, ownership_type=digital
|
# data["pur"] defaults to type=game, ownership_type=digital
|
||||||
gf = GameFilter.from_json({
|
gf = GameFilter.from_json(
|
||||||
"purchase_type": {"value": ["game"], "modifier": "INCLUDES"}
|
{"purchase_type": {"value": ["game"], "modifier": "INCLUDES"}}
|
||||||
})
|
)
|
||||||
assert data["game"] in set(Game.objects.filter(gf.to_q()))
|
assert data["game"] in set(Game.objects.filter(gf.to_q()))
|
||||||
|
|
||||||
gf = GameFilter.from_json({
|
gf = GameFilter.from_json(
|
||||||
"purchase_ownership_type": {"value": ["di"], "modifier": "INCLUDES"}
|
{"purchase_ownership_type": {"value": ["di"], "modifier": "INCLUDES"}}
|
||||||
})
|
)
|
||||||
assert data["game"] in set(Game.objects.filter(gf.to_q()))
|
assert data["game"] in set(Game.objects.filter(gf.to_q()))
|
||||||
|
|
||||||
def test_game_filter_purchase_price_any_and_total(self):
|
def test_game_filter_purchase_price_any_and_total(self):
|
||||||
@@ -926,16 +943,28 @@ class TestExpandedFiltersAgainstDB:
|
|||||||
|
|
||||||
data = self._setup_entities()
|
data = self._setup_entities()
|
||||||
# data["pur"] has converted_price=45.00 linked to data["game"]
|
# data["pur"] has converted_price=45.00 linked to data["game"]
|
||||||
gf_any = GameFilter.from_json({
|
gf_any = GameFilter.from_json(
|
||||||
"purchase_price_any": {"value": 40.0, "value2": 50.0, "modifier": "BETWEEN"}
|
{
|
||||||
})
|
"purchase_price_any": {
|
||||||
|
"value": 40.0,
|
||||||
|
"value2": 50.0,
|
||||||
|
"modifier": "BETWEEN",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
results = set(Game.objects.filter(gf_any.to_q()))
|
results = set(Game.objects.filter(gf_any.to_q()))
|
||||||
assert data["game"] in results
|
assert data["game"] in results
|
||||||
assert data["game2"] not in results
|
assert data["game2"] not in results
|
||||||
|
|
||||||
gf_total = GameFilter.from_json({
|
gf_total = GameFilter.from_json(
|
||||||
"purchase_price_total": {"value": 40.0, "value2": 50.0, "modifier": "BETWEEN"}
|
{
|
||||||
})
|
"purchase_price_total": {
|
||||||
|
"value": 40.0,
|
||||||
|
"value2": 50.0,
|
||||||
|
"modifier": "BETWEEN",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
results = set(Game.objects.filter(gf_total.to_q()))
|
results = set(Game.objects.filter(gf_total.to_q()))
|
||||||
assert data["game"] in results
|
assert data["game"] in results
|
||||||
assert data["game2"] not in results
|
assert data["game2"] not in results
|
||||||
@@ -946,12 +975,14 @@ class TestExpandedFiltersAgainstDB:
|
|||||||
|
|
||||||
data = self._setup_entities()
|
data = self._setup_entities()
|
||||||
# data["pe"] has note="Completed 100%" on data["game"]
|
# data["pe"] has note="Completed 100%" on data["game"]
|
||||||
gf = GameFilter.from_json({
|
gf = GameFilter.from_json(
|
||||||
"playevent_note": {
|
{
|
||||||
"value": [{"id": "Completed", "label": "Completed"}],
|
"playevent_note": {
|
||||||
"modifier": "INCLUDES",
|
"value": [{"id": "Completed", "label": "Completed"}],
|
||||||
|
"modifier": "INCLUDES",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
results = set(Game.objects.filter(gf.to_q()))
|
results = set(Game.objects.filter(gf.to_q()))
|
||||||
assert data["game"] in results
|
assert data["game"] in results
|
||||||
assert data["game2"] not in results
|
assert data["game2"] not in results
|
||||||
@@ -962,12 +993,220 @@ class TestExpandedFiltersAgainstDB:
|
|||||||
|
|
||||||
data = self._setup_entities()
|
data = self._setup_entities()
|
||||||
# data["s1"] has 10 minutes manual + 30 minutes calculated
|
# data["s1"] has 10 minutes manual + 30 minutes calculated
|
||||||
gf_manual = GameFilter.from_json({
|
gf_manual = GameFilter.from_json(
|
||||||
"manual_playtime_minutes": {"value": 10, "modifier": "EQUALS"}
|
{"manual_playtime_minutes": {"value": 10, "modifier": "EQUALS"}}
|
||||||
})
|
)
|
||||||
assert data["game"] in set(Game.objects.filter(gf_manual.to_q()))
|
assert data["game"] in set(Game.objects.filter(gf_manual.to_q()))
|
||||||
|
|
||||||
gf_calc = GameFilter.from_json({
|
gf_calc = GameFilter.from_json(
|
||||||
"calculated_playtime_minutes": {"value": 30, "modifier": "EQUALS"}
|
{"calculated_playtime_minutes": {"value": 30, "modifier": "EQUALS"}}
|
||||||
})
|
)
|
||||||
assert data["game"] in set(Game.objects.filter(gf_calc.to_q()))
|
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
|
||||||
|
|||||||
@@ -290,3 +290,152 @@ class RenderedPagesTest(TestCase):
|
|||||||
self.assertNoEscapedTags(html)
|
self.assertNoEscapedTags(html)
|
||||||
# The Python builder emits well-formed, balanced markup.
|
# The Python builder emits well-formed, balanced markup.
|
||||||
self.assertEqual(html.count("<div"), html.count("</div>"))
|
self.assertEqual(html.count("<div"), html.count("</div>"))
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user