Align set-criterion modifiers with Stash (any/all/none) and harmonize EXCLUDES

Closes #10.

Backend (common/criteria.py):
- Treat `excludes` as an always-orthogonal AND'd negative across both
  MultiCriterion and ChoiceCriterion; the modifier now governs only the
  `value` (include) set. This removes the prior divergence where
  MultiCriterion.EXCLUDES dropped the excludes list and ChoiceCriterion.EXCLUDES
  swapped include/exclude into a positive.
- Fold INCLUDES / INCLUDES_ALL / EXCLUDES (+ EQUALS/NOT_EQUALS aliases) into the
  shared _SetCriterion base so the two subclasses cannot drift; remove _extra_q.

M2M "has all" (games/filters.py):
- PurchaseFilter._games_to_q builds a pk__in subquery with one join per value so
  INCLUDES_ALL on the many-to-many games field works in a single .filter()
  (a naive Q(games=a) & Q(games=b) collapses to one join and matches nothing).

UI (FilterSelect + filter_bar.js):
- Add an optional any/all/none match-mode <select> (INCLUDES/INCLUDES_ALL/
  EXCLUDES) rendered before the pills via a new `leading` slot on the shared
  combobox shell. A native control so its value is its state. readSearchSelect
  serialises it to data-match; filter_bar folds it into the criterion modifier.
  Orthogonal to the (Any)/(None) presence pseudo-options and the exclude channel.
- Enable it for the M2M Purchase.games field (INCLUDES_ALL is only meaningful
  for multi-valued relations). Styled with already-compiled utilities.

Tests: harmonized EXCLUDES + INCLUDES_ALL for both criterion types, a DB-backed
INCLUDES_ALL vs INCLUDES contrast on Purchase.games, and FilterSelect /
PurchaseFilterBar rendering + round-trip of the match mode.

https://claude.ai/code/session_01KwVrGFbq13mZdhDL9G6zhg
This commit is contained in:
Claude
2026-06-08 20:08:50 +00:00
committed by Lukáš Kucharczyk
parent 05534875d6
commit ba9b92d419
9 changed files with 419 additions and 72 deletions
+50 -2
View File
@@ -92,16 +92,64 @@ class FilterBarRenderingTest(TestCase):
self._assert_shell(html, "/presets/purchases/list", "/presets/purchases/save")
self._assert_range_slider(html)
def test_purchase_filter_bar_games_has_match_modes(self):
"""The many-to-many games field surfaces the any/all/none match select;
single-valued fields (platform) do not."""
html = str(
PurchaseFilterBar(
filter_json="", preset_list_url="/l", preset_save_url="/s"
)
)
self.assertIn("data-search-select-match", html)
self.assertIn('value="INCLUDES_ALL"', html)
# Platform is single-valued: no match select before its widget.
games_start = html.find('data-name="games"')
platform_start = html.find('data-name="platform"')
platform_section = html[platform_start:]
self.assertNotIn("data-search-select-match", platform_section)
self.assertGreater(games_start, 0)
def test_purchase_filter_bar_roundtrips_includes_all(self):
"""A stored INCLUDES_ALL modifier pre-selects the match <option> and the
included game still renders as a pill."""
filter_json = json.dumps(
{
"games": {
"value": [{"id": "5", "label": "Hollow Knight"}],
"modifier": "INCLUDES_ALL",
}
}
)
html = str(
PurchaseFilterBar(
filter_json=filter_json, preset_list_url="/l", preset_save_url="/s"
)
)
self.assertIn('data-match="INCLUDES_ALL"', html)
self.assertIn('value="INCLUDES_ALL" selected=""', html)
self.assertIn("Hollow Knight", html)
self.assertIn('data-search-select-type="include"', html)
self.assertNoEscapedTags(html)
def test_game_filter_bar_roundtrips_selected_status(self):
"""A status in filter_json renders as an include pill in the widget."""
filter_json = json.dumps({"status": {"value": [{"id": "f", "label": "Finished"}], "modifier": "INCLUDES"}})
filter_json = json.dumps(
{
"status": {
"value": [{"id": "f", "label": "Finished"}],
"modifier": "INCLUDES",
}
}
)
html = str(
FilterBar(
filter_json=filter_json, preset_list_url="/l", preset_save_url="/s"
)
)
self.assertIn('data-search-select-mode="filter"', html)
self.assertIn('data-search-select-type="include"', html) # rendered as an include pill
self.assertIn(
'data-search-select-type="include"', html
) # rendered as an include pill
self.assertIn('data-value="f"', html) # selected status reflected in widget
self.assertIn("Finished", html) # ...with its label
self.assertNoEscapedTags(html)
+124 -2
View File
@@ -94,6 +94,18 @@ class TestChoiceCriterion:
q = c.to_q("status")
assert q == Q()
def test_excludes_modifier_keeps_excludes_orthogonal(self):
"""Harmonized (Stash model): under EXCLUDES the ``excludes`` channel stays
an orthogonal AND'd negative — it is *not* swapped into a positive
include (the old divergent ChoiceCriterion behaviour)."""
c = ChoiceCriterion(value=["f"], excludes=["a"], modifier=Modifier.EXCLUDES)
assert c.to_q("status") == ~Q(status__in=["f"]) & ~Q(status__in=["a"])
def test_includes_all(self):
"""INCLUDES_ALL ANDs an equality per value (shared with MultiCriterion)."""
c = ChoiceCriterion(value=["f", "p"], modifier=Modifier.INCLUDES_ALL)
assert c.to_q("status") == Q(status="f") & Q(status="p")
def test_not_equals(self):
c = ChoiceCriterion(value=["f"], modifier=Modifier.NOT_EQUALS)
assert c.to_q("status") == ~Q(status__in=["f"])
@@ -117,6 +129,18 @@ class TestMultiCriterion:
c = MultiCriterion(value=[1], excludes=[2], modifier=Modifier.INCLUDES)
assert c.to_q("game_id") == Q(game_id__in=[1]) & ~Q(game_id__in=[2])
def test_excludes_modifier_applies_excludes_channel(self):
"""Harmonized (Stash model): EXCLUDES negates ``value`` AND still applies
the orthogonal ``excludes`` channel. Previously MultiCriterion.EXCLUDES
dropped the excludes list entirely."""
c = MultiCriterion(value=[1], excludes=[2], modifier=Modifier.EXCLUDES)
assert c.to_q("game_id") == ~Q(game_id__in=[1]) & ~Q(game_id__in=[2])
def test_includes_all(self):
"""INCLUDES_ALL requires the row to relate to every value (M2M)."""
c = MultiCriterion(value=[1, 2], modifier=Modifier.INCLUDES_ALL)
assert c.to_q("games") == Q(games=1) & Q(games=2)
def test_is_null(self):
c = MultiCriterion(value=[], modifier=Modifier.IS_NULL)
assert c.to_q("device_id") == Q(device_id__isnull=True)
@@ -124,7 +148,10 @@ class TestMultiCriterion:
def test_from_json_strips_embedded_labels(self):
"""from_json normalises {id, label} dicts to bare ids."""
c = MultiCriterion.from_json(
{"value": [{"id": 797, "label": "Hollow Knight"}], "excludes": [{"id": 11, "label": "Steam Deck"}]}
{
"value": [{"id": 797, "label": "Hollow Knight"}],
"excludes": [{"id": 11, "label": "Steam Deck"}],
}
)
assert c.value == [797]
assert c.excludes == [11]
@@ -216,6 +243,96 @@ class TestChoiceCriterionAgainstDB:
assert self._count(c) == 0
class TestPurchaseGamesIncludesAllAgainstDB:
"""INCLUDES_ALL on the many-to-many ``Purchase.games`` should match only
purchases linked to *all* of the given games — Stash's ``includes all``."""
def _seed(self):
import datetime
from games.models import Game, Platform, Purchase
platform, _ = Platform.objects.get_or_create(name="Test", icon="test")
a, _ = Game.objects.get_or_create(name="A", defaults={"platform": platform})
b, _ = Game.objects.get_or_create(name="B", defaults={"platform": platform})
c, _ = Game.objects.get_or_create(name="C", defaults={"platform": platform})
def make(linked):
purchase = Purchase.objects.create(
platform=platform, date_purchased=datetime.date(2024, 1, 1)
)
purchase.games.set(linked)
return purchase
return {
"a": a,
"b": b,
"both": make([a, b]),
"only_a": make([a]),
"all_three": make([a, b, c]),
}
@pytest.mark.django_db
def test_includes_all_matches_only_supersets(self):
from games.filters import PurchaseFilter
from games.models import Purchase
seeded = self._seed()
pf = PurchaseFilter.from_json(
{
"games": {
"value": [seeded["a"].id, seeded["b"].id],
"modifier": "INCLUDES_ALL",
}
}
)
result = set(Purchase.objects.filter(pf.to_q()))
assert result == {seeded["both"], seeded["all_three"]}
@pytest.mark.django_db
def test_includes_any_is_broader(self):
"""Contrast: plain INCLUDES (any) also matches the A-only purchase."""
from games.filters import PurchaseFilter
from games.models import Purchase
seeded = self._seed()
pf = PurchaseFilter.from_json(
{
"games": {
"value": [seeded["a"].id, seeded["b"].id],
"modifier": "INCLUDES",
}
}
)
result = set(Purchase.objects.filter(pf.to_q()))
assert result == {seeded["both"], seeded["only_a"], seeded["all_three"]}
@pytest.mark.django_db
def test_includes_all_strips_embedded_labels(self):
"""Stash-style {id, label} value items are normalised to bare ids."""
from common.criteria import Modifier
from games.filters import PurchaseFilter
from games.models import Purchase
seeded = self._seed()
pf = PurchaseFilter.from_json(
{
"games": {
"value": [
{"id": seeded["a"].id, "label": "A"},
{"id": seeded["b"].id, "label": "B"},
],
"modifier": "INCLUDES_ALL",
}
}
)
assert pf.games is not None
assert pf.games.modifier == Modifier.INCLUDES_ALL
assert pf.games.value == [seeded["a"].id, seeded["b"].id]
result = set(Purchase.objects.filter(pf.to_q()))
assert result == {seeded["both"], seeded["all_three"]}
class TestGameFilterFromJson:
def test_status_choice_criterion(self):
gf = GameFilter.from_json(
@@ -293,7 +410,12 @@ class TestFilterBarRendering:
html = str(
FilterBar(
filter_json=json.dumps(
{"status": {"value": [{"id": "f", "label": "Finished"}], "modifier": "INCLUDES"}}
{
"status": {
"value": [{"id": "f", "label": "Finished"}],
"modifier": "INCLUDES",
}
}
),
)
)
+26 -1
View File
@@ -208,7 +208,9 @@ class FilterSelectComponentTest(unittest.TestCase):
panel = html.split("data-search-select-template")[0]
self.assertNotIn('data-search-select-option=""', panel)
self.assertIn('data-search-select-template="row"', html)
self.assertIn('data-search-select-modifier-option="NOT_NULL"', html) # still pinned
self.assertIn(
'data-search-select-modifier-option="NOT_NULL"', html
) # still pinned
self.assertIn('data-prefetch="20"', html)
def test_search_url_pills_use_resolved_labels(self):
@@ -221,6 +223,29 @@ class FilterSelectComponentTest(unittest.TestCase):
self.assertIn(">Obscure Game</span>", html)
self.assertIn('data-value="4172"', html)
MATCH_MODES = [("INCLUDES", "any"), ("INCLUDES_ALL", "all"), ("EXCLUDES", "none")]
def test_match_modes_render_native_select(self):
html = FilterSelect(field_name="games", match_modes=self.MATCH_MODES)
# A native <select> carries the include-set match mode; options are labels.
self.assertIn("data-search-select-match", html)
self.assertIn('value="INCLUDES_ALL"', html)
self.assertIn(">all</option>", html)
# The container exposes the active mode (defaults to the first) for the JS.
self.assertIn('data-match="INCLUDES"', html)
def test_active_match_marks_selected_option(self):
html = FilterSelect(
field_name="games", match="INCLUDES_ALL", match_modes=self.MATCH_MODES
)
self.assertIn('data-match="INCLUDES_ALL"', html)
self.assertIn('value="INCLUDES_ALL" selected=""', html)
def test_no_match_modes_omits_select(self):
html = FilterSelect(field_name="status", options=[("f", "Finished")])
self.assertNotIn("data-search-select-match", html)
self.assertNotIn("data-match=", html)
class SearchLabelTest(django.test.TestCase):
@classmethod