Merge pull request #69 from KucharczykL/feat/issue-67-playevent-datecriterion
feat(filters): date-range filtering on PlayEventFilter.started/ended (#67)
This commit is contained in:
@@ -1479,6 +1479,8 @@ class PlayEventFilterBar(_FilterBarBase):
|
|||||||
def _playevent_fields(existing: dict) -> list:
|
def _playevent_fields(existing: dict) -> list:
|
||||||
game_choice = _filter_get_choice(existing, "game")
|
game_choice = _filter_get_choice(existing, "game")
|
||||||
days_min, days_max = _parse_range(existing, "days_to_finish")
|
days_min, days_max = _parse_range(existing, "days_to_finish")
|
||||||
|
started_min, started_max = _parse_range(existing, "started")
|
||||||
|
ended_min, ended_max = _parse_range(existing, "ended")
|
||||||
|
|
||||||
fields = [
|
fields = [
|
||||||
Div(
|
Div(
|
||||||
@@ -1495,6 +1497,24 @@ def _playevent_fields(existing: dict) -> list:
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
_filter_field(
|
||||||
|
"Started",
|
||||||
|
DateRangePicker(
|
||||||
|
label="Started",
|
||||||
|
input_name_prefix="filter-started",
|
||||||
|
min_value=started_min,
|
||||||
|
max_value=started_max,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_filter_field(
|
||||||
|
"Finished",
|
||||||
|
DateRangePicker(
|
||||||
|
label="Finished",
|
||||||
|
input_name_prefix="filter-ended",
|
||||||
|
min_value=ended_min,
|
||||||
|
max_value=ended_max,
|
||||||
|
),
|
||||||
|
),
|
||||||
_filter_field(
|
_filter_field(
|
||||||
"Days to Finish",
|
"Days to Finish",
|
||||||
RangeSlider(
|
RangeSlider(
|
||||||
|
|||||||
+2
-2
@@ -899,8 +899,8 @@ class PlayEventFilter(OperatorFilter):
|
|||||||
NOT: PlayEventFilter | None = None
|
NOT: PlayEventFilter | None = None
|
||||||
|
|
||||||
game: MultiCriterion | None = None # filters on game_id
|
game: MultiCriterion | None = None # filters on game_id
|
||||||
started: StringCriterion | None = None # date string
|
started: DateCriterion | None = None # DateField, bare lookup
|
||||||
ended: StringCriterion | None = None # date string
|
ended: DateCriterion | None = None # DateField, bare lookup
|
||||||
days_to_finish: IntCriterion | None = None
|
days_to_finish: IntCriterion | None = None
|
||||||
note: StringCriterion | None = None
|
note: StringCriterion | None = None
|
||||||
created_at: StringCriterion | None = None
|
created_at: StringCriterion | None = None
|
||||||
|
|||||||
@@ -223,6 +223,59 @@ class FilterBarRenderingTest(TestCase):
|
|||||||
)
|
)
|
||||||
self._assert_shell(html, "/presets/playevents/list", "/presets/playevents/save")
|
self._assert_shell(html, "/presets/playevents/list", "/presets/playevents/save")
|
||||||
|
|
||||||
|
def test_playevent_filter_bar_renders_date_inputs(self):
|
||||||
|
"""PlayEventFilterBar surfaces started and ended as DateRangePicker
|
||||||
|
widgets whose -min/-max hidden inputs (the JS serializer contract)
|
||||||
|
carry the filter-started / filter-ended prefixes, in labelled fields."""
|
||||||
|
from common.components import PlayEventFilterBar
|
||||||
|
|
||||||
|
html = str(
|
||||||
|
PlayEventFilterBar(
|
||||||
|
filter_json="", preset_list_url="/l", preset_save_url="/s"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for name in (
|
||||||
|
"filter-started-min",
|
||||||
|
"filter-started-max",
|
||||||
|
"filter-ended-min",
|
||||||
|
"filter-ended-max",
|
||||||
|
):
|
||||||
|
self.assertIn(f'name="{name}"', html)
|
||||||
|
self.assertIn(f'id="{name}"', html)
|
||||||
|
self.assertIn("<date-range-picker", html)
|
||||||
|
self.assertIn("Started", html)
|
||||||
|
self.assertIn("Finished", html)
|
||||||
|
self.assertNoEscapedTags(html)
|
||||||
|
|
||||||
|
def test_playevent_filter_bar_prepopulates_ended_between(self):
|
||||||
|
"""A BETWEEN filter on ended populates both date bounds via _parse_range."""
|
||||||
|
from common.components import PlayEventFilterBar
|
||||||
|
|
||||||
|
filter_json = json.dumps(
|
||||||
|
{
|
||||||
|
"ended": {
|
||||||
|
"value": "2024-01-01",
|
||||||
|
"value2": "2024-12-31",
|
||||||
|
"modifier": "BETWEEN",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
html = str(
|
||||||
|
PlayEventFilterBar(
|
||||||
|
filter_json=filter_json,
|
||||||
|
preset_list_url="/l",
|
||||||
|
preset_save_url="/s",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
'name="filter-ended-min" id="filter-ended-min" value="2024-01-01"',
|
||||||
|
html,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
'name="filter-ended-max" id="filter-ended-max" value="2024-12-31"',
|
||||||
|
html,
|
||||||
|
)
|
||||||
|
|
||||||
def test_playevent_filter_bar_labels_days_to_finish_slider(self):
|
def test_playevent_filter_bar_labels_days_to_finish_slider(self):
|
||||||
"""The Days to Finish range slider must be wrapped in a labelled field —
|
"""The Days to Finish range slider must be wrapped in a labelled field —
|
||||||
RangeSlider does not render its own label, so a bare slider shows none."""
|
RangeSlider does not render its own label, so a bare slider shows none."""
|
||||||
|
|||||||
@@ -1241,3 +1241,101 @@ class TestPurchaseFilterDates:
|
|||||||
assert out["date_purchased"]["value2"] == "2024-12-31"
|
assert out["date_purchased"]["value2"] == "2024-12-31"
|
||||||
assert out["date_purchased"]["modifier"] == Modifier.BETWEEN
|
assert out["date_purchased"]["modifier"] == Modifier.BETWEEN
|
||||||
assert out["date_refunded"]["modifier"] == Modifier.NOT_NULL
|
assert out["date_refunded"]["modifier"] == Modifier.NOT_NULL
|
||||||
|
|
||||||
|
|
||||||
|
class TestPlayEventFilterDates:
|
||||||
|
"""End-to-end: a PlayEventFilter built from JSON narrows the queryset
|
||||||
|
correctly across the started/ended DateCriterion fields. PlayEvent.started
|
||||||
|
and ended are DateField columns, so the criteria apply with bare field
|
||||||
|
names (no __date lookup)."""
|
||||||
|
|
||||||
|
def _seed(self):
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from games.models import Game, Platform, PlayEvent
|
||||||
|
|
||||||
|
platform, _ = Platform.objects.get_or_create(name="Test", icon="test")
|
||||||
|
game = Game.objects.create(name="Test Game", platform=platform)
|
||||||
|
early = PlayEvent.objects.create(
|
||||||
|
game=game,
|
||||||
|
started=datetime.date(2024, 1, 10),
|
||||||
|
ended=datetime.date(2024, 1, 20),
|
||||||
|
)
|
||||||
|
mid = PlayEvent.objects.create(
|
||||||
|
game=game,
|
||||||
|
started=datetime.date(2024, 6, 1),
|
||||||
|
ended=datetime.date(2024, 6, 30),
|
||||||
|
)
|
||||||
|
late = PlayEvent.objects.create(
|
||||||
|
game=game,
|
||||||
|
started=datetime.date(2025, 2, 1),
|
||||||
|
ended=datetime.date(2025, 2, 15),
|
||||||
|
)
|
||||||
|
return {"early": early, "mid": mid, "late": late}
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_ended_between_finds_year(self):
|
||||||
|
"""'Finished in 2024' expressed as a BETWEEN range over ended."""
|
||||||
|
from games.filters import PlayEventFilter
|
||||||
|
from games.models import PlayEvent
|
||||||
|
|
||||||
|
seeded = self._seed()
|
||||||
|
pf = PlayEventFilter.from_json(
|
||||||
|
{
|
||||||
|
"ended": {
|
||||||
|
"value": "2024-01-01",
|
||||||
|
"value2": "2024-12-31",
|
||||||
|
"modifier": "BETWEEN",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
results = set(PlayEvent.objects.filter(pf.to_q()))
|
||||||
|
assert results == {seeded["early"], seeded["mid"]}
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_started_greater_than(self):
|
||||||
|
from games.filters import PlayEventFilter
|
||||||
|
from games.models import PlayEvent
|
||||||
|
|
||||||
|
seeded = self._seed()
|
||||||
|
pf = PlayEventFilter.from_json(
|
||||||
|
{"started": {"value": "2024-06-01", "modifier": "GREATER_THAN"}}
|
||||||
|
)
|
||||||
|
results = set(PlayEvent.objects.filter(pf.to_q()))
|
||||||
|
assert results == {seeded["late"]}
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_ended_less_than(self):
|
||||||
|
from games.filters import PlayEventFilter
|
||||||
|
from games.models import PlayEvent
|
||||||
|
|
||||||
|
seeded = self._seed()
|
||||||
|
pf = PlayEventFilter.from_json(
|
||||||
|
{"ended": {"value": "2024-06-30", "modifier": "LESS_THAN"}}
|
||||||
|
)
|
||||||
|
results = set(PlayEvent.objects.filter(pf.to_q()))
|
||||||
|
assert results == {seeded["early"]}
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_playevent_filter_json_round_trip(self):
|
||||||
|
"""PlayEventFilter started/ended survive json → object → json,
|
||||||
|
confirming DateCriterion is dispatched by from_json (not
|
||||||
|
StringCriterion)."""
|
||||||
|
from games.filters import PlayEventFilter
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"started": {"value": "2024-01-01", "modifier": "GREATER_THAN"},
|
||||||
|
"ended": {
|
||||||
|
"value": "2024-01-01",
|
||||||
|
"value2": "2024-12-31",
|
||||||
|
"modifier": "BETWEEN",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
pf = PlayEventFilter.from_json(payload)
|
||||||
|
assert isinstance(pf.started, DateCriterion)
|
||||||
|
assert isinstance(pf.ended, DateCriterion)
|
||||||
|
out = pf.to_json()
|
||||||
|
assert out["ended"]["value"] == "2024-01-01"
|
||||||
|
assert out["ended"]["value2"] == "2024-12-31"
|
||||||
|
assert out["ended"]["modifier"] == Modifier.BETWEEN
|
||||||
|
assert out["started"]["modifier"] == Modifier.GREATER_THAN
|
||||||
|
|||||||
@@ -201,6 +201,8 @@ function buildFilterJSON(form: HTMLElement): Record<string, unknown> {
|
|||||||
const dateRangeFields = [
|
const dateRangeFields = [
|
||||||
{ prefix: "filter-date-purchased", key: "date_purchased" },
|
{ prefix: "filter-date-purchased", key: "date_purchased" },
|
||||||
{ prefix: "filter-date-refunded", key: "date_refunded" },
|
{ prefix: "filter-date-refunded", key: "date_refunded" },
|
||||||
|
{ prefix: "filter-started", key: "started" },
|
||||||
|
{ prefix: "filter-ended", key: "ended" },
|
||||||
];
|
];
|
||||||
dateRangeFields.forEach((dateField) => {
|
dateRangeFields.forEach((dateField) => {
|
||||||
const valueMin = stringValue(form, dateField.prefix + "-min");
|
const valueMin = stringValue(form, dateField.prefix + "-min");
|
||||||
|
|||||||
Reference in New Issue
Block a user