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

Closes #10.

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

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

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

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

https://claude.ai/code/session_01KwVrGFbq13mZdhDL9G6zhg
This commit is contained in:
Claude
2026-06-08 20:08:50 +00:00
committed by Lukáš Kucharczyk
parent 05534875d6
commit ba9b92d419
9 changed files with 419 additions and 72 deletions
+61 -7
View File
@@ -93,6 +93,19 @@ def _parse_bool(existing: dict, key: str) -> bool:
_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]:
"""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
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(
field_name: str, options, choice: FilterChoice, *, nullable
) -> 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]
included = [(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]
included = [
(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(
field_name=field_name,
options=options_str,
included=included,
excluded=excluded,
modifier=choice.modifier,
modifier=presence,
modifier_options=_modifier_options(nullable),
)
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:
"""A FilterSelect backed by a search endpoint.
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(
field_name=field_name,
included=[(value, label or value) for value, label in choice.selected],
excluded=[(value, label or value) for value, label in choice.excluded],
modifier=choice.modifier,
modifier=presence,
modifier_options=_modifier_options(nullable),
match=match,
match_modes=match_modes or [],
search_url=search_url,
prefetch=_FILTER_PREFETCH,
)
@@ -804,6 +855,9 @@ def PurchaseFilterBar(
game_choice,
search_url="/api/games/search",
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(
+63 -6
View File
@@ -101,6 +101,14 @@ _FILTER_ACTION_BUTTON_CLASS = (
_FILTER_MODIFIER_ROW_CLASS = (
"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:
@@ -159,6 +167,7 @@ def _combobox_shell(
always_visible: bool,
items_visible: int,
templates: list[SafeText] | None = None,
leading: SafeText | None = None,
) -> SafeText:
"""Assemble the shared, domain-agnostic combobox skeleton.
@@ -169,8 +178,9 @@ def _combobox_shell(
``options_children`` (value rows plus any pinned pseudo-options), the
``container_attributes`` that carry the widget's identity and behaviour flags,
and any ``templates`` (inert ``<template>`` prototypes the JS clones for
dynamically-added rows/pills). The shell knows nothing about how individual
rows or pills look.
dynamically-added rows/pills). An optional ``leading`` element is placed
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)
@@ -191,10 +201,11 @@ def _combobox_shell(
children=[*options_children, no_results],
)
return Div(
attributes=container_attributes,
children=[pills, search, options_panel, *(templates or [])],
)
children: list[SafeText] = []
if leading is not None:
children.append(leading)
children += [pills, search, options_panel, *(templates or [])]
return Div(attributes=container_attributes, children=children)
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(
*,
field_name: str,
@@ -405,6 +445,8 @@ def FilterSelect(
excluded: list[LabeledOption | SearchSelectOption] | None = None,
modifier: str = "",
modifier_options: list[LabeledOption] | None = None,
match: str = "",
match_modes: list[LabeledOption] | None = None,
search_url: str = "",
prefetch: int = 0,
items_visible: int = 6,
@@ -421,6 +463,14 @@ def FilterSelect(
value pills. State is read from the DOM into the filter JSON by
``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
labels even when the value rows come from ``search_url``. ``options``
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 [])]
excluded = [_normalize_option(option) for option in (excluded 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 = ""
for modifier_value, label in modifier_options:
@@ -512,9 +564,13 @@ def FilterSelect(
]
if modifier:
container_attributes.append(("data-modifier", modifier))
if match_modes:
container_attributes.append(("data-match", active_match))
if id:
container_attributes.append(("id", id))
leading = _filter_match_select(match_modes, active_match) if match_modes else None
return _combobox_shell(
container_attributes=container_attributes,
pills=pills,
@@ -523,6 +579,7 @@ def FilterSelect(
always_visible=False,
items_visible=items_visible,
templates=templates,
leading=leading,
)