Fix filter stuff

This commit is contained in:
2026-06-08 23:26:15 +02:00
parent ba9b92d419
commit 14efff8078
6 changed files with 174 additions and 21 deletions
+5 -2
View File
@@ -128,9 +128,12 @@ def _split_modifier(
first offered mode otherwise.
"""
default_match = match_modes[0][0] if match_modes else ""
if modifier in _PRESENCE_MODIFIERS:
if modifier in _PRESENCE_MODIFIERS or not match_modes:
# When there's no match-mode select, the modifier stays whole — it IS
# the full criterion modifier (enum/choice fields). Only split when a
# match-mode axis exists to receive the non-presence part.
return modifier, default_match
if modifier and match_modes:
if modifier:
return "", modifier
return "", default_match
+11 -11
View File
@@ -318,17 +318,17 @@ class _SetCriterion(_Criterion):
if modifier in (Modifier.EXCLUDES, Modifier.NOT_EQUALS):
return ~Q(**{f"{field_name}__in": self.value}) if self.value else Q()
if modifier == Modifier.INCLUDES_ALL:
# Logical AND of equalities ("related to every value"). NOTE: for a
# *multi-valued* relation this only behaves as "has all" when each
# equality lands on its own join — i.e. applied via chained
# ``.filter()`` calls or a ``pk__in`` subquery, not a single
# ``.filter(Q(rel=a) & Q(rel=b))`` (which would require one related
# row to equal both). M2M callers (e.g. PurchaseFilter.games) build
# that subquery; see PurchaseFilter._games_to_q.
q = Q()
for value in self.value:
q &= Q(**{field_name: value})
return q
# INCLUDES_ALL ("related to all of these") is 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 subquery
# pattern (chained .filter() calls + pk__in).
assert False, (
"INCLUDES_ALL 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
+120
View File
@@ -293,27 +293,85 @@
--leading-5: 20px;
--radius-base: 12px;
--color-body: var(--color-gray-600);
--color-body-subtle: var(--color-gray-500);
--color-heading: var(--color-gray-900);
--color-fg-brand-subtle: var(--color-blue-200);
--color-fg-brand: var(--color-blue-700);
--color-fg-brand-strong: var(--color-blue-900);
--color-fg-success: var(--color-emerald-700);
--color-fg-success-strong: var(--color-emerald-900);
--color-fg-danger: var(--color-rose-700);
--color-fg-danger-strong: var(--color-rose-900);
--color-fg-warning-subtle: var(--color-orange-600);
--color-fg-warning: var(--color-orange-900);
--color-fg-yellow: var(--color-yellow-400);
--color-fg-disabled: var(--color-gray-400);
--color-fg-purple: var(--color-purple-600);
--color-fg-cyan: var(--color-cyan-600);
--color-fg-indigo: var(--color-indigo-600);
--color-fg-pink: var(--color-pink-600);
--color-fg-lime: var(--color-lime-600);
--color-neutral-primary-soft: var(--color-white);
--color-neutral-primary: var(--color-white);
--color-neutral-primary-medium: var(--color-white);
--color-neutral-primary-strong: var(--color-white);
--color-neutral-secondary-soft: var(--color-gray-50);
--color-neutral-secondary: var(--color-gray-50);
--color-neutral-secondary-medium: var(--color-gray-50);
--color-neutral-secondary-strong: var(--color-gray-50);
--color-neutral-secondary-strongest: var(--color-gray-50);
--color-neutral-tertiary-soft: var(--color-gray-100);
--color-neutral-tertiary: var(--color-gray-100);
--color-neutral-tertiary-medium: var(--color-gray-100);
--color-neutral-quaternary: var(--color-gray-200);
--color-neutral-quaternary-medium: var(--color-gray-200);
--color-gray: var(--color-gray-300);
--color-brand-softer: var(--color-blue-50);
--color-brand-soft: var(--color-blue-100);
--color-brand: var(--color-blue-700);
--color-brand-medium: var(--color-blue-200);
--color-brand-strong: var(--color-blue-800);
--color-success-soft: var(--color-emerald-50);
--color-success: var(--color-emerald-700);
--color-success-medium: var(--color-emerald-100);
--color-success-strong: var(--color-emerald-800);
--color-danger-soft: var(--color-rose-50);
--color-danger: var(--color-rose-700);
--color-danger-medium: var(--color-rose-100);
--color-danger-strong: var(--color-rose-800);
--color-warning-soft: var(--color-orange-50);
--color-warning: var(--color-orange-500);
--color-warning-medium: var(--color-orange-100);
--color-warning-strong: var(--color-orange-700);
--color-dark-soft: var(--color-gray-800);
--color-dark: var(--color-gray-800);
--color-dark-strong: var(--color-gray-900);
--color-disabled: var(--color-gray-100);
--color-purple: var(--color-purple-500);
--color-sky: var(--color-sky-500);
--color-teal: var(--color-teal-600);
--color-pink: var(--color-pink-600);
--color-cyan: var(--color-cyan-500);
--color-fuchsia: var(--color-fuchsia-600);
--color-indigo: var(--color-indigo-600);
--color-orange: var(--color-orange-400);
--color-buffer: var(--color-white);
--color-buffer-medium: var(--color-white);
--color-buffer-strong: var(--color-white);
--color-muted: var(--color-gray-50);
--color-light-subtle: var(--color-gray-100);
--color-light: var(--color-gray-100);
--color-light-medium: var(--color-gray-100);
--color-default-subtle: var(--color-gray-200);
--color-default: var(--color-gray-200);
--color-default-medium: var(--color-gray-200);
--color-default-strong: var(--color-gray-200);
--color-success-subtle: var(--color-emerald-200);
--color-danger-subtle: var(--color-rose-200);
--color-warning-subtle: var(--color-orange-200);
--color-brand-subtle: var(--color-blue-200);
--color-brand-light: var(--color-blue-600);
--color-dark-subtle: var(--color-gray-800);
--color-dark-backdrop: var(--color-gray-950);
--color-accent: #7c3aed;
}
@@ -823,12 +881,18 @@
.start-0 {
inset-inline-start: calc(var(--spacing) * 0);
}
.end-1 {
inset-inline-end: calc(var(--spacing) * 1);
}
.end-1\.5 {
inset-inline-end: calc(var(--spacing) * 1.5);
}
.top-0 {
top: calc(var(--spacing) * 0);
}
.top-1 {
top: calc(var(--spacing) * 1);
}
.top-1\/2 {
top: calc(1 / 2 * 100%);
}
@@ -850,6 +914,9 @@
.bottom-0 {
bottom: calc(var(--spacing) * 0);
}
.bottom-1 {
bottom: calc(var(--spacing) * 1);
}
.bottom-1\.5 {
bottom: calc(var(--spacing) * 1.5);
}
@@ -1559,9 +1626,15 @@
text-align: center;
}
}
.w-1 {
width: calc(var(--spacing) * 1);
}
.w-1\/2 {
width: calc(1 / 2 * 100%);
}
.w-2 {
width: calc(var(--spacing) * 2);
}
.w-2\.5 {
width: calc(var(--spacing) * 2.5);
}
@@ -1679,6 +1752,9 @@
.shrink-0 {
flex-shrink: 0;
}
.border-collapse {
border-collapse: collapse;
}
.-translate-x-full {
--tw-translate-x: -100%;
translate: var(--tw-translate-x) var(--tw-translate-y);
@@ -1695,6 +1771,10 @@
--tw-translate-x: 100%;
translate: var(--tw-translate-x) var(--tw-translate-y);
}
.-translate-y-1 {
--tw-translate-y: calc(var(--spacing) * -1);
translate: var(--tw-translate-x) var(--tw-translate-y);
}
.-translate-y-1\/2 {
--tw-translate-y: calc(calc(1 / 2 * 100%) * -1);
translate: var(--tw-translate-x) var(--tw-translate-y);
@@ -2080,12 +2160,18 @@
.bg-amber-50 {
background-color: var(--color-amber-50);
}
.bg-amber-500 {
background-color: var(--color-amber-500);
}
.bg-amber-500\/15 {
background-color: color-mix(in srgb, oklch(76.9% 0.188 70.08) 15%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-amber-500) 15%, transparent);
}
}
.bg-black {
background-color: var(--color-black);
}
.bg-black\/70 {
background-color: color-mix(in srgb, #000 70%, transparent);
@supports (color: color-mix(in lab, red, red)) {
@@ -2110,6 +2196,9 @@
background-color: color-mix(in oklab, var(--color-brand) 15%, transparent);
}
}
.bg-dark-backdrop {
background-color: var(--color-dark-backdrop);
}
.bg-dark-backdrop\/70 {
background-color: color-mix(in srgb, oklch(13% 0.028 261.692) 70%, transparent);
@supports (color: color-mix(in lab, red, red)) {
@@ -2128,12 +2217,18 @@
.bg-gray-500 {
background-color: var(--color-gray-500);
}
.bg-gray-800 {
background-color: var(--color-gray-800);
}
.bg-gray-800\/20 {
background-color: color-mix(in srgb, oklch(27.8% 0.033 256.848) 20%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-gray-800) 20%, transparent);
}
}
.bg-gray-900 {
background-color: var(--color-gray-900);
}
.bg-gray-900\/50 {
background-color: color-mix(in srgb, oklch(21% 0.034 264.665) 50%, transparent);
@supports (color: color-mix(in lab, red, red)) {
@@ -2263,6 +2358,18 @@
fill: white !important;
}
}
.apexcharts-gridline {
stroke: var(--color-default) !important;
.dark & {
stroke: var(--color-default) !important;
}
}
.apexcharts-xcrosshairs {
stroke: var(--color-default) !important;
.dark & {
stroke: var(--color-default) !important;
}
}
.apexcharts-ycrosshairs {
stroke: var(--color-default) !important;
.dark & {
@@ -2321,6 +2428,9 @@
.px-6 {
padding-inline: calc(var(--spacing) * 6);
}
.py-0 {
padding-block: calc(var(--spacing) * 0);
}
.py-0\.5 {
padding-block: calc(var(--spacing) * 0.5);
}
@@ -2547,6 +2657,9 @@
.text-balance {
text-wrap: balance;
}
.text-wrap {
text-wrap: wrap;
}
.whitespace-nowrap {
white-space: nowrap;
}
@@ -2682,6 +2795,9 @@
.line-through {
text-decoration-line: line-through;
}
.no-underline {
text-decoration-line: none;
}
.no-underline\! {
text-decoration-line: none !important;
}
@@ -2748,6 +2864,10 @@
-webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
}
.backdrop-filter {
-webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
}
.transition {
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events;
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
+4 -2
View File
@@ -70,9 +70,11 @@
// 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.
// matches. Fields without a data-match attribute have no match-mode select
// — the full modifier lives in data-modifier (e.g. enum/choice fields).
var presence = widget.getAttribute("data-modifier");
var match = widget.getAttribute("data-match") || "INCLUDES";
var matchVal = widget.getAttribute("data-match");
var match = matchVal || presence || "INCLUDES";
if (presence === "NOT_NULL" || presence === "IS_NULL") {
filter[field] = { modifier: presence };
} else if (included.length > 0 || excluded.length > 0) {
+24
View File
@@ -158,3 +158,27 @@ class FilterBarRenderingTest(TestCase):
# for the double-escape bug the dedup fixed.
self.assertIn(""status"", html)
self.assertNotIn(""", 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)
+10 -6
View File
@@ -101,10 +101,12 @@ class TestChoiceCriterion:
c = ChoiceCriterion(value=["f"], excludes=["a"], modifier=Modifier.EXCLUDES)
assert c.to_q("status") == ~Q(status__in=["f"]) & ~Q(status__in=["a"])
def test_includes_all(self):
"""INCLUDES_ALL ANDs an equality per value (shared with MultiCriterion)."""
def test_includes_all_requires_filter_builder(self):
"""INCLUDES_ALL cannot be built by the generic criterion layer — it
requires a filter-level Q builder (see PurchaseFilter._games_to_q)."""
c = ChoiceCriterion(value=["f", "p"], modifier=Modifier.INCLUDES_ALL)
assert c.to_q("status") == Q(status="f") & Q(status="p")
with pytest.raises(AssertionError, match="INCLUDES_ALL requires"):
c.to_q("status")
def test_not_equals(self):
c = ChoiceCriterion(value=["f"], modifier=Modifier.NOT_EQUALS)
@@ -136,10 +138,12 @@ class TestMultiCriterion:
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])
def test_includes_all(self):
"""INCLUDES_ALL requires the row to relate to every value (M2M)."""
def test_includes_all_requires_filter_builder(self):
"""INCLUDES_ALL cannot be built by the generic criterion layer — it
requires a filter-level Q builder (see PurchaseFilter._games_to_q)."""
c = MultiCriterion(value=[1, 2], modifier=Modifier.INCLUDES_ALL)
assert c.to_q("games") == Q(games=1) & Q(games=2)
with pytest.raises(AssertionError, match="INCLUDES_ALL requires"):
c.to_q("games")
def test_is_null(self):
c = MultiCriterion(value=[], modifier=Modifier.IS_NULL)