Consolidate Multi/Choice criteria into a shared _SetCriterion base

MultiCriterion and ChoiceCriterion were near-duplicate copies whose INCLUDES
branches had drifted — the exclude-only bug existed in one but not the other.
Extract the shared include/exclude/null set-membership logic into a _SetCriterion
base implemented once (INCLUDES with empty-list guards, EQUALS as an alias,
IS_NULL/NOT_NULL); subclasses contribute only their value type and their own
modifiers via _extra_q (INCLUDES_ALL for Multi; EXCLUDES/NOT_EQUALS for Choice).
Behaviour preserved (full modifier vocabulary kept); the duplication that caused
the drift is gone. Surfacing the modifier axis and harmonizing EXCLUDES is
tracked in #10.

https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
This commit is contained in:
Claude
2026-06-08 15:58:17 +00:00
committed by Lukáš Kucharczyk
parent 22d7834ae9
commit 112d3107ef
+57 -46
View File
@@ -267,78 +267,89 @@ class BoolCriterion(_Criterion):
@dataclass @dataclass
class MultiCriterion(_Criterion): 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:
- ``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.
Subclasses contribute their own modifiers (e.g. ``INCLUDES_ALL``) by
overriding ``_extra_q``.
"""
value: list = field(default_factory=list)
excludes: list = field(default_factory=list)
modifier: Modifier = Modifier.INCLUDES
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__}")
def _extra_q(self, field_name: str) -> Q | None:
"""Hook for subclass-specific modifiers; ``None`` means unsupported."""
return None
@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."""
value: list[int] = field(default_factory=list) value: list[int] = field(default_factory=list)
excludes: list[int] = field(default_factory=list) excludes: list[int] = field(default_factory=list)
modifier: Modifier = Modifier.INCLUDES
def to_q(self, field_name: str) -> Q: def _extra_q(self, field_name: str) -> Q | None:
m = self.modifier if self.modifier == Modifier.EXCLUDES:
if m == Modifier.INCLUDES:
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 m == Modifier.EXCLUDES:
return ~Q(**{f"{field_name}__in": self.value}) return ~Q(**{f"{field_name}__in": self.value})
if m == Modifier.INCLUDES_ALL: if self.modifier == Modifier.INCLUDES_ALL:
q = Q() q = Q()
for v in self.value: for value in self.value:
q &= Q(**{field_name: v}) q &= Q(**{field_name: value})
return q return q
if m == Modifier.IS_NULL: return None
return Q(**{f"{field_name}__isnull": True})
if m == Modifier.NOT_NULL:
return Q(**{f"{field_name}__isnull": False})
raise ValueError(f"Unsupported modifier {m} for multi field")
@dataclass @dataclass
class ChoiceCriterion(_Criterion): class ChoiceCriterion(_SetCriterion):
"""Filter on a choice/enum field with multi-select include/exclude. """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.
Supports INCLUDES, EXCLUDES, EQUALS, IS_NULL, NOT_NULL modifiers.
""" """
value: list[str] = field(default_factory=list) value: list[str] = field(default_factory=list)
excludes: list[str] = field(default_factory=list) excludes: list[str] = field(default_factory=list)
modifier: Modifier = Modifier.INCLUDES
def to_q(self, field_name: str) -> Q: def _extra_q(self, field_name: str) -> Q | None:
m = self.modifier if self.modifier == Modifier.EXCLUDES:
if m == Modifier.INCLUDES:
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 m == Modifier.EXCLUDES:
q = Q() q = Q()
if self.value: if self.value:
q &= ~Q(**{f"{field_name}__in": self.value}) q &= ~Q(**{f"{field_name}__in": self.value})
if self.excludes: if self.excludes:
q &= Q(**{f"{field_name}__in": self.excludes}) q &= Q(**{f"{field_name}__in": self.excludes})
return q return q
if m == Modifier.EQUALS: if self.modifier == Modifier.NOT_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 m == Modifier.NOT_EQUALS:
return ~Q(**{f"{field_name}__in": self.value}) return ~Q(**{f"{field_name}__in": self.value})
if m == Modifier.IS_NULL: return None
return Q(**{f"{field_name}__isnull": True})
if m == Modifier.NOT_NULL:
return Q(**{f"{field_name}__isnull": False})
raise ValueError(f"Unsupported modifier {m} for choice field")
# ── OperatorFilter base ──────────────────────────────────────────────────── # ── OperatorFilter base ────────────────────────────────────────────────────