"""Stash-style filter bars, built from FilterSelect widgets."""
from typing import NamedTuple
from common.components.core import BaseComponent, Element, Node, Safe
from common.components.custom_elements import _FilterBarElement
from common.components.date_range_picker import DateRangePicker
from common.components.primitives import Checkbox, Div, Input, Label, Radio, Span
from common.components.search_select import (
DEFAULT_PREFETCH,
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
class NumberValues(NamedTuple):
"""(value, value2, modifier) parsed from a numeric filter criterion."""
value: str
value2: str
modifier: 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_RADIO_CLASS = (
"rounded-full 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) -> list[LabeledOption]:
"""Convert filter values to ``(value, label)`` pairs.
UI-built filters carry ``{id, label}`` dicts; programmatically-built ones
(e.g. stats_links) carry bare ids/choices. A bare value uses itself as its
own label so the bar renders any valid filter instead of crashing."""
pairs: list[LabeledOption] = []
for item in items:
if isinstance(item, dict):
pairs.append((str(item["id"]), str(item["label"])))
else:
pairs.append((str(item), str(item)))
return pairs
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_number(existing: dict, key: str) -> NumberValues:
"""Extract (value, value2, modifier) from a numeric filter criterion.
Backward compatible with old RangeSlider JSON: a stored GREATER_THAN /
LESS_THAN / BETWEEN criterion maps straight onto value/value2/modifier.
"""
field = existing.get(key, {})
if not isinstance(field, dict):
return NumberValues("", "", "EQUALS")
return NumberValues(
str(field.get("value", "")),
str(field.get("value2", "")),
str(field.get("modifier") or "EQUALS"),
)
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))
def _parse_bool_nullable(existing: dict, key: str) -> bool | None:
"""Extract a nullable boolean value from a filter criterion."""
if key not in existing:
return None
field = existing[key]
if not isinstance(field, dict):
return None
val = field.get("value")
if val is None:
return None
if isinstance(val, str):
if val.lower() in ("true", "1", "yes"):
return True
if val.lower() in ("false", "0", "no"):
return False
return bool(val)
# ── 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.
# 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) -> Node:
"""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,
) -> Node:
"""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=DEFAULT_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 _widget_id(widget) -> str:
"""Best-effort id of a widget node, for the field label's ``for`` target.
Widgets are nodes carrying ``.attributes``, so the id is now reachable
directly (the old free ``Component`` string couldn't expose it).
"""
for name, value in getattr(widget, "attributes", []):
if name == "id":
return str(value)
return ""
def _filter_field(label: str, widget) -> Node:
"""A labelled filter field: ``
… {widget}
``.
The label's ``for`` points at the widget's own id when it has one;
composite widgets without a single root id simply omit ``for``.
"""
label_attributes = [("class", _FILTER_LABEL_CLASS)]
widget_id = _widget_id(widget)
if widget_id:
label_attributes.append(("for", widget_id))
return Div(
attributes=[("class", "flex flex-col gap-1")],
children=[
Label(attributes=label_attributes, children=[label]),
widget,
],
)
def _filter_checkbox(name: str, label: str, checked: bool) -> Node:
"""Thin adapter mapping legacy checkbox filters to the generalized Checkbox primitive."""
return Checkbox(name=name, label=label, checked=checked)
def _filter_boolean_radio(name: str, label: str, value: bool | None) -> Node:
"""Renders a filter-specific boolean radio button group with 'True' and 'False' options."""
return Div(
attributes=[("class", "flex flex-col gap-1")],
children=[
Span(
attributes=[("class", _FILTER_LABEL_CLASS)],
children=[label],
),
Div(
attributes=[("class", "flex items-center gap-4 h-9")],
children=[
Radio(name=name, label="True", checked=value is True, value="true"),
Radio(
name=name, label="False", checked=value is False, value="false"
),
],
),
],
)
_DATE_RANGE_INPUT_CLASS = (
"w-full rounded-base border border-default-medium bg-neutral-secondary-medium "
"text-sm text-heading p-1.5 focus:ring-brand focus:border-brand"
)
def DateRangeFilter(
*,
label: str,
input_name_prefix: str,
min_value: str = "",
max_value: str = "",
min_placeholder: str = "From",
max_placeholder: str = "To",
) -> Node:
"""A pair of `` `` elements representing a date range.
Two inputs named ``{prefix}-min`` and ``{prefix}-max`` — the browser's
native date picker is the UI. Serialized client-side into a ``DateCriterion``
with ``BETWEEN`` / ``GREATER_THAN`` / ``LESS_THAN`` depending on which
bound(s) the user filled.
"""
min_input_id = f"{input_name_prefix}-min"
max_input_id = f"{input_name_prefix}-max"
return Div(
attributes=[("class", "date-range-block mb-4")],
children=[
Div(
attributes=[("class", "flex items-center gap-2")],
children=[
Input(
attributes=[
("type", "date"),
("name", min_input_id),
("id", min_input_id),
("value", min_value),
("placeholder", min_placeholder),
("aria-label", f"{label} from"),
("class", _DATE_RANGE_INPUT_CLASS),
],
),
Span(
attributes=[("class", "text-body text-sm")],
children=["–"],
),
Input(
attributes=[
("type", "date"),
("name", max_input_id),
("id", max_input_id),
("value", max_value),
("placeholder", max_placeholder),
("aria-label", f"{label} to"),
("class", _DATE_RANGE_INPUT_CLASS),
],
),
],
),
],
)
_FILTER_FORM_ID = "filter-bar-form"
_FILTER_INPUT_ID = "filter-json-input"
def _filter_collapse_button() -> Node:
return Element(
"button",
attributes=[
("type", "button"),
# Slider handles are positioned in percentages, so initializing
# them while the body is hidden is safe — no re-init on reveal.
# Click is wired by filter-bar.ts (no inline handler).
("data-filter-bar-toggle", ""),
(
"class",
"flex items-center gap-2 text-sm font-medium text-body "
"hover:text-heading mb-2",
),
],
children=[
Safe(
' '
),
"Filters",
],
)
def _filter_action_row() -> Node:
return Div(
attributes=[("class", "flex gap-3 items-center")],
children=[
Element(
"button",
attributes=[
("type", "submit"),
(
"class",
"px-4 py-2 text-sm font-medium text-white bg-brand "
"rounded-lg hover:bg-brand-strong focus:ring-4 "
"focus:ring-brand-medium",
),
],
children=["Apply"],
),
Element(
"button",
attributes=[
("type", "button"),
("data-filter-bar-clear", ""),
(
"class",
"px-4 py-2 text-sm font-medium text-gray-900 bg-white "
"border border-gray-200 rounded-lg hover:bg-gray-100 "
"dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 "
"dark:hover:bg-gray-700 dark:hover:text-white",
),
],
children=["Clear"],
),
Span(
attributes=[
("class", "flex gap-2 items-center"),
("id", "save-preset-area"),
],
children=[
Input(
attributes=[
("type", "text"),
("id", "preset-name-input"),
("data-filter-bar-preset-name", ""),
("placeholder", "Preset name..."),
(
"class",
"hidden px-3 py-2 text-sm rounded-lg border "
"border-default-medium bg-neutral-secondary-medium "
"text-heading focus:ring-brand focus:border-brand",
),
],
),
Element(
"button",
attributes=[
("type", "button"),
("id", "save-preset-btn"),
("data-filter-bar-save", ""),
(
"class",
"px-4 py-2 text-sm font-medium text-gray-900 "
"bg-white border border-gray-200 rounded-lg "
"hover:bg-gray-100 dark:bg-gray-800 "
"dark:border-gray-600 dark:text-gray-400 "
"dark:hover:bg-gray-700 dark:hover:text-white",
),
],
children=["Save Preset"],
),
Element(
"button",
attributes=[
("type", "button"),
("id", "confirm-save-preset-btn"),
("data-filter-bar-confirm-save", ""),
(
"class",
"hidden px-4 py-2 text-sm font-medium text-white "
"bg-green-700 rounded-lg hover:bg-green-800 "
"focus:ring-4 focus:ring-green-300",
),
],
children=["Save"],
),
],
),
Div(
attributes=[
("id", "preset-dropdown"),
("class", "relative"),
],
children=[
Span(
attributes=[("class", "text-sm text-body")],
children=["Loading presets..."],
),
],
),
],
)
class _FilterBarBase(BaseComponent):
"""Shared collapsible filter-bar chrome.
Subclasses implement ``build_fields()`` returning the per-entity body
(grids, sliders, checkboxes); this base wraps it in the collapse toggle,
the form, the hidden filter-json input and the Apply/Clear/preset action
row. ``filter-bar.js`` (declared via ``_FilterBarElement``) wires the
chrome; widget media bubbles up from the contained widgets via the node
tree, so the view never threads ``scripts=`` by hand.
"""
def __init__(
self,
filter_json: str = "",
preset_list_url: str = "",
preset_save_url: str = "",
) -> None:
self.filter_json = filter_json
self.preset_list_url = preset_list_url
self.preset_save_url = preset_save_url
self.existing = _filter_parse(filter_json)
def build_fields(self) -> list:
"""Return the per-entity filter body. Implemented by each subclass."""
raise NotImplementedError
def render(self) -> Node:
return _FilterBarElement(
preset_list_url=self.preset_list_url,
preset_save_url=self.preset_save_url,
)[
Div(
attributes=[("id", "filter-bar"), ("class", "mb-6")],
children=[
_filter_collapse_button(),
Div(
attributes=[
("id", "filter-bar-body"),
(
"class",
"hidden border border-default-medium rounded-base p-4 "
"bg-neutral-secondary-medium/50",
),
],
children=[
Element(
"form",
attributes=[
("id", _FILTER_FORM_ID),
],
children=[
Input(
attributes=[
("type", "hidden"),
("id", _FILTER_INPUT_ID),
("name", "filter"),
# NB: attribute values are escaped, so the
# raw JSON passes through (no double-escape).
("value", self.filter_json),
],
),
*self.build_fields(),
_filter_action_row(),
],
),
],
),
],
)
]
class FilterBar(_FilterBarBase):
"""Collapsible filter bar for the Game list."""
def __init__(
self,
filter_json: str = "",
status_options: list[LabeledOption] | None = None,
preset_list_url: str = "",
preset_save_url: str = "",
) -> None:
super().__init__(filter_json, preset_list_url, preset_save_url)
self.status_options = status_options
def build_fields(self) -> list:
return _game_fields(self.existing, self.status_options)
def _game_fields(
existing: dict, status_options: list[LabeledOption] | None = None
) -> list:
from games.models import Game, Purchase
if status_options is None:
status_options = [(s.value, s.label) for s in Game.Status]
status_choice = _filter_get_choice(existing, "status")
platform_choice = _filter_get_choice(existing, "platform")
platform_group_choice = _filter_get_choice(existing, "platform_group")
device_choice = _filter_get_choice(existing, "device")
purchase_type_choice = _filter_get_choice(existing, "purchase_type")
purchase_ownership_choice = _filter_get_choice(existing, "purchase_ownership_type")
playevent_note_value = existing.get("playevent_note", {}).get("value", "")
playevent_note_modifier = existing.get("playevent_note", {}).get(
"modifier", "EQUALS"
)
year = _parse_number(existing, "year_released")
original_year = _parse_number(existing, "original_year_released")
mastered_value = _parse_bool_nullable(existing, "mastered")
playtime = _parse_number(existing, "playtime_hours")
session_count = _parse_number(existing, "session_count")
session_avg = _parse_number(existing, "session_average")
purchase_count = _parse_number(existing, "purchase_count")
playevent_count = _parse_number(existing, "playevent_count")
manual_pt = _parse_number(existing, "manual_playtime_hours")
calc_pt = _parse_number(existing, "calculated_playtime_hours")
price_total = _parse_number(existing, "purchase_price_total")
price_any = _parse_number(existing, "purchase_price_any")
purchase_refunded_value = _parse_bool_nullable(existing, "purchase_refunded")
purchase_infinite_value = _parse_bool_nullable(existing, "purchase_infinite")
session_emulated_value = _parse_bool_nullable(existing, "session_emulated")
fields = [
Div(
attributes=[("class", _FILTER_GRID_CLASS)],
children=[
_filter_field(
"Status",
_enum_filter(
"status",
status_options,
status_choice,
nullable=not Game._meta.get_field("status").has_default(),
),
),
_filter_field(
"Platform",
_model_filter(
"platform",
platform_choice,
search_url="/api/platforms/search",
nullable=Game._meta.get_field("platform").null,
),
),
_filter_field(
"Platform Group",
_model_filter(
"platform_group",
platform_group_choice,
search_url="/api/platforms/groups",
nullable=False,
),
),
_filter_field(
"Device",
_model_filter(
"device",
device_choice,
search_url="/api/devices/search",
nullable=False,
),
),
_filter_field(
"Purchase Type",
_enum_filter(
"purchase_type",
Purchase.TYPES,
purchase_type_choice,
nullable=False,
),
),
_filter_field(
"Purchase Ownership",
_enum_filter(
"purchase_ownership_type",
Purchase.OWNERSHIP_TYPES,
purchase_ownership_choice,
nullable=False,
),
),
_filter_field(
"Playevent Note",
StringFilter(
input_name_prefix="filter-playevent_note",
value=playevent_note_value,
modifier=playevent_note_modifier,
placeholder="e.g. Completed, Started",
),
),
_filter_field(
"Year",
NumberFilter(
input_name_prefix="filter-year",
value=year.value,
value2=year.value2,
modifier=year.modifier,
placeholder="e.g. 2020",
placeholder2="e.g. 2024",
),
),
_filter_field(
"Original Year",
NumberFilter(
input_name_prefix="filter-original-year",
value=original_year.value,
value2=original_year.value2,
modifier=original_year.modifier,
placeholder="e.g. 1985",
placeholder2="e.g. 2010",
),
),
_filter_field(
"Total playtime",
NumberFilter(
input_name_prefix="filter-playtime-hours",
value=playtime.value,
value2=playtime.value2,
modifier=playtime.modifier,
placeholder="e.g. 1",
placeholder2="e.g. 100",
),
),
_filter_field(
"Manual Playtime (hrs)",
NumberFilter(
input_name_prefix="filter-manual-playtime-hours",
value=manual_pt.value,
value2=manual_pt.value2,
modifier=manual_pt.modifier,
placeholder="e.g. 1",
placeholder2="e.g. 10",
),
),
_filter_field(
"Calculated Playtime (hrs)",
NumberFilter(
input_name_prefix="filter-calculated-playtime-hours",
value=calc_pt.value,
value2=calc_pt.value2,
modifier=calc_pt.modifier,
placeholder="e.g. 1",
placeholder2="e.g. 10",
),
),
_filter_field(
"Session Count",
NumberFilter(
input_name_prefix="filter-session-count",
value=session_count.value,
value2=session_count.value2,
modifier=session_count.modifier,
placeholder="e.g. 1",
placeholder2="e.g. 50",
),
),
_filter_field(
"Average Session Duration (mins)",
NumberFilter(
input_name_prefix="filter-session-average",
value=session_avg.value,
value2=session_avg.value2,
modifier=session_avg.modifier,
placeholder="e.g. 10",
placeholder2="e.g. 120",
),
),
_filter_field(
"Number of Purchases",
NumberFilter(
input_name_prefix="filter-purchase-count",
value=purchase_count.value,
value2=purchase_count.value2,
modifier=purchase_count.modifier,
placeholder="e.g. 1",
placeholder2="e.g. 5",
),
),
_filter_field(
"Number of Play Events",
NumberFilter(
input_name_prefix="filter-playevent-count",
value=playevent_count.value,
value2=playevent_count.value2,
modifier=playevent_count.modifier,
placeholder="e.g. 1",
placeholder2="e.g. 5",
),
),
_filter_field(
"Total Purchase Price",
NumberFilter(
input_name_prefix="filter-purchase-price-total",
value=price_total.value,
value2=price_total.value2,
modifier=price_total.modifier,
placeholder="0",
placeholder2="e.g. 100",
step="0.01",
),
),
_filter_field(
"Any Purchase Price",
NumberFilter(
input_name_prefix="filter-purchase-price-any",
value=price_any.value,
value2=price_any.value2,
modifier=price_any.modifier,
placeholder="0",
placeholder2="e.g. 100",
step="0.01",
),
),
],
),
Div(
attributes=[("class", "flex items-end gap-6 mb-4 flex-wrap")],
children=[
_filter_boolean_radio("filter-mastered", "Mastered", mastered_value),
_filter_boolean_radio(
"filter-purchase-refunded", "Refunded", purchase_refunded_value
),
_filter_boolean_radio(
"filter-purchase-infinite", "Infinite", purchase_infinite_value
),
_filter_boolean_radio(
"filter-session-emulated", "Emulated", session_emulated_value
),
],
),
]
return fields
def _find_label(options: list[LabeledOption], value: str) -> str:
for v, label in options:
if str(v) == str(value):
return label
return value
class SessionFilterBar(_FilterBarBase):
"""Collapsible filter bar for the Session list."""
def build_fields(self) -> list:
return _session_fields(self.existing)
def _session_fields(existing: dict) -> list:
from games.models import Game, Session
game_choice = _filter_get_choice(existing, "game")
device_choice = _filter_get_choice(existing, "device")
note_value = existing.get("note", {}).get("value", "")
note_modifier = existing.get("note", {}).get("modifier", "EQUALS")
dur_tot = _parse_number(existing, "duration_total_hours")
dur_man = _parse_number(existing, "duration_manual_hours")
dur_calc = _parse_number(existing, "duration_calculated_hours")
emulated_value = _parse_bool_nullable(existing, "emulated")
is_active_value = _parse_bool_nullable(existing, "is_active")
fields = [
Div(
attributes=[("class", _FILTER_GRID_CLASS)],
children=[
_filter_field(
"Game",
_model_filter(
"game",
game_choice,
search_url="/api/games/search",
nullable=not Game._meta.get_field("name").has_default(),
),
),
_filter_field(
"Device",
_model_filter(
"device",
device_choice,
search_url="/api/devices/search",
nullable=Session._meta.get_field("device").null,
),
),
_filter_field(
"Session Note",
StringFilter(
input_name_prefix="filter-note",
value=note_value,
modifier=note_modifier,
placeholder="e.g. Boss fight, speedrun",
),
),
],
),
_filter_field(
"Total Duration (hrs)",
NumberFilter(
input_name_prefix="filter-duration-total-hours",
value=dur_tot.value,
value2=dur_tot.value2,
modifier=dur_tot.modifier,
placeholder="e.g. 1",
placeholder2="e.g. 10",
),
),
_filter_field(
"Manual Duration (hrs)",
NumberFilter(
input_name_prefix="filter-duration-manual-hours",
value=dur_man.value,
value2=dur_man.value2,
modifier=dur_man.modifier,
placeholder="e.g. 1",
placeholder2="e.g. 10",
),
),
_filter_field(
"Calculated Duration (hrs)",
NumberFilter(
input_name_prefix="filter-duration-calculated-hours",
value=dur_calc.value,
value2=dur_calc.value2,
modifier=dur_calc.modifier,
placeholder="e.g. 1",
placeholder2="e.g. 10",
),
),
Div(
attributes=[("class", "flex gap-6 mb-4")],
children=[
_filter_boolean_radio("filter-emulated", "Emulated", emulated_value),
_filter_boolean_radio("filter-active", "Active", is_active_value),
],
),
]
return fields
class PurchaseFilterBar(_FilterBarBase):
"""Collapsible filter bar for the Purchase list."""
def build_fields(self) -> list:
return _purchase_fields(self.existing)
def _purchase_fields(existing: dict) -> list:
from games.models import Purchase
type_options = Purchase.TYPES
ownership_options = Purchase.OWNERSHIP_TYPES
game_choice = _filter_get_choice(existing, "games")
platform_choice = _filter_get_choice(existing, "platform")
type_choice = _filter_get_choice(existing, "type")
ownership_choice = _filter_get_choice(existing, "ownership_type")
price = _parse_number(existing, "price")
is_refunded_value = _parse_bool_nullable(existing, "is_refunded")
infinite_value = _parse_bool_nullable(existing, "infinite")
needs_price_update_value = _parse_bool_nullable(existing, "needs_price_update")
price_currency_value = existing.get("price_currency", {}).get("value", "")
price_currency_modifier = existing.get("price_currency", {}).get(
"modifier", "EQUALS"
)
converted_currency_value = existing.get("converted_currency", {}).get("value", "")
converted_currency_modifier = existing.get("converted_currency", {}).get(
"modifier", "EQUALS"
)
date_purchased_min, date_purchased_max = _parse_range(existing, "date_purchased")
date_refunded_min, date_refunded_max = _parse_range(existing, "date_refunded")
num = _parse_number(existing, "num_purchases")
fields = [
Div(
attributes=[("class", _FILTER_GRID_CLASS)],
children=[
_filter_field(
"Game",
_model_filter(
"games",
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(
"Platform",
_model_filter(
"platform",
platform_choice,
search_url="/api/platforms/search",
nullable=Purchase._meta.get_field("platform").null,
),
),
_filter_field(
"Type",
_enum_filter(
"type",
type_options,
type_choice,
nullable=not Purchase._meta.get_field("type").has_default(),
),
),
_filter_field(
"Ownership",
_enum_filter(
"ownership_type",
ownership_options,
ownership_choice,
nullable=not Purchase._meta.get_field(
"ownership_type"
).has_default(),
),
),
Div(
attributes=[("class", _FILTER_GRID_CLASS)],
children=[
_filter_field(
"Original Currency",
StringFilter(
input_name_prefix="filter-price_currency",
value=price_currency_value,
modifier=price_currency_modifier,
placeholder="e.g. USD, EUR",
),
),
_filter_field(
"Converted Currency",
StringFilter(
input_name_prefix="filter-converted_currency",
value=converted_currency_value,
modifier=converted_currency_modifier,
placeholder="e.g. USD, EUR",
),
),
],
),
_filter_field(
"Purchased",
DateRangePicker(
label="Purchased",
input_name_prefix="filter-date-purchased",
min_value=date_purchased_min,
max_value=date_purchased_max,
),
),
_filter_field(
"Refunded",
DateRangeFilter(
label="Refunded",
input_name_prefix="filter-date-refunded",
min_value=date_refunded_min,
max_value=date_refunded_max,
),
),
_filter_field(
"Price",
NumberFilter(
input_name_prefix="filter-price",
value=price.value,
value2=price.value2,
modifier=price.modifier,
placeholder="0.00",
placeholder2="100.00",
step="0.01",
),
),
_filter_field(
"Games in purchase",
NumberFilter(
input_name_prefix="filter-num-purchases",
value=num.value,
value2=num.value2,
modifier=num.modifier,
placeholder="e.g. 1",
placeholder2="e.g. 5",
),
),
Div(
attributes=[("class", "flex flex-col items-start gap-4 mb-4")],
children=[
_filter_boolean_radio(
"filter-refunded", "Refunded", is_refunded_value
),
_filter_boolean_radio(
"filter-infinite", "Infinite", infinite_value
),
_filter_boolean_radio(
"filter-needs-price-update",
"Needs Price Update",
needs_price_update_value,
),
],
),
],
),
]
return fields
class DeviceFilterBar(_FilterBarBase):
"""Collapsible filter bar for the Device list."""
def build_fields(self) -> list:
return _device_fields(self.existing)
def _device_fields(existing: dict) -> list:
from games.models import Device
type_options = Device.DEVICE_TYPES
type_choice = _filter_get_choice(existing, "type")
fields = [
Div(
attributes=[("class", _FILTER_GRID_CLASS)],
children=[
_filter_field(
"Device Type",
_enum_filter(
"type",
type_options,
type_choice,
nullable=True,
),
),
],
),
]
return fields
class PlatformFilterBar(_FilterBarBase):
"""Collapsible filter bar for the Platform list."""
def build_fields(self) -> list:
return _platform_fields(self.existing)
def _platform_fields(existing: dict) -> list:
name_value = existing.get("name", {}).get("value", "")
name_modifier = existing.get("name", {}).get("modifier", "EQUALS")
group_value = existing.get("group", {}).get("value", "")
group_modifier = existing.get("group", {}).get("modifier", "EQUALS")
fields = [
Div(
attributes=[("class", _FILTER_GRID_CLASS)],
children=[
_filter_field(
"Platform Name",
StringFilter(
input_name_prefix="filter-name",
value=name_value,
modifier=name_modifier,
placeholder="e.g. Nintendo Switch",
),
),
_filter_field(
"Platform Group",
StringFilter(
input_name_prefix="filter-group",
value=group_value,
modifier=group_modifier,
placeholder="e.g. Nintendo",
),
),
],
),
]
return fields
class PlayEventFilterBar(_FilterBarBase):
"""Collapsible filter bar for the PlayEvent list."""
def build_fields(self) -> list:
return _playevent_fields(self.existing)
def _playevent_fields(existing: dict) -> list:
game_choice = _filter_get_choice(existing, "game")
days = _parse_number(existing, "days_to_finish")
started_min, started_max = _parse_range(existing, "started")
ended_min, ended_max = _parse_range(existing, "ended")
fields = [
Div(
attributes=[("class", _FILTER_GRID_CLASS)],
children=[
_filter_field(
"Game",
_model_filter(
"game",
game_choice,
search_url="/api/games/search",
nullable=False,
),
),
],
),
_filter_field(
"Started",
DateRangePicker(
label="Started",
input_name_prefix="filter-started",
min_value=started_min,
max_value=started_max,
),
),
_filter_field(
"Finished",
DateRangePicker(
label="Finished",
input_name_prefix="filter-ended",
min_value=ended_min,
max_value=ended_max,
),
),
_filter_field(
"Days to Finish",
NumberFilter(
input_name_prefix="filter-days-to-finish",
value=days.value,
value2=days.value2,
modifier=days.modifier,
placeholder="e.g. 1",
placeholder2="e.g. 30",
),
),
]
return fields
def StringFilter(
input_name_prefix: str,
value: str = "",
modifier: str = "EQUALS",
placeholder: str = "",
) -> Node:
"""Renders a string filter with 8 modifier radio options and a text input."""
from common.criteria import Modifier
if modifier not in [m.value for m in Modifier.for_strings()]:
modifier = "EQUALS"
options = [
("EQUALS", "is"),
("NOT_EQUALS", "is not"),
("INCLUDES", "includes"),
("EXCLUDES", "excludes"),
("MATCHES_REGEX", "matches regex"),
("NOT_MATCHES_REGEX", "not matches regex"),
("IS_NULL", "is null"),
("NOT_NULL", "is not null"),
]
# Grid of Radios using standard Radio primitives
radio_buttons = [
Radio(
name=f"{input_name_prefix}-modifier",
label=lbl,
checked=(modifier == mod_val),
value=mod_val,
attributes=[
("data-string-modifier-radio", ""),
],
)
for mod_val, lbl in options
]
input_disabled = modifier in ("IS_NULL", "NOT_NULL")
input_attrs = [
("type", "text"),
("name", input_name_prefix),
("value", value if not input_disabled else ""),
("placeholder", placeholder),
(
"class",
# text-sm + px-3 py-2.5 match every other input (canonical size).
"w-full rounded-base border border-default-medium px-3 py-2.5 text-sm "
"bg-neutral-secondary-medium text-body "
"focus:border-brand focus:ring-brand "
# No transition-* here: with transition-all the border-color animated
# from near-white default → brand on focus, which read as a white
# "blink". The other inputs snap to the focus state, so this does too.
+ ("opacity-50 cursor-not-allowed" if input_disabled else ""),
),
]
if input_disabled:
input_attrs.append(("disabled", "true"))
return Div(
attributes=[("class", "flex flex-col gap-2 @container")],
children=[
Div(
attributes=[
(
"class",
"grid grid-cols-2 @md:grid-cols-4 gap-2 py-1",
)
],
children=radio_buttons,
),
Input(attributes=input_attrs),
],
)
# text-sm + px-3 py-2.5 match every other input (canonical size).
_NUMBER_FILTER_INPUT_CLASS = (
"w-full rounded-base border border-default-medium px-3 py-2.5 text-sm "
"bg-neutral-secondary-medium text-body focus:border-brand focus:ring-brand "
)
def NumberFilter(
input_name_prefix: str,
value: str = "",
value2: str = "",
modifier: str = "EQUALS",
placeholder: str = "",
placeholder2: str = "",
step: str = "1",
) -> Node:
"""Renders a numeric filter with 8 modifier radio options and two inputs.
Modeled 1:1 on :func:`StringFilter`. Both inputs are disabled for the
presence modifiers (IS_NULL/NOT_NULL); the second input is shown only for
the range modifiers (BETWEEN/NOT_BETWEEN). Initial state is server-rendered
so the widget never flashes before its JS runs.
"""
from common.criteria import Modifier
if modifier not in [m.value for m in Modifier.for_numbers()]:
modifier = "EQUALS"
options = [
("EQUALS", "is"),
("NOT_EQUALS", "is not"),
("GREATER_THAN", "is greater than"),
("LESS_THAN", "is less than"),
("BETWEEN", "between"),
("NOT_BETWEEN", "not between"),
("IS_NULL", "is null"),
("NOT_NULL", "is not null"),
]
radio_buttons = [
Radio(
name=f"{input_name_prefix}-modifier",
label=lbl,
checked=(modifier == mod_val),
value=mod_val,
attributes=[
("data-number-modifier-radio", ""),
],
)
for mod_val, lbl in options
]
inputs_disabled = modifier in ("IS_NULL", "NOT_NULL")
second_shown = modifier in ("BETWEEN", "NOT_BETWEEN")
disabled_class = "opacity-50 cursor-not-allowed" if inputs_disabled else ""
value_attrs = [
("name", input_name_prefix),
("value", value if not inputs_disabled else ""),
("placeholder", placeholder),
("step", step),
("class", _NUMBER_FILTER_INPUT_CLASS + disabled_class),
]
if inputs_disabled:
value_attrs.append(("disabled", "true"))
value2_attrs = [
("name", f"{input_name_prefix}-value2"),
("value", value2 if not inputs_disabled else ""),
("placeholder", placeholder2),
("step", step),
("data-number-value2", ""),
(
"class",
_NUMBER_FILTER_INPUT_CLASS
+ disabled_class
+ ("" if second_shown else " hidden"),
),
]
if inputs_disabled:
value2_attrs.append(("disabled", "true"))
return Div(
attributes=[("class", "flex flex-col gap-2 @container")],
children=[
Div(
attributes=[
(
"class",
"grid grid-cols-2 @md:grid-cols-4 gap-2 py-1",
)
],
children=radio_buttons,
),
Div(
attributes=[("class", "flex items-center gap-2")],
children=[
Input(type="number", attributes=value_attrs),
Input(type="number", attributes=value2_attrs),
],
),
],
)