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:
@@ -93,6 +93,19 @@ def _parse_bool(existing: dict, key: str) -> bool:
|
|||||||
|
|
||||||
_FILTER_PREFETCH = 20
|
_FILTER_PREFETCH = 20
|
||||||
|
|
||||||
|
# Presence modifiers drive the pinned (Any)/(None) pseudo-options (they clear the
|
||||||
|
# value set); every other modifier is a match mode for the include set.
|
||||||
|
_PRESENCE_MODIFIERS = frozenset({"NOT_NULL", "IS_NULL"})
|
||||||
|
|
||||||
|
# Include-set match modes (Stash's any/all/none axis). Offered only for
|
||||||
|
# many-to-many fields, where INCLUDES_ALL ("related to all of these") is
|
||||||
|
# meaningful — a single-valued field can never match all of several values.
|
||||||
|
_MATCH_MODES: list[LabeledOption] = [
|
||||||
|
("INCLUDES", "any"),
|
||||||
|
("INCLUDES_ALL", "all"),
|
||||||
|
("EXCLUDES", "none"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _modifier_options(nullable: bool) -> list[LabeledOption]:
|
def _modifier_options(nullable: bool) -> list[LabeledOption]:
|
||||||
"""Pinned (Any)/(None) pseudo-options; (None) only when the field is nullable."""
|
"""Pinned (Any)/(None) pseudo-options; (None) only when the field is nullable."""
|
||||||
@@ -102,37 +115,75 @@ def _modifier_options(nullable: bool) -> list[LabeledOption]:
|
|||||||
return options
|
return options
|
||||||
|
|
||||||
|
|
||||||
|
def _split_modifier(
|
||||||
|
modifier: str, match_modes: list[LabeledOption] | None
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
"""Split a stored modifier into ``(presence_modifier, match_mode)``.
|
||||||
|
|
||||||
|
A criterion stores a single ``modifier``, but the widget surfaces it on two
|
||||||
|
orthogonal controls: the pinned (Any)/(None) presence pseudo-options and the
|
||||||
|
match-mode select. Presence modifiers (NOT_NULL/IS_NULL) route to the former;
|
||||||
|
the rest (INCLUDES/INCLUDES_ALL/EXCLUDES) to the latter. The match mode is
|
||||||
|
irrelevant when the field has no match-mode control, and falls back to the
|
||||||
|
first offered mode otherwise.
|
||||||
|
"""
|
||||||
|
default_match = match_modes[0][0] if match_modes else ""
|
||||||
|
if modifier in _PRESENCE_MODIFIERS:
|
||||||
|
return modifier, default_match
|
||||||
|
if modifier and match_modes:
|
||||||
|
return "", modifier
|
||||||
|
return "", default_match
|
||||||
|
|
||||||
|
|
||||||
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 match-mode control (any/all/none is
|
||||||
|
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
|
||||||
|
]
|
||||||
|
presence, _match = _split_modifier(choice.modifier, None)
|
||||||
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=presence,
|
||||||
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,
|
||||||
|
match_modes: 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 ``match_modes`` for
|
||||||
|
many-to-many fields to surface the any/all/none match-mode select.
|
||||||
"""
|
"""
|
||||||
|
presence, match = _split_modifier(choice.modifier, match_modes)
|
||||||
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=presence,
|
||||||
modifier_options=_modifier_options(nullable),
|
modifier_options=_modifier_options(nullable),
|
||||||
|
match=match,
|
||||||
|
match_modes=match_modes or [],
|
||||||
search_url=search_url,
|
search_url=search_url,
|
||||||
prefetch=_FILTER_PREFETCH,
|
prefetch=_FILTER_PREFETCH,
|
||||||
)
|
)
|
||||||
@@ -804,6 +855,9 @@ 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" (INCLUDES_ALL)
|
||||||
|
# means a purchase linked to every selected game.
|
||||||
|
match_modes=_MATCH_MODES,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_filter_field(
|
_filter_field(
|
||||||
|
|||||||
@@ -101,6 +101,14 @@ _FILTER_ACTION_BUTTON_CLASS = (
|
|||||||
_FILTER_MODIFIER_ROW_CLASS = (
|
_FILTER_MODIFIER_ROW_CLASS = (
|
||||||
"px-2 py-1 text-sm text-body hover:bg-neutral-secondary-strong cursor-pointer"
|
"px-2 py-1 text-sm text-body hover:bg-neutral-secondary-strong cursor-pointer"
|
||||||
)
|
)
|
||||||
|
# The match-mode <select> (any/all/none → INCLUDES/INCLUDES_ALL/EXCLUDES). A
|
||||||
|
# native control so its value *is* its state — no class toggling in the JS.
|
||||||
|
# shrink-0 keeps it from collapsing as pills wrap; it sits before the pills.
|
||||||
|
_FILTER_MATCH_SELECT_CLASS = (
|
||||||
|
"shrink-0 rounded border border-default-medium bg-neutral-secondary-medium "
|
||||||
|
"text-xs text-body px-2 py-0.5 cursor-pointer "
|
||||||
|
"focus:ring-brand focus:border-brand"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _normalize_option(option) -> SearchSelectOption:
|
def _normalize_option(option) -> SearchSelectOption:
|
||||||
@@ -159,6 +167,7 @@ def _combobox_shell(
|
|||||||
always_visible: bool,
|
always_visible: bool,
|
||||||
items_visible: int,
|
items_visible: int,
|
||||||
templates: list[SafeText] | None = None,
|
templates: list[SafeText] | None = None,
|
||||||
|
leading: SafeText | None = None,
|
||||||
) -> SafeText:
|
) -> SafeText:
|
||||||
"""Assemble the shared, domain-agnostic combobox skeleton.
|
"""Assemble the shared, domain-agnostic combobox skeleton.
|
||||||
|
|
||||||
@@ -169,8 +178,9 @@ def _combobox_shell(
|
|||||||
``options_children`` (value rows plus any pinned pseudo-options), the
|
``options_children`` (value rows plus any pinned pseudo-options), the
|
||||||
``container_attributes`` that carry the widget's identity and behaviour flags,
|
``container_attributes`` that carry the widget's identity and behaviour flags,
|
||||||
and any ``templates`` (inert ``<template>`` prototypes the JS clones for
|
and any ``templates`` (inert ``<template>`` prototypes the JS clones for
|
||||||
dynamically-added rows/pills). The shell knows nothing about how individual
|
dynamically-added rows/pills). An optional ``leading`` element is placed
|
||||||
rows or pills look.
|
before the pills (e.g. the filter match-mode select). The shell knows nothing
|
||||||
|
about how individual rows or pills look.
|
||||||
"""
|
"""
|
||||||
search = Input(attributes=search_attributes)
|
search = Input(attributes=search_attributes)
|
||||||
|
|
||||||
@@ -191,10 +201,11 @@ def _combobox_shell(
|
|||||||
children=[*options_children, no_results],
|
children=[*options_children, no_results],
|
||||||
)
|
)
|
||||||
|
|
||||||
return Div(
|
children: list[SafeText] = []
|
||||||
attributes=container_attributes,
|
if leading is not None:
|
||||||
children=[pills, search, options_panel, *(templates or [])],
|
children.append(leading)
|
||||||
)
|
children += [pills, search, options_panel, *(templates or [])]
|
||||||
|
return Div(attributes=container_attributes, children=children)
|
||||||
|
|
||||||
|
|
||||||
def SearchSelect(
|
def SearchSelect(
|
||||||
@@ -397,6 +408,35 @@ def _filter_modifier_row(modifier_value: str, label: str) -> SafeText:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_match_select(match_modes: list[LabeledOption], active: str) -> SafeText:
|
||||||
|
"""The include-set match-mode ``<select>`` (e.g. any/all/none).
|
||||||
|
|
||||||
|
Each option's value is a ``Modifier`` name (INCLUDES / INCLUDES_ALL /
|
||||||
|
EXCLUDES) that governs how the include (✓) pills match; the exclude (✗) pills
|
||||||
|
stay an orthogonal negative. ``readSearchSelect`` reads the chosen value into
|
||||||
|
the container's ``data-match`` and ``filter_bar.js`` folds it into the
|
||||||
|
criterion's ``modifier``. Distinct from the pinned (Any)/(None) pseudo-options
|
||||||
|
(presence: NOT_NULL / IS_NULL), which clear the value pills.
|
||||||
|
"""
|
||||||
|
option_nodes: list[SafeText] = []
|
||||||
|
for modifier_value, label in match_modes:
|
||||||
|
attributes: list[HTMLAttribute] = [("value", modifier_value)]
|
||||||
|
if modifier_value == active:
|
||||||
|
attributes.append(("selected", ""))
|
||||||
|
option_nodes.append(
|
||||||
|
Component(tag_name="option", attributes=attributes, children=[label])
|
||||||
|
)
|
||||||
|
return Component(
|
||||||
|
tag_name="select",
|
||||||
|
attributes=[
|
||||||
|
("data-search-select-match", ""),
|
||||||
|
("aria-label", "Match mode"),
|
||||||
|
("class", _FILTER_MATCH_SELECT_CLASS),
|
||||||
|
],
|
||||||
|
children=option_nodes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def FilterSelect(
|
def FilterSelect(
|
||||||
*,
|
*,
|
||||||
field_name: str,
|
field_name: str,
|
||||||
@@ -405,6 +445,8 @@ def FilterSelect(
|
|||||||
excluded: list[LabeledOption | SearchSelectOption] | None = None,
|
excluded: list[LabeledOption | SearchSelectOption] | None = None,
|
||||||
modifier: str = "",
|
modifier: str = "",
|
||||||
modifier_options: list[LabeledOption] | None = None,
|
modifier_options: list[LabeledOption] | None = None,
|
||||||
|
match: str = "",
|
||||||
|
match_modes: list[LabeledOption] | None = None,
|
||||||
search_url: str = "",
|
search_url: str = "",
|
||||||
prefetch: int = 0,
|
prefetch: int = 0,
|
||||||
items_visible: int = 6,
|
items_visible: int = 6,
|
||||||
@@ -421,6 +463,14 @@ def FilterSelect(
|
|||||||
value pills. State is read from the DOM into the filter JSON by
|
value pills. State is read from the DOM into the filter JSON by
|
||||||
``readSearchSelect`` (filter mode) — nothing is submitted by ``name``.
|
``readSearchSelect`` (filter mode) — nothing is submitted by ``name``.
|
||||||
|
|
||||||
|
When ``match_modes`` is given (e.g.
|
||||||
|
``[("INCLUDES", "any"), ("INCLUDES_ALL", "all"), ("EXCLUDES", "none")]``) a
|
||||||
|
small ``<select>`` is rendered before the pills, letting the user choose how
|
||||||
|
the include (✓) set matches — Stash's modifier axis. ``match`` is the active
|
||||||
|
one (defaults to the first). It is orthogonal to ``modifier_options`` (which
|
||||||
|
handle presence) and to the exclude (✗) channel. ``INCLUDES_ALL`` is only
|
||||||
|
meaningful for many-to-many fields.
|
||||||
|
|
||||||
``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``
|
||||||
pre-renders the value rows for the complete-set (no ``search_url``) case.
|
pre-renders the value rows for the complete-set (no ``search_url``) case.
|
||||||
@@ -429,6 +479,8 @@ def FilterSelect(
|
|||||||
included = [_normalize_option(option) for option in (included or [])]
|
included = [_normalize_option(option) for option in (included or [])]
|
||||||
excluded = [_normalize_option(option) for option in (excluded or [])]
|
excluded = [_normalize_option(option) for option in (excluded or [])]
|
||||||
modifier_options = modifier_options or []
|
modifier_options = modifier_options or []
|
||||||
|
match_modes = match_modes or []
|
||||||
|
active_match = match or (match_modes[0][0] if match_modes else "")
|
||||||
|
|
||||||
active_modifier_label = ""
|
active_modifier_label = ""
|
||||||
for modifier_value, label in modifier_options:
|
for modifier_value, label in modifier_options:
|
||||||
@@ -512,9 +564,13 @@ def FilterSelect(
|
|||||||
]
|
]
|
||||||
if modifier:
|
if modifier:
|
||||||
container_attributes.append(("data-modifier", modifier))
|
container_attributes.append(("data-modifier", modifier))
|
||||||
|
if match_modes:
|
||||||
|
container_attributes.append(("data-match", active_match))
|
||||||
if id:
|
if id:
|
||||||
container_attributes.append(("id", id))
|
container_attributes.append(("id", id))
|
||||||
|
|
||||||
|
leading = _filter_match_select(match_modes, active_match) if match_modes else None
|
||||||
|
|
||||||
return _combobox_shell(
|
return _combobox_shell(
|
||||||
container_attributes=container_attributes,
|
container_attributes=container_attributes,
|
||||||
pills=pills,
|
pills=pills,
|
||||||
@@ -523,6 +579,7 @@ def FilterSelect(
|
|||||||
always_visible=False,
|
always_visible=False,
|
||||||
items_visible=items_visible,
|
items_visible=items_visible,
|
||||||
templates=templates,
|
templates=templates,
|
||||||
|
leading=leading,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+57
-49
@@ -271,17 +271,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 +299,37 @@ 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 == Modifier.INCLUDES_ALL:
|
||||||
|
# Logical AND of equalities ("related to every value"). NOTE: for a
|
||||||
|
# *multi-valued* relation this only behaves as "has all" when each
|
||||||
|
# equality lands on its own join — i.e. applied via chained
|
||||||
|
# ``.filter()`` calls or a ``pk__in`` subquery, not a single
|
||||||
|
# ``.filter(Q(rel=a) & Q(rel=b))`` (which would require one related
|
||||||
|
# row to equal both). M2M callers (e.g. PurchaseFilter.games) build
|
||||||
|
# that subquery; see PurchaseFilter._games_to_q.
|
||||||
|
q = Q()
|
||||||
|
for value in self.value:
|
||||||
|
q &= Q(**{field_name: value})
|
||||||
|
return q
|
||||||
|
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 +338,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 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
+24
-1
@@ -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,29 @@ 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") 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``. The orthogonal
|
||||||
|
``excludes`` channel is applied as a negative, consistent with every
|
||||||
|
other modifier. All other modifiers delegate to the criterion.
|
||||||
|
"""
|
||||||
|
if criterion.modifier == Modifier.INCLUDES_ALL and criterion.value:
|
||||||
|
from games.models import Purchase
|
||||||
|
|
||||||
|
subquery = Purchase.objects.all()
|
||||||
|
for game_id in criterion.value:
|
||||||
|
subquery = subquery.filter(games=game_id)
|
||||||
|
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 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -67,16 +67,21 @@
|
|||||||
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");
|
||||||
var modifier = widget.getAttribute("data-modifier");
|
// Two orthogonal axes: a presence modifier (NOT_NULL/IS_NULL) from the
|
||||||
if (modifier === "NOT_NULL" || modifier === "IS_NULL") {
|
// pinned (Any)/(None) pseudo-options clears the value set, while the
|
||||||
filter[field] = { modifier: modifier };
|
// match mode (INCLUDES/INCLUDES_ALL/EXCLUDES) governs how the include set
|
||||||
|
// matches. Fields without a match-mode select default to INCLUDES.
|
||||||
|
var presence = widget.getAttribute("data-modifier");
|
||||||
|
var match = widget.getAttribute("data-match") || "INCLUDES";
|
||||||
|
if (presence === "NOT_NULL" || presence === "IS_NULL") {
|
||||||
|
filter[field] = { modifier: presence };
|
||||||
} 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
|
||||||
// URL and saved presets are self-describing (Stash-style).
|
// URL and saved presets are self-describing (Stash-style).
|
||||||
filter[field] = {
|
filter[field] = {
|
||||||
value: included.map(function (item) { return {id: item.id, label: item.label}; }),
|
value: included.map(function (item) { return {id: item.id, label: item.label}; }),
|
||||||
excludes: excluded.map(function (item) { return {id: item.id, label: item.label}; }),
|
excludes: excluded.map(function (item) { return {id: item.id, label: item.label}; }),
|
||||||
modifier: modifier || "INCLUDES",
|
modifier: match,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -447,6 +447,11 @@
|
|||||||
container.setAttribute("data-excluded", JSON.stringify(excluded));
|
container.setAttribute("data-excluded", JSON.stringify(excluded));
|
||||||
if (modifier) container.setAttribute("data-modifier", modifier);
|
if (modifier) container.setAttribute("data-modifier", modifier);
|
||||||
else container.removeAttribute("data-modifier");
|
else container.removeAttribute("data-modifier");
|
||||||
|
// The match-mode <select> (any/all/none) governs how the include set
|
||||||
|
// matches; its value is the criterion modifier. A native control, so its
|
||||||
|
// value is read directly — no pill bookkeeping.
|
||||||
|
var matchSelect = container.querySelector("[data-search-select-match]");
|
||||||
|
if (matchSelect) container.setAttribute("data-match", matchSelect.value);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var values = pills
|
var values = pills
|
||||||
|
|||||||
@@ -92,16 +92,64 @@ 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_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):
|
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)
|
||||||
|
|||||||
+124
-2
@@ -94,6 +94,18 @@ 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"])
|
||||||
|
|
||||||
|
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):
|
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 +129,18 @@ 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])
|
||||||
|
|
||||||
|
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):
|
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 +148,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 +243,96 @@ 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_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:
|
class TestGameFilterFromJson:
|
||||||
def test_status_choice_criterion(self):
|
def test_status_choice_criterion(self):
|
||||||
gf = GameFilter.from_json(
|
gf = GameFilter.from_json(
|
||||||
@@ -293,7 +410,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",
|
||||||
|
}
|
||||||
|
}
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -208,7 +208,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 +223,29 @@ 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)
|
||||||
|
|
||||||
|
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):
|
class SearchLabelTest(django.test.TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
Reference in New Issue
Block a user