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
+5
View File
@@ -447,6 +447,11 @@
container.setAttribute("data-excluded", JSON.stringify(excluded));
if (modifier) container.setAttribute("data-modifier", 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;
}
var values = pills