From 64392c3935657ef5058c7feb620bb539083772cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Wed, 10 Jun 2026 18:19:45 +0200 Subject: [PATCH] feat: migrate playevent_note to StringCriterion and add note string filter to SessionFilterBar --- common/components/filters.py | 27 ++++++++++++++++++--------- games/filters.py | 29 ++++++----------------------- games/static/js/filter_bar.js | 4 +++- tests/test_filter_bars.py | 6 +++--- tests/test_filters.py | 5 +++-- 5 files changed, 33 insertions(+), 38 deletions(-) diff --git a/common/components/filters.py b/common/components/filters.py index 235931f..20e4bb3 100644 --- a/common/components/filters.py +++ b/common/components/filters.py @@ -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( diff --git a/games/filters.py b/games/filters.py index ca44943..f6614c0 100644 --- a/games/filters.py +++ b/games/filters.py @@ -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 ────────────────────────────────────────────────────────── diff --git a/games/static/js/filter_bar.js b/games/static/js/filter_bar.js index 7d61959..53696e3 100644 --- a/games/static/js/filter_bar.js +++ b/games/static/js/filter_bar.js @@ -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'); diff --git a/tests/test_filter_bars.py b/tests/test_filter_bars.py index 3ba963f..18a7814 100644 --- a/tests/test_filter_bars.py +++ b/tests/test_filter_bars.py @@ -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) diff --git a/tests/test_filters.py b/tests/test_filters.py index e358746..e6359cb 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -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", } }