"""Stash-style filter bars, built from FilterSelect widgets."""
from typing import NamedTuple
from django.db import models
from django.utils.safestring import SafeText, mark_safe
from common.components.core import Component
from common.components.primitives import Label, Span
from common.components.search_select import FilterSelect, LabeledOption
class FilterChoice(NamedTuple):
"""Parsed include/exclude/modifier state of a filter field from filter JSON.
``selected`` and ``excluded`` are lists of ``(value, label)`` pairs. For
model-backed fields the label is embedded in the filter JSON (Stash-style);
for enum fields the label is resolved from the fixed option list.
"""
selected: list[LabeledOption]
excluded: list[LabeledOption]
modifier: str
class RangeValues(NamedTuple):
"""A (min, max) string pair parsed from a range filter criterion."""
min: str
max: str
_FILTER_LABEL_CLASS = "text-xs font-medium text-body uppercase tracking-wide"
_FILTER_CHECKBOX_CLASS = (
"rounded border-default-medium bg-neutral-secondary-medium "
"text-brand focus:ring-brand"
)
_FILTER_GRID_CLASS = "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4"
def _filter_parse(filter_json: str) -> dict:
if not filter_json:
return {}
try:
import json
loaded = json.loads(filter_json)
return loaded if isinstance(loaded, dict) else {}
except (ValueError, TypeError):
return {}
def _extract_labeled(items: list[dict]) -> list[LabeledOption]:
"""Convert a list of ``{id, label}`` dicts to ``(value, label)`` pairs."""
return [(str(item["id"]), str(item["label"])) for item in items]
def _filter_get_choice(existing: dict, field: str) -> FilterChoice:
raw = existing.get(field, {})
if not isinstance(raw, dict):
return FilterChoice([], [], "")
return FilterChoice(
selected=_extract_labeled(raw.get("value") or []),
excluded=_extract_labeled(raw.get("excludes") or []),
modifier=raw.get("modifier") or "",
)
def _parse_range(existing: dict, key: str) -> RangeValues:
"""Extract (min, max) from a range filter criterion, defaulting to ("", "")."""
field = existing.get(key, {})
if not isinstance(field, dict):
return RangeValues("", "")
return RangeValues(str(field.get("value", "")), str(field.get("value2", "")))
def _parse_bool(existing: dict, key: str) -> bool:
"""Extract a boolean value from a filter criterion."""
field = existing.get(key, {})
if not isinstance(field, dict):
return False
return bool(field.get("value", False))
# ── FilterSelect adapters ────────────────────────────────────────────────────
# Each list filter is a FilterSelect. Enum fields pre-render their small, fixed
# option set; model-backed fields fetch from a search endpoint on demand, with
# labels embedded in the filter JSON so pills render without a DB round-trip.
_FILTER_PREFETCH = 20
# Presence modifiers drive the pinned (Any)/(None) pseudo-options (they clear the
# value set); every other modifier is a match mode for the include set.
_PRESENCE_MODIFIERS = frozenset({"NOT_NULL", "IS_NULL"})
# Include-set match modes (Stash's any/all/none axis). Offered only for
# many-to-many fields, where INCLUDES_ALL ("related to all of these") is
# meaningful — a single-valued field can never match all of several values.
_MATCH_MODES: list[LabeledOption] = [
("INCLUDES", "any"),
("INCLUDES_ALL", "all"),
("EXCLUDES", "none"),
]
def _modifier_options(nullable: bool) -> list[LabeledOption]:
"""Pinned (Any)/(None) pseudo-options; (None) only when the field is nullable."""
options = [("NOT_NULL", "(Any)")]
if nullable:
options.append(("IS_NULL", "(None)"))
return options
def _split_modifier(
modifier: str, match_modes: list[LabeledOption] | None
) -> tuple[str, str]:
"""Split a stored modifier into ``(presence_modifier, match_mode)``.
A criterion stores a single ``modifier``, but the widget surfaces it on two
orthogonal controls: the pinned (Any)/(None) presence pseudo-options and the
match-mode select. Presence modifiers (NOT_NULL/IS_NULL) route to the former;
the rest (INCLUDES/INCLUDES_ALL/EXCLUDES) to the latter. The match mode is
irrelevant when the field has no match-mode control, and falls back to the
first offered mode otherwise.
"""
default_match = match_modes[0][0] if match_modes else ""
if modifier in _PRESENCE_MODIFIERS:
return modifier, default_match
if modifier and match_modes:
return "", modifier
return "", default_match
def _enum_filter(
field_name: str, options, choice: FilterChoice, *, nullable
) -> SafeText:
"""A FilterSelect over a small, fully pre-rendered option set (enum field).
Enum fields are single-valued, so no match-mode control (any/all/none is
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
]
presence, _match = _split_modifier(choice.modifier, None)
return FilterSelect(
field_name=field_name,
options=options_str,
included=included,
excluded=excluded,
modifier=presence,
modifier_options=_modifier_options(nullable),
)
def _model_filter(
field_name: str,
choice: FilterChoice,
*,
search_url,
nullable,
match_modes: 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. Pass ``match_modes`` for
many-to-many fields to surface the any/all/none match-mode select.
"""
presence, match = _split_modifier(choice.modifier, match_modes)
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=presence,
modifier_options=_modifier_options(nullable),
match=match,
match_modes=match_modes or [],
search_url=search_url,
prefetch=_FILTER_PREFETCH,
)
def _filter_mins_to_hrs(val) -> str:
if val is None or val == "" or val == 0:
return ""
try:
mins = int(val)
except (TypeError, ValueError):
return ""
if mins == 0:
return ""
hrs = mins / 60
return str(int(hrs)) if hrs == int(hrs) else f"{hrs:.1f}"
def _filter_field(label: str, widget) -> SafeText:
"""A labelled filter field: