Compare commits
8 Commits
05534875d6
...
1c9fb474df
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c9fb474df | |||
| 737dd9275b | |||
| 9f436b245d | |||
| 7ebaa51eb0 | |||
| a7ff2962a6 | |||
| 103219a5e7 | |||
| 14efff8078 | |||
| ba9b92d419 |
@@ -12,3 +12,4 @@ dist/
|
||||
.DS_Store
|
||||
.python-version
|
||||
.direnv
|
||||
.hermes/
|
||||
|
||||
@@ -93,46 +93,101 @@ def _parse_bool(existing: dict, key: str) -> bool:
|
||||
|
||||
_FILTER_PREFETCH = 20
|
||||
|
||||
# Presence modifiers drive the pinned (Any)/(None) pseudo-options. They are
|
||||
# mutually exclusive with value pills (selecting one clears the value set).
|
||||
# Must match JS PRESENCE_MODIFIERS in search_select.js.
|
||||
_PRESENCE_MODIFIERS = frozenset({"NOT_NULL", "IS_NULL"})
|
||||
|
||||
def _modifier_options(nullable: bool) -> list[LabeledOption]:
|
||||
"""Pinned (Any)/(None) pseudo-options; (None) only when the field is nullable."""
|
||||
options = [("NOT_NULL", "(Any)")]
|
||||
# M2M-only modifiers surfaced as additional pseudo-options in the dropdown.
|
||||
# "any" (INCLUDES) is the implicit default when neither a presence nor an
|
||||
# M2M modifier is set — no dedicated row needed. "none" (EXCLUDES) is
|
||||
# redundant with individual exclude (✗) pills. Only INCLUDES_ALL and
|
||||
# INCLUDES_ONLY can't be expressed through pills alone, so they are the
|
||||
# only M2M modifiers with explicit UI.
|
||||
_M2M_MODIFIERS: list[LabeledOption] = [
|
||||
("INCLUDES_ALL", "(All)"),
|
||||
("INCLUDES_ONLY", "(Only)"),
|
||||
]
|
||||
|
||||
|
||||
def _modifier_options(
|
||||
nullable: bool, m2m_modifiers: list[LabeledOption] | None = None
|
||||
) -> list[LabeledOption]:
|
||||
"""Pinned pseudo-options rendered at the top of the dropdown.
|
||||
|
||||
Always includes ``(Any)`` (NOT_NULL); adds ``(None)`` (IS_NULL) when
|
||||
``nullable`` is True. When ``m2m_modifiers`` is given (M2M fields only),
|
||||
appends those rows (e.g. ``(All)`` / ``(Only)``)."""
|
||||
options: list[LabeledOption] = [("NOT_NULL", "(Any)")]
|
||||
if nullable:
|
||||
options.append(("IS_NULL", "(None)"))
|
||||
if m2m_modifiers:
|
||||
options.extend(m2m_modifiers)
|
||||
return options
|
||||
|
||||
|
||||
def _split_modifier(modifier: str, has_m2m: bool = False) -> str:
|
||||
"""Return the modifier value to surface as the modifier pill.
|
||||
|
||||
Presence modifiers (NOT_NULL / IS_NULL) are always surfaced. Non-presence
|
||||
modifiers (INCLUDES / INCLUDES_ALL / INCLUDES_ONLY) only need a pill on M2M
|
||||
fields — otherwise the modifier is just the implicit default.
|
||||
"""
|
||||
if modifier in _PRESENCE_MODIFIERS or not has_m2m:
|
||||
return modifier
|
||||
if modifier:
|
||||
return modifier
|
||||
return ""
|
||||
|
||||
|
||||
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 M2M modifiers (all/only are
|
||||
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
|
||||
]
|
||||
modifier = _split_modifier(choice.modifier)
|
||||
return FilterSelect(
|
||||
field_name=field_name,
|
||||
options=options_str,
|
||||
included=included,
|
||||
excluded=excluded,
|
||||
modifier=choice.modifier,
|
||||
modifier=modifier,
|
||||
modifier_options=_modifier_options(nullable),
|
||||
)
|
||||
|
||||
|
||||
def _model_filter(
|
||||
field_name: str, choice: FilterChoice, *, search_url, nullable
|
||||
field_name: str,
|
||||
choice: FilterChoice,
|
||||
*,
|
||||
search_url,
|
||||
nullable,
|
||||
m2m_modifiers: 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 ``m2m_modifiers`` for
|
||||
many-to-many fields to surface ``(All)`` / ``(Only)`` pseudo-options in the
|
||||
dropdown alongside the presence options.
|
||||
"""
|
||||
modifier = _split_modifier(choice.modifier, has_m2m=bool(m2m_modifiers))
|
||||
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_options=_modifier_options(nullable),
|
||||
modifier=modifier,
|
||||
modifier_options=_modifier_options(nullable, m2m_modifiers),
|
||||
search_url=search_url,
|
||||
prefetch=_FILTER_PREFETCH,
|
||||
)
|
||||
@@ -792,6 +847,16 @@ def PurchaseFilterBar(
|
||||
except Exception:
|
||||
price_range_min, price_range_max = 0, 100
|
||||
|
||||
num_min, num_max = _parse_range(existing, "num_purchases")
|
||||
try:
|
||||
num_aggregate = Purchase.objects.aggregate(
|
||||
num_min=models.Min("num_purchases"), num_max=models.Max("num_purchases")
|
||||
)
|
||||
num_range_min = max(int(num_aggregate.get("num_min") or 0), 0)
|
||||
num_range_max = max(int(num_aggregate.get("num_max") or 10), 1)
|
||||
except Exception:
|
||||
num_range_min, num_range_max = 0, 10
|
||||
|
||||
fields = [
|
||||
Component(
|
||||
tag_name="div",
|
||||
@@ -804,6 +869,10 @@ def PurchaseFilterBar(
|
||||
game_choice,
|
||||
search_url="/api/games/search",
|
||||
nullable=False,
|
||||
# games is many-to-many on Purchase: (All) means
|
||||
# INCLUDES_ALL ("purchase linked to every selected
|
||||
# game"); (Only) means INCLUDES_ONLY.
|
||||
m2m_modifiers=_M2M_MODIFIERS,
|
||||
),
|
||||
),
|
||||
_filter_field(
|
||||
@@ -854,5 +923,16 @@ def PurchaseFilterBar(
|
||||
min_placeholder="0.00",
|
||||
max_placeholder="100.00",
|
||||
),
|
||||
RangeSlider(
|
||||
label="Games in purchase",
|
||||
input_name_prefix="filter-num-purchases",
|
||||
min_value=num_min,
|
||||
max_value=num_max,
|
||||
range_min=num_range_min,
|
||||
range_max=num_range_max,
|
||||
step="1",
|
||||
min_placeholder="e.g. 1",
|
||||
max_placeholder="e.g. 5",
|
||||
),
|
||||
]
|
||||
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
|
||||
|
||||
@@ -191,10 +191,8 @@ def _combobox_shell(
|
||||
children=[*options_children, no_results],
|
||||
)
|
||||
|
||||
return Div(
|
||||
attributes=container_attributes,
|
||||
children=[pills, search, options_panel, *(templates or [])],
|
||||
)
|
||||
children: list[SafeText] = [pills, search, options_panel, *(templates or [])]
|
||||
return Div(attributes=container_attributes, children=children)
|
||||
|
||||
|
||||
def SearchSelect(
|
||||
@@ -417,9 +415,12 @@ def FilterSelect(
|
||||
Like ``SearchSelect`` but each value row carries +/− buttons that add an
|
||||
*include* (✓) or *exclude* (✗) pill, plus an optional set of pinned
|
||||
``modifier_options`` (e.g. ``[("NOT_NULL", "(Any)"), ("IS_NULL", "(None)")]``)
|
||||
rendered above the value rows. A selected modifier is mutually exclusive with
|
||||
value pills. State is read from the DOM into the filter JSON by
|
||||
``readSearchSelect`` (filter mode) — nothing is submitted by ``name``.
|
||||
rendered above the value rows. Presence modifiers (NOT_NULL / IS_NULL) are
|
||||
mutually exclusive with value pills. Non-presence modifiers (INCLUDES_ALL /
|
||||
INCLUDES_ONLY) coexist with value pills — they govern how the include set
|
||||
matches and are only surfaced for many-to-many fields. State is read from
|
||||
the DOM into the filter JSON by ``readSearchSelect`` (filter mode) — nothing
|
||||
is submitted by ``name``.
|
||||
|
||||
``included``/``excluded`` are resolved options (value + label) so pills show
|
||||
labels even when the value rows come from ``search_url``. ``options``
|
||||
@@ -436,15 +437,18 @@ def FilterSelect(
|
||||
active_modifier_label = label
|
||||
break
|
||||
|
||||
# ── Pills: a lone modifier pill, or include/exclude value pills ──
|
||||
# ── Pills: modifier pill (if active), then include/exclude value pills ──
|
||||
# Presence modifiers (NOT_NULL / IS_NULL) are mutually exclusive with value
|
||||
# pills — but the stored state guarantees they never coexist, so we render
|
||||
# both channels unconditionally. Non-presence modifiers (INCLUDES_ALL /
|
||||
# INCLUDES_ONLY) coexist with value pills and render side by side.
|
||||
pills_children: list[SafeText] = []
|
||||
if active_modifier_label:
|
||||
pills_children.append(_filter_modifier_pill(modifier, active_modifier_label))
|
||||
else:
|
||||
for option in included:
|
||||
pills_children.append(_filter_value_pill(option, "include"))
|
||||
for option in excluded:
|
||||
pills_children.append(_filter_value_pill(option, "exclude"))
|
||||
for option in included:
|
||||
pills_children.append(_filter_value_pill(option, "include"))
|
||||
for option in excluded:
|
||||
pills_children.append(_filter_value_pill(option, "exclude"))
|
||||
|
||||
pills = Div(
|
||||
attributes=[("data-search-select-pills", ""), ("class", _PILLS_CLASS)],
|
||||
|
||||
+60
-49
@@ -30,6 +30,7 @@ class Modifier(str, Enum):
|
||||
INCLUDES = "INCLUDES"
|
||||
EXCLUDES = "EXCLUDES"
|
||||
INCLUDES_ALL = "INCLUDES_ALL"
|
||||
INCLUDES_ONLY = "INCLUDES_ONLY"
|
||||
IS_NULL = "IS_NULL"
|
||||
NOT_NULL = "NOT_NULL"
|
||||
MATCHES_REGEX = "MATCHES_REGEX"
|
||||
@@ -71,6 +72,7 @@ class Modifier(str, Enum):
|
||||
cls.INCLUDES,
|
||||
cls.EXCLUDES,
|
||||
cls.INCLUDES_ALL,
|
||||
cls.INCLUDES_ONLY,
|
||||
cls.IS_NULL,
|
||||
cls.NOT_NULL,
|
||||
]
|
||||
@@ -271,17 +273,26 @@ class _SetCriterion(_Criterion):
|
||||
"""Shared base for set-membership criteria (``MultiCriterion`` /
|
||||
``ChoiceCriterion``).
|
||||
|
||||
``value`` is the include set and ``excludes`` the exclude set. The common
|
||||
modifiers are implemented once here so the two subclasses cannot drift:
|
||||
Two orthogonal channels, mirroring Stash's modifier model:
|
||||
|
||||
- ``INCLUDES`` — in ``value`` (when non-empty) AND not in ``excludes`` (when
|
||||
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.
|
||||
- ``value`` is the *include* set. The ``modifier`` governs how it matches:
|
||||
|
||||
Subclasses contribute their own modifiers (e.g. ``INCLUDES_ALL``) by
|
||||
overriding ``_extra_q``.
|
||||
- ``INCLUDES`` — in ``value`` (match *any*); ``EQUALS`` is an alias.
|
||||
- ``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)
|
||||
@@ -290,25 +301,38 @@ class _SetCriterion(_Criterion):
|
||||
|
||||
def to_q(self, field_name: str) -> Q:
|
||||
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:
|
||||
return Q(**{f"{field_name}__isnull": True})
|
||||
if modifier == Modifier.NOT_NULL:
|
||||
return Q(**{f"{field_name}__isnull": False})
|
||||
extra = self._extra_q(field_name)
|
||||
if extra is not None:
|
||||
return extra
|
||||
raise ValueError(f"Unsupported modifier {modifier} for {type(self).__name__}")
|
||||
# The modifier governs only the include set; ``excludes`` is an orthogonal
|
||||
# AND'd negative applied for every (non-presence) modifier.
|
||||
q = self._value_q(field_name)
|
||||
if self.excludes:
|
||||
q &= ~Q(**{f"{field_name}__in": self.excludes})
|
||||
return q
|
||||
|
||||
def _extra_q(self, field_name: str) -> Q | None:
|
||||
"""Hook for subclass-specific modifiers; ``None`` means unsupported."""
|
||||
return None
|
||||
def _value_q(self, field_name: str) -> Q:
|
||||
"""Build the Q for the include (``value``) set, per the modifier."""
|
||||
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 in (Modifier.INCLUDES_ALL, Modifier.INCLUDES_ONLY):
|
||||
# INCLUDES_ALL ("related to all of these") and INCLUDES_ONLY
|
||||
# ("related to exactly these, nothing else") are only meaningful
|
||||
# for many-to-many fields. A naive Q(field=a) & Q(field=b)
|
||||
# collapses to a single join requiring one through-row to equal
|
||||
# both values (impossible), so the generic criterion layer cannot
|
||||
# build a correct Q. M2M callers must supply their own Q builder
|
||||
# at the filter level — see PurchaseFilter._games_to_q for the
|
||||
# chained-subquery pattern.
|
||||
assert False, (
|
||||
f"{modifier} requires a filter-level Q builder for M2M fields. "
|
||||
"See PurchaseFilter._games_to_q for the chained-subquery pattern."
|
||||
)
|
||||
raise ValueError(f"Unsupported modifier {modifier} for {type(self).__name__}")
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, data: dict | None) -> Self | None:
|
||||
@@ -317,51 +341,38 @@ class _SetCriterion(_Criterion):
|
||||
return None
|
||||
# Labels embedded as {id, label} dicts are display-only; strip to bare ids
|
||||
# so the querying layer stays clean and typed.
|
||||
result.value = [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]
|
||||
result.value = [
|
||||
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
|
||||
|
||||
|
||||
@dataclass
|
||||
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)
|
||||
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
|
||||
class ChoiceCriterion(_SetCriterion):
|
||||
"""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)
|
||||
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 ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
+57
-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,62 @@ 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") and
|
||||
``INCLUDES_ONLY`` ("related to exactly these, nothing else") 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``. ``INCLUDES_ONLY`` additionally excludes purchases that have
|
||||
any game outside the specified set.
|
||||
|
||||
``INCLUDES`` (plain "any") also uses a subquery instead of a raw
|
||||
``games__in`` join because a single purchase linked to *n* of the
|
||||
given games would appear *n* times in the result set (M2M join
|
||||
duplicates).
|
||||
|
||||
The orthogonal ``excludes`` channel is applied as a negative,
|
||||
consistent with every other modifier. All other modifiers delegate
|
||||
to the criterion.
|
||||
"""
|
||||
# Empty value means no constraint; still apply excludes if any
|
||||
if not criterion.value:
|
||||
if criterion.excludes:
|
||||
return ~Q(games__in=criterion.excludes)
|
||||
return Q()
|
||||
|
||||
from games.models import Game, Purchase
|
||||
|
||||
if criterion.modifier in (Modifier.INCLUDES_ALL, Modifier.INCLUDES_ONLY):
|
||||
subquery = Purchase.objects.all()
|
||||
for game_id in criterion.value:
|
||||
subquery = subquery.filter(games=game_id)
|
||||
|
||||
if criterion.modifier == Modifier.INCLUDES_ONLY:
|
||||
extra_ids = Game.objects.exclude(
|
||||
id__in=criterion.value
|
||||
).values_list("id", flat=True)
|
||||
if extra_ids:
|
||||
subquery = subquery.exclude(games__in=extra_ids)
|
||||
|
||||
q = Q(pk__in=subquery.values("pk"))
|
||||
if criterion.excludes:
|
||||
q &= ~Q(games__in=criterion.excludes)
|
||||
return q
|
||||
|
||||
if criterion.modifier == Modifier.INCLUDES:
|
||||
# Use subquery to avoid duplicate rows from M2M join
|
||||
subquery = Purchase.objects.filter(games__in=criterion.value)
|
||||
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,8 +67,14 @@
|
||||
var field = widget.getAttribute("data-name");
|
||||
var included = parseJSONAttr(widget, "data-included");
|
||||
var excluded = parseJSONAttr(widget, "data-excluded");
|
||||
// Two orthogonal axes: a presence modifier (NOT_NULL/IS_NULL) from the
|
||||
// pinned (Any)/(None) pseudo-options clears the value set and has no
|
||||
// values; the non-presence modifier (INCLUDES_ALL/INCLUDES_ONLY) governs
|
||||
// how the include set matches. When neither is set the implicit default
|
||||
// is INCLUDES ("any"). Must match Python _PRESENCE_MODIFIERS.
|
||||
var modifier = widget.getAttribute("data-modifier");
|
||||
if (modifier === "NOT_NULL" || modifier === "IS_NULL") {
|
||||
var IS_PRESENCE = modifier === "NOT_NULL" || modifier === "IS_NULL";
|
||||
if (IS_PRESENCE) {
|
||||
filter[field] = { modifier: modifier };
|
||||
} else if (included.length > 0 || excluded.length > 0) {
|
||||
// All filter pills carry {id, label}; store them as-is so the filter
|
||||
@@ -123,6 +129,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── Purchase-specific: num_purchases ──
|
||||
var numGamesMin = numberValue(form, "filter-num-purchases-min");
|
||||
var numGamesMax = numberValue(form, "filter-num-purchases-max");
|
||||
if (numGamesMin !== "" && numGamesMax !== "") {
|
||||
filter.num_purchases = criterion(parseInt(numGamesMin, 10), parseInt(numGamesMax, 10), "BETWEEN");
|
||||
} else if (numGamesMin !== "") {
|
||||
filter.num_purchases = criterion(parseInt(numGamesMin, 10), null, "GREATER_THAN");
|
||||
} else if (numGamesMax !== "") {
|
||||
filter.num_purchases = criterion(parseInt(numGamesMax, 10), null, "LESS_THAN");
|
||||
}
|
||||
|
||||
if (mastered && mastered.checked) {
|
||||
filter.mastered = criterion(true, null, "EQUALS");
|
||||
}
|
||||
|
||||
@@ -24,7 +24,13 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var DEBOUNCE_MS = 500;
|
||||
var DEBOUNCE_MS = 100;
|
||||
|
||||
// Must match Python common/components/filters.py:_PRESENCE_MODIFIERS.
|
||||
// These modifiers are mutually exclusive with value pills — selecting
|
||||
// one clears all value pills. Non-presence modifiers (INCLUDES_ALL,
|
||||
// INCLUDES_ONLY) coexist with value pills.
|
||||
var PRESENCE_MODIFIERS = ["NOT_NULL", "IS_NULL"];
|
||||
|
||||
function initAll() {
|
||||
document.querySelectorAll("[data-search-select]").forEach(function (element) {
|
||||
@@ -65,6 +71,60 @@
|
||||
if (noResults) noResults.classList.toggle("hidden", !visible);
|
||||
}
|
||||
|
||||
// ── Highlight tracking (filter mode) ──
|
||||
var highlightedRow = null;
|
||||
|
||||
function highlightOption(row) {
|
||||
clearHighlight();
|
||||
if (!row) return;
|
||||
row.style.backgroundColor = "var(--color-brand, rgba(59, 130, 246, 0.15))";
|
||||
row.style.outline = "1px solid var(--color-brand, #3b82f6)";
|
||||
highlightedRow = row;
|
||||
row.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
function clearHighlight() {
|
||||
if (highlightedRow) {
|
||||
highlightedRow.style.backgroundColor = "";
|
||||
highlightedRow.style.outline = "";
|
||||
highlightedRow = null;
|
||||
}
|
||||
}
|
||||
|
||||
function getVisibleOptions() {
|
||||
var all = options.querySelectorAll("[data-search-select-option]");
|
||||
return Array.prototype.filter.call(all, function (row) {
|
||||
return row.style.display !== "none";
|
||||
});
|
||||
}
|
||||
|
||||
function autoHighlight(query) {
|
||||
var visible = getVisibleOptions();
|
||||
if (visible.length === 0) {
|
||||
clearHighlight();
|
||||
return;
|
||||
}
|
||||
var lower = query.toLowerCase();
|
||||
// 1. Starts-with match
|
||||
for (var i = 0; i < visible.length; i++) {
|
||||
var label = (visible[i].getAttribute("data-label") || "").toLowerCase();
|
||||
if (lower && label.startsWith(lower)) {
|
||||
highlightOption(visible[i]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 2. Substring match (fuzzy-lite)
|
||||
for (var j = 0; j < visible.length; j++) {
|
||||
var subLabel = (visible[j].getAttribute("data-label") || "").toLowerCase();
|
||||
if (lower && subLabel.indexOf(lower) !== -1) {
|
||||
highlightOption(visible[j]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 3. Fallback: first visible option
|
||||
highlightOption(visible[0]);
|
||||
}
|
||||
|
||||
// ── Render server-fetched rows into the panel ──
|
||||
function renderRows(items) {
|
||||
options.querySelectorAll("[data-search-select-option]").forEach(function (row) {
|
||||
@@ -140,6 +200,7 @@
|
||||
renderRows(items);
|
||||
// Re-apply the live query: the box may hold more text than was sent.
|
||||
setNoResults(filterRows(search.value.trim()) === 0);
|
||||
if (isFilter) autoHighlight(search.value.trim());
|
||||
})
|
||||
.catch(function (error) {
|
||||
if (error && error.name === "AbortError") return; // superseded
|
||||
@@ -166,6 +227,7 @@
|
||||
} else {
|
||||
setNoResults(filterRows(query) === 0);
|
||||
}
|
||||
if (isFilter) autoHighlight(query);
|
||||
}
|
||||
|
||||
// ── Single-select combobox: the search box shows the committed label;
|
||||
@@ -188,12 +250,15 @@
|
||||
// Show whatever is already loaded; the server decides no-results.
|
||||
filterRows(search.value.trim());
|
||||
setNoResults(false);
|
||||
if (isFilter) autoHighlight(search.value.trim());
|
||||
}
|
||||
} else {
|
||||
setNoResults(filterRows(search.value.trim()) === 0);
|
||||
if (isFilter) autoHighlight(search.value.trim());
|
||||
}
|
||||
});
|
||||
search.addEventListener("input", function () {
|
||||
clearHighlight();
|
||||
if (!multi) container._searchSelectDirty = true;
|
||||
runSearch();
|
||||
});
|
||||
@@ -215,6 +280,45 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ── Keyboard navigation (filter mode) ──
|
||||
search.addEventListener("keydown", function (event) {
|
||||
if (!isFilter) return;
|
||||
var key = event.key;
|
||||
if (key === "ArrowDown" || key === "ArrowUp" || key === "Enter" || key === "Escape") {
|
||||
var visible = getVisibleOptions();
|
||||
if (visible.length === 0) {
|
||||
if (key === "Escape") hidePanel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
showPanel();
|
||||
var idx = highlightedRow ? visible.indexOf(highlightedRow) : -1;
|
||||
var next = visible[(idx + 1) % visible.length];
|
||||
highlightOption(next);
|
||||
} else if (key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
showPanel();
|
||||
var idx = highlightedRow ? visible.indexOf(highlightedRow) : -1;
|
||||
var prev = visible[(idx - 1 + visible.length) % visible.length];
|
||||
highlightOption(prev);
|
||||
} else if (key === "Enter") {
|
||||
if (highlightedRow) {
|
||||
event.preventDefault();
|
||||
var option = optionFromRow(highlightedRow);
|
||||
addFilterPill(option, "include");
|
||||
search.value = "";
|
||||
clearHighlight();
|
||||
hidePanel();
|
||||
}
|
||||
} else if (key === "Escape") {
|
||||
clearHighlight();
|
||||
hidePanel();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Clicking an option must not blur the input before the click selects.
|
||||
options.addEventListener("mousedown", function (event) {
|
||||
event.preventDefault();
|
||||
@@ -243,21 +347,38 @@
|
||||
}
|
||||
// Include / exclude button on a value row.
|
||||
var button = event.target.closest("[data-search-select-action]");
|
||||
if (!button) return;
|
||||
var row = button.closest("[data-search-select-option]");
|
||||
if (!row) return;
|
||||
addFilterPill(optionFromRow(row), button.getAttribute("data-search-select-action"));
|
||||
if (button) {
|
||||
var row = button.closest("[data-search-select-option]");
|
||||
if (!row) return;
|
||||
addFilterPill(optionFromRow(row), button.getAttribute("data-search-select-action"));
|
||||
return;
|
||||
}
|
||||
// Click on the option row itself → include.
|
||||
var optionRow = event.target.closest("[data-search-select-option]");
|
||||
if (optionRow) {
|
||||
addFilterPill(optionFromRow(optionRow), "include");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Add (or re-type) an include/exclude pill for a value. Selecting any value
|
||||
// clears an active modifier — the two are mutually exclusive.
|
||||
// clears a presence modifier — NOT_NULL / IS_NULL are mutually exclusive
|
||||
// with value pills. Non-presence modifiers (INCLUDES_ALL / INCLUDES_ONLY)
|
||||
// persist alongside value pills.
|
||||
function addFilterPill(option, kind) {
|
||||
clearModifier();
|
||||
var modPill = pills.querySelector("[data-search-select-modifier]");
|
||||
if (modPill) {
|
||||
var modVal = modPill.getAttribute("data-search-select-modifier");
|
||||
if (PRESENCE_MODIFIERS.indexOf(modVal) !== -1) {
|
||||
clearModifier();
|
||||
}
|
||||
}
|
||||
var existing = pills.querySelector(
|
||||
'[data-pill][data-value="' + cssEscape(option.value) + '"]'
|
||||
);
|
||||
if (existing) existing.remove();
|
||||
pills.appendChild(buildFilterValuePill(option, kind));
|
||||
search.value = "";
|
||||
emitChange(null);
|
||||
}
|
||||
|
||||
@@ -270,24 +391,36 @@
|
||||
return pill;
|
||||
}
|
||||
|
||||
// Set the lone modifier pill, clearing all value pills (mutual exclusivity).
|
||||
// Set the modifier pill. Presence modifiers (NOT_NULL / IS_NULL) clear all
|
||||
// value pills — they are mutually exclusive. Non-presence modifiers
|
||||
// (INCLUDES_ALL / INCLUDES_ONLY) are prepended before existing value pills.
|
||||
function setModifier(modifierValue, label) {
|
||||
pills.innerHTML = "";
|
||||
// Remove any existing modifier pill to avoid duplicates.
|
||||
clearModifierPill();
|
||||
if (PRESENCE_MODIFIERS.indexOf(modifierValue) !== -1) {
|
||||
pills.innerHTML = "";
|
||||
}
|
||||
var pill = cloneTemplate("pill-modifier");
|
||||
pill.setAttribute("data-search-select-modifier", modifierValue);
|
||||
setLabel(pill, label);
|
||||
pills.appendChild(pill);
|
||||
pills.insertBefore(pill, pills.firstChild);
|
||||
container.setAttribute("data-modifier", modifierValue);
|
||||
hidePanel();
|
||||
emitChange(null);
|
||||
}
|
||||
|
||||
function clearModifier() {
|
||||
// Remove the modifier pill and its container attribute. Safe to call when
|
||||
// there is no modifier pill (no-op). Does not touch value pills.
|
||||
function clearModifierPill() {
|
||||
var modifierPill = pills.querySelector("[data-search-select-modifier]");
|
||||
if (modifierPill) modifierPill.remove();
|
||||
container.removeAttribute("data-modifier");
|
||||
}
|
||||
|
||||
function clearModifier() {
|
||||
clearModifierPill();
|
||||
}
|
||||
|
||||
function optionFromRow(row) {
|
||||
if (row._searchSelectOption) return row._searchSelectOption;
|
||||
var data = {};
|
||||
@@ -351,12 +484,12 @@
|
||||
var pill = removeButton.closest("[data-pill]");
|
||||
if (!pill) return;
|
||||
if (isFilter) {
|
||||
// Filter pills have no hidden input; a modifier pill also clears the
|
||||
// container flag.
|
||||
// Filter pills have no hidden input.
|
||||
if (pill.hasAttribute("data-search-select-modifier")) {
|
||||
container.removeAttribute("data-modifier");
|
||||
clearModifierPill();
|
||||
} else {
|
||||
pill.remove();
|
||||
}
|
||||
pill.remove();
|
||||
emitChange(null);
|
||||
return;
|
||||
}
|
||||
@@ -431,8 +564,8 @@
|
||||
pills.querySelectorAll("[data-pill]").forEach(function (pill) {
|
||||
var pillModifier = pill.getAttribute("data-search-select-modifier");
|
||||
if (pillModifier) {
|
||||
modifier = pillModifier;
|
||||
return;
|
||||
modifier = pillModifier; // last modifier pill wins
|
||||
return; // skip value extraction for this pill
|
||||
}
|
||||
var value = pill.getAttribute("data-value");
|
||||
var label = pill.getAttribute("data-label") || "";
|
||||
|
||||
@@ -92,16 +92,68 @@ class FilterBarRenderingTest(TestCase):
|
||||
self._assert_shell(html, "/presets/purchases/list", "/presets/purchases/save")
|
||||
self._assert_range_slider(html)
|
||||
|
||||
def test_purchase_filter_bar_games_has_m2m_modifiers(self):
|
||||
"""The many-to-many games field surfaces (All)/(Only) pseudo-options
|
||||
in the dropdown alongside the presence (Any)/(None) rows. Single-valued
|
||||
fields (platform) do not get M2M modifiers."""
|
||||
html = str(
|
||||
PurchaseFilterBar(
|
||||
filter_json="", preset_list_url="/l", preset_save_url="/s"
|
||||
)
|
||||
)
|
||||
# (All) and (Only) appear as modifier rows in the dropdown.
|
||||
self.assertIn('data-search-select-modifier-option="INCLUDES_ALL"', html)
|
||||
self.assertIn('data-search-select-modifier-option="INCLUDES_ONLY"', html)
|
||||
# No legacy match-mode <select>.
|
||||
self.assertNotIn("data-search-select-match", html)
|
||||
# Platform is single-valued: no M2M modifier options in its section.
|
||||
games_start = html.find('data-name="games"')
|
||||
platform_start = html.find('data-name="platform"')
|
||||
platform_section = html[platform_start:]
|
||||
self.assertNotIn("INCLUDES_ALL", platform_section)
|
||||
self.assertGreater(games_start, 0)
|
||||
|
||||
def test_purchase_filter_bar_roundtrips_includes_all(self):
|
||||
"""A stored INCLUDES_ALL modifier renders as the modifier pill and the
|
||||
included game still renders as a value 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-modifier="INCLUDES_ALL"', html)
|
||||
self.assertIn("(All)", html) # modifier pill label
|
||||
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):
|
||||
"""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(
|
||||
FilterBar(
|
||||
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-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("Finished", html) # ...with its label
|
||||
self.assertNoEscapedTags(html)
|
||||
@@ -110,3 +162,27 @@ class FilterBarRenderingTest(TestCase):
|
||||
# for the double-escape bug the dedup fixed.
|
||||
self.assertIn(""status"", html)
|
||||
self.assertNotIn("&quot;", html)
|
||||
|
||||
def test_game_filter_bar_preserves_excludes_modifier(self):
|
||||
"""An enum field with an EXCLUDES modifier renders data-modifier correctly
|
||||
so the JS roundtrip preserves the modifier (regression: _split_modifier
|
||||
silently dropped non-presence modifiers when match_modes was None)."""
|
||||
filter_json = json.dumps(
|
||||
{
|
||||
"status": {
|
||||
"value": [{"id": "f", "label": "Finished"}],
|
||||
"modifier": "EXCLUDES",
|
||||
}
|
||||
}
|
||||
)
|
||||
html = str(
|
||||
FilterBar(
|
||||
filter_json=filter_json, preset_list_url="/l", preset_save_url="/s"
|
||||
)
|
||||
)
|
||||
# The full modifier is stored on data-modifier when there's no match-mode
|
||||
# select (enum/choice fields). No data-match attribute is present.
|
||||
self.assertIn('data-modifier="EXCLUDES"', html)
|
||||
self.assertNotIn("data-match=", html)
|
||||
self.assertIn("Finished", html)
|
||||
self.assertNoEscapedTags(html)
|
||||
|
||||
+332
-2
@@ -17,6 +17,21 @@ from common.components import FilterBar
|
||||
from games.filters import GameFilter
|
||||
|
||||
|
||||
class TestModifier:
|
||||
def test_includes_only_in_enum(self):
|
||||
assert Modifier.INCLUDES_ONLY == "INCLUDES_ONLY"
|
||||
|
||||
def test_includes_only_in_for_multi(self):
|
||||
assert Modifier.INCLUDES_ONLY in Modifier.for_multi()
|
||||
|
||||
def test_for_multi_includes_all_four_match_modes(self):
|
||||
modes = Modifier.for_multi()
|
||||
assert Modifier.INCLUDES in modes
|
||||
assert Modifier.INCLUDES_ALL in modes
|
||||
assert Modifier.INCLUDES_ONLY in modes
|
||||
assert Modifier.EXCLUDES in modes
|
||||
|
||||
|
||||
class TestStringCriterion:
|
||||
def test_equals(self):
|
||||
c = StringCriterion(value="zelda", modifier=Modifier.EQUALS)
|
||||
@@ -94,6 +109,24 @@ class TestChoiceCriterion:
|
||||
q = c.to_q("status")
|
||||
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"])
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"modifier", [Modifier.INCLUDES_ALL, Modifier.INCLUDES_ONLY]
|
||||
)
|
||||
def test_m2m_modifiers_require_filter_builder(self, modifier):
|
||||
"""INCLUDES_ALL / INCLUDES_ONLY cannot be built by the generic criterion
|
||||
layer — they require a filter-level Q builder (see
|
||||
PurchaseFilter._games_to_q)."""
|
||||
c = ChoiceCriterion(value=["f", "p"], modifier=modifier)
|
||||
with pytest.raises(AssertionError, match="requires a filter-level"):
|
||||
c.to_q("status")
|
||||
|
||||
def test_not_equals(self):
|
||||
c = ChoiceCriterion(value=["f"], modifier=Modifier.NOT_EQUALS)
|
||||
assert c.to_q("status") == ~Q(status__in=["f"])
|
||||
@@ -117,6 +150,24 @@ class TestMultiCriterion:
|
||||
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])
|
||||
|
||||
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])
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"modifier", [Modifier.INCLUDES_ALL, Modifier.INCLUDES_ONLY]
|
||||
)
|
||||
def test_m2m_modifiers_require_filter_builder(self, modifier):
|
||||
"""INCLUDES_ALL / INCLUDES_ONLY cannot be built by the generic criterion
|
||||
layer — they require a filter-level Q builder (see
|
||||
PurchaseFilter._games_to_q)."""
|
||||
c = MultiCriterion(value=[1, 2], modifier=modifier)
|
||||
with pytest.raises(AssertionError, match="requires a filter-level"):
|
||||
c.to_q("games")
|
||||
|
||||
def test_is_null(self):
|
||||
c = MultiCriterion(value=[], modifier=Modifier.IS_NULL)
|
||||
assert c.to_q("device_id") == Q(device_id__isnull=True)
|
||||
@@ -124,7 +175,10 @@ class TestMultiCriterion:
|
||||
def test_from_json_strips_embedded_labels(self):
|
||||
"""from_json normalises {id, label} dicts to bare ids."""
|
||||
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.excludes == [11]
|
||||
@@ -216,6 +270,210 @@ class TestChoiceCriterionAgainstDB:
|
||||
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_any_no_duplicates(self):
|
||||
"""INCLUDES [A, B] must not return duplicate rows for a purchase linked
|
||||
to both A and B — the M2M join must not inflate the result.
|
||||
|
||||
Regression: ``games__in`` on a many-to-many field produces one row per
|
||||
matching through-table entry, so a purchase linked to N of the selected
|
||||
games would appear N times. The fix uses a subquery so each purchase
|
||||
appears at most once.
|
||||
"""
|
||||
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 = list(Purchase.objects.filter(pf.to_q()))
|
||||
# Must have 3 distinct purchases, not duplicates
|
||||
assert len(result) == 3
|
||||
assert set(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 TestPurchaseGamesIncludesOnlyAgainstDB:
|
||||
"""INCLUDES_ONLY on the many-to-many ``Purchase.games`` should match only
|
||||
purchases linked to *exactly* the given games — Stash's ``only`` mode,
|
||||
which INCLUDES_ALL does not provide (it includes supersets)."""
|
||||
|
||||
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_only_matches_exact_set(self):
|
||||
"""INCLUDES_ONLY [A, B] returns only purchases with exactly A and B."""
|
||||
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_ONLY",
|
||||
}
|
||||
}
|
||||
)
|
||||
result = set(Purchase.objects.filter(pf.to_q()))
|
||||
assert result == {seeded["both"]}
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_includes_only_single_game(self):
|
||||
"""INCLUDES_ONLY [A] = exactly game A, no others."""
|
||||
from games.filters import PurchaseFilter
|
||||
from games.models import Purchase
|
||||
|
||||
seeded = self._seed()
|
||||
pf = PurchaseFilter.from_json(
|
||||
{
|
||||
"games": {
|
||||
"value": [seeded["a"].id],
|
||||
"modifier": "INCLUDES_ONLY",
|
||||
}
|
||||
}
|
||||
)
|
||||
result = set(Purchase.objects.filter(pf.to_q()))
|
||||
assert result == {seeded["only_a"]}
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_includes_only_contrast_with_includes_all(self):
|
||||
"""INCLUDES_ONLY excludes the superset that INCLUDES_ALL would match."""
|
||||
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_ONLY",
|
||||
}
|
||||
}
|
||||
)
|
||||
result = set(Purchase.objects.filter(pf.to_q()))
|
||||
# all_three has A, B, C — INCLUDES_ALL would match it, ONLY does not.
|
||||
assert seeded["all_three"] not in result
|
||||
assert seeded["both"] in result
|
||||
|
||||
|
||||
class TestGameFilterFromJson:
|
||||
def test_status_choice_criterion(self):
|
||||
gf = GameFilter.from_json(
|
||||
@@ -293,7 +551,12 @@ class TestFilterBarRendering:
|
||||
html = str(
|
||||
FilterBar(
|
||||
filter_json=json.dumps(
|
||||
{"status": {"value": [{"id": "f", "label": "Finished"}], "modifier": "INCLUDES"}}
|
||||
{
|
||||
"status": {
|
||||
"value": [{"id": "f", "label": "Finished"}],
|
||||
"modifier": "INCLUDES",
|
||||
}
|
||||
}
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -327,3 +590,70 @@ class TestFilterBarRendering:
|
||||
platform_section = html[platform_start:]
|
||||
# Should have at least one modifier option
|
||||
assert "(Any)" in platform_section or "(None)" in platform_section
|
||||
|
||||
|
||||
class TestPurchaseNumPurchasesAgainstDB:
|
||||
"""num_purchases IntCriterion filters purchases by game count."""
|
||||
|
||||
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})
|
||||
|
||||
single = Purchase.objects.create(
|
||||
platform=platform, date_purchased=datetime.date(2024, 1, 1)
|
||||
)
|
||||
single.games.set([a])
|
||||
|
||||
double = Purchase.objects.create(
|
||||
platform=platform, date_purchased=datetime.date(2024, 1, 1)
|
||||
)
|
||||
double.games.set([a, b])
|
||||
|
||||
triple = Purchase.objects.create(
|
||||
platform=platform, date_purchased=datetime.date(2024, 1, 1)
|
||||
)
|
||||
triple.games.set([a, b, c])
|
||||
|
||||
return {"single": single, "double": double, "triple": triple}
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_between_two_and_three(self):
|
||||
from games.filters import PurchaseFilter
|
||||
from games.models import Purchase
|
||||
|
||||
seeded = self._seed()
|
||||
pf = PurchaseFilter.from_json(
|
||||
{"num_purchases": {"value": 2, "value2": 3, "modifier": "BETWEEN"}}
|
||||
)
|
||||
result = set(Purchase.objects.filter(pf.to_q()))
|
||||
assert result == {seeded["double"], seeded["triple"]}
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_greater_than_one(self):
|
||||
from games.filters import PurchaseFilter
|
||||
from games.models import Purchase
|
||||
|
||||
seeded = self._seed()
|
||||
pf = PurchaseFilter.from_json(
|
||||
{"num_purchases": {"value": 1, "modifier": "GREATER_THAN"}}
|
||||
)
|
||||
result = set(Purchase.objects.filter(pf.to_q()))
|
||||
assert result == {seeded["double"], seeded["triple"]}
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_equals_one(self):
|
||||
from games.filters import PurchaseFilter
|
||||
from games.models import Purchase
|
||||
|
||||
seeded = self._seed()
|
||||
pf = PurchaseFilter.from_json(
|
||||
{"num_purchases": {"value": 1, "modifier": "EQUALS"}}
|
||||
)
|
||||
result = set(Purchase.objects.filter(pf.to_q()))
|
||||
assert result == {seeded["single"]}
|
||||
|
||||
@@ -179,7 +179,9 @@ class FilterSelectComponentTest(unittest.TestCase):
|
||||
self.assertIn('data-search-select-modifier-option="NOT_NULL"', html)
|
||||
self.assertIn('data-search-select-modifier-option="IS_NULL"', html)
|
||||
|
||||
def test_active_modifier_replaces_value_pills(self):
|
||||
def test_modifier_pill_coexists_with_value_pills(self):
|
||||
"""Modifier and value pills both render server-side; the JS handles
|
||||
mutual exclusivity for presence modifiers (PRESENCE_MODIFIERS)."""
|
||||
html = FilterSelect(
|
||||
field_name="platform",
|
||||
options=[("1", "Steam")],
|
||||
@@ -187,13 +189,12 @@ class FilterSelectComponentTest(unittest.TestCase):
|
||||
modifier="IS_NULL",
|
||||
modifier_options=self.MODIFIERS,
|
||||
)
|
||||
# The lone modifier pill is shown; include/exclude pills are suppressed.
|
||||
# (Scope the check to the live pills region — the cloneable pill <template>s
|
||||
# legitimately contain data-search-select-type.)
|
||||
pills_region = html.split("data-search-select-template")[0]
|
||||
# Both the modifier pill and the value pill render.
|
||||
self.assertIn('data-search-select-modifier="IS_NULL"', html)
|
||||
self.assertIn("(None)", html)
|
||||
self.assertNotIn('data-search-select-type="include"', pills_region)
|
||||
self.assertIn(
|
||||
'data-search-select-type="include"', html
|
||||
) # value pill present
|
||||
self.assertIn('data-modifier="IS_NULL"', html) # container carries it too
|
||||
|
||||
def test_search_url_omits_value_rows_but_keeps_modifiers(self):
|
||||
@@ -208,7 +209,9 @@ class FilterSelectComponentTest(unittest.TestCase):
|
||||
panel = html.split("data-search-select-template")[0]
|
||||
self.assertNotIn('data-search-select-option=""', panel)
|
||||
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)
|
||||
|
||||
def test_search_url_pills_use_resolved_labels(self):
|
||||
@@ -221,6 +224,64 @@ class FilterSelectComponentTest(unittest.TestCase):
|
||||
self.assertIn(">Obscure Game</span>", html)
|
||||
self.assertIn('data-value="4172"', html)
|
||||
|
||||
M2M_MODIFIERS = [
|
||||
("INCLUDES_ALL", "(All)"),
|
||||
("INCLUDES_ONLY", "(Only)"),
|
||||
]
|
||||
|
||||
def test_m2m_modifiers_render_as_option_rows(self):
|
||||
"""M2M modifiers (All)/(Only) render as modifier-option rows in the
|
||||
dropdown, not as a separate <select>."""
|
||||
html = FilterSelect(
|
||||
field_name="games",
|
||||
modifier_options=[
|
||||
("NOT_NULL", "(Any)"),
|
||||
("IS_NULL", "(None)"),
|
||||
("INCLUDES_ALL", "(All)"),
|
||||
("INCLUDES_ONLY", "(Only)"),
|
||||
],
|
||||
)
|
||||
self.assertIn(
|
||||
'data-search-select-modifier-option="INCLUDES_ALL"', html
|
||||
)
|
||||
self.assertIn(
|
||||
'data-search-select-modifier-option="INCLUDES_ONLY"', html
|
||||
)
|
||||
self.assertIn(
|
||||
'data-search-select-modifier-option="NOT_NULL"', html
|
||||
)
|
||||
# No legacy match-mode <select>.
|
||||
self.assertNotIn("data-search-select-match", html)
|
||||
|
||||
def test_active_modifier_renders_pill(self):
|
||||
"""When modifier is INCLUDES_ALL, the modifier pill renders with the
|
||||
(All) label alongside any value pills."""
|
||||
html = FilterSelect(
|
||||
field_name="games",
|
||||
modifier="INCLUDES_ALL",
|
||||
modifier_options=[
|
||||
("NOT_NULL", "(Any)"),
|
||||
("IS_NULL", "(None)"),
|
||||
("INCLUDES_ALL", "(All)"),
|
||||
("INCLUDES_ONLY", "(Only)"),
|
||||
],
|
||||
included=[{"value": 5, "label": "Hollow Knight", "data": {}}],
|
||||
)
|
||||
self.assertIn('data-modifier="INCLUDES_ALL"', html)
|
||||
self.assertIn("(All)", html)
|
||||
self.assertIn("Hollow Knight", html)
|
||||
self.assertIn('data-search-select-type="include"', html)
|
||||
|
||||
def test_presence_only_modifiers_no_m2m_rows(self):
|
||||
"""When modifier_options only has presence entries, no M2M rows appear."""
|
||||
html = FilterSelect(
|
||||
field_name="status",
|
||||
modifier_options=[("NOT_NULL", "(Any)"), ("IS_NULL", "(None)")],
|
||||
options=[("f", "Finished")],
|
||||
)
|
||||
self.assertNotIn("INCLUDES_ALL", html)
|
||||
self.assertNotIn("INCLUDES_ONLY", html)
|
||||
|
||||
|
||||
class SearchLabelTest(django.test.TestCase):
|
||||
@classmethod
|
||||
|
||||
Reference in New Issue
Block a user