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")
|
||||
purchase_type_choice = _filter_get_choice(existing, "purchase_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")
|
||||
original_year_min, original_year_max = _parse_range(
|
||||
@@ -874,14 +875,11 @@ def FilterBar(
|
||||
),
|
||||
_filter_field(
|
||||
"Playevent Note",
|
||||
FilterSelect(
|
||||
field_name="playevent_note",
|
||||
included=playevent_note_choice.selected,
|
||||
excluded=playevent_note_choice.excluded,
|
||||
modifier=_split_modifier(playevent_note_choice.modifier),
|
||||
modifier_options=_modifier_options(nullable=False),
|
||||
free_text=True,
|
||||
placeholder="Type a note substring…",
|
||||
StringFilter(
|
||||
input_name_prefix="filter-playevent_note",
|
||||
value=playevent_note_value,
|
||||
modifier=playevent_note_modifier,
|
||||
placeholder="e.g. Completed, Started",
|
||||
),
|
||||
),
|
||||
_filter_field(
|
||||
@@ -1085,6 +1083,8 @@ def SessionFilterBar(
|
||||
existing = _filter_parse(filter_json)
|
||||
game_choice = _filter_get_choice(existing, "game")
|
||||
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_man_min, dur_man_max = _parse_range(existing, "duration_manual_minutes")
|
||||
@@ -1126,6 +1126,15 @@ def SessionFilterBar(
|
||||
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(
|
||||
|
||||
+6
-23
@@ -88,7 +88,7 @@ class GameFilter(OperatorFilter):
|
||||
purchase_ownership_type: ChoiceCriterion | None = None # by ownership
|
||||
|
||||
# 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)
|
||||
search: StringCriterion | None = None
|
||||
@@ -407,30 +407,13 @@ class GameFilter(OperatorFilter):
|
||||
return Q()
|
||||
|
||||
@staticmethod
|
||||
def _playevent_note_to_q(criterion: ChoiceCriterion) -> Q:
|
||||
"""Match games by substrings 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.
|
||||
"""
|
||||
def _playevent_note_to_q(criterion: StringCriterion) -> Q:
|
||||
"""Match games by substring / regex / null against their playevents' notes."""
|
||||
from games.models import PlayEvent
|
||||
|
||||
q = Q()
|
||||
if criterion.value:
|
||||
include_q = Q()
|
||||
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
|
||||
event_q = criterion.to_q("note")
|
||||
matching_ids = PlayEvent.objects.filter(event_q).values_list("game_id", flat=True)
|
||||
return Q(id__in=matching_ids)
|
||||
|
||||
|
||||
# ── SessionFilter ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -105,7 +105,9 @@
|
||||
{ name: "filter-price_currency", key: "price_currency" },
|
||||
{ name: "filter-converted_currency", key: "converted_currency" },
|
||||
{ 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) {
|
||||
var modifierEl = form.querySelector('[name="' + tf.name + '-modifier"]:checked');
|
||||
|
||||
@@ -240,9 +240,9 @@ class FilterBarRenderingTest(TestCase):
|
||||
# New enum selects (purchase type / ownership)
|
||||
self.assertIn('data-name="purchase_type"', html)
|
||||
self.assertIn('data-name="purchase_ownership_type"', html)
|
||||
# Free-text widget for playevent notes
|
||||
self.assertIn('data-name="playevent_note"', html)
|
||||
self.assertIn('data-search-select-free-text="true"', html)
|
||||
# Free-text widget for playevent notes (now StringFilter)
|
||||
self.assertIn('name="filter-playevent_note"', html)
|
||||
self.assertIn('name="filter-playevent_note-modifier"', html)
|
||||
# New range slider input prefixes
|
||||
self.assertIn('name="filter-purchase-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):
|
||||
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):
|
||||
html = str(
|
||||
@@ -1002,7 +1003,7 @@ class TestExpandedFiltersAgainstDB:
|
||||
gf = GameFilter.from_json(
|
||||
{
|
||||
"playevent_note": {
|
||||
"value": [{"id": "Completed", "label": "Completed"}],
|
||||
"value": "Completed",
|
||||
"modifier": "INCLUDES",
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user