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:
+24
-1
@@ -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 ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -67,16 +67,21 @@
|
||||
var field = widget.getAttribute("data-name");
|
||||
var included = parseJSONAttr(widget, "data-included");
|
||||
var excluded = parseJSONAttr(widget, "data-excluded");
|
||||
var modifier = widget.getAttribute("data-modifier");
|
||||
if (modifier === "NOT_NULL" || modifier === "IS_NULL") {
|
||||
filter[field] = { modifier: modifier };
|
||||
// Two orthogonal axes: a presence modifier (NOT_NULL/IS_NULL) from the
|
||||
// pinned (Any)/(None) pseudo-options clears the value set, while the
|
||||
// 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) {
|
||||
// All filter pills carry {id, label}; store them as-is so the filter
|
||||
// URL and saved presets are self-describing (Stash-style).
|
||||
filter[field] = {
|
||||
value: included.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));
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user