"""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 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 _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[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)) 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 ) -> 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=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 _filter_field(label: str, widget, for_widget: str = None) -> SafeText: """A labelled filter field:
{widget}
. TODO: Use widget.attributes.get("id", "") to get the widget's ID instead of the superfluous "for" argument. This requires refactoring the Component function to be a class intead. Also see RangeSlider's TODO """ return Div( attributes=[("class", "flex flex-col gap-1")], children=[ Label( attributes=[ ("class", _FILTER_LABEL_CLASS), ("for", for_widget), ], children=[label], ), widget, ], ) def _filter_checkbox(name: str, label: str, checked: bool) -> SafeText: """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) -> SafeText: """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" ), ], ), ], ) # SVG icons for the mode toggle (shared across all RangeSliders) _RANGE_ICON_SVG = ( '' '' '' '' "" ) _POINT_ICON_SVG = ( '' '' "" ) _RANGE_SLIDER_INPUT_CLASS = ( "w-24 rounded-base border border-default-medium bg-neutral-secondary-medium " "text-sm text-heading p-1.5 focus:ring-brand focus:border-brand" ) def RangeSlider( *, label: str, input_name_prefix: str, min_value: str = "", max_value: str = "", range_min: int, range_max: int, step: str = "1", min_placeholder: str = "", max_placeholder: str = "", ) -> SafeText: """A labelled range slider with number inputs and range/point mode toggle. Renders a label row (label, two number inputs, toggle button) and a slider row (track with one or two custom draggable handles). Defaults to range mode (two handles). If min_value and max_value are both set and equal, starts in point mode (single handle). The toggle switches between modes. """ min_input_id = f"{input_name_prefix}-min" max_input_id = f"{input_name_prefix}-max" point_mode = bool(min_value and max_value and min_value == max_value) initial_mode = "point" if point_mode else "range" return Div( attributes=[("class", "range-slider-block mb-4")], children=[ # ── Label row ── Div( attributes=[("class", "flex items-center gap-2 mb-1")], children=[ # TODO: This should be done outside the RangeSlider component, but the current Component function doesn't allow getting the id # Label( # attributes=[ # ("class", _FILTER_LABEL_CLASS), # ("for", min_input_id), # ], # children=[label], # ), Input( attributes=[ ("type", "number"), ("name", min_input_id), ("id", min_input_id), ("value", min_value), ("placeholder", min_placeholder), ( "class", f"{_RANGE_SLIDER_INPUT_CLASS}" + (" hidden" if point_mode else ""), ), ], ), Span( attributes=[ ( "class", "range-dash text-body text-sm" + (" hidden" if point_mode else ""), ), ], children=["–"], ), Input( attributes=[ ("type", "number"), ("name", max_input_id), ("id", max_input_id), ("value", max_value), ("placeholder", max_placeholder), ("class", _RANGE_SLIDER_INPUT_CLASS), ], ), Component( tag_name="button", attributes=[ ("type", "button"), ( "class", "range-mode-toggle p-1 text-body hover:text-heading " "rounded cursor-pointer shrink-0", ), ( "title", "Toggle between range and single value", ), ( "aria-label", "Toggle between range and single value", ), ], children=[ Span( attributes=[ ( "class", "range-mode-icon-range" + (" hidden" if point_mode else ""), ), ], children=[mark_safe(_RANGE_ICON_SVG)], ), Span( attributes=[ ( "class", "range-mode-icon-point" + ("" if point_mode else " hidden"), ), ], children=[mark_safe(_POINT_ICON_SVG)], ), ], ), ], ), # ── Slider row ── Div( attributes=[ ("class", "range-slider relative h-10 w-5/6 select-none mt-1"), ("data-mode", initial_mode), ("data-min", str(range_min)), ("data-max", str(range_max)), ("data-step", str(step)), ], children=[ Div( attributes=[ ( "class", "absolute top-1/2 -translate-y-1/2 w-full h-2 " "rounded-full bg-neutral-quaternary", ), ], ), Div( attributes=[ ( "class", "range-track-fill absolute top-1/2 -translate-y-1/2 " "h-2 bg-brand rounded-full", ), ("style", "left:0;width:100%"), ], ), # Min handle (hidden in point mode via JS) Div( attributes=[ ( "class", "range-handle range-handle-min absolute top-1/2 " "-translate-y-1/2 w-5 h-5 bg-brand rounded-full " "border-2 border-white shadow cursor-pointer " "hover:scale-110 transition-transform", ), ("data-target", min_input_id), ( "style", "left:0" + (";display:none" if point_mode else ""), ), ], ), # Max handle Div( attributes=[ ( "class", "range-handle range-handle-max absolute top-1/2 " "-translate-y-1/2 w-5 h-5 bg-brand rounded-full " "border-2 border-white shadow cursor-pointer " "hover:scale-110 transition-transform", ), ("data-target", max_input_id), ("style", "left:100%"), ], ), ], ), ], ) _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", ) -> SafeText: """A pair of ```` elements representing a date range. Mirrors ``RangeSlider`` in shape (two inputs named ``{prefix}-min`` and ``{prefix}-max``) but without a slider track — 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() -> SafeText: return Component( tag_name="button", attributes=[ ("type", "button"), ( "onclick", "var b=document.getElementById('filter-bar-body');b.classList.toggle('hidden');if(!b.classList.contains('hidden')&&window.initRangeSliders)window.initRangeSliders()", ), ( "class", "flex items-center gap-2 text-sm font-medium text-body " "hover:text-heading mb-2", ), ], children=[ mark_safe( '' ), "Filters", ], ) def _filter_action_row(preset_list_url: str, preset_save_url: str) -> SafeText: return Div( attributes=[("class", "flex gap-3 items-center")], children=[ Component( tag_name="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"], ), Component( tag_name="button", attributes=[ ("type", "button"), ( "onclick", f"clearFilterBar('{_FILTER_FORM_ID}', '{_FILTER_INPUT_ID}')", ), ( "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"), ("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", ), ], ), Component( tag_name="button", attributes=[ ("type", "button"), ("id", "save-preset-btn"), ("onclick", "showPresetNameInput()"), ( "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"], ), Component( tag_name="button", attributes=[ ("type", "button"), ("id", "confirm-save-preset-btn"), ( "onclick", f"savePreset('{_FILTER_FORM_ID}', '{_FILTER_INPUT_ID}', '{preset_save_url}')", ), ( "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"), ("data-preset-list-url", preset_list_url), ], children=[ Span( attributes=[("class", "text-sm text-body")], children=["Loading presets..."], ), ], ), ], ) def _filter_bar(fields, filter_json, preset_list_url, preset_save_url) -> SafeText: """Shared collapsible filter-bar chrome. `fields` is the per-entity body (grids, sliders, checkboxes); the shell adds the collapse toggle, the form, the hidden filter-json input and the Apply/Clear/preset action row.""" return 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=[ Component( tag_name="form", attributes=[ ("id", _FILTER_FORM_ID), ("onsubmit", "return applyFilterBar(event)"), ], children=[ Input( attributes=[ ("type", "hidden"), ("id", _FILTER_INPUT_ID), ("name", "filter"), # NB: Component escapes attribute values, so the # raw JSON is passed through (no double-escape). ("value", filter_json), ], ), *fields, _filter_action_row(preset_list_url, preset_save_url), ], ), ], ), ], ) def FilterBar( filter_json: str = "", status_options: list[LabeledOption] | None = None, preset_list_url: str = "", preset_save_url: str = "", ) -> SafeText: """Collapsible filter bar for the Game list.""" from games.models import Game, Purchase if status_options is None: status_options = [(s.value, s.label) for s in Game.Status] existing = _filter_parse(filter_json) 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_min, year_max = _parse_range(existing, "year_released") original_year_min, original_year_max = _parse_range( existing, "original_year_released" ) mastered_value = _parse_bool_nullable(existing, "mastered") playtime = existing.get("playtime_minutes", {}) if isinstance(playtime, dict): playtime_min = _filter_mins_to_hrs(playtime.get("value", "")) playtime_max = _filter_mins_to_hrs(playtime.get("value2", "")) else: playtime_min = "" playtime_max = "" session_count_min, session_count_max = _parse_range(existing, "session_count") session_avg_min, session_avg_max = _parse_range(existing, "session_average") purchase_count_min, purchase_count_max = _parse_range(existing, "purchase_count") playevent_count_min, playevent_count_max = _parse_range(existing, "playevent_count") manual_pt_min, manual_pt_max = _parse_range(existing, "manual_playtime_minutes") calc_pt_min, calc_pt_max = _parse_range(existing, "calculated_playtime_minutes") price_total_min, price_total_max = _parse_range(existing, "purchase_price_total") price_any_min, price_any_max = _parse_range(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") try: year_aggregate = Game.objects.aggregate( year_min=models.Min("year_released"), year_max=models.Max("year_released") ) except Exception: year_aggregate = {} try: original_year_aggregate = Game.objects.aggregate( year_min=models.Min("original_year_released"), year_max=models.Max("original_year_released"), ) except Exception: original_year_aggregate = {} try: playtime_aggregate = Game.objects.aggregate(playtime_max=models.Max("playtime")) except Exception: playtime_aggregate = {} try: price_aggregate = Purchase.objects.aggregate( price_min=models.Min("converted_price"), price_max=models.Max("converted_price"), ) except Exception: price_aggregate = {} year_range_min = max(int(year_aggregate.get("year_min") or 1970), 1970) year_range_max = min(int(year_aggregate.get("year_max") or 2030), 2030) original_year_range_min = max( int(original_year_aggregate.get("year_min") or 1970), 1970 ) original_year_range_max = min( int(original_year_aggregate.get("year_max") or 2030), 2030 ) playtime_range_max = ( int((playtime_aggregate.get("playtime_max") or 0).total_seconds() / 3600) if playtime_aggregate.get("playtime_max") else 200 ) price_range_min = int(price_aggregate.get("price_min") or 0) price_range_max = max(int(price_aggregate.get("price_max") or 100), 1) 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", RangeSlider( label="Year", input_name_prefix="filter-year", min_value=year_min, max_value=year_max, range_min=year_range_min, range_max=year_range_max, min_placeholder="e.g. 2020", max_placeholder="e.g. 2024", ), ), _filter_field( "Original Year", RangeSlider( label="Original Year", input_name_prefix="filter-original-year", min_value=original_year_min, max_value=original_year_max, range_min=original_year_range_min, range_max=original_year_range_max, min_placeholder="e.g. 1985", max_placeholder="e.g. 2010", ), ), _filter_field( "Total playtime", RangeSlider( label="Total playtime", input_name_prefix="filter-playtime", min_value=playtime_min, max_value=playtime_max, range_min=0, range_max=playtime_range_max, step="1", min_placeholder="e.g. 1", max_placeholder="e.g. 100", ), ), _filter_field( "Manual Playtime (mins)", RangeSlider( label="Manual Playtime (mins)", input_name_prefix="filter-manual-playtime-minutes", min_value=manual_pt_min, max_value=manual_pt_max, range_min=0, range_max=max(playtime_range_max * 60, 240), step="1", min_placeholder="e.g. 10", max_placeholder="e.g. 120", ), ), _filter_field( "Calculated Playtime (mins)", RangeSlider( label="Calculated Playtime (mins)", input_name_prefix="filter-calculated-playtime-minutes", min_value=calc_pt_min, max_value=calc_pt_max, range_min=0, range_max=max(playtime_range_max * 60, 240), step="1", min_placeholder="e.g. 30", max_placeholder="e.g. 120", ), ), _filter_field( "Calculated Playtime (mins)", RangeSlider( label="Calculated Playtime (mins)", input_name_prefix="filter-calculated-playtime-minutes", min_value=calc_pt_min, max_value=calc_pt_max, range_min=0, range_max=max(playtime_range_max * 60, 240), step="1", min_placeholder="e.g. 30", max_placeholder="e.g. 180", ), ), _filter_field( "Session Count", RangeSlider( label="Session Count", input_name_prefix="filter-session-count", min_value=session_count_min, max_value=session_count_max, range_min=0, range_max=100, step="1", min_placeholder="e.g. 1", max_placeholder="e.g. 50", ), ), _filter_field( "Average Session Duration (mins)", RangeSlider( label="Average Session Duration (mins)", input_name_prefix="filter-session-average", min_value=session_avg_min, max_value=session_avg_max, range_min=0, range_max=240, step="1", min_placeholder="e.g. 10", max_placeholder="e.g. 120", ), ), _filter_field( "Number of Purchases", RangeSlider( label="Number of Purchases", input_name_prefix="filter-purchase-count", min_value=purchase_count_min, max_value=purchase_count_max, range_min=0, range_max=20, step="1", min_placeholder="e.g. 1", max_placeholder="e.g. 5", ), ), _filter_field( "Number of Play Events", RangeSlider( label="Number of Play Events", input_name_prefix="filter-playevent-count", min_value=playevent_count_min, max_value=playevent_count_max, range_min=0, range_max=20, step="1", min_placeholder="e.g. 1", max_placeholder="e.g. 5", ), ), _filter_field( "Total Purchase Price", RangeSlider( label="Total Purchase Price", input_name_prefix="filter-purchase-price-total", min_value=price_total_min, max_value=price_total_max, range_min=price_range_min, range_max=price_range_max, min_placeholder="0", max_placeholder=str(price_range_max), ), ), _filter_field( "Any Purchase Price", RangeSlider( label="Any Purchase Price", input_name_prefix="filter-purchase-price-any", min_value=price_any_min, max_value=price_any_max, range_min=price_range_min, range_max=price_range_max, min_placeholder="0", max_placeholder=str(price_range_max), ), ), ], ), 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 _filter_bar(fields, filter_json, preset_list_url, preset_save_url) def _find_label(options: list[LabeledOption], value: str) -> str: for v, label in options: if str(v) == str(value): return label return value def SessionFilterBar( filter_json="", preset_list_url="", preset_save_url="" ) -> SafeText: """Collapsible filter bar for the Session list.""" from games.models import Game, Session existing = _filter_parse(filter_json) 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_min, dur_tot_max = _parse_range(existing, "duration_total_minutes") dur_man_min, dur_man_max = _parse_range(existing, "duration_manual_minutes") dur_calc_min, dur_calc_max = _parse_range(existing, "duration_calculated_minutes") emulated_value = _parse_bool_nullable(existing, "emulated") is_active_value = _parse_bool_nullable(existing, "is_active") try: duration_aggregate = Session.objects.aggregate( duration_max=models.Max("duration_total") ) duration_range_max = max( int((duration_aggregate.get("duration_max") or 0).total_seconds() / 3600) if duration_aggregate.get("duration_max") else 200, 1, ) except Exception: duration_range_max = 200 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", ), ), ], ), RangeSlider( label="Total Duration (mins)", input_name_prefix="filter-duration-total-minutes", min_value=dur_tot_min, max_value=dur_tot_max, range_min=0, range_max=duration_range_max * 60, # Range sliders use minutes now step="1", min_placeholder="e.g. 30", max_placeholder="e.g. 180", ), RangeSlider( label="Manual Duration (mins)", input_name_prefix="filter-duration-manual-minutes", min_value=dur_man_min, max_value=dur_man_max, range_min=0, range_max=240, step="1", min_placeholder="e.g. 10", max_placeholder="e.g. 120", ), RangeSlider( label="Calculated Duration (mins)", input_name_prefix="filter-duration-calculated-minutes", min_value=dur_calc_min, max_value=dur_calc_max, range_min=0, range_max=duration_range_max * 60, step="1", min_placeholder="e.g. 30", max_placeholder="e.g. 180", ), 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 _filter_bar(fields, filter_json, preset_list_url, preset_save_url) def PurchaseFilterBar( filter_json="", preset_list_url="", preset_save_url="" ) -> SafeText: """Collapsible filter bar for the Purchase list.""" from games.models import Purchase type_options = Purchase.TYPES ownership_options = Purchase.OWNERSHIP_TYPES existing = _filter_parse(filter_json) 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_min, price_max = _parse_range(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") try: price_aggregate = Purchase.objects.aggregate( price_min=models.Min("price"), price_max=models.Max("price") ) price_range_min = int(price_aggregate.get("price_min") or 0) price_range_max = max(int(price_aggregate.get("price_max") or 100), 1) except Exception: price_range_min, price_range_max = 0, 100 num_min, num_max = _parse_range(existing, "num_purchases") try: num_aggregate = Purchase.objects.aggregate( num_min=models.Min("num_purchases"), num_max=models.Max("num_purchases") ) num_range_min = max(int(num_aggregate.get("num_min") or 0), 0) num_range_max = max(int(num_aggregate.get("num_max") or 10), 1) except Exception: num_range_min, num_range_max = 0, 10 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", DateRangeFilter( 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", RangeSlider( label="Price", input_name_prefix="filter-price", min_value=price_min, max_value=price_max, range_min=price_range_min, range_max=price_range_max, min_placeholder="0.00", max_placeholder="100.00", ), ), _filter_field( "Games in purchase", RangeSlider( label="Games in purchase", input_name_prefix="filter-num-purchases", min_value=num_min, max_value=num_max, range_min=num_range_min, range_max=num_range_max, step="1", min_placeholder="e.g. 1", max_placeholder="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 _filter_bar(fields, filter_json, preset_list_url, preset_save_url) def DeviceFilterBar(filter_json="", preset_list_url="", preset_save_url="") -> SafeText: """Collapsible filter bar for the Device list.""" from games.models import Device existing = _filter_parse(filter_json) 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 _filter_bar(fields, filter_json, preset_list_url, preset_save_url) def PlatformFilterBar( filter_json="", preset_list_url="", preset_save_url="" ) -> SafeText: """Collapsible filter bar for the Platform list.""" existing = _filter_parse(filter_json) 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 _filter_bar(fields, filter_json, preset_list_url, preset_save_url) def PlayEventFilterBar( filter_json="", preset_list_url="", preset_save_url="" ) -> SafeText: """Collapsible filter bar for the PlayEvent list.""" existing = _filter_parse(filter_json) game_choice = _filter_get_choice(existing, "game") days_min, days_max = _parse_range(existing, "days_to_finish") fields = [ Div( attributes=[("class", _FILTER_GRID_CLASS)], children=[ _filter_field( "Game", _model_filter( "game", game_choice, search_url="/api/games/search", nullable=False, ), ), ], ), RangeSlider( label="Days to Finish", input_name_prefix="filter-days-to-finish", min_value=days_min, max_value=days_max, range_min=0, range_max=365, step="1", min_placeholder="e.g. 1", max_placeholder="e.g. 30", ), ] return _filter_bar(fields, filter_json, preset_list_url, preset_save_url) def StringFilter( input_name_prefix: str, value: str = "", modifier: str = "EQUALS", placeholder: str = "", ) -> SafeText: """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", ""), ("onclick", "toggleStringFilterInput(this)"), ], ) 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", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body transition-all " + ("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), ], )