Compare commits

..

8 Commits

Author SHA1 Message Date
lukas 1c9fb474df Unify UI for filter modifiers
Django CI/CD / test (push) Successful in 40s
Django CI/CD / build-and-push (push) Successful in 1m16s
2026-06-09 08:47:20 +02:00
lukas 737dd9275b Fix includes all query returning duplicates 2026-06-09 08:47:20 +02:00
lukas 9f436b245d Decrease debounce for search select 2026-06-09 08:47:20 +02:00
lukas 7ebaa51eb0 Ignore .hermes folder 2026-06-09 08:47:20 +02:00
lukas a7ff2962a6 Add number of games filter to purchases 2026-06-09 08:47:20 +02:00
lukas 103219a5e7 Add includes only matcher mode 2026-06-09 08:47:20 +02:00
lukas 14efff8078 Fix filter stuff 2026-06-09 08:47:20 +02:00
Claude ba9b92d419 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
2026-06-09 08:47:20 +02:00
10 changed files with 872 additions and 103 deletions
+1
View File
@@ -12,3 +12,4 @@ dist/
.DS_Store .DS_Store
.python-version .python-version
.direnv .direnv
.hermes/
+91 -11
View File
@@ -93,46 +93,101 @@ def _parse_bool(existing: dict, key: str) -> bool:
_FILTER_PREFETCH = 20 _FILTER_PREFETCH = 20
# Presence modifiers drive the pinned (Any)/(None) pseudo-options. They are
# mutually exclusive with value pills (selecting one clears the value set).
# Must match JS PRESENCE_MODIFIERS in search_select.js.
_PRESENCE_MODIFIERS = frozenset({"NOT_NULL", "IS_NULL"})
def _modifier_options(nullable: bool) -> list[LabeledOption]: # M2M-only modifiers surfaced as additional pseudo-options in the dropdown.
"""Pinned (Any)/(None) pseudo-options; (None) only when the field is nullable.""" # "any" (INCLUDES) is the implicit default when neither a presence nor an
options = [("NOT_NULL", "(Any)")] # M2M modifier is set — no dedicated row needed. "none" (EXCLUDES) is
# redundant with individual exclude (✗) pills. Only INCLUDES_ALL and
# INCLUDES_ONLY can't be expressed through pills alone, so they are the
# only M2M modifiers with explicit UI.
_M2M_MODIFIERS: list[LabeledOption] = [
("INCLUDES_ALL", "(All)"),
("INCLUDES_ONLY", "(Only)"),
]
def _modifier_options(
nullable: bool, m2m_modifiers: list[LabeledOption] | None = None
) -> list[LabeledOption]:
"""Pinned pseudo-options rendered at the top of the dropdown.
Always includes ``(Any)`` (NOT_NULL); adds ``(None)`` (IS_NULL) when
``nullable`` is True. When ``m2m_modifiers`` is given (M2M fields only),
appends those rows (e.g. ``(All)`` / ``(Only)``)."""
options: list[LabeledOption] = [("NOT_NULL", "(Any)")]
if nullable: if nullable:
options.append(("IS_NULL", "(None)")) options.append(("IS_NULL", "(None)"))
if m2m_modifiers:
options.extend(m2m_modifiers)
return options return options
def _split_modifier(modifier: str, has_m2m: bool = False) -> str:
"""Return the modifier value to surface as the modifier pill.
Presence modifiers (NOT_NULL / IS_NULL) are always surfaced. Non-presence
modifiers (INCLUDES / INCLUDES_ALL / INCLUDES_ONLY) only need a pill on M2M
fields — otherwise the modifier is just the implicit default.
"""
if modifier in _PRESENCE_MODIFIERS or not has_m2m:
return modifier
if modifier:
return modifier
return ""
def _enum_filter( def _enum_filter(
field_name: str, options, choice: FilterChoice, *, nullable field_name: str, options, choice: FilterChoice, *, nullable
) -> SafeText: ) -> SafeText:
"""A FilterSelect over a small, fully pre-rendered option set (enum field).""" """A FilterSelect over a small, fully pre-rendered option set (enum field).
Enum fields are single-valued, so no M2M modifiers (all/only are
meaningless); only the presence modifier is surfaced.
"""
options_str = [(str(value), label) for value, label in options] options_str = [(str(value), label) for value, label in options]
included = [(value, _find_label(options_str, value)) for value, _label in choice.selected] included = [
excluded = [(value, _find_label(options_str, value)) for value, _label in choice.excluded] (value, _find_label(options_str, value)) for value, _label in choice.selected
]
excluded = [
(value, _find_label(options_str, value)) for value, _label in choice.excluded
]
modifier = _split_modifier(choice.modifier)
return FilterSelect( return FilterSelect(
field_name=field_name, field_name=field_name,
options=options_str, options=options_str,
included=included, included=included,
excluded=excluded, excluded=excluded,
modifier=choice.modifier, modifier=modifier,
modifier_options=_modifier_options(nullable), modifier_options=_modifier_options(nullable),
) )
def _model_filter( def _model_filter(
field_name: str, choice: FilterChoice, *, search_url, nullable field_name: str,
choice: FilterChoice,
*,
search_url,
nullable,
m2m_modifiers: list[LabeledOption] | None = None,
) -> SafeText: ) -> SafeText:
"""A FilterSelect backed by a search endpoint. """A FilterSelect backed by a search endpoint.
Labels are embedded in the filter JSON (Stash-style), so pills render Labels are embedded in the filter JSON (Stash-style), so pills render
directly from ``choice`` with no DB round-trip. directly from ``choice`` with no DB round-trip. Pass ``m2m_modifiers`` for
many-to-many fields to surface ``(All)`` / ``(Only)`` pseudo-options in the
dropdown alongside the presence options.
""" """
modifier = _split_modifier(choice.modifier, has_m2m=bool(m2m_modifiers))
return FilterSelect( return FilterSelect(
field_name=field_name, field_name=field_name,
included=[(value, label or value) for value, label in choice.selected], included=[(value, label or value) for value, label in choice.selected],
excluded=[(value, label or value) for value, label in choice.excluded], excluded=[(value, label or value) for value, label in choice.excluded],
modifier=choice.modifier, modifier=modifier,
modifier_options=_modifier_options(nullable), modifier_options=_modifier_options(nullable, m2m_modifiers),
search_url=search_url, search_url=search_url,
prefetch=_FILTER_PREFETCH, prefetch=_FILTER_PREFETCH,
) )
@@ -792,6 +847,16 @@ def PurchaseFilterBar(
except Exception: except Exception:
price_range_min, price_range_max = 0, 100 price_range_min, price_range_max = 0, 100
num_min, num_max = _parse_range(existing, "num_purchases")
try:
num_aggregate = Purchase.objects.aggregate(
num_min=models.Min("num_purchases"), num_max=models.Max("num_purchases")
)
num_range_min = max(int(num_aggregate.get("num_min") or 0), 0)
num_range_max = max(int(num_aggregate.get("num_max") or 10), 1)
except Exception:
num_range_min, num_range_max = 0, 10
fields = [ fields = [
Component( Component(
tag_name="div", tag_name="div",
@@ -804,6 +869,10 @@ def PurchaseFilterBar(
game_choice, game_choice,
search_url="/api/games/search", search_url="/api/games/search",
nullable=False, nullable=False,
# games is many-to-many on Purchase: (All) means
# INCLUDES_ALL ("purchase linked to every selected
# game"); (Only) means INCLUDES_ONLY.
m2m_modifiers=_M2M_MODIFIERS,
), ),
), ),
_filter_field( _filter_field(
@@ -854,5 +923,16 @@ def PurchaseFilterBar(
min_placeholder="0.00", min_placeholder="0.00",
max_placeholder="100.00", max_placeholder="100.00",
), ),
RangeSlider(
label="Games in purchase",
input_name_prefix="filter-num-purchases",
min_value=num_min,
max_value=num_max,
range_min=num_range_min,
range_max=num_range_max,
step="1",
min_placeholder="e.g. 1",
max_placeholder="e.g. 5",
),
] ]
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url) return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
+13 -9
View File
@@ -191,10 +191,8 @@ def _combobox_shell(
children=[*options_children, no_results], children=[*options_children, no_results],
) )
return Div( children: list[SafeText] = [pills, search, options_panel, *(templates or [])]
attributes=container_attributes, return Div(attributes=container_attributes, children=children)
children=[pills, search, options_panel, *(templates or [])],
)
def SearchSelect( def SearchSelect(
@@ -417,9 +415,12 @@ def FilterSelect(
Like ``SearchSelect`` but each value row carries +/ buttons that add an Like ``SearchSelect`` but each value row carries +/ buttons that add an
*include* (✓) or *exclude* (✗) pill, plus an optional set of pinned *include* (✓) or *exclude* (✗) pill, plus an optional set of pinned
``modifier_options`` (e.g. ``[("NOT_NULL", "(Any)"), ("IS_NULL", "(None)")]``) ``modifier_options`` (e.g. ``[("NOT_NULL", "(Any)"), ("IS_NULL", "(None)")]``)
rendered above the value rows. A selected modifier is mutually exclusive with rendered above the value rows. Presence modifiers (NOT_NULL / IS_NULL) are
value pills. State is read from the DOM into the filter JSON by mutually exclusive with value pills. Non-presence modifiers (INCLUDES_ALL /
``readSearchSelect`` (filter mode) — nothing is submitted by ``name``. INCLUDES_ONLY) coexist with value pills — they govern how the include set
matches and are only surfaced for many-to-many fields. State is read from
the DOM into the filter JSON by ``readSearchSelect`` (filter mode) — nothing
is submitted by ``name``.
``included``/``excluded`` are resolved options (value + label) so pills show ``included``/``excluded`` are resolved options (value + label) so pills show
labels even when the value rows come from ``search_url``. ``options`` labels even when the value rows come from ``search_url``. ``options``
@@ -436,11 +437,14 @@ def FilterSelect(
active_modifier_label = label active_modifier_label = label
break break
# ── Pills: a lone modifier pill, or include/exclude value pills ── # ── Pills: modifier pill (if active), then include/exclude value pills ──
# Presence modifiers (NOT_NULL / IS_NULL) are mutually exclusive with value
# pills — but the stored state guarantees they never coexist, so we render
# both channels unconditionally. Non-presence modifiers (INCLUDES_ALL /
# INCLUDES_ONLY) coexist with value pills and render side by side.
pills_children: list[SafeText] = [] pills_children: list[SafeText] = []
if active_modifier_label: if active_modifier_label:
pills_children.append(_filter_modifier_pill(modifier, active_modifier_label)) pills_children.append(_filter_modifier_pill(modifier, active_modifier_label))
else:
for option in included: for option in included:
pills_children.append(_filter_value_pill(option, "include")) pills_children.append(_filter_value_pill(option, "include"))
for option in excluded: for option in excluded:
+60 -49
View File
@@ -30,6 +30,7 @@ class Modifier(str, Enum):
INCLUDES = "INCLUDES" INCLUDES = "INCLUDES"
EXCLUDES = "EXCLUDES" EXCLUDES = "EXCLUDES"
INCLUDES_ALL = "INCLUDES_ALL" INCLUDES_ALL = "INCLUDES_ALL"
INCLUDES_ONLY = "INCLUDES_ONLY"
IS_NULL = "IS_NULL" IS_NULL = "IS_NULL"
NOT_NULL = "NOT_NULL" NOT_NULL = "NOT_NULL"
MATCHES_REGEX = "MATCHES_REGEX" MATCHES_REGEX = "MATCHES_REGEX"
@@ -71,6 +72,7 @@ class Modifier(str, Enum):
cls.INCLUDES, cls.INCLUDES,
cls.EXCLUDES, cls.EXCLUDES,
cls.INCLUDES_ALL, cls.INCLUDES_ALL,
cls.INCLUDES_ONLY,
cls.IS_NULL, cls.IS_NULL,
cls.NOT_NULL, cls.NOT_NULL,
] ]
@@ -271,17 +273,26 @@ class _SetCriterion(_Criterion):
"""Shared base for set-membership criteria (``MultiCriterion`` / """Shared base for set-membership criteria (``MultiCriterion`` /
``ChoiceCriterion``). ``ChoiceCriterion``).
``value`` is the include set and ``excludes`` the exclude set. The common Two orthogonal channels, mirroring Stash's modifier model:
modifiers are implemented once here so the two subclasses cannot drift:
- ``INCLUDES`` — in ``value`` (when non-empty) AND not in ``excludes`` (when - ``value`` is the *include* set. The ``modifier`` governs how it matches:
non-empty). Empty lists contribute no constraint, so an exclude-only
criterion means "everything except ``excludes``".
- ``EQUALS`` — alias of ``INCLUDES``.
- ``IS_NULL`` / ``NOT_NULL`` — presence; the lists are ignored.
Subclasses contribute their own modifiers (e.g. ``INCLUDES_ALL``) by - ``INCLUDES`` — in ``value`` (match *any*); ``EQUALS`` is an alias.
overriding ``_extra_q``. - ``INCLUDES_ALL`` — related to *all* of ``value`` (meaningful for
many-to-many fields, e.g. a purchase's games).
- ``EXCLUDES`` — in none of ``value`` (match *none*); ``NOT_EQUALS`` is an
alias.
- ``excludes`` is an *always-orthogonal* negative: it contributes
``AND NOT IN (excludes)`` for every (non-presence) modifier, never
swapped into the include set. An exclude-only criterion therefore means
"everything except ``excludes``".
Empty lists contribute no constraint. ``IS_NULL`` / ``NOT_NULL`` test
presence and ignore both lists.
The logic lives entirely here so the two subclasses (which differ only in
their value type) cannot drift.
""" """
value: list = field(default_factory=list) value: list = field(default_factory=list)
@@ -290,25 +301,38 @@ class _SetCriterion(_Criterion):
def to_q(self, field_name: str) -> Q: def to_q(self, field_name: str) -> Q:
modifier = self.modifier modifier = self.modifier
if modifier in (Modifier.INCLUDES, Modifier.EQUALS):
q = Q()
if self.value:
q &= Q(**{f"{field_name}__in": self.value})
if self.excludes:
q &= ~Q(**{f"{field_name}__in": self.excludes})
return q
if modifier == Modifier.IS_NULL: if modifier == Modifier.IS_NULL:
return Q(**{f"{field_name}__isnull": True}) return Q(**{f"{field_name}__isnull": True})
if modifier == Modifier.NOT_NULL: if modifier == Modifier.NOT_NULL:
return Q(**{f"{field_name}__isnull": False}) return Q(**{f"{field_name}__isnull": False})
extra = self._extra_q(field_name) # The modifier governs only the include set; ``excludes`` is an orthogonal
if extra is not None: # AND'd negative applied for every (non-presence) modifier.
return extra q = self._value_q(field_name)
raise ValueError(f"Unsupported modifier {modifier} for {type(self).__name__}") if self.excludes:
q &= ~Q(**{f"{field_name}__in": self.excludes})
return q
def _extra_q(self, field_name: str) -> Q | None: def _value_q(self, field_name: str) -> Q:
"""Hook for subclass-specific modifiers; ``None`` means unsupported.""" """Build the Q for the include (``value``) set, per the modifier."""
return None modifier = self.modifier
if modifier in (Modifier.INCLUDES, Modifier.EQUALS):
return Q(**{f"{field_name}__in": self.value}) if self.value else Q()
if modifier in (Modifier.EXCLUDES, Modifier.NOT_EQUALS):
return ~Q(**{f"{field_name}__in": self.value}) if self.value else Q()
if modifier in (Modifier.INCLUDES_ALL, Modifier.INCLUDES_ONLY):
# INCLUDES_ALL ("related to all of these") and INCLUDES_ONLY
# ("related to exactly these, nothing else") are only meaningful
# for many-to-many fields. A naive Q(field=a) & Q(field=b)
# collapses to a single join requiring one through-row to equal
# both values (impossible), so the generic criterion layer cannot
# build a correct Q. M2M callers must supply their own Q builder
# at the filter level — see PurchaseFilter._games_to_q for the
# chained-subquery pattern.
assert False, (
f"{modifier} requires a filter-level Q builder for M2M fields. "
"See PurchaseFilter._games_to_q for the chained-subquery pattern."
)
raise ValueError(f"Unsupported modifier {modifier} for {type(self).__name__}")
@classmethod @classmethod
def from_json(cls, data: dict | None) -> Self | None: def from_json(cls, data: dict | None) -> Self | None:
@@ -317,51 +341,38 @@ class _SetCriterion(_Criterion):
return None return None
# Labels embedded as {id, label} dicts are display-only; strip to bare ids # Labels embedded as {id, label} dicts are display-only; strip to bare ids
# so the querying layer stays clean and typed. # so the querying layer stays clean and typed.
result.value = [item["id"] if isinstance(item, dict) else item for item in result.value] result.value = [
result.excludes = [item["id"] if isinstance(item, dict) else item for item in result.excludes] item["id"] if isinstance(item, dict) else item for item in result.value
]
result.excludes = [
item["id"] if isinstance(item, dict) else item for item in result.excludes
]
return result return result
@dataclass @dataclass
class MultiCriterion(_SetCriterion): class MultiCriterion(_SetCriterion):
"""Filter on a many-to-many or ForeignKey relationship by ID list.""" """Filter on a many-to-many or ForeignKey relationship by ID list.
All modifier logic (including ``INCLUDES_ALL`` and ``EXCLUDES``) lives in
``_SetCriterion``; this subclass only refines the value type.
"""
value: list[int] = field(default_factory=list) value: list[int] = field(default_factory=list)
excludes: list[int] = field(default_factory=list) excludes: list[int] = field(default_factory=list)
def _extra_q(self, field_name: str) -> Q | None:
if self.modifier == Modifier.EXCLUDES:
return ~Q(**{f"{field_name}__in": self.value})
if self.modifier == Modifier.INCLUDES_ALL:
q = Q()
for value in self.value:
q &= Q(**{field_name: value})
return q
return None
@dataclass @dataclass
class ChoiceCriterion(_SetCriterion): class ChoiceCriterion(_SetCriterion):
"""Filter on a choice/enum field with multi-select include/exclude. """Filter on a choice/enum field with multi-select include/exclude.
Used by FilterSelect widgets for status, ownership_type, etc. Used by FilterSelect widgets for status, ownership_type, etc. Shares all
modifier logic with ``MultiCriterion`` via ``_SetCriterion``.
""" """
value: list[str] = field(default_factory=list) value: list[str] = field(default_factory=list)
excludes: list[str] = field(default_factory=list) excludes: list[str] = field(default_factory=list)
def _extra_q(self, field_name: str) -> Q | None:
if self.modifier == Modifier.EXCLUDES:
q = Q()
if self.value:
q &= ~Q(**{f"{field_name}__in": self.value})
if self.excludes:
q &= Q(**{f"{field_name}__in": self.excludes})
return q
if self.modifier == Modifier.NOT_EQUALS:
return ~Q(**{f"{field_name}__in": self.value})
return None
# ── OperatorFilter base ──────────────────────────────────────────────────── # ── OperatorFilter base ────────────────────────────────────────────────────
+57 -1
View File
@@ -331,7 +331,7 @@ class PurchaseFilter(OperatorFilter):
if self.platform is not None: if self.platform is not None:
q &= self.platform.to_q("platform_id") q &= self.platform.to_q("platform_id")
if self.games is not None: if self.games is not None:
q &= self.games.to_q("games") q &= self._games_to_q(self.games)
if self.date_purchased is not None: if self.date_purchased is not None:
q &= self.date_purchased.to_q("date_purchased") q &= self.date_purchased.to_q("date_purchased")
if self.date_refunded is not None: if self.date_refunded is not None:
@@ -385,6 +385,62 @@ class PurchaseFilter(OperatorFilter):
return q return q
@staticmethod
def _games_to_q(criterion: ChoiceCriterion) -> Q:
"""Build the Q for the many-to-many ``games`` field.
``INCLUDES_ALL`` ("related to every selected game") and
``INCLUDES_ONLY`` ("related to exactly these, nothing else") cannot be
a single ``.filter(Q(games=a) & Q(games=b))`` — that collapses to one
join and would require a single link row to be both games. Instead
chain a filter per game so each gets its own join, then match by
``pk``. ``INCLUDES_ONLY`` additionally excludes purchases that have
any game outside the specified set.
``INCLUDES`` (plain "any") also uses a subquery instead of a raw
``games__in`` join because a single purchase linked to *n* of the
given games would appear *n* times in the result set (M2M join
duplicates).
The orthogonal ``excludes`` channel is applied as a negative,
consistent with every other modifier. All other modifiers delegate
to the criterion.
"""
# Empty value means no constraint; still apply excludes if any
if not criterion.value:
if criterion.excludes:
return ~Q(games__in=criterion.excludes)
return Q()
from games.models import Game, Purchase
if criterion.modifier in (Modifier.INCLUDES_ALL, Modifier.INCLUDES_ONLY):
subquery = Purchase.objects.all()
for game_id in criterion.value:
subquery = subquery.filter(games=game_id)
if criterion.modifier == Modifier.INCLUDES_ONLY:
extra_ids = Game.objects.exclude(
id__in=criterion.value
).values_list("id", flat=True)
if extra_ids:
subquery = subquery.exclude(games__in=extra_ids)
q = Q(pk__in=subquery.values("pk"))
if criterion.excludes:
q &= ~Q(games__in=criterion.excludes)
return q
if criterion.modifier == Modifier.INCLUDES:
# Use subquery to avoid duplicate rows from M2M join
subquery = Purchase.objects.filter(games__in=criterion.value)
q = Q(pk__in=subquery.values("pk"))
if criterion.excludes:
q &= ~Q(games__in=criterion.excludes)
return q
return criterion.to_q("games")
# ── Convenience helpers ──────────────────────────────────────────────────── # ── Convenience helpers ────────────────────────────────────────────────────
+18 -1
View File
@@ -67,8 +67,14 @@
var field = widget.getAttribute("data-name"); var field = widget.getAttribute("data-name");
var included = parseJSONAttr(widget, "data-included"); var included = parseJSONAttr(widget, "data-included");
var excluded = parseJSONAttr(widget, "data-excluded"); var excluded = parseJSONAttr(widget, "data-excluded");
// Two orthogonal axes: a presence modifier (NOT_NULL/IS_NULL) from the
// pinned (Any)/(None) pseudo-options clears the value set and has no
// values; the non-presence modifier (INCLUDES_ALL/INCLUDES_ONLY) governs
// how the include set matches. When neither is set the implicit default
// is INCLUDES ("any"). Must match Python _PRESENCE_MODIFIERS.
var modifier = widget.getAttribute("data-modifier"); var modifier = widget.getAttribute("data-modifier");
if (modifier === "NOT_NULL" || modifier === "IS_NULL") { var IS_PRESENCE = modifier === "NOT_NULL" || modifier === "IS_NULL";
if (IS_PRESENCE) {
filter[field] = { modifier: modifier }; filter[field] = { modifier: modifier };
} else if (included.length > 0 || excluded.length > 0) { } else if (included.length > 0 || excluded.length > 0) {
// All filter pills carry {id, label}; store them as-is so the filter // All filter pills carry {id, label}; store them as-is so the filter
@@ -123,6 +129,17 @@
} }
} }
// ── Purchase-specific: num_purchases ──
var numGamesMin = numberValue(form, "filter-num-purchases-min");
var numGamesMax = numberValue(form, "filter-num-purchases-max");
if (numGamesMin !== "" && numGamesMax !== "") {
filter.num_purchases = criterion(parseInt(numGamesMin, 10), parseInt(numGamesMax, 10), "BETWEEN");
} else if (numGamesMin !== "") {
filter.num_purchases = criterion(parseInt(numGamesMin, 10), null, "GREATER_THAN");
} else if (numGamesMax !== "") {
filter.num_purchases = criterion(parseInt(numGamesMax, 10), null, "LESS_THAN");
}
if (mastered && mastered.checked) { if (mastered && mastered.checked) {
filter.mastered = criterion(true, null, "EQUALS"); filter.mastered = criterion(true, null, "EQUALS");
} }
+145 -12
View File
@@ -24,7 +24,13 @@
(function () { (function () {
"use strict"; "use strict";
var DEBOUNCE_MS = 500; var DEBOUNCE_MS = 100;
// Must match Python common/components/filters.py:_PRESENCE_MODIFIERS.
// These modifiers are mutually exclusive with value pills — selecting
// one clears all value pills. Non-presence modifiers (INCLUDES_ALL,
// INCLUDES_ONLY) coexist with value pills.
var PRESENCE_MODIFIERS = ["NOT_NULL", "IS_NULL"];
function initAll() { function initAll() {
document.querySelectorAll("[data-search-select]").forEach(function (element) { document.querySelectorAll("[data-search-select]").forEach(function (element) {
@@ -65,6 +71,60 @@
if (noResults) noResults.classList.toggle("hidden", !visible); if (noResults) noResults.classList.toggle("hidden", !visible);
} }
// ── Highlight tracking (filter mode) ──
var highlightedRow = null;
function highlightOption(row) {
clearHighlight();
if (!row) return;
row.style.backgroundColor = "var(--color-brand, rgba(59, 130, 246, 0.15))";
row.style.outline = "1px solid var(--color-brand, #3b82f6)";
highlightedRow = row;
row.scrollIntoView({ block: "nearest" });
}
function clearHighlight() {
if (highlightedRow) {
highlightedRow.style.backgroundColor = "";
highlightedRow.style.outline = "";
highlightedRow = null;
}
}
function getVisibleOptions() {
var all = options.querySelectorAll("[data-search-select-option]");
return Array.prototype.filter.call(all, function (row) {
return row.style.display !== "none";
});
}
function autoHighlight(query) {
var visible = getVisibleOptions();
if (visible.length === 0) {
clearHighlight();
return;
}
var lower = query.toLowerCase();
// 1. Starts-with match
for (var i = 0; i < visible.length; i++) {
var label = (visible[i].getAttribute("data-label") || "").toLowerCase();
if (lower && label.startsWith(lower)) {
highlightOption(visible[i]);
return;
}
}
// 2. Substring match (fuzzy-lite)
for (var j = 0; j < visible.length; j++) {
var subLabel = (visible[j].getAttribute("data-label") || "").toLowerCase();
if (lower && subLabel.indexOf(lower) !== -1) {
highlightOption(visible[j]);
return;
}
}
// 3. Fallback: first visible option
highlightOption(visible[0]);
}
// ── Render server-fetched rows into the panel ── // ── Render server-fetched rows into the panel ──
function renderRows(items) { function renderRows(items) {
options.querySelectorAll("[data-search-select-option]").forEach(function (row) { options.querySelectorAll("[data-search-select-option]").forEach(function (row) {
@@ -140,6 +200,7 @@
renderRows(items); renderRows(items);
// Re-apply the live query: the box may hold more text than was sent. // Re-apply the live query: the box may hold more text than was sent.
setNoResults(filterRows(search.value.trim()) === 0); setNoResults(filterRows(search.value.trim()) === 0);
if (isFilter) autoHighlight(search.value.trim());
}) })
.catch(function (error) { .catch(function (error) {
if (error && error.name === "AbortError") return; // superseded if (error && error.name === "AbortError") return; // superseded
@@ -166,6 +227,7 @@
} else { } else {
setNoResults(filterRows(query) === 0); setNoResults(filterRows(query) === 0);
} }
if (isFilter) autoHighlight(query);
} }
// ── Single-select combobox: the search box shows the committed label; // ── Single-select combobox: the search box shows the committed label;
@@ -188,12 +250,15 @@
// Show whatever is already loaded; the server decides no-results. // Show whatever is already loaded; the server decides no-results.
filterRows(search.value.trim()); filterRows(search.value.trim());
setNoResults(false); setNoResults(false);
if (isFilter) autoHighlight(search.value.trim());
} }
} else { } else {
setNoResults(filterRows(search.value.trim()) === 0); setNoResults(filterRows(search.value.trim()) === 0);
if (isFilter) autoHighlight(search.value.trim());
} }
}); });
search.addEventListener("input", function () { search.addEventListener("input", function () {
clearHighlight();
if (!multi) container._searchSelectDirty = true; if (!multi) container._searchSelectDirty = true;
runSearch(); runSearch();
}); });
@@ -215,6 +280,45 @@
}); });
} }
// ── Keyboard navigation (filter mode) ──
search.addEventListener("keydown", function (event) {
if (!isFilter) return;
var key = event.key;
if (key === "ArrowDown" || key === "ArrowUp" || key === "Enter" || key === "Escape") {
var visible = getVisibleOptions();
if (visible.length === 0) {
if (key === "Escape") hidePanel();
return;
}
if (key === "ArrowDown") {
event.preventDefault();
showPanel();
var idx = highlightedRow ? visible.indexOf(highlightedRow) : -1;
var next = visible[(idx + 1) % visible.length];
highlightOption(next);
} else if (key === "ArrowUp") {
event.preventDefault();
showPanel();
var idx = highlightedRow ? visible.indexOf(highlightedRow) : -1;
var prev = visible[(idx - 1 + visible.length) % visible.length];
highlightOption(prev);
} else if (key === "Enter") {
if (highlightedRow) {
event.preventDefault();
var option = optionFromRow(highlightedRow);
addFilterPill(option, "include");
search.value = "";
clearHighlight();
hidePanel();
}
} else if (key === "Escape") {
clearHighlight();
hidePanel();
}
}
});
// Clicking an option must not blur the input before the click selects. // Clicking an option must not blur the input before the click selects.
options.addEventListener("mousedown", function (event) { options.addEventListener("mousedown", function (event) {
event.preventDefault(); event.preventDefault();
@@ -243,21 +347,38 @@
} }
// Include / exclude button on a value row. // Include / exclude button on a value row.
var button = event.target.closest("[data-search-select-action]"); var button = event.target.closest("[data-search-select-action]");
if (!button) return; if (button) {
var row = button.closest("[data-search-select-option]"); var row = button.closest("[data-search-select-option]");
if (!row) return; if (!row) return;
addFilterPill(optionFromRow(row), button.getAttribute("data-search-select-action")); addFilterPill(optionFromRow(row), button.getAttribute("data-search-select-action"));
return;
}
// Click on the option row itself → include.
var optionRow = event.target.closest("[data-search-select-option]");
if (optionRow) {
addFilterPill(optionFromRow(optionRow), "include");
return;
}
} }
// Add (or re-type) an include/exclude pill for a value. Selecting any value // Add (or re-type) an include/exclude pill for a value. Selecting any value
// clears an active modifier — the two are mutually exclusive. // clears a presence modifier — NOT_NULL / IS_NULL are mutually exclusive
// with value pills. Non-presence modifiers (INCLUDES_ALL / INCLUDES_ONLY)
// persist alongside value pills.
function addFilterPill(option, kind) { function addFilterPill(option, kind) {
var modPill = pills.querySelector("[data-search-select-modifier]");
if (modPill) {
var modVal = modPill.getAttribute("data-search-select-modifier");
if (PRESENCE_MODIFIERS.indexOf(modVal) !== -1) {
clearModifier(); clearModifier();
}
}
var existing = pills.querySelector( var existing = pills.querySelector(
'[data-pill][data-value="' + cssEscape(option.value) + '"]' '[data-pill][data-value="' + cssEscape(option.value) + '"]'
); );
if (existing) existing.remove(); if (existing) existing.remove();
pills.appendChild(buildFilterValuePill(option, kind)); pills.appendChild(buildFilterValuePill(option, kind));
search.value = "";
emitChange(null); emitChange(null);
} }
@@ -270,24 +391,36 @@
return pill; return pill;
} }
// Set the lone modifier pill, clearing all value pills (mutual exclusivity). // Set the modifier pill. Presence modifiers (NOT_NULL / IS_NULL) clear all
// value pills — they are mutually exclusive. Non-presence modifiers
// (INCLUDES_ALL / INCLUDES_ONLY) are prepended before existing value pills.
function setModifier(modifierValue, label) { function setModifier(modifierValue, label) {
// Remove any existing modifier pill to avoid duplicates.
clearModifierPill();
if (PRESENCE_MODIFIERS.indexOf(modifierValue) !== -1) {
pills.innerHTML = ""; pills.innerHTML = "";
}
var pill = cloneTemplate("pill-modifier"); var pill = cloneTemplate("pill-modifier");
pill.setAttribute("data-search-select-modifier", modifierValue); pill.setAttribute("data-search-select-modifier", modifierValue);
setLabel(pill, label); setLabel(pill, label);
pills.appendChild(pill); pills.insertBefore(pill, pills.firstChild);
container.setAttribute("data-modifier", modifierValue); container.setAttribute("data-modifier", modifierValue);
hidePanel(); hidePanel();
emitChange(null); emitChange(null);
} }
function clearModifier() { // Remove the modifier pill and its container attribute. Safe to call when
// there is no modifier pill (no-op). Does not touch value pills.
function clearModifierPill() {
var modifierPill = pills.querySelector("[data-search-select-modifier]"); var modifierPill = pills.querySelector("[data-search-select-modifier]");
if (modifierPill) modifierPill.remove(); if (modifierPill) modifierPill.remove();
container.removeAttribute("data-modifier"); container.removeAttribute("data-modifier");
} }
function clearModifier() {
clearModifierPill();
}
function optionFromRow(row) { function optionFromRow(row) {
if (row._searchSelectOption) return row._searchSelectOption; if (row._searchSelectOption) return row._searchSelectOption;
var data = {}; var data = {};
@@ -351,12 +484,12 @@
var pill = removeButton.closest("[data-pill]"); var pill = removeButton.closest("[data-pill]");
if (!pill) return; if (!pill) return;
if (isFilter) { if (isFilter) {
// Filter pills have no hidden input; a modifier pill also clears the // Filter pills have no hidden input.
// container flag.
if (pill.hasAttribute("data-search-select-modifier")) { if (pill.hasAttribute("data-search-select-modifier")) {
container.removeAttribute("data-modifier"); clearModifierPill();
} } else {
pill.remove(); pill.remove();
}
emitChange(null); emitChange(null);
return; return;
} }
@@ -431,8 +564,8 @@
pills.querySelectorAll("[data-pill]").forEach(function (pill) { pills.querySelectorAll("[data-pill]").forEach(function (pill) {
var pillModifier = pill.getAttribute("data-search-select-modifier"); var pillModifier = pill.getAttribute("data-search-select-modifier");
if (pillModifier) { if (pillModifier) {
modifier = pillModifier; modifier = pillModifier; // last modifier pill wins
return; return; // skip value extraction for this pill
} }
var value = pill.getAttribute("data-value"); var value = pill.getAttribute("data-value");
var label = pill.getAttribute("data-label") || ""; var label = pill.getAttribute("data-label") || "";
+78 -2
View File
@@ -92,16 +92,68 @@ class FilterBarRenderingTest(TestCase):
self._assert_shell(html, "/presets/purchases/list", "/presets/purchases/save") self._assert_shell(html, "/presets/purchases/list", "/presets/purchases/save")
self._assert_range_slider(html) self._assert_range_slider(html)
def test_purchase_filter_bar_games_has_m2m_modifiers(self):
"""The many-to-many games field surfaces (All)/(Only) pseudo-options
in the dropdown alongside the presence (Any)/(None) rows. Single-valued
fields (platform) do not get M2M modifiers."""
html = str(
PurchaseFilterBar(
filter_json="", preset_list_url="/l", preset_save_url="/s"
)
)
# (All) and (Only) appear as modifier rows in the dropdown.
self.assertIn('data-search-select-modifier-option="INCLUDES_ALL"', html)
self.assertIn('data-search-select-modifier-option="INCLUDES_ONLY"', html)
# No legacy match-mode <select>.
self.assertNotIn("data-search-select-match", html)
# Platform is single-valued: no M2M modifier options in its section.
games_start = html.find('data-name="games"')
platform_start = html.find('data-name="platform"')
platform_section = html[platform_start:]
self.assertNotIn("INCLUDES_ALL", platform_section)
self.assertGreater(games_start, 0)
def test_purchase_filter_bar_roundtrips_includes_all(self):
"""A stored INCLUDES_ALL modifier renders as the modifier pill and the
included game still renders as a value 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-modifier="INCLUDES_ALL"', html)
self.assertIn("(All)", html) # modifier pill label
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): def test_game_filter_bar_roundtrips_selected_status(self):
"""A status in filter_json renders as an include pill in the widget.""" """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( html = str(
FilterBar( FilterBar(
filter_json=filter_json, preset_list_url="/l", preset_save_url="/s" 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-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('data-value="f"', html) # selected status reflected in widget
self.assertIn("Finished", html) # ...with its label self.assertIn("Finished", html) # ...with its label
self.assertNoEscapedTags(html) self.assertNoEscapedTags(html)
@@ -110,3 +162,27 @@ class FilterBarRenderingTest(TestCase):
# for the double-escape bug the dedup fixed. # for the double-escape bug the dedup fixed.
self.assertIn("&quot;status&quot;", html) self.assertIn("&quot;status&quot;", html)
self.assertNotIn("&amp;quot;", html) self.assertNotIn("&amp;quot;", html)
def test_game_filter_bar_preserves_excludes_modifier(self):
"""An enum field with an EXCLUDES modifier renders data-modifier correctly
so the JS roundtrip preserves the modifier (regression: _split_modifier
silently dropped non-presence modifiers when match_modes was None)."""
filter_json = json.dumps(
{
"status": {
"value": [{"id": "f", "label": "Finished"}],
"modifier": "EXCLUDES",
}
}
)
html = str(
FilterBar(
filter_json=filter_json, preset_list_url="/l", preset_save_url="/s"
)
)
# The full modifier is stored on data-modifier when there's no match-mode
# select (enum/choice fields). No data-match attribute is present.
self.assertIn('data-modifier="EXCLUDES"', html)
self.assertNotIn("data-match=", html)
self.assertIn("Finished", html)
self.assertNoEscapedTags(html)
+332 -2
View File
@@ -17,6 +17,21 @@ from common.components import FilterBar
from games.filters import GameFilter from games.filters import GameFilter
class TestModifier:
def test_includes_only_in_enum(self):
assert Modifier.INCLUDES_ONLY == "INCLUDES_ONLY"
def test_includes_only_in_for_multi(self):
assert Modifier.INCLUDES_ONLY in Modifier.for_multi()
def test_for_multi_includes_all_four_match_modes(self):
modes = Modifier.for_multi()
assert Modifier.INCLUDES in modes
assert Modifier.INCLUDES_ALL in modes
assert Modifier.INCLUDES_ONLY in modes
assert Modifier.EXCLUDES in modes
class TestStringCriterion: class TestStringCriterion:
def test_equals(self): def test_equals(self):
c = StringCriterion(value="zelda", modifier=Modifier.EQUALS) c = StringCriterion(value="zelda", modifier=Modifier.EQUALS)
@@ -94,6 +109,24 @@ class TestChoiceCriterion:
q = c.to_q("status") q = c.to_q("status")
assert q == Q() 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"])
@pytest.mark.parametrize(
"modifier", [Modifier.INCLUDES_ALL, Modifier.INCLUDES_ONLY]
)
def test_m2m_modifiers_require_filter_builder(self, modifier):
"""INCLUDES_ALL / INCLUDES_ONLY cannot be built by the generic criterion
layer — they require a filter-level Q builder (see
PurchaseFilter._games_to_q)."""
c = ChoiceCriterion(value=["f", "p"], modifier=modifier)
with pytest.raises(AssertionError, match="requires a filter-level"):
c.to_q("status")
def test_not_equals(self): def test_not_equals(self):
c = ChoiceCriterion(value=["f"], modifier=Modifier.NOT_EQUALS) c = ChoiceCriterion(value=["f"], modifier=Modifier.NOT_EQUALS)
assert c.to_q("status") == ~Q(status__in=["f"]) assert c.to_q("status") == ~Q(status__in=["f"])
@@ -117,6 +150,24 @@ class TestMultiCriterion:
c = MultiCriterion(value=[1], excludes=[2], modifier=Modifier.INCLUDES) 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]) 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])
@pytest.mark.parametrize(
"modifier", [Modifier.INCLUDES_ALL, Modifier.INCLUDES_ONLY]
)
def test_m2m_modifiers_require_filter_builder(self, modifier):
"""INCLUDES_ALL / INCLUDES_ONLY cannot be built by the generic criterion
layer — they require a filter-level Q builder (see
PurchaseFilter._games_to_q)."""
c = MultiCriterion(value=[1, 2], modifier=modifier)
with pytest.raises(AssertionError, match="requires a filter-level"):
c.to_q("games")
def test_is_null(self): def test_is_null(self):
c = MultiCriterion(value=[], modifier=Modifier.IS_NULL) c = MultiCriterion(value=[], modifier=Modifier.IS_NULL)
assert c.to_q("device_id") == Q(device_id__isnull=True) assert c.to_q("device_id") == Q(device_id__isnull=True)
@@ -124,7 +175,10 @@ class TestMultiCriterion:
def test_from_json_strips_embedded_labels(self): def test_from_json_strips_embedded_labels(self):
"""from_json normalises {id, label} dicts to bare ids.""" """from_json normalises {id, label} dicts to bare ids."""
c = MultiCriterion.from_json( 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.value == [797]
assert c.excludes == [11] assert c.excludes == [11]
@@ -216,6 +270,210 @@ class TestChoiceCriterionAgainstDB:
assert self._count(c) == 0 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_any_no_duplicates(self):
"""INCLUDES [A, B] must not return duplicate rows for a purchase linked
to both A and B — the M2M join must not inflate the result.
Regression: ``games__in`` on a many-to-many field produces one row per
matching through-table entry, so a purchase linked to N of the selected
games would appear N times. The fix uses a subquery so each purchase
appears at most once.
"""
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 = list(Purchase.objects.filter(pf.to_q()))
# Must have 3 distinct purchases, not duplicates
assert len(result) == 3
assert set(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 TestPurchaseGamesIncludesOnlyAgainstDB:
"""INCLUDES_ONLY on the many-to-many ``Purchase.games`` should match only
purchases linked to *exactly* the given games — Stash's ``only`` mode,
which INCLUDES_ALL does not provide (it includes supersets)."""
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_only_matches_exact_set(self):
"""INCLUDES_ONLY [A, B] returns only purchases with exactly A and B."""
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_ONLY",
}
}
)
result = set(Purchase.objects.filter(pf.to_q()))
assert result == {seeded["both"]}
@pytest.mark.django_db
def test_includes_only_single_game(self):
"""INCLUDES_ONLY [A] = exactly game A, no others."""
from games.filters import PurchaseFilter
from games.models import Purchase
seeded = self._seed()
pf = PurchaseFilter.from_json(
{
"games": {
"value": [seeded["a"].id],
"modifier": "INCLUDES_ONLY",
}
}
)
result = set(Purchase.objects.filter(pf.to_q()))
assert result == {seeded["only_a"]}
@pytest.mark.django_db
def test_includes_only_contrast_with_includes_all(self):
"""INCLUDES_ONLY excludes the superset that INCLUDES_ALL would match."""
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_ONLY",
}
}
)
result = set(Purchase.objects.filter(pf.to_q()))
# all_three has A, B, C — INCLUDES_ALL would match it, ONLY does not.
assert seeded["all_three"] not in result
assert seeded["both"] in result
class TestGameFilterFromJson: class TestGameFilterFromJson:
def test_status_choice_criterion(self): def test_status_choice_criterion(self):
gf = GameFilter.from_json( gf = GameFilter.from_json(
@@ -293,7 +551,12 @@ class TestFilterBarRendering:
html = str( html = str(
FilterBar( FilterBar(
filter_json=json.dumps( filter_json=json.dumps(
{"status": {"value": [{"id": "f", "label": "Finished"}], "modifier": "INCLUDES"}} {
"status": {
"value": [{"id": "f", "label": "Finished"}],
"modifier": "INCLUDES",
}
}
), ),
) )
) )
@@ -327,3 +590,70 @@ class TestFilterBarRendering:
platform_section = html[platform_start:] platform_section = html[platform_start:]
# Should have at least one modifier option # Should have at least one modifier option
assert "(Any)" in platform_section or "(None)" in platform_section assert "(Any)" in platform_section or "(None)" in platform_section
class TestPurchaseNumPurchasesAgainstDB:
"""num_purchases IntCriterion filters purchases by game count."""
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})
single = Purchase.objects.create(
platform=platform, date_purchased=datetime.date(2024, 1, 1)
)
single.games.set([a])
double = Purchase.objects.create(
platform=platform, date_purchased=datetime.date(2024, 1, 1)
)
double.games.set([a, b])
triple = Purchase.objects.create(
platform=platform, date_purchased=datetime.date(2024, 1, 1)
)
triple.games.set([a, b, c])
return {"single": single, "double": double, "triple": triple}
@pytest.mark.django_db
def test_between_two_and_three(self):
from games.filters import PurchaseFilter
from games.models import Purchase
seeded = self._seed()
pf = PurchaseFilter.from_json(
{"num_purchases": {"value": 2, "value2": 3, "modifier": "BETWEEN"}}
)
result = set(Purchase.objects.filter(pf.to_q()))
assert result == {seeded["double"], seeded["triple"]}
@pytest.mark.django_db
def test_greater_than_one(self):
from games.filters import PurchaseFilter
from games.models import Purchase
seeded = self._seed()
pf = PurchaseFilter.from_json(
{"num_purchases": {"value": 1, "modifier": "GREATER_THAN"}}
)
result = set(Purchase.objects.filter(pf.to_q()))
assert result == {seeded["double"], seeded["triple"]}
@pytest.mark.django_db
def test_equals_one(self):
from games.filters import PurchaseFilter
from games.models import Purchase
seeded = self._seed()
pf = PurchaseFilter.from_json(
{"num_purchases": {"value": 1, "modifier": "EQUALS"}}
)
result = set(Purchase.objects.filter(pf.to_q()))
assert result == {seeded["single"]}
+68 -7
View File
@@ -179,7 +179,9 @@ class FilterSelectComponentTest(unittest.TestCase):
self.assertIn('data-search-select-modifier-option="NOT_NULL"', html) self.assertIn('data-search-select-modifier-option="NOT_NULL"', html)
self.assertIn('data-search-select-modifier-option="IS_NULL"', html) self.assertIn('data-search-select-modifier-option="IS_NULL"', html)
def test_active_modifier_replaces_value_pills(self): def test_modifier_pill_coexists_with_value_pills(self):
"""Modifier and value pills both render server-side; the JS handles
mutual exclusivity for presence modifiers (PRESENCE_MODIFIERS)."""
html = FilterSelect( html = FilterSelect(
field_name="platform", field_name="platform",
options=[("1", "Steam")], options=[("1", "Steam")],
@@ -187,13 +189,12 @@ class FilterSelectComponentTest(unittest.TestCase):
modifier="IS_NULL", modifier="IS_NULL",
modifier_options=self.MODIFIERS, modifier_options=self.MODIFIERS,
) )
# The lone modifier pill is shown; include/exclude pills are suppressed. # Both the modifier pill and the value pill render.
# (Scope the check to the live pills region — the cloneable pill <template>s
# legitimately contain data-search-select-type.)
pills_region = html.split("data-search-select-template")[0]
self.assertIn('data-search-select-modifier="IS_NULL"', html) self.assertIn('data-search-select-modifier="IS_NULL"', html)
self.assertIn("(None)", html) self.assertIn("(None)", html)
self.assertNotIn('data-search-select-type="include"', pills_region) self.assertIn(
'data-search-select-type="include"', html
) # value pill present
self.assertIn('data-modifier="IS_NULL"', html) # container carries it too self.assertIn('data-modifier="IS_NULL"', html) # container carries it too
def test_search_url_omits_value_rows_but_keeps_modifiers(self): def test_search_url_omits_value_rows_but_keeps_modifiers(self):
@@ -208,7 +209,9 @@ class FilterSelectComponentTest(unittest.TestCase):
panel = html.split("data-search-select-template")[0] panel = html.split("data-search-select-template")[0]
self.assertNotIn('data-search-select-option=""', panel) self.assertNotIn('data-search-select-option=""', panel)
self.assertIn('data-search-select-template="row"', html) 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) self.assertIn('data-prefetch="20"', html)
def test_search_url_pills_use_resolved_labels(self): def test_search_url_pills_use_resolved_labels(self):
@@ -221,6 +224,64 @@ class FilterSelectComponentTest(unittest.TestCase):
self.assertIn(">Obscure Game</span>", html) self.assertIn(">Obscure Game</span>", html)
self.assertIn('data-value="4172"', html) self.assertIn('data-value="4172"', html)
M2M_MODIFIERS = [
("INCLUDES_ALL", "(All)"),
("INCLUDES_ONLY", "(Only)"),
]
def test_m2m_modifiers_render_as_option_rows(self):
"""M2M modifiers (All)/(Only) render as modifier-option rows in the
dropdown, not as a separate <select>."""
html = FilterSelect(
field_name="games",
modifier_options=[
("NOT_NULL", "(Any)"),
("IS_NULL", "(None)"),
("INCLUDES_ALL", "(All)"),
("INCLUDES_ONLY", "(Only)"),
],
)
self.assertIn(
'data-search-select-modifier-option="INCLUDES_ALL"', html
)
self.assertIn(
'data-search-select-modifier-option="INCLUDES_ONLY"', html
)
self.assertIn(
'data-search-select-modifier-option="NOT_NULL"', html
)
# No legacy match-mode <select>.
self.assertNotIn("data-search-select-match", html)
def test_active_modifier_renders_pill(self):
"""When modifier is INCLUDES_ALL, the modifier pill renders with the
(All) label alongside any value pills."""
html = FilterSelect(
field_name="games",
modifier="INCLUDES_ALL",
modifier_options=[
("NOT_NULL", "(Any)"),
("IS_NULL", "(None)"),
("INCLUDES_ALL", "(All)"),
("INCLUDES_ONLY", "(Only)"),
],
included=[{"value": 5, "label": "Hollow Knight", "data": {}}],
)
self.assertIn('data-modifier="INCLUDES_ALL"', html)
self.assertIn("(All)", html)
self.assertIn("Hollow Knight", html)
self.assertIn('data-search-select-type="include"', html)
def test_presence_only_modifiers_no_m2m_rows(self):
"""When modifier_options only has presence entries, no M2M rows appear."""
html = FilterSelect(
field_name="status",
modifier_options=[("NOT_NULL", "(Any)"), ("IS_NULL", "(None)")],
options=[("f", "Finished")],
)
self.assertNotIn("INCLUDES_ALL", html)
self.assertNotIn("INCLUDES_ONLY", html)
class SearchLabelTest(django.test.TestCase): class SearchLabelTest(django.test.TestCase):
@classmethod @classmethod