"""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 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"})
# 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).
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
]
modifier = _split_modifier(choice.modifier)
return FilterSelect(
field_name=field_name,
options=options_str,
included=included,
excluded=excluded,
modifier=modifier,
modifier_options=_modifier_options(nullable),
)
def _model_filter(
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. 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=modifier,
modifier_options=_modifier_options(nullable, m2m_modifiers),
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: