feat: migrate playevent_note to StringCriterion and add note string filter to SessionFilterBar

This commit is contained in:
2026-06-10 18:19:45 +02:00
parent a1304e19ad
commit 64392c3935
5 changed files with 33 additions and 38 deletions
+18 -9
View File
@@ -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
View File
@@ -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 ──────────────────────────────────────────────────────────
+3 -1
View File
@@ -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');
+3 -3
View File
@@ -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)
+3 -2
View File
@@ -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",
} }
} }