feat: migrate playevent_note to StringCriterion and add note string filter to SessionFilterBar
This commit is contained in:
@@ -747,7 +747,8 @@ def FilterBar(
|
|||||||
device_choice = _filter_get_choice(existing, "device")
|
device_choice = _filter_get_choice(existing, "device")
|
||||||
purchase_type_choice = _filter_get_choice(existing, "purchase_type")
|
purchase_type_choice = _filter_get_choice(existing, "purchase_type")
|
||||||
purchase_ownership_choice = _filter_get_choice(existing, "purchase_ownership_type")
|
purchase_ownership_choice = _filter_get_choice(existing, "purchase_ownership_type")
|
||||||
playevent_note_choice = _filter_get_choice(existing, "playevent_note")
|
playevent_note_value = existing.get("playevent_note", {}).get("value", "")
|
||||||
|
playevent_note_modifier = existing.get("playevent_note", {}).get("modifier", "EQUALS")
|
||||||
|
|
||||||
year_min, year_max = _parse_range(existing, "year_released")
|
year_min, year_max = _parse_range(existing, "year_released")
|
||||||
original_year_min, original_year_max = _parse_range(
|
original_year_min, original_year_max = _parse_range(
|
||||||
@@ -874,14 +875,11 @@ def FilterBar(
|
|||||||
),
|
),
|
||||||
_filter_field(
|
_filter_field(
|
||||||
"Playevent Note",
|
"Playevent Note",
|
||||||
FilterSelect(
|
StringFilter(
|
||||||
field_name="playevent_note",
|
input_name_prefix="filter-playevent_note",
|
||||||
included=playevent_note_choice.selected,
|
value=playevent_note_value,
|
||||||
excluded=playevent_note_choice.excluded,
|
modifier=playevent_note_modifier,
|
||||||
modifier=_split_modifier(playevent_note_choice.modifier),
|
placeholder="e.g. Completed, Started",
|
||||||
modifier_options=_modifier_options(nullable=False),
|
|
||||||
free_text=True,
|
|
||||||
placeholder="Type a note substring…",
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_filter_field(
|
_filter_field(
|
||||||
@@ -1085,6 +1083,8 @@ def SessionFilterBar(
|
|||||||
existing = _filter_parse(filter_json)
|
existing = _filter_parse(filter_json)
|
||||||
game_choice = _filter_get_choice(existing, "game")
|
game_choice = _filter_get_choice(existing, "game")
|
||||||
device_choice = _filter_get_choice(existing, "device")
|
device_choice = _filter_get_choice(existing, "device")
|
||||||
|
note_value = existing.get("note", {}).get("value", "")
|
||||||
|
note_modifier = existing.get("note", {}).get("modifier", "EQUALS")
|
||||||
|
|
||||||
dur_tot_min, dur_tot_max = _parse_range(existing, "duration_total_minutes")
|
dur_tot_min, dur_tot_max = _parse_range(existing, "duration_total_minutes")
|
||||||
dur_man_min, dur_man_max = _parse_range(existing, "duration_manual_minutes")
|
dur_man_min, dur_man_max = _parse_range(existing, "duration_manual_minutes")
|
||||||
@@ -1126,6 +1126,15 @@ def SessionFilterBar(
|
|||||||
nullable=Session._meta.get_field("device").null,
|
nullable=Session._meta.get_field("device").null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
_filter_field(
|
||||||
|
"Session Note",
|
||||||
|
StringFilter(
|
||||||
|
input_name_prefix="filter-note",
|
||||||
|
value=note_value,
|
||||||
|
modifier=note_modifier,
|
||||||
|
placeholder="e.g. Boss fight, speedrun",
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
RangeSlider(
|
RangeSlider(
|
||||||
|
|||||||
+6
-23
@@ -88,7 +88,7 @@ class GameFilter(OperatorFilter):
|
|||||||
purchase_ownership_type: ChoiceCriterion | None = None # by ownership
|
purchase_ownership_type: ChoiceCriterion | None = None # by ownership
|
||||||
|
|
||||||
# Cross-entity: substring match against the game's playevent notes
|
# Cross-entity: substring match against the game's playevent notes
|
||||||
playevent_note: ChoiceCriterion | None = None
|
playevent_note: StringCriterion | None = None
|
||||||
|
|
||||||
# Free-text search (combines name + sort_name + platform name)
|
# Free-text search (combines name + sort_name + platform name)
|
||||||
search: StringCriterion | None = None
|
search: StringCriterion | None = None
|
||||||
@@ -407,30 +407,13 @@ class GameFilter(OperatorFilter):
|
|||||||
return Q()
|
return Q()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _playevent_note_to_q(criterion: ChoiceCriterion) -> Q:
|
def _playevent_note_to_q(criterion: StringCriterion) -> Q:
|
||||||
"""Match games by substrings against their playevents' notes.
|
"""Match games by substring / regex / null against their playevents' notes."""
|
||||||
|
|
||||||
Each `value` entry is a substring OR'd into the include side; each
|
|
||||||
`excludes` entry is AND'd as a NOT. Empty lists contribute nothing.
|
|
||||||
"""
|
|
||||||
from games.models import PlayEvent
|
from games.models import PlayEvent
|
||||||
|
|
||||||
q = Q()
|
event_q = criterion.to_q("note")
|
||||||
if criterion.value:
|
matching_ids = PlayEvent.objects.filter(event_q).values_list("game_id", flat=True)
|
||||||
include_q = Q()
|
return Q(id__in=matching_ids)
|
||||||
negate_include = criterion.modifier == Modifier.EXCLUDES
|
|
||||||
for term in criterion.value:
|
|
||||||
matching_ids = PlayEvent.objects.filter(
|
|
||||||
note__icontains=term
|
|
||||||
).values_list("game_id", flat=True)
|
|
||||||
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
|
|
||||||
)
|
|
||||||
q &= ~Q(id__in=matching_ids)
|
|
||||||
return q
|
|
||||||
|
|
||||||
|
|
||||||
# ── SessionFilter ──────────────────────────────────────────────────────────
|
# ── SessionFilter ──────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -105,7 +105,9 @@
|
|||||||
{ name: "filter-price_currency", key: "price_currency" },
|
{ name: "filter-price_currency", key: "price_currency" },
|
||||||
{ name: "filter-converted_currency", key: "converted_currency" },
|
{ name: "filter-converted_currency", key: "converted_currency" },
|
||||||
{ name: "filter-name", key: "name" },
|
{ name: "filter-name", key: "name" },
|
||||||
{ name: "filter-group", key: "group" }
|
{ name: "filter-group", key: "group" },
|
||||||
|
{ name: "filter-playevent_note", key: "playevent_note" },
|
||||||
|
{ name: "filter-note", key: "note" }
|
||||||
];
|
];
|
||||||
textFields.forEach(function (tf) {
|
textFields.forEach(function (tf) {
|
||||||
var modifierEl = form.querySelector('[name="' + tf.name + '-modifier"]:checked');
|
var modifierEl = form.querySelector('[name="' + tf.name + '-modifier"]:checked');
|
||||||
|
|||||||
@@ -240,9 +240,9 @@ class FilterBarRenderingTest(TestCase):
|
|||||||
# New enum selects (purchase type / ownership)
|
# New enum selects (purchase type / ownership)
|
||||||
self.assertIn('data-name="purchase_type"', html)
|
self.assertIn('data-name="purchase_type"', html)
|
||||||
self.assertIn('data-name="purchase_ownership_type"', html)
|
self.assertIn('data-name="purchase_ownership_type"', html)
|
||||||
# Free-text widget for playevent notes
|
# Free-text widget for playevent notes (now StringFilter)
|
||||||
self.assertIn('data-name="playevent_note"', html)
|
self.assertIn('name="filter-playevent_note"', html)
|
||||||
self.assertIn('data-search-select-free-text="true"', html)
|
self.assertIn('name="filter-playevent_note-modifier"', html)
|
||||||
# New range slider input prefixes
|
# New range slider input prefixes
|
||||||
self.assertIn('name="filter-purchase-count-min"', html)
|
self.assertIn('name="filter-purchase-count-min"', html)
|
||||||
self.assertIn('name="filter-playevent-count-min"', html)
|
self.assertIn('name="filter-playevent-count-min"', html)
|
||||||
|
|||||||
@@ -560,7 +560,8 @@ class TestFilterBarRendering:
|
|||||||
|
|
||||||
def test_mastered_not_checked_by_default(self):
|
def test_mastered_not_checked_by_default(self):
|
||||||
html = str(FilterBar(filter_json=""))
|
html = str(FilterBar(filter_json=""))
|
||||||
assert 'checked="true"' not in html
|
assert 'name="filter-mastered" value="true" class="rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand" checked="true"' not in html
|
||||||
|
assert 'name="filter-mastered" value="false" class="rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand" checked="true"' not in html
|
||||||
|
|
||||||
def test_mastered_checked_when_filtered(self):
|
def test_mastered_checked_when_filtered(self):
|
||||||
html = str(
|
html = str(
|
||||||
@@ -1002,7 +1003,7 @@ class TestExpandedFiltersAgainstDB:
|
|||||||
gf = GameFilter.from_json(
|
gf = GameFilter.from_json(
|
||||||
{
|
{
|
||||||
"playevent_note": {
|
"playevent_note": {
|
||||||
"value": [{"id": "Completed", "label": "Completed"}],
|
"value": "Completed",
|
||||||
"modifier": "INCLUDES",
|
"modifier": "INCLUDES",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user