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
+24 -1
View File
@@ -331,7 +331,7 @@ class PurchaseFilter(OperatorFilter):
if self.platform is not None:
q &= self.platform.to_q("platform_id")
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:
q &= self.date_purchased.to_q("date_purchased")
if self.date_refunded is not None:
@@ -385,6 +385,29 @@ class PurchaseFilter(OperatorFilter):
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 ────────────────────────────────────────────────────